Skip to content

sqlitedb: recover a poisoned handle inside a write operation by replaying it - #93

Merged
stevevanhooser merged 1 commit into
mainfrom
claude/port-sqlite-reopen-recovery
Oct 24, 2026
Merged

stevevanhooser merged 1 commit into
mainfrom
claude/port-sqlite-reopen-recovery

Conversation

@stevevanhooser

Copy link
Copy Markdown
Contributor

Closes the last deviation #90 recorded for DID-matlab da83fa3: "a genuinely poisoned handle (DBMOVED) is not recovered at COMMIT".

Why it isn't a one-line port

MATLAB reopens the connection before retrying a READONLY statement, because a poisoned handle (SQLITE_READONLY_DBMOVED) clears on reopen and never by waiting. mksqlite commits every statement on its own, so the failed statement is all MATLAB has to re-run. SQLiteDB runs each add, remove or branch operation as one transaction, and a poisoned handle loses the transaction's earlier statements with it.

Re-running the whole operation isn't safe: _do_add_doc copies ingested files, and with delete_original deletes the source, before it commits, so a second run would fail on the missing source. What is safe to re-run is the operation's SQL.

The change

  • Recording. The four write operations (_do_add_branch, _do_add_doc, _do_remove_doc, _do_delete_branch) now run through a recording cursor (_JournalCursor, under @_journaled). For each statement it records:
    • the SQL and parameters;
    • the rowcount;
    • an INSERT's lastrowid, since later statements are built from it;
    • every row the operation read.
  • Recovery. On a READONLY at a statement or at COMMIT, _recover_transaction:
    1. rolls back the dead transaction, so its locks don't hold other writers up;
    2. waits on MATLAB's backoff schedule;
    3. reopens the connection;
    4. replays the recorded statements, checking each one against what it did the first time;
    5. then runs the failed statement, or commits.
  • Divergence. A replay that differs is refused: for example another writer took the next doc_idx, a different file is now at the path, or a statement fails on replay. Nothing is written, and the caller gets the original error. A divergence isn't retried.
  • Unchanged. A BUSY at COMMIT is still waited out on the same connection, since the transaction is intact. do_run_sql_query keeps its own retry for statements outside a write operation.
  • Rollback without a connection. If every reopen fails, the operations' rollback now tolerates there being no connection (_rollback()), so the caller sees the real error rather than an AttributeError.

Found while porting

  • DBMOVED is reported at a transaction's first write, not at COMMIT. The tests reproduce a real one by replacing the database file under the open connection. A COMMIT-only recovery would have missed the case that actually happens.
  • A file swapped after the first write isn't detected at all. COMMIT "succeeds" into the unlinked old file and the data is lost. SQLite can't see it, so no retry can, in either language. It's recorded in the bridge entry.

Cost

Adding 1,500 documents takes 6.4 s, against 6.1 s on main, about 4% slower. The recorded statements are kept for one operation and dropped at commit.

Tests (tests/test_sqlitedb_transient_errors.py, 13 new)

  • Real DBMOVED (file replaced under the handle), recovered for add_docs, add_branch and remove_docs. The recording ends with the operation, so the next one replays only its own statements.
  • Divergence refused:
    • an older file now at the path;
    • another writer inserting during the backoff;
    • a replayed statement failing.
  • READONLY at COMMIT, using a stand-in connection whose commit always fails, as a poisoned one does:
    • the transaction is replayed and committed;
    • a file already ingested with delete_original isn't needed again, and the document's file still opens afterwards;
    • BUSY is still waited out, not replayed.
  • Every reopen failing: the original error reaches the caller and nothing is written.
  • Without the change: 10 of these fail.

Locally: black --check, ruff check (0.16.5), pytest (794 passed), and check_bridge_coverage.py (0 problems).


Generated by Claude Code

…ying it

Closes the last deviation recorded for DID-matlab da83fa3. MATLAB
reopens the connection before retrying a READONLY statement, because a
poisoned handle (SQLITE_READONLY_DBMOVED) is cleared by reopening and
never by waiting. mksqlite autocommits, so there the failed statement is
all there is to re-run. In SQLiteDB it is part of a transaction whose
earlier statements die with the handle.

The four write operations (_do_add_branch, _do_add_doc, _do_remove_doc,
_do_delete_branch) now run through a journaling cursor. It records each
statement's SQL, parameters, rowcount, an INSERT's lastrowid and every
row read. On a READONLY at a statement or at COMMIT, _recover_transaction
rolls back the dead transaction, waits on the same backoff schedule,
reopens and replays the journal. Each statement is checked against what
it did the first time before the failed statement runs or the
transaction commits. Only SQL is replayed: files already copied, or
deleted with delete_original, are not needed again. A replay that differs
(another writer took the next doc_idx, a different file is at the path,
a statement fails) is refused, nothing is written, and the original
error is raised.

The tests reproduce a real DBMOVED by replacing the database file under
the open connection. Doing that showed SQLite reports it at the first
write of a transaction rather than at COMMIT, so a COMMIT-only recovery
would have missed the real case. It also showed that a swap after the
first write is not detected at all, which no retry can see.

Adding documents is about 4% slower (1,500 documents: 6.1 s -> 6.4 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Dn4gCNCU2jUGdmfPVCAGK
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.

2 participants