Skip to main content

heliosdb_proxy/pool/
backend_pool.rs

1//! Data-path backend connection pool for Transaction / Statement pooling modes.
2//!
3//! This is the *raw-stream* pool that actually multiplexes clients onto a
4//! bounded set of backend connections — the piece that makes the
5//! `pool-modes` feature do real work on the wire. It is deliberately distinct
6//! from [`crate::pool::manager::ConnectionPoolManager`], which models pooling
7//! over the higher-level `BackendClient` message API; the proxy data path
8//! forwards raw PostgreSQL-wire bytes, so it needs a pool of authenticated
9//! `TcpStream`s.
10//!
11//! ## Identity keying (why this is safe)
12//!
13//! HeliosProxy authenticates backend connections by **passing the client's own
14//! credentials through** to PostgreSQL (the client SCRAM handshake is relayed).
15//! A parked connection is therefore authenticated as a specific
16//! `(user, database)` principal. The pool keys idle connections by
17//! `node\0user\0database`, so a connection is only ever handed to a client that
18//! connected with the *same* identity — and that client independently
19//! authenticated before it could reach the pool. This is exactly PgBouncer's
20//! per-(user,db) pooling model; it does not multiplex distinct users onto one
21//! backend identity (that would need proxy-terminated auth with a shared
22//! backend credential, which is a separate, larger change).
23//!
24//! ## Cleanliness
25//!
26//! A connection is `DISCARD ALL`-reset by the caller before it is parked
27//! (see the release path in `server.rs`), so the next borrower — possibly a
28//! *different* client of the same identity — never inherits GUCs, temp tables,
29//! prepared statements, or advisory locks. On checkout the connection is
30//! liveness-probed so a peer that closed the socket while idle is dropped
31//! rather than handed out.
32
33use dashmap::DashMap;
34use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
35use std::time::{Duration, Instant};
36use tokio::net::TcpStream;
37
38/// Build the pool key for a `(node, user, database)` triple. NUL-delimited so
39/// the three components can never collide across boundaries.
40pub fn pool_key(node: &str, user: &str, database: &str) -> String {
41    format!("{}\0{}\0{}", node, user, database)
42}
43
44/// A bounded set of idle, authenticated backend connections, partitioned by
45/// connection identity. Cheap to clone-share behind an `Arc`.
46pub struct BackendIdlePool {
47    /// identity-key -> stack of (idle authenticated stream, park time).
48    idle: DashMap<String, Vec<(TcpStream, Instant)>>,
49    /// Hard cap on idle connections parked per identity key.
50    max_idle_per_key: usize,
51    /// Hard cap on idle connections parked across ALL identities — bounds total
52    /// file descriptors / memory regardless of how many distinct
53    /// `(node,user,db)` identities appear.
54    max_total_idle: usize,
55    /// Live count of parked connections (kept in step with `idle`) so the
56    /// global-cap check and `idle_count()` are O(1) instead of O(keys).
57    total_idle: AtomicUsize,
58    /// Checkout hits — a parked connection was reused.
59    reuses: AtomicU64,
60    /// Connections parked (checked in) successfully.
61    parked: AtomicU64,
62    /// Check-ins refused because an idle cap (per-key or global) was reached —
63    /// the connection is closed by the caller dropping it.
64    over_capacity: AtomicU64,
65    /// Parked connections dropped at checkout because the peer had closed
66    /// them (or left unexpected bytes) while idle.
67    stale_evicted: AtomicU64,
68    /// Parked connections dropped by the idle reaper for exceeding the TTL.
69    reaped: AtomicU64,
70    /// Connections parked WITHOUT running the reset query because they were
71    /// provably clean (the `skip_clean_reset` conditional-reset optimisation).
72    resets_skipped: AtomicU64,
73}
74
75impl BackendIdlePool {
76    /// Create a pool that parks at most `max_idle_per_key` connections per
77    /// `(node,user,db)` identity and `max_total_idle` across all identities.
78    /// A floor of 1 is enforced on each so the pool always retains at least one
79    /// reusable connection.
80    pub fn new(max_idle_per_key: usize, max_total_idle: usize) -> Self {
81        Self {
82            idle: DashMap::new(),
83            max_idle_per_key: max_idle_per_key.max(1),
84            max_total_idle: max_total_idle.max(1),
85            total_idle: AtomicUsize::new(0),
86            reuses: AtomicU64::new(0),
87            parked: AtomicU64::new(0),
88            over_capacity: AtomicU64::new(0),
89            stale_evicted: AtomicU64::new(0),
90            reaped: AtomicU64::new(0),
91            resets_skipped: AtomicU64::new(0),
92        }
93    }
94
95    /// Record that a provably-clean connection was parked without a reset.
96    pub fn note_reset_skipped(&self) {
97        self.resets_skipped.fetch_add(1, Ordering::Relaxed);
98    }
99
100    /// Connections parked without a reset because they were provably clean.
101    pub fn resets_skipped(&self) -> u64 {
102        self.resets_skipped.load(Ordering::Relaxed)
103    }
104
105    /// Take a live idle connection for `key`, or `None` if the pool has no
106    /// usable one (caller then dials a fresh connection). Dead/stale parked
107    /// connections are evicted in passing.
108    pub fn checkout(&self, key: &str) -> Option<TcpStream> {
109        let mut guard = self.idle.get_mut(key)?;
110        while let Some((stream, _parked_at)) = guard.pop() {
111            self.total_idle.fetch_sub(1, Ordering::Relaxed);
112            if Self::probe_alive(&stream) {
113                self.reuses.fetch_add(1, Ordering::Relaxed);
114                return Some(stream);
115            }
116            // Peer closed (or desynced) while idle — drop it and try the next.
117            self.stale_evicted.fetch_add(1, Ordering::Relaxed);
118        }
119        None
120    }
121
122    /// Park a (freshly reset) connection for reuse under `key`. Returns `false`
123    /// when an idle cap (per-key OR global) is already reached — in that case
124    /// the connection is dropped (closed) by being moved in and discarded,
125    /// shedding excess capacity.
126    pub fn checkin(&self, key: &str, stream: TcpStream) -> bool {
127        // Global ceiling first — bounds total FDs across all identities. Reserve
128        // the slot atomically (fetch_add, inspect the prior value) rather than
129        // load-then-act: a plain load outside the lock lets N concurrent
130        // check-ins to distinct keys each observe `cap - 1` and all push,
131        // overshooting the ceiling. With a reservation only one racer sees a
132        // prior value below the cap; the rest roll their increment back.
133        if self.total_idle.fetch_add(1, Ordering::Relaxed) >= self.max_total_idle {
134            self.total_idle.fetch_sub(1, Ordering::Relaxed);
135            self.over_capacity.fetch_add(1, Ordering::Relaxed);
136            return false; // `stream` dropped here → socket closed.
137        }
138        let mut entry = self.idle.entry(key.to_string()).or_default();
139        if entry.len() >= self.max_idle_per_key {
140            // Per-key cap reached — release the global slot we reserved.
141            self.total_idle.fetch_sub(1, Ordering::Relaxed);
142            self.over_capacity.fetch_add(1, Ordering::Relaxed);
143            return false; // `stream` dropped here → socket closed.
144        }
145        entry.push((stream, Instant::now()));
146        self.parked.fetch_add(1, Ordering::Relaxed);
147        true
148    }
149
150    /// Drop parked connections that have been idle longer than `max_age` so a
151    /// connection the backend has (or will) close on its own idle timeout is
152    /// not handed out stale, and idle capacity is released back to the OS.
153    /// Returns the number reaped. Intended to be called periodically by a
154    /// background task.
155    pub fn reap_idle(&self, max_age: Duration) -> usize {
156        let mut reaped = 0usize;
157        for mut entry in self.idle.iter_mut() {
158            let before = entry.value().len();
159            entry
160                .value_mut()
161                .retain(|(_, parked_at)| parked_at.elapsed() < max_age);
162            reaped += before - entry.value().len();
163        }
164        if reaped > 0 {
165            self.total_idle.fetch_sub(reaped, Ordering::Relaxed);
166            self.reaped.fetch_add(reaped as u64, Ordering::Relaxed);
167        }
168        reaped
169    }
170
171    /// Liveness probe for an idle parked connection: a clean idle backend has
172    /// no pending bytes, so a non-blocking read should report `WouldBlock`.
173    /// `Ok(0)` means the peer closed; `Ok(n>0)` means unexpected data (protocol
174    /// desync) — both are treated as dead.
175    fn probe_alive(stream: &TcpStream) -> bool {
176        let mut probe = [0u8; 1];
177        matches!(
178            stream.try_read(&mut probe),
179            Err(e) if e.kind() == std::io::ErrorKind::WouldBlock
180        )
181    }
182
183    /// Total idle connections currently parked across all identities (O(1)).
184    pub fn idle_count(&self) -> usize {
185        self.total_idle.load(Ordering::Relaxed)
186    }
187
188    /// Global ceiling on parked idle connections.
189    pub fn max_total_idle(&self) -> usize {
190        self.max_total_idle
191    }
192
193    /// Number of parked connections dropped by the idle reaper (TTL).
194    pub fn reaped(&self) -> u64 {
195        self.reaped.load(Ordering::Relaxed)
196    }
197
198    /// Number of checkout hits (connections reused rather than dialed fresh).
199    pub fn reuses(&self) -> u64 {
200        self.reuses.load(Ordering::Relaxed)
201    }
202
203    /// Number of successful check-ins.
204    pub fn parked(&self) -> u64 {
205        self.parked.load(Ordering::Relaxed)
206    }
207
208    /// Number of check-ins refused for exceeding the per-key idle cap.
209    pub fn over_capacity(&self) -> u64 {
210        self.over_capacity.load(Ordering::Relaxed)
211    }
212
213    /// Number of stale connections evicted at checkout.
214    pub fn stale_evicted(&self) -> u64 {
215        self.stale_evicted.load(Ordering::Relaxed)
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use tokio::net::TcpListener;
223
224    /// Open a connected TcpStream pair against a throwaway loopback listener so
225    /// tests can exercise the pool's bookkeeping with real (live) sockets.
226    async fn live_stream(listener: &TcpListener) -> TcpStream {
227        let addr = listener.local_addr().unwrap();
228        let connect = TcpStream::connect(addr);
229        let accept = listener.accept();
230        let (client, _server) = tokio::join!(connect, accept);
231        // Keep the server side alive by leaking it into a long-lived holder via
232        // the caller; here we just return the client side. The accepted half is
233        // dropped, which is fine for liveness tests that re-accept per stream.
234        client.unwrap()
235    }
236
237    #[test]
238    fn pool_key_is_nul_delimited_and_distinct() {
239        assert_eq!(pool_key("n", "u", "d"), "n\0u\0d");
240        assert_ne!(pool_key("n", "ud", ""), pool_key("n", "u", "d"));
241    }
242
243    #[tokio::test]
244    async fn checkin_then_checkout_reuses_same_connection() {
245        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
246        let pool = BackendIdlePool::new(4, 1000);
247        let key = pool_key("127.0.0.1:5432", "bench", "benchdb");
248
249        // Park a live connection, then check it back out.
250        let s = live_stream(&listener).await;
251        let parked_addr = s.local_addr().unwrap();
252        assert!(pool.checkin(&key, s));
253        assert_eq!(pool.idle_count(), 1);
254
255        let got = pool
256            .checkout(&key)
257            .expect("a parked connection is reusable");
258        assert_eq!(got.local_addr().unwrap(), parked_addr, "same socket reused");
259        assert_eq!(pool.reuses(), 1);
260        assert_eq!(pool.idle_count(), 0);
261
262        // Empty pool → miss.
263        assert!(pool.checkout(&key).is_none());
264    }
265
266    #[tokio::test]
267    async fn distinct_identities_do_not_share() {
268        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
269        let pool = BackendIdlePool::new(4, 1000);
270        let alice = pool_key("n", "alice", "db");
271        let bob = pool_key("n", "bob", "db");
272
273        pool.checkin(&alice, live_stream(&listener).await);
274        // Bob must NOT see alice's connection.
275        assert!(pool.checkout(&bob).is_none());
276        assert!(pool.checkout(&alice).is_some());
277    }
278
279    #[tokio::test]
280    async fn per_key_cap_sheds_excess() {
281        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
282        let pool = BackendIdlePool::new(2, 1000);
283        let key = pool_key("n", "u", "d");
284
285        assert!(pool.checkin(&key, live_stream(&listener).await));
286        assert!(pool.checkin(&key, live_stream(&listener).await));
287        // Third exceeds the cap of 2 → refused (and dropped/closed).
288        assert!(!pool.checkin(&key, live_stream(&listener).await));
289        assert_eq!(pool.over_capacity(), 1);
290        assert_eq!(pool.idle_count(), 2);
291    }
292
293    #[tokio::test]
294    async fn checkout_evicts_a_closed_connection() {
295        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
296        let pool = BackendIdlePool::new(4, 1000);
297        let key = pool_key("n", "u", "d");
298
299        // Park a connection, then close the server side so the parked socket is
300        // dead.
301        let addr = listener.local_addr().unwrap();
302        let client = TcpStream::connect(addr).await.unwrap();
303        let (server, _) = listener.accept().await.unwrap();
304        pool.checkin(&key, client);
305        drop(server); // peer closes
306                      // Give the close a moment to propagate.
307        tokio::task::yield_now().await;
308        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
309
310        // Checkout must not hand out the dead connection.
311        assert!(pool.checkout(&key).is_none());
312        assert_eq!(pool.stale_evicted(), 1);
313    }
314
315    #[tokio::test]
316    async fn global_cap_sheds_across_distinct_identities() {
317        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
318        // Per-key cap is generous (10) but the GLOBAL cap is 2.
319        let pool = BackendIdlePool::new(10, 2);
320        // Three different identities, one connection each.
321        assert!(pool.checkin(&pool_key("n", "a", "d"), live_stream(&listener).await));
322        assert!(pool.checkin(&pool_key("n", "b", "d"), live_stream(&listener).await));
323        // Third exceeds the global ceiling even though its per-key bucket is empty.
324        assert!(!pool.checkin(&pool_key("n", "c", "d"), live_stream(&listener).await));
325        assert_eq!(pool.idle_count(), 2);
326        assert_eq!(pool.over_capacity(), 1);
327    }
328
329    #[tokio::test]
330    async fn reaper_drops_aged_idle_connections() {
331        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
332        let pool = BackendIdlePool::new(4, 100);
333        let key = pool_key("n", "u", "d");
334        pool.checkin(&key, live_stream(&listener).await);
335        assert_eq!(pool.idle_count(), 1);
336
337        // Nothing reaped while within the TTL.
338        assert_eq!(pool.reap_idle(std::time::Duration::from_secs(60)), 0);
339        assert_eq!(pool.idle_count(), 1);
340
341        // Let it age, then reap with a tiny TTL.
342        tokio::time::sleep(std::time::Duration::from_millis(15)).await;
343        assert_eq!(pool.reap_idle(std::time::Duration::from_millis(5)), 1);
344        assert_eq!(pool.idle_count(), 0);
345        assert_eq!(pool.reaped(), 1);
346        // A subsequent checkout misses (it was reaped).
347        assert!(pool.checkout(&key).is_none());
348    }
349}