From 20f2a0be09afeee5ddb30c774c07e9628eb729bf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Jan 2026 10:37:42 +0000 Subject: [PATCH 1/5] Allow disabling database connection timeouts via config Co-authored-by: contact --- configuration.md | 4 ++-- src/app_config.rs | 8 ++----- src/webserver/database/connect.rs | 40 +++++++++++++++++-------------- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/configuration.md b/configuration.md index 77e6b322..e401113c 100644 --- a/configuration.md +++ b/configuration.md @@ -15,8 +15,8 @@ Here are the available configuration options and their default values: | `unix_socket` | | Path to a UNIX socket to listen on instead of the TCP port. If specified, SQLPage will accept HTTP connections only on this socket and not on any TCP port. This option is mutually exclusive with `listen_on` and `port`. | `host` | | The web address where your application is accessible (e.g., "myapp.example.com"). Used for login redirects with OIDC. | | `max_database_pool_connections` | PostgreSQL: 50
MySql: 75
SQLite: 16
MSSQL: 100 | How many simultaneous database connections to open at most | -| `database_connection_idle_timeout_seconds` | SQLite: None
All other: 30 minutes | Automatically close database connections after this period of inactivity | -| `database_connection_max_lifetime_seconds` | SQLite: None
All other: 60 minutes | Always close database connections after this amount of time | +| `database_connection_idle_timeout_seconds` | SQLite: None
All other: 30 minutes | Automatically close database connections after this period of inactivity. Set to 0 or a negative value to disable. | +| `database_connection_max_lifetime_seconds` | SQLite: None
All other: 60 minutes | Always close database connections after this amount of time. Set to 0 or a negative value to disable. | | `database_connection_retries` | 6 | Database connection attempts before giving up. Retries will happen every 5 seconds. | | `database_connection_acquire_timeout_seconds` | 10 | How long to wait when acquiring a database connection from the pool before giving up and returning an error. | | `sqlite_extensions` | | An array of SQLite extensions to load, such as `mod_spatialite` | diff --git a/src/app_config.rs b/src/app_config.rs index 5ebcab63..3012c481 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -109,16 +109,12 @@ impl AppConfig { } if let Some(idle_timeout) = self.database_connection_idle_timeout_seconds { if idle_timeout < 0.0 { - return Err(anyhow::anyhow!( - "Database connection idle timeout must be non-negative" - )); + log::warn!("Database connection idle timeout is negative, this will disable the timeout"); } } if let Some(max_lifetime) = self.database_connection_max_lifetime_seconds { if max_lifetime < 0.0 { - return Err(anyhow::anyhow!( - "Database connection max lifetime must be non-negative" - )); + log::warn!("Database connection max lifetime is negative, this will disable the timeout"); } } anyhow::ensure!(self.max_pending_rows > 0, "max_pending_rows cannot be null"); diff --git a/src/webserver/database/connect.rs b/src/webserver/database/connect.rs index 4ece367a..83f2a706 100644 --- a/src/webserver/database/connect.rs +++ b/src/webserver/database/connect.rs @@ -89,24 +89,20 @@ impl Database { AnyKind::Mssql => 100, } }) - .idle_timeout( - config - .database_connection_idle_timeout_seconds - .map(Duration::from_secs_f64) - .or_else(|| match kind { - AnyKind::Sqlite => None, - _ => Some(Duration::from_secs(30 * 60)), - }), - ) - .max_lifetime( - config - .database_connection_max_lifetime_seconds - .map(Duration::from_secs_f64) - .or_else(|| match kind { - AnyKind::Sqlite => None, - _ => Some(Duration::from_secs(60 * 60)), - }), - ) + .idle_timeout(resolve_timeout( + config.database_connection_idle_timeout_seconds, + match kind { + AnyKind::Sqlite => None, + _ => Some(Duration::from_secs(30 * 60)), + }, + )) + .max_lifetime(resolve_timeout( + config.database_connection_max_lifetime_seconds, + match kind { + AnyKind::Sqlite => None, + _ => Some(Duration::from_secs(60 * 60)), + }, + )) .acquire_timeout(Duration::from_secs_f64( config.database_connection_acquire_timeout_seconds, )); @@ -266,3 +262,11 @@ fn set_database_password(options: &mut AnyConnectOptions, password: &str) { unreachable!("Unsupported database type"); } } + +fn resolve_timeout(config_val: Option, default: Option) -> Option { + match config_val { + Some(v) if v <= 0.0 || !v.is_finite() => None, + Some(v) => Some(Duration::from_secs_f64(v)), + None => default, + } +} From fc94be648f56393022a32b3bc05d9d3c4859860b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Jan 2026 10:41:14 +0000 Subject: [PATCH 2/5] Simplify documentation for disabling database timeouts Co-authored-by: contact --- configuration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/configuration.md b/configuration.md index e401113c..c5911799 100644 --- a/configuration.md +++ b/configuration.md @@ -15,8 +15,8 @@ Here are the available configuration options and their default values: | `unix_socket` | | Path to a UNIX socket to listen on instead of the TCP port. If specified, SQLPage will accept HTTP connections only on this socket and not on any TCP port. This option is mutually exclusive with `listen_on` and `port`. | `host` | | The web address where your application is accessible (e.g., "myapp.example.com"). Used for login redirects with OIDC. | | `max_database_pool_connections` | PostgreSQL: 50
MySql: 75
SQLite: 16
MSSQL: 100 | How many simultaneous database connections to open at most | -| `database_connection_idle_timeout_seconds` | SQLite: None
All other: 30 minutes | Automatically close database connections after this period of inactivity. Set to 0 or a negative value to disable. | -| `database_connection_max_lifetime_seconds` | SQLite: None
All other: 60 minutes | Always close database connections after this amount of time. Set to 0 or a negative value to disable. | +| `database_connection_idle_timeout_seconds` | SQLite: None
All other: 30 minutes | Automatically close database connections after this period of inactivity. Set to 0 to disable. | +| `database_connection_max_lifetime_seconds` | SQLite: None
All other: 60 minutes | Always close database connections after this amount of time. Set to 0 to disable. | | `database_connection_retries` | 6 | Database connection attempts before giving up. Retries will happen every 5 seconds. | | `database_connection_acquire_timeout_seconds` | 10 | How long to wait when acquiring a database connection from the pool before giving up and returning an error. | | `sqlite_extensions` | | An array of SQLite extensions to load, such as `mod_spatialite` | From d2e6e13178984f4394c93221b6e08765bcd23e5c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Jan 2026 10:48:09 +0000 Subject: [PATCH 3/5] Refactor timeout resolution to AppConfig Co-authored-by: contact --- src/app_config.rs | 52 +++++++++++++++++++++++++++---- src/webserver/database/connect.rs | 24 ++------------ 2 files changed, 48 insertions(+), 28 deletions(-) diff --git a/src/app_config.rs b/src/app_config.rs index 3012c481..784cce20 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -9,6 +9,7 @@ use serde::de::Error; use serde::{Deserialize, Deserializer, Serialize}; use std::net::{SocketAddr, ToSocketAddrs}; use std::path::{Path, PathBuf}; +use std::time::Duration; #[cfg(not(feature = "lambda-web"))] const DEFAULT_DATABASE_FILE: &str = "sqlpage.db"; @@ -73,6 +74,8 @@ impl AppConfig { .validate() .context("The provided configuration is invalid")?; + config.resolve_timeouts(); + log::debug!("Loaded configuration: {config:#?}"); log::info!( "Configuration loaded from {}", @@ -82,6 +85,26 @@ impl AppConfig { Ok(config) } + fn resolve_timeouts(&mut self) { + let is_sqlite = self.database_url.starts_with("sqlite:"); + self.database_connection_idle_timeout = resolve_timeout( + self.database_connection_idle_timeout_seconds_raw, + if is_sqlite { + None + } else { + Some(Duration::from_secs(30 * 60)) + }, + ); + self.database_connection_max_lifetime = resolve_timeout( + self.database_connection_max_lifetime_seconds_raw, + if is_sqlite { + None + } else { + Some(Duration::from_secs(60 * 60)) + }, + ); + } + fn validate(&self) -> anyhow::Result<()> { if !self.web_root.is_dir() { return Err(anyhow::anyhow!( @@ -107,12 +130,12 @@ impl AppConfig { )); } } - if let Some(idle_timeout) = self.database_connection_idle_timeout_seconds { + if let Some(idle_timeout) = self.database_connection_idle_timeout_seconds_raw { if idle_timeout < 0.0 { log::warn!("Database connection idle timeout is negative, this will disable the timeout"); } } - if let Some(max_lifetime) = self.database_connection_max_lifetime_seconds { + if let Some(max_lifetime) = self.database_connection_max_lifetime_seconds_raw { if max_lifetime < 0.0 { log::warn!("Database connection max lifetime is negative, this will disable the timeout"); } @@ -142,8 +165,15 @@ pub struct AppConfig { #[serde(default)] pub database_password: Option, pub max_database_pool_connections: Option, - pub database_connection_idle_timeout_seconds: Option, - pub database_connection_max_lifetime_seconds: Option, + #[serde(rename = "database_connection_idle_timeout_seconds")] + pub database_connection_idle_timeout_seconds_raw: Option, + #[serde(rename = "database_connection_max_lifetime_seconds")] + pub database_connection_max_lifetime_seconds_raw: Option, + + #[serde(skip)] + pub database_connection_idle_timeout: Option, + #[serde(skip)] + pub database_connection_max_lifetime: Option, #[serde(default)] pub sqlite_extensions: Vec, @@ -607,6 +637,14 @@ impl DevOrProd { } } +fn resolve_timeout(config_val: Option, default: Option) -> Option { + match config_val { + Some(v) if v <= 0.0 || !v.is_finite() => None, + Some(v) => Some(Duration::from_secs_f64(v)), + None => default, + } +} + #[must_use] pub fn test_database_url() -> String { std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite::memory:".to_string()) @@ -619,14 +657,16 @@ pub mod tests { #[must_use] pub fn test_config() -> AppConfig { - serde_json::from_str::( + let mut config = serde_json::from_str::( &serde_json::json!({ "database_url": test_database_url(), "listen_on": "localhost:8080" }) .to_string(), ) - .unwrap() + .unwrap(); + config.resolve_timeouts(); + config } } diff --git a/src/webserver/database/connect.rs b/src/webserver/database/connect.rs index 83f2a706..d96f2b5a 100644 --- a/src/webserver/database/connect.rs +++ b/src/webserver/database/connect.rs @@ -89,20 +89,8 @@ impl Database { AnyKind::Mssql => 100, } }) - .idle_timeout(resolve_timeout( - config.database_connection_idle_timeout_seconds, - match kind { - AnyKind::Sqlite => None, - _ => Some(Duration::from_secs(30 * 60)), - }, - )) - .max_lifetime(resolve_timeout( - config.database_connection_max_lifetime_seconds, - match kind { - AnyKind::Sqlite => None, - _ => Some(Duration::from_secs(60 * 60)), - }, - )) + .idle_timeout(config.database_connection_idle_timeout) + .max_lifetime(config.database_connection_max_lifetime) .acquire_timeout(Duration::from_secs_f64( config.database_connection_acquire_timeout_seconds, )); @@ -262,11 +250,3 @@ fn set_database_password(options: &mut AnyConnectOptions, password: &str) { unreachable!("Unsupported database type"); } } - -fn resolve_timeout(config_val: Option, default: Option) -> Option { - match config_val { - Some(v) if v <= 0.0 || !v.is_finite() => None, - Some(v) => Some(Duration::from_secs_f64(v)), - None => default, - } -} From 4c193b580d0c53c051d97e5ad7da48249ea398d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Jan 2026 11:03:57 +0000 Subject: [PATCH 4/5] Remove *_raw fields and use custom deserializer for timeouts Co-authored-by: contact --- src/app_config.rs | 41 +++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/src/app_config.rs b/src/app_config.rs index 784cce20..a405c89f 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -88,7 +88,7 @@ impl AppConfig { fn resolve_timeouts(&mut self) { let is_sqlite = self.database_url.starts_with("sqlite:"); self.database_connection_idle_timeout = resolve_timeout( - self.database_connection_idle_timeout_seconds_raw, + self.database_connection_idle_timeout, if is_sqlite { None } else { @@ -96,7 +96,7 @@ impl AppConfig { }, ); self.database_connection_max_lifetime = resolve_timeout( - self.database_connection_max_lifetime_seconds_raw, + self.database_connection_max_lifetime, if is_sqlite { None } else { @@ -130,16 +130,6 @@ impl AppConfig { )); } } - if let Some(idle_timeout) = self.database_connection_idle_timeout_seconds_raw { - if idle_timeout < 0.0 { - log::warn!("Database connection idle timeout is negative, this will disable the timeout"); - } - } - if let Some(max_lifetime) = self.database_connection_max_lifetime_seconds_raw { - if max_lifetime < 0.0 { - log::warn!("Database connection max lifetime is negative, this will disable the timeout"); - } - } anyhow::ensure!(self.max_pending_rows > 0, "max_pending_rows cannot be null"); Ok(()) } @@ -165,14 +155,9 @@ pub struct AppConfig { #[serde(default)] pub database_password: Option, pub max_database_pool_connections: Option, - #[serde(rename = "database_connection_idle_timeout_seconds")] - pub database_connection_idle_timeout_seconds_raw: Option, - #[serde(rename = "database_connection_max_lifetime_seconds")] - pub database_connection_max_lifetime_seconds_raw: Option, - - #[serde(skip)] + #[serde(default, deserialize_with = "deserialize_duration_seconds", rename = "database_connection_idle_timeout_seconds")] pub database_connection_idle_timeout: Option, - #[serde(skip)] + #[serde(default, deserialize_with = "deserialize_duration_seconds", rename = "database_connection_max_lifetime_seconds")] pub database_connection_max_lifetime: Option, #[serde(default)] @@ -637,10 +622,22 @@ impl DevOrProd { } } -fn resolve_timeout(config_val: Option, default: Option) -> Option { +fn deserialize_duration_seconds<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let seconds: Option = Option::deserialize(deserializer)?; + match seconds { + None => Ok(None), + Some(s) if s <= 0.0 || !s.is_finite() => Ok(Some(Duration::ZERO)), + Some(s) => Ok(Some(Duration::from_secs_f64(s))), + } +} + +fn resolve_timeout(config_val: Option, default: Option) -> Option { match config_val { - Some(v) if v <= 0.0 || !v.is_finite() => None, - Some(v) => Some(Duration::from_secs_f64(v)), + Some(v) if v.is_zero() => None, + Some(v) => Some(v), None => default, } } From 46fda54444fd5811a438c5996ae1ca0c583f5a56 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Jan 2026 11:11:19 +0000 Subject: [PATCH 5/5] Fix formatting in AppConfig Co-authored-by: contact --- src/app_config.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app_config.rs b/src/app_config.rs index a405c89f..9e601ade 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -155,9 +155,17 @@ pub struct AppConfig { #[serde(default)] pub database_password: Option, pub max_database_pool_connections: Option, - #[serde(default, deserialize_with = "deserialize_duration_seconds", rename = "database_connection_idle_timeout_seconds")] + #[serde( + default, + deserialize_with = "deserialize_duration_seconds", + rename = "database_connection_idle_timeout_seconds" + )] pub database_connection_idle_timeout: Option, - #[serde(default, deserialize_with = "deserialize_duration_seconds", rename = "database_connection_max_lifetime_seconds")] + #[serde( + default, + deserialize_with = "deserialize_duration_seconds", + rename = "database_connection_max_lifetime_seconds" + )] pub database_connection_max_lifetime: Option, #[serde(default)]