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}