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