Skip to main content

zirv_db_sqlx/
db.rs

1//! Global connection-pool management for the compile-time selected database backend.
2//!
3//! Exactly one database backend feature (`mysql`, `postgres`, or `sqlite`) selects
4//! the concrete sqlx [`Database`](sqlx::Database) used throughout this module. The
5//! pool is stored in a process-wide [`OnceLock`] and shared by reference, so
6//! [`get_db_pool`] is effectively free to call from anywhere.
7
8use std::sync::OnceLock;
9use std::time::Duration;
10
11use sqlx::Pool;
12use sqlx::pool::PoolOptions;
13use zirv_config::read_config;
14
15// The backend is selected at compile time. The `not(...)` guards make the
16// selection unambiguous even if several backend features are enabled at once
17// (the `compile_error!` in `lib.rs` still reports the misconfiguration, but we
18// avoid a confusing duplicate-type-definition error on top of it).
19#[cfg(feature = "mysql")]
20/// The sqlx database backend selected at compile time.
21pub type Db = sqlx::MySql;
22#[cfg(all(feature = "postgres", not(feature = "mysql")))]
23/// The sqlx database backend selected at compile time.
24pub type Db = sqlx::Postgres;
25#[cfg(all(feature = "sqlite", not(any(feature = "mysql", feature = "postgres"))))]
26/// The sqlx database backend selected at compile time.
27pub type Db = sqlx::Sqlite;
28
29/// Connection pool for the selected [`Db`] backend.
30pub type DbPool = Pool<Db>;
31
32/// The global, one-time-initialized connection pool.
33static DB_POOL: OnceLock<DbPool> = OnceLock::new();
34
35/// Connection-pool tuning resolved from the `database.*` configuration namespace.
36///
37/// Every field except [`url`](Self::url) is optional in configuration and falls
38/// back to the same default sqlx itself uses, so an application that only sets
39/// `database.url` keeps sqlx's well-chosen defaults. The recognised keys are:
40///
41/// | Config key | Type | Default | Meaning |
42/// |------------|------|---------|---------|
43/// | `database.url` | string | (required) | Connection string passed to sqlx. |
44/// | `database.max_connections` | u32 | `10` | Hard cap on open connections. |
45/// | `database.min_connections` | u32 | `0` | Connections kept warm when idle. |
46/// | `database.acquire_timeout_seconds` | u64 | `30` | How long `acquire` waits before erroring. |
47/// | `database.idle_timeout_seconds` | u64 | `600` | Reap idle connections after this long. `0` disables. |
48/// | `database.max_lifetime_seconds` | u64 | `1800` | Recycle connections older than this. `0` disables. |
49/// | `database.test_before_acquire` | bool | `true` | Ping a connection before handing it out. |
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct PoolSettings {
52    /// Database connection string (e.g. `mysql://user:pass@host/db`).
53    pub url: String,
54    /// Maximum number of connections the pool will open.
55    pub max_connections: u32,
56    /// Minimum number of connections kept open while the pool is idle.
57    pub min_connections: u32,
58    /// Maximum time to wait when acquiring a connection from the pool.
59    pub acquire_timeout: Duration,
60    /// Close a connection after it has been idle for this long. `None` disables.
61    pub idle_timeout: Option<Duration>,
62    /// Close a connection once it reaches this age. `None` disables.
63    pub max_lifetime: Option<Duration>,
64    /// Whether to validate a connection (with a ping) before handing it out.
65    pub test_before_acquire: bool,
66}
67
68impl PoolSettings {
69    /// Reads pool settings from the registered `database` configuration block.
70    ///
71    /// # Panics
72    /// Panics if `database.url` is not configured.
73    pub fn from_config() -> Self {
74        Self::resolve(
75            read_config!("database.url", String)
76                .expect("`database.url` must be set in the configuration"),
77            read_config!("database.max_connections", u32),
78            read_config!("database.min_connections", u32),
79            read_config!("database.acquire_timeout_seconds", u64),
80            read_config!("database.idle_timeout_seconds", u64),
81            read_config!("database.max_lifetime_seconds", u64),
82            read_config!("database.test_before_acquire", bool),
83        )
84    }
85
86    /// Applies defaults to raw, optional configuration values. Kept separate from
87    /// [`from_config`](Self::from_config) so the defaulting logic is unit-testable
88    /// without a global configuration store.
89    fn resolve(
90        url: String,
91        max_connections: Option<u32>,
92        min_connections: Option<u32>,
93        acquire_timeout_secs: Option<u64>,
94        idle_timeout_secs: Option<u64>,
95        max_lifetime_secs: Option<u64>,
96        test_before_acquire: Option<bool>,
97    ) -> Self {
98        // A zero-second duration means "disabled" (no timeout / no max lifetime).
99        let opt_duration = |secs: u64| (secs > 0).then(|| Duration::from_secs(secs));
100        Self {
101            url,
102            max_connections: max_connections.unwrap_or(10),
103            min_connections: min_connections.unwrap_or(0),
104            acquire_timeout: Duration::from_secs(acquire_timeout_secs.unwrap_or(30)),
105            idle_timeout: opt_duration(idle_timeout_secs.unwrap_or(600)),
106            max_lifetime: opt_duration(max_lifetime_secs.unwrap_or(1800)),
107            test_before_acquire: test_before_acquire.unwrap_or(true),
108        }
109    }
110
111    /// Builds [`PoolOptions`] for the selected backend from these settings, without
112    /// opening any connections.
113    pub fn to_pool_options(&self) -> PoolOptions<Db> {
114        PoolOptions::<Db>::new()
115            .max_connections(self.max_connections)
116            .min_connections(self.min_connections)
117            .acquire_timeout(self.acquire_timeout)
118            .idle_timeout(self.idle_timeout)
119            .max_lifetime(self.max_lifetime)
120            .test_before_acquire(self.test_before_acquire)
121    }
122}
123
124/// Initializes the global database pool exactly once.
125///
126/// Reads [`PoolSettings`] from configuration and eagerly opens the pool so that a
127/// misconfigured or unreachable database fails fast at startup. Call this early in
128/// your application's lifecycle (for example, in `main`).
129///
130/// # Panics
131/// - If `database.url` is not configured.
132/// - If the pool fails to connect.
133/// - If the pool has already been initialized.
134pub async fn init_db_pool() {
135    let settings = PoolSettings::from_config();
136    let pool = settings
137        .to_pool_options()
138        .connect(&settings.url)
139        .await
140        .expect("Failed to create the database connection pool");
141
142    DB_POOL
143        .set(pool)
144        .expect("Database pool is already initialized; call init_db_pool only once");
145}
146
147/// Returns a reference to the global database pool.
148///
149/// # Panics
150/// Panics if [`init_db_pool`] has not been called yet.
151pub fn get_db_pool() -> &'static DbPool {
152    DB_POOL
153        .get()
154        .expect("Database pool is not initialized; call init_db_pool first")
155}
156
157/// Gracefully closes the global pool, waiting for in-flight connections to be
158/// returned and closed. Intended to be called during shutdown. This is a no-op if
159/// the pool was never initialized, so it is always safe to call.
160pub async fn close_db_pool() {
161    if let Some(pool) = DB_POOL.get() {
162        pool.close().await;
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn resolve_applies_sqlx_matching_defaults_when_unset() {
172        let s = PoolSettings::resolve(
173            "mysql://localhost/db".to_string(),
174            None,
175            None,
176            None,
177            None,
178            None,
179            None,
180        );
181        assert_eq!(s.max_connections, 10);
182        assert_eq!(s.min_connections, 0);
183        assert_eq!(s.acquire_timeout, Duration::from_secs(30));
184        assert_eq!(s.idle_timeout, Some(Duration::from_secs(600)));
185        assert_eq!(s.max_lifetime, Some(Duration::from_secs(1800)));
186        assert!(s.test_before_acquire);
187    }
188
189    #[test]
190    fn resolve_honours_explicit_overrides() {
191        let s = PoolSettings::resolve(
192            "postgres://localhost/db".to_string(),
193            Some(50),
194            Some(5),
195            Some(10),
196            Some(120),
197            Some(3600),
198            Some(false),
199        );
200        assert_eq!(s.max_connections, 50);
201        assert_eq!(s.min_connections, 5);
202        assert_eq!(s.acquire_timeout, Duration::from_secs(10));
203        assert_eq!(s.idle_timeout, Some(Duration::from_secs(120)));
204        assert_eq!(s.max_lifetime, Some(Duration::from_secs(3600)));
205        assert!(!s.test_before_acquire);
206    }
207
208    #[test]
209    fn resolve_treats_zero_durations_as_disabled() {
210        let s = PoolSettings::resolve(
211            "sqlite::memory:".to_string(),
212            None,
213            None,
214            None,
215            Some(0),
216            Some(0),
217            None,
218        );
219        assert_eq!(s.idle_timeout, None);
220        assert_eq!(s.max_lifetime, None);
221    }
222
223    #[test]
224    fn to_pool_options_maps_every_field() {
225        let s = PoolSettings::resolve(
226            "mysql://localhost/db".to_string(),
227            Some(42),
228            Some(7),
229            Some(15),
230            Some(0),
231            Some(900),
232            Some(false),
233        );
234        let opts = s.to_pool_options();
235        assert_eq!(opts.get_max_connections(), 42);
236        assert_eq!(opts.get_min_connections(), 7);
237        assert_eq!(opts.get_acquire_timeout(), Duration::from_secs(15));
238        assert_eq!(opts.get_idle_timeout(), None);
239        assert_eq!(opts.get_max_lifetime(), Some(Duration::from_secs(900)));
240        assert!(!opts.get_test_before_acquire());
241    }
242}