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