Skip to content

Deallocate a failed prepare by the name sent in Parse, and only when Parse completed - #2644

Open
eliranbz wants to merge 2 commits into
jackc:masterfrom
eliranbz:fix-failed-prepare-deallocation
Open

Deallocate a failed prepare by the name sent in Parse, and only when Parse completed#2644
eliranbz wants to merge 2 commits into
jackc:masterfrom
eliranbz:fix-failed-prepare-deallocation

Conversation

@eliranbz

@eliranbz eliranbz commented Aug 28, 2026

Copy link
Copy Markdown

Fixes #2640

database/sql (and GORM with PrepareStmt: true) end up calling Prepare(ctx, sql, sql). In that path the server-side statement is named stmt_<digest> but the client keys it by the SQL text. When a prepare failed during Describe, the cleanup remembered the client key and later deallocated by it, i.e. it sent Close for a name that never existed. So the real stmt_<digest> stayed on the server, retrying the same SQL on that connection hit 42P05 for the rest of its life, and the cleanup error could pop up on whatever query happened to prepare next on that pooled connection.

The first commit stores psName (what was actually sent in Parse) instead, so the Close hits the real statement. Named prepares are unaffected since psName == psKey there. The test is the digested-name variant of TestPrepareHandlesTimeoutBetweenParseAndDescribe, and like that test it checks pg_prepared_statements directly for the leaked statement.

While writing the test I noticed PrepareError.ParseComplete is never checked. If Parse itself failed (any syntax error, say) there is no statement on the server, but we would still schedule a deallocation: a wasted round trip at the start of the next Prepare, which can also fail and surface its error on an unrelated query. The second commit only schedules the cleanup when ParseComplete is true, with a test that verifies no Close message goes out in that case.

I saw #2642 already has the rename half of this. Since I filed the issue I wanted to put up the complete fix, with the ParseComplete part and the leak assertion included.

Tested against PostgreSQL 17: both new tests fail before their respective commit and pass after, and the full package and stdlib suites pass.

I also reproduced the incident that led to the issue through the same application stack where we hit it in production — GORM with PrepareStmt: true over stdlib, single pooled connection, prepare killed by statement_timeout between Parse and Describe. On v5.10.0 the connection is poisoned: every retry of that query returns 42P05 for the connection's remaining life. On this branch the first retry succeeds and the connection recovers.

When Prepare is called with name == sql (the stdlib/database/sql path), the
statement is prepared on the server under stmt_<digest> while the client keys
it by the SQL text. The cleanup for a prepare that failed during Describe
stored that client key and later deallocated by it, sending Close for a
statement name that does not exist. The real stmt_<digest> leaked on the
server, and any retry of the same SQL on that connection failed with 42P05
duplicate_prepared_statement for the rest of the connection's life. The
cleanup's own round trip could also surface its errors on whatever query next
prepared on the connection, quoting the old query's full SQL.

Store the name actually sent in Parse so the deallocation closes the real
statement. The named-prepare path is unchanged: there the key and the wire
name are the same.

jackc#2640
A PrepareError with ParseComplete == false means the server rejected the
Parse itself — no statement was created, so there is nothing to deallocate.
Scheduling the cleanup anyway cost a wasted round trip at the start of the
next Prepare on the connection after every failed prepare (e.g. any syntax
error), and gave that round trip a chance to fail and surface its error on an
unrelated query.

jackc#2640
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant