Skip to content

Fix silent vector loss from concurrent writes in SQLiteVectorStore - #1469

Open
edwinyyyu wants to merge 5 commits into
MemMachine:mainfrom
edwinyyyu:claude/github-issue-1468-p9o01q
Open

Fix silent vector loss from concurrent writes in SQLiteVectorStore#1469
edwinyyyu wants to merge 5 commits into
MemMachine:mainfrom
edwinyyyu:claude/github-issue-1468-p9o01q

Conversation

@edwinyyyu

@edwinyyyu edwinyyyu commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Purpose of the change

Fix the concurrent-write races described in #1468. In SQLiteVectorStore every 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_id reuse. The per-collection records table declared Column("row_id", Integer, primary_key=True, autoincrement=True) without sqlite_autoincrement=True on the Table, so SQLAlchemy emitted a plain row_id INTEGER PRIMARY KEY (a rowid alias). SQLite assigns max(rowid) + 1, so deleting the row holding the maximum row_id frees 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=True on 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 AUTOINCREMENT cannot reach the case where two writes address one uuid -- an upsert of an existing uuid keeps its row_id by design:

  • An upsert that overtakes a delete of the same uuid re-adds its vector after the record is gone. The vector resolves to no row, so it wins result slots and is then dropped from them (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.
  • A save falling in the window costs a write outright. 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 is in neither: live in memory, absent from disk. That is a committed write lost to a process crash, which is the failure this store otherwise rules out.

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_collection call 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's row_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 reason AUTOINCREMENT is 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

  • Bug fix (non-breaking change which fixes an issue)

How Has This Been Tested?

  • Unit Test

Each test was run against the code without the corresponding fix and fails there: removing sqlite_autoincrement fails the two TestRowIdReuse tests, and removing the write lock fails test_an_upsert_cannot_overtake_a_delete_of_the_same_uuid and test_a_save_cannot_trim_a_write_it_did_not_publish.

Test Results:

uv run pytest packages/server/server_tests/memmachine_server/common/vector_store -q
276 passed, 140 deselected

ruff check, ruff format --check, and ty check packages are clean.

Checklist

  • I have performed a self-review of my own code
  • I have commented my code
  • My changes generate no new warnings
  • I have added unit tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Further comments

sqlite_autoincrement only affects CREATE TABLE DDL, so records tables of collections created before this change keep plain-rowid behavior until recreated; new collections get AUTOINCREMENT. 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 AUTOINCREMENT is SQLite's sqlite_sequence bookkeeping write, which is negligible here.

claude and others added 4 commits July 1, 2026 23:03
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>
@edwinyyyu edwinyyyu changed the title Fix silent vector loss from SQLite row_id reuse in SQLiteVectorStore Fix silent vector loss from concurrent writes in SQLiteVectorStore Aug 13, 2026
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>
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.

SQLiteVectorStore: row_id reuse + concurrent async writes silently lose a record's vector

2 participants