Repository navigation
sqlitedb: make each write operation one transaction, recovered by replay - #227
Merged
Merged
Conversation
mksqlite issues no BEGIN, so every statement autocommitted on its own. A document that failed part-way through do_add_doc was left half-written (an error, Ctrl-C, a full disk, a poisoned handle). With da83fa3, a poisoned handle mid-operation was reopened and the rest of the document written to whatever file was then at the path. Each write operation (do_add_doc, do_remove_doc, do_add_branch, do_delete_branch) now runs as one transaction (beginWrite / commitWrite, BEGIN IMMEDIATE). It rolls back on any error or early return, and fields_cache is restored with the rollback. Files are deleted only after the commit (deleteAfterCommit). This covers delete_original in do_add_doc and the reclaimed cached copies in reclaim_unreferenced_doc. A rollback must not leave the only copy of a file unreferenced, or rows pointing at deleted files. A READONLY inside an operation is still recovered by reopening, as da83fa3 intended, but the operation's statements are now journaled with what each returned and how many rows it changed. After the reopen, recoverTransaction replays them, checks each one, and then runs the failed statement or commits. A replay that differs is refused, DID:SQLITEDB:ReplayDiverged is attached as the cause, and nothing is written. If the database file no longer exists, it is not reopened, since that would create a new, empty database there. Just before COMMIT, commitWrite checks that the path still names the file the connection opened (unix dev:inode via Java). SQLite notices a replaced file only when a write transaction starts. A COMMIT after a later swap "succeeds" into a file that no longer has a name, and the write is lost without an error. add_docs(..., 'Validate', false) now sets journal_mode=MEMORY instead of OFF: with OFF, SQLite's ROLLBACK "behaves in an undefined way". This mirrors DID-python's SQLiteDB (VH-Lab/DID-python#93, #94). New tests are in tests/+did/+unittest/TestWriteTransactions.m, with a fault-injecting subclass in did.test.helper.WriteFaultSqlitedb. Not run locally: no MATLAB was available. Per AGENTS.md this needs the CI run and a human review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Dn4gCNCU2jUGdmfPVCAGK
Contributor
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #227 +/- ##
==========================================
+ Coverage 65.63% 65.71% +0.07%
==========================================
Files 66 66
Lines 4391 4579 +188
==========================================
+ Hits 2882 3009 +127
- Misses 1509 1570 +61 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Replace the database file after do_add_doc's reads but before its first INSERT. That is where SQLite checks for SQLITE_READONLY_DBMOVED, so the INSERT fails on the poisoned handle. It then goes through run_sql_noOpen's write-operation hook and recoverTransaction's statement path, which replays the journaled reads and re-runs the INSERT on the reopened connection. No other test reached these lines (codecov/patch was 65.50% against a 65.63% target). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Dn4gCNCU2jUGdmfPVCAGK
stevewds
approved these changes
Oct 24, 2026
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.
The problem
sqlitedb.mnever starts a transaction (there is noBEGIN/COMMIT/ROLLBACKanywhere insqlitedb.mordatabase.m), and mksqlite doesn't start one either. So every statement commits on its own, and a document that fails part-way throughdo_add_docis left half-written. That covers an error, Ctrl-C, a full disk or a poisoned handle. The upfront validation indo_add_doccatches bad input, but not a failure after the inserts have started.da83fa3 made one case worse. On a poisoned handle (
SQLITE_READONLY_DBMOVED), it reopens and carries on, writing the rest of the document to whatever file is at the path by then.The change
Each write operation is one transaction.
do_add_doc,do_remove_doc,do_add_branchanddo_delete_branchcallbeginWrite(BEGIN IMMEDIATE) andcommitWrite. The handlebeginWritereturns is anonCleanup, so any error or early return rolls the operation back.fields_cacheis restored with the rollback; otherwise a field added in a rolled-back transaction would stay cached, and the next document'sdoc_datawould point at a field that doesn't exist.Files are deleted only after the commit (
deleteAfterCommit). This coversdelete_originalindo_add_docand the reclaimed cached copies inreclaim_unreferenced_doc. A rollback must not leave the only copy of a file unreferenced, or leave rows pointing at files already deleted.da83fa3's reopen still works inside a transaction, by replaying it:
SELECT changes());recoverTransactionrolls back the dead transaction first. That releases its locks, and keeps its rollback journal from being played back into the new file.Only SQL is replayed. Files already copied stay where they are, which is why the operation isn't simply run again.
doc_idx, a different file is at the path, or a statement fails. Nothing is written, and the original error is raised withDID:SQLITEDB:ReplayDivergedas its cause.DID:SQLITEDB:DatabaseFileGoneas its cause.Replaced-file check before COMMIT. SQLite notices a replaced file only when a transaction's first write opens its journal. If a sync client, a restore or a copy-then-rename swaps the file after that, COMMIT "succeeds" into the old file, which no longer has a name, and the write is lost without an error.
commitWrite(andbeginWrite) compare the path's unixdev:inode(read through Java) with the one recorded at open. If they differ, the transaction is recovered as above. On Windows, or with-nojvm, there's no inode, so the check is skipped.add_docs(..., 'Validate', false)usesjournal_mode=MEMORYinstead ofOFF. WithOFF, SQLite documents thatROLLBACK"behaves in an undefined way".MEMORYstill writes no journal file, but rollback works.This mirrors DID-python's
SQLiteDB(VH-Lab/DID-python#93 and #94).Behaviour changes to review
customFileHandlerdownloads, rather than per statement. Another process writing the same database waits, up to the 30 sbusy_timeout, for one document's ingest. DID-python behaves the same way.delete_originaldeletes the source after the document commits, not during the file loop.DID:SQLITEDB:DatabaseFileGone, instead of a write into a new, empty file.SELECT changes()per write statement inside an operation. In exchange there's one commit per document instead of one per statement, so bulk adds should not be slower; I haven't measured it.Tests
tests/+did/+unittest/TestWriteTransactions.m(8 tests) usesdid.test.helper.WriteFaultSqlitedb, a subclass that can refuse a commit, or run an action (replace or delete the file) just before the first INSERT into a chosen table:docsorbranch_docsrows;fields_cacheis restored, so nodoc_datarow ends up pointing at a missing field;ReplayDiverged.The file-replacing tests are skipped on Windows.
Follow-up in DID-python
Once this merges, DID-python's "Bridge coverage vs DID-matlab" check will report drift on
sqlitedb.manddatabase.muntil it's synced. The Python side already has the transactions, replay and replaced-file check. It still needs:delete_originalsources before commit today);The
journal_modechange doesn't apply, since DID-python never changes it.Generated by Claude Code