From a782e1135588513957865b8b61898fa157e908dc Mon Sep 17 00:00:00 2001 From: paidax <2338239869@qq.com> Date: Sun, 6 Sep 2026 20:12:50 +0800 Subject: [PATCH] fix: deallocate failed prepare by server-side statement name When name == sql, Prepare creates the statement on the server under a digest name (stmt_) while the client maps it under the SQL text. If the prepare failed after ParseComplete (e.g. statement_timeout between Parse and Describe), failedDescribeStatement stored the SQL text, so the deferred Deallocate sent Close with the SQL text as the statement name. That closes nothing: the stmt_ statement leaks and every retry of the same sql on this connection fails with 42P05 duplicate_prepared_statement, re-arming the failed cleanup each time for the lifetime of the connection. Store the server-side name (psName) instead. For the named path (name != sql) psName and psKey are identical, so behavior there is unchanged. Fixes #2640 --- conn.go | 6 ++++- conn_test.go | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/conn.go b/conn.go index 229c380c5..f58866ee5 100644 --- a/conn.go +++ b/conn.go @@ -360,7 +360,11 @@ func (c *Conn) Prepare(ctx context.Context, name, sql string) (sd *pgconn.Statem if err != nil { var pErr *pgconn.PrepareError if errors.As(err, &pErr) { - c.failedDescribeStatement = psKey + // The statement may have been created on the server under psName. Store psName (not psKey) so the + // deferred Deallocate sends the Close for the name the server actually knows. With psKey, the digest + // name==sql path would deallocate the SQL text, leak the statement, and permanently poison this + // connection with 42P05 on any retry of the same sql. See https://github.com/jackc/pgx/issues/2640. + c.failedDescribeStatement = psName } return nil, err } diff --git a/conn_test.go b/conn_test.go index db3578b4b..c5a30588d 100644 --- a/conn_test.go +++ b/conn_test.go @@ -3,7 +3,9 @@ package pgx_test import ( "bytes" "context" + "crypto/sha256" "database/sql" + "encoding/hex" "io" "net" "os" @@ -532,6 +534,76 @@ func TestPrepareHandlesTimeoutBetweenParseAndDescribe(t *testing.T) { require.NotNil(t, psd) } +// https://github.com/jackc/pgx/issues/2640 +// When name == sql the statement is created on the server under a digest name (stmt_). The deferred cleanup of +// a failed prepare must deallocate that server-side name; dealing the SQL text instead leaks the statement and every +// retry of the same sql on this connection fails with 42P05 duplicate_prepared_statement. +func TestPrepareHandlesTimeoutBetweenParseAndDescribeWhenNameEqualsSQL(t *testing.T) { + // Not parallel because it is a timing sensitive test. + + config, err := pgx.ParseConfig(os.Getenv("PGX_TEST_DATABASE")) + require.NoError(t, err) + + var faultyConn *faultyconn.Conn + config.AfterNetConnect = func(ctx context.Context, config *pgconn.Config, conn net.Conn) (net.Conn, error) { + faultyConn = faultyconn.New(conn) + return faultyConn, nil + } + + ctx := context.Background() + conn, err := pgx.ConnectConfig(ctx, config) + require.NoError(t, err) + defer closeConn(t, conn) + require.NotNil(t, faultyConn) + + pgxtest.SkipCockroachDB(t, conn, "Induced error does not occur on CockroachDB") + + _, err = conn.Exec(ctx, "set statement_timeout = '100ms'") + require.NoError(t, err) + + faultyConn.HandleFrontendMessage = func(backendWriter io.Writer, msg pgproto3.FrontendMessage) error { + if _, ok := msg.(*pgproto3.Describe); ok { + time.Sleep(200 * time.Millisecond) + } + buf, err := msg.Encode(nil) + if err != nil { + return err + } + _, err = backendWriter.Write(buf) + return err + } + + sql := "select $1::varchar" + digest := sha256.Sum256([]byte(sql)) + psName := "stmt_" + hex.EncodeToString(digest[0:24]) + + psd, err := conn.Prepare(ctx, sql, sql) + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + require.Equal(t, "57014", pgErr.Code) + require.Nil(t, psd) + + faultyConn.HandleFrontendMessage = nil + + _, err = conn.Exec(ctx, "set statement_timeout = default") + require.NoError(t, err) + + var existsOnServer bool + err = conn.QueryRow( + ctx, + "select exists(select 1 from pg_prepared_statements where name = '"+psName+"')", + // Avoid using the prepared statement cache or it will clear the broken statement before we can check for its + // existence. + pgx.QueryExecModeExec, + ).Scan(&existsOnServer) + require.NoError(t, err) + require.True(t, existsOnServer) + + psd, err = conn.Prepare(ctx, sql, sql) + require.NoError(t, err) + require.NotNil(t, psd) +} + func TestPrepareBadSQLFailure(t *testing.T) { t.Parallel()