thedotmack/claude-mem · error · Error

projection_error: epoch mismatch

Error message

projection_error: epoch mismatch

What it means

advanceProjectionCheckpoint() verifies that the epoch string supplied by the projector matches the hub's current epoch meta value. The epoch is rotated on hub re-initialization to fence off stale projectors; a checkpoint arriving under an old epoch would move projected_seq in the wrong log generation, so the DO throws this projectionError inside the same transaction as the lease check.

Solutions

  1. Handle this error by releasing the stale lease, calling getProjectionState() to read the new epoch and projected_seq, re-acquiring the lease, and resuming projection from the new checkpoint.
  2. Never cache epoch across process restarts of the projector; read it from acquireProjectionLease's returned ProjectionLease each cycle.
  3. Compare your cached epoch to the one in each lease response before advancing; on divergence, rebuild local state.
  4. Log and alert on epoch mismatches — they indicate the hub was recreated and downstream projections may need a rebuild.

Example fix

// before
await hub.advanceProjectionCheckpoint(token, myEpoch, from, through);
// after
try {
  await hub.advanceProjectionCheckpoint(token, lease.epoch, from, through);
} catch (e) {
  if (String(e).includes('epoch mismatch')) {
    hub.releaseProjectionLease(token);
    const st = await hub.getProjectionState();
    lease = await hub.acquireProjectionLease(st.head_seq); // resume with new epoch
  }
}
Defensive patterns

Strategy: retry

Validate before calling

const st = await hub.getProjectionState();
if (cachedEpoch !== st.epoch) {
  cachedEpoch = st.epoch; // rebuild local projector state before continuing
}

Try / catch

try {
  await hub.advanceProjectionCheckpoint(token, epoch, from, through);
} catch (e) {
  if (String(e).includes('epoch mismatch')) {
    hub.releaseProjectionLease(token);
    await rebuildProjectorState(); // new epoch: re-read head/projected, re-acquire
    return;
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling advanceProjectionCheckpoint(token, epoch, from, through) with an epoch captured before a DO reset/epoch rotation; a projector that kept running across the hub being recreated; copying an epoch string from a stale ProjectionState.

Common situations: Durable Object hibernation/restart with a new epoch; deploy that wipes DO storage and rotates epoch while a projector worker holds in-memory state; multi-region projector where one observed an old epoch.

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/81e3ff3f67a746cb. Report an issue: GitHub.

Appendix: source

Thrown at workers/sync-hub/src/do/SyncHub.ts:752

	heartbeatProjectionLease(leaseToken: string, now = Date.now()): ProjectionState {
		this.assertLease(leaseToken, now);
		this.renewProjectionLease(leaseToken, now);
		return this.getProjectionState();
	}

	advanceProjectionCheckpoint(
		leaseToken: string,
		epoch: string,
		fromSeqExclusive: string,
		throughSeq: string,
		now = Date.now(),
	): ProjectionState {
		this.ctx.storage.transactionSync(() => {
			// Token check and checkpoint compare-and-set are fenced in the same
			// synchronous transaction. A timed-out predecessor can never replay
			// after a successor has acquired a fresh token.
			this.assertLease(leaseToken, now);
			if (epoch !== this.meta("epoch")) throw projectionError("epoch mismatch");
			const expected = assertCanonicalDecimal(fromSeqExclusive);
			const through = assertCanonicalDecimal(throughSeq);
			const current = this.projectedSeq();
			if (current !== expected) throw projectionError("checkpoint compare-and-set mismatch");
			if (compareCanonicalDecimals(through, current) < 0 || compareCanonicalDecimals(through, this.headSeq()) > 0) {
				throw projectionError("invalid projected through_seq");
			}
			this.setMeta("projected_seq", through);
			this.setMeta("projection_lease_expires_at", this.leaseExpiry(this.leaseNow(now)));
		});
		return this.getProjectionState();
	}

	releaseProjectionLease(leaseToken: string): void {
		if (this.metaOptional("projection_lease_token") !== leaseToken) return;
		this.ctx.storage.transactionSync(() => {
			this.deleteMeta("projection_lease_token");
			this.deleteMeta("projection_lease_expires_at");

View on GitHub (pinned to d8bc9755e7)