Publish vector index files atomically, not durably - #1460
Open
edwinyyyu wants to merge 5 commits into
Open
Conversation
SQLiteVectorStore persists each collection's index by calling the search engine's save(), which wrote directly to the final path. A crash mid-write left a truncated/corrupt file. Because index_saved=True makes the on-disk index a durable contract (missing/corrupt is a hard IndexLoadError, not a silent empty rebuild), an interrupted save could render a collection unrecoverable. Write the index to a sibling temp file and swap it into place with os.replace (atomic on POSIX and Windows on the same filesystem), so a reader sees either the old or new index, never a partial write; a failed save leaves the previous index intact. Leftover temp files are cleared on load so a crash does not leak them across restarts. Implemented in the engines (shared index_persistence helper) rather than in SQLiteVectorStore/SQLiteVectorStoreCollection, since the index save location and number of files written differ across engine implementations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
26 tasks
Contributor
|
This pull request has been automatically marked as stale because it has not had recent activity. It will be closed in 14 days if no further activity occurs. If you are still working on this, please push a commit or leave a comment. Reviewers: please respond, or add the |
The swap protects a reader from a torn index, but the vector store also trims its pending-operation log once `save` returns -- and that log is the only other copy of those vectors, since the records table stores no vector column. So the swap reaching disk is load-bearing rather than a bonus: - fsync the parent directory after the replace, since POSIX `rename(2)` leaves the new directory entry in the page cache. Best-effort and ignored on failure, matching SQLite's `unixSync`; a no-op on Windows, which has no equivalent operation. - stop swallowing a failed fsync of the temp file. SQLite draws the same line -- a file fsync failure raises SQLITE_IOERR_FSYNC while a directory fsync failure is ignored -- and `EIO` means the writeback already failed and the dirty pages were dropped, which is exactly when the save must not be reported as committed. The existing cleanup then leaves the previous index in place with the log untrimmed, so the next save retries. - use F_FULLFSYNC on macOS, where plain `fsync` leaves the data in the drive's volatile write cache, falling back when a filesystem refuses it. State the resulting obligation on `VectorSearchEngine.save` itself, since that is what the store now relies on: replace atomically, then make the replacement as durable as the platform allows. An engine whose backend already implements the whole protocol can delegate to it and skip these helpers; the rest use `atomic_index_write`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pending log holds the only durable copy of a vector between checkpoints -- the records table has no vector column -- so trimming it is safe only at an instant when the index provably holds those vectors. The temp-write + rename protocol this PR shipped could not provide that instant. A rename changes a directory entry, and Windows exposes no way to flush one: os.fsync is _commit, which is FlushFileBuffers, which is for file data, and you cannot open a directory to fsync it. The decisive evidence is SQLite's own -- it threads a directory-sync flag through every commit-relevant directory operation, honors it in unixDelete, and declares it /* Not used on win32 */ in winDelete. So os.replace could return, _save_collection_index could commit its trim durably behind it, and a power cut could still roll the rename back: records forward, index back, no copy of the difference left. MOVEFILE_WRITE_THROUGH is not a fix; its documented guarantee covers copy-and-delete (cross-volume) moves, not same-volume renames. Take SQLite's answer, which was not to harden the directory operation but to stop using one as a commit point (PERSIST commits by zeroing a header, TRUNCATE by truncating, WAL by appending frames). A base path now expands into two index slots plus a generation record each, created once and thereafter only overwritten. A checkpoint writes the index over the inactive slot and flushes it, then writes that slot's generation record and flushes that. The record is the commit, and it is a write into a file that already exists. It holds the generation and its bitwise complement, so a torn write reads as absent rather than as some other generation -- all or nothing without needing single-sector atomicity from the hardware. load takes the highest believable generation, and deliberately does not fall back to the older slot when the published index will not parse: the log was trimmed against the newer one, so the older is stale by exactly the ops that can no longer be replayed. Both backends already write straight to the path they are given, which is what this protocol wants -- verified that repeated saves preserve the inode and leave no stray files -- so no engine gains a temp file, a buffer, or a rename. Durability is entirely the engine's, including which artifact is live. The store keeps no slot pointer, manifest, or generation, so no schema change and no migration: what remains is one rule, never trim past what save says is durable, and _save_collection_index already had that order. index_path becomes index_base_path since it no longer names a file, and discarding a collection asks the engine layer which files that covers. BREAKING CHANGE: an index written by the previous protocol is not published under the new one, so a collection with index_saved=True raises IndexLoadError until its index directory is cleared and the records re-ingested. Anomaly tests walk every crash point in the publish sequence by constructing the on-disk state each would leave, plus one that pins the ordering itself (a failed index write must publish nothing) since state-based tests cannot observe it. Verified against three deliberate breaks -- dropping the complement check, writing the record first, and reusing one slot instead of alternating -- each caught. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two-slot generation-record protocol bought a guarantee we have decided not to make: that a save survives a power failure. Every engine would have to implement and maintain that protocol, and the failure it buys out is bounded -- search recall for the records applied since the last checkpoint, repaired by re-ingesting them. The direction that actually costs, a published index that will not parse, is closed by the atomic swap on its own. So this returns to the temp-file-plus-rename publication and spends the difference on stating the contract instead of strengthening it: `save` publishes atomically, never durably; the store trims the pending log behind a publication a power failure can revert; a record whose vector is lost that way still resolves by uuid, is absent from search until it is upserted again, and nothing here detects the gap for the caller. Reverts the durability and engine-owned-publication commits, keeps the atomic swap, and adds a store-level test that reconstructs a reverted publication deterministically -- restore the previous index bytes after the trim -- to pin the direction it fails in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`update_feature` reads the stored embedding back when a caller updates a feature without supplying one, and that is the only place in the server that depends on the index still holding a vector. With publication now atomic rather than durable, a power failure can leave a feature whose row is intact and whose vector is not -- a state this path reported as "Vector record not found", which points the caller at the wrong thing and hides the repair. Split the two cases. A record that is genuinely absent keeps the old message; a record whose embedding the index no longer holds says so and names the fix, which is to pass a fresh embedding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
15 tasks
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.
Problem
SQLiteVectorStorepersists each collection's index by calling the search engine'ssave(), which writes directly to the final path. A crash partway through leaves a truncated file on disk.The torn file is only half of it.
_save_collection_indextrims the applied_PendingOperationRows as soon assave()returns, and that log is the only other copy of those vectors — the records table has no vector column. So the trim is safe at exactly one instant: when the index at the published path holds them. Getting that instant wrong is silent, because a missing vector is indistinguishable from a vector that simply doesn't match.Fix
A shared
index_persistencehelper writes the index to a sibling temp file, flushes it, and swaps it into place withos.replace; both engines adopt it and neither gains anything else.loadclears a temp left by an interrupted save, so a crash leaks at most one stale file per index.After this, a save either publishes a complete index or leaves the previous one exactly where it was. Nothing else changes: no schema change, no migration, no rename of
index_path, and no new file at the base path.What a save does not promise
It does not promise that the publication is durable. That is a decision rather than an oversight, so the reasoning is below.
A rename changes a directory entry, not file data, and the two have very different guarantees:
fsync/F_FULLFSYNC/FlushFileBuffersfsyncon a directory file descriptoros.fsyncis_commit, which isFlushFileBuffers, which is for file data; you cannot open a directory to fsync it.MOVEFILE_WRITE_THROUGHdoes not help either: its documented guarantee is scoped to "a move performed as a copy and delete operation", the cross-volume path, and says nothing about flushing NTFS metadata for a same-volume rename. The decisive evidence is SQLite's — its VFS threads a directory-sync flag through every commit-relevant directory operation,unixDeletehonors it, andwinDeletedeclares the same parameter/* Not used on win32 */. SQLite cares about this more than almost any software and makes no attempt on Windows.So a power failure can roll the rename back after
savereturned, while the trim behind it stays committed.Why that gap stays open
Closing it means never using a directory operation as the commit point: two index slots plus a generation record written into a file that already exists, in the spirit of SQLite's
PERSISTjournal mode. An earlier revision of this PR implemented exactly that, and it worked. It was still the wrong trade, for four reasons.The window is narrow, and only one kind of failure lands in it. A clean shutdown, an unhandled exception, an OOM kill, a
SIGKILL— none of these lose a rename, because the kernel still owns the page cache and writes it back. Those failures are already covered: the pending log holds everything the engine has not been checkpointed with, and startup replays it. What is left is the machine itself dying between the rename and writeback — a power cut, a host failure, a panic — plus the filesystems where a directory fsync is best-effort or unavailable anyway.What it costs is recall, not correctness. The records table is the authority on what exists, and every query hit resolves through it, so a reverted publication cannot produce a wrong or stale result — only fewer results. Concretely: the records survive,
getstill returns them,querystops finding them, and the exposure is bounded by the operations applied since the previous checkpoint, i.e. at mostsave_threshold. Re-upserting an affected record repairs it. That is the direction this store already tolerates, and it is repairable by the same ingest path that created the record.Detecting it is not cheap enough to be worth it. Comparing the index's size against the record count is the obvious check and it does not work:
row_ids areAUTOINCREMENTand never reused, so deleting a record and upserting the same uuid again moves it to a new id. A rollback spanning that pair leaves the index holding the old id and missing the new one — one extra, one missing, identical count — and a re-embedding workload produces that shape constantly. A check that does work needs an id-set digest maintained by every engine, or a full reconcile scan at every boot, which is most expensive precisely where the index is large. Neither earns its keep against a bounded recall gap.And the guarantee is not free to hold. It cannot be delegated to a rename, so it has to become a layout: two slots and a generation record per index, which every engine has to implement and every future engine has to be audited against. Engines that persist by writing one file — which is what both engines here do, and what most ANN libraries expose — stop being usable as they ship. Existing indexes stop loading until they are cleared and re-ingested, which is a migration this revision no longer asks anyone to perform. Fewer guarantees, less machinery to keep correct, and a wider set of engines that can plug in unmodified.
The direction that would cost — a published index that will not parse — stays closed: the atomic swap prevents it, the pre-swap
fsyncnarrows the window where a rename outlives the data behind it, and a saved-but-unloadable index remains a loudIndexLoadErrorrather than a silently empty rebuild.Nothing here detects a lost publication for the caller. A deployment that needs every record searchable after a power failure must be able to re-ingest.
Where the contract is stated
The guarantee is only useful if callers can find it, so it is written where each of them looks:
VectorSearchEngine.save— returning means a laterloadreads this index or the one it replaced, never a mixture; it does not mean the publication survives a power failure.index_persistence— the mechanism, and why a stronger guarantee is not portable.sqlite_vector_store— what survives a process crash, what a power failure costs, and that re-ingest is the repair._save_collection_index— why the save-then-trim order is the whole protocol.One consumer needed more than a docstring.
VectorStoreSemanticStorage.update_featurereads a feature's stored embedding back when the caller updates the feature without supplying one — the only place in the server that depends on the index still holding a vector. It reported a lost vector asVector record not found, naming the feature, which points at the wrong thing and hides the repair; it now separates a record that is genuinely absent from one whose embedding is gone, and the second says to pass a fresh embedding.Tests
test_index_persistence.py: the swap publishes completely or not at all, a failed write leaves the previous index intact and removes the temp, and a stale temp is cleared on load.test_a_reverted_publication_costs_search_not_recordsreconstructs a lost publication deterministically — restore the previous index bytes after the trim has committed — and pins the direction it fails in: the record still resolves by uuid, and only search loses it. Missing and corrupt indexes each still raiseIndexLoadErroronceindex_savedis set.vector_storesuites pass (284 passed, 140 integration deselected) andsemantic_memorysuites pass (345 passed, 2 skipped, 999 integration deselected);ruffandtyclean.Changed from the earlier revision
This PR previously shipped the two-slot generation-record protocol and its breaking on-disk change. That guarantee has been dropped in favor of the atomic swap plus an explicit contract, so the breaking-change notice no longer applies: indexes written by the current code keep loading, and there is nothing to migrate.
🤖 Generated with Claude Code