From b625ca0dd06d88459cea52092d398f5996b9ada0 Mon Sep 17 00:00:00 2001 From: Swanny Date: Thu, 16 Apr 2026 08:29:39 -0400 Subject: [PATCH 1/3] feat(node-config): add SQL connection pool tuning env vars The SQL cold storage pool previously used sqlx defaults (10 max connections, 30s acquire timeout), causing pool starvation under moderate RPC load when 64 concurrent cold-storage readers compete for connections. Add five new environment variables to StorageConfig for tuning the sqlx pool: max/min connections, acquire timeout, idle timeout, and max lifetime. Defaults are tuned for RPC workloads (100 max, 5 min, 5s acquire, 10m idle, 30m lifetime). Also bumps signet-storage dependencies from 0.6.5 to 0.7 to pick up the SqlConnector builder methods. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.toml | 12 ++-- crates/node-config/src/storage.rs | 112 +++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4e267647..200d94f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,12 +56,12 @@ signet-tx-cache = "0.16.0" signet-types = "0.16.0" signet-zenith = "0.16.0" signet-journal = "0.16.0" -signet-storage = "0.6.5" -signet-cold = "0.6.5" -signet-hot = "0.6.5" -signet-hot-mdbx = "0.6.5" -signet-cold-mdbx = "0.6.5" -signet-storage-types = "0.6.5" +signet-storage = "0.7" +signet-cold = "0.7" +signet-hot = "0.7" +signet-hot-mdbx = "0.7" +signet-cold-mdbx = "0.7" +signet-storage-types = "0.7" # ajj ajj = "0.7.0" diff --git a/crates/node-config/src/storage.rs b/crates/node-config/src/storage.rs index f26bee66..9967dd88 100644 --- a/crates/node-config/src/storage.rs +++ b/crates/node-config/src/storage.rs @@ -5,6 +5,21 @@ use signet_storage::{DatabaseEnv, MdbxConnector, UnifiedStorage, builder::Storag use std::borrow::Cow; use tokio_util::sync::CancellationToken; +// Pool-tuning defaults, only compiled when an SQL backend is available. +#[cfg(any(feature = "postgres", feature = "sqlite"))] +mod pool_defaults { + /// Maximum number of connections in the SQL cold storage pool. + pub(super) const MAX_CONNECTIONS: u32 = 100; + /// Minimum number of connections in the SQL cold storage pool. + pub(super) const MIN_CONNECTIONS: u32 = 5; + /// Timeout (in seconds) for acquiring a connection from the pool. + pub(super) const ACQUIRE_TIMEOUT_SECS: u64 = 5; + /// Idle timeout (in seconds) before closing unused connections. + pub(super) const IDLE_TIMEOUT_SECS: u64 = 600; + /// Maximum lifetime (in seconds) of individual connections. + pub(super) const MAX_LIFETIME_SECS: u64 = 1800; +} + /// Configuration for signet unified storage. /// /// Reads hot and cold storage configuration from environment variables. @@ -19,6 +34,20 @@ use tokio_util::sync::CancellationToken; /// /// Exactly one of `SIGNET_COLD_PATH` or `SIGNET_COLD_SQL_URL` must be set. /// +/// ## SQL Connection Pool Tuning +/// +/// When using SQL cold storage, the following optional variables control +/// the connection pool: +/// +/// - `SIGNET_COLD_SQL_MAX_CONNECTIONS` – Maximum pool size (default: 100). +/// - `SIGNET_COLD_SQL_MIN_CONNECTIONS` – Minimum idle connections (default: 5). +/// - `SIGNET_COLD_SQL_ACQUIRE_TIMEOUT_SECS` – Timeout for acquiring a +/// connection (default: 5 s). +/// - `SIGNET_COLD_SQL_IDLE_TIMEOUT_SECS` – Idle timeout before closing a +/// connection (default: 600 s). +/// - `SIGNET_COLD_SQL_MAX_LIFETIME_SECS` – Maximum lifetime of a connection +/// before it is recycled (default: 1800 s). +/// /// # Example /// /// ```rust,no_run @@ -49,12 +78,71 @@ pub struct StorageConfig { )] #[serde(default)] cold_sql_url: Cow<'static, str>, + + /// Maximum number of connections in the SQL pool. + #[from_env( + var = "SIGNET_COLD_SQL_MAX_CONNECTIONS", + desc = "Max SQL pool connections", + optional + )] + #[serde(default)] + #[cfg_attr(not(any(feature = "postgres", feature = "sqlite")), allow(dead_code))] + cold_sql_max_connections: Option, + + /// Minimum number of idle connections to maintain. + #[from_env( + var = "SIGNET_COLD_SQL_MIN_CONNECTIONS", + desc = "Min SQL pool connections", + optional + )] + #[serde(default)] + #[cfg_attr(not(any(feature = "postgres", feature = "sqlite")), allow(dead_code))] + cold_sql_min_connections: Option, + + /// Connection acquire timeout in seconds. + #[from_env( + var = "SIGNET_COLD_SQL_ACQUIRE_TIMEOUT_SECS", + desc = "SQL pool acquire timeout (seconds)", + optional + )] + #[serde(default)] + #[cfg_attr(not(any(feature = "postgres", feature = "sqlite")), allow(dead_code))] + cold_sql_acquire_timeout_secs: Option, + + /// Idle connection timeout in seconds. + #[from_env( + var = "SIGNET_COLD_SQL_IDLE_TIMEOUT_SECS", + desc = "SQL pool idle timeout (seconds)", + optional + )] + #[serde(default)] + #[cfg_attr(not(any(feature = "postgres", feature = "sqlite")), allow(dead_code))] + cold_sql_idle_timeout_secs: Option, + + /// Maximum lifetime of a connection in seconds. + #[from_env( + var = "SIGNET_COLD_SQL_MAX_LIFETIME_SECS", + desc = "SQL pool max connection lifetime (seconds)", + optional + )] + #[serde(default)] + #[cfg_attr(not(any(feature = "postgres", feature = "sqlite")), allow(dead_code))] + cold_sql_max_lifetime_secs: Option, } impl StorageConfig { /// Create a new storage configuration with MDBX cold backend. pub const fn new(hot_path: Cow<'static, str>, cold_path: Cow<'static, str>) -> Self { - Self { hot_path, cold_path, cold_sql_url: Cow::Borrowed("") } + Self { + hot_path, + cold_path, + cold_sql_url: Cow::Borrowed(""), + cold_sql_max_connections: None, + cold_sql_min_connections: None, + cold_sql_acquire_timeout_secs: None, + cold_sql_idle_timeout_secs: None, + cold_sql_max_lifetime_secs: None, + } } /// Get the hot storage path. @@ -72,6 +160,26 @@ impl StorageConfig { &self.cold_sql_url } + /// Build a [`SqlConnector`] with pool settings from this configuration. + #[cfg(any(feature = "postgres", feature = "sqlite"))] + fn build_sql_connector(&self) -> SqlConnector { + use pool_defaults as d; + use std::time::Duration; + + let max_conns = self.cold_sql_max_connections.unwrap_or(d::MAX_CONNECTIONS); + let min_conns = self.cold_sql_min_connections.unwrap_or(d::MIN_CONNECTIONS); + let acquire = self.cold_sql_acquire_timeout_secs.unwrap_or(d::ACQUIRE_TIMEOUT_SECS); + let idle = self.cold_sql_idle_timeout_secs.unwrap_or(d::IDLE_TIMEOUT_SECS); + let lifetime = self.cold_sql_max_lifetime_secs.unwrap_or(d::MAX_LIFETIME_SECS); + + SqlConnector::new(self.cold_sql_url.as_ref()) + .with_max_connections(max_conns) + .with_min_connections(min_conns) + .with_acquire_timeout(Duration::from_secs(acquire)) + .with_idle_timeout(Some(Duration::from_secs(idle))) + .with_max_lifetime(Some(Duration::from_secs(lifetime))) + } + /// Build unified storage from this configuration. /// /// Creates connectors from the configured paths, spawns the cold storage @@ -96,7 +204,7 @@ impl StorageConfig { #[cfg(any(feature = "postgres", feature = "sqlite"))] (false, true) => Ok(StorageBuilder::new() .hot(hot) - .cold(SqlConnector::new(self.cold_sql_url.as_ref())) + .cold(self.build_sql_connector()) .cancel_token(cancel) .build() .await?), From 202bb7d83b63930415a1a87160412c422b80f33b Mon Sep 17 00:00:00 2001 From: Swanny Date: Thu, 16 Apr 2026 08:51:35 -0400 Subject: [PATCH 2/3] fix: use pub(crate) instead of pub(super) for pool defaults CLAUDE.md prohibits pub(super). Use pub(crate) for the pool_defaults module constants. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/node-config/src/storage.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/node-config/src/storage.rs b/crates/node-config/src/storage.rs index 9967dd88..e2236b5e 100644 --- a/crates/node-config/src/storage.rs +++ b/crates/node-config/src/storage.rs @@ -9,15 +9,15 @@ use tokio_util::sync::CancellationToken; #[cfg(any(feature = "postgres", feature = "sqlite"))] mod pool_defaults { /// Maximum number of connections in the SQL cold storage pool. - pub(super) const MAX_CONNECTIONS: u32 = 100; + pub(crate) const MAX_CONNECTIONS: u32 = 100; /// Minimum number of connections in the SQL cold storage pool. - pub(super) const MIN_CONNECTIONS: u32 = 5; + pub(crate) const MIN_CONNECTIONS: u32 = 5; /// Timeout (in seconds) for acquiring a connection from the pool. - pub(super) const ACQUIRE_TIMEOUT_SECS: u64 = 5; + pub(crate) const ACQUIRE_TIMEOUT_SECS: u64 = 5; /// Idle timeout (in seconds) before closing unused connections. - pub(super) const IDLE_TIMEOUT_SECS: u64 = 600; + pub(crate) const IDLE_TIMEOUT_SECS: u64 = 600; /// Maximum lifetime (in seconds) of individual connections. - pub(super) const MAX_LIFETIME_SECS: u64 = 1800; + pub(crate) const MAX_LIFETIME_SECS: u64 = 1800; } /// Configuration for signet unified storage. From 1a0144cf8863ea9fa902836c73b398cc79ab9df9 Mon Sep 17 00:00:00 2001 From: Fraser Hutchison <190532+Fraser999@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:04:54 +0100 Subject: [PATCH 3/3] use SqlConnector directly in StorageConfig --- .gitignore | 2 + Cargo.toml | 4 +- crates/node-config/Cargo.toml | 4 +- crates/node-config/src/storage.rs | 220 ++++++++++++++---------------- 4 files changed, 106 insertions(+), 124 deletions(-) diff --git a/.gitignore b/.gitignore index 8b08ddc6..03d035b2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ Cargo.lock .idea/ docs/ +.claude/*.local.* +CLAUDE.local.md diff --git a/Cargo.toml b/Cargo.toml index 200d94f6..4fb31b7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,7 +45,7 @@ signet-node-tests = { version = "0.17.0", path = "crates/node-tests" } signet-node-types = { version = "0.17.0", path = "crates/node-types" } signet-rpc = { version = "0.17.0", path = "crates/rpc" } -init4-bin-base = { version = "0.19.0", features = ["alloy"] } +init4-bin-base = { version = "0.19.1", features = ["alloy"] } signet-bundle = "0.16.0" signet-constants = "0.16.0" @@ -116,5 +116,3 @@ url = "2.5.4" # Test Utils tempfile = "3.17.0" - -# init4-bin-base = { path = "../shared" } diff --git a/crates/node-config/Cargo.toml b/crates/node-config/Cargo.toml index 3dc100f7..9a1a34cb 100644 --- a/crates/node-config/Cargo.toml +++ b/crates/node-config/Cargo.toml @@ -28,5 +28,5 @@ signet-genesis.workspace = true [features] test_utils = [] -postgres = ["signet-storage/postgres"] -sqlite = ["signet-storage/sqlite"] +postgres = ["signet-storage/postgres", "init4-bin-base/cold-sql"] +sqlite = ["signet-storage/sqlite", "init4-bin-base/cold-sql"] diff --git a/crates/node-config/src/storage.rs b/crates/node-config/src/storage.rs index e2236b5e..6270b565 100644 --- a/crates/node-config/src/storage.rs +++ b/crates/node-config/src/storage.rs @@ -3,22 +3,9 @@ use init4_bin_base::utils::from_env::FromEnv; use signet_storage::SqlConnector; use signet_storage::{DatabaseEnv, MdbxConnector, UnifiedStorage, builder::StorageBuilder}; use std::borrow::Cow; -use tokio_util::sync::CancellationToken; - -// Pool-tuning defaults, only compiled when an SQL backend is available. #[cfg(any(feature = "postgres", feature = "sqlite"))] -mod pool_defaults { - /// Maximum number of connections in the SQL cold storage pool. - pub(crate) const MAX_CONNECTIONS: u32 = 100; - /// Minimum number of connections in the SQL cold storage pool. - pub(crate) const MIN_CONNECTIONS: u32 = 5; - /// Timeout (in seconds) for acquiring a connection from the pool. - pub(crate) const ACQUIRE_TIMEOUT_SECS: u64 = 5; - /// Idle timeout (in seconds) before closing unused connections. - pub(crate) const IDLE_TIMEOUT_SECS: u64 = 600; - /// Maximum lifetime (in seconds) of individual connections. - pub(crate) const MAX_LIFETIME_SECS: u64 = 1800; -} +use std::time::Duration; +use tokio_util::sync::CancellationToken; /// Configuration for signet unified storage. /// @@ -34,19 +21,12 @@ mod pool_defaults { /// /// Exactly one of `SIGNET_COLD_PATH` or `SIGNET_COLD_SQL_URL` must be set. /// -/// ## SQL Connection Pool Tuning -/// -/// When using SQL cold storage, the following optional variables control -/// the connection pool: +/// When using SQL cold storage, connection pool tuning is configured via +/// [`SqlConnector`]'s own environment variables (e.g. +/// `SIGNET_COLD_SQL_MAX_CONNECTIONS`). See the `cold-sql` feature of +/// `init4-bin-base` for the full list. /// -/// - `SIGNET_COLD_SQL_MAX_CONNECTIONS` – Maximum pool size (default: 100). -/// - `SIGNET_COLD_SQL_MIN_CONNECTIONS` – Minimum idle connections (default: 5). -/// - `SIGNET_COLD_SQL_ACQUIRE_TIMEOUT_SECS` – Timeout for acquiring a -/// connection (default: 5 s). -/// - `SIGNET_COLD_SQL_IDLE_TIMEOUT_SECS` – Idle timeout before closing a -/// connection (default: 600 s). -/// - `SIGNET_COLD_SQL_MAX_LIFETIME_SECS` – Maximum lifetime of a connection -/// before it is recycled (default: 1800 s). +/// [`SqlConnector`]: signet_storage::SqlConnector /// /// # Example /// @@ -59,7 +39,7 @@ mod pool_defaults { /// # Ok(()) /// # } /// ``` -#[derive(Debug, Clone, serde::Deserialize, FromEnv)] +#[derive(Debug, Clone, FromEnv)] pub struct StorageConfig { /// Path to the hot MDBX database. #[from_env(var = "SIGNET_HOT_PATH", desc = "Path to hot MDBX storage", infallible)] @@ -69,65 +49,74 @@ pub struct StorageConfig { #[from_env(var = "SIGNET_COLD_PATH", desc = "Path to cold MDBX storage", infallible)] cold_path: Cow<'static, str>, - /// SQL connection URL for cold storage (requires `postgres` or `sqlite` - /// feature). - #[from_env( - var = "SIGNET_COLD_SQL_URL", - desc = "SQL connection URL for cold storage", - infallible - )] - #[serde(default)] - cold_sql_url: Cow<'static, str>, - - /// Maximum number of connections in the SQL pool. - #[from_env( - var = "SIGNET_COLD_SQL_MAX_CONNECTIONS", - desc = "Max SQL pool connections", - optional - )] - #[serde(default)] - #[cfg_attr(not(any(feature = "postgres", feature = "sqlite")), allow(dead_code))] - cold_sql_max_connections: Option, + /// Pre-configured SQL connector with pool settings. Populated + /// automatically from environment variables via [`FromEnv`], or from + /// the pool-tuning fields when deserializing via serde. + #[cfg(any(feature = "postgres", feature = "sqlite"))] + cold_sql: Option, +} - /// Minimum number of idle connections to maintain. - #[from_env( - var = "SIGNET_COLD_SQL_MIN_CONNECTIONS", - desc = "Min SQL pool connections", - optional - )] - #[serde(default)] - #[cfg_attr(not(any(feature = "postgres", feature = "sqlite")), allow(dead_code))] - cold_sql_min_connections: Option, +impl<'de> serde::Deserialize<'de> for StorageConfig { + fn deserialize>(deserializer: D) -> Result { + /// Flat helper that mirrors the env-var layout so config files use the + /// same field names operators already know. + #[derive(serde::Deserialize)] + struct Helper { + hot_path: Cow<'static, str>, + cold_path: Cow<'static, str>, + #[serde(default)] + cold_sql_url: Option, + #[cfg(any(feature = "postgres", feature = "sqlite"))] + #[serde(default)] + cold_sql_max_connections: Option, + #[cfg(any(feature = "postgres", feature = "sqlite"))] + #[serde(default)] + cold_sql_min_connections: Option, + #[cfg(any(feature = "postgres", feature = "sqlite"))] + #[serde(default)] + cold_sql_acquire_timeout_secs: Option, + #[cfg(any(feature = "postgres", feature = "sqlite"))] + #[serde(default)] + cold_sql_idle_timeout_secs: Option, + #[cfg(any(feature = "postgres", feature = "sqlite"))] + #[serde(default)] + cold_sql_max_lifetime_secs: Option, + } - /// Connection acquire timeout in seconds. - #[from_env( - var = "SIGNET_COLD_SQL_ACQUIRE_TIMEOUT_SECS", - desc = "SQL pool acquire timeout (seconds)", - optional - )] - #[serde(default)] - #[cfg_attr(not(any(feature = "postgres", feature = "sqlite")), allow(dead_code))] - cold_sql_acquire_timeout_secs: Option, + let helper = Helper::deserialize(deserializer)?; - /// Idle connection timeout in seconds. - #[from_env( - var = "SIGNET_COLD_SQL_IDLE_TIMEOUT_SECS", - desc = "SQL pool idle timeout (seconds)", - optional - )] - #[serde(default)] - #[cfg_attr(not(any(feature = "postgres", feature = "sqlite")), allow(dead_code))] - cold_sql_idle_timeout_secs: Option, + #[cfg(not(any(feature = "postgres", feature = "sqlite")))] + if helper.cold_sql_url.as_ref().is_some_and(|url| !url.is_empty()) { + return Err(serde::de::Error::custom( + "cold_sql_url requires the 'postgres' or 'sqlite' feature", + )); + } - /// Maximum lifetime of a connection in seconds. - #[from_env( - var = "SIGNET_COLD_SQL_MAX_LIFETIME_SECS", - desc = "SQL pool max connection lifetime (seconds)", - optional - )] - #[serde(default)] - #[cfg_attr(not(any(feature = "postgres", feature = "sqlite")), allow(dead_code))] - cold_sql_max_lifetime_secs: Option, + // Defaults must match bin-base's `FromEnv for SqlConnector` so that + // env-var and config-file paths produce identical pool behavior. + #[cfg(any(feature = "postgres", feature = "sqlite"))] + let cold_sql = helper.cold_sql_url.filter(|url| !url.is_empty()).map(|url| { + SqlConnector::new(url) + .with_max_connections(helper.cold_sql_max_connections.unwrap_or(100)) + .with_min_connections(helper.cold_sql_min_connections.unwrap_or(5)) + .with_acquire_timeout(Duration::from_secs( + helper.cold_sql_acquire_timeout_secs.unwrap_or(5), + )) + .with_idle_timeout(Some(Duration::from_secs( + helper.cold_sql_idle_timeout_secs.unwrap_or(600), + ))) + .with_max_lifetime(Some(Duration::from_secs( + helper.cold_sql_max_lifetime_secs.unwrap_or(1800), + ))) + }); + + Ok(StorageConfig { + hot_path: helper.hot_path, + cold_path: helper.cold_path, + #[cfg(any(feature = "postgres", feature = "sqlite"))] + cold_sql, + }) + } } impl StorageConfig { @@ -136,12 +125,8 @@ impl StorageConfig { Self { hot_path, cold_path, - cold_sql_url: Cow::Borrowed(""), - cold_sql_max_connections: None, - cold_sql_min_connections: None, - cold_sql_acquire_timeout_secs: None, - cold_sql_idle_timeout_secs: None, - cold_sql_max_lifetime_secs: None, + #[cfg(any(feature = "postgres", feature = "sqlite"))] + cold_sql: None, } } @@ -155,29 +140,18 @@ impl StorageConfig { &self.cold_path } - /// Get the cold SQL connection URL. + /// Get the cold SQL connection URL. Returns an empty string when SQL + /// cold storage is not configured. + #[cfg_attr( + not(any(feature = "postgres", feature = "sqlite")), + expect(clippy::missing_const_for_fn, reason = "not const when SQL features are enabled") + )] pub fn cold_sql_url(&self) -> &str { - &self.cold_sql_url - } - - /// Build a [`SqlConnector`] with pool settings from this configuration. - #[cfg(any(feature = "postgres", feature = "sqlite"))] - fn build_sql_connector(&self) -> SqlConnector { - use pool_defaults as d; - use std::time::Duration; - - let max_conns = self.cold_sql_max_connections.unwrap_or(d::MAX_CONNECTIONS); - let min_conns = self.cold_sql_min_connections.unwrap_or(d::MIN_CONNECTIONS); - let acquire = self.cold_sql_acquire_timeout_secs.unwrap_or(d::ACQUIRE_TIMEOUT_SECS); - let idle = self.cold_sql_idle_timeout_secs.unwrap_or(d::IDLE_TIMEOUT_SECS); - let lifetime = self.cold_sql_max_lifetime_secs.unwrap_or(d::MAX_LIFETIME_SECS); - - SqlConnector::new(self.cold_sql_url.as_ref()) - .with_max_connections(max_conns) - .with_min_connections(min_conns) - .with_acquire_timeout(Duration::from_secs(acquire)) - .with_idle_timeout(Some(Duration::from_secs(idle))) - .with_max_lifetime(Some(Duration::from_secs(lifetime))) + #[cfg(any(feature = "postgres", feature = "sqlite"))] + if let Some(connector) = &self.cold_sql { + return connector.url(); + } + "" } /// Build unified storage from this configuration. @@ -185,14 +159,18 @@ impl StorageConfig { /// Creates connectors from the configured paths, spawns the cold storage /// background task, and returns a [`UnifiedStorage`] ready for use. /// - /// Exactly one of `cold_path` or `cold_sql_url` must be non-empty. + /// Exactly one of `cold_path` or `cold_sql` must be configured. pub async fn build_storage( &self, cancel: CancellationToken, ) -> eyre::Result> { let hot = MdbxConnector::new(self.hot_path.as_ref()); let has_mdbx = !self.cold_path.is_empty(); - let has_sql = !self.cold_sql_url.is_empty(); + + #[cfg(any(feature = "postgres", feature = "sqlite"))] + let has_sql = self.cold_sql.is_some(); + #[cfg(not(any(feature = "postgres", feature = "sqlite")))] + let has_sql = std::env::var("SIGNET_COLD_SQL_URL").is_ok_and(|v| !v.is_empty()); match (has_mdbx, has_sql) { (true, false) => Ok(StorageBuilder::new() @@ -202,12 +180,16 @@ impl StorageConfig { .build() .await?), #[cfg(any(feature = "postgres", feature = "sqlite"))] - (false, true) => Ok(StorageBuilder::new() - .hot(hot) - .cold(self.build_sql_connector()) - .cancel_token(cancel) - .build() - .await?), + (false, true) => { + let connector = + self.cold_sql.clone().expect("cold_sql must be Some when has_sql is true"); + Ok(StorageBuilder::new() + .hot(hot) + .cold(connector) + .cancel_token(cancel) + .build() + .await?) + } #[cfg(not(any(feature = "postgres", feature = "sqlite")))] (false, true) => { eyre::bail!("SIGNET_COLD_SQL_URL requires the 'postgres' or 'sqlite' feature")