perf(engine): retain vector writer across commits + lexical segment-meta cache (#864)#869
Merged
Merged
Conversation
…eta cache (#864) Remove the two verified per-doc write-path offenders (Issue #864, making the #572 commit batching real): Vector: VectorStore::commit no longer take()s the cached writer when the index opts in via the new VectorIndex::retain_writer_after_commit() (default false; true for HnswIndex only). Post-commit the writer's in-memory state equals the file it just wrote, so the first upsert after every commit no longer reloads + dequantizes the entire .hnsw. The cache is dropped whenever the index is rewritten behind the writer - auto-compaction inside commit, VectorStore::optimize (now async) - and on any mid-ladder commit failure, where the writer/disk agreement is unknown. A new VectorIndexWriter::has_pending_changes() (HNSW: !is_finalized) gates the flush, so a no-change commit skips the full index rewrite, and optimize() flushes a dirty writer whose buffer was emptied by deletions instead of discarding the mutation. Lexical (closes #559, #571): InvertedIndexWriter caches (segment_id, min, max) ranges - seeded by the constructor's existing recovery scan, extended on flush - with a doc_id > max_committed_doc_id fast path, so fresh-id upserts stop listing + JSON-parsing every .meta file, and reuses one lazily-created DeletionManager across overwrites instead of reloading every .delmap per call. LexicalStore::commit and ::optimize hold the writer-cache lock across their merges so a concurrent upsert can neither run against a stale cache nor construct a writer from a racing mid-merge scan; invalidate_segment_cache rebuilds atomically (swap on success) and optimize drops the writer if the rebuild fails. Verification is deterministic: CountingStorage decorators assert exact .hnsw open/create and list_files counts; both corruption gates (compaction resurrection, ghost-segment deletion) and both fault-injection tests (failed commit drops the writer, failed invalidate preserves the old cache) fail when their fix is disabled. A pre-existing HNSW incremental-append reachability bug discovered by these tests is tracked separately as #868; membership assertions gate on stats().document_count until it is fixed. Closes #864, closes #572, closes #559, closes #571. Refs #551, #868
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Removes the two verified per-doc write-path offenders (#864, PR-2 of the #551 campaign) — this is what makes #572's commit batching real:
1. Vector writer retention across commits.
VectorStore::commitno longertake()s the cached writer when the index opts in via the newVectorIndex::retain_writer_after_commit()(defaultfalse;trueforHnswIndexonly — Flat/IVF keep today's drop-on-commit until audited). Pre-fix, the first upsert after every commit reloaded + dequantized the entire.hnsw; post-commit the writer's in-memory state equals the file it just wrote (idempotentfinalize(), incremental graph append, non-consumingwrite(&self)), so the reload is pure waste. The writer-cache lock is now held across the commit ladder so a concurrent upsert cannot interleave.The two paths that rewrite the index behind the writer both invalidate the cache — without this, a stale retained writer would write physically reclaimed vectors back with no deletion bitmap marking them:
commit(maybe_auto_compact()? == true),VectorStore::optimize()(nowasync; commits the cached writer first when it holds buffered docs so nothing is lost, then always drops the cache). Only two test call sites needed.await; no other callers exist.2. Lexical segment-metadata cache + cached
DeletionManager(closes #559 and #571). Pre-fix, every upsert listed the storage and JSON-parsed every*.metafile (find_segments_for_doc), and every overwrite of a committed doc built a freshDeletionManager(reloading every.delmap). NowInvertedIndexWriterkeeps:segment_ranges+max_committed_doc_id— seeded by the constructor's existing recovery scan (zero extra I/O), extended in place by both flush paths. Fresh WAL doc ids exceed every committed max, so steady-state ingest answers "is this doc committed?" with one integer compare; overwrites resolve from memory.deletion_managerreused across overwrites (built on first need only — pure-ingest writers never pay the bitmap load).invalidate_segment_cache()(new default-no-op trait hook), called byLexicalStore::optimizeon the live writer — force-merge is the only path that rewrites segments behind a live writer (commitdrops the writer before merging). Without it, a post-optimize overwrite would mark its deletion in a ghost segment and the old version would resurface: the test pinshits("alpha") == 0.Hardening from the pre-commit adversarial review
A 3-dimension × adversarial-verify review of the diff confirmed five defect classes in the first draft; all are fixed and regression-pinned in this PR:
maybe_auto_compact(which may have partially rewritten the index and cleared the delmap before erroring) left the stale writer cached — and made Flat/IVF flush failures sticky. Now any mid-ladder error drops the cache (the pre-retention behavior on every path). Pinned byfailed_commit_drops_retained_writer(fault-injectedcreate_outputfailure).optimize()'spending_docs() > 0gate discarded deletions: a dirty writer whose buffer was emptied by deletions haspending_docs() == 0but holds an uncommitted delete-everything mutation. NewVectorIndexWriter::has_pending_changes()(defaulttrue; HNSW:!is_finalized) is the correct gate. Pinned byoptimize_flushes_writer_emptied_by_deletions..hnswfrom the retained writer even with zero changes — a perf regression for no-op commits. The samehas_pending_changes()gate skips the flush; pinned bynoop_commit_does_not_rewrite_hnsw(create-count stays flat over back-to-back commits, rises again on a real change).LexicalStore::optimizeran the force-merge outside the writer lock (a concurrent upsert could mark deletions in segments being deleted), andcommitreleased the lock beforemaybe_merge(a writer constructed mid-merge would seed its cache from a racing, inconsistent.metascan and never be invalidated). Both now hold the writer-cache lock across the merge, mirroring the vector side;optimizealso invalidates even when the merge errs partway, and drops the writer if the rebuild itself fails.invalidate_segment_cachewas not failure-atomic: it cleared the cache before the fallible rescan, so alist_fileserror left a live writer with an empty cache silently skipping every subsequent deletion. Now it builds the new view first and swaps on success. Pinned byinvalidate_failure_preserves_old_cache(fault-injectedlist_filesfailure).Verification (deterministic counters; fault-injection-proven)
CountingStoragedecorators:.hnswopen_inputcount stays flat across post-commit upserts and follow-up commits; 50 fresh-id upserts perform zerolist_files; the first overwrite pays exactly the one-timeDeletionManagerscan, subsequent overwrites zero.document_count61 → 101; the lexical optimize test resurrects the pre-merge doc version.--features laurus/embeddings-all) clean /laurus-wasmbuilds / markdownlint clean (en+ja docs).Discovered while testing: pre-existing HNSW reachability bug → #868
The retention tests initially asserted exact search hit sets and flaked: incremental HNSW appends can nondeterministically leave nodes unreachable on layer 0. Reproduced on unmodified main (27eed28) — up to 6 of 30 docs missing after 3 commit cycles, varying per run, persisting across reopen, with all records present on disk. Filed as #868 with full evidence; the tests here gate on
stats().document_count(record-level, exact) instead, and can be tightened back once #868 is fixed.API note
VectorStore::optimize()is nowasync(it must take the writer-cache tokio mutex). Approved in the campaign plan; no callers outside the store besides two integration tests.Docs
docs/{src,ja/src}/concepts/indexing/vector_indexing.md: new "Writer retention across commits" section.Closes #864, closes #572, closes #559, closes #571. Refs #551, #868.