Skip to content

Return the SQLite errors of IVF deletes, point reads and KNN queries - #357

Open
MayCXC wants to merge 4 commits into
asg017:mainfrom
MayCXC:ivf-delete-knn-errors
Open

MayCXC wants to merge 4 commits into
asg017:mainfrom
MayCXC:ivf-delete-knn-errors

Conversation

@MayCXC

@MayCXC MayCXC commented Oct 23, 2026

Copy link
Copy Markdown

When a step inside an IVF delete, point read or KNN query fails, an interrupt included, the statement carries on and can report success over what it has not done:

db.execute("create virtual table t using vec0(v float[4] indexed by ivf(nlist=2, nprobe=2))")
# 20 inserts
steps = 0
def interrupt_one_step():  # step 5 is inside the point query that finds the row to delete
    global steps
    steps += 1
    return steps == 5
db.set_progress_handler(interrupt_one_step, 1)
db.execute("delete from t where rowid = 5")  # succeeds
db.set_progress_handler(None, 0)
db.execute("select count(*) from t where rowid = 5").fetchone()  # (1,): the row is still there

What passes over a failed step:

  • ivf_get_vector_data() reads a failed step of its rowid-map lookup as a missing row. The point query a DELETE runs to find its row then finds none, and the delete reports success with the row still in the table and the index.
  • ivf_delete() reads a failed lookup the same way, and clears the slot's validity bit, decrements n_vectors and deletes the rowid-map and _ivf_vectors rows without checking any of them, so a delete can report success with the row gone from the table but still in the index.
  • The KNN query stops reading centroids or cells at a failed step and ranks what it has read, returning fewer rows than it should, or none, as if they were all. With oversample, a failed read of a candidate's float32 vector keeps its quantized distance, and an unprepared statement skips the rescoring altogether.

Each of those statements and blob accesses now returns its error, and the delete or query returns it to SQLite.

Measured by interrupting one statement at each VM step on 20 rows (with the IVF define):

runs that end wrong, main with #354 here
delete, no quantizer (untrained or trained) 32 of 123 report success: 21 with the row still in the table and the index, 11 with it in the index alone 0 of 123
delete, int8 35 of 133 report success with the row left behind 0 of 133
KNN, untrained 14 of 117 report success with no rows 0 of 117
KNN, trained, no quantizer or int8 52 of 167 report success with no rows or 11, and 8 fail with "SQL logic error" 0 of 167
KNN, int8 with oversample=4 220 of 327, the same 60 and 160 more reporting success with 20 rows or distances other than the uninterrupted query's 0 of 327

Here every interrupted KNN run reports "interrupted". A delete's failure can still read as vec0's own message, such as "could not open validity blob for chunk 1", when the interrupted step is vec0's chunk bookkeeping rather than the index's; it does on a flat column too, and in those runs the row stays in both the table and the index. And a delete interrupted at its last steps can report "interrupted" with the row already gone, from the table and the index alike, as a plain SQLite table's DELETE does under the same sweep.

Test: test_ivf_delete_interrupted_at_each_step and test_ivf_knn_interrupted_at_each_step in test-ivf.py interrupt each step of a delete and a KNN query, on an untrained and a trained index, without a quantizer and with int8, and the KNN query with an oversample of 4 too. A delete that reports success must have taken its row out of the index, and one that fails must leave the table and the index agreeing; a KNN query must report "interrupted" or return every row and distance of the uninterrupted query. All seven fail on #354's head and pass here. The Python suite passes (with -DSQLITE_VEC_EXPERIMENTAL_IVF_ENABLE=1, 315 passed, 4 skipped; without it, 239 passed, 80 skipped), and test-ivf.py passes under ASan and UBSan. The IVF build prints the same warnings as #354's head, and without the define sqlite-vec-ivf.c is not compiled. sqlite-vec-ivf.c is outside the Makefile's FORMAT_FILES, and the changed lines follow its style.

This builds on #354, and through it #351, and carries their commits: #354 makes the IVF commands, inserts and ivf_is_trained() return their errors, and left these paths because #347 and #350 change them. It merges cleanly with #347 and with #350 (on #349), and the three merged build and pass the suite (325 passed, 4 skipped) with no run of the sweep above reporting success over a failed step; #349's ivf_full_vector_read(), which quantized point reads go through, already returns its step's error. Like #354 it conflicts with #313, in the same places.

Origin. Found in the IVF port to photostructure/sqlite-vec, photostructure#12 (65817f4), whose IVF code returns these errors, and measured here after #354 named them. The sweep interrupts each step as mceachen's test_interrupt_is_reported_as_interrupt does vec0's statements in photostructure/sqlite-vec@7a9ab11, which the port extends to IVF; the change and the tests are written here for asg017's main. No open or closed PR or issue here makes the same change.

ivf_is_trained() kept the trained state per connection once it had read
it, and compute-centroids, set-centroid and clear-centroids set that
cache as they ran. Another connection's command, or a rolled-back one,
left it wrong: a connection that had queried before another trained the
index scanned only the emptied unassigned cells and found no rows, and
one that had trained it, then saw another connection clear it or rolled
its own training back, failed every insert and query with "SQL logic
error". The state is now read from _info each time, so it is always
that of the transaction reading it.
The IVF commands, and the cell writes behind every insert, ignored the
result of most of their statements, so one that failed part way, an
interrupt included, left the command or insert to carry on and report
success over a half-rebuilt index. compute-centroids and clear-centroids
read every vector through ivf_load_all_vectors(), which stopped at a
failed step and returned what it had read as all of them, then deleted
the cells and rebuilt them from that part, and compute-centroids its
_ivf_vectors too. assign-vectors deleted the unassigned cells whatever
happened to the vectors it was moving out of them, and ivf_cell_insert()
wrote a vector, its rowid and its map entry without checking any of
them. Where a failure was reported, it was as "SQL logic error" or "No
vectors". compute-centroids also wrapped its work in a SAVEPOINT that
never opened, since none can while the INSERT running the command is in
progress.

Every statement and blob access in the commands, ivf_exec(),
ivf_is_trained(), ivf_load_all_vectors() and ivf_cell_insert() now
returns its error, and the command or insert returns it to SQLite, which
undoes the statement's writes. The savepoint is gone.
An error the command returns is rolled back with the transaction in
autocommit mode, or inside BEGIN when it is an interrupt or a memory,
I/O or full-disk error. A constraint or other error inside BEGIN leaves
the writes before it, since a single-row INSERT into a virtual table
opens no statement journal.
ivf_delete(), ivf_get_vector_data() and the KNN scan ignored the result
of most of their steps, so a delete or a query whose statement failed
part way, an interrupt included, carried on and reported success over
what it had not done:

- ivf_get_vector_data() read a failed step of its rowid-map lookup as a
  missing row. The point query a DELETE runs to find its row then found
  none, and the delete reported success with the row still in the table
  and the index.
- ivf_delete() read a failed lookup the same way, and cleared the slot's
  validity bit, decremented n_vectors and deleted the rowid-map and
  _ivf_vectors rows without checking any of them, so a delete could
  report success with the row gone from the table but still in the
  index.
- The KNN query stopped reading centroids or cells at a failed step and
  ranked what it had read, returning fewer rows than it should as if
  they were all, or none. With oversample, a failed read of a candidate's
  float32 vector kept its quantized distance.

Each of those statements and blob accesses now returns its error, and
the delete or query returns it to SQLite.

Interrupting one statement at each VM step of a delete of one of 20
rows, before this change 32 of 123 runs reported success and left the
row in the table and the index (21) or in the index alone (11), and 35
of 133 with the int8 quantizer; after it none do. Of a KNN query for all
20 rows on a trained index, 52 of 167 runs reported success with no rows
or 11, and 8 failed with "SQL logic error"; with the int8 quantizer and
an oversample of 4, 160 more of 327 reported success with 20 rows or
distances other than the uninterrupted query's. After it, every
interrupted run reports "interrupted" and the run that finishes returns
all 20 rows and their distances.

test_ivf_delete_interrupted_at_each_step and
test_ivf_knn_interrupted_at_each_step check that for every step, on an
untrained and a trained index, without a quantizer and with int8, and
the KNN test with an oversample of 4 too. A failed delete may still have
taken effect when the interrupt comes after the row is gone, as it can
for any SQLite table, so the delete test asks that the table and the
index agree, and that a delete reporting success removed the row.

This branch has not been deployed

No deployments
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.

1 participant