Conversation
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 bug
An rpc is posted to
pglite-tab:${tabId}, which only the leader listens on, and only after thetab-herehandshake.BroadcastChanneldoes not buffer, so a call posted while the tab isdisconnected — between
leader-hereand the new leader'sconnected— reaches nobody. Nothingreplies to it, and the
leader-changelistener it installs cannot reject it either: the leaderchange that disconnected the tab was dispatched before that listener existed.
_runExclusiveQueryand_runExclusiveTransactiondo exactly that from theirfinally:A statement merely queued on the lock is fine — its
_acquireTransactionLockrejects before thetry. A statement that held the lock is not: its own rpc rejects, thefinallythen awaits arelease nobody will answer, and that await swallows the rejection. The caller hangs forever with no
error.
query()andexec()are themselves wrapped in_runExclusiveTransaction, so any statementcan hit this, not just an explicit
transaction().Both locks are affected, and for a plain
query()it is the query lock that strands first:query()wraps_runExclusiveTransactionaround#runQuery, which wraps_runExclusiveQueryaround the protocol calls, so the inner
_releaseQueryLockis the release that goes unanswered.A tab promoted to leader escapes by luck: its worker posts
leader-now, dispatching a secondleader-changethat rejects the stuck release. Only a tab that stays a follower hangs, which takesthree instances to reproduce — presumably why this has gone unnoticed.
The fix
#rpcreports the indeterminate state rather than posting into a channel nobody is listening on:Callers already handle
LeaderChangedErrorfrom the queued case. Paths behind_checkReady()areunaffected in the normal case — they wait for a connection before their first rpc.
Test
tests/targets/web/leader-change.test.web.js, picked up bypnpm test:weband CI through theexisting glob. No second tab needed:
navigator.locksis origin scoped and shared with dedicatedworkers, so several
PGliteWorkers sharing anidcontend for one election lock. The test starts aSELECT pg_sleep(2)on the third instance — the one that stays a follower, since locks are grantedin request order — closes the leader, and asserts the statement settles. Reports
hungonmain,rejects with
Leader changed, pending operation in indeterminate statewith the fix.The warm-up
SELECT 1before that statement is load bearing. The leader's PGlite boots lazily, anduntil it is ready every rpc sits queued on
_acquireTransactionLock— the benign case that rejectsbefore the
try. Without the warm-up the statement is still queued 500ms in, and the test passes onmainas well, testing nothing. Checked both directions against the built worker: with the guardstripped out the test fails with
hung, with it in place the statement rejects about 2s in.Also ran
pnpm test:basic(277 passed;utilsandexec-protocolfail to load on@electric-sql/pg-protocolin my workspace, both before and after) and the Chromium web targets(20 passed,
opfs-ahpamong them). Firefox and WebKit left to CI.Notes
Found from pg-boss, where a
BEGIN … COMMITthrough its PGliteadapter stalled a maintenance cycle permanently after a leader change.
One judgement call worth your input: an rpc issued while disconnected now fails fast rather than
waiting for the new leader. Right for anything spanning a leader change, but a fresh call that
merely raced the reconnect window is also rejected and has to be retried. Happy to make
#rpcwaitfor
connectedinstead, though it cannot be transparent: nothing in a call says which leader itsoperation belongs to, so
#rpccannot tell a fresh call from one the dead leader started.