From 47b599e6b3ce34bbd5ddcc22b1b4f8ddd7d7a075 Mon Sep 17 00:00:00 2001 From: Igor Ohrimenko Date: Wed, 5 Aug 2026 13:13:07 +0300 Subject: [PATCH 1/7] Limit the global prepared statements cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cache had no working limit: prepared_statements_limit was applied to it only on RELOAD and from the admin console, so between those a workload preparing a stream of unique statements grows it without bound. A statement spike of a few million unique queries takes a pooler to gigabytes of RSS, and nothing reclaims that while traffic keeps flowing. Enforce the count limit continuously and add a companion prepared_statements_memory_limit (bytes, 0 = unlimited, also PGDOG_PREPARED_STATEMENTS_MEMORY_LIMIT and settable from the admin console), the same shape as the query cache limits. Only statements no client is holding are evicted, so the cache can still exceed its caps while everything in it is in use — it shrinks the moment statements are released. The byte total is maintained incrementally on insert/remove, so enforcement doesn't rescan the maps; the same number feeds the new prepared_statements_memory_limit gauge next to the existing prepared_statements_memory_used. One behavior change in the admin console: SET prepared_statements_limit TO 0 used to wipe the cache (close_unused treats 0 as "remove everything"); it now means "unlimited", matching the config semantics. --- .schema/pgdog.schema.json | 8 + pgdog-config/src/general.rs | 17 + pgdog/src/admin/set.rs | 15 +- pgdog/src/backend/databases.rs | 16 +- .../prepared_statements/global_cache.rs | 293 ++++++++++++++++-- pgdog/src/stats/pools.rs | 11 + 6 files changed, 319 insertions(+), 41 deletions(-) diff --git a/.schema/pgdog.schema.json b/.schema/pgdog.schema.json index 62b2fad16..f28e4fae8 100644 --- a/.schema/pgdog.schema.json +++ b/.schema/pgdog.schema.json @@ -83,6 +83,7 @@ "port": 6432, "prepared_statements": "extended", "prepared_statements_limit": 9223372036854775807, + "prepared_statements_memory_limit": 0, "pub_sub_channel_size": 0, "query_cache_limit": 1000, "query_log": null, @@ -974,6 +975,13 @@ "default": 9223372036854775807, "minimum": 0 }, + "prepared_statements_memory_limit": { + "description": "Approximate memory limit (bytes) for the global prepared statements cache. Statements no client is holding are evicted once the cache grows past it. `0` disables the limit.\n\n_Default:_ `0`\n\n", + "type": "integer", + "format": "uint", + "default": 0, + "minimum": 0 + }, "pub_sub_channel_size": { "description": "Enables support for pub/sub and configures the size of the background task queue.\n\n", "type": "integer", diff --git a/pgdog-config/src/general.rs b/pgdog-config/src/general.rs index 5630ef054..94d7b2d0d 100644 --- a/pgdog-config/src/general.rs +++ b/pgdog-config/src/general.rs @@ -382,6 +382,14 @@ pub struct General { #[serde(default = "General::prepared_statements_limit")] pub prepared_statements_limit: usize, + /// Approximate memory limit (bytes) for the global prepared statements cache. Statements no client is holding are evicted once the cache grows past it. `0` disables the limit. + /// + /// _Default:_ `0` + /// + /// + #[serde(default = "General::prepared_statements_memory_limit")] + pub prepared_statements_memory_limit: usize, + /// Limit on the number of statements saved in the statement cache used to accelerate query parsing. /// /// _Default:_ `50000` @@ -887,6 +895,7 @@ impl Default for General { regex_parser_limit: Self::regex_parser_limit(), query_parser_engine: QueryParserEngine::default(), prepared_statements_limit: Self::prepared_statements_limit(), + prepared_statements_memory_limit: Self::prepared_statements_memory_limit(), query_cache_limit: Self::query_cache_limit(), passthrough_auth: Self::default_passthrough_auth(), connect_timeout: Self::default_connect_timeout(), @@ -1381,6 +1390,10 @@ impl General { Self::env_or_default("PGDOG_PREPARED_STATEMENTS_LIMIT", i64::MAX as usize) } + pub fn prepared_statements_memory_limit() -> usize { + Self::env_or_default("PGDOG_PREPARED_STATEMENTS_MEMORY_LIMIT", 0) + } + pub fn query_cache_limit() -> usize { Self::env_or_default("PGDOG_QUERY_CACHE_LIMIT", 1_000) } @@ -1826,12 +1839,14 @@ mod tests { let _guard = set_env_var("PGDOG_MIRROR_EXPOSURE", "0.5"); let _guard = set_env_var("PGDOG_DNS_TTL", "60000"); let _guard = set_env_var("PGDOG_PUB_SUB_CHANNEL_SIZE", "100"); + let _guard = set_env_var("PGDOG_PREPARED_STATEMENTS_MEMORY_LIMIT", "4294967296"); let _guard = set_env_var("PGDOG_LOG_MIN_DURATION_PARSE", "5"); let _guard = set_env_var("PGDOG_LOG_QUERY_SAMPLE_LENGTH", "200"); assert_eq!(General::broadcast_port(), 7432); assert_eq!(General::openmetrics_port(), Some(9090)); assert_eq!(General::prepared_statements_limit(), 1000); + assert_eq!(General::prepared_statements_memory_limit(), 4294967296); assert_eq!(General::query_cache_limit(), 500); assert_eq!(General::connect_attempts(), 3); assert_eq!(General::mirror_queue(), 256); @@ -1844,6 +1859,7 @@ mod tests { let _guard = remove_env_var("PGDOG_BROADCAST_PORT"); let _guard = remove_env_var("PGDOG_OPENMETRICS_PORT"); let _guard = remove_env_var("PGDOG_PREPARED_STATEMENTS_LIMIT"); + let _guard = remove_env_var("PGDOG_PREPARED_STATEMENTS_MEMORY_LIMIT"); let _guard = remove_env_var("PGDOG_QUERY_CACHE_LIMIT"); let _guard = remove_env_var("PGDOG_CONNECT_ATTEMPTS"); let _guard = remove_env_var("PGDOG_MIRROR_QUEUE"); @@ -1856,6 +1872,7 @@ mod tests { assert_eq!(General::broadcast_port(), General::port() + 1); assert_eq!(General::openmetrics_port(), None); assert_eq!(General::prepared_statements_limit(), i64::MAX as usize); + assert_eq!(General::prepared_statements_memory_limit(), 0); assert_eq!(General::query_cache_limit(), 1_000); assert_eq!(General::connect_attempts(), 1); assert_eq!(General::mirror_queue(), 128); diff --git a/pgdog/src/admin/set.rs b/pgdog/src/admin/set.rs index cae78e626..916bd1520 100644 --- a/pgdog/src/admin/set.rs +++ b/pgdog/src/admin/set.rs @@ -117,9 +117,18 @@ impl Command for Set { "prepared_statements_limit" => { config.config.general.prepared_statements_limit = self.value.parse()?; - PreparedStatements::global() - .write() - .close_unused(config.config.general.prepared_statements_limit); + PreparedStatements::global().write().configure( + config.config.general.prepared_statements_limit, + config.config.general.prepared_statements_memory_limit, + ); + } + + "prepared_statements_memory_limit" => { + config.config.general.prepared_statements_memory_limit = self.value.parse()?; + PreparedStatements::global().write().configure( + config.config.general.prepared_statements_limit, + config.config.general.prepared_statements_memory_limit, + ); } "prepared_statements" => { diff --git a/pgdog/src/backend/databases.rs b/pgdog/src/backend/databases.rs index a6e170341..6d2801681 100644 --- a/pgdog/src/backend/databases.rs +++ b/pgdog/src/backend/databases.rs @@ -107,6 +107,12 @@ pub fn init() -> Result<(), Error> { // Resize query cache Cache::resize(config.config.general.query_cache_limit); + // Apply prepared statements cache limits. + PreparedStatements::global().write().configure( + config.config.general.prepared_statements_limit, + config.config.general.prepared_statements_memory_limit, + ); + // Start two-pc manager. let _monitor = Manager::get(); @@ -147,10 +153,12 @@ pub fn reload() -> Result<(), Error> { // Reload TLS connectors. tls::reload()?; - // Remove any unused prepared statements. - PreparedStatements::global() - .write() - .close_unused(new_config.config.general.prepared_statements_limit); + // Apply prepared statements cache limits, dropping anything + // unused over the new caps. + PreparedStatements::global().write().configure( + new_config.config.general.prepared_statements_limit, + new_config.config.general.prepared_statements_memory_limit, + ); // Resize query cache. Cache::resize(new_config.config.general.query_cache_limit); diff --git a/pgdog/src/frontend/prepared_statements/global_cache.rs b/pgdog/src/frontend/prepared_statements/global_cache.rs index 50eed73ba..6ccd18efd 100644 --- a/pgdog/src/frontend/prepared_statements/global_cache.rs +++ b/pgdog/src/frontend/prepared_statements/global_cache.rs @@ -8,6 +8,8 @@ use std::{collections::hash_map::HashMap, str::from_utf8}; use fnv::FnvHashSet as HashSet; +use super::str_mem; + // Format the globally unique prepared statement // name based on the counter. fn global_name(counter: usize) -> String { @@ -115,6 +117,16 @@ pub struct GlobalCache { unused: HashSet, counter: usize, versions: usize, + /// Maximum number of cached statements (0 = unlimited). Only statements + /// no client holds can be evicted, so the cache can exceed this while + /// they are all in use. + capacity: usize, + /// Approximate memory budget in bytes (0 = unlimited), enforced the same + /// way as `capacity`. + memory_limit: usize, + /// Incremental sum of what the live entries cost; kept in step with every + /// insert and remove so enforcement doesn't rescan the maps. + bytes: usize, } impl MemoryUsage for GlobalCache { @@ -129,6 +141,55 @@ impl MemoryUsage for GlobalCache { } impl GlobalCache { + /// Apply cache limits from configuration, evicting anything over the new + /// caps. A `capacity` or `memory_limit` of 0 disables that limit. + pub fn configure(&mut self, capacity: usize, memory_limit: usize) { + self.capacity = capacity; + self.memory_limit = memory_limit; + self.enforce(); + } + + /// Approximate memory used by the cached statements. + pub fn memory_bytes(&self) -> usize { + self.bytes + } + + /// What an entry adds to the byte total: both map entries, keyed by the + /// global name and the cache key. Kept symmetrical with `entry_removed`. + fn entry_inserted(&mut self, name: &str, statement: &Statement, cached: &CachedStmt) { + self.bytes += str_mem(name) + + statement.memory_usage() + + statement.cache_key.memory_usage() + + cached.memory_usage(); + } + + fn entry_removed(&mut self, name: &str, statement: &Statement, cached: &CachedStmt) { + self.bytes = self.bytes.saturating_sub( + str_mem(name) + + statement.memory_usage() + + statement.cache_key.memory_usage() + + cached.memory_usage(), + ); + } + + fn over_budget(&self) -> bool { + (self.capacity > 0 && self.statements.len() > self.capacity) + || (self.memory_limit > 0 && self.bytes > self.memory_limit) + } + + /// Evict statements nobody holds until the cache fits its limits. If every + /// statement is in use the cache stays over budget: evicting one would + /// break the client using it. + fn enforce(&mut self) { + while self.over_budget() { + let Some(&counter) = self.unused.iter().next() else { + break; + }; + self.unused.remove(&counter); + self.remove(&global_name(counter)); + } + } + /// Record a Parse message with the global cache and return a globally unique /// name PgDog is using for that statement. /// @@ -158,22 +219,20 @@ impl GlobalCache { version: 0, }; - self.statements.insert( - cache_key.clone(), - CachedStmt { - counter: self.counter, - used: 1, - }, - ); - - self.names.insert( - name.clone(), - Statement { - parse, - cache_key, - ..Default::default() - }, - ); + let cached = CachedStmt { + counter: self.counter, + used: 1, + }; + let statement = Statement { + parse, + cache_key: cache_key.clone(), + ..Default::default() + }; + + self.entry_inserted(&name, &statement, &cached); + self.statements.insert(cache_key, cached); + self.names.insert(name.clone(), statement); + self.enforce(); (true, name) } @@ -194,23 +253,21 @@ impl GlobalCache { version: self.versions, }; - self.statements.insert( - key.clone(), - CachedStmt { - counter: self.counter, - used: 1, - }, - ); + let cached = CachedStmt { + counter: self.counter, + used: 1, + }; + let statement = Statement { + parse, + version: self.versions, + cache_key: key.clone(), + ..Default::default() + }; - self.names.insert( - name.clone(), - Statement { - parse, - version: self.versions, - cache_key: key, - ..Default::default() - }, - ); + self.entry_inserted(&name, &statement, &cached); + self.statements.insert(key, cached); + self.names.insert(name.clone(), statement); + self.enforce(); name } @@ -228,6 +285,7 @@ impl GlobalCache { if let Some(ref mut entry) = self.names.get_mut(name) && entry.row_description.is_none() { + self.bytes += row_description.memory_usage(); entry.row_description = Some(row_description); } } @@ -239,6 +297,7 @@ impl GlobalCache { self.unused.clear(); self.counter = 0; self.versions = 0; + self.bytes = 0; } /// Get the query string stored in the global cache @@ -305,6 +364,9 @@ impl GlobalCache { self.remove(name); } else if entry.used == 0 { self.unused.insert(entry.counter); + // The statement just became evictable; if the cache is + // over budget, this is the moment it can shrink. + self.enforce(); } } } @@ -331,8 +393,10 @@ impl GlobalCache { /// Remove statement from global cache. fn remove(&mut self, name: &str) { - if let Some(stmt) = self.names.remove(name) { - self.statements.remove(&stmt.cache_key()); + if let Some(stmt) = self.names.remove(name) + && let Some(cached) = self.statements.remove(&stmt.cache_key()) + { + self.entry_removed(name, &stmt, &cached); } } @@ -344,6 +408,7 @@ impl GlobalCache { stmt.used = stmt.used.saturating_sub(1); if stmt.used == 0 { self.unused.insert(stmt.counter); + self.enforce(); } } } @@ -362,6 +427,166 @@ impl GlobalCache { mod test { use super::*; + use super::super::str_mem; + use crate::net::messages::Field; + + /// The incremental byte counter must equal a from-scratch recount over the + /// live entries, no matter what sequence of operations got us here. + fn recount(cache: &GlobalCache) -> usize { + cache + .names() + .iter() + .map(|(name, stmt)| str_mem(name) + stmt.memory_usage()) + .sum::() + + cache + .statements() + .iter() + .map(|(key, stmt)| key.memory_usage() + stmt.memory_usage()) + .sum::() + } + + #[test] + fn test_capacity_evicts_unused_on_insert() { + let mut cache = GlobalCache::default(); + cache.configure(10, 0); + + // Ten statements nobody uses anymore. + for i in 0..10 { + let (_, name) = cache.insert(&Parse::named("s", format!("SELECT {i:02}"))); + cache.close(&name); + } + assert_eq!(cache.len(), 10); + + // The next insert pushes an unused one out instead of growing the cache. + let (new, name) = cache.insert(&Parse::named("s", "SELECT 'over'")); + assert!(new); + assert_eq!(cache.len(), 10); + assert!(cache.parse(&name).is_some(), "the new statement is cached"); + } + + #[test] + fn test_capacity_never_evicts_statements_in_use() { + let mut cache = GlobalCache::default(); + cache.configure(5, 0); + + let mut names = vec![]; + for i in 0..10 { + let (_, name) = cache.insert(&Parse::named("s", format!("SELECT {i:02}"))); + names.push(name); + } + + // All ten are still held by clients: over capacity, but evicting any + // of them would break the client using it. + assert_eq!(cache.len(), 10); + + // As clients let go, the cache falls back to its capacity. + for name in &names { + cache.close(name); + } + assert_eq!(cache.len(), 5); + } + + #[test] + fn test_memory_limit_evicts_unused() { + // Measure what one entry costs, then budget for about three. + let mut probe = GlobalCache::default(); + let (_, name) = probe.insert(&Parse::named("s", "SELECT 00")); + probe.close(&name); + let per_entry = probe.memory_bytes(); + assert!(per_entry > 0); + + let budget = per_entry * 3 + per_entry / 2; + let mut cache = GlobalCache::default(); + cache.configure(0, budget); + + for i in 0..10 { + let (_, name) = cache.insert(&Parse::named("s", format!("SELECT {i:02}"))); + cache.close(&name); + } + + assert!( + cache.memory_bytes() <= budget, + "cache stays within its memory budget: {} <= {}", + cache.memory_bytes(), + budget + ); + assert_eq!(cache.len(), 3); + } + + #[test] + fn test_memory_limit_never_evicts_statements_in_use() { + let mut cache = GlobalCache::default(); + cache.configure(0, 1); // Nothing fits. + + let (_, name) = cache.insert(&Parse::named("s", "SELECT 1")); + assert_eq!(cache.len(), 1, "a statement in use stays regardless"); + + cache.close(&name); + assert_eq!(cache.len(), 0, "and goes as soon as nobody holds it"); + } + + #[test] + fn test_zero_limits_mean_unlimited() { + let mut cache = GlobalCache::default(); + cache.configure(0, 0); + + for i in 0..1000 { + let (_, name) = cache.insert(&Parse::named("s", format!("SELECT {i:04}"))); + cache.close(&name); + } + + assert_eq!(cache.len(), 1000); + } + + #[test] + fn test_configure_enforces_immediately() { + let mut cache = GlobalCache::default(); + + for i in 0..100 { + let (_, name) = cache.insert(&Parse::named("s", format!("SELECT {i:03}"))); + cache.close(&name); + } + assert_eq!(cache.len(), 100); + + // A reload with a smaller limit shrinks the cache on the spot. + cache.configure(10, 0); + assert_eq!(cache.len(), 10); + } + + #[test] + fn test_memory_accounting_survives_mixed_operations() { + let mut cache = GlobalCache::default(); + cache.configure(0, 0); + + let mut names = vec![]; + for i in 0..20 { + let (_, name) = cache.insert(&Parse::named("s", format!("SELECT {i:02}"))); + names.push(name); + } + + // A RowDescription recorded later grows the entry. + cache.insert_row_description(&names[0], RowDescription::new(&[Field::text("x")])); + // Duplicate insert of an existing statement adds nothing. + cache.insert(&Parse::named("s", "SELECT 00")); + // insert_anyway always creates a fresh entry. + let extra = cache.insert_anyway(&Parse::named("s", "SELECT 00")); + + for name in names.iter().chain([&extra]) { + cache.close(name); + } + cache.close(&names[0]); // The duplicate insert above took a second hold. + + assert_eq!(cache.memory_bytes(), recount(&cache)); + + // Evictions subtract what the entries actually cost. + cache.configure(5, 0); + assert_eq!(cache.len(), 5); + assert_eq!(cache.memory_bytes(), recount(&cache)); + + cache.reset(); + assert_eq!(cache.memory_bytes(), 0); + } + #[test] fn test_prep_stmt_cache_close() { let mut cache = GlobalCache::default(); diff --git a/pgdog/src/stats/pools.rs b/pgdog/src/stats/pools.rs index b6755bd8b..bfba341b4 100644 --- a/pgdog/src/stats/pools.rs +++ b/pgdog/src/stats/pools.rs @@ -344,6 +344,17 @@ impl Pools { metric_type: None, })); + metrics.push(Metric::new(PoolMetric { + name: "prepared_statements_memory_limit".into(), + measurements: vec![Measurement { + labels: vec![], + measurement: general.prepared_statements_memory_limit.into(), + }], + help: "Memory limit (bytes) for the prepared statements cache, 0 = unlimited".into(), + unit: None, + metric_type: None, + })); + metrics.push(Metric::new(PoolMetric { name: "query_cache_limit".into(), measurements: vec![Measurement { From 70907bd39b49ac416bfa6ecb5ea2c73b46452460 Mon Sep 17 00:00:00 2001 From: Igor Ohrimenko Date: Wed, 5 Aug 2026 13:29:10 +0300 Subject: [PATCH 2/7] Cover statement release through decrement() --- .../prepared_statements/global_cache.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/pgdog/src/frontend/prepared_statements/global_cache.rs b/pgdog/src/frontend/prepared_statements/global_cache.rs index 6ccd18efd..86b0d7456 100644 --- a/pgdog/src/frontend/prepared_statements/global_cache.rs +++ b/pgdog/src/frontend/prepared_statements/global_cache.rs @@ -553,6 +553,28 @@ mod test { assert_eq!(cache.len(), 10); } + #[test] + fn test_decrement_releases_for_eviction() { + let mut cache = GlobalCache::default(); + cache.configure(1, 0); + + let (_, first) = cache.insert(&Parse::named("s", "SELECT 1")); + let (_, second) = cache.insert(&Parse::named("s", "SELECT 2")); + assert_eq!( + cache.len(), + 2, + "both in use: over capacity, nothing to evict" + ); + + // decrement() is the other way a statement gets released. + cache.decrement(&first); + assert_eq!(cache.len(), 1); + assert!( + cache.parse(&second).is_some(), + "the statement still in use survives" + ); + } + #[test] fn test_memory_accounting_survives_mixed_operations() { let mut cache = GlobalCache::default(); From 57f29d9d27309a4b580a8317f714b39aa280c6ec Mon Sep 17 00:00:00 2001 From: Igor Ohrimenko Date: Wed, 5 Aug 2026 14:27:58 +0300 Subject: [PATCH 3/7] Evict oldest-first, warn when the admin sets limit 0 The unused set was a hash set, so eviction order depended on hasher state. A BTreeSet keyed by the statement counter makes it evict the oldest statement first, deterministically, and close_unused inherits the same order. SET prepared_statements_limit TO 0 used to clear the cache and now means unlimited, so log a warning pointing at RESET prepared_statements for operators relying on the old behavior. --- pgdog/src/admin/set.rs | 6 ++++++ .../prepared_statements/global_cache.rs | 19 ++++++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/pgdog/src/admin/set.rs b/pgdog/src/admin/set.rs index 916bd1520..02067eea3 100644 --- a/pgdog/src/admin/set.rs +++ b/pgdog/src/admin/set.rs @@ -117,6 +117,12 @@ impl Command for Set { "prepared_statements_limit" => { config.config.general.prepared_statements_limit = self.value.parse()?; + if config.config.general.prepared_statements_limit == 0 { + tracing::warn!( + "prepared_statements_limit set to 0, which now means unlimited; \ + to clear the cache, use RESET prepared_statements" + ); + } PreparedStatements::global().write().configure( config.config.general.prepared_statements_limit, config.config.general.prepared_statements_memory_limit, diff --git a/pgdog/src/frontend/prepared_statements/global_cache.rs b/pgdog/src/frontend/prepared_statements/global_cache.rs index 86b0d7456..88c4262a9 100644 --- a/pgdog/src/frontend/prepared_statements/global_cache.rs +++ b/pgdog/src/frontend/prepared_statements/global_cache.rs @@ -4,9 +4,10 @@ use crate::{ net::messages::{Parse, RowDescription}, stats::memory::MemoryUsage, }; -use std::{collections::hash_map::HashMap, str::from_utf8}; - -use fnv::FnvHashSet as HashSet; +use std::{ + collections::{BTreeSet, hash_map::HashMap}, + str::from_utf8, +}; use super::str_mem; @@ -114,7 +115,9 @@ impl CachedStmt { pub struct GlobalCache { statements: HashMap, names: HashMap, - unused: HashSet, + /// Statements no client is holding, ordered by creation: eviction takes + /// the oldest first, deterministically. + unused: BTreeSet, counter: usize, versions: usize, /// Maximum number of cached statements (0 = unlimited). Only statements @@ -457,11 +460,17 @@ mod test { } assert_eq!(cache.len(), 10); - // The next insert pushes an unused one out instead of growing the cache. + // The next insert pushes the oldest unused one out instead of growing + // the cache. let (new, name) = cache.insert(&Parse::named("s", "SELECT 'over'")); assert!(new); assert_eq!(cache.len(), 10); assert!(cache.parse(&name).is_some(), "the new statement is cached"); + assert!( + cache.parse("__pgdog_1").is_none(), + "eviction is deterministic: oldest unused goes first" + ); + assert!(cache.parse("__pgdog_2").is_some()); } #[test] From 6894d3bc7b85c69cadabfb850722397daa0f7d3a Mon Sep 17 00:00:00 2001 From: Igor Ohrimenko Date: Wed, 5 Aug 2026 18:05:18 +0300 Subject: [PATCH 4/7] Address review: honest accounting, unified close_unused semantics - close_unused(0) no longer wipes the whole cache and resets the name counter: it now drops everything not in use, keeps statements clients hold, and never reuses global names. The old reset path could hand a server connection a reused __pgdog_N name pointing at a different query. - RESET prepared_statements passes 0 explicitly, so it clears the cache regardless of the configured limit. With the default (unlimited) limit it was a no-op. - GlobalCache::memory_usage() now returns the same number the memory limit is enforced against, so the prepared_statements_memory_used metric and the budget can't drift apart. - Statement::memory_usage() counts the rewritten Parse; rewrite() and insert_row_description() adjust the byte total and enforce. - remove() clears the unused entry for evict_on_close closes and asserts the two maps stay in sync; enforce() uses pop_first(). - Config doc note: a limit below the working set causes constant re-preparation. --- .schema/pgdog.schema.json | 2 +- pgdog-config/src/general.rs | 2 + pgdog/src/admin/reset_prepared.rs | 9 ++- .../prepared_statements/global_cache.rs | 58 ++++++++++++++----- 4 files changed, 50 insertions(+), 21 deletions(-) diff --git a/.schema/pgdog.schema.json b/.schema/pgdog.schema.json index f28e4fae8..313dc408e 100644 --- a/.schema/pgdog.schema.json +++ b/.schema/pgdog.schema.json @@ -976,7 +976,7 @@ "minimum": 0 }, "prepared_statements_memory_limit": { - "description": "Approximate memory limit (bytes) for the global prepared statements cache. Statements no client is holding are evicted once the cache grows past it. `0` disables the limit.\n\n_Default:_ `0`\n\n", + "description": "Approximate memory limit (bytes) for the global prepared statements cache. Statements no client is holding are evicted once the cache grows past it. `0` disables the limit.\n\n**Note:** A limit smaller than the working set causes constant eviction and re-preparation of statements; size it well above what the active workload keeps in flight.\n\n_Default:_ `0`\n\n", "type": "integer", "format": "uint", "default": 0, diff --git a/pgdog-config/src/general.rs b/pgdog-config/src/general.rs index 94d7b2d0d..fcc0c9038 100644 --- a/pgdog-config/src/general.rs +++ b/pgdog-config/src/general.rs @@ -384,6 +384,8 @@ pub struct General { /// Approximate memory limit (bytes) for the global prepared statements cache. Statements no client is holding are evicted once the cache grows past it. `0` disables the limit. /// + /// **Note:** A limit smaller than the working set causes constant eviction and re-preparation of statements; size it well above what the active workload keeps in flight. + /// /// _Default:_ `0` /// /// diff --git a/pgdog/src/admin/reset_prepared.rs b/pgdog/src/admin/reset_prepared.rs index a04707626..c3cfb32de 100644 --- a/pgdog/src/admin/reset_prepared.rs +++ b/pgdog/src/admin/reset_prepared.rs @@ -1,5 +1,4 @@ //! RESET PREPARED. -use crate::config::config; use crate::frontend::prepared_statements::PreparedStatements; use super::prelude::*; @@ -17,10 +16,10 @@ impl Command for ResetPrepared { } async fn execute(&self) -> Result, Error> { - let config = config(); - PreparedStatements::global() - .write() - .close_unused(config.config.general.prepared_statements_limit); + // Explicit 0: drop everything not in use, whatever the configured + // limit is. With the default (unlimited) limit this would otherwise + // be a no-op. + PreparedStatements::global().write().close_unused(0); Ok(vec![]) } } diff --git a/pgdog/src/frontend/prepared_statements/global_cache.rs b/pgdog/src/frontend/prepared_statements/global_cache.rs index 88c4262a9..1ef0f4532 100644 --- a/pgdog/src/frontend/prepared_statements/global_cache.rs +++ b/pgdog/src/frontend/prepared_statements/global_cache.rs @@ -32,6 +32,7 @@ impl MemoryUsage for Statement { #[inline] fn memory_usage(&self) -> usize { self.parse.len() + + self.rewrite.as_ref().map(|parse| parse.len()).unwrap_or(0) + if let Some(ref row_description) = self.row_description { row_description.memory_usage() } else { @@ -133,10 +134,11 @@ pub struct GlobalCache { } impl MemoryUsage for GlobalCache { + /// The same number the memory limit is enforced against, so the metric + /// and the budget can't drift apart, plus the bookkeeping fields. #[inline] fn memory_usage(&self) -> usize { - self.statements.memory_usage() - + self.names.memory_usage() + self.bytes + self.counter.memory_usage() + self.versions.memory_usage() + self.unused.len() * std::mem::size_of::() @@ -185,10 +187,9 @@ impl GlobalCache { /// break the client using it. fn enforce(&mut self) { while self.over_budget() { - let Some(&counter) = self.unused.iter().next() else { + let Some(counter) = self.unused.pop_first() else { break; }; - self.unused.remove(&counter); self.remove(&global_name(counter)); } } @@ -278,7 +279,12 @@ impl GlobalCache { /// Rewrite prepared statement in the global cache. pub fn rewrite(&mut self, parse: &Parse) { if let Some(stmt) = self.names.get_mut(parse.name()) { + if let Some(old) = stmt.rewrite.take() { + self.bytes = self.bytes.saturating_sub(old.len()); + } + self.bytes += parse.len(); stmt.rewrite = Some(parse.clone()); + self.enforce(); } } @@ -290,6 +296,7 @@ impl GlobalCache { { self.bytes += row_description.memory_usage(); entry.row_description = Some(row_description); + self.enforce(); } } @@ -375,14 +382,11 @@ impl GlobalCache { } } - /// Close all unused statements exceeding capacity. + /// Close unused statements until the cache is down to `capacity` entries, + /// or nothing unused is left. `0` removes every statement not in use; + /// statements clients hold, and the name counter, are never touched, so + /// global names are not reused. pub fn close_unused(&mut self, capacity: usize) -> usize { - if capacity == 0 { - let removed = self.len(); - self.reset(); - return removed; - } - let over = self.len().saturating_sub(capacity); let remove = self.unused.iter().take(over).copied().collect::>(); @@ -396,10 +400,13 @@ impl GlobalCache { /// Remove statement from global cache. fn remove(&mut self, name: &str) { - if let Some(stmt) = self.names.remove(name) - && let Some(cached) = self.statements.remove(&stmt.cache_key()) - { - self.entry_removed(name, &stmt, &cached); + if let Some(stmt) = self.names.remove(name) { + let cached = self.statements.remove(&stmt.cache_key()); + debug_assert!(cached.is_some(), "names and statements maps out of sync"); + if let Some(cached) = cached { + self.unused.remove(&cached.counter); + self.entry_removed(name, &stmt, &cached); + } } } @@ -584,6 +591,24 @@ mod test { ); } + #[test] + fn test_close_unused_zero_keeps_in_use_and_counter() { + let mut cache = GlobalCache::default(); + + let (_, held) = cache.insert(&Parse::named("s", "SELECT 'held'")); + let (_, released) = cache.insert(&Parse::named("s", "SELECT 'released'")); + cache.close(&released); + + assert_eq!(cache.close_unused(0), 1); + assert!(cache.parse(&held).is_some(), "statements in use survive"); + assert!(cache.parse(&released).is_none()); + + // The counter moves on: global names are never reused, so server + // connections holding old names can't be handed a different query. + let (_, next) = cache.insert(&Parse::named("s", "SELECT 'next'")); + assert_eq!(next, "__pgdog_3"); + } + #[test] fn test_memory_accounting_survives_mixed_operations() { let mut cache = GlobalCache::default(); @@ -597,6 +622,9 @@ mod test { // A RowDescription recorded later grows the entry. cache.insert_row_description(&names[0], RowDescription::new(&[Field::text("x")])); + // So does a rewritten Parse, twice to cover the replacement path. + cache.rewrite(&Parse::named(&names[1], "SELECT 1, 2")); + cache.rewrite(&Parse::named(&names[1], "SELECT 1, 2, 3")); // Duplicate insert of an existing statement adds nothing. cache.insert(&Parse::named("s", "SELECT 00")); // insert_anyway always creates a fresh entry. From 0dc8a3423a63fc6ef2773715663b2007d2ef2b85 Mon Sep 17 00:00:00 2001 From: Igor Ohrimenko Date: Wed, 5 Aug 2026 19:46:33 +0300 Subject: [PATCH 5/7] Keep 0 meaning unlimited in maintenance, make reset() test-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_maintenance() passed prepared_statements_limit straight into close_unused(), where 0 now means "drop everything unused" — the exact opposite of the "unlimited" this limit documents. Re-apply the configured limits through configure() instead: 0 flows through over_budget() as unlimited, the memory limit gets the same safety net, and the maintenance tick stays a no-op when runtime enforcement has already done the work. GlobalCache::reset() lost its last production caller when close_unused stopped wiping the cache; keep it for tests only, so the path that rolls the name counter back can't quietly return. --- .../frontend/prepared_statements/global_cache.rs | 5 ++++- pgdog/src/frontend/prepared_statements/mod.rs | 13 +++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/pgdog/src/frontend/prepared_statements/global_cache.rs b/pgdog/src/frontend/prepared_statements/global_cache.rs index 1ef0f4532..bcc645406 100644 --- a/pgdog/src/frontend/prepared_statements/global_cache.rs +++ b/pgdog/src/frontend/prepared_statements/global_cache.rs @@ -300,7 +300,10 @@ impl GlobalCache { } } - /// Clear the global cache. + /// Clear the global cache. Test-only: rolling the name counter back + /// would let a server connection holding an old `__pgdog_N` name be + /// handed a different query under it. + #[cfg(test)] pub fn reset(&mut self) { self.statements.clear(); self.names.clear(); diff --git a/pgdog/src/frontend/prepared_statements/mod.rs b/pgdog/src/frontend/prepared_statements/mod.rs index 310e668d7..f3c45f487 100644 --- a/pgdog/src/frontend/prepared_statements/mod.rs +++ b/pgdog/src/frontend/prepared_statements/mod.rs @@ -184,11 +184,16 @@ pub fn start_maintenance() { }); } -/// Check prepared statements cache for overflows -/// and remove any unused statements exceeding the limit. +/// Re-apply the configured cache limits, evicting anything unused over +/// them. A safety net behind the enforcement that already runs when the +/// cache grows or a statement is released; `0` means unlimited here the +/// same as everywhere else. pub fn run_maintenance() { - let capacity = config().config.general.prepared_statements_limit; - PreparedStatements::global().write().close_unused(capacity); + let general = &config().config.general; + PreparedStatements::global().write().configure( + general.prepared_statements_limit, + general.prepared_statements_memory_limit, + ); } #[cfg(test)] From 4b2c55ba16e8861111acb5effe6a1eda28240079 Mon Sep 17 00:00:00 2001 From: Igor Ohrimenko Date: Thu, 6 Aug 2026 00:15:13 +0300 Subject: [PATCH 6/7] Point the limit-0 warning at RESET PREPARED The admin parser accepts RESET PREPARED, not RESET prepared_statements: the warning was advising a command that answers with a syntax error. Verified against a live admin console. --- pgdog/src/admin/set.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pgdog/src/admin/set.rs b/pgdog/src/admin/set.rs index 02067eea3..f2800411e 100644 --- a/pgdog/src/admin/set.rs +++ b/pgdog/src/admin/set.rs @@ -120,7 +120,7 @@ impl Command for Set { if config.config.general.prepared_statements_limit == 0 { tracing::warn!( "prepared_statements_limit set to 0, which now means unlimited; \ - to clear the cache, use RESET prepared_statements" + to clear the cache, use RESET PREPARED" ); } PreparedStatements::global().write().configure( From e34343c466debc3a920eff2882318c1a81383f89 Mon Sep 17 00:00:00 2001 From: Igor Ohrimenko Date: Thu, 6 Aug 2026 00:36:17 +0300 Subject: [PATCH 7/7] Pin the advised admin command to the parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The limit-0 warning quoted a command spelling the admin parser doesn't accept; nothing tied the two together. Put the spelling in one const — ResetPrepared::name(), the warning and the parser test all use it — so the advice can't drift from what actually parses. Also covers RESET PREPARED in the parser tests at all: RESET QUERY_CACHE had a test, this one didn't. --- pgdog/src/admin/parser.rs | 8 ++++++++ pgdog/src/admin/reset_prepared.rs | 6 +++++- pgdog/src/admin/set.rs | 3 ++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/pgdog/src/admin/parser.rs b/pgdog/src/admin/parser.rs index 0b17000cf..cce6ea8ee 100644 --- a/pgdog/src/admin/parser.rs +++ b/pgdog/src/admin/parser.rs @@ -255,6 +255,14 @@ mod tests { assert!(matches!(result, Ok(ParseResult::ResetQueryCache(_)))); } + #[test] + fn parses_reset_prepared_command() { + // The exact string the prepared_statements_limit=0 warning advises: + // if this stops parsing, the advice is broken. + let result = Parser::parse(super::super::reset_prepared::RESET_PREPARED); + assert!(matches!(result, Ok(ParseResult::ResetPrepared(_)))); + } + #[test] fn rejects_unknown_admin_command() { let result = Parser::parse("FOO BAR"); diff --git a/pgdog/src/admin/reset_prepared.rs b/pgdog/src/admin/reset_prepared.rs index c3cfb32de..130e6cc85 100644 --- a/pgdog/src/admin/reset_prepared.rs +++ b/pgdog/src/admin/reset_prepared.rs @@ -3,12 +3,16 @@ use crate::frontend::prepared_statements::PreparedStatements; use super::prelude::*; +/// The admin console spelling of this command. The limit-0 warning quotes +/// it, and a parser test keeps the advice parseable. +pub(super) const RESET_PREPARED: &str = "RESET PREPARED"; + pub struct ResetPrepared; #[async_trait] impl Command for ResetPrepared { fn name(&self) -> String { - "RESET PREPARED".into() + RESET_PREPARED.into() } fn parse(_: &str) -> Result { diff --git a/pgdog/src/admin/set.rs b/pgdog/src/admin/set.rs index f2800411e..f296cc851 100644 --- a/pgdog/src/admin/set.rs +++ b/pgdog/src/admin/set.rs @@ -120,7 +120,8 @@ impl Command for Set { if config.config.general.prepared_statements_limit == 0 { tracing::warn!( "prepared_statements_limit set to 0, which now means unlimited; \ - to clear the cache, use RESET PREPARED" + to clear the cache, use {}", + super::reset_prepared::RESET_PREPARED, ); } PreparedStatements::global().write().configure(