thedotmack/claude-mem · error · Error
projection_error: projection lease is not held
Error message
projection_error: projection lease is not held
What it means
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.)
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.
Example fix
// before
const page = hub.getProjectionPage(staleToken, target, userId);
// after
let token = lease.lease_token;
try {
page = hub.getProjectionPage(token, target, userId);
} catch (e) {
if (String(e).includes('lease is not held') || String(e).includes('lease expired')) {
lease = await hub.acquireProjectionLease(target);
if (!lease.acquired) throw new Error('lease held by another projector');
page = hub.getProjectionPage(lease.lease_token, target, userId);
} else throw e;
} Defensive patterns
Strategy: retry
Validate before calling
if (typeof leaseToken !== 'string' || leaseToken.length === 0) {
throw new TypeError('lease token missing — call acquireProjectionLease first');
} Type guard
function holdsLease(l: ProjectionLease): l is ProjectionLease & { lease_token: string } {
return l.acquired === true && typeof l.lease_token === 'string' && l.lease_token.length > 0;
} Try / catch
try {
page = hub.getProjectionPage(token, target, userId);
} catch (e) {
if (String(e).includes('lease is not held') || String(e).includes('lease expired')) {
const fresh = await hub.acquireProjectionLease(target);
if (!fresh.acquired) throw new Error('projection lease held elsewhere');
page = hub.getProjectionPage(fresh.lease_token, target, userId);
} else throw e;
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Understand the failure class
Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.
Related errors
- projection_error: checkpoint compare-and-set mismatch
- projection_error: epoch mismatch
- projection_error: projection lease expired
- Conflict
- observation generation job status transition was not applied
AI-assisted analysis of thedotmack/claude-mem@d8bc9755e7 (2026-09-17).
Data as JSON: /api/errors/79e559f74e0381d7.
Report an issue: GitHub.
Appendix: source
Thrown at workers/sync-hub/src/do/SyncHub.ts:785
if (this.metaOptional("projection_lease_token") !== leaseToken) return;
this.ctx.storage.transactionSync(() => {
this.deleteMeta("projection_lease_token");
this.deleteMeta("projection_lease_expires_at");
});
}
getProjectionState(): ProjectionState {
return {
protocol_version: 1,
epoch: this.meta("epoch"),
head_seq: this.headSeq(),
projected_seq: this.projectedSeq(),
};
}
private assertLease(token: string, now: number): void {
if (typeof token !== "string" || token.length === 0 || this.metaOptional("projection_lease_token") !== token) {
throw projectionError("projection lease is not held");
}
const expires = this.metaOptional("projection_lease_expires_at");
if (!expires || compareCanonicalDecimals(expires, this.leaseNow(now)) <= 0) {
throw projectionError("projection lease expired");
}
}
private renewProjectionLease(token: string, now: number): void {
this.ctx.storage.transactionSync(() => {
this.assertLease(token, now);
this.setMeta("projection_lease_expires_at", this.leaseExpiry(this.leaseNow(now)));
});
}
private leaseNow(now: number): string {
if (!Number.isSafeInteger(now) || now < 0) throw projectionError("lease clock must be a safe millisecond integer");
return String(now);
}View on GitHub (pinned to d8bc9755e7)