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

  1. 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.
  2. Always obtain the token from acquireProjectionLease's returned lease_token — never persist and reuse tokens beyond a single lease lifetime.
  3. Call heartbeatProjectionLease periodically during long batches so the lease doesn't expire mid-work and get stolen.
  4. 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

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


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)