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