thunder/client/pool.rs
1//! Optional connection pool (CLT-080) — a layer **above** the
2//! single-connection [`Client`] (CLT-001: "pooling is a layer above").
3//!
4//! Under a mandatory-`HELLO` profile with `auth_required`, a fresh connection
5//! costs a handshake round trip before the first request. A caller that opens a
6//! connection per operation therefore pays that round trip every time — the
7//! failure Nexus's per-request REST client and Vectorizer's per-RPC Raft
8//! channel each hit as TIME_WAIT port exhaustion. The pool amortizes it: `N`
9//! operations over a checked-out connection pay **one** connect and **one**
10//! handshake, not `N`.
11//!
12//! The shape is deliberately minimal — a fixed number of connections bounded by
13//! a semaphore, an idle list, lazy connect on first checkout, and an RAII guard
14//! that returns the connection on drop. It is **not** `bb8`/`deadpool`/`r2d2`:
15//! those bring async traits and heavyweight reconnect logic this layer does not
16//! need. Health checks, background reaping and min-idle warmup are out of scope;
17//! a poisoned connection (CLT-014) is dropped on return and the next checkout
18//! connects fresh, leaving reconnect to CLT-030 rather than the pool.
19//!
20//! The pool adds **no wire behavior**: it builds the same [`Client`] as
21//! [`Client::connect_with`] from a [`Config`] and [`ClientConfig`], and the
22//! single-connection client's API is unchanged (CLT-001). `max_in_flight`
23//! (CLT-012) stays a per-connection bound; the pool bounds connections, not
24//! in-flight calls.
25//!
26//! ```no_run
27//! use thunder::{ClientConfig, Config};
28//! use thunder::client::Pool;
29//!
30//! # async fn demo() -> Result<(), thunder::ClientError> {
31//! let app = Config::standard().scheme("myapp").port(9000);
32//! let pool = Pool::new("myapp://localhost", app, ClientConfig::new(), 8);
33//! let conn = pool.acquire().await?; // reuses an idle connection, or dials one
34//! let pong = conn.call("PING", vec![]).await?;
35//! assert_eq!(pong.as_str(), Some("PONG"));
36//! // `conn` returns the connection to the pool when it drops.
37//! # Ok(())
38//! # }
39//! ```
40
41use std::sync::{Arc, Mutex as StdMutex, MutexGuard, PoisonError};
42
43use tokio::sync::{OwnedSemaphorePermit, Semaphore};
44
45use crate::client::{Client, ClientConfig, ClientError};
46use crate::wire::Config;
47
48/// Ride through std-mutex poisoning: a panicked holder must not wedge the pool.
49fn lock<T>(mutex: &StdMutex<T>) -> MutexGuard<'_, T> {
50 mutex.lock().unwrap_or_else(PoisonError::into_inner)
51}
52
53/// A bounded pool of [`Client`]s over one endpoint (CLT-080).
54///
55/// At most `max_connections` connections are live at once; a checkout beyond
56/// that awaits a return. Connections are dialed lazily — construction opens
57/// none — and reused across checkouts so the handshake is paid once per
58/// connection, not once per operation.
59pub struct Pool {
60 endpoint: String,
61 config: Config,
62 client_config: ClientConfig,
63 /// Bounds live + checked-out connections to `max_connections`.
64 permits: Arc<Semaphore>,
65 /// Idle connections available for reuse.
66 idle: Arc<StdMutex<Vec<Client>>>,
67}
68
69impl Pool {
70 /// Build a pool for `endpoint`. Opens no connections — the first
71 /// [`acquire`](Self::acquire) dials the first one. `max_connections` is
72 /// clamped to at least 1.
73 pub fn new(
74 endpoint: impl Into<String>,
75 config: Config,
76 client_config: ClientConfig,
77 max_connections: usize,
78 ) -> Self {
79 let max = max_connections.max(1);
80 Self {
81 endpoint: endpoint.into(),
82 config,
83 client_config,
84 permits: Arc::new(Semaphore::new(max)),
85 idle: Arc::new(StdMutex::new(Vec::with_capacity(max))),
86 }
87 }
88
89 /// Check out a connection. Reuses an idle, **live** connection when one is
90 /// available; otherwise dials and handshakes a fresh one (CLT-002). Awaits a
91 /// return when `max_connections` are already checked out. The returned
92 /// [`PooledConn`] returns the connection to the pool on drop.
93 pub async fn acquire(&self) -> Result<PooledConn, ClientError> {
94 let permit = Arc::clone(&self.permits)
95 .acquire_owned()
96 .await
97 .map_err(|_| ClientError::Connection {
98 message: "connection pool is closed".to_owned(),
99 })?;
100
101 // Reuse the newest idle connection that is still live; discard any that
102 // were poisoned (CLT-014) while sitting idle.
103 let reused = {
104 let mut idle = lock(&self.idle);
105 loop {
106 match idle.pop() {
107 Some(client) if client.is_alive() => break Some(client),
108 Some(_dead) => continue,
109 None => break None,
110 }
111 }
112 };
113 let client = match reused {
114 Some(client) => client,
115 None => {
116 Client::connect_with(
117 &self.endpoint,
118 self.config.clone(),
119 self.client_config.clone(),
120 )
121 .await?
122 }
123 };
124
125 Ok(PooledConn {
126 inner: Some(client),
127 idle: Arc::clone(&self.idle),
128 _permit: permit,
129 })
130 }
131
132 /// Idle connections currently parked in the pool. For diagnostics and tests
133 /// — production code should not branch on it.
134 pub fn idle_count(&self) -> usize {
135 lock(&self.idle).len()
136 }
137}
138
139/// RAII guard from [`Pool::acquire`]. Derefs to the [`Client`], and returns the
140/// connection to the pool on drop so the next checkout reuses it — unless the
141/// connection was poisoned, in which case it is dropped and the next checkout
142/// connects fresh (CLT-014/030).
143pub struct PooledConn {
144 /// `Some` for the guard's whole life; taken only in [`Drop`].
145 inner: Option<Client>,
146 idle: Arc<StdMutex<Vec<Client>>>,
147 /// Held for the checkout's duration; releasing it lets a waiter proceed.
148 _permit: OwnedSemaphorePermit,
149}
150
151impl PooledConn {
152 /// Borrow the checked-out client. (Also available via [`Deref`].)
153 pub fn client(&self) -> &Client {
154 match &self.inner {
155 Some(client) => client,
156 // Unreachable: `inner` is only taken in `Drop`, after which no
157 // method can be called on the guard.
158 None => unreachable!("PooledConn::client after drop"),
159 }
160 }
161}
162
163impl std::ops::Deref for PooledConn {
164 type Target = Client;
165
166 fn deref(&self) -> &Client {
167 self.client()
168 }
169}
170
171impl Drop for PooledConn {
172 fn drop(&mut self) {
173 if let Some(client) = self.inner.take() {
174 // CLT-014: only a live connection returns to the pool. A poisoned or
175 // closed one is dropped here; the next checkout dials fresh, leaving
176 // reconnect to CLT-030 rather than the pool.
177 if client.is_alive() {
178 lock(&self.idle).push(client);
179 }
180 }
181 }
182}
183
184#[cfg(test)]
185#[allow(clippy::unwrap_used, clippy::expect_used)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn new_does_not_dial_and_clamps_capacity() {
191 let pool = Pool::new(
192 "test://127.0.0.1:0",
193 Config::standard(),
194 ClientConfig::new(),
195 0,
196 );
197 // No connection opened at construction, and max clamped to >= 1.
198 assert_eq!(pool.idle_count(), 0);
199 assert_eq!(pool.permits.available_permits(), 1);
200 }
201}