From b7d8faadf7ee427543393404800dd586209b031a Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 7 Jul 2026 20:56:14 +0200 Subject: [PATCH] fix: size the DB pool and fail fast on saturation The single Postgres pool (shared by HTTP handlers and the crawler/analysis daemons) hardcoded max_connections=10 with no acquire_timeout, so under saturation callers hung on the driver's silent 30s default. Add a DbConfig (DB_MAX_CONNECTIONS default 20, DB_ACQUIRE_TIMEOUT_SECS default 10, clamped to >=1) and apply both in PgPoolOptions. Bump to 0.124.4. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- backend/src/app.rs | 3 +- backend/src/config.rs | 72 +++++++++++++++++++++++++++++++++++++++++++ frontend/package.json | 2 +- 5 files changed, 77 insertions(+), 4 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 69e36a9..cae7461 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.124.3" +version = "0.124.4" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 69a3500..2cb9c3e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.124.3" +version = "0.124.4" edition = "2021" default-run = "mangalord" diff --git a/backend/src/app.rs b/backend/src/app.rs index 5e2e8ce..2dfd2eb 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -279,7 +279,8 @@ impl DaemonReloader for Supervisors { pub async fn build(config: Config) -> anyhow::Result { let db = PgPoolOptions::new() - .max_connections(10) + .max_connections(config.db.max_connections) + .acquire_timeout(config.db.acquire_timeout) .connect(&config.database_url) .await?; sqlx::migrate!("./migrations").run(&db).await?; diff --git a/backend/src/config.rs b/backend/src/config.rs index 9a6dda5..06da544 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -72,6 +72,45 @@ impl Default for UploadConfig { } } +/// Postgres connection-pool sizing. One pool backs every HTTP handler plus +/// the crawler/analysis daemons, so it must be large enough not to starve +/// interactive reads and fail fast (rather than hang on the driver's silent +/// 30 s default) when genuinely saturated. +#[derive(Clone, Debug)] +pub struct DbConfig { + /// `DB_MAX_CONNECTIONS`. Upper bound on open connections. + pub max_connections: u32, + /// `DB_ACQUIRE_TIMEOUT_SECS`. How long a caller waits for a free + /// connection before erroring — short so overload surfaces as a fast + /// 500 instead of a 30 s hang. + pub acquire_timeout: Duration, +} + +impl Default for DbConfig { + fn default() -> Self { + Self { + max_connections: 20, + acquire_timeout: Duration::from_secs(10), + } + } +} + +impl DbConfig { + pub fn from_env() -> Self { + let default = Self::default(); + Self { + // `.max(1)`: a zero-size pool can never hand out a connection and + // would deadlock every query — clamp to at least one. + max_connections: env_u64("DB_MAX_CONNECTIONS", default.max_connections.into()) + .max(1) as u32, + acquire_timeout: Duration::from_secs(env_u64( + "DB_ACQUIRE_TIMEOUT_SECS", + default.acquire_timeout.as_secs(), + )), + } + } +} + /// How the worker asks the model to constrain its output. OpenAI-compatible /// servers disagree here: LM Studio accepts only `json_schema` or `text` /// (NOT `json_object`); vanilla OpenAI/vLLM accept `json_object` too. The @@ -371,6 +410,7 @@ pub struct Config { pub database_url: String, pub bind_address: String, pub storage_dir: PathBuf, + pub db: DbConfig, pub auth: AuthConfig, pub upload: UploadConfig, pub cors_allowed_origins: Vec, @@ -517,6 +557,7 @@ impl Config { storage_dir: std::env::var("STORAGE_DIR") .unwrap_or_else(|_| "./data/storage".to_string()) .into(), + db: DbConfig::from_env(), auth: AuthConfig { cookie_secure: env_bool("COOKIE_SECURE", true), cookie_domain: std::env::var("COOKIE_DOMAIN") @@ -813,6 +854,37 @@ mod tests { assert_eq!(cfg.browser_restart_threshold, 7); } + #[test] + fn db_pool_defaults_when_unset() { + let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + std::env::remove_var("DB_MAX_CONNECTIONS"); + std::env::remove_var("DB_ACQUIRE_TIMEOUT_SECS"); + let cfg = DbConfig::from_env(); + assert_eq!(cfg.max_connections, 20); + assert_eq!(cfg.acquire_timeout, Duration::from_secs(10)); + } + + #[test] + fn db_pool_parses_from_env() { + let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + std::env::set_var("DB_MAX_CONNECTIONS", "50"); + std::env::set_var("DB_ACQUIRE_TIMEOUT_SECS", "3"); + let cfg = DbConfig::from_env(); + std::env::remove_var("DB_MAX_CONNECTIONS"); + std::env::remove_var("DB_ACQUIRE_TIMEOUT_SECS"); + assert_eq!(cfg.max_connections, 50); + assert_eq!(cfg.acquire_timeout, Duration::from_secs(3)); + } + + #[test] + fn db_pool_max_connections_clamps_to_at_least_one() { + let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + std::env::set_var("DB_MAX_CONNECTIONS", "0"); + let cfg = DbConfig::from_env(); + std::env::remove_var("DB_MAX_CONNECTIONS"); + assert_eq!(cfg.max_connections, 1); + } + #[test] fn analysis_config_defaults_when_unset() { let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); diff --git a/frontend/package.json b/frontend/package.json index 7f4a776..0ffa3bf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.124.3", + "version": "0.124.4", "private": true, "type": "module", "scripts": {