Fix silent vector loss from concurrent writes in SQLiteVectorStore - #1469
Open
edwinyyyu wants to merge 5 commits into
Open
Fix silent vector loss from concurrent writes in SQLiteVectorStore#1469edwinyyyu wants to merge 5 commits into
edwinyyyu wants to merge 5 commits into
Conversation
Two tests for SQLiteVectorStore, both failing on current code: - test_row_ids_are_never_reused: a record inserted after a delete is assigned the deleted record's row_id (SQLite reuses max rowid without AUTOINCREMENT). - test_concurrent_delete_and_upsert_keeps_upserted_vector: a delete() suspended between its SQL commit and its engine remove() erases the vector of a concurrently upserted record that reused the freed row_id, leaving the record present but silently unsearchable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXPkjdQN9zgxVfYdM1tzpR
Fixes MemMachine#1468 The per-collection records table used a plain INTEGER PRIMARY KEY (a rowid alias), so SQLite assigns max(rowid) + 1 and deleting the record holding the maximum row_id frees that id for the very next insert. Because writes commit their SQL transaction before awaiting the search engine apply, a delete() suspended at its engine remove() could erase the vector of a concurrently upserted record that reused the freed row_id — the record remained in SQLite but was silently unsearchable, and the next index save persisted its absence. Declare the table with sqlite_autoincrement=True so row_ids are never reused, making unrelated writes id-disjoint: a suspended delete can only ever remove the dead id. This also hardens the query path, where a stale engine key now maps to no row and is dropped instead of being attributed to a new record that reused the id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXPkjdQN9zgxVfYdM1tzpR
Every write commits its SQL transaction and only then applies to the search engine, so two writers that overlap can reach the engine in the opposite order to the one they committed in. Never reusing a row_id does not help here: both writes address one uuid, and an upsert of an existing uuid keeps its row_id by design. The damage is durable and silent. An upsert that overtakes a delete of the same uuid re-adds its vector after the record is gone, leaving a vector that resolves to no row: it wins result slots and is dropped from them, so a query returns fewer matches than it found, and the next save publishes it into the index for good. Two upserts of one uuid inverting leaves the index serving the older vector while SQLite records the newer. The same window costs a write outright when a save falls in it. A save publishes the index and then trims every operation the index now holds, and the pending log is the only other copy of those vectors. A write that applies to the engine after the file is written but before the trim satisfies neither: not in the file, and no longer in the log. It is live in memory and absent from disk -- a committed write lost to a process crash, which is the failure this store otherwise rules out. So a collection's writes now run one at a time, from the SQL commit through the engine apply, the mark-applied, and any save they trigger. The lock belongs to the store rather than to a collection handle: a handle is constructed per open_collection call, so several can address one collection, and only a shared lock serializes them. Shutdown's save takes it too, for the same reason a write's own save is inside it. Writers already serialized on SQLite's single-writer lock for the transaction and on the engine's own lock for the apply, so what this gives up is the overlap between one writer's transaction and another's engine work. Readers are untouched and still run freely. Signed-off-by: Edwin Yu <edwinyyyu@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The kwarg reads as a style choice and the comment above it explained only the window the previous commit has since closed, which between them invite deleting it during a cleanup. What that would cost is on the read path, which the write lock deliberately does not cover: query() scores keys in the engine and resolves them to rows afterwards, so a reused id returns a record that was never scored, wearing the score of the record that was. That failure has nothing pointing at it -- the record exists, the score is in range, no row or vector is left dangling -- so the reason it cannot happen belongs next to the line that prevents it. Signed-off-by: Edwin Yu <edwinyyyu@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI caught this on one runner: the interleaving tests wrap their poll for another task's committed state in `asyncio.timeout`, and under load the deadline lands inside a query rather than between two. Cancelling there returns the connection to the pool without resetting it, so the read transaction it had open stays open, and the next commit waits out SQLite's busy timeout and fails with "database is locked". The deadline belongs between polls, where expiring costs nothing. That is also the only place it can be checked without a timeout wrapper, so the wait is now a plain loop over `loop.time()`. Signed-off-by: Edwin Yu <edwinyyyu@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Purpose of the change
Fix the concurrent-write races described in #1468. In
SQLiteVectorStoreevery write commits its SQL transaction and only then awaits the search engine apply, so two writers on one collection can overlap. Two independent defects live in that window, and both end with SQLite and the index durably disagreeing while every surface a caller can inspect looks intact.Description
1.
row_idreuse. The per-collection records table declaredColumn("row_id", Integer, primary_key=True, autoincrement=True)withoutsqlite_autoincrement=Trueon theTable, so SQLAlchemy emitted a plainrow_id INTEGER PRIMARY KEY(a rowid alias). SQLite assignsmax(rowid) + 1, so deleting the row holding the maximumrow_idfrees that id for the very next insert, and a new record inherits the id an old one was being served under.Fixed with
sqlite_autoincrement=Trueon the records table. Ids are never reused, so a suspended delete can only ever target the dead id, and a stale engine key maps to no row instead of to whichever record inherited it.2. Ordering. Serialization was fix option 3 in the issue, and it turns out to be load-bearing rather than optional, because
AUTOINCREMENTcannot reach the case where two writes address one uuid -- an upsert of an existing uuid keeps itsrow_idby design:Fixed with a per-collection write lock held across the whole sequence -- SQL commit, engine apply, mark applied, and any save it triggers. The lock lives on the store, not on a collection handle, because a handle is constructed per
open_collectioncall and only a shared lock serializes them;shutdown's save takes it for the same reason a write's own save is inside it. Readers are untouched and still run freely.Writers already serialized on SQLite's single-writer lock for the transaction and on the engine's own lock for the apply, so what this gives up is the overlap between one writer's transaction and another's engine work. The case worth checking is the save, which now runs inside the lock: every engine holds its own write lock for the whole of
save, so a concurrent writer already blocked there for that duration, and what it waits for on top of that is one SQL transaction.Regression tests (
test_sqlite_vector_store.py), each failing on the code it fixes:test_row_ids_are_never_reused-- a record inserted after a delete must not receive the deleted record'srow_id.test_a_query_cannot_return_a_record_it_never_scored--query()scores keys and resolves them to rows in a second step, holding nothing in between. A gated engine parks in that gap while the scored record is deleted and another is inserted; the query must not return a record that was never scored, wearing the score of the one that was. This is the read-path exposure from the issue, and the reasonAUTOINCREMENTis still load-bearing after writes are serialized.test_an_upsert_survives_a_delete_of_another_record-- the issue's original interleaving, end to end.test_an_upsert_cannot_overtake_a_delete_of_the_same_uuid-- the inversion above; without the lock the query returns nothing, its only slot taken by a vector that resolves to no record.test_a_save_cannot_trim_a_write_it_did_not_publish-- a write applied behind a parked save, then a simulated crash; without the lock the record comes back with its vector gone from the index and its log row trimmed.No new dependencies.
Fixes/Closes
Fixes #1468
Type of change
How Has This Been Tested?
Each test was run against the code without the corresponding fix and fails there: removing
sqlite_autoincrementfails the twoTestRowIdReusetests, and removing the write lock failstest_an_upsert_cannot_overtake_a_delete_of_the_same_uuidandtest_a_save_cannot_trim_a_write_it_did_not_publish.Test Results:
ruff check,ruff format --check, andty check packagesare clean.Checklist
Further comments
sqlite_autoincrementonly affectsCREATE TABLEDDL, so records tables of collections created before this change keep plain-rowid behavior until recreated; new collections getAUTOINCREMENT. The write lock has no such gap -- it closes the write-path half for pre-existing tables too, since serialized writers cannot be handed a freed id inside another write's window. What those tables still lack is the read-path guarantee, which only recreating them restores.Per-insert cost of
AUTOINCREMENTis SQLite'ssqlite_sequencebookkeeping write, which is negligible here.