Wait for every index write a backfill checkpoint covers, so a failed write cannot be skipped - #2563
Conversation
… of each record A record fans out into one index put per indexed value, but runIndexing tracked only the last one in lastResolution. A rejection from any earlier put set hadIndexingErrors whenever it happened to settle, while persistCheckpoint read that flag once on entry and the completion path waited only one event turn. Whether a failed index write froze the checkpoint was therefore an accident of scheduling: with the rejection delayed 30ms the checkpoint advanced past the failed record, and at 300ms the backfill declared the index complete over a silently missing entry. Track every index mutation the build issues - puts, the removal drops, and the LMDB clearAsync - in one insertion-ordered set that each entry removes itself from on either outcome. The checkpoint drains a snapshot of that set before its flush and refuses to certify when any member failed, and completion drains it in place of the setImmediate heuristic. The set also owns backpressure, so the bound is on what actually accumulates: the per-record counter it replaces stayed low while one unsettled write per row piled up. Checkpoints now carry a CHECKPOINT_ALGORITHM stamp. A descriptor without the current stamp resumes as uncertified and rebuilds, so a checkpoint the previous code certified past a failed record cannot be resumed from. Refs #2536 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01892ogD1y5AdZrV42zWpMXb
…amp, clear coverage The backpressure wait was on the set's oldest entry, and the removal drops and the LMDB clear are inserted before the scan starts, so it could sit behind a background clear that faster writes had already overtaken. Wait on the next settlement of any tracked mutation instead, which also drops the per-record iterator allocation. A clean completion now keeps the CHECKPOINT_ALGORITHM stamp instead of deleting it. The resume-side gate only repairs an interrupted build; an index a pre-fix release declared complete carries no marker at all. Stamping completions does not repair those, but it stops every index built from here on from being indistinguishable from one, which is what a later remediation would have to key on. Tests: an LMDB case for a rejected clearAsync, the one new barrier with no coverage. The completion-race case now waits for the failing write to be issued before its release deadline, rather than assuming the scan reached it. Refs #2536 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01892ogD1y5AdZrV42zWpMXb
… design record The stamp a clean completion writes was dropped by the next rewrite of that descriptor: the declared attribute never carries it, and indexing a second attribute on the table persists the freshly declared object. One completed index plus one added index was enough to make the first indistinguishable from a pre-stamp build, which is exactly what the stamp exists to prevent. Carry it forward from the durable descriptor whenever the declaration lacks it. DESIGN.md still described the removed last-promise error handling and a checkpoint written every 100 records; it now describes the mutation set, the drain barriers, and the two checkpoint stamps. The test file uses the shared waitFor helper AGENTS.md mandates instead of a local copy. Refs #2536 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01892ogD1y5AdZrV42zWpMXb
Release cherry-pick
|
There was a problem hiding this comment.
Code Review
This pull request improves the reliability of the index backfill process by tracking all pending mutations in a set, preventing checkpoints from certifying if any write fails, and bounding the number of concurrent in-flight writes. It also introduces a checkpoint algorithm versioning mechanism to prevent resuming from incompatible or corrupt checkpoints, and adds comprehensive tests to cover these scenarios. The reviewer noted that the lastResolution variable is now unused and should be removed.
| delete attribute.checkpointCertified; | ||
| // Survives completion, unlike the checkpoint fields: without it an index built here is | ||
| // indistinguishable from one a release that could skip a failed record declared complete. | ||
| attribute.checkpointAlgorithm = CHECKPOINT_ALGORITHM; |
There was a problem hiding this comment.
File: resources/databases.ts:3463-3473 and :3481-3500
What: Both tail loops in runIndexing — the hadIndexingErrors branch (~3463-3473) and this completion branch — loop over attributes, reassigning a single lastResolution = Table.dbisDB.put(attribute.key, attribute) each iteration, then await lastResolution only once after the loop. When attributes.length > 1 (any multi-attribute backfill, e.g. the tag+group cases this PR's own tests exercise), only the last attribute's put is awaited — the earlier ones are fired with no .catch()/error handler attached anywhere.
Why it matters: This is the exact anti-pattern this PR just replaced everywhere else in this function (a single lastResolution var overwritten in a loop, only the last one awaited) — but these two loops still have it. Table.dbisDB.put() can genuinely reject in production: this function's own outer catch explicitly anticipates "a worker shutting down closes its stores mid-backfill" causing a put to throw "Database not open" (see the comment at ~3509-3514). If an earlier attribute's put rejects in that scenario here, nothing ever attaches a handler to that promise — an unhandled rejection, which by default terminates the Node process, undermining the very graceful-shutdown handling this function goes out of its way to provide a few lines below.
Suggested fix: Mirror the pattern already used 30 lines further down in this same function's outer catch block (harper#843, ~3528-3537): collect the puts into an array and await Promise.all(puts) after the loop, in both tail loops.
|
One blocker: the two tail descriptor-persist loops in |
A secondary-index backfill could persist a resume checkpoint past a record whose index write later failed, or declare the index complete over one, leaving that record silently missing from the index.
runIndexingissues one index put per indexed value per record, plus a drop per removed index and a clear per full rebuild, but tracked only the last put inlastResolution. Everything else was fire-and-forget: a rejection sethadIndexingErrorswhenever it happened to settle, while the checkpoint read that flag once on entry and completion waited a single event turn. Whether a failed index write froze the checkpoint was an accident of scheduling. Varying only how long the failing write takes to reject, on one 600-record table whose record atk-0250fails:k-0199, correctk-0499, past the failed recordEvery index mutation the build issues is now registered in a per-build set that absorbs its own rejection, covering the per-value puts, the drops for removed indexes and the LMDB clear that a full rebuild runs first. A checkpoint drains a snapshot of that set and refuses to certify when any mutation it covers failed, and completion drains it in place of the one-
setImmediateheuristic, so "no errors" means every write settled rather than none had failed yet. The set also bounds what stays in flight, waiting on the next settlement of any mutation rather than a chosen one, because the drop and clear are inserted before the scan starts and faster writes overtake them.Checkpoints carry a
CHECKPOINT_ALGORITHMstamp. A descriptor without the current stamp resumes as uncertified and rebuilds, so a checkpoint the previous code certified past a failed record cannot be resumed from. A clean completion keeps the stamp rather than clearing it with the other checkpoint fields, and it is carried across a later descriptor rewrite; without that, indexing a second attribute on the table dropped the stamp and made the first index indistinguishable from a pre-fix build.For the human reviewer
v5.2.10was tagged 2026-09-10 onorigin/v5.2and contains the defective code. A build it declared complete over a failed write cleared every marker, so on upgrade its descriptor has no checkpoint and no stamp,table()treats it as ready, and the record stays missing from that index. The alternative is forcing one rebuild of every unstamped completed index on upgrade, whose cost depends on how farv5.2.10was distributed — a fact I do not have. I chose to make the later decision possible instead of making it now: stamping completions means every index built from here on is distinguishable, which is what any remediation has to key on. Changing this later needs another version bump and that same expensive rebuild; leaving it means any already-gapped index stays wrong in the field. This is the call I most want ruled on.!==, not>=. A downgrade-then-upgrade cycle therefore forces a full rebuild of any in-flight checkpoint. That is the conservative direction and cheap to change while there is one consumer, but it is a persisted on-disk shape, so changing it later needs its own compatibility story.MAX_OUTSTANDING_INDEXING(1000) andMIN_OUTSTANDING_INDEXING(10) were tuned against a per-record counter and now count individual index writes, so a fan-out table reaches both sooner. Two reviewers called this a throughput risk; I measured it rather than argue it, and there is no regression (numbers in Verification). Splitting the yield hint back onto a per-record counter is one line if you disagree with the reading.hadIndexingErrorsfalse and the scan continuing, so progress is under-recorded and a crash rescans further back than necessary. That predates this change; I did not widen it into an indexing error here, because parking a build that is writing correct index data is its own harm.I also declined one review suggestion on evidence: re-reading
hadIndexingErrorsafter the flush and before writing descriptors. I tried it first, and it made the existing freeze cases checkpoint atk-0099instead ofk-0199, because a failure at a later key invalidated a checkpoint whose own records had all succeeded. The drain's scoped result is the correct guard; the global flag is not.Changes
resources/databases.tscarries the mechanism above. Beyond the links in the summary:trackreturns the original result untouched and registers nothing when a put resolves synchronously, so the RocksDB path allocates as it did before;drainMutationsreports whether any member of its snapshot failed, which is what lets a failure on a later key leave an earlier checkpoint valid; andnextSettlementresolves on the next settlement of anything tracked. Thewhen()import is gone with its last use.unitTests/resources/indexBackfillConvergence.test.jsadds five cases; see Verification.DESIGN.mddescribed the removedwhen()error handler, the removed finalawait lastResolutiontry-catch, and a checkpoint written every 100 records. It now describes the mutation set, the drain barriers, and both checkpoint stamps.Verification
Five new cases, each of which fails on a
distbuilt fromorigin/main'sresources/databases.ts:checkpoint k-0299 must not pass the failed record k-0250— the nightly symptom, now deterministick-0200A sixth case covers the stamp surviving a descriptor rewrite; it fails on the first two commits of this branch rather than on base, since it guards the stamp those commits introduced.
The rejection in the first two cases is released by an observed scan position, not a timer, so the interleaving is the same on a fast and a slow runner.
The two
test:unit:mainfailures are environmental and reproduce on this box without the diff: a token test that depends on a shared system database, and a config-validator case that asserts on the length of the checkout path. The integration run's only failures are theollama-backendsuite, which fails at module load withERR_IMPORT_ATTRIBUTE_MISSINGforsystemSchema.jsonand needs a live Ollama instance.Throughput, since this changes what the backpressure thresholds count. 50,000 records, two indexed attributes of two-element arrays, four index writes per record, RocksDB, same box,
distbuilt from each side:origin/mainRefs #2536
Complexity: complicated
🤖 Generated with Claude Code
https://claude.ai/code/session_01892ogD1y5AdZrV42zWpMXb
Review-Coverage: authored=claude; ran=gemini,codex; declined=cursor-grok,cursor-composer,domain; rounds=3 @ 33dfa28
Human-Review-Need: 4 @ 33dfa28