Repository navigation
OPFSAnyContextVFS: publish writes where SQLite ends them - #363
rhashimoto merged 5 commits into
Conversation
Changes pending in an open FileSystemWritableFileStream are not visible to other contexts until the stream is closed. OPFSAnyContextVFS closes it in xSync, but SQLite does not always call xSync after its last change: the truncation at the end of a VACUUM is one such case. The lock was then released with the truncation unpublished, and another context could read the file at its old size. On Firefox, that reader's File then fails with an AbortError once the writer closes its stream for its own next read, which SQLite reports as SQLITE_IOERR_READ. xUnlock now closes a pending writable before the lock is released. The new test truncates in one context with no xSync after it, unlocks, and reads the size from a second context: 8192 instead of 4096 without this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
I think this should not happen. I asked the SQLite folks. |
|
Thanks for checking it with the CLI — your trace shows the same sequence. It looks deliberate: in SQLite 3.53.0, the version wa-sqlite builds,
Growing the file, by contrast, happens in Whatever the answer on the forum, I don't think the VFS can count on an I've also merged master into the branch. It predated the JSPI detection fix, so the PR's test now runs on jspi too, and fails there on master the same way ( Happy to add the |
You're right, and a response on the forum by someone knowledgeable concurs. This appears to be intentional.
No, not necessary. The only reason to use it is to get better write performance, but (1) this is not the VFS to use for better write performance, and (2) it's not going to get any faster if we just make the same calls in unlock anyway. |
|
Fair enough on the test. One nuance on (1), though: |
rhashimoto
left a comment
There was a problem hiding this comment.
I have apparently encountered this issue before but I don't remember it at all! It is handled in IDBMirrorVFS here.
The best place to close the writable for VACUUM truncate is in jFileControl() for opcode SQLITE_FCNTL_COMMIT_PHASETWO, which is sent after truncation. But that doesn't handle database changes that don't end with a commit (e.g. ROLLBACK) so we also need to close for opcode SQLITE_FCNTL_SYNC (which arrives regardless of any PRAGMA synchronous setting). Every commit will send both opcodes, which would write the entire database twice in the VACUUM truncate scenario, so skip the SQLITE_FCNTL_SYNC close if we have seen SQLITE_FCNTL_OVERWRITE. That's what IDBMirrorVFS does.
Using jUnlock() mostly works, but affects durability with PRAGMA locking_mode=EXCLUSIVE because the database is not unlocked when a transaction ends. One of the main lessons I had to learn in writing VFSs (more than once!) is not to use locking to infer transaction boundaries.
IDBMirrorVFS only needs to sync main database files to storage, but OPFSAnyContextVFS has to handle writables for all its files. SQLITE_FCNTL_SYNC is only delivered for database files, so closing is still needed injSync() for other files.
Summary:
- Add a File boolean to track overwrite state.
- Implement
jFileControl():- SQLITE_FCNTL_OVERWRITE - Set overwrite state.
- SQLITE_FCNTL_SYNC - Close writable if not overwriting. Clear overwrite state.
- SQLITE_FCNTL_COMMIT_PHASETWO - Close writable.
- Modify
jSync()to close writable only if not a database file (or not overwriting).
There are a couple painful cases, but both have workarounds:
-
With
PRAGMA synchronous=FULL, which is the default, SQLite syncs the journal file, modifies the journal header, and syncs the journal file again. This double sync in this VFS will write the entire journal file twice. We could detect this and ignore the first sync, but users can easily work around it withPRAGMA synchronous=NORMALwhich is believed to be safe on modern filesystems. -
Using
PRAGMA auto_vacuum=FULLwill overwrite the entire database twice on any transaction that reduces the database page count, once for the transaction data and once for the truncation, because this doesn't send the overwrite opcode. The workaround here is don't do that.
|
Thanks for pointing me to IDBMirrorVFS, and for the lesson about locks and transaction boundaries. Before reworking the PR I tried your design next to the current one. I ran both in the test runner with two workers, on Chromium and Firefox and on both builds. I'd like your opinion on one point first. You're right about There is one path your design doesn't cover: I/O errors. If a cache spill write fails, the pager goes to The writable is then closed by that context's next read, with no lock held. Closing it replaces the whole file, so it discards anything another context committed in between. I tested this with a write failure injected in the VFS. Context A fails. Context B recovers the hot journal and commits a row. Then A reads, and B's row is gone. That happens on master and with your design. Also closing in So I'd propose your design as you laid it out, plus the close in Two smaller points:
Looking at IDBMirrorVFS for the same kind of problem, it seems to have one of its own, when the IndexedDB transaction in If that sounds right to you, I'll rework this PR along those lines and add a test for the error path. |
I was considering leaving that in
That's a good observation. As you can probably guess, optimizing write performance for this VFS isn't a priority for me so that isn't something I particularly want. However, it isn't going to add a lot of complexity so it isn't something I object to either. If you want it, I would be happy to review and include it.
We can keep it in
Yes, please, if you're up for that. Thanks! |
Close the writable on SQLITE_FCNTL_SYNC, unless a VACUUM is overwriting the database, and on SQLITE_FCNTL_COMMIT_PHASETWO, as IDBMirrorVFS does; locking_mode=EXCLUSIVE keeps the lock past a commit. The close in jUnlock stays as a backstop: after an I/O error SQLite releases the lock without either. jFileSize answers from the open writable, so a VACUUM copies the database once instead of twice.
…lock-publishes-truncate
|
Reworked along those lines: your design, with the close in The new I'll open the IDBMirrorVFS PR separately. |
|
Thanks for helping to figure all this out! LGTM. |
) * Close OPFSAnyContextVFS's writable before releasing a lock. Changes pending in an open FileSystemWritableFileStream are not visible to other contexts until the stream is closed. OPFSAnyContextVFS closes it in xSync, but SQLite does not always call xSync after its last change: the truncation at the end of a VACUUM is one such case. The lock was then released with the truncation unpublished, and another context could read the file at its old size. On Firefox, that reader's File then fails with an AbortError once the writer closes its stream for its own next read, which SQLite reports as SQLITE_IOERR_READ. xUnlock now closes a pending writable before the lock is released. The new test truncates in one context with no xSync after it, unlocks, and reads the size from a second context: 8192 instead of 4096 without this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Publish OPFSAnyContextVFS's writes where SQLite ends them. Close the writable on SQLITE_FCNTL_SYNC, unless a VACUUM is overwriting the database, and on SQLITE_FCNTL_COMMIT_PHASETWO, as IDBMirrorVFS does; locking_mode=EXCLUSIVE keeps the lock past a commit. The close in jUnlock stays as a backstop: after an I/O error SQLite releases the lock without either. jFileSize answers from the open writable, so a VACUUM copies the database once instead of twice. * Test when OPFSAnyContextVFS publishes its writes. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
OPFSAnyContextVFSkeeps writes and truncations in aFileSystemWritableFileStream, and nothing it holds is visible to another context until it is closed. OnlyxSyncand the context's own next read close it today, and SQLite does not always sync after its last change.What happens today
VACUUM. The truncation that ends aVACUUMcomes after the transaction's lastxSync, while the lock is still held. A context that takes the lock next gets the file at its old size. On Firefox itsFilethen fails withAbortErroronce the writer closes its stream, whichxReadreturns asSQLITE_IOERR_READ.locking_mode=EXCLUSIVEwithsynchronous=OFF. There is noxSyncand no unlock, so a commit stays in the open stream and is lost if the context goes away without closing the database.The change
jFileControlcloses the stream onSQLITE_FCNTL_SYNC, which SQLite sends at every commit whateverPRAGMA synchronoussays, and onSQLITE_FCNTL_COMMIT_PHASETWO, which follows the commit's truncation. AfterSQLITE_FCNTL_OVERWRITE, i.e. during aVACUUM, it waits for the latter, asIDBMirrorVFSdoes.jSyncskips its close in that case and still closes for every other file.jUnlockcloses any stream still open before releasing the lock, as a backstop for the error paths. A failed close is reported asSQLITE_IOERR_UNLOCK, and the lock is released either way.jFileSizeanswers from the open stream instead of closing it.pager_truncate()asks for the size just before truncating, so aVACUUMnow copies the database once instead of twice.Test
test/vfs_publication.jshas a worker of its own whose VFS can make database writes fail, and is wired intoOPFSAnyContextVFS.test.js:locking_mode=EXCLUSIVEandsynchronous=OFF, A commits a row and its worker is terminated; B must see the row;VACUUMopens one stream on the database, and the file has its truncated size right after.test/vfs_xUnlock.jscalls the VFS directly: A writes, syncs, truncates with noxSyncafter it and unlocks, and B must see the truncated size.With master's VFS, all four fail on asyncify and jspi (
Expected 200 to be 201,Expected 2 to be 1,Expected 110592 to be 61440,Expected 8192 to equal 4096). With this change, the file's 120 tests pass, 3 runs of 3, and also on Firefox outside the suite. The whole suite: 6142 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.