Skip to content

Make a one-shot query cost one exchange - #461

Closed
lumiai-io wants to merge 2 commits into
isoos:masterfrom
lumiai-io:fix/single-exchange-queries
Closed

lumiai-io wants to merge 2 commits into
isoos:masterfrom
lumiai-io:fix/single-exchange-queries

Conversation

@lumiai-io

@lumiai-io lumiai-io commented Sep 15, 2026

Copy link
Copy Markdown

A one-shot execute currently costs three network round trips where the protocol asks for one. This makes it one, in two steps.

1. Parse, bind and execute travel together

execute parsed in its own exchange, with a Sync before the bind. Everything between two Sync messages is one transaction, so that is two of them — and a pooler in transaction mode hands the server connection to another client at the ReadyForQuery a Sync produces. The bind then arrives somewhere that never saw the parse (26000), or somewhere still holding another client's statement of the same generated name (42P05).

Parse, Bind, Describe, Execute and Sync now go out as one aggregated message, under the unnamed statement, which cannot collide. This is what pgx's QueryExecModeExec does.

2. The unnamed statement is not closed

execute then sent Close for that statement and waited for CloseComplete — a second exchange asking the server to forget something it forgets anyway. Per the protocol docs, "named prepared statements must be explicitly closed before they can be redefined by another Parse message, but this is not required for the unnamed statement": the next Parse naming '' replaces it.

Named statements from prepare() still close, because a name has to become free before another Parse can reuse it. This mirrors the portal handling a few lines below, which is already conditional on whether the portal actually needs closing.

The exchange is kept on the failure path, and that turned out to matter. Removing it unconditionally broke timeout_test.dart: Cancel current statement through a new connection — reproducibly, 3/3 either way. A statement that did not finish can leave the connection with messages still to deliver, and that exchange is where they arrive; without it a cancelled query's 57014 escapes as an unhandled error rather than becoming a _PgQueryCancelledException. Closing is beside the point in that case, but having somewhere to arrive is not.

Effect

Measured against a Supabase database from Cloud Run in the same region, on a production workload — roughly 1ms of query work per statement:

per database operation
before ~33ms
after step 1 ~33ms (still two exchanges)
after step 2 ~17ms

End to end for the workload that prompted this, event queued to work started: 143ms → 46ms.

Tests

367 pass. test/single_exchange_test.dart is new and covers both halves: parse and bind with no Sync between them, the statement it parses is the unnamed one, a one-shot query sends no CloseMessage, and a prepared statement still does.

lumiai-io and others added 2 commits September 15, 2026 10:27
Everything between two Sync messages is one transaction, so a query that
syncs after parsing is two of them, and closing afterwards makes three. A
pooler in transaction mode takes the server connection back at the
ReadyForQuery a Sync produces, so those three can land on three different
connections.

Both ways that goes wrong are visible from a client: the bind arrives
where nothing was parsed and fails with 26000, or it arrives where
another client's statement of the same generated name is still sitting
and the parse fails with 42P05. Statement names are a per-connection
counter, so every connection starts at s/0 and collisions are the norm
rather than the exception.

A one-shot query now parses, binds and executes in a single exchange,
under the unnamed statement -- which belongs to the transaction using the
connection rather than to the connection, and so cannot be left behind
for anyone to collide with. Statements from prepare() are unchanged: they
outlive the call that made them, so they keep a name and their own
exchange.

This also removes a round trip from every query.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A one-shot query parses under the unnamed statement, and the unnamed
statement is replaced by the next parse that leaves the name empty. So
the Close that `execute` sends on the way out asks the server to forget
something it forgets anyway -- and it is a whole exchange, waiting for
CloseComplete. Against a database a network away that is most of what a
query costs: a caller measuring one round trip of work was paying two.

Named statements are different, and still close. A name has to become
free before another parse can use it, which is the whole reason the
portals a few lines below are closed conditionally too.

The exchange is kept on the failure path. A statement that did not finish
can leave the connection with messages still to deliver, and this is
where they arrive; a cancelled query surfaces its 57014 there rather than
as an unhandled error. Closing is beside the point in that case, but
having somewhere to arrive is not.
@lumiai-io lumiai-io changed the title Parse, bind and execute a one-shot query in one exchange Make a one-shot query cost one exchange Sep 15, 2026
@lumiai-io

Copy link
Copy Markdown
Author

Pushed a second commit and rescoped the PR, since editing the description doesn't notify.

94617d2 stops execute closing the unnamed statement on the success path. The first commit made parse/bind/execute one exchange; the trailing Close was the other one, waiting for CloseComplete to confirm the server had forgotten a statement the next Parse replaces regardless.

Named statements from prepare() still close. So does a statement whose run failed — I tried removing it unconditionally first and it broke timeout_test.dart: Cancel current statement through a new connection (3/3 reproducible, 3/3 green without the change). A cancelled statement can leave the connection with messages still to deliver, and that exchange is where they land; without it the 57014 surfaces as an unhandled error instead of a _PgQueryCancelledException.

Full suite green at 367, including four new assertions in test/single_exchange_test.dart covering both the unnamed and named cases.

Happy to split this into a separate PR if you'd rather review them independently — say the word and I'll open one.

@isoos

isoos commented Sep 15, 2026

Copy link
Copy Markdown
Owner

@lumiai-io: would it be possible to split the PR into two, so the scope of the changes do not overlap (of if they do, first land the one that the other depends on)?

@isoos

isoos commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Reused/reimplemented (with LLM + reviews) most of this in #464, with some modifications + with split commits. Closing.

@isoos isoos closed this Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants