Index backfills resume from their checkpoint and yield the event loop, so large tables converge (harper#2536) - #2539
Conversation
…vent loop on the RocksDB path (harper#2536) runIndexing never used its resume checkpoint: `start` began undefined and the guard `compareKeys(lastIndexedKey, start) < 0` could never be true because ordered-binary sorts undefined lowest, so every retrigger rescanned from the first record. It also never yielded the event loop on a plain RocksDB index: `outstanding` was decremented synchronously because RocksIndexStore.put is putSync, so none of the outstanding-based yields ever fired and a large backfill ran as one uninterrupted turn until the worker was terminated. - resumeStartKey() computes the minimum persisted checkpoint across the attributes being built, or undefined (full scan) when any attribute has none. - The loop yields every INDEXING_YIELD_INTERVAL (100) scanned entries, deletion entries included, independent of write-completion timing. - Because a checkpoint is now actually consumed, it must certify a fully indexed prefix: it is written once the index writes it covers have settled and stops advancing after any record fails, so the retry re-covers that record instead of resuming past it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVCC8xu4zk7eirYMg1ZHPb
…t, yield independent of the backpressure await - A child process seeds a table, starts the backfill, flushes and SIGKILLs itself at its first persisted checkpoint; the parent resumes from that checkpoint through the PID-mismatch trigger. The flush is what a clean shutdown does: RocksDB data/index stores open without a WAL while the descriptor store has one, so an unflushed hard kill is out of contract. - The record-count yield no longer sits behind the `outstanding > MAX` await, and the RocksDB yield test asserts the exact 100-record cadence. - Plain `node:assert` per house style; narrating comments trimmed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVCC8xu4zk7eirYMg1ZHPb
…persist the interrupt checkpoint synchronously - Deletion entries now flow through the checkpoint, interrupt and yield path instead of `continue`-ing past it, so a dense tombstone region still checkpoints and still honours a worker restart. - The interrupt path awaits the last write and persists its checkpoint before returning, so a thread restart on LMDB cannot drop it. - The RocksDB yield assertion is a range, not an exact turn count. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVCC8xu4zk7eirYMg1ZHPb
Release cherry-pick
|
There was a problem hiding this comment.
Code Review
This pull request introduces a mechanism to resume secondary-index backfills from a persisted checkpoint rather than scanning from the beginning, and yields the event loop at bounded intervals to prevent event-loop starvation. It also adds comprehensive unit tests to cover crash-resume scenarios and event-loop yielding. The reviewer recommends improving the crash-resume test robustness by wrapping the child process execution in a try/finally block with a Promise.race watchdog timer to prevent deadlocks and resource leaks. Additionally, the reviewer suggests using the codebase's loose equality idiom '== null' instead of '== undefined' for null-or-undefined checks.
| const child = spawn( | ||
| process.execPath, | ||
| [ | ||
| path.join(__dirname, 'indexBackfillConvergence-crash.js'), | ||
| path.join(crashDir, 'child-root'), | ||
| path.join(crashDir, 'shared'), | ||
| DATABASE, | ||
| TABLE, | ||
| markerPath, | ||
| String(N), | ||
| ], | ||
| { stdio: ['ignore', 'ignore', 'pipe'] } | ||
| ); | ||
| let stderr = ''; | ||
| child.stderr.on('data', (chunk) => (stderr += chunk)); | ||
| const [code, signal] = await new Promise((resolve, reject) => { | ||
| child.once('error', reject); | ||
| child.once('exit', (code, signal) => resolve([code, signal])); | ||
| }); | ||
| assert.strictEqual( | ||
| signal, | ||
| 'SIGKILL', | ||
| `the child should have killed itself at its first checkpoint (exit ${code}): ${stderr}` | ||
| ); | ||
| const checkpoint = readFileSync(markerPath, 'utf8'); | ||
| assert.match(checkpoint, /^c-\d{6}$/, 'the child should have recorded a durable checkpoint'); | ||
|
|
||
| // The dead process's PID on the descriptor is the crash-recovery trigger. | ||
| const Tbl = table({ | ||
| table: TABLE, | ||
| database: DATABASE, | ||
| attributes: [ | ||
| { name: 'id', isPrimaryKey: true }, | ||
| { name: 'tag', indexed: true }, | ||
| ], | ||
| }); | ||
| try { | ||
| assert.ok(Tbl.indexingOperation, 'reopening after the crash should retrigger the backfill'); | ||
| const resumed = observeRange(Tbl); | ||
| try { | ||
| await Tbl.indexingOperation; | ||
| } finally { | ||
| resumed.restore(); | ||
| } | ||
| assert.strictEqual( | ||
| resumed.start, | ||
| checkpoint, | ||
| "the resumed scan should start at the crashed process's checkpoint" | ||
| ); | ||
| assert.strictEqual(resumed.keys[0], checkpoint); | ||
| assert.strictEqual( | ||
| findDescriptor(Tbl, 'tag').value.indexingPID, | ||
| undefined, | ||
| 'the resumed backfill should complete' | ||
| ); | ||
| let total = 0; | ||
| for (let i = 0; i < 7; i++) { | ||
| total += (await collect(Tbl.search({ conditions: [{ attribute: 'tag', value: 't-' + i }] }))).length; | ||
| } | ||
| assert.strictEqual(total, N, 'every row should be indexed after the resumed backfill'); | ||
| } finally { | ||
| closeDatabase(DATABASE); | ||
| env.setProperty(terms.CONFIG_PARAMS.DATABASES, databasesConfig); | ||
| } |
There was a problem hiding this comment.
The setup and execution of the child process, as well as the subsequent assertions, are not fully protected by the try/finally block. If the child process hangs, the test suite will deadlock. If an assertion fails or an error is thrown before the try block is entered, the global database configuration (terms.CONFIG_PARAMS.DATABASES) will not be restored, polluting subsequent tests. Additionally, the child process and the temporary crashDir directory may leak.
Wrapping the entire setup and execution in a try/finally block with a Promise.race watchdog timer ensures robust cleanup and prevents test suite hangs.
let child;
let exitTimeout;
try {
child = spawn(
process.execPath,
[
path.join(__dirname, 'indexBackfillConvergence-crash.js'),
path.join(crashDir, 'child-root'),
path.join(crashDir, 'shared'),
DATABASE,
TABLE,
markerPath,
String(N),
],
{ stdio: ['ignore', 'ignore', 'pipe'] }
);
let stderr = '';
child.stderr.on('data', (chunk) => (stderr += chunk));
const [code, signal] = await Promise.race([
new Promise((resolve, reject) => {
child.once('error', reject);
child.once('exit', (code, signal) => resolve([code, signal]));
}),
new Promise((_, reject) => {
exitTimeout = setTimeout(() => reject(new Error('Child process exit timeout')), 10000);
}),
]);
clearTimeout(exitTimeout);
assert.strictEqual(
signal,
'SIGKILL',
`the child should have killed itself at its first checkpoint (exit ${code}): ${stderr}`
);
const checkpoint = readFileSync(markerPath, 'utf8');
assert.match(checkpoint, /^c-\d{6}$/, 'the child should have recorded a durable checkpoint');
// The dead process's PID on the descriptor is the crash-recovery trigger.
const Tbl = table({
table: TABLE,
database: DATABASE,
attributes: [
{ name: 'id', isPrimaryKey: true },
{ name: 'tag', indexed: true },
],
});
assert.ok(Tbl.indexingOperation, 'reopening after the crash should retrigger the backfill');
const resumed = observeRange(Tbl);
try {
await Tbl.indexingOperation;
} finally {
resumed.restore();
}
assert.strictEqual(
resumed.start,
checkpoint,
"the resumed scan should start at the crashed process's checkpoint"
);
assert.strictEqual(resumed.keys[0], checkpoint);
assert.strictEqual(
findDescriptor(Tbl, 'tag').value.indexingPID,
undefined,
'the resumed backfill should complete'
);
let total = 0;
for (let i = 0; i < 7; i++) {
total += (await collect(Tbl.search({ conditions: [{ attribute: 'tag', value: 't-' + i }] }))).length;
}
assert.strictEqual(total, N, 'every row should be indexed after the resumed backfill');
} finally {
if (exitTimeout) {
clearTimeout(exitTimeout);
}
if (child && !child.killed) {
child.kill('SIGKILL');
}
closeDatabase(DATABASE);
env.setProperty(terms.CONFIG_PARAMS.DATABASES, databasesConfig);
rmSync(crashDir, { recursive: true, force: true });
}References
- Ensure temporary test resources (such as directories created for child processes) are cleaned up in a finally block to prevent resource leaks in the event of assertion failures, non-zero exit statuses, or malformed output.
- When testing operations or promises that may never settle, use Promise.race with a setTimeout watchdog timer as a race guard to prevent hanging the test suite, rather than condition-polling helpers like waitFor which cannot bound an unsettled promise.
| export function resumeStartKey(attributes: { lastIndexedKey?: any }[]): any { | ||
| let start: any; | ||
| for (const attribute of attributes) { | ||
| if (attribute.lastIndexedKey == undefined) return undefined; |
There was a problem hiding this comment.
To adhere to the codebase's loose equality idiom for null-or-undefined checks, please use == null instead of == undefined.
| if (attribute.lastIndexedKey == undefined) return undefined; | |
| if (attribute.lastIndexedKey == null) return undefined; |
References
- Use loose equality checks ('!= null' or '== null') for null-or-undefined checks. This complies with the style guide and prevents runtime errors (such as 'TypeError') when a value can be 'null' rather than 'undefined' at runtime. (link)
LMDB commits the deferred checkpoint writes asynchronously, so a descriptor read right after the interrupted pass could see the previous checkpoint and then watch the last one land. Wait for the expected checkpoint, read the failed-pass checkpoint only once it has stopped changing, and accept a later committed checkpoint after the child-process kill on LMDB. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVCC8xu4zk7eirYMg1ZHPb
…bility Every checkpoint put is queued by the time runIndexing resolves, so waiting for the write queue to flush makes the descriptor read authoritative. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVCC8xu4zk7eirYMg1ZHPb
|
TL;DR: this PR fixes convergence (confirmed on a rig), but as written it can bake a permanent silent index gap into the exact nodes it's meant to recover — and there's a second, independent WAL durability gap. Both matter before this recovers a stuck cluster. ✔ Convergence fix — confirmed. 1M-row SIGKILL crash-loop: base resumes at record 0 every cycle; with this PR the checkpoint advances each cycle and the final run gate-verifies complete. Blocker — resuming a legacy checkpoint can silently drop rows.
Separate issue — the index store is WAL-off.
Both surfaced this session; rig harness available if useful. 🤖 Findings from a session rig repro + cross-model (Codex) review. |
…y checkpoint cannot leave an index gap RocksDB data and index stores open without a WAL while the descriptor store has one, so a checkpoint written every 100 records could outlive the index entries it certifies after a SIGKILL/OOM. A checkpoint is now persisted at most once per indexingCheckpointPeriodMs (5s) and only after the RocksDB store is flushed, with at most one in flight; the interrupt, failure and completion paths drain it first. Every checkpoint written this way carries checkpointCertified. The trigger only resumes a stamped checkpoint: earlier releases advanced lastIndexedKey past failed and unflushed index writes (both the indexingFailed and the interrupted exits), so an unstamped one is a full rebuild. No field cleanup is needed. setIndexingCheckpointPeriod() is the test seam; the crash test no longer flushes by hand, so it now exercises the real hard-kill contract. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVCC8xu4zk7eirYMg1ZHPb
…hare one flush per root store - A record's non-last index puts (multi-value attributes, a second attribute) now attach a rejection handler, so a rejected LMDB put anywhere in the record freezes the checkpoint instead of being certified past. - Concurrent backfills on one database share a single in-flight flush, and the flush uses the default (non-stalling) option. - persistCheckpoint awaits its descriptor puts, so the interrupt path returns only once the checkpoint is committed on LMDB and a rejection is counted. - The checkpoint period is paced with performance.now(). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVCC8xu4zk7eirYMg1ZHPb
…to its key, flush before the ready descriptor - A flush only covers writes issued before it started, so a checkpoint never joins a flush in flight: it joins the next one, shared by every backfill on that database asking meanwhile (at most one in flight and one queued). - checkpointCertified now repeats the checkpoint key; the trigger resumes only when it matches lastIndexedKey, so a descriptor advanced by an older binary that round-trips the field cannot be mistaken for a certified one. - The completion path flushes the tail written since the last checkpoint before persisting the ready descriptor, and parks the index if that flush fails: a kill right after completion previously lost every unflushed entry (0 of 10,000 survived in the new child-process test on the parent commit). - reindex reason 'uncertified-checkpoint' is logged for the legacy fallback. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVCC8xu4zk7eirYMg1ZHPb
…, drop rejections, full-scan clears every index - A checkpoint now also waits for at least 10,000 more records (the flush seals every column family, so a slow backfill must not impose the 5s flush rate on unrelated tables); setIndexingCheckpointPeriod takes both knobs. - index.drop() rejections are tracked again; asynchronous rejections are logged once per backfill; a failed checkpoint write logs at warn. - A full-scan rebuild clears every index it rebuilds, not only the ones that had no checkpoint. - Tests: the child crash fixture is killed by the parent after 60s if it never reaches its marker; a cross-attribute asynchronous rejection case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVCC8xu4zk7eirYMg1ZHPb
…g, record-floor test - Asynchronous index-put rejections are counted and logged per attribute, through the same once-per-attribute dedup as synchronous errors. - A test proves checkpoints are never closer than the record floor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVCC8xu4zk7eirYMg1ZHPb
|
Thanks — the rig repro was exactly the evidence this needed, and both findings are now addressed on this branch. Legacy checkpoints. Adopted your stamp, bound to the key rather than a boolean: every checkpoint written by the new path carries WAL-off index store. Went with the flush gate: a checkpoint is persisted only after Your harness would be a good cross-check on the shipping policy (5s / 10k); the unit tests exercise it only at a 10k floor. — Claude Fable 5.1 |
| env.setProperty(terms.CONFIG_PARAMS.DATABASES, { | ||
| ...databasesConfig, | ||
| [DATABASE]: { path: path.join(crashDir, 'shared') }, | ||
| }); | ||
|
|
||
| const { code, signal, stderr } = await runCrashChild([ | ||
| path.join(crashDir, 'child-root'), | ||
| path.join(crashDir, 'shared'), | ||
| DATABASE, | ||
| TABLE, | ||
| markerPath, | ||
| String(N), | ||
| 'kill-at-checkpoint', | ||
| ]); | ||
| assert.strictEqual( | ||
| signal, | ||
| 'SIGKILL', | ||
| `the child should have killed itself at its first checkpoint (exit ${code}): ${stderr}` | ||
| ); | ||
| const checkpoint = readFileSync(markerPath, 'utf8'); | ||
| assert.match(checkpoint, /^c-\d{6}$/, 'the child should have recorded a durable checkpoint'); |
There was a problem hiding this comment.
Suggestion (non-blocking): env.setProperty(terms.CONFIG_PARAMS.DATABASES, ...) (line 441) mutates process-wide config before the try that restores it starts at line 472. runCrashChild's own 60s watchdog now bounds the child-hang case gemini flagged on this thread, but the restore gap itself is still open: if runCrashChild rejects, or either assert on lines 455/461 throws, env.setProperty(terms.CONFIG_PARAMS.DATABASES, databasesConfig) never runs, leaking the test's DATABASES override into every later test in this worker. The sibling test at lines 518-538 ("flushes the tail...") has the same shape. Moving the config swap inside the try (or wrapping the whole body in an outer try/finally) would make the restore unconditional.
|
Reviewed; no blockers found. One non-blocking suggestion posted inline. |
Index backfills resume from their checkpoint and yield the event loop, so large tables converge (harper#2536)
…test's descriptor lookup The cherry-pick of PR #2539 onto v5.2 left conflict markers around runIndexing's new preamble: v5.2's signature takes no branchPath (branches are main-only), so the resolution keeps the three-parameter signature and every added helper (checkpoint rate limiting, flushIndexStores, resumeStartKey, per-put rejection tracking). The resulting diff against v5.2 is line-for-line identical to PR #2539's diff on main. indexRestartNumber.test.js's findDescriptor scanned the whole dbisDB, which the 'test' database shares across suites, so the new indexBackfillConvergence tables' own `tag` attribute was returned instead and two assertions failed. Scoped the scan to the table's key prefix, matching the fix already on main (#2258). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A secondary-index backfill on a large table could never converge:
runIndexingdiscarded its own resume checkpoint (ordered-binary sortsundefinedlowest, so the running-minimum guard never fired and every retrigger rescanned from the first record), and on a plain RocksDB index it never yielded the event loop (RocksIndexStore.putis synchronous, so theoutstandingcounter the yields were keyed to never left 0 and a 19M-row build ran as one ~18-minute turn until the worker was terminated). Backfills now resume from the minimum persisted checkpoint across the attributes being built (used as the scan'sstart, clearing every index when a full scan is needed), yield every 100 scanned entries regardless of write-completion timing (deletion entries count too and still pace the checkpoints). Because the checkpoint is now actually consumed, it has to be trustworthy:persistCheckpointwrites it only after the index writes it covers have settled and the RocksDB store has been flushed (flushIndexStores: index stores have no WAL), at most once per 5s and never before 10k more records, never advances after any index write fails (every put's rejection is tracked, not only a record's last), and stamps each checkpoint with its own key so the trigger ignores a checkpoint left by an earlier release (which advanced past failed and unflushed writes) and rebuilds instead of resuming past a gap. The completion path flushes the tail before persisting the ready descriptor and parks the index if that flush fails; the interrupt path awaits the in-flight checkpoint and persists synchronously. Where to look hardest:persistCheckpoint,flushIndexStoresand the trigger'suncertifiedCheckpoint— everything else is plumbing around them. Fixes #2536 (the per-threadisIndexingdivergence and the dead-backfill marker are #2537, whose PR #2543 edits lines adjacent to the trigger change here; expect a trivial rebase on whichever lands second).For the human reviewer
Step-6 planning review returned
Framing-Verdict: better-alternative-existstwice and both were adopted: round 1 made the checkpoint certify a successfully indexed prefix; after the task owner's ruling on the two open questions (1b, 2a) and a teammate's rig repro, the recheck accepted the layer and all four rejected alternatives (WAL-on index column families, colocating the checkpoint in the index CF, a trigger-side discard on process change, and accept-and-document) and asked for three corrections, all in: never join a flush already in flight, bind the stamp to its key, flush before readiness. Nine pre-push rounds; the last two produced no new actionable finding.disableWAL: truebut the descriptor store with the WAL on, so a checkpoint written every 100 records could outlive the entries it certifies after a SIGKILL/OOM (proven on a child-process kill: 99 of the first 100 entries lost, checkpoint intact). A checkpoint is now persisted only after a flush, at most once per 5s and never before 10k more records (setIndexingCheckpointPeriod()is the test seam) — the flush seals every column family in the database (atomic_flush), so the record floor keeps a slow backfill from imposing the 5s rate on unrelated tables. At most one flush in flight and one queued per root store; a caller never joins one already running because a flush only covers writes issued before it started. Consequences to weigh: a table under 10k rows never checkpoints mid-scan (a crash costs a sub-second rescan; the final barrier still flushes), each interruption reworks ≤10k records or ≤5s, and a crash loop shorter than that cannot converge. LMDB commits one environment in order and needs no flush.checkpointCertifiedrepeats the checkpoint key; the trigger resumes only when it equalslastIndexedKeyand otherwise rebuilds (logged as reindex reasonuncertified-checkpoint). That covers both legacy exits —indexingFailedand the marker-lessinterruptedreturn — so no field cleanup is needed before deploying; the one-time cost is that an in-progress legacy backfill restarts from record 0 on upgrade. This is the one descriptor schema addition in the PR (a field the old trigger drops, since it rebuilds the descriptor from the schema definition).maintoo: a kill right after completion lost 0 of 10,000 index entries with it and all 10,000 without it (child-process test). A flush failure parks the index (indexingFailed) rather than announcing it complete.startat the minimum checkpoint across attributes, re-putting already-indexed rows for attributes that were further ahead: index puts are idempotent and the redundant range is at most one interval. Reversible.main'sdidSynchronousIndexingbranch; reviewers flagged it as a throughput ceiling, but it is not this change's to alter.resumeStartKeyandsetIndexingCheckpointPeriodare exported solely for the unit tests; internal, trivially reversible.Verification
Route (b): new in-process end-to-end tests through
table()→runIndexing→ the real primary store on both engines, plus two real process kills.unitTests/resources/indexBackfillConvergence.test.js(withindexBackfillConvergence-crash.jsas the child fixture): the fourresumeStartKeycases; an interrupted two-attribute backfill resumes with its scan opened at the persisted, key-stamped checkpoint (asserted on thestartoption and the first key visited, and that the stamp is cleared on completion); a record whose index write throws, rejects asynchronously on a non-last value, or rejects while a later attribute's put resolves freezes the checkpoint before it and the retry revisits and indexes that record; unequal persisted checkpoints resume at the lower one, an absent one forces a full scan, and an unstamped legacy checkpoint is rebuilt from the first record; a child process SIGKILLs itself at its first persisted checkpoint and the parent resumes from that key through the PID-mismatch trigger; a child killed right after the ready descriptor leaves an index every entry of which survives; a rejecting flush parks the index and the retry completes; checkpoints are never closer than the record floor; and a 2000-row plain-index backfill yields every 100 records on RocksDB (asetImmediateticker stamps every visited key).Fails-on-base (base
distbuilt fromorigin/main'sdatabases.ts): the resumed scan'sstartwasundefined(expected the checkpoint) and all 2000 records were visited in one event-loop turn; on the parent commit of the completion barrier, the kill-after-complete test kept 0 of 10,000 index entries. With the fix: 14/14 on RocksDB and 13/13 (+1 skipped) underHARPER_STORAGE_ENGINE=lmdb.Gates on the final head:
npm run test:unit:resourcesRocksDB 2266 passing / 0 failing; LMDB 1763 passing / 0 failing;npm run test:unit:main5461 passing / 2 failing (theconfigValidatordomain-socket path-length case, a local worktree-path artifact, and thersa_keystoken-authentication flake, both known on this box and unrelated). Earlier runs on this branch hit the pre-existinglongLivedTransactionschain-link flake once per run; it passes alone.oxlintandprettier --checkclean on the changed files. Measured: 0.8µs persetImmediateyield; a 100k-row RocksDB backfill ran at 216k rec/s on base and 219–244k rec/s with the fix; anawaitat the checkpoint instead of the deferred write made the LMDB backfill 8x slower, which is why the checkpoint write is attached to the last put's settlement.🤖 Generated with Claude Code
https://claude.ai/code/session_01EVCC8xu4zk7eirYMg1ZHPb
Complexity: complicated
Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=9 @ 763ba6f
Human-Review-Need: 3 @ 763ba6f