sql(postgres): stop sending a redundant Sync after a simple Query - #32772
Conversation
A simple Query ('Q') is its own sync point: the backend always answers it
with exactly one ReadyForQuery. executeQuery also appended an
extended-protocol Sync, so every simple query produced a second,
unaccounted ReadyForQuery. That spurious ReadyForQuery set
IS_READY_FOR_QUERY and cleared WAITING_TO_PREPARE while the next query's
Parse+Describe round trip was still in flight, and advance() then wrote a
third query into that window. Its replies were delivered to the wrong
query: a concurrent .simple() query received the rows of the in-flight
prepare (and vice versa), and a concurrent parameterized query hung
forever because its Describe reply was consumed under another statement.
|
Updated 10:46 AM PT - Jun 26th, 2026
❌ @robobun, your commit b78fd1a has 1 failures in
🧪 To try this PR locally: bunx bun-pr 32772That installs a local version of the PR into your bun-32772 --bun |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughSimple Postgres queries now omit an extra ChangesPostgres simple-query pipeline
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
The one-line removal looks correct per the Postgres protocol spec (simple Query already elicits its own ReadyForQuery), and the wire traces and regression tests are convincing — but since this touches the connection state machine for the production Postgres driver, it's worth a quick human sanity-check before merging.
Extended reasoning...
Overview
This PR removes a single writer.write(&protocol::SYNC)? line from execute_query in src/sql_jsc/postgres/PostgresRequest.rs, which writes the simple-protocol Q message. It adds a new test file with two regression tests against a real Postgres container via describeWithContainer.
The root-cause analysis is well-documented and matches the PostgreSQL protocol spec: a simple Query message is always answered with a terminal ReadyForQuery, and Sync (an extended-protocol message) independently elicits another ReadyForQuery. Sending both yields two Z messages per simple query, and the connection's IS_READY_FOR_QUERY / WAITING_TO_PREPARE state machine assumes one Z per operation. The PR includes before/after wire traces from a logging proxy that confirm the duplicate Z and the resulting misrouted result sets.
Security risks
None identified. This is a wire-protocol sequencing fix; no auth, crypto, input parsing, or trust-boundary changes.
Level of scrutiny
Moderate-to-high. The diff is one line, and the protocol reasoning is provably correct against the spec. But execute_query is on the hot path of the production Postgres driver, and the surrounding pipelining/state-machine logic in PostgresSQLConnection.rs (advance(), can_pipeline, can_prepare_query, nonpipelinable_requests) is intricate enough that a maintainer familiar with it should confirm nothing else implicitly depended on the second ReadyForQuery (e.g., error recovery or transaction-state transitions). Note that simple-protocol error handling does not require Sync — the backend sends ErrorResponse then ReadyForQuery on its own — so that path should be unaffected.
Other factors
- No bugs found by the automated review.
- Test follows the established
describeWithContainer("postgres", { image: "postgres_plain" }, ...)pattern used by neighboring tests. - PR description states the existing
test/js/sql/Postgres suites still pass. - No CODEOWNERS entry for this path.
- CI build was still in progress at review time (#65034).
sql.unsafe(text) with zero parameters routes through the same simple ('Q')
protocol as query.simple(), so it hit the same redundant-Sync bug without
the caller ever opting into simple mode. The BEGIN/COMMIT/ROLLBACK of
sql.begin() take the same path.
|
Looked into both of the suggested issues before adding any #22040 is #32004 looked plausible, because In a release build that subtraction silently wraps to While verifying this I found that the original description understated the reach of this bug: the simple query protocol, and therefore the redundant On the automated review's question of whether anything depended on the second |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/sql/postgres-simple-query-pipeline.test.ts`:
- Around line 43-58: The new regression test is in the wrong place and should be
moved into the existing Postgres SQL test coverage instead of living in a
separate standalone suite. Relocate the `unsafe() with no parameters does not
steal the rows of an in-flight prepare` test to the established Postgres SQL
test file that already covers `SQL.unsafe()` and related query behavior, keeping
it alongside the existing `SQL`/`sql.unsafe(...)` tests. Preserve the same test
logic and assertions, but insert it near the related Postgres SQL cases so the
coverage stays discoverable and organized.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 293eebf0-153a-45d8-8cc2-e751dba699b4
📒 Files selected for processing (1)
test/js/sql/postgres-simple-query-pipeline.test.ts
Per review: the past-tense bug narrative belongs in the PR description. Keep the invariant and state the consequence of re-adding the Sync, since every sibling writer in this file ends with Flush+Sync and this one deliberately does not.
There was a problem hiding this comment.
Thanks for addressing the comment nit in b78fd1a — the change looks correct and the wire-trace verification is thorough, but since this alters Postgres wire-protocol sequencing on a data-correctness path I'd like a human (the SQL maintainer) to give it a final look.
Extended reasoning...
Overview
The functional change is a single line: execute_query in src/sql_jsc/postgres/PostgresRequest.rs no longer writes Sync after a simple-protocol Query ('Q'). A three-line comment documents the invariant (a simple Q is its own sync point) and the deliberate deviation from sibling writers, which all end Flush+Sync. A new test file adds three regression tests against a real Postgres via describeWithContainer, covering .simple(), sql.unsafe() with no parameters, and a second prepare queued behind an in-flight prepare.
My one prior inline nit (rephrase the past-tense bug-history sentence as a forward-looking invariant) was addressed in b78fd1a and the thread is resolved. CodeRabbit's test-placement suggestion was withdrawn after a well-reasoned rebuttal. The bug-hunting system found nothing.
Security risks
None. No auth, crypto, permissions, or untrusted-input parsing is touched. The change only removes one outbound protocol byte sequence; it does not change how inbound data is parsed or trusted.
Level of scrutiny
This is where I land on deferral rather than approval. The diff is tiny and the Postgres protocol spec is unambiguous that a simple Q always concludes with its own ReadyForQuery, so the fix is protocol-correct. The PR description is exceptionally rigorous: before/after wire traces against Postgres 17, verification that sql.begin() transaction-status tracking (I→T→T→T→I) still works, confirmation that the simple-protocol error path already terminates with its own ReadyForQuery, and a careful negative result on the two suggested linked issues.
However, the effect of this one line is a change to how the connection state machine's IS_READY_FOR_QUERY / WAITING_TO_PREPARE sequencing interacts with request pipelining — the exact area where the original bug caused silent row misattribution. The redundant Sync dates back to the original Zig implementation, so this is removing long-standing (if incorrect) behavior. That's the kind of change where the SQL maintainer's domain knowledge of edge cases (PgBouncer, pipelined error recovery, COPY, etc.) is worth more than my read of the spec.
Other factors
No CODEOWNERS entry covers src/sql_jsc/. Tests follow the established test/js/sql/postgres-*.test.ts + describeWithContainer pattern and the author confirmed the existing Postgres suites still pass. The trailing Flush after Q is also technically a no-op per the protocol but was deliberately left alone, which is appropriately conservative for this PR's scope.
There was a problem hiding this comment.
Thanks for the comment reword — that resolves my only nit. The one-line fix and wire traces look correct to me, but since this changes the simple-protocol write path that every .simple(), parameterless unsafe(), and sql.begin() BEGIN/COMMIT flows through, I'd like a Postgres-driver maintainer to give the final sign-off.
Extended reasoning...
Overview
The PR removes a single writer.write(&protocol::SYNC)?; from execute_query() in src/sql_jsc/postgres/PostgresRequest.rs and adds a three-line invariant comment explaining why. A new test file test/js/sql/postgres-simple-query-pipeline.test.ts adds three regression tests (via describeWithContainer) covering the three observed failure modes: row misattribution via .simple(), the same via parameterless sql.unsafe(), and a hung second prepare.
The root cause is well-established by the Postgres protocol: a simple Query (Q) always concludes with its own ReadyForQuery, so an appended Sync produces a second, unaccounted ReadyForQuery that re-arms advance() mid-prepare. The author captured before/after wire traces against Postgres 17, verified sql.begin() transaction-status tracking (I→T→T→T→I) and the simple-query error path are unaffected, and confirmed the existing test/js/sql/ Postgres suites still pass. My one prior nit (past-tense comment phrasing) was addressed in b78fd1a and the thread is resolved; CodeRabbit's test-placement suggestion was withdrawn.
Security risks
None identified. This is a wire-protocol write-ordering fix; no auth, crypto, input-parsing, or trust-boundary changes.
Level of scrutiny
High. Although the diff is one functional line, it sits on the Postgres simple-protocol write path that every .simple() query, every parameterless sql.unsafe(), and every BEGIN/COMMIT/ROLLBACK from sql.begin() flows through. The bug it fixes is silent row misattribution — a data-correctness failure — and the surrounding connection state machine has other known subtle issues nearby (the author found a nonpipelinable_requests underflow in finish_request while investigating #32004). Protocol-level changes to a DB driver warrant a maintainer with domain context confirming nothing else depends on the second ReadyForQuery.
Other factors
- No CODEOWNERS entry for
src/sql_jsc/ortest/js/sql/. - The one CI failure (
test/cli/install/bun-install.test.tson Windows x64) is unrelated to this change. - CodeRabbit suggested cirospaciari as a reviewer, which aligns with deferring to a Postgres-driver maintainer.
- Bug-hunting system found nothing; all inline review threads are resolved.
|
Status for a reviewer: the diff is complete, all review threads are resolved, and this needs a maintainer. On CI:
The repro, protocol citation, before and after wire traces, and the fail-before / pass-after output for all three tests are in the PR description. |
What
Bun.SQL(Postgres) silently delivers another query's rows to the wrong query, with no error, when a simple-protocol query runs concurrently with a not-yet-prepared parameterized query on the same connection. A second variant of the same bug makes the third query hang forever.The simple query protocol ('Q') is used by
query.simple(), bysql.unsafe(text)with no parameters (src/js/bun/sql.ts:134), and by theBEGIN/COMMIT/ROLLBACKthatsql.begin()issues, so this is reachable without the caller ever opting into simple mode.Repro
Reproduces on Bun 1.3.14, 1.4.0, and main, against a real Postgres, every run:
The same happens with
.simple()in place of the no-parameterunsafe()calls. If the third query is parameterized instead, it never resolves.Cause
PostgresRequest::execute_querywroteQuery ('Q') + Flush + Syncfor the simple query protocol. A simpleQis its own sync point: the backend always answers it with exactly oneReadyForQuery. The trailingSyncmade the server send a second, unaccountedReadyForQueryper simple query.The only place
IS_READY_FOR_QUERYis set (andWAITING_TO_PREPAREcleared) is theReadyForQueryhandler, and the two flags are otherwise mutually exclusive with the prepare path. So the state machine is correct as long as everyReadyForQuerycorresponds to an operation Bun wrote. The extra one breaks that: it arrives right afteradvance()has written the next query'sParse+Describe+Sync, re-armsIS_READY_FOR_QUERY, clearsWAITING_TO_PREPARE, and triggers anotheradvance(). That pass skips the head query (its statement isParsing) and admits the third query into the prepare's Describe window. From there the FIFO reply dispatch hands each arriving result set to the wrong head-of-queue request.Wire traces captured through a logging proxy against a real Postgres 17:
before (broken) and after (fixed)
Before: A's extra
Syncyields twoZ. The second one lets C'sQout between B'sParse/Describe/Syncand B'sBind/Execute, so C's result set (T D C Z) arrives while B is still the current query.After: one
Zper simple query, and each query's full exchange completes before the next one is written.sql.begin()after the fix, for reference (the transaction-status byte inZtracksI -> T -> T -> T -> Icorrectly):The
Flush + SyncafterQdates to the original Zig implementation ofexecuteQueryand was carried through the Rust port unchanged.Flushafter aQelicits no response, so it is left alone.Fix
Stop writing
Syncafter a simpleQinexecute_query. OneReadyForQueryper operation is the invariant the rest of the connection state machine already assumes.Test
test/js/sql/postgres-simple-query-pipeline.test.ts, against a real Postgres viadescribeWithContainer. All three cases fail on the unfixed build:and pass with the fix. The existing
test/js/sql/Postgres suites (postgres-multi-statement-fields,sql-prepare-false,wire-frames, the binary/onconnect/close ones) still pass.