Repository navigation
IDBMirrorVFS: recover from a commit whose IndexedDB transaction aborts - #371
Conversation
The view already includes a transaction when its IndexedDB commit aborts. A later commit then carried the failed rows (synchronous=full) or was stored on top of a state that never was (synchronous=normal), including commits already queued behind the aborted one. Such a view is no longer published: the commits built on it are refused or dropped, the view is reloaded from IndexedDB when SQLite next validates its cache, and its journal is not played back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In exclusive locking mode the lock is never released, so a connection with synchronous=normal stayed failed until reopened after an abort. SQLite discards its cache after the refused commit's error, so the view can be reloaded there, except when SQLite rolls the transaction back through a journal written on the aborted view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
I added a commit, 3367cb6, which removes one of the costs listed in the description: in exclusive locking mode with In that mode the lock is never released, so the view could not be reloaded at There is one exception: a refused transaction that had spilled to a rollback journal. After the error, SQLite rolls it back through that journal, which writes pages of the aborted view over the reloaded one. The next commit then stored them: the aborted rows, or a corrupt database, in every run. So the reload is skipped while the database has a journal in the VFS, and that case still needs a reopen. The exclusive |
There was a problem hiding this comment.
Thanks for the PR!
The approach looks fine. It took me a while to figure out the new #commitTx() code so I suggested some reorganization.
The code excerpts below came through in a confusing order. My comments should make more sense if you read them in the file view.
The commit fence is now awaited inside an async executor instead of gating a nested write function. Also declare commitsPending with the other File properties and note a faster way to load the database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review! All three are in 874303e: One thing to be aware of with the async executor: an exception inside it no longer reaches
Nothing in that body should throw today, so I left it as you wrote it. If you'd rather close the gap, wrapping the body in a |
You're right, that's totally valid. I was tempted to weasel out of another round of changes, but yes, please wrap in a try/catch. |
The write body now runs after an await, outside any request event handler, so an exception there no longer aborts the IndexedDB transaction: the requests already issued auto-commit and the commit resolves. Abort explicitly so a failed write is never stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Done 😉 |
|
Thanks! LGTM. |
As offered in #363. This one turned out trickier than I expected 😅
What happens
#commitTxadds a transaction to the connection's view (#acceptTx,#setView) before its IndexedDB transaction commits. When that transaction aborts, on a quota error for instance, nothing undoes it:synchronous=full, SQLite getsSQLITE_IOERR, buttxActiveis kept. The connection's next commit stores the failed commit's rows along with its own.synchronous=normal, the commit has already returnedSQLITE_OKwhen the abort arrives. The connection goes on from a view that was never stored, and its next commit writes only its own pages on top of the stored database. A new connection then failsintegrity_check(Page 28: never used), or cannot read the database at all (database disk image is malformed) when the aborted transaction was larger than the page cache.locking_mode=exclusive, commits already queued behind the aborted one are stored the same way.The change
When a commit's IndexedDB transaction aborts, the file's
AbortControlleris aborted.Filealready declared that field, but it was never created. From then on:#commitTxrefuses the transaction with an error, so SQLite getsSQLITE_IOERR. A commit already queued behind the aborted one checks for the abort in its first IndexedDB request and aborts itself.SHAREDlock, immediately in thesynchronous=fullerror path, and when#commitTxrefuses a transaction, unless SQLite has a rollback journal for it. Loading moves out ofjOpeninto#loadFileso that all three can use it.SQLITE_BUSYatRESERVED, so it starts over on the reloaded view.Why this way
Writes never fail; the commit is refused instead. Failing every call after the abort, as
OPFSPermutedVFSdoes, is not enough here. When a batch-atomic write fails with anSQLITE_IOERRcode, SQLite retries the commit with a rollback journal (pager.c). That journal stays in the VFS, and the next open plays it back over the stored database: with that approach, reopening after asynchronous=normalabort failedintegrity_checkevery time. Here writes only reachtxActive, so they can always succeed. The refusal comes atSQLITE_FCNTL_SYNC, outside the batch, and SQLite writes no journal.The view is reloaded rather than the connection failed. Once nothing built on the aborted view can be stored, failing the connection until it is reopened would be safe too. But every application would have to reopen after a quota error. Reloading is safe at two points:
SHARED, where SQLite checks its cache against the file's change counter;synchronous=fullrecovers at once, even in exclusive mode, and why exclusivesynchronous=normalrecovers after the commit that is refused.There is one exception to the second point. If the refused transaction had spilled to a rollback journal, SQLite rolls it back through that journal after the error. That writes pages of the aborted view over the reloaded one, and the next commit stored them: the aborted rows, or a corrupt database, in every run. So the view is not reloaded when the database has a journal in the VFS.
SQLITE_BUSYatRESERVED. Withsynchronous=normal, the abort usually becomes known only when the next write transaction takesRESERVED, because reading thetxstore waits for the aborted IndexedDB transaction. By then SQLite has already validated its cache against the aborted view, so that transaction cannot go on. The check just above it, for a view that is out of date, already returnsSQLITE_BUSYfor this reason. SQLite then releases its lock, and with a busy timeout it retries fromSHARED(btree.c), which reloads the view. With a busy timeout the application never sees the abort; without one it getsSQLITE_BUSYonce. Refusing only at commit time gaveSQLITE_IOERRin both cases.A gate request only while another commit is pending.
IDBTransaction.abort()throws oncecommit()has been called, so the aborted transaction cannot cancel the ones queued after it. Instead, a queued transaction issues a request first. That request runs only after the earlier transactions have finished, so its callback can see the abort and abort its own transaction. The gate is used only when another commit is still pending, which never happens withsynchronous=full:synchronous=fullcommits in exclusive mode;synchronous=normalcommits 1.7 to 2 times slower on Chromium.The journal is removed on close. In exclusive locking mode with
synchronous=normal, a transaction larger than the page cache writes a journal before its commit is refused. Played back at the next open, that journal stores the aborted rows.What it costs
synchronous=normal, the aborted commit is lost. In exclusive locking mode, so are the commits that returned before the connection learned of the abort; otherwise none can, since the next write transaction waits for the aborted one atRESERVED. The stored database stays as it was before them.synchronous=normal, the lock is never released, so the view is reloaded only when a commit is refused. That commit fails withSQLITE_IOERR, and until then reads still show the lost commit. If the refused transaction was larger than the page cache, commits fail until the database is reopened.master, within run-to-run variation: 9 interleaved runs, Chromium and Firefox, asyncify and jspi,fullandnormal, normal and exclusive locking mode.Test
test/vfs_commit_abort.js, with a worker of its own, is wired intotest/IDBMirrorVFS.test.js. The worker replacesIDBTransaction.prototype.commitso that, once armed, the next read-write transaction aborts, optionally after being kept alive for 300 ms with requests. The tests:synchronoussetting and each locking mode. The aborted rows never reach the store, and the connection's next insert works. In exclusivenormal, it comes after the one commit that fails, which must stay invisible. A new connection counts 201 rows and passesintegrity_check.normal) is not stored.Before closing, the worker lets pending commits finish. Closing first makes the commit's broadcast throw on the closed channel (
InvalidStateError). That also happens onmaster, and this PR does not address it.On
master, all six tests fail on asyncify and jspi, for example:Expected 204 to be 201.withsynchronous=full;integrity_checkresult withsynchronous=normal;Expected 11 to be 500.for the queued commit;Expected 3 to be 0.for the journal.With this change, the file's 190 tests pass, 3 runs of 3, on Chromium and on Firefox (Playwright).
Each part of the change is needed by a test. Removing one at a time:
SQLITE_BUSYatRESERVEDnormal/normallocking: the next insert getsSQLITE_IOERRSHAREDnormal/normallockingfullerror pathfull/ exclusive locking: the connection stays failednormal/ exclusive locking: the connection stays failed#commitTxnormal, the queued commit and the journal testsThe whole suite on this branch: 6274 passed, 0 failed.
Checklist
non-exclusive, royalty-free, irrevocable copyright license to reproduce, prepare
derivative works of, publicly display, sublicense, and distribute this
Contribution and such derivative works.
Contribution contains no content requiring a license from any third party.