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