Summary
Config::prepared_statements() decides whether to track and rewrite prepared statements by checking only the global [general] pooler_mode. When [general] is session but a user is overridden to pooler_mode = "transaction", that user gets transaction pooling (correct) but no prepared statement tracking (incorrect). With a replica configured, Parse and Bind for the same statement are routed to different servers and every replica-eligible prepared statement fails with 26000.
Affects v0.1.56; the code is unchanged on main (checked at v0.1.58).
Reproduction
pgdog.toml:
[general]
host = "127.0.0.1"
port = 6432
pooler_mode = "session" # default for other users on this proxy
query_parser = "on"
read_write_split = "include_primary_if_replica_banned"
read_write_strategy = "conservative"
[[databases]]
name = "app"
database_name = "app"
host = "127.0.0.1"
port = 5432
role = "primary"
[[databases]]
name = "app"
database_name = "app"
host = "127.0.0.1"
port = 5433
role = "replica"
users.toml:
[[users]]
name = "app"
database = "app"
password = "app"
pooler_mode = "transaction" # per-user override
Client (pgx v5, default QueryExecModeCacheStatement):
cfg, _ := pgx.ParseConfig("postgres://app:app@127.0.0.1:6432/app?sslmode=disable")
conn, _ := pgx.ConnectConfig(ctx, cfg)
var n int64
err := conn.QueryRow(ctx, "SELECT id FROM items WHERE id = $1", int64(1)).Scan(&n)
// ERROR: prepared statement "stmtcache_8975a608c669..." does not exist (SQLSTATE 26000)
Fails on the first execution, every time. Writes through the same connection succeed (both Parse and Bind route to the primary).
What happens
log_level = "debug" shows the sequence:
['P', 'D', 'S'] — the SELECT is parsed, read: true, executed on the replica. The Parse is forwarded with the client's name (stmtcache_…), not renamed to __pgdog_N, and not recorded in the global cache.
['B', 'E', 'S'] — ClientRequest::query() looks up stmtcache_… in the global cache, finds nothing, so there is no AST; the router returns Command::default() (read: false) and the batch goes to the primary.
check_prepared() on the server has nothing to inject, so Bind is forwarded as-is and Postgres returns 26000.
Root cause is pgdog-config/src/core.rs:
pub fn prepared_statements(&self) -> PreparedStatementsLevel {
// Disable prepared statements automatically in session mode
if self.config.general.pooler_mode == PoolerMode::Session {
PreparedStatementsLevel::Disabled
} else {
self.config.general.prepared_statements
}
}
Client::buffer() applies this to every client regardless of the pooler mode its cluster actually resolved to (user.pooler_mode → database.pooler_mode → general.pooler_mode, already computed in ClusterConfig::new).
Confirmation: changing only [general] pooler_mode to transaction (leaving the per-user overrides as they are) makes the error disappear on the unpatched binary.
Proposed fix
Derive the level from the cluster's resolved pooler mode instead of the global default:
Cluster::prepared_statements_level() returns Disabled only when that cluster is session-pooled, otherwise general.prepared_statements.
Client::buffer() uses the connected cluster's level, falling back to Config::prepared_statements() only when there is no cluster (admin DB).
I have a patch against v0.1.56 (about 90 lines including a unit test) that I verified on Linux ARM64: with the config above unchanged, the pgx repro passes and a mixed workload went from 100 % replica-read failures to zero 26000 errors; session-mode clients on the same proxy were unaffected. Existing prepared_statements::, extended_anonymous::, and close_parse_global_cache:: tests pass. Happy to open a PR.
Environment
- PgDog v0.1.56 (release binary), also reproduced with a
main-equivalent build
- Linux aarch64, Postgres 16 primary + streaming replica
- Client: pgx v5 (
QueryExecModeCacheStatement); any driver using named prepared statements should reproduce
Summary
Config::prepared_statements()decides whether to track and rewrite prepared statements by checking only the global[general] pooler_mode. When[general]issessionbut a user is overridden topooler_mode = "transaction", that user gets transaction pooling (correct) but no prepared statement tracking (incorrect). With a replica configured,ParseandBindfor the same statement are routed to different servers and every replica-eligible prepared statement fails with26000.Affects v0.1.56; the code is unchanged on
main(checked at v0.1.58).Reproduction
pgdog.toml:users.toml:Client (pgx v5, default
QueryExecModeCacheStatement):Fails on the first execution, every time. Writes through the same connection succeed (both
ParseandBindroute to the primary).What happens
log_level = "debug"shows the sequence:['P', 'D', 'S']— the SELECT is parsed,read: true, executed on the replica. TheParseis forwarded with the client's name (stmtcache_…), not renamed to__pgdog_N, and not recorded in the global cache.['B', 'E', 'S']—ClientRequest::query()looks upstmtcache_…in the global cache, finds nothing, so there is no AST; the router returnsCommand::default()(read: false) and the batch goes to the primary.check_prepared()on the server has nothing to inject, soBindis forwarded as-is and Postgres returns26000.Root cause is
pgdog-config/src/core.rs:Client::buffer()applies this to every client regardless of the pooler mode its cluster actually resolved to (user.pooler_mode→database.pooler_mode→general.pooler_mode, already computed inClusterConfig::new).Confirmation: changing only
[general] pooler_modetotransaction(leaving the per-user overrides as they are) makes the error disappear on the unpatched binary.Proposed fix
Derive the level from the cluster's resolved pooler mode instead of the global default:
Cluster::prepared_statements_level()returnsDisabledonly when that cluster is session-pooled, otherwisegeneral.prepared_statements.Client::buffer()uses the connected cluster's level, falling back toConfig::prepared_statements()only when there is no cluster (admin DB).I have a patch against v0.1.56 (about 90 lines including a unit test) that I verified on Linux ARM64: with the config above unchanged, the pgx repro passes and a mixed workload went from 100 % replica-read failures to zero
26000errors; session-mode clients on the same proxy were unaffected. Existingprepared_statements::,extended_anonymous::, andclose_parse_global_cache::tests pass. Happy to open a PR.Environment
main-equivalent buildQueryExecModeCacheStatement); any driver using named prepared statements should reproduce