From 8848ae40fb57d3f0770b6a909eeaf3083985efdc Mon Sep 17 00:00:00 2001 From: Peter Bwire Date: Fri, 4 Sep 2026 15:29:37 +0300 Subject: [PATCH] datastore/postgres: apply PreferSimpleProtocol and PreparedStatements at the pgx layer gorm's postgres driver only honours Config.PreferSimpleProtocol when it parses the DSN itself. The adapter hands it an already-open *sql.DB, so the preference every service sets (and the pool default of true) was silently dropped and pgx stayed in its statement-cache mode. Behind a transaction-mode pooler (PgBouncer, Supavisor, Neon) that fails with "prepared statement ... does not exist / already exists" (SQLSTATE 26000 / 42P05) as soon as pooled connections are reassigned; service-trustage hit this in production on 2026-09-01 and needed a DSN workaround. Set ConnConfig.DefaultQueryExecMode from the connection options: simple_protocol when PreferSimpleProtocol, exec when prepared statements are disabled, cache_statement otherwise. An explicit default_query_exec_mode in the DSN still wins. QueryExecMode is exported so callers can inspect it. Claude-Session: https://claude.ai/code/session_016Bc2hUCUhZkA51qkHQ8UWX --- datastore/dialect/postgres/exec_mode_test.go | 39 ++++++++++++++++++++ datastore/dialect/postgres/postgres.go | 36 ++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 datastore/dialect/postgres/exec_mode_test.go diff --git a/datastore/dialect/postgres/exec_mode_test.go b/datastore/dialect/postgres/exec_mode_test.go new file mode 100644 index 00000000..5c2b08bf --- /dev/null +++ b/datastore/dialect/postgres/exec_mode_test.go @@ -0,0 +1,39 @@ +package postgres_test + +import ( + "testing" + + "github.com/jackc/pgx/v5" + "github.com/pitabwire/frame/v2/datastore/dialect" + "github.com/pitabwire/frame/v2/datastore/dialect/postgres" + "github.com/stretchr/testify/require" +) + +const testDSN = "host=localhost port=5432 user=u password=p dbname=d" + +func TestQueryExecModeFollowsConnectionOptions(t *testing.T) { + cases := []struct { + name string + opts dialect.ConnectionOptions + want pgx.QueryExecMode + }{ + { + "simple protocol preferred", + dialect.ConnectionOptions{PreferSimpleProtocol: true, PreparedStatements: true}, + pgx.QueryExecModeSimpleProtocol, + }, + {"no prepared statements", dialect.ConnectionOptions{PreparedStatements: false}, pgx.QueryExecModeExec}, + {"prepared statements", dialect.ConnectionOptions{PreparedStatements: true}, pgx.QueryExecModeCacheStatement}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, postgres.QueryExecMode(testDSN, tc.opts)) + }) + } +} + +func TestQueryExecModeDSNOverrideWins(t *testing.T) { + dsn := testDSN + " default_query_exec_mode=cache_describe" + got := postgres.QueryExecMode(dsn, dialect.ConnectionOptions{PreferSimpleProtocol: true, PreparedStatements: true}) + require.Equal(t, pgx.QueryExecModeCacheDescribe, got) +} diff --git a/datastore/dialect/postgres/postgres.go b/datastore/dialect/postgres/postgres.go index 6456555f..f1b32ada 100644 --- a/datastore/dialect/postgres/postgres.go +++ b/datastore/dialect/postgres/postgres.go @@ -188,6 +188,41 @@ func applyPoolSizing(cfg *pgxpool.Config, opts dialect.ConnectionOptions) { } } +// applyQueryExecMode makes PreferSimpleProtocol / PreparedStatements take +// effect at the pgx layer. +// +// gorm's postgres driver only honours Config.PreferSimpleProtocol when it +// parses the DSN itself; this adapter hands it an already-open *sql.DB, so +// the preference was silently dropped and pgx kept its default +// statement-cache mode. Behind a transaction-mode pooler (PgBouncer, +// Supavisor, Neon) that mode fails with "prepared statement ... does not +// exist / already exists" (SQLSTATE 26000 / 42P05) as soon as connections +// are reassigned. An explicit default_query_exec_mode in the DSN always wins. +func applyQueryExecMode(cfg *pgxpool.Config, dsn string, opts dialect.ConnectionOptions) { + cfg.ConnConfig.DefaultQueryExecMode = QueryExecMode(dsn, opts) +} + +// QueryExecMode reports the pgx query execution mode the adapter will use for +// the given DSN and connection options. +func QueryExecMode(dsn string, opts dialect.ConnectionOptions) pgx.QueryExecMode { + if strings.Contains(dsn, "default_query_exec_mode") { + cfg, err := pgx.ParseConfig(dsn) + if err == nil { + return cfg.DefaultQueryExecMode + } + } + switch { + case opts.PreferSimpleProtocol: + return pgx.QueryExecModeSimpleProtocol + case !opts.PreparedStatements: + // Extended protocol without server-side prepared statements: safe + // behind transaction pooling, keeps binary parameter encoding. + return pgx.QueryExecModeExec + default: + return pgx.QueryExecModeCacheStatement + } +} + // configureSQLDB applies *sql.DB pool sizing. MaxIdleConns is forced // to 0 so every release flows through pgxpool, which is the property // the hook chain relies on for tenancy hook correctness. @@ -226,6 +261,7 @@ func (a *Adapter) OpenConnection( } applyPoolSizing(cfg, opts) + applyQueryExecMode(cfg, cleanDSN, opts) cfg.ConnConfig.Tracer = otelpgx.NewTracer() // Wire PrepareConn / AfterRelease to dispatchers that close over a