connpool
Connection pooling for Rust — blocking & async (tokio), zero deps for the blocking pool.
The Rust member of poolyglot · Pool once. Speak everywhere.
A generic connection pool for Rust. The blocking ConnectionPool is standard-library only
(zero runtime dependencies); the async AsyncConnectionPool lives behind the optional tokio
feature. It pools anything: DB drivers, HTTP clients, SSH sessions, sockets… because the pool
knows nothing about connections except what a small ConnectionFactory tells it. T is tracked
by value identity (Clone + Eq + Hash) — e.g. Arc<Conn> — the Rust analog of Go's comparable.
use ;
use Arc;
use ;
// A toy "connection": a unique id behind an Arc so clones share identity.
let next = new;
let factory = new;
let pool = new
.expect;
pool.open?; // optional: warm-up + background sweeper
let conn = pool.acquire?; // acquire -> use -> release
// ... use conn ...
pool.release?;
This is the Rust member of poolyglot: one pooling model, implemented per language,
kept API-parallel. Field names, event names, defaults, and CloseReason/BreakerState wire values
are identical to the Python (connpool), C# (ConnPool), Java (connpool), Go (connpool) and
TypeScript (@jeffeek/connpool) implementations — switching languages is a syntax change, not a
model change.
Install
The crate is published as connpool-rs (the crates.io name connpool was taken) but is
imported as connpool (the library name), so use connpool::... works unchanged:
[]
= "1"
# with the async pool:
= { = "1", = ["tokio"] }
Requires Rust 1.85+ (edition 2024). Only the blocking pool is dependency-free; enabling the
tokio feature adds a single dependency (tokio) used by AsyncConnectionPool /
AsyncPoolRegistry. With the feature off (the default), the crate pulls in nothing.
Project layout
src/ the library (dependency-free for the blocking pool; one crate `connpool`)
core/ errors.rs, clock.rs, connection.rs (PooledConnection/CloseReason), timeouts.rs (Timeout)
resilience/ breaker.rs (CircuitBreaker + BreakerState/BreakerConfig), retry.rs (RetryPolicy)
policy/ config.rs (PoolConfig/IdleStrategy), lifecycle.rs (pure "when must a connection retire?" rules)
factories/ sync_factory.rs, async_factory.rs (tokio-gated) <- what YOU implement
observability/ events.rs (PoolListener), stats.rs (PoolStats)
pools/ sync_pool.rs, async_pool.rs (tokio-gated), registry.rs, async_registry.rs (tokio-gated)
tests inline `#[cfg(test)] mod tests` per file (cargo test; 100% line gate via cargo-llvm-cov)
examples/ client code - NOT part of the published crate
Dependency direction is strictly downward (a layer only uses layers listed above it):
core → resilience → policy → factories/observability → pools. Everything lives in the single
connpool crate, so one use connpool::... imports the whole public API via src/lib.rs.
📐 See the architecture diagrams — layering, connection lifecycle, and circuit-breaker states (shared across all poolyglot languages).
What you get
| Concern | How it works |
|---|---|
| Sizing | min_size kept warm, max_size is a hard cap (slot reserved before create) |
| Timeouts | acquire_timeout (per-call override via Timeout), create_timeout (async), retries never sleep past the deadline |
| Rotation | max_lifetime (with jitter), max_uses, max_idle_time, LIFO/FIFO reuse |
| Health | validate on checkout if idle ≥ validate_after_idle; reset() on release; after an error: fatal → discard, otherwise re-validate; optional validate_idle pings idle connections in the background sweep and reaps dead ones |
| Retries | exponential backoff + jitter, configurable retry filter |
| Circuit breaker | closed → open → half-open, probe limits, state-change events; open = fail fast. Counts one failure per logical creation (a whole retry sequence), not per attempt |
| Backpressure | max_waiters caps the blocked-caller queue; excess callers fail fast with PoolError::Exhausted |
| Leak detection | leak_detection_timeout warns (via PoolListener::on_connection_leak) when a borrowed connection is held too long; warn-only, never force-reclaimed |
| Observability | PoolListener hooks + pool.stats() snapshot (counters, avg + p50/p95/p99 for both acquire-wait and create-latency over a recent window) |
| Shutdown | idempotent close(), wakes blocked callers, in-use connections close on release; close(Some(timeout)) drains gracefully |
| Cancellation | async cancellation (future-drop, see below) never leaks a slot |
| Many backends | PoolRegistry / AsyncPoolRegistry = one lazily-created pool per key (host, tenant, DB) |
Writing a factory
Implement ConnectionFactory<T> (or build one from closures with FnFactory):
use ;
use Error;
;
Async: implement AsyncConnectionFactory<T> (behind the tokio feature), or build one from
closures with AsyncFnFactory.
Configuration (PoolConfig)
PoolConfig is a plain struct with Default — build with struct-update syntax and call
validate() (the pool constructor does this for you, returning PoolError::Config on failure).
Optional seconds / counts are Option<f64> / Option<usize> / Option<u64>; None means "no
limit".
| Field | Default | Meaning |
|---|---|---|
min_size / max_size |
0 / 10 | warm connections / hard cap |
acquire_timeout |
30 | seconds to wait (None = forever, 0 = don't wait) |
max_waiters |
None | backpressure: cap callers blocked in acquire() (excess → PoolError::Exhausted; None = unlimited, 0 = never wait when full) |
create_timeout |
10 | async only; sync factories enforce their own |
max_lifetime / lifetime_jitter |
1800 / 0.1 | retire after age, shaved by up to 10% |
max_idle_time |
600 | evict idle connections (never below min_size) |
max_uses |
None | retire after N checkouts |
idle_strategy |
Lifo | Fifo spreads load evenly |
validate_after_idle |
30 | validate on checkout if idle this long (0 = always, None = never) |
validate_idle |
false | also validate idle connections during the background sweep and reap dead ones (needs open(); adds I/O) |
maintenance_interval |
30 | background sweep period (None = off) |
leak_detection_timeout |
None | warn when a borrowed connection is held longer than this (needs open(); checked once per sweep; None = off) |
retry |
3 attempts, 0.1s base, ×2, 5s cap | RetryPolicy (RetryPolicy::no_retry() disables) |
breaker |
5 failures / 30s recovery | Option<BreakerConfig> (None disables); failure_threshold counts failed creations, not retry attempts |
Build, test, run examples
Coverage needs the llvm-tools-preview component and cargo-llvm-cov:
Idiom mapping from Python/Go
- Errors are a single
PoolErrorenum (not an exception hierarchy or a marker-interface struct family) — match on the variant, the analog ofexcept PoolTimeoutError/errors.As(&PoolTimeoutError{}). - Optional seconds/counts are
Option<f64>/Option<usize>(Go's*float64/*int, Python'sOptional[float]). - Connections are tracked by value identity, so
T: Clone + Eq + Hash— useArc<Conn>or an id, the direct analog of Go'sT comparable. - No RAII guard type yet — borrowing is explicit
acquire()/release(), mirroring Go'sAcquire/Releasepair (not C#'susing lease = pool.Connection()).
- Async cancellation is future-drop, not a token: dropping the
acquire()/use_()future while it's pending frees the reserved slot via aDropguard, the Rust analog of Python's implicit task-cancellation handling and C#'sCancellationToken.
- A tiny built-in SplitMix64 RNG (
Rng/SystemRng) drives jitter so the blocking pool stays zero-dependency instead of pulling inrand. - Wire values stay identical across languages:
CloseReason::as_str(),BreakerState::as_str(),IdleStrategy::as_str(), andPoolStats::to_dictionary()keys are snake_case and match Python, C#, Java, Go and TypeScript exactly.
Limits / extension points
- The breaker guards connection creation. To also trip on request failures, wrap calls with
CircuitBreaker::call(). - The breaker counts consecutive failed creations (one per logical acquire, retries included). A real backend failure or a
create_timeoutfiring both count; an acquire-timeout (caller impatience) or cancellation mid-create does not. - Leak detection is warn-only by design and only runs while the pool is
open()ed (the maintenance sweep drives it), so registry pools need an explicitopen(), and a warning can lag by up to onemaintenance_interval. - Not fork-safe / not process-shared.
max_sizeis per-process; create a fresh pool per process. - Pool size is fixed at construction; there is no runtime resize. Use a new pool, or a
PoolRegistrykeyed by profile. - Wake-up order of blocked callers is not strictly FIFO (the condition broadcasts on every change).
- A blocking
create()can't be interrupted by the pool; give your client a connect timeout. For the async pool,create_timeoutis only enforced if your factory honors cancellation. - Listener hooks are synchronous; push heavy work to a queue.
- A factory's
create()must return a distinct object each call (Tis pooled by value identity; useArc<Conn>or similar); returning a shared/singleton connection is rejected withPoolError::ConnectionCreate. Tis constrained toClone + Eq + Hashbecause the pool tracks borrow state by the connection value in a map.- The async pool requires the
tokiofeature — Rust's standard library has no async runtime or timer of its own, unlike Go (context) or C# (Task). The blocking pool stays zero-dependency; this mirrors how the TypeScript port is async-only for the opposite reason (no blocking primitives at all). - Acquire-wait / create-latency percentiles are estimated over the last 1024 samples (bounded memory), not the full history.
- With
validate_idleon, idle connections being pinged by the background sweep are briefly reserved and count as in-use (not idle) instats()for the duration of that check. - Coverage gate is 100% lines (the LCOV metric Codecov reports), enforced by
run_tests.sh. LLVM region/branch coverage is intentionally not gated:async fnstate-machine codegen leaves a few region artifacts that aren't real untested paths.
Considered, not implemented
- A
PoolGuard/RAII wrapper that releases onDrop(like C#'susing lease = pool.Connection()) — skipped for now;acquire()/release()stays explicit and panics on an unreleased connection are avoided deliberately, since a double-free-style bug in aDropimpl can't return aResult. - Runtime pool resize — same rationale as the other languages: a
PoolRegistrykeyed by profile covers the common case without adding mutable shared config.
License
MIT — see LICENSE.