Skip to main content

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}