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}