Conversation
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.
|
Pushed a second commit and rescoped the PR, since editing the description doesn't notify.
Named statements from Full suite green at 367, including four new assertions in Happy to split this into a separate PR if you'd rather review them independently — say the word and I'll open one. |
|
@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)? |
|
Reused/reimplemented (with LLM + reviews) most of this in #464, with some modifications + with split commits. Closing. |
A one-shot
executecurrently 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
executeparsed in its own exchange, with aSyncbefore the bind. Everything between twoSyncmessages is one transaction, so that is two of them — and a pooler in transaction mode hands the server connection to another client at theReadyForQueryaSyncproduces. 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
QueryExecModeExecdoes.2. The unnamed statement is not closed
executethen sentClosefor that statement and waited forCloseComplete— 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 nextParsenaming''replaces it.Named statements from
prepare()still close, because a name has to become free before anotherParsecan 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's57014escapes 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:
End to end for the workload that prompted this, event queued to work started: 143ms → 46ms.
Tests
367 pass.
test/single_exchange_test.dartis new and covers both halves: parse and bind with noSyncbetween them, the statement it parses is the unnamed one, a one-shot query sends noCloseMessage, and aprepared statement still does.