Skip to main content

chopin_pg/
pool.rs

1//! Worker-local connection pool with RAII guards for Shared-Nothing architecture.
2//!
3//! Each worker thread owns its own `PgPool` instance — **no locks, no
4//! cross-thread synchronization**.
5//!
6//! ## Design
7//!
8//! - Idle connections are kept in a **FIFO queue** (`VecDeque`).
9//! - `get()` / `try_get()` return a [`ConnectionGuard`] that automatically
10//!   returns the connection to the idle queue when dropped.
11//! - A waiter queue allows callers to register interest when the pool is
12//!   exhausted; the next `ConnectionGuard` drop will service the first waiter.
13//! - Connections are validated on checkout (optional) and reaped on idle /
14//!   lifetime expiry.
15//!
16//! ## Features
17//! - Lazy and eager connection initialization
18//! - `try_get()` — non-blocking, returns `WouldBlock` if exhausted
19//! - `get()` — blocks with timeout, returns `PoolTimeout` if exceeded
20//! - RAII `ConnectionGuard` — connection returned on drop
21//! - Connection validation (`test_on_checkout`)
22//! - Max lifetime and idle timeout
23//! - Automatic reconnection on stale connections
24//! - Graceful shutdown via `close_all()`
25
26use std::collections::VecDeque;
27use std::time::{Duration, Instant};
28
29use crate::connection::{PgConfig, PgConnection};
30use crate::error::{PgError, PgResult};
31
32// ─── Pool Configuration ───────────────────────────────────────
33
34/// Pool configuration options.
35#[derive(Debug, Clone)]
36pub struct PgPoolConfig {
37    /// Maximum number of connections in this pool.
38    pub max_size: usize,
39    /// Minimum number of connections to maintain (eagerly created).
40    pub min_size: usize,
41    /// Maximum lifetime of a connection before it is closed and recreated.
42    pub max_lifetime: Option<Duration>,
43    /// Close connections that have been idle for longer than this.
44    pub idle_timeout: Option<Duration>,
45    /// Maximum time to wait when all connections are busy (`get()`).
46    pub checkout_timeout: Option<Duration>,
47    /// Maximum time to wait when creating a new connection.
48    pub connection_timeout: Option<Duration>,
49    /// If true, run a validation query before returning a connection from the pool.
50    pub test_on_checkout: bool,
51    /// The query to use for validation (default: `"SELECT 1"`).
52    pub validation_query: String,
53    /// If true, automatically reconnect when a connection is found to be dead.
54    pub auto_reconnect: bool,
55}
56
57impl Default for PgPoolConfig {
58    fn default() -> Self {
59        Self {
60            max_size: 10,
61            min_size: 1,
62            max_lifetime: Some(Duration::from_secs(30 * 60)), // 30 min
63            idle_timeout: Some(Duration::from_secs(10 * 60)), // 10 min
64            checkout_timeout: Some(Duration::from_secs(5)),
65            connection_timeout: Some(Duration::from_secs(5)),
66            test_on_checkout: false,
67            validation_query: "SELECT 1".to_string(),
68            auto_reconnect: true,
69        }
70    }
71}
72
73impl PgPoolConfig {
74    /// Create a new pool config with defaults.
75    pub fn new() -> Self {
76        Self::default()
77    }
78
79    /// Set the maximum pool size.
80    pub fn max_size(mut self, size: usize) -> Self {
81        self.max_size = size;
82        self
83    }
84
85    /// Set the minimum pool size.
86    pub fn min_size(mut self, size: usize) -> Self {
87        self.min_size = size;
88        self
89    }
90
91    /// Set the maximum connection lifetime.
92    pub fn max_lifetime(mut self, duration: Duration) -> Self {
93        self.max_lifetime = Some(duration);
94        self
95    }
96
97    /// Set the idle timeout.
98    pub fn idle_timeout(mut self, duration: Duration) -> Self {
99        self.idle_timeout = Some(duration);
100        self
101    }
102
103    /// Set the checkout timeout (how long `get()` waits for a free connection).
104    pub fn checkout_timeout(mut self, duration: Duration) -> Self {
105        self.checkout_timeout = Some(duration);
106        self
107    }
108
109    /// Set the connection timeout.
110    pub fn connection_timeout(mut self, duration: Duration) -> Self {
111        self.connection_timeout = Some(duration);
112        self
113    }
114
115    /// Enable or disable test-on-checkout.
116    pub fn test_on_checkout(mut self, enable: bool) -> Self {
117        self.test_on_checkout = enable;
118        self
119    }
120
121    /// Disable max lifetime.
122    pub fn no_max_lifetime(mut self) -> Self {
123        self.max_lifetime = None;
124        self
125    }
126
127    /// Disable idle timeout.
128    pub fn no_idle_timeout(mut self) -> Self {
129        self.idle_timeout = None;
130        self
131    }
132}
133
134// ─── PooledConn ───────────────────────────────────────────────
135
136/// Metadata for a pooled connection.
137struct PooledConn {
138    conn: PgConnection,
139    created_at: Instant,
140    last_used: Instant,
141}
142
143impl PooledConn {
144    fn new(conn: PgConnection) -> Self {
145        let now = Instant::now();
146        Self {
147            conn,
148            created_at: now,
149            last_used: now,
150        }
151    }
152
153    /// Returns `true` if this connection has exceeded its max lifetime.
154    fn is_lifetime_expired(&self, max_lifetime: Option<Duration>) -> bool {
155        max_lifetime.is_some_and(|max| self.created_at.elapsed() > max)
156    }
157
158    /// Returns `true` if this connection has been idle too long.
159    fn is_idle_expired(&self, idle_timeout: Option<Duration>) -> bool {
160        idle_timeout.is_some_and(|timeout| self.last_used.elapsed() > timeout)
161    }
162}
163
164// ─── Pool Statistics ──────────────────────────────────────────
165
166/// Pool statistics.
167#[derive(Debug, Clone, Default)]
168pub struct PoolStats {
169    pub total_checkouts: u64,
170    pub total_connections_created: u64,
171    pub total_connections_closed: u64,
172    pub validation_failures: u64,
173    pub lifetime_expirations: u64,
174    pub idle_expirations: u64,
175    pub checkout_timeouts: u64,
176}
177
178// ─── PgPool ───────────────────────────────────────────────────
179
180/// A single-threaded, worker-local connection pool.
181///
182/// Connections are stored in an **idle FIFO queue**.  On checkout the oldest
183/// idle connection is returned; on return (guard drop) the connection is
184/// pushed to the back of the queue.
185pub struct PgPool {
186    config: PgConfig,
187    pool_config: PgPoolConfig,
188    /// Idle connections, ready to be checked out.
189    idle: VecDeque<PooledConn>,
190    /// Number of connections that have been checked out and are currently
191    /// in use (tracked so we can enforce `max_size`).
192    active: usize,
193    /// Statistics.
194    stats: PoolStats,
195}
196
197impl PgPool {
198    /// Create a new pool with the given configuration and size.
199    /// Connections are lazily initialized on first checkout.
200    pub fn new(config: PgConfig, size: usize) -> Self {
201        let pool_config = PgPoolConfig::default().max_size(size);
202        Self {
203            config,
204            pool_config,
205            idle: VecDeque::with_capacity(size),
206            active: 0,
207            stats: PoolStats::default(),
208        }
209    }
210
211    /// Create a new pool with full configuration.
212    pub fn with_config(config: PgConfig, pool_config: PgPoolConfig) -> Self {
213        Self {
214            idle: VecDeque::with_capacity(pool_config.max_size),
215            config,
216            pool_config,
217            active: 0,
218            stats: PoolStats::default(),
219        }
220    }
221
222    /// Create a pool and eagerly initialize `size` connections.
223    pub fn connect(config: PgConfig, size: usize) -> PgResult<Self> {
224        let mut pool = Self::new(config, size);
225        for _ in 0..size {
226            let conn = PgConnection::connect(&pool.config)?;
227            pool.idle.push_back(PooledConn::new(conn));
228            pool.stats.total_connections_created += 1;
229        }
230        Ok(pool)
231    }
232
233    /// Create a pool with full config and eagerly initialize `min_size` connections.
234    pub fn connect_with_config(config: PgConfig, pool_config: PgPoolConfig) -> PgResult<Self> {
235        let min = pool_config.min_size.min(pool_config.max_size);
236        let mut pool = Self::with_config(config, pool_config);
237        for _ in 0..min {
238            let conn = PgConnection::connect(&pool.config)?;
239            pool.idle.push_back(PooledConn::new(conn));
240            pool.stats.total_connections_created += 1;
241        }
242        Ok(pool)
243    }
244
245    // ─── Checkout Methods ─────────────────────────────────────
246
247    /// Internal: attempt to check out a `PooledConn` without wrapping in a
248    /// guard.  Does **not** increment `active` – the caller is responsible
249    /// for that.
250    fn try_checkout(&mut self) -> PgResult<PooledConn> {
251        self.stats.total_checkouts += 1;
252
253        // Try to pop an idle connection (FIFO – oldest first)
254        while let Some(mut pooled) = self.idle.pop_front() {
255            // Check lifetime / idle expiry
256            if pooled.is_lifetime_expired(self.pool_config.max_lifetime) {
257                self.stats.lifetime_expirations += 1;
258                self.stats.total_connections_closed += 1;
259                continue; // drop it, try next
260            }
261            if pooled.is_idle_expired(self.pool_config.idle_timeout) {
262                self.stats.idle_expirations += 1;
263                self.stats.total_connections_closed += 1;
264                continue;
265            }
266
267            // Optionally validate
268            if self.pool_config.test_on_checkout
269                && pooled
270                    .conn
271                    .query_simple(&self.pool_config.validation_query)
272                    .is_err()
273            {
274                self.stats.validation_failures += 1;
275                self.stats.total_connections_closed += 1;
276                if self.pool_config.auto_reconnect {
277                    // Replace with a fresh connection
278                    match PgConnection::connect(&self.config) {
279                        Ok(new_conn) => {
280                            pooled = PooledConn::new(new_conn);
281                            self.stats.total_connections_created += 1;
282                        }
283                        Err(e) => return Err(e),
284                    }
285                } else {
286                    return Err(PgError::PoolValidationFailed);
287                }
288            }
289
290            pooled.last_used = Instant::now();
291            return Ok(pooled);
292        }
293
294        // No idle connection — can we create a new one?
295        let total = self.active + self.idle.len();
296        if total < self.pool_config.max_size {
297            let conn = PgConnection::connect(&self.config)?;
298            self.stats.total_connections_created += 1;
299            let pooled = PooledConn::new(conn);
300            return Ok(pooled);
301        }
302
303        // Pool is exhausted
304        Err(PgError::PoolExhausted)
305    }
306
307    /// Non-blocking attempt to get a connection.
308    ///
309    /// Returns a [`ConnectionGuard`] wrapping the connection.  When the guard
310    /// is dropped the connection is returned to the idle queue automatically.
311    ///
312    /// Returns `Err(PgError::PoolExhausted)` if no connection is available and
313    /// the pool is at capacity.
314    pub fn try_get(&mut self) -> PgResult<ConnectionGuard<'_>> {
315        let pooled = self.try_checkout()?;
316        self.active += 1;
317        Ok(ConnectionGuard {
318            pool: self as *mut PgPool,
319            conn: Some(pooled),
320            _marker: std::marker::PhantomData,
321        })
322    }
323
324    /// Get a connection, waiting up to the configured `checkout_timeout`.
325    ///
326    /// Internally calls `try_checkout` in a loop with a short sleep between
327    /// attempts.  In a production event-loop the sleep would be replaced
328    /// by yielding to the scheduler.
329    pub fn get(&mut self) -> PgResult<ConnectionGuard<'_>> {
330        let timeout = self
331            .pool_config
332            .checkout_timeout
333            .unwrap_or(Duration::from_secs(5));
334        let start = Instant::now();
335
336        // First attempt — fast path.
337        match self.try_checkout() {
338            Ok(pooled) => {
339                self.active += 1;
340                return Ok(ConnectionGuard {
341                    pool: self as *mut PgPool,
342                    conn: Some(pooled),
343                    _marker: std::marker::PhantomData,
344                });
345            }
346            Err(PgError::PoolExhausted) => { /* fall through to retry loop */ }
347            Err(e) => return Err(e),
348        }
349
350        // Retry loop with back-off: 100µs → 500µs → 1ms (capped).
351        let backoff_us = [100u64, 250, 500, 1000];
352        let mut attempt = 0usize;
353        loop {
354            if start.elapsed() >= timeout {
355                self.stats.checkout_timeouts += 1;
356                return Err(PgError::PoolTimeout);
357            }
358
359            let sleep_us = backoff_us[attempt.min(backoff_us.len() - 1)];
360            std::thread::sleep(Duration::from_micros(sleep_us));
361            attempt += 1;
362
363            match self.try_checkout() {
364                Ok(pooled) => {
365                    self.active += 1;
366                    return Ok(ConnectionGuard {
367                        pool: self as *mut PgPool,
368                        conn: Some(pooled),
369                        _marker: std::marker::PhantomData,
370                    });
371                }
372                Err(PgError::PoolExhausted) => continue,
373                Err(e) => return Err(e),
374            }
375        }
376    }
377
378    /// Return a connection to the pool (called by `ConnectionGuard::drop`).
379    fn return_conn(&mut self, mut pooled: PooledConn) {
380        self.active = self.active.saturating_sub(1);
381
382        // Discard broken connections — they cannot be reused.
383        if pooled.conn.is_broken() {
384            self.stats.total_connections_closed += 1;
385            return; // pooled dropped here → PgConnection::drop sends Terminate
386        }
387
388        pooled.last_used = Instant::now();
389
390        // Only return if pool is not over capacity
391        if self.idle.len() + self.active < self.pool_config.max_size {
392            self.idle.push_back(pooled);
393        } else {
394            self.stats.total_connections_closed += 1;
395            // pooled is dropped here, calling PgConnection::drop → Terminate
396        }
397    }
398
399    // ─── Maintenance ──────────────────────────────────────────
400
401    /// Reap expired connections (call periodically from your event loop).
402    ///
403    /// Removes connections that have exceeded `max_lifetime` or `idle_timeout`,
404    /// then ensures `min_size` idle connections exist.
405    pub fn reap(&mut self) {
406        let mut i = 0;
407        while i < self.idle.len() {
408            let expired = {
409                let pooled = &self.idle[i];
410                pooled.is_lifetime_expired(self.pool_config.max_lifetime)
411                    || pooled.is_idle_expired(self.pool_config.idle_timeout)
412            };
413            if expired {
414                self.idle.remove(i);
415                self.stats.total_connections_closed += 1;
416            } else {
417                i += 1;
418            }
419        }
420
421        // Ensure min_size connections
422        let total = self.active + self.idle.len();
423        if total < self.pool_config.min_size {
424            let need = self.pool_config.min_size - total;
425            for _ in 0..need {
426                if let Ok(conn) = PgConnection::connect(&self.config) {
427                    self.idle.push_back(PooledConn::new(conn));
428                    self.stats.total_connections_created += 1;
429                }
430            }
431        }
432    }
433
434    // ─── Accessors ────────────────────────────────────────────
435
436    /// Get the pool configuration (PgConfig).
437    pub fn config(&self) -> &PgConfig {
438        &self.config
439    }
440
441    /// Get the pool config.
442    pub fn pool_config(&self) -> &PgPoolConfig {
443        &self.pool_config
444    }
445
446    /// Get the maximum pool size.
447    pub fn pool_size(&self) -> usize {
448        self.pool_config.max_size
449    }
450
451    /// Resize the pool at runtime.
452    ///
453    /// If `new_size` is smaller than the current total, excess idle
454    /// connections are discarded immediately.  Active (checked-out)
455    /// connections are not interrupted — the pool will converge to the
456    /// new size as they are returned.
457    pub fn set_max_size(&mut self, new_size: usize) {
458        self.pool_config.max_size = new_size;
459        // Shrink idle queue if necessary
460        while self.idle.len() + self.active > new_size && !self.idle.is_empty() {
461            self.idle.pop_front();
462            self.stats.total_connections_closed += 1;
463        }
464    }
465
466    /// Number of idle connections available for checkout.
467    pub fn idle_connections(&self) -> usize {
468        self.idle.len()
469    }
470
471    /// Number of connections currently checked out.
472    pub fn active_connections(&self) -> usize {
473        self.active
474    }
475
476    /// Total connections (idle + active).
477    pub fn total_connections(&self) -> usize {
478        self.idle.len() + self.active
479    }
480
481    /// Get pool statistics.
482    pub fn stats(&self) -> &PoolStats {
483        &self.stats
484    }
485
486    /// Close all idle connections in the pool.
487    pub fn close_all(&mut self) {
488        let closed = self.idle.len();
489        self.idle.clear();
490        self.stats.total_connections_closed += closed as u64;
491    }
492}
493
494// ─── ConnectionGuard ──────────────────────────────────────────
495
496/// RAII guard for a pooled connection.
497///
498/// Provides `&mut PgConnection` via `conn()` or `DerefMut`.  When dropped,
499/// the connection is automatically returned to the pool's idle queue.
500///
501/// # Example
502/// ```ignore
503/// let mut guard = pool.get()?;
504/// let rows = guard.conn().query("SELECT 1", &[])?;
505/// // guard drops here → connection returned to pool
506/// ```
507pub struct ConnectionGuard<'a> {
508    /// Raw pointer to the owning pool.  We use a raw pointer instead of
509    /// `&'a mut PgPool` to avoid a double-mutable-borrow conflict: the
510    /// guard itself borrows the pool, but the user also needs `&mut` access
511    /// to the connection inside the guard.  Since the pool is single-threaded
512    /// this is safe.
513    pool: *mut PgPool,
514    conn: Option<PooledConn>,
515    /// Phantom to tie the lifetime to the pool.
516    _marker: std::marker::PhantomData<&'a mut PgPool>,
517}
518
519impl<'a> ConnectionGuard<'a> {
520    /// Get a mutable reference to the underlying connection.
521    #[inline]
522    pub fn conn(&mut self) -> &mut PgConnection {
523        &mut self
524            .conn
525            .as_mut()
526            .expect("ConnectionGuard used after take")
527            .conn
528    }
529}
530
531impl<'a> std::ops::Deref for ConnectionGuard<'a> {
532    type Target = PgConnection;
533    fn deref(&self) -> &PgConnection {
534        &self
535            .conn
536            .as_ref()
537            .expect("ConnectionGuard used after take")
538            .conn
539    }
540}
541
542impl<'a> std::ops::DerefMut for ConnectionGuard<'a> {
543    fn deref_mut(&mut self) -> &mut PgConnection {
544        &mut self
545            .conn
546            .as_mut()
547            .expect("ConnectionGuard used after take")
548            .conn
549    }
550}
551
552impl<'a> Drop for ConnectionGuard<'a> {
553    fn drop(&mut self) {
554        if let Some(pooled) = self.conn.take() {
555            // SAFETY: The pool is single-threaded and lives at least as long
556            // as 'a.  The raw pointer was obtained from a valid &mut PgPool.
557            unsafe {
558                (*self.pool).return_conn(pooled);
559            }
560        }
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use crate::connection::PgConfig;
568    use crate::error::PgError;
569
570    fn dummy_config() -> PgConfig {
571        PgConfig::new("127.0.0.1", 5432, "test", "test", "testdb")
572    }
573
574    // ─── PgPoolConfig Defaults ────────────────────────────────────────────────
575
576    #[test]
577    fn test_pool_config_default_values() {
578        let cfg = PgPoolConfig::default();
579        assert_eq!(cfg.max_size, 10);
580        assert_eq!(cfg.min_size, 1);
581        assert!(
582            cfg.max_lifetime.is_some(),
583            "default max_lifetime should be set"
584        );
585        assert!(
586            cfg.idle_timeout.is_some(),
587            "default idle_timeout should be set"
588        );
589        assert!(
590            cfg.checkout_timeout.is_some(),
591            "default checkout_timeout should be set"
592        );
593        assert!(cfg.connection_timeout.is_some());
594        assert!(
595            !cfg.test_on_checkout,
596            "test_on_checkout should default to false"
597        );
598        assert!(cfg.auto_reconnect, "auto_reconnect should default to true");
599        assert_eq!(cfg.validation_query, "SELECT 1");
600    }
601
602    #[test]
603    fn test_pool_config_new_equals_default() {
604        let a = PgPoolConfig::new();
605        let b = PgPoolConfig::default();
606        assert_eq!(a.max_size, b.max_size);
607        assert_eq!(a.min_size, b.min_size);
608    }
609
610    // ─── PgPoolConfig Builder Methods ────────────────────────────────────────
611
612    #[test]
613    fn test_builder_max_size() {
614        let cfg = PgPoolConfig::new().max_size(25);
615        assert_eq!(cfg.max_size, 25);
616    }
617
618    #[test]
619    fn test_builder_min_size() {
620        let cfg = PgPoolConfig::new().min_size(3);
621        assert_eq!(cfg.min_size, 3);
622    }
623
624    #[test]
625    fn test_builder_max_lifetime() {
626        let d = Duration::from_secs(900);
627        let cfg = PgPoolConfig::new().max_lifetime(d);
628        assert_eq!(cfg.max_lifetime, Some(d));
629    }
630
631    #[test]
632    fn test_builder_no_max_lifetime() {
633        let cfg = PgPoolConfig::new().no_max_lifetime();
634        assert!(cfg.max_lifetime.is_none());
635    }
636
637    #[test]
638    fn test_builder_idle_timeout() {
639        let d = Duration::from_secs(300);
640        let cfg = PgPoolConfig::new().idle_timeout(d);
641        assert_eq!(cfg.idle_timeout, Some(d));
642    }
643
644    #[test]
645    fn test_builder_no_idle_timeout() {
646        let cfg = PgPoolConfig::new().no_idle_timeout();
647        assert!(cfg.idle_timeout.is_none());
648    }
649
650    #[test]
651    fn test_builder_checkout_timeout() {
652        let d = Duration::from_secs(10);
653        let cfg = PgPoolConfig::new().checkout_timeout(d);
654        assert_eq!(cfg.checkout_timeout, Some(d));
655    }
656
657    #[test]
658    fn test_builder_connection_timeout() {
659        let d = Duration::from_secs(3);
660        let cfg = PgPoolConfig::new().connection_timeout(d);
661        assert_eq!(cfg.connection_timeout, Some(d));
662    }
663
664    #[test]
665    fn test_builder_test_on_checkout() {
666        let cfg = PgPoolConfig::new().test_on_checkout(true);
667        assert!(cfg.test_on_checkout);
668        let cfg2 = PgPoolConfig::new().test_on_checkout(false);
669        assert!(!cfg2.test_on_checkout);
670    }
671
672    #[test]
673    fn test_builder_auto_reconnect_false() {
674        let mut cfg = PgPoolConfig::new();
675        cfg.auto_reconnect = false;
676        assert!(!cfg.auto_reconnect);
677        cfg.auto_reconnect = true;
678        assert!(cfg.auto_reconnect);
679    }
680
681    #[test]
682    fn test_builder_validation_query() {
683        let mut cfg = PgPoolConfig::new();
684        cfg.validation_query = "SELECT version()".to_string();
685        assert_eq!(cfg.validation_query, "SELECT version()");
686    }
687
688    #[test]
689    fn test_builder_chained() {
690        let mut cfg = PgPoolConfig::new()
691            .max_size(20)
692            .min_size(2)
693            .checkout_timeout(Duration::from_secs(5))
694            .test_on_checkout(true)
695            .no_idle_timeout();
696        cfg.auto_reconnect = false;
697        cfg.validation_query = "SELECT 1+1".to_string();
698        assert_eq!(cfg.max_size, 20);
699        assert_eq!(cfg.min_size, 2);
700        assert!(cfg.test_on_checkout);
701        assert!(!cfg.auto_reconnect);
702        assert!(cfg.idle_timeout.is_none());
703        assert_eq!(cfg.validation_query, "SELECT 1+1");
704    }
705
706    #[test]
707    fn test_builder_clone() {
708        let cfg = PgPoolConfig::new().max_size(7).min_size(2);
709        let cloned = cfg.clone();
710        assert_eq!(cloned.max_size, 7);
711        assert_eq!(cloned.min_size, 2);
712    }
713
714    // ─── PoolStats ────────────────────────────────────────────────────────────
715
716    #[test]
717    fn test_pool_stats_all_zero_initially() {
718        let stats = PoolStats::default();
719        assert_eq!(stats.total_checkouts, 0);
720        assert_eq!(stats.total_connections_created, 0);
721        assert_eq!(stats.total_connections_closed, 0);
722        assert_eq!(stats.validation_failures, 0);
723        assert_eq!(stats.lifetime_expirations, 0);
724        assert_eq!(stats.idle_expirations, 0);
725        assert_eq!(stats.checkout_timeouts, 0);
726    }
727
728    // ─── PgPool Initial State (Lazy, No DB) ──────────────────────────────────
729
730    #[test]
731    fn test_pool_new_starts_empty() {
732        let pool = PgPool::new(dummy_config(), 10);
733        assert_eq!(pool.idle_connections(), 0);
734        assert_eq!(pool.active_connections(), 0);
735        assert_eq!(pool.total_connections(), 0);
736    }
737
738    #[test]
739    fn test_pool_stats_initially_zeroed() {
740        let pool = PgPool::new(dummy_config(), 5);
741        let s = pool.stats();
742        assert_eq!(s.total_checkouts, 0);
743        assert_eq!(s.total_connections_created, 0);
744        assert_eq!(s.total_connections_closed, 0);
745    }
746
747    #[test]
748    fn test_pool_total_equals_idle_plus_active() {
749        let pool = PgPool::new(dummy_config(), 10);
750        assert_eq!(
751            pool.total_connections(),
752            pool.idle_connections() + pool.active_connections()
753        );
754    }
755
756    // ─── Pool Exhaustion — critical reliability test ──────────────────────────
757    // try_get() must return PoolExhausted (not WouldBlock) when at capacity.
758    // This ensures callers don't accidentally retry-loop on non-blocking API.
759
760    #[test]
761    fn test_try_get_returns_pool_exhausted_when_at_capacity() {
762        let mut pool = PgPool::new(dummy_config(), 0);
763        let result = pool.try_get();
764        assert!(
765            matches!(result, Err(PgError::PoolExhausted)),
766            "Expected PoolExhausted, got: {:?}",
767            result.err()
768        );
769    }
770
771    #[test]
772    fn test_try_get_never_returns_would_block() {
773        // Regression: before Sprint 6, try_get returned WouldBlock instead of PoolExhausted
774        let mut pool = PgPool::new(dummy_config(), 0);
775        let result = pool.try_get();
776        assert!(
777            !matches!(result, Err(PgError::WouldBlock)),
778            "try_get must NOT return WouldBlock — pool should return PoolExhausted"
779        );
780    }
781
782    #[test]
783    fn test_get_with_short_timeout_returns_pool_timeout_when_empty() {
784        let pool_cfg = PgPoolConfig::new()
785            .max_size(0)
786            .checkout_timeout(Duration::from_millis(1));
787        let mut pool = PgPool::with_config(dummy_config(), pool_cfg);
788        let result = pool.get();
789        assert!(
790            matches!(result, Err(PgError::PoolTimeout)),
791            "Expected PoolTimeout after checkout_timeout exceeded, got: {:?}",
792            result.err()
793        );
794    }
795
796    #[test]
797    fn test_get_timeout_increments_checkout_timeout_counter() {
798        let pool_cfg = PgPoolConfig::new()
799            .max_size(0)
800            .checkout_timeout(Duration::from_millis(1));
801        let mut pool = PgPool::with_config(dummy_config(), pool_cfg);
802        let _ = pool.get();
803        assert_eq!(pool.stats().checkout_timeouts, 1);
804    }
805
806    // ─── set_max_size ─────────────────────────────────────────────────────────
807
808    #[test]
809    fn test_set_max_size_to_zero_makes_pool_exhausted() {
810        let mut pool = PgPool::new(dummy_config(), 10);
811        pool.set_max_size(0);
812        let result = pool.try_get();
813        assert!(matches!(result, Err(PgError::PoolExhausted)));
814    }
815
816    #[test]
817    fn test_set_max_size_grow_does_not_panic() {
818        let mut pool = PgPool::new(dummy_config(), 5);
819        pool.set_max_size(100); // grow — should not panic or discard anything
820        assert_eq!(pool.idle_connections(), 0); // still lazy
821    }
822
823    #[test]
824    fn test_set_max_size_shrink_with_empty_idle_is_noop() {
825        // No idle connections → shrink has nothing to discard
826        let mut pool = PgPool::new(dummy_config(), 10);
827        pool.set_max_size(1);
828        assert_eq!(pool.idle_connections(), 0);
829    }
830
831    // ─── close_all ────────────────────────────────────────────────────────────
832
833    #[test]
834    fn test_close_all_on_empty_pool_no_panic() {
835        let mut pool = PgPool::new(dummy_config(), 10);
836        pool.close_all(); // must not panic
837        assert_eq!(pool.idle_connections(), 0);
838    }
839
840    #[test]
841    fn test_close_all_does_not_affect_active_count() {
842        // No active connections → active stays 0 after close_all
843        let mut pool = PgPool::new(dummy_config(), 10);
844        pool.close_all();
845        assert_eq!(pool.active_connections(), 0);
846    }
847
848    #[test]
849    fn test_close_all_increments_closed_stats() {
850        // No idle connections → closed counter stays 0
851        let mut pool = PgPool::new(dummy_config(), 5);
852        pool.close_all();
853        // With 0 idle, nothing closes
854        assert_eq!(pool.stats().total_connections_closed, 0);
855    }
856
857    // ─── try_checkout counter ─────────────────────────────────────────────────
858
859    #[test]
860    fn test_try_get_increments_checkout_counter() {
861        let mut pool = PgPool::new(dummy_config(), 0);
862        let _ = pool.try_get(); // will fail with PoolExhausted
863        assert_eq!(pool.stats().total_checkouts, 1);
864    }
865
866    #[test]
867    fn test_try_get_multiple_exhausted_increments_counter() {
868        let mut pool = PgPool::new(dummy_config(), 0);
869        for _ in 0..5 {
870            let _ = pool.try_get();
871        }
872        assert_eq!(pool.stats().total_checkouts, 5);
873    }
874
875    // ─── with_config ─────────────────────────────────────────────────────────
876
877    #[test]
878    fn test_pool_with_config_respects_max_size() {
879        let cfg = PgPoolConfig::new().max_size(3);
880        let pool = PgPool::with_config(dummy_config(), cfg);
881        assert_eq!(pool.idle_connections(), 0);
882        assert_eq!(pool.active_connections(), 0);
883    }
884
885    // ─── reap on empty pool ───────────────────────────────────────────────────
886
887    #[test]
888    fn test_reap_on_empty_pool_no_panic() {
889        let mut pool = PgPool::new(dummy_config(), 10);
890        pool.reap(); // must not panic on empty idle queue
891        assert_eq!(pool.idle_connections(), 0);
892    }
893}