{"record":{"id":"79e559f74e0381d7","repo":"thedotmack/claude-mem","slug":"projection-error-projection-lease-is-not-held","errorCode":null,"errorMessage":"projection_error: projection lease is not held","messagePattern":"projection_error: projection lease is not held","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"workers/sync-hub/src/do/SyncHub.ts","lineNumber":785,"sourceCode":"\t\tif (this.metaOptional(\"projection_lease_token\") !== leaseToken) return;\n\t\tthis.ctx.storage.transactionSync(() => {\n\t\t\tthis.deleteMeta(\"projection_lease_token\");\n\t\t\tthis.deleteMeta(\"projection_lease_expires_at\");\n\t\t});\n\t}\n\n\tgetProjectionState(): ProjectionState {\n\t\treturn {\n\t\t\tprotocol_version: 1,\n\t\t\tepoch: this.meta(\"epoch\"),\n\t\t\thead_seq: this.headSeq(),\n\t\t\tprojected_seq: this.projectedSeq(),\n\t\t};\n\t}\n\n\tprivate assertLease(token: string, now: number): void {\n\t\tif (typeof token !== \"string\" || token.length === 0 || this.metaOptional(\"projection_lease_token\") !== token) {\n\t\t\tthrow projectionError(\"projection lease is not held\");\n\t\t}\n\t\tconst expires = this.metaOptional(\"projection_lease_expires_at\");\n\t\tif (!expires || compareCanonicalDecimals(expires, this.leaseNow(now)) <= 0) {\n\t\t\tthrow projectionError(\"projection lease expired\");\n\t\t}\n\t}\n\n\tprivate renewProjectionLease(token: string, now: number): void {\n\t\tthis.ctx.storage.transactionSync(() => {\n\t\t\tthis.assertLease(token, now);\n\t\t\tthis.setMeta(\"projection_lease_expires_at\", this.leaseExpiry(this.leaseNow(now)));\n\t\t});\n\t}\n\n\tprivate leaseNow(now: number): string {\n\t\tif (!Number.isSafeInteger(now) || now < 0) throw projectionError(\"lease clock must be a safe millisecond integer\");\n\t\treturn String(now);\n\t}","sourceCodeStart":767,"sourceCodeEnd":803,"githubUrl":"https://github.com/thedotmack/claude-mem/blob/d8bc9755e74915e5c3b999181e10a67c889bce2a/workers/sync-hub/src/do/SyncHub.ts#L767-L803","documentation":"assertLease() guards every projection entry point: the supplied leaseToken must be a non-empty string exactly equal to the hub's stored projection_lease_token. If the token is empty, not a string, or doesn't match (no lease held, wrong token, or the lease was released/replaced), the DO throws this projectionError. (An expired-but-matching token raises the separate 'projection lease expired' error.)","triggerScenarios":"Calling getProjectionPage / heartbeatProjectionLease / advanceProjectionCheckpoint with a token from an expired-and-reacquired lease, after releaseProjectionLease(), a fabricated/empty token, or a token belonging to a predecessor projector that lost the lease.","commonSituations":"Projector paused past the lease TTL while another acquired a fresh token; retry queue replaying calls with an old token; two projector workers sharing state and one using the other's token; calling page/advance without acquiring a lease first.","solutions":["Wrap lease-using calls in a handler that catches this error, calls acquireProjectionLease with a valid target, and retries the operation once with the new token.","Always obtain the token from acquireProjectionLease's returned lease_token — never persist and reuse tokens beyond a single lease lifetime.","Call heartbeatProjectionLease periodically during long batches so the lease doesn't expire mid-work and get stolen.","Ensure only one projector instance runs per hub/user; use the lease's acquired:false response to back off when another holder is active."],"exampleFix":"// before\nconst page = hub.getProjectionPage(staleToken, target, userId);\n// after\nlet token = lease.lease_token;\ntry {\n  page = hub.getProjectionPage(token, target, userId);\n} catch (e) {\n  if (String(e).includes('lease is not held') || String(e).includes('lease expired')) {\n    lease = await hub.acquireProjectionLease(target);\n    if (!lease.acquired) throw new Error('lease held by another projector');\n    page = hub.getProjectionPage(lease.lease_token, target, userId);\n  } else throw e;\n}","handlingStrategy":"retry","validationCode":"if (typeof leaseToken !== 'string' || leaseToken.length === 0) {\n  throw new TypeError('lease token missing — call acquireProjectionLease first');\n}","typeGuard":"function holdsLease(l: ProjectionLease): l is ProjectionLease & { lease_token: string } {\n  return l.acquired === true && typeof l.lease_token === 'string' && l.lease_token.length > 0;\n}","tryCatchPattern":"try {\n  page = hub.getProjectionPage(token, target, userId);\n} catch (e) {\n  if (String(e).includes('lease is not held') || String(e).includes('lease expired')) {\n    const fresh = await hub.acquireProjectionLease(target);\n    if (!fresh.acquired) throw new Error('projection lease held elsewhere');\n    page = hub.getProjectionPage(fresh.lease_token, target, userId);\n  } else throw e;\n}","preventionTips":["Always acquire before paging/advancing; never fabricate tokens","Heartbeat the lease during long batches to avoid expiry/theft","Release the lease cleanly on shutdown; back off when acquire returns acquired:false","Run a single projector instance per hub to avoid token races"],"tags":["projection","lease","concurrency","fencing"],"backgroundTag":"invalid-state-transition","analyzedSha":"d8bc9755e74915e5c3b999181e10a67c889bce2a","analyzedAt":"2026-09-17T16:40:26.182Z","contentChangedAt":"2026-09-17T16:40:26.182Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}