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

  1. Renew the lease before it lapses: call renewProjectionLease(token, now) inside the same storage transaction before the expiry passes.
  2. Re-acquire a fresh lease and retry the operation if the work can safely be restarted idempotently.
  3. Increase the lease window (PROJECTION_LEASE_MS) if legitimate work regularly outlives it.
  4. Treat the error as a lost-lock signal: abort the projection attempt rather than proceeding with a stale token.
  5. 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

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


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)