thedotmack/claude-mem · error · Error
projection_error: projection lease expired
Error message
projection_error: projection lease expired
What it means
projectionError("projection lease expired") is thrown by SyncHub.assertLease when the caller holds a projection lease whose expiry timestamp (meta key projection_lease_expires_at) is at or before the current lease clock. The lease is a timed mutual-exclusion token: only the holder of the matching token may perform projection work, and holding it is not enough — it must not have expired. This guards concurrent DO instances or retried requests from double-applying projections.
Solutions
- Renew the lease before it lapses: call renewProjectionLease(token, now) inside the same storage transaction before the expiry passes.
- Re-acquire a fresh lease and retry the operation if the work can safely be restarted idempotently.
- Increase the lease window (PROJECTION_LEASE_MS) if legitimate work regularly outlives it.
- Treat the error as a lost-lock signal: abort the projection attempt rather than proceeding with a stale token.
- Verify the stored projection_lease_expires_at value is a canonical decimal and in the future; fix any manual meta writes.
Example fix
// before this.assertLease(token, now); doExpensiveProjection(); // lease may expire mid-way // after this.renewProjectionLease(token, now); this.assertLease(token, now); doExpensiveProjection();
Defensive patterns
Strategy: try-catch
Validate before calling
const expires = /* read stored projection_lease_expires_at */;
const token = /* held lease token */;
if (!token) throw new Error("no lease held");
if (!expires || BigInt(expires) <= BigInt(Date.now())) throw new Error("lease already expired; renew first"); Type guard
function hasLiveLease(expiresAt: string | undefined, now: number): boolean {
return typeof expiresAt === "string" && /^\d+$/.test(expiresAt) && BigInt(expiresAt) > BigInt(now);
} Try / catch
try {
await doProjection();
} catch (e) {
if (e instanceof Error && e.message.includes("projection lease expired")) {
await reacquireLease(); // re-acquire and retry idempotently
} else throw e;
} Prevention
- Renew the lease inside the same storage transaction as the work, not before an await boundary.
- Keep lease-held work short relative to PROJECTION_LEASE_MS.
- Renew proactively on a timer at ~half the lease TTL during long operations.
- Never cache a lease token across request retries without re-validating expiry.
- In tests, advance the mock clock consistently with stored expiries.
When it happens
Trigger: Calling any projection mutation while the lease's projection_lease_expires_at <= leaseNow(now); the lease was acquired earlier and never renewed (renewProjectionLease) before its PROJECTION_LEASE_MS window elapsed; a long-running operation overran the lease TTL; or a clock value was stored manually that is already in the past.
Common situations: Batch jobs or handlers that hold a lease across an await that takes longer than the lease TTL; retry storms where the original lease expired before the retry runs; two workers contending where one lets its lease lapse mid-operation; testing with a fixed 'now' in the past relative to a stored expiry.
Related errors
- projection_error: checkpoint compare-and-set mismatch
- projection_error: projection lease is not held
- Backfill failed
- canonical content
- Chroma unavailable before write; leaving documents unsynced
AI-assisted analysis of thedotmack/claude-mem@d8bc9755e7 (2026-09-17).
Data as JSON: /api/errors/b20884a197010e5d.
Report an issue: GitHub.
Appendix: source
Thrown at workers/sync-hub/src/do/SyncHub.ts:789
});
}
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);
}
private leaseExpiry(now: string): string {
return (BigInt(assertCanonicalDecimal(now)) + BigInt(PROJECTION_LEASE_MS)).toString(10);
}View on GitHub (pinned to d8bc9755e7)