From b380ba3e073919a487082fadcd15868e93a0c889 Mon Sep 17 00:00:00 2001 From: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc Date: Mon, 3 Aug 2026 18:12:50 -0400 Subject: [PATCH] Make the Postgres writer pool minimum configurable The relay could size the pool ceiling via BUZZ_DB_POOL_SIZE but had no way to set the floor: DbConfig::min_connections existed and was applied, yet the only value it ever took in production was the hardcoded 2 from DbConfig::default(), because relay main filled the rest of the struct from that default. Add BUZZ_DB_MIN_POOL_SIZE -> Config::db_pool_min_size (default 20) and wire it into the writer pool explicitly, mirroring how the maximum is already plumbed. The default lives at the relay env boundary rather than in DbConfig::default() on purpose. buzz-admin also constructs DbConfig with ..DbConfig::default() and is a short-lived CLI that connects, performs one operation and exits, so raising the library default would make every admin invocation eagerly open 20 sessions for no throughput benefit. The value is clamped to db_pool_size. sqlx obeys max_connections regardless (try_min_connections bails when the semaphore has no permit) so an unclamped pair is not a hang, but sqlx stores the requested minimum verbatim and documents that applications allowing either value to be set dynamically should validate the pair themselves. Without the clamp, pool.options() would report an impossible min > max. Unlike the pool maxima, 0 is honoured rather than rejected: a minimum of zero is a meaningful setting (a fully lazy pool, which is what the read-replica pool pins deliberately), whereas a maximum of zero is not. Reusing the maxima's filter(v > 0) would make the one value an operator would reach for to stop pre-warming silently mean 20 instead. The reader pool is untouched: it stays pinned at 0 and lazy so a replica that is down at boot cannot gate relay startup. Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc --- .env.example | 7 ++ crates/buzz-relay/src/config.rs | 124 ++++++++++++++++++++++++++++++++ crates/buzz-relay/src/main.rs | 1 + 3 files changed, 132 insertions(+) diff --git a/.env.example b/.env.example index b9bfcada0e..07a98e3830 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,13 @@ REDIS_URL=redis://localhost:6379 # READ_DATABASE_URL is set, reader (default 50). # BUZZ_DB_POOL_SIZE=50 +# Minimum idle connections kept in the relay's Postgres writer pool +# (default 20). Pre-warms the pool so requests after a deploy or an idle +# period skip the connect path. Clamped to BUZZ_DB_POOL_SIZE. Set to 0 for a +# fully lazy pool. Does not apply to the reader pool, which stays lazy so a +# replica that is down at boot cannot gate startup. +# BUZZ_DB_MIN_POOL_SIZE=20 + # ----------------------------------------------------------------------------- # Typesense (search) # ----------------------------------------------------------------------------- diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 85a0ca2efe..ba39d4ed33 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -13,6 +13,11 @@ use tracing::warn; /// NIP-44 encryption overhead. pub const DEFAULT_MAX_FRAME_BYTES: usize = 512 * 1024; +/// Default minimum idle connections in the Postgres writer pool. +/// +/// Named so the value is asserted in tests rather than duplicated as a literal. +pub const DEFAULT_DB_POOL_MIN_SIZE: u32 = 20; + /// Errors that can occur while loading relay configuration. #[derive(Debug, Error)] pub enum ConfigError { @@ -76,6 +81,21 @@ pub struct Config { /// the per-pod pool and requests fail on acquire timeout while the /// database sits idle. pub db_pool_size: u32, + /// Minimum idle connections kept in the Postgres writer pool + /// (`BUZZ_DB_MIN_POOL_SIZE`). Defaults to 20. + /// + /// Pre-warming the pool keeps the first requests after a deploy or an + /// idle period off the connect path, which against Aurora costs a TLS + /// handshake per connection. Clamped to [`Self::db_pool_size`]: sqlx + /// obeys the maximum regardless, but stores the requested minimum + /// verbatim, so an unclamped pair would misreport itself in + /// `pool.options()` for no behavioural gain. + /// + /// Applies to the writer pool only. The read-replica pool is built lazily + /// with a pinned minimum of 0 so a replica that is down at boot cannot + /// gate relay startup — pre-warming it would reintroduce exactly that + /// coupling. + pub db_pool_min_size: u32, /// Maximum connections in the Postgres read-replica pool /// (`BUZZ_DB_READ_POOL_SIZE`). Defaults to `db_pool_size`. Sized /// independently so reader capacity can be tuned against the replica's @@ -473,6 +493,21 @@ impl Config { .and_then(|v| v.parse::().ok()) .filter(|&v| v > 0); + // Unlike the pool maxima, `0` is honoured rather than rejected: a + // minimum of zero is a meaningful setting (a fully lazy pool, which is + // what the read-replica pool pins deliberately), whereas a maximum of + // zero is not. Rejecting it would make the one value an operator would + // reach for to stop pre-warming mean the opposite. Unparsable values + // still fall back to the default. + let db_pool_min_size = std::env::var("BUZZ_DB_MIN_POOL_SIZE") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_DB_POOL_MIN_SIZE) + // sqlx never opens more than `max_connections` regardless, but it + // reports back whatever minimum it was handed; clamping here keeps + // the pool's own introspection honest. + .min(db_pool_size); + let relay_url = std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()); @@ -937,6 +972,7 @@ impl Config { redis_url, redis_pool_size, db_pool_size, + db_pool_min_size, db_read_pool_size, relay_url, pairing_relay_url, @@ -1006,6 +1042,7 @@ mod tests { assert!(!config.redis_url.is_empty()); assert_eq!(config.redis_pool_size, 16); assert_eq!(config.db_pool_size, 50); + assert_eq!(config.db_pool_min_size, DEFAULT_DB_POOL_MIN_SIZE); assert!(config.max_connections > 0); assert!(config.send_buffer_size > 0); assert_eq!(config.max_frame_bytes, DEFAULT_MAX_FRAME_BYTES); @@ -1158,6 +1195,93 @@ mod tests { assert_eq!(junk, 50, "unparsable value must fall back to the default"); } + #[test] + fn db_pool_min_size_env_override_and_invalid_fallback() { + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_DB_MIN_POOL_SIZE"); + + std::env::remove_var("BUZZ_DB_MIN_POOL_SIZE"); + let unset = Config::from_env().expect("config").db_pool_min_size; + + std::env::set_var("BUZZ_DB_MIN_POOL_SIZE", "35"); + let overridden = Config::from_env().expect("config").db_pool_min_size; + + // `0` is a meaningful minimum (fully lazy pool), so unlike the maxima + // it must be honoured rather than falling back to the default. + std::env::set_var("BUZZ_DB_MIN_POOL_SIZE", "0"); + let zero = Config::from_env().expect("config").db_pool_min_size; + + std::env::set_var("BUZZ_DB_MIN_POOL_SIZE", "not-a-number"); + let junk = Config::from_env().expect("config").db_pool_min_size; + + if let Some(value) = previous { + std::env::set_var("BUZZ_DB_MIN_POOL_SIZE", value); + } else { + std::env::remove_var("BUZZ_DB_MIN_POOL_SIZE"); + } + + assert_eq!( + unset, DEFAULT_DB_POOL_MIN_SIZE, + "unset must use the default minimum" + ); + assert_eq!(overridden, 35); + assert_eq!(zero, 0, "zero must be honoured, not treated as unset"); + assert_eq!( + junk, DEFAULT_DB_POOL_MIN_SIZE, + "unparsable value must fall back to the default" + ); + } + + /// The minimum must never exceed the maximum. sqlx obeys the maximum + /// regardless, but stores the requested minimum verbatim, so without this + /// clamp `pool.options()` would report an impossible pair. + #[test] + fn db_pool_min_size_is_clamped_to_max() { + let _guard = ENV_MUTEX.lock().unwrap(); + let previous_min = std::env::var_os("BUZZ_DB_MIN_POOL_SIZE"); + let previous_max = std::env::var_os("BUZZ_DB_POOL_SIZE"); + + // Explicit minimum above an explicit maximum. + std::env::set_var("BUZZ_DB_POOL_SIZE", "8"); + std::env::set_var("BUZZ_DB_MIN_POOL_SIZE", "30"); + let explicit = Config::from_env().expect("config"); + + // Default minimum (20) against a smaller explicit maximum: the clamp + // must fire without the operator naming a minimum at all. + std::env::remove_var("BUZZ_DB_MIN_POOL_SIZE"); + let defaulted = Config::from_env().expect("config"); + + // A maximum above the minimum must leave the minimum untouched. + std::env::set_var("BUZZ_DB_POOL_SIZE", "60"); + std::env::set_var("BUZZ_DB_MIN_POOL_SIZE", "30"); + let headroom = Config::from_env().expect("config"); + + for (key, value) in [ + ("BUZZ_DB_MIN_POOL_SIZE", previous_min), + ("BUZZ_DB_POOL_SIZE", previous_max), + ] { + if let Some(value) = value { + std::env::set_var(key, value); + } else { + std::env::remove_var(key); + } + } + + assert_eq!(explicit.db_pool_size, 8); + assert_eq!( + explicit.db_pool_min_size, 8, + "explicit minimum above the maximum must clamp to the maximum" + ); + assert_eq!( + defaulted.db_pool_min_size, 8, + "default minimum above a smaller maximum must clamp to the maximum" + ); + assert_eq!( + headroom.db_pool_min_size, 30, + "a minimum below the maximum must pass through unchanged" + ); + } + #[test] fn db_read_pool_size_env_override_and_invalid_fallback() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 799cf9cf60..18593eb437 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -168,6 +168,7 @@ async fn main() -> anyhow::Result<()> { read_database_url: config.read_database_url.clone(), replica_read_max_age_ms: config.replica_read_max_age_ms, max_connections: config.db_pool_size, + min_connections: config.db_pool_min_size, read_max_connections: config.db_read_pool_size, ..DbConfig::default() };