thedotmack/claude-mem · critical · Error
projection_error: unprojected log gap
Error message
projection_error: unprojected log gap
What it means
After paging, getProjectionPage() checks the invariant that if the caller asked for a target beyond the projected checkpoint, at least one op must have been returned. If zero ops came back while projected_seq < target_seq, the canonical log has a hole relative to expectations — the expected contiguous ops are missing — so the DO throws this projectionError to signal a broken invariant rather than silently returning an empty page.
Solutions
- Inspect the canonical_ops table for the (projected, target] range; if rows were deleted, restore them or reset projected_seq to the true earliest remaining seq via advanceProjectionCheckpoint under a lease.
- Recover the DO from a snapshot taken before the gap, or trigger epoch rotation so state rebuilds cleanly.
- Re-acquire the lease and retry once in case of transient read timing; persistent gaps indicate data loss, not a retryable condition.
- Halt the projection loop on this error instead of spinning — it's an invariant violation requiring data repair.
Example fix
// before
while (projected < target) { page = hub.getProjectionPage(token, target, userId); }
// after
try {
page = hub.getProjectionPage(token, target, userId);
} catch (e) {
if (String(e).includes('unprojected log gap')) {
// stop and alert: canonical log hole needs repair, not retry
}
} Defensive patterns
Strategy: try-catch
Validate before calling
const st = await hub.getProjectionState(); if (st.projected_seq >= targetSeq) return; // nothing to project; avoid gap path
Try / catch
try {
page = hub.getProjectionPage(token, target, userId);
} catch (e) {
if (String(e).includes('unprojected log gap')) {
alertOps('canonical log gap — manual repair required', { projected, target });
return; // do NOT retry; invariant violation
}
throw e;
} Prevention
- Never delete/compact canonical_ops rows without advancing projected_seq through the lease API
- Monitor projection_lag_ops and halt on repeated gap errors
- Take DO snapshots only at consistent points; avoid partial restores
When it happens
Trigger: Calling getProjectionPage(token, target, userId) where projected_seq < target but the SQL range (projected, target] yields no rows: e.g. target_seq equals projected (no-op request should return empty, but if target > projected and the log gap exists), or ops between projected and target were deleted/compacted, or a non-canonical target confusing the lexicographic length-aware comparison.
Common situations: Manual DB maintenance/compaction removed canonical_ops rows without advancing projected_seq; restoring a DO snapshot from an older backup while meta says projected_seq is ahead; projector pointed at a target from a divergent epoch.
Understand the failure class
Background: "This is a bug, please report it": internal invariant violations, unreachable panics, and SNH errors explained — this error's family across 47 libraries.
Related errors
- projection_error: invalid projected through_seq
- projection_error: target_seq exceeds head_seq
- cloud sync canonical payload
- cloud sync identity unavailable; refusing an unreplicated…
- cloud sync unavailable; refusing an unreplicated delete
AI-assisted analysis of thedotmack/claude-mem@d8bc9755e7 (2026-09-17).
Data as JSON: /api/errors/c138aa6c8981987f.
Report an issue: GitHub.
Appendix: source
Thrown at workers/sync-hub/src/do/SyncHub.ts:721
const ops: ChangeOp[] = [];
for (const row of rows) {
const op = toChange(row);
const candidate = [...ops, op];
const bytes = projectionRequestBytes({
userId,
epoch,
fromSeqExclusive: projected,
throughSeq: op.seq,
ops: candidate,
});
if (bytes > byteLimit) {
if (ops.length === 0) throw projectionError("one operation exceeds projection request byte budget");
break;
}
ops.push(op);
}
if (ops.length === 0 && compareCanonicalDecimals(projected, target) < 0) {
throw projectionError("unprojected log gap");
}
this.renewProjectionLease(leaseToken, now);
return {
protocol_version: 1,
epoch,
from_seq_exclusive: projected,
through_seq: ops.length > 0 ? ops[ops.length - 1].seq : projected,
target_seq: target,
ops,
};
}
heartbeatProjectionLease(leaseToken: string, now = Date.now()): ProjectionState {
this.assertLease(leaseToken, now);
this.renewProjectionLease(leaseToken, now);
return this.getProjectionState();
}
View on GitHub (pinned to d8bc9755e7)