Skip to content

Harden reindex and KNN serving against crash residue (travsr#735 follow-up) - #21

Merged
anketpratapsingh merged 3 commits into
mainfrom
fix/735-crash-residue-hardening
Aug 22, 2026
Merged

Harden reindex and KNN serving against crash residue (travsr#735 follow-up)#21
anketpratapsingh merged 3 commits into
mainfrom
fix/735-crash-residue-hardening

Conversation

@anketpratapsingh

@anketpratapsingh anketpratapsingh commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Sidecar-side follow-up to Travsr-com/travsr#735 (host-side fix: Travsr-com/travsr#754). A reindex killed without cleanup leaves residue — a large un-truncated embed.db-wal, a possibly partial .hnsw.usearch, and orphaned lock metadata — that the sidecar handled badly. A live occurrence was observed on macOS as a daemon-mode sidecar at a 24 GB memory footprint with a 440 GB virtual size.

Changes

1. Truncated index bytes could SIGSEGV the process. usearch's native view()/load() parse the file header without validating it, so a partially-written .hnsw.usearch could crash the sidecar outright instead of returning an error. This is not theoretical: it reproduced on this PR's own Linux and macOS CI runners once a test began feeding garbage to a served index. Every published index holds at least one f32-384 vector and is written save-to-tmp + rename, so a file below a 512-byte floor is always truncation residue; it is now rejected in Rust before any native parsing, on the serve path, the load path, and the KNN reload path. The floor cannot catch large garbage, which is called out in the constant's docs.

2. KNN reload throttle + keep-old-on-failure (daemon mode). A failed reload was retried on every KNN call forever, and a rapidly republished file was re-mapped per query; both construct a fresh native index per query. Reload attempts are now throttled to one per 5 seconds (reload_due, pure and unit-tested), and a served (mmap) index keeps answering from its previous mapping when a reload is rejected instead of degrading to an empty index. The #736 C2 free-before-load behaviour is preserved for RAM-copy handles, where the 2x transient is the real concern.

3. Corrupt-index quarantine on the load-based reindex path. A direct --reindex without --parallel calls try_load on the existing index and previously failed and exited 1 on every attempt, with nothing ever cleaning the bad file. That path now quarantines it as .corrupt (keeping only the latest) and rebuilds from embed.db; stale .usearch.tmp files are swept. Scope note: the --parallel path the daemon always uses never loads the existing index — it rebuilds and renames at end of run — so it already self-healed. Both behaviours were verified by hand against a locally built binary.

4. NULL-tolerant pending-row decode. Rows whose text columns failed to decode were silently dropped by filter_map(|r| r.ok()) and re-selected by NOT EXISTS in every later chunk; a chunk of only-bad rows ended the run "successfully" with pending work the daemon respawned forever. pending_row_to_text (shared by both reindex paths) now tolerates NULL/mistyped columns so every selected row is embedded and inserted, residual failures are counted and logged, and an all-undecodable chunk aborts loudly.

Tests

New: reload-throttle decision table; quarantine round-trip (including replacing a prior quarantined copy); try_load rejecting truncation residue cleanly; a served index continuing to answer after a rejected reload (all platforms — the plausibility gate fires before the view/load split); NULL and mistyped pending-row decode against in-memory SQLite. Full suite 64 passed / 0 failed on Windows in release mode (per this repo's platform notes); clippy -D warnings and rustfmt clean; CI green on Linux, macOS and Windows.

Also included: blob_to_f32 migrated from chunks_exact(4) to as_chunks::<4>(). CI runners moved to Rust 1.98, whose new chunks_exact_to_as_chunks lint (denied via -D warnings) fails on that pre-existing line; main fails the same way today. Semantics are identical and as_chunks stabilised in 1.88, below this repo's 1.91 floor.

Manual verification

Against a locally built binary on Windows: killed a reindex host mid-run to produce the real residue (embed.lock.info naming a dead PID, un-truncated WAL, orphaned sidecar), then confirmed the next reindex recovers the stale metadata, completes all 1320 pending nodes, and releases the lock cleanly. Corrupt-index behaviour confirmed separately on both reindex paths.

Notes for review

  • No protocol/SDK changes; builds against travsr master unchanged, so no paired travsr PR is needed and this can merge in either order relative to #754.
  • The macOS 440 GB-virtual observation is consistent with the reload churn fixed here, but a native usearch view/drop retention audit is still worth doing separately if the pattern recurs after this lands.

@Abhishek5517 Abhishek5517 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: empirical + E2E (before/after on the real load path)

Built this branch, ran the suite, and reproduced the corrupt-index recovery end to end against a real index (the travsr repo's own graph.db / embed.db, arctic-embed-m-v1.5, copied to scratch).

Tests

  • Full suite: 64 passed / 0 failed. Verified the new tests run and pass:
    try_load_rejects_truncation_residue_cleanly, failed_review_keeps_serving_the_old_index,
    quarantine_moves_corrupt_file_aside, reload_due_throttles_attempts, and the NULL/mistyped pending-row decode tests.

E2E: defect 3 (corrupt index on the --reindex load path)

Seeded 800 pending nodes, overwrote the code .hnsw.usearch with residue, and ran the identical input through both binaries:

  • Shipped v1.4.0: reindex failed: load existing HNSW index: load usearch index from disk: Failed to read vectors, exit 1, and the corrupt file was left in place. On a daemon this is the respawn-every-tick poison loop this PR targets. Reproduced.
  • This branch: detected 100 bytes, below the 512 byte minimum any published index can have, quarantined the file to .hnsw.usearch.corrupt, rebuilt a valid 23 MB index from embed.db, embedded all 800 pending, exit 0.

Also confirmed:

  • Re-quarantine replaces the previous .corrupt copy (only the latest is kept: md5 rotated, one file on disk).
  • The rebuilt index is queryable (a follow-up reindex reports "up to date", 0 pending).
  • The .usearch.tmp / .usearch.corrupt names match the real save() path (with_extension("usearch.tmp") / ("usearch.corrupt")), so the sweep and quarantine target the right siblings.

One caveat on the SIGSEGV claim

With the 100-byte residue I used, native usearch returned a clean Err rather than SIGSEGV, so I did not trigger the native crash locally (it is byte-pattern dependent, and your CI runners hit it). The exit-1 poison loop and the recovery are clearly demonstrated. The 512-byte floor cannot catch large-but-corrupt garbage, so a residual native-crash window remains on the serve path, which the constant's docs already acknowledge and the respawn cap bounds. Worth keeping the native view/load audit on the list as the PR notes.

Cross-PR conflict (please coordinate with #22)

This branch and #22 both modify src/main.rs and both add the identical blob_to_f32 as_chunks change to src/model.rs. A trial merge of #22 onto this branch conflicts in src/main.rs (two adjacent new #[cfg(test)] modules inserted before mod query_memo_tests). It is a trivial keep-both resolve, but #22's description currently says it "touches different code", which is not accurate. Whoever merges second will need to resolve it.

Verdict

Solid, and the recovery behavior is exactly right. No blocking issues.

Abhishek5517
Abhishek5517 previously approved these changes Aug 22, 2026

@Abhishek5517 Abhishek5517 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Verified the corrupt-index recovery before/after on the real --reindex load path: shipped v1.4.0 fails exit 1 and leaves the poison file, this branch quarantines and rebuilds cleanly. Full suite green (64). See my detailed comment above. Non-blocking: please coordinate the src/main.rs conflict with #22.

…ow-up)

A reindex killed without cleanup leaves state that previously poisoned
every later run:

1. A partial/corrupt .hnsw.usearch made every subsequent reindex fail at
   load and exit 1, and the daemon respawned one per tick, forever. The
   reindex write path now quarantines an unloadable index as .corrupt
   (keeping only the latest copy) and rebuilds from embed.db; stale
   .usearch.tmp files from a save killed between write and rename are
   swept. Write-path only: the serving path never deletes a file a
   concurrent reindex may be about to replace.

2. The daemon-mode KNN path retried a failed index reload on every call,
   forever, and re-mapped the file per query while a reindex kept
   publishing it; both are native-index construction loops. Reload
   attempts are now throttled to one per 5 seconds (reload_due), and a
   served (mmap) index keeps answering from the previous mapping when a
   re-view fails instead of degrading to an empty index; the previous
   free-before-load behavior is kept for RAM-copy handles, where the 2x
   transient is the real concern.

3. Pending rows whose text columns failed to decode were silently
   dropped by filter_map(r.ok()) and re-selected by NOT EXISTS in every
   later chunk; a chunk of only-bad rows ended the run 'successfully'
   with pending work the daemon respawned forever. Decoding is now
   NULL-tolerant (pending_row_to_text) so every selected row is embedded
   and inserted, residual failures are counted and logged, and an
   all-undecodable chunk aborts loudly.

Tests: reload throttle, quarantine round-trip, keep-old-mapping on
failed re-view (unix), and NULL/mistyped pending-row decode.
The keep-old-on-failed-reload test exposed on CI that usearch's native
view()/load() parse the file header without validation and SIGSEGV on
truncated bytes (observed on Linux and macOS runners). An error return
was the assumed failure mode; a native crash bypasses both the
quarantine (write path) and keep-old (serve path) handling entirely.

Add a Rust-side plausibility gate before any native parsing: every
legitimately published index holds at least one f32-384 vector (1536
bytes of data alone) and is written via save-to-tmp + rename, so a file
under 512 bytes is always truncation residue from a killed process.
try_load rejects it with a clean Err (the reindex caller quarantines),
try_serve likewise, and knn's throttled reload keeps the current index
and retries after backoff, now checked BEFORE freeing anything so even
the load() branch no longer trades a live index for junk.

The gate cannot catch large garbage; that residual native-crash risk is
bounded by the host's respawn cap and noted in the constant's docs. The
keep-old test now exercises the gate deterministically on every
platform (no native parsing of garbage), and a new test pins try_load's
clean rejection. The reload body moves to reload_from_disk for the
early-return shape; behavior is otherwise unchanged.
Hand-testing a locally built binary showed the entry overstated defect 1:
the host always spawns the sidecar with --parallel, and that path never
loads the existing index (it rebuilds and renames at end of run), so a
corrupt index already self-healed there. The load-and-fail behaviour is
real only on a direct --reindex without --parallel.

Also promotes the truncation-residue gate to its own entry: a native
SIGSEGV on partially-written index bytes is the most serious of the four
and was missing from the list entirely.
@anketpratapsingh

Copy link
Copy Markdown
Collaborator Author

Rebased onto main now that #22 has merged, resolving the conflict flagged in review on Travsr-com/travsr#754.

Both PRs inserted a test module at the same point in src/main.rs (immediately before mod query_memo_tests) and both carried the same blob_to_f32 clippy-1.98 fix. The resolution keeps both modules (token_budget_tests from #22, pending_row_tests from here); the duplicate blob_to_f32 commit dropped out automatically because main already carries that content, so this branch is down to three commits.

Verified locally on Windows after the rebase: rustfmt clean, clippy -D warnings clean, 68 tests passing (both PRs' suites combined). GitHub now reports the PR as MERGEABLE.

@anketpratapsingh
anketpratapsingh merged commit b4035e2 into main Aug 22, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants