Repository navigation
Conversation
Every top level query and exec ends with syncToFs(), unless the database is in a transaction. transaction() ran its terminal COMMIT while the in-transaction flag was still set, and cleared the flag only afterwards, so a resolved transaction() had not synced: a committed transaction reached the filesystem (for example IndexedDB with idb://) only at some later query outside a transaction, and was lost if the page reloaded or the worker stopped before that. End the transaction with one explicit syncToFs() after the flag is cleared, on the commit path and on the rollback paths (a throwing callback, tx.rollback(), a COMMIT that reports a rollback). A failing sync does not replace the error that ended the transaction. Related: electric-sql#1065, electric-sql#1066 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… worker
BasePGlite sends every protocol message of a query with
execProtocolStream(message, { syncToFs: false }) and calls syncToFs()
once at the end of the query. PGliteWorker.execProtocolStream() sent
only the message to the worker, and the worker called
db.execProtocolStream(message) without options, so the default
syncToFs: true applied: every protocol message (Parse, Describe, Bind,
Execute, Sync) ran a full filesystem sync. With idb:// that is a full
IDBFS syncfs() per message, about 15 ms each in Chromium, and a read
transaction of seven parameterised queries took about 740 ms instead of
about 18 ms on a direct instance.
Send the options that a message can carry (syncToFs and throwOnError) in
execProtocol, execProtocolStream and execProtocolRaw. A callback cannot
be sent with postMessage, so onNotice is called on the tab with the
notices of the result, and execProtocolRawStream, which failed with a
DataCloneError because of its onRawData callback, collects the chunks in
the worker and the tab calls onRawData with each of them.
The tests run a real leader worker in a Node.js worker thread with a
filesystem that counts syncToFs() calls and keeps a snapshot of PGDATA
as of the last sync.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
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.
Problem
Two bugs of the same area, in
@electric-sql/pglite0.5.8 to 0.5.15, with a filesystem that has something to sync (idb://is the one that matters).1.
PGliteWorkerdrops the options ofexecProtocol*, so every protocol message syncs the filesystem.BasePGlitesends each protocol message of a query withsyncToFs: falseand syncs once at the end of the query.PGliteWorker.execProtocolStream(message)sends only the message to the worker, and the worker callsdb.execProtocolStream(message)without options, so the defaultsyncToFs: trueapplies. Each Parse, Describe, Bind, Execute and Sync runs a fullsyncToFs(), which is an EmscriptenFS.syncfs(false)withidb://.Measured in Chromium with
idb://:PGlitePGliteWorker(before)syncToFs: falseand syncs once after a Sync outside a transaction (the behaviour this PR gives)A parameterised query is six messages. The tests in this PR count the calls that reach the filesystem: a parameterised
query()through the worker made 7syncToFs()calls (6 messages + the one at the end of the query) against 1 on a direct instance, and the 7 queries of a transaction made 42 between the first and the last one.The same dropped options affect
onNoticeandthrowOnError, which are silently ignored through the worker, andexecProtocolRawStream(message, { onRawData })cannot work at all: the callback is part of the RPC message andpostMessagethrows aDataCloneError.2. A resolved
transaction()has not synced (already reported in #1065).transaction()runsCOMMITthrough#runExecwhile#inTransactionis stilltrue, so theif (!this.#inTransaction) await this.syncToFs()at the end of#runExecis skipped, and the flag is cleared only afterwards. Nothing syncs after the commit. The committed transaction reaches the filesystem at the next query outside a transaction, and is lost if the page reloads or the worker stops before that. This is also true of a direct (non-worker) instance, and ofROLLBACK(no sync after it either).Root cause (at
bac163e)packages/pglite/src/base.tsL165-L175:#execProtocolNoSynccallsthis.execProtocolStream(message, { ...options, syncToFs: false }), and#runQuery/#runExeccallsyncToFs()once at the end (L325-L327, L374-L376).packages/pglite/src/worker/index.tsL335-L355, L368-L373: the tab-sideexecProtocolRaw,execProtocolandexecProtocolStreamaccept onlymessage;execProtocolRawStreamsends itsoptions, functions included. On the worker side, L658-L689 calldb.execProtocol*(message)without options.packages/pglite/src/pglite.tsL1033-L1041:execProtocolRawdefaults tosyncToFs = true, which is what the worker then gets.packages/pglite/src/base.tsL514-L538: theCOMMITat L518 runs with the flag set, the flag is cleared at L528, andtransaction()returns with no sync.Through the worker, bug 1 hides bug 2, because every message syncs. Fixing only bug 1 turns bug 2 into data loss (see below).
Fix
Commit 1,
fix(pglite): sync to the filesystem when a transaction ends(base.ts): after theCOMMIT, the flag is cleared andtransaction()awaits onesyncToFs()before it resolves. A transaction that ends with a rollback (throwing callback,tx.rollback(), aCOMMITthat reports a rollback) also ends with one sync, which does not replace the error that ended the transaction. The flag is also cleared if theROLLBACKitself fails. A transaction costs one sync for its end, plus the sync after itsBEGINthat already existed; the statements in between do not sync.Commit 2,
fix(pglite): forward the exec protocol options of PGliteWorker to the worker(worker/index.ts):execProtocolRaw,execProtocolandexecProtocolStreamsendsyncToFsandthrowOnErrorto the worker, which passes them on. Only these two are sent, as the others are functions andpostMessagecannot clone them.onNoticeruns on the tab, with the notices among the messages the worker returns. It runs when the call returns, not while the statement runs.execProtocolRawStreamtakesExecProtocolOptionsStreamlike the interface says. The worker collects the chunks (copies them, they are views of the WASM heap) and returns them, and the tab callsonRawDatawith each one. It was aDataCloneErrorbefore, so nothing that worked changes.Not changed: a
DatabaseErrorstill reaches the tab as a plainError, and withthrowOnError: falseit is in the returned messages with its text only, as the structured clone drops the class.Tests
packages/pglite/tests/transaction-sync.test.ts(direct instance,MemoryFSsubclass that countssyncToFs()calls and keeps a snapshot of PGDATA taken at each sync, as IDBFS would persist it):COMMIT;loadDataDirand read the row.packages/pglite/tests/worker-sync.test.tswithtests/fixtures/sync-worker-thread.js: a real leader worker (worker()fromdist/worker/index.js) in a Node.jsworker_threadsthread,PGliteWorkeron the main thread, the RPC overBroadcastChannelandnavigator.locksas in a browser. The same checks through the worker, plus:syncToFsoption ofexecProtocol,execProtocolStreamandexecProtocolRawreaches the filesystem layer (falsegives no call, the default gives one);throwOnError,onNotice(forexecProtocol,execProtocolStreamandexec) andexecProtocolRawStreamwork through the worker;await db.transaction(...)anddb.close()(which terminates the worker), the snapshot holds the row.They fail without the fix and pass with it (
pnpm vitest run <file>, Node 24, results atbac163e):transaction-sync.test.ts(commit 1,base.tsfrombac163e)COMMIT(0 instead of 1), no sync after a rollback (1 instead of 2), the committed row is not in the snapshotworker-sync.test.ts(commit 2,worker/index.tsfrom before it)syncToFs: falseignored (3 calls),throwOnErrorignored,onNoticenever called,DataCloneErrorforonRawDatapnpm testof the package (test:basicandtest:node): 68 files, 360 tests passed, 1 skipped, type errors none.pnpm lint,prettier --check ./src ./testsandpnpm typecheckare clean. A changeset (patch) is added for each commit.Durability
Commit 2 alone would turn bug 2 into data loss through the worker: with the options forwarded, nothing syncs after the
COMMIT, and the worker test "has persisted a transaction when it resolves, and the worker is gone" fails on that combination (the committed row is not in the snapshot taken when the worker is terminated right aftertransaction()resolved). With commit 1 as well it passes. This is why the two commits belong together. The same test on a direct instance fails onbac163e, so it is not only a worker problem.Related
COMMITunder the in-transaction flag and syncs explicitly after it, so there is exactly one sync after the commit.syncToFs()can resolve before a queued sync has started. Independent of this change, and complementary, as the sync that ends a transaction relies on it.worker/index.ts(the constructor and theinitmessage, not the methods touched here).Not covered
The tests use a filesystem double in Node.js, not IDBFS in a browser. The 15.7 ms / 742 ms / 50 ms figures are from Chromium with
idb://and are not reproduced by these tests, which count calls. The browser suites (pnpm test:web) were not run.🤖 Generated with Claude Code