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}