Skip to main content

hyperdb_api/
pool.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Connection pools for Hyper database.
5//!
6//! This module provides two pools that share a common configuration surface:
7//!
8//! - [`Pool`] — an async pool built on [`deadpool`], for `async`/`await`
9//!   applications. Created via [`create_pool`].
10//! - [`ConnectionPool`] — a synchronous, r2d2-style pool with **no Tokio
11//!   dependency** on its hot path, for blocking applications. Created via
12//!   [`SyncPoolConfig::build`].
13//!
14//! Both pools open connections lazily, serialize the first-connection
15//! database-creation handshake, recycle connections via a configurable
16//! [`RecycleStrategy`] / [`SyncRecycleStrategy`], and enforce optional
17//! lifetime / idle caps.
18//!
19//! # Async example
20//!
21//! ```no_run
22//! use hyperdb_api::pool::{create_pool, PoolConfig};
23//! use hyperdb_api::CreateMode;
24//!
25//! #[tokio::main]
26//! async fn main() -> hyperdb_api::Result<()> {
27//!     // Create a pool configuration
28//!     let config = PoolConfig::new("localhost:7483", "example.hyper")
29//!         .create_mode(CreateMode::CreateIfNotExists)
30//!         .max_size(16);
31//!
32//!     // Build the pool
33//!     let pool = create_pool(config)?;
34//!
35//!     // Get a connection from the pool
36//!     let conn = pool.get().await.map_err(|e| hyperdb_api::Error::internal(e.to_string()))?;
37//!
38//!     // Use the connection
39//!     conn.execute_command("SELECT 1").await?;
40//!
41//!     // Connection is returned to pool when dropped
42//!     Ok(())
43//! }
44//! ```
45//!
46//! # Sync example
47//!
48//! ```no_run
49//! use hyperdb_api::pool::SyncPoolConfig;
50//! use hyperdb_api::CreateMode;
51//! use std::time::Duration;
52//!
53//! # fn main() -> hyperdb_api::Result<()> {
54//! let pool = SyncPoolConfig::new("localhost:7483", "example.hyper")
55//!     .create_mode(CreateMode::CreateIfNotExists)
56//!     .max_size(8)
57//!     .wait_timeout(Some(Duration::from_secs(5)))
58//!     .build();
59//!
60//! let conn = pool.get()?;
61//! conn.execute_command("SELECT 1")?;
62//! // Connection returns to the pool when `conn` drops.
63//! # Ok(())
64//! # }
65//! ```
66//!
67//! # Tuning knobs (shared by both pools)
68//!
69//! - **Recycle strategy** ([`PoolConfig::recycle`] / [`SyncPoolConfig::recycle`])
70//!   controls the per-checkout health probe. Defaults to `SelectOne` (a
71//!   `SELECT 1` round-trip). Use `Ping` for the connection's native ping,
72//!   `None` to skip the probe on hot paths, or `Custom(..)` for a bespoke check.
73//! - **`max_lifetime`** caps how long a physical connection may live before it
74//!   is retired at checkout, regardless of health.
75//! - **`idle_timeout`** retires connections that have sat idle too long (down to
76//!   `min_idle`, which is kept warm).
77//! - **Timeouts** (`wait_timeout`, `create_timeout`, `recycle_timeout`) bound how
78//!   long an acquire may block. The async pool enforces all three via deadpool's
79//!   Tokio runtime; the sync pool enforces `wait_timeout` natively (see
80//!   [`SyncPoolConfig`] for the create/recycle caveat).
81//!
82//! # Lifecycle hooks (async pool only)
83//!
84//! `PoolConfig` supports two async lifecycle hooks:
85//!
86//! - `after_connect` runs once on every newly-opened connection (useful for
87//!   `SET search_path`, prepared-statement warmup, etc.)
88//! - `before_acquire` runs every time a connection is checked out (useful
89//!   for session reset, telemetry, custom health checks)
90//!
91//! ```no_run
92//! use hyperdb_api::pool::{create_pool, PoolConfig, RecycleStrategy};
93//! use hyperdb_api::CreateMode;
94//!
95//! # #[tokio::main]
96//! # async fn main() -> hyperdb_api::Result<()> {
97//! let config = PoolConfig::new("localhost:7483", "example.hyper")
98//!     .create_mode(CreateMode::CreateIfNotExists)
99//!     .max_size(16)
100//!     .recycle(RecycleStrategy::None) // skip the per-checkout probe
101//!     .after_connect(|conn| Box::pin(async move {
102//!         conn.execute_command("SET search_path TO public").await?;
103//!         Ok(())
104//!     }));
105//! let _pool = create_pool(config)?;
106//! # Ok(())
107//! # }
108//! ```
109
110use std::collections::VecDeque;
111use std::pin::Pin;
112use std::sync::{Arc, Condvar, Mutex};
113use std::time::{Duration, Instant};
114
115use deadpool::managed::{self, Manager, Metrics, RecycleError, RecycleResult, Timeouts};
116use deadpool::Runtime;
117use tokio::sync::Mutex as AsyncMutex;
118
119use crate::async_connection::AsyncConnection;
120use crate::connection::Connection;
121use crate::error::{Error, Result};
122use crate::CreateMode;
123
124/// Future returned by pool lifecycle hooks.
125pub type HookFuture<'a> = Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>>;
126
127/// A hook that runs once on every newly-opened connection (after authentication
128/// and any database-creation handshake). Use it to set session variables, install
129/// statement caches, warm prepared statements, etc.
130///
131/// Returning `Err` from the hook causes pool creation to fail and the connection
132/// to be dropped.
133pub type AfterConnectHook = Arc<dyn Fn(&AsyncConnection) -> HookFuture<'_> + Send + Sync + 'static>;
134
135/// A hook that runs every time a connection is checked out of the pool, before
136/// it is handed to the caller. Use it for per-acquire health checks, session
137/// resets, or telemetry.
138///
139/// Returning `Err` from the hook causes the connection to be evicted (the pool
140/// retries with another connection or builds a new one).
141pub type BeforeAcquireHook =
142    Arc<dyn Fn(&AsyncConnection) -> HookFuture<'_> + Send + Sync + 'static>;
143
144/// A user-supplied async per-checkout health check for [`RecycleStrategy::Custom`].
145///
146/// Returning `Err` evicts the connection from the pool.
147pub type RecycleCheck = Arc<dyn Fn(&AsyncConnection) -> HookFuture<'_> + Send + Sync + 'static>;
148
149/// Strategy used by the async [`Pool`] to validate a connection when it is
150/// checked back out (recycled).
151///
152/// Defaults to [`SelectOne`](RecycleStrategy::SelectOne). The probe runs on
153/// every acquire after the connection's passive liveness check; a failing probe
154/// evicts the connection and the pool transparently builds a fresh one.
155#[derive(Clone, Default)]
156pub enum RecycleStrategy {
157    /// Run a `SELECT 1` round-trip on every checkout. The default — catches a
158    /// half-dead connection at acquire time at the cost of one round-trip.
159    #[default]
160    SelectOne,
161    /// Call [`AsyncConnection::ping`] on every checkout (equivalent round-trip,
162    /// expressed via the connection's own health primitive).
163    Ping,
164    /// Skip the active probe entirely. The pool still drops connections that
165    /// fail the passive [`AsyncConnection::is_alive`] check. Use on hot paths
166    /// where the round-trip cost outweighs detecting a dead connection early.
167    None,
168    /// Run a user-supplied async check on every checkout.
169    Custom(RecycleCheck),
170}
171
172impl std::fmt::Debug for RecycleStrategy {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        match self {
175            Self::SelectOne => f.write_str("SelectOne"),
176            Self::Ping => f.write_str("Ping"),
177            Self::None => f.write_str("None"),
178            Self::Custom(_) => f.write_str("Custom(<fn>)"),
179        }
180    }
181}
182
183/// Configuration for the async connection pool.
184#[derive(Clone)]
185pub struct PoolConfig {
186    /// Server endpoint (e.g., "localhost:7483" or "<http://localhost:7484>")
187    pub endpoint: String,
188    /// Database path
189    pub database: String,
190    /// Database creation mode (only used for first connection)
191    pub create_mode: CreateMode,
192    /// Optional username for authentication
193    pub user: Option<String>,
194    /// Optional password for authentication
195    pub password: Option<String>,
196    /// Maximum number of connections in the pool
197    pub max_size: usize,
198    /// If `false`, skip the per-checkout health probe. Retained for backwards
199    /// compatibility — it is kept in sync with [`recycle`](Self::recycle) by the
200    /// [`health_check`](Self::health_check) and [`recycle`](Self::recycle)
201    /// builders. Prefer setting [`recycle`](PoolConfig::recycle) directly.
202    pub health_check: bool,
203    /// Strategy used to validate connections on checkout. Defaults to
204    /// [`RecycleStrategy::SelectOne`].
205    pub recycle: RecycleStrategy,
206    /// Maximum time to wait for a slot to become available on
207    /// [`get`](managed::Pool::get). `None` waits indefinitely (the default).
208    pub wait_timeout: Option<Duration>,
209    /// Maximum time to wait for a new connection to be created. `None` disables
210    /// the cap (the default).
211    pub create_timeout: Option<Duration>,
212    /// Maximum time to wait for the recycle probe to complete. `None` disables
213    /// the cap (the default).
214    pub recycle_timeout: Option<Duration>,
215    /// Maximum lifetime of a physical connection before it is retired at
216    /// checkout, regardless of health. `None` disables the cap (the default).
217    pub max_lifetime: Option<Duration>,
218    /// Maximum time a connection may sit idle before it is retired at checkout.
219    /// `None` disables the cap (the default).
220    pub idle_timeout: Option<Duration>,
221    /// Minimum number of idle connections to keep warm (not eagerly created;
222    /// used to bias eviction decisions). `None` means no floor (the default).
223    pub min_idle: Option<u32>,
224    /// Optional hook run on every newly-opened connection (see [`AfterConnectHook`]).
225    pub after_connect: Option<AfterConnectHook>,
226    /// Optional hook run on every checkout (see [`BeforeAcquireHook`]).
227    pub before_acquire: Option<BeforeAcquireHook>,
228}
229
230impl std::fmt::Debug for PoolConfig {
231    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232        f.debug_struct("PoolConfig")
233            .field("endpoint", &self.endpoint)
234            .field("database", &self.database)
235            .field("create_mode", &self.create_mode)
236            .field("user", &self.user)
237            .field("password", &self.password.as_ref().map(|_| "<redacted>"))
238            .field("max_size", &self.max_size)
239            .field("health_check", &self.health_check)
240            .field("recycle", &self.recycle)
241            .field("wait_timeout", &self.wait_timeout)
242            .field("create_timeout", &self.create_timeout)
243            .field("recycle_timeout", &self.recycle_timeout)
244            .field("max_lifetime", &self.max_lifetime)
245            .field("idle_timeout", &self.idle_timeout)
246            .field("min_idle", &self.min_idle)
247            .field(
248                "after_connect",
249                &self.after_connect.as_ref().map(|_| "<fn>"),
250            )
251            .field(
252                "before_acquire",
253                &self.before_acquire.as_ref().map(|_| "<fn>"),
254            )
255            .finish()
256    }
257}
258
259impl PoolConfig {
260    /// Creates a new pool configuration.
261    ///
262    /// All optional knobs default to `None`/disabled and `recycle` defaults to
263    /// [`RecycleStrategy::SelectOne`], so a config that sets only
264    /// endpoint/database/`max_size` behaves identically to prior versions.
265    pub fn new(endpoint: impl Into<String>, database: impl Into<String>) -> Self {
266        Self {
267            endpoint: endpoint.into(),
268            database: database.into(),
269            create_mode: CreateMode::DoNotCreate,
270            user: None,
271            password: None,
272            max_size: 16,
273            health_check: true,
274            recycle: RecycleStrategy::SelectOne,
275            wait_timeout: None,
276            create_timeout: None,
277            recycle_timeout: None,
278            max_lifetime: None,
279            idle_timeout: None,
280            min_idle: None,
281            after_connect: None,
282            before_acquire: None,
283        }
284    }
285
286    /// Sets the database creation mode.
287    #[must_use]
288    pub fn create_mode(mut self, mode: CreateMode) -> Self {
289        self.create_mode = mode;
290        self
291    }
292
293    #[must_use]
294    /// Sets authentication credentials.
295    pub fn auth(mut self, user: impl Into<String>, password: impl Into<String>) -> Self {
296        self.user = Some(user.into());
297        self.password = Some(password.into());
298        self
299    }
300
301    /// Sets the maximum pool size.
302    #[must_use]
303    pub fn max_size(mut self, size: usize) -> Self {
304        self.max_size = size;
305        self
306    }
307
308    /// Enables or disables the per-checkout health probe.
309    ///
310    /// Backwards-compatible shorthand for [`recycle`](Self::recycle): `true`
311    /// selects [`RecycleStrategy::SelectOne`], `false` selects
312    /// [`RecycleStrategy::None`]. Prefer `recycle` for finer control.
313    #[must_use]
314    pub fn health_check(mut self, enabled: bool) -> Self {
315        self.health_check = enabled;
316        self.recycle = if enabled {
317            RecycleStrategy::SelectOne
318        } else {
319            RecycleStrategy::None
320        };
321        self
322    }
323
324    /// Sets the per-checkout recycle strategy. Keeps the legacy
325    /// [`health_check`](Self::health_check) flag in sync (`false` iff the
326    /// strategy is [`RecycleStrategy::None`]).
327    #[must_use]
328    pub fn recycle(mut self, strategy: RecycleStrategy) -> Self {
329        self.health_check = !matches!(strategy, RecycleStrategy::None);
330        self.recycle = strategy;
331        self
332    }
333
334    /// Sets the maximum time to wait for an available slot on `get`.
335    #[must_use]
336    pub fn wait_timeout(mut self, timeout: Option<Duration>) -> Self {
337        self.wait_timeout = timeout;
338        self
339    }
340
341    /// Sets the maximum time to wait for a new connection to be created.
342    #[must_use]
343    pub fn create_timeout(mut self, timeout: Option<Duration>) -> Self {
344        self.create_timeout = timeout;
345        self
346    }
347
348    /// Sets the maximum time to wait for the recycle probe to complete.
349    #[must_use]
350    pub fn recycle_timeout(mut self, timeout: Option<Duration>) -> Self {
351        self.recycle_timeout = timeout;
352        self
353    }
354
355    /// Sets the maximum lifetime of a physical connection.
356    #[must_use]
357    pub fn max_lifetime(mut self, lifetime: Option<Duration>) -> Self {
358        self.max_lifetime = lifetime;
359        self
360    }
361
362    /// Sets the maximum idle time before a connection is retired at checkout.
363    #[must_use]
364    pub fn idle_timeout(mut self, timeout: Option<Duration>) -> Self {
365        self.idle_timeout = timeout;
366        self
367    }
368
369    /// Sets the minimum number of idle connections to keep warm.
370    #[must_use]
371    pub fn min_idle(mut self, min_idle: Option<u32>) -> Self {
372        self.min_idle = min_idle;
373        self
374    }
375
376    /// Installs a hook that runs on every newly-opened connection.
377    ///
378    /// Use this to apply session-level setup (e.g. `SET search_path`, install
379    /// prepared statements). The hook is called once per physical connection,
380    /// not per checkout.
381    #[must_use]
382    pub fn after_connect<F>(mut self, hook: F) -> Self
383    where
384        F: Fn(&AsyncConnection) -> HookFuture<'_> + Send + Sync + 'static,
385    {
386        self.after_connect = Some(Arc::new(hook));
387        self
388    }
389
390    /// Installs a hook that runs on every connection checkout, before the
391    /// connection is handed to the caller.
392    ///
393    /// Returning `Err` from the hook evicts the connection from the pool;
394    /// the caller's `pool.get()` then retries with another connection or
395    /// builds a new one. Use this for per-acquire health checks beyond the
396    /// configured [`recycle`](Self::recycle) probe (e.g. validating session state).
397    #[must_use]
398    pub fn before_acquire<F>(mut self, hook: F) -> Self
399    where
400        F: Fn(&AsyncConnection) -> HookFuture<'_> + Send + Sync + 'static,
401    {
402        self.before_acquire = Some(Arc::new(hook));
403        self
404    }
405
406    /// Returns `true` if any deadpool-enforced timeout is configured (and thus a
407    /// Tokio runtime must be wired into the builder).
408    fn has_timeout(&self) -> bool {
409        self.wait_timeout.is_some()
410            || self.create_timeout.is_some()
411            || self.recycle_timeout.is_some()
412    }
413}
414
415/// Connection pool manager for `AsyncConnection`.
416///
417/// The first call to [`Manager::create`] holds an async mutex while attempting
418/// to open a connection with the configured [`CreateMode`]. Concurrent callers
419/// wait for that attempt to finish, then use `CreateMode::DoNotCreate`. If the
420/// first attempt fails, the next caller retries with the original create_mode
421/// (for idempotent modes only — `Create` is not retried because a sibling
422/// connection may have already created the database).
423#[derive(Debug)]
424pub struct ConnectionManager {
425    config: Arc<PoolConfig>,
426    /// Synchronizes the first-connection attempt across concurrent callers.
427    /// `Some(())` after the first successful attempt; held while a first
428    /// attempt is in progress to serialize concurrent races. The value is the
429    /// outcome of the first call (the database is now known to exist).
430    init_lock: Arc<AsyncMutex<bool>>,
431}
432
433impl ConnectionManager {
434    /// Creates a new connection manager.
435    #[must_use]
436    pub fn new(config: PoolConfig) -> Self {
437        Self {
438            config: Arc::new(config),
439            init_lock: Arc::new(AsyncMutex::new(false)),
440        }
441    }
442
443    async fn open(&self, mode: CreateMode) -> Result<AsyncConnection> {
444        if let (Some(user), Some(password)) = (&self.config.user, &self.config.password) {
445            AsyncConnection::connect_with_auth(
446                &self.config.endpoint,
447                &self.config.database,
448                mode,
449                user,
450                password,
451            )
452            .await
453        } else {
454            AsyncConnection::connect(&self.config.endpoint, &self.config.database, mode).await
455        }
456    }
457}
458
459impl Manager for ConnectionManager {
460    type Type = AsyncConnection;
461    type Error = Error;
462
463    async fn create(&self) -> Result<AsyncConnection> {
464        // Fast path: if the first connection already succeeded, just open with
465        // DoNotCreate. We hold the lock briefly to read the flag.
466        // (Lock is uncontended after the first connection — fast path is cheap.)
467        let conn = {
468            let initialized = self.init_lock.lock().await;
469            if *initialized {
470                drop(initialized);
471                self.open(CreateMode::DoNotCreate).await?
472            } else {
473                drop(initialized);
474                // Slow path: first creation. Acquire the lock and re-check (in
475                // case another waiter raced us), then attempt with the
476                // configured mode.
477                let mut initialized = self.init_lock.lock().await;
478                if *initialized {
479                    drop(initialized);
480                    self.open(CreateMode::DoNotCreate).await?
481                } else {
482                    let result = self.open(self.config.create_mode).await;
483                    if result.is_ok() {
484                        *initialized = true;
485                    }
486                    // On failure leave `initialized = false` so the next caller
487                    // retries with the original create_mode.
488                    result?
489                }
490            }
491        };
492
493        // Run the after_connect hook (if any) before handing the connection
494        // to the pool. Hook errors propagate as connection-creation errors.
495        if let Some(hook) = self.config.after_connect.as_ref() {
496            hook(&conn).await?;
497        }
498        Ok(conn)
499    }
500
501    async fn recycle(
502        &self,
503        conn: &mut AsyncConnection,
504        metrics: &Metrics,
505    ) -> RecycleResult<Self::Error> {
506        // Retire connections that have outlived their configured caps before
507        // spending a round-trip probing them. Returning a `Message` error evicts
508        // the connection; deadpool then builds a fresh one transparently.
509        if let Some(max_lifetime) = self.config.max_lifetime {
510            if metrics.age() >= max_lifetime {
511                return Err(RecycleError::message("connection exceeded max_lifetime"));
512            }
513        }
514        if let Some(idle_timeout) = self.config.idle_timeout {
515            if metrics.last_used() >= idle_timeout {
516                return Err(RecycleError::message("connection exceeded idle_timeout"));
517            }
518        }
519
520        // Active health probe per the configured strategy.
521        match &self.config.recycle {
522            RecycleStrategy::SelectOne => {
523                conn.execute_command("SELECT 1")
524                    .await
525                    .map_err(RecycleError::Backend)?;
526            }
527            RecycleStrategy::Ping => {
528                conn.ping().await.map_err(RecycleError::Backend)?;
529            }
530            RecycleStrategy::None => {}
531            RecycleStrategy::Custom(check) => {
532                check(conn).await.map_err(RecycleError::Backend)?;
533            }
534        }
535
536        // Per-checkout user hook (e.g. session reset, telemetry).
537        if let Some(hook) = self.config.before_acquire.as_ref() {
538            hook(conn).await.map_err(RecycleError::Backend)?;
539        }
540        Ok(())
541    }
542}
543
544/// A pool of async connections to a Hyper database.
545///
546/// This pool manages a set of reusable connections, automatically creating
547/// new connections when needed and recycling them after use.
548pub type Pool = managed::Pool<ConnectionManager>;
549
550/// A pooled connection wrapper.
551pub type PooledConnection = managed::Object<ConnectionManager>;
552
553/// Creates a new connection pool from configuration.
554///
555/// # Errors
556///
557/// Returns [`Error::Config`] wrapping the `deadpool` builder failure if
558/// the pool cannot be constructed (e.g. invalid `max_size`). Connections
559/// themselves are opened lazily on first use, so endpoint/auth errors
560/// surface from [`Pool::get`](managed::Pool::get), not here.
561pub fn create_pool(config: PoolConfig) -> Result<Pool> {
562    let max_size = config.max_size;
563    let timeouts = Timeouts {
564        wait: config.wait_timeout,
565        create: config.create_timeout,
566        recycle: config.recycle_timeout,
567    };
568    // deadpool requires a runtime to enforce any timeout; only wire one in when
569    // a timeout is actually configured so the zero-config path stays untouched.
570    let needs_runtime = config.has_timeout();
571    let manager = ConnectionManager::new(config);
572    let mut builder = Pool::builder(manager).max_size(max_size).timeouts(timeouts);
573    if needs_runtime {
574        builder = builder.runtime(Runtime::Tokio1);
575    }
576    builder
577        .build()
578        .map_err(|e| Error::config(format!("Failed to create pool: {e}")))
579}
580
581// ---------------------------------------------------------------------------
582// Synchronous, r2d2-style pool (no Tokio on the hot path).
583// ---------------------------------------------------------------------------
584
585/// A user-supplied synchronous per-checkout health check for
586/// [`SyncRecycleStrategy::Custom`].
587///
588/// Returning `Err` evicts the connection from the pool.
589pub type SyncRecycleCheck = Arc<dyn Fn(&Connection) -> Result<()> + Send + Sync + 'static>;
590
591/// Strategy used by the synchronous [`ConnectionPool`] to validate a connection
592/// when it is checked out.
593///
594/// Mirrors [`RecycleStrategy`] for the blocking [`Connection`] type. Defaults to
595/// [`SelectOne`](SyncRecycleStrategy::SelectOne).
596#[derive(Clone, Default)]
597pub enum SyncRecycleStrategy {
598    /// Run a `SELECT 1` round-trip on every checkout (the default).
599    #[default]
600    SelectOne,
601    /// Call [`Connection::ping`] on every checkout.
602    Ping,
603    /// Skip the active probe; only the passive [`Connection::is_alive`] check runs.
604    None,
605    /// Run a user-supplied synchronous check on every checkout.
606    Custom(SyncRecycleCheck),
607}
608
609impl std::fmt::Debug for SyncRecycleStrategy {
610    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
611        match self {
612            Self::SelectOne => f.write_str("SelectOne"),
613            Self::Ping => f.write_str("Ping"),
614            Self::None => f.write_str("None"),
615            Self::Custom(_) => f.write_str("Custom(<fn>)"),
616        }
617    }
618}
619
620/// Configuration and builder for the synchronous [`ConnectionPool`].
621///
622/// Mirrors the async [`PoolConfig`] surface. All optional knobs default to
623/// `None`/disabled and `recycle` defaults to
624/// [`SyncRecycleStrategy::SelectOne`], so a config that sets only
625/// endpoint/database/`max_size` opens connections lazily and probes them with
626/// `SELECT 1` on checkout — the natural baseline.
627///
628/// # Timeout support
629///
630/// The sync pool enforces [`wait_timeout`](Self::wait_timeout) natively (it
631/// bounds how long [`ConnectionPool::get`] blocks waiting for a slot).
632/// `create_timeout` and `recycle_timeout` require an async runtime to interrupt
633/// a blocking syscall and are therefore **async-only** ([`PoolConfig`]); they
634/// are intentionally absent here to avoid pulling Tokio into the sync path.
635#[derive(Clone)]
636pub struct SyncPoolConfig {
637    /// Server endpoint (e.g., "localhost:7483").
638    pub endpoint: String,
639    /// Database path.
640    pub database: String,
641    /// Database creation mode (only used for the first connection).
642    pub create_mode: CreateMode,
643    /// Optional username for authentication.
644    pub user: Option<String>,
645    /// Optional password for authentication.
646    pub password: Option<String>,
647    /// Maximum number of connections in the pool.
648    pub max_size: usize,
649    /// Per-checkout recycle strategy. Defaults to [`SyncRecycleStrategy::SelectOne`].
650    pub recycle: SyncRecycleStrategy,
651    /// Maximum time [`ConnectionPool::get`] blocks waiting for a slot. `None`
652    /// waits indefinitely (the default).
653    pub wait_timeout: Option<Duration>,
654    /// Maximum lifetime of a physical connection before it is retired at checkout.
655    pub max_lifetime: Option<Duration>,
656    /// Maximum idle time before a connection is retired at checkout (down to
657    /// [`min_idle`](Self::min_idle), which is kept warm).
658    pub idle_timeout: Option<Duration>,
659    /// Minimum number of idle connections to keep warm (biases idle eviction).
660    pub min_idle: Option<u32>,
661}
662
663impl std::fmt::Debug for SyncPoolConfig {
664    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
665        f.debug_struct("SyncPoolConfig")
666            .field("endpoint", &self.endpoint)
667            .field("database", &self.database)
668            .field("create_mode", &self.create_mode)
669            .field("user", &self.user)
670            .field("password", &self.password.as_ref().map(|_| "<redacted>"))
671            .field("max_size", &self.max_size)
672            .field("recycle", &self.recycle)
673            .field("wait_timeout", &self.wait_timeout)
674            .field("max_lifetime", &self.max_lifetime)
675            .field("idle_timeout", &self.idle_timeout)
676            .field("min_idle", &self.min_idle)
677            .finish()
678    }
679}
680
681impl SyncPoolConfig {
682    /// Creates a new synchronous pool configuration.
683    pub fn new(endpoint: impl Into<String>, database: impl Into<String>) -> Self {
684        Self {
685            endpoint: endpoint.into(),
686            database: database.into(),
687            create_mode: CreateMode::DoNotCreate,
688            user: None,
689            password: None,
690            max_size: 16,
691            recycle: SyncRecycleStrategy::SelectOne,
692            wait_timeout: None,
693            max_lifetime: None,
694            idle_timeout: None,
695            min_idle: None,
696        }
697    }
698
699    /// Sets the database creation mode.
700    #[must_use]
701    pub fn create_mode(mut self, mode: CreateMode) -> Self {
702        self.create_mode = mode;
703        self
704    }
705
706    /// Sets authentication credentials.
707    #[must_use]
708    pub fn auth(mut self, user: impl Into<String>, password: impl Into<String>) -> Self {
709        self.user = Some(user.into());
710        self.password = Some(password.into());
711        self
712    }
713
714    /// Sets the maximum pool size.
715    #[must_use]
716    pub fn max_size(mut self, size: usize) -> Self {
717        self.max_size = size;
718        self
719    }
720
721    /// Sets the per-checkout recycle strategy.
722    #[must_use]
723    pub fn recycle(mut self, strategy: SyncRecycleStrategy) -> Self {
724        self.recycle = strategy;
725        self
726    }
727
728    /// Sets the maximum time `get` blocks waiting for a slot.
729    #[must_use]
730    pub fn wait_timeout(mut self, timeout: Option<Duration>) -> Self {
731        self.wait_timeout = timeout;
732        self
733    }
734
735    /// Sets the maximum lifetime of a physical connection.
736    #[must_use]
737    pub fn max_lifetime(mut self, lifetime: Option<Duration>) -> Self {
738        self.max_lifetime = lifetime;
739        self
740    }
741
742    /// Sets the maximum idle time before a connection is retired at checkout.
743    #[must_use]
744    pub fn idle_timeout(mut self, timeout: Option<Duration>) -> Self {
745        self.idle_timeout = timeout;
746        self
747    }
748
749    /// Sets the minimum number of idle connections to keep warm.
750    #[must_use]
751    pub fn min_idle(mut self, min_idle: Option<u32>) -> Self {
752        self.min_idle = min_idle;
753        self
754    }
755
756    /// Builds the synchronous connection pool. Connections are opened lazily on
757    /// first [`ConnectionPool::get`].
758    #[must_use]
759    pub fn build(self) -> ConnectionPool {
760        ConnectionPool {
761            inner: Arc::new(SyncPoolInner {
762                config: self,
763                state: Mutex::new(SyncPoolState {
764                    idle: VecDeque::new(),
765                    size: 0,
766                    initialized: false,
767                    init_in_progress: false,
768                }),
769                available: Condvar::new(),
770            }),
771        }
772    }
773}
774
775/// An idle connection together with the bookkeeping needed to enforce
776/// lifetime/idle caps.
777struct IdleConn {
778    conn: Connection,
779    created: Instant,
780    last_used: Instant,
781}
782
783/// Mutable pool state guarded by the pool mutex.
784struct SyncPoolState {
785    /// Idle connections available for checkout (LIFO via `pop_back`/`push_back`
786    /// keeps the hottest connection warm).
787    idle: VecDeque<IdleConn>,
788    /// Total live connections (idle + checked out).
789    size: usize,
790    /// Set once the first-connection database-creation handshake has succeeded.
791    initialized: bool,
792    /// Held while a first-connection attempt is in flight, to serialize the
793    /// create-mode handshake across threads (mirrors the async `init_lock`).
794    init_in_progress: bool,
795}
796
797struct SyncPoolInner {
798    config: SyncPoolConfig,
799    state: Mutex<SyncPoolState>,
800    available: Condvar,
801}
802
803impl SyncPoolInner {
804    /// Opens a fresh physical connection, using the configured create mode only
805    /// for the first connection.
806    fn open(&self, first: bool) -> Result<Connection> {
807        let mode = if first {
808            self.config.create_mode
809        } else {
810            CreateMode::DoNotCreate
811        };
812        if let (Some(user), Some(password)) = (&self.config.user, &self.config.password) {
813            Connection::connect_with_auth(
814                &self.config.endpoint,
815                &self.config.database,
816                mode,
817                user,
818                password,
819            )
820        } else {
821            Connection::connect(&self.config.endpoint, &self.config.database, mode)
822        }
823    }
824
825    /// Returns `true` if an idle connection should be retired before reuse.
826    ///
827    /// `max_lifetime` is a hard cap. `idle_timeout` is honored only while doing
828    /// so keeps the live count at or above `min_idle` (idle connections are kept
829    /// warm down to that floor).
830    fn should_evict(&self, idle: &IdleConn, live_size: usize) -> bool {
831        if let Some(max_lifetime) = self.config.max_lifetime {
832            if idle.created.elapsed() >= max_lifetime {
833                return true;
834            }
835        }
836        if let Some(idle_timeout) = self.config.idle_timeout {
837            let min_idle = self.config.min_idle.unwrap_or(0) as usize;
838            if idle.last_used.elapsed() >= idle_timeout && live_size > min_idle {
839                return true;
840            }
841        }
842        false
843    }
844
845    /// Runs the configured recycle probe against a connection.
846    fn recycle(&self, conn: &Connection) -> Result<()> {
847        if !conn.is_alive() {
848            return Err(Error::connection("pooled connection is no longer alive"));
849        }
850        match &self.config.recycle {
851            SyncRecycleStrategy::SelectOne => {
852                conn.execute_command("SELECT 1")?;
853            }
854            SyncRecycleStrategy::Ping => {
855                conn.ping()?;
856            }
857            SyncRecycleStrategy::None => {}
858            SyncRecycleStrategy::Custom(check) => {
859                check(conn)?;
860            }
861        }
862        Ok(())
863    }
864
865    /// Returns a connection to the idle set and wakes a waiter.
866    fn checkin(&self, conn: Connection, created: Instant) {
867        {
868            let mut state = self.state.lock().expect("pool mutex poisoned");
869            state.idle.push_back(IdleConn {
870                conn,
871                created,
872                last_used: Instant::now(),
873            });
874        }
875        self.available.notify_one();
876    }
877
878    /// Drops a checked-out connection that the caller chose not to return,
879    /// freeing its slot.
880    fn discard(&self) {
881        {
882            let mut state = self.state.lock().expect("pool mutex poisoned");
883            state.size = state.size.saturating_sub(1);
884        }
885        self.available.notify_one();
886    }
887}
888
889/// A synchronous, r2d2-style pool of blocking [`Connection`]s.
890///
891/// Cloneable handles share one underlying pool. Connections are opened lazily,
892/// validated on checkout per the configured [`SyncRecycleStrategy`], and
893/// returned to the pool when the [`SyncPooledConnection`] guard drops. The hot
894/// path uses only `std` synchronization primitives — no Tokio.
895#[derive(Clone)]
896pub struct ConnectionPool {
897    inner: Arc<SyncPoolInner>,
898}
899
900impl std::fmt::Debug for ConnectionPool {
901    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
902        f.debug_struct("ConnectionPool")
903            .field("config", &self.inner.config)
904            .field("status", &self.status())
905            .finish()
906    }
907}
908
909impl ConnectionPool {
910    /// Acquires a connection, blocking up to the configured
911    /// [`wait_timeout`](SyncPoolConfig::wait_timeout) for a slot.
912    ///
913    /// # Errors
914    ///
915    /// Returns [`Error::Timeout`] if no slot becomes available within
916    /// `wait_timeout`, or the underlying connection error if opening a new
917    /// connection fails.
918    pub fn get(&self) -> Result<SyncPooledConnection> {
919        self.get_timeout(self.inner.config.wait_timeout)
920    }
921
922    /// Acquires a connection, blocking up to `timeout` for a slot (overriding
923    /// the configured default). `None` blocks indefinitely.
924    ///
925    /// # Errors
926    ///
927    /// See [`get`](Self::get).
928    ///
929    /// # Panics
930    ///
931    /// Panics if the internal pool mutex has been poisoned by a thread that
932    /// panicked while holding it.
933    pub fn get_timeout(&self, timeout: Option<Duration>) -> Result<SyncPooledConnection> {
934        let deadline = timeout.map(|t| Instant::now() + t);
935
936        loop {
937            // Decide what to do under the lock, then release it before any
938            // blocking network I/O (connect / recycle probe).
939            enum Action {
940                Reuse(IdleConn),
941                Create { first: bool },
942                Wait,
943            }
944
945            let action = {
946                let mut state = self.inner.state.lock().expect("pool mutex poisoned");
947                if let Some(idle) = state.idle.pop_back() {
948                    Action::Reuse(idle)
949                } else if !state.initialized {
950                    // First connection must run the create-mode handshake, and
951                    // only one thread may do so. Others wait until it lands.
952                    if state.init_in_progress {
953                        Action::Wait
954                    } else {
955                        state.init_in_progress = true;
956                        state.size += 1;
957                        Action::Create { first: true }
958                    }
959                } else if state.size < self.inner.config.max_size {
960                    state.size += 1;
961                    Action::Create { first: false }
962                } else {
963                    Action::Wait
964                }
965            };
966
967            match action {
968                Action::Reuse(idle) => {
969                    let live_size = {
970                        let state = self.inner.state.lock().expect("pool mutex poisoned");
971                        state.size
972                    };
973                    if self.inner.should_evict(&idle, live_size) {
974                        self.inner.discard();
975                        continue;
976                    }
977                    if self.inner.recycle(&idle.conn).is_ok() {
978                        return Ok(SyncPooledConnection {
979                            pool: Arc::clone(&self.inner),
980                            conn: Some(idle.conn),
981                            created: idle.created,
982                        });
983                    }
984                    // Probe failed: drop this connection and loop again to reuse
985                    // another idle one or build a fresh one.
986                    self.inner.discard();
987                }
988                Action::Create { first } => match self.inner.open(first) {
989                    Ok(conn) => {
990                        if first {
991                            let mut state = self.inner.state.lock().expect("pool mutex poisoned");
992                            state.initialized = true;
993                            state.init_in_progress = false;
994                            drop(state);
995                            // A successful first connection unblocks every
996                            // waiter that parked on `init_in_progress`.
997                            self.inner.available.notify_all();
998                        }
999                        return Ok(SyncPooledConnection {
1000                            pool: Arc::clone(&self.inner),
1001                            conn: Some(conn),
1002                            created: Instant::now(),
1003                        });
1004                    }
1005                    Err(e) => {
1006                        {
1007                            let mut state = self.inner.state.lock().expect("pool mutex poisoned");
1008                            state.size = state.size.saturating_sub(1);
1009                            if first {
1010                                state.init_in_progress = false;
1011                            }
1012                        }
1013                        // Wake waiters so the next one can retry the handshake.
1014                        self.inner.available.notify_all();
1015                        return Err(e);
1016                    }
1017                },
1018                Action::Wait => {
1019                    let state = self.inner.state.lock().expect("pool mutex poisoned");
1020                    // Re-check before parking to avoid a lost wakeup.
1021                    if !state.idle.is_empty()
1022                        || (state.initialized && state.size < self.inner.config.max_size)
1023                        || !state.initialized && !state.init_in_progress
1024                    {
1025                        continue;
1026                    }
1027                    match deadline {
1028                        Some(dl) => {
1029                            let now = Instant::now();
1030                            if now >= dl {
1031                                return Err(Error::timeout(
1032                                    "timed out waiting for an available pool connection",
1033                                ));
1034                            }
1035                            let (_guard, res) = self
1036                                .inner
1037                                .available
1038                                .wait_timeout(state, dl - now)
1039                                .expect("pool mutex poisoned");
1040                            if res.timed_out() {
1041                                return Err(Error::timeout(
1042                                    "timed out waiting for an available pool connection",
1043                                ));
1044                            }
1045                        }
1046                        None => {
1047                            let _guard = self
1048                                .inner
1049                                .available
1050                                .wait(state)
1051                                .expect("pool mutex poisoned");
1052                        }
1053                    }
1054                }
1055            }
1056        }
1057    }
1058
1059    /// Returns the current pool status.
1060    ///
1061    /// # Panics
1062    ///
1063    /// Panics if the internal pool mutex has been poisoned.
1064    #[must_use]
1065    pub fn status(&self) -> PoolStatus {
1066        let state = self.inner.state.lock().expect("pool mutex poisoned");
1067        PoolStatus {
1068            idle: state.idle.len(),
1069            size: state.size,
1070            max_size: self.inner.config.max_size,
1071        }
1072    }
1073}
1074
1075/// A snapshot of [`ConnectionPool`] occupancy.
1076#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1077pub struct PoolStatus {
1078    /// Number of idle connections currently available.
1079    pub idle: usize,
1080    /// Total live connections (idle + checked out).
1081    pub size: usize,
1082    /// Configured maximum pool size.
1083    pub max_size: usize,
1084}
1085
1086/// A connection checked out of a synchronous [`ConnectionPool`].
1087///
1088/// Derefs to [`Connection`]. Returns to the pool when dropped; if it is dropped
1089/// while no longer alive (or after [`take`](Self::take)), the slot is freed
1090/// instead so the pool can build a replacement.
1091pub struct SyncPooledConnection {
1092    pool: Arc<SyncPoolInner>,
1093    conn: Option<Connection>,
1094    created: Instant,
1095}
1096
1097impl SyncPooledConnection {
1098    /// Removes the connection from the pool's management, taking ownership.
1099    ///
1100    /// The pool slot is freed; the returned connection will not be recycled.
1101    ///
1102    /// # Panics
1103    ///
1104    /// Panics if the connection has already been taken out of this guard
1105    /// (only reachable via internal misuse — `take` consumes `self`).
1106    #[must_use]
1107    pub fn take(mut self) -> Connection {
1108        let conn = self.conn.take().expect("connection already taken");
1109        self.pool.discard();
1110        conn
1111    }
1112}
1113
1114impl std::ops::Deref for SyncPooledConnection {
1115    type Target = Connection;
1116
1117    fn deref(&self) -> &Self::Target {
1118        self.conn.as_ref().expect("connection already taken")
1119    }
1120}
1121
1122impl std::fmt::Debug for SyncPooledConnection {
1123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1124        f.debug_struct("SyncPooledConnection")
1125            .field("checked_out", &self.conn.is_some())
1126            .finish_non_exhaustive()
1127    }
1128}
1129
1130impl Drop for SyncPooledConnection {
1131    fn drop(&mut self) {
1132        if let Some(conn) = self.conn.take() {
1133            // Return healthy connections to the pool; drop dead ones (freeing
1134            // the slot) so a replacement can be built on next checkout.
1135            if conn.is_alive() {
1136                self.pool.checkin(conn, self.created);
1137            } else {
1138                drop(conn);
1139                self.pool.discard();
1140            }
1141        }
1142    }
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147    use super::*;
1148
1149    #[test]
1150    fn test_pool_config_builder() {
1151        let config = PoolConfig::new("localhost:7483", "test.hyper")
1152            .create_mode(CreateMode::CreateIfNotExists)
1153            .auth("user", "pass")
1154            .max_size(32);
1155
1156        assert_eq!(config.endpoint, "localhost:7483");
1157        assert_eq!(config.database, "test.hyper");
1158        assert_eq!(config.create_mode, CreateMode::CreateIfNotExists);
1159        assert_eq!(config.user, Some("user".to_string()));
1160        assert_eq!(config.password, Some("pass".to_string()));
1161        assert_eq!(config.max_size, 32);
1162    }
1163
1164    #[test]
1165    fn test_pool_config_defaults_are_additive() {
1166        // A config that sets only the required fields must keep every new knob
1167        // at its zero-impact default so existing behavior is preserved.
1168        let config = PoolConfig::new("localhost:7483", "test.hyper");
1169        assert!(config.health_check);
1170        assert!(matches!(config.recycle, RecycleStrategy::SelectOne));
1171        assert_eq!(config.wait_timeout, None);
1172        assert_eq!(config.create_timeout, None);
1173        assert_eq!(config.recycle_timeout, None);
1174        assert_eq!(config.max_lifetime, None);
1175        assert_eq!(config.idle_timeout, None);
1176        assert_eq!(config.min_idle, None);
1177        assert!(!config.has_timeout());
1178    }
1179
1180    #[test]
1181    fn test_health_check_and_recycle_stay_in_sync() {
1182        let off = PoolConfig::new("e", "d").health_check(false);
1183        assert!(!off.health_check);
1184        assert!(matches!(off.recycle, RecycleStrategy::None));
1185
1186        let on = PoolConfig::new("e", "d").health_check(true);
1187        assert!(on.health_check);
1188        assert!(matches!(on.recycle, RecycleStrategy::SelectOne));
1189
1190        let via_recycle = PoolConfig::new("e", "d").recycle(RecycleStrategy::None);
1191        assert!(!via_recycle.health_check);
1192
1193        let via_ping = PoolConfig::new("e", "d").recycle(RecycleStrategy::Ping);
1194        assert!(via_ping.health_check);
1195        assert!(matches!(via_ping.recycle, RecycleStrategy::Ping));
1196    }
1197
1198    #[test]
1199    fn test_pool_config_timeout_builders() {
1200        let config = PoolConfig::new("e", "d")
1201            .wait_timeout(Some(Duration::from_secs(1)))
1202            .create_timeout(Some(Duration::from_secs(2)))
1203            .recycle_timeout(Some(Duration::from_secs(3)))
1204            .max_lifetime(Some(Duration::from_secs(60)))
1205            .idle_timeout(Some(Duration::from_secs(30)))
1206            .min_idle(Some(2));
1207        assert_eq!(config.wait_timeout, Some(Duration::from_secs(1)));
1208        assert_eq!(config.create_timeout, Some(Duration::from_secs(2)));
1209        assert_eq!(config.recycle_timeout, Some(Duration::from_secs(3)));
1210        assert_eq!(config.max_lifetime, Some(Duration::from_secs(60)));
1211        assert_eq!(config.idle_timeout, Some(Duration::from_secs(30)));
1212        assert_eq!(config.min_idle, Some(2));
1213        assert!(config.has_timeout());
1214    }
1215
1216    #[test]
1217    fn test_sync_pool_config_builder_and_defaults() {
1218        let config = SyncPoolConfig::new("localhost:7483", "test.hyper");
1219        assert_eq!(config.max_size, 16);
1220        assert!(matches!(config.recycle, SyncRecycleStrategy::SelectOne));
1221        assert_eq!(config.wait_timeout, None);
1222        assert_eq!(config.max_lifetime, None);
1223        assert_eq!(config.idle_timeout, None);
1224        assert_eq!(config.min_idle, None);
1225
1226        let tuned = SyncPoolConfig::new("e", "d")
1227            .create_mode(CreateMode::CreateIfNotExists)
1228            .auth("u", "p")
1229            .max_size(4)
1230            .recycle(SyncRecycleStrategy::Ping)
1231            .wait_timeout(Some(Duration::from_millis(500)))
1232            .max_lifetime(Some(Duration::from_secs(10)))
1233            .idle_timeout(Some(Duration::from_secs(5)))
1234            .min_idle(Some(1));
1235        assert_eq!(tuned.max_size, 4);
1236        assert!(matches!(tuned.recycle, SyncRecycleStrategy::Ping));
1237        assert_eq!(tuned.user, Some("u".to_string()));
1238        assert_eq!(tuned.wait_timeout, Some(Duration::from_millis(500)));
1239    }
1240
1241    #[test]
1242    fn test_debug_redacts_password() {
1243        let dbg = format!("{:?}", PoolConfig::new("e", "d").auth("u", "secret"));
1244        assert!(dbg.contains("<redacted>"));
1245        assert!(!dbg.contains("secret"));
1246
1247        let sync_dbg = format!("{:?}", SyncPoolConfig::new("e", "d").auth("u", "secret"));
1248        assert!(sync_dbg.contains("<redacted>"));
1249        assert!(!sync_dbg.contains("secret"));
1250    }
1251}