brain_db_sdk/pool.rs
1//! A fixed-size connection pool over [`BrainClient`].
2//!
3//! Each [`BrainClient`] is already a *multiplexed* connection — one socket
4//! that serves many concurrent requests demultiplexed by `stream_id` — so a
5//! pool isn't needed for concurrency. What a pool buys is **socket-level
6//! parallelism**: spreading load across N independent TCP connections (and
7//! thus N server-side connection slots / shards' accept paths), and isolating
8//! a single slow or stalled socket from the rest of the workload.
9//!
10//! [`Pool::connect`] opens `size` connections up front, each running its own
11//! handshake. [`Pool::get`] hands back a shared [`BrainClient`] by round-robin;
12//! callers issue verbs on it directly (the client's verbs take `&self`). The
13//! returned `Arc` can be cloned and moved into tasks freely.
14//!
15//! [`Pool::get_healthy`] replaces a member whose connection has died (the
16//! server restarted, or the socket failed) before handing it out, so a
17//! long-lived pool survives a server restart instead of failing every request
18//! on the dead sockets forever. At most one reconnect runs per member at a
19//! time; concurrent callers wait for it rather than each opening a socket.
20//!
21//! Deferred: periodic background health-checking, and graceful per-member BYE
22//! on shutdown. Dropping the pool drops every client, which closes its socket
23//! (a TCP FIN); it does not send a BYE frame first.
24
25use std::net::SocketAddr;
26use std::sync::atomic::{AtomicUsize, Ordering};
27use std::sync::{Arc, RwLock};
28
29use crate::client::{Auth, BrainClient, ClientConfig};
30use crate::error::{BrainError, Result};
31
32/// One pooled connection, swappable when it dies.
33struct Slot {
34 client: RwLock<Arc<BrainClient>>,
35 /// Serializes reconnects of this slot so a burst opens one socket.
36 reconnecting: tokio::sync::Mutex<()>,
37}
38
39impl Slot {
40 fn new(client: BrainClient) -> Self {
41 Self {
42 client: RwLock::new(Arc::new(client)),
43 reconnecting: tokio::sync::Mutex::new(()),
44 }
45 }
46
47 fn current(&self) -> Arc<BrainClient> {
48 Arc::clone(&self.client.read().expect("pool slot lock poisoned"))
49 }
50}
51
52/// A fixed-size set of [`BrainClient`] connections handed out round-robin.
53pub struct Pool {
54 slots: Vec<Slot>,
55 next: AtomicUsize,
56 addr: SocketAddr,
57 config: ClientConfig,
58}
59
60impl std::fmt::Debug for Pool {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 f.debug_struct("Pool")
63 .field("size", &self.slots.len())
64 .finish_non_exhaustive()
65 }
66}
67
68impl Pool {
69 /// Open `size` connections to `addr` with the given credential and default
70 /// transport settings, and run every handshake. Fails (closing any
71 /// already-opened members) if `size` is 0 or any connection's handshake
72 /// fails.
73 pub async fn connect(addr: SocketAddr, size: usize, auth: Auth) -> Result<Self> {
74 Self::connect_with(addr, size, &ClientConfig::new(auth)).await
75 }
76
77 /// Open `size` connections to `addr` with an explicit configuration
78 /// template, cloned per connection. Each member runs its own handshake.
79 /// All members share the template's credential, so the server binds them to
80 /// the same agent; they remain individually identifiable by session id.
81 pub async fn connect_with(
82 addr: SocketAddr,
83 size: usize,
84 config: &ClientConfig,
85 ) -> Result<Self> {
86 if size == 0 {
87 return Err(BrainError::Protocol(
88 "connection pool size must be >= 1".to_string(),
89 ));
90 }
91 let mut slots = Vec::with_capacity(size);
92 for _ in 0..size {
93 let cfg = config.clone();
94 match BrainClient::connect_with(addr, cfg).await {
95 Ok(client) => slots.push(Slot::new(client)),
96 Err(e) => {
97 // Best-effort close of the members opened so far before
98 // surfacing the failure; dropping each closes its socket.
99 drop(slots);
100 return Err(e);
101 }
102 }
103 }
104 Ok(Self {
105 slots,
106 next: AtomicUsize::new(0),
107 addr,
108 config: config.clone(),
109 })
110 }
111
112 /// The number of pooled connections.
113 #[must_use]
114 pub fn size(&self) -> usize {
115 self.slots.len()
116 }
117
118 fn next_slot(&self) -> &Slot {
119 let idx = self.next.fetch_add(1, Ordering::Relaxed) % self.slots.len();
120 &self.slots[idx]
121 }
122
123 /// Borrow the next connection, round-robin, as-is — it may be dead if the
124 /// server went away. Prefer [`Pool::get_healthy`] for long-lived pools.
125 /// The returned `Arc` is cheap to clone and safe to move across tasks; the
126 /// underlying client multiplexes concurrent requests itself.
127 ///
128 /// # Panics
129 /// Only if a slot's lock was poisoned by a panic while swapping a client.
130 #[must_use]
131 pub fn get(&self) -> Arc<BrainClient> {
132 self.next_slot().current()
133 }
134
135 /// Borrow the next live connection, round-robin, reconnecting it first if
136 /// it has died (e.g. the server restarted). Requests already in flight on
137 /// the old connection fail as before; new ones get the fresh socket.
138 ///
139 /// # Errors
140 /// The reconnect's [`BrainError`] if the server is still unreachable.
141 ///
142 /// # Panics
143 /// Only if a slot's lock was poisoned by a panic while swapping a client.
144 pub async fn get_healthy(&self) -> Result<Arc<BrainClient>> {
145 let slot = self.next_slot();
146 let client = slot.current();
147 if !client.is_closed() {
148 return Ok(client);
149 }
150 let _guard = slot.reconnecting.lock().await;
151 // Another caller may have reconnected while we waited.
152 let client = slot.current();
153 if !client.is_closed() {
154 return Ok(client);
155 }
156 let fresh = Arc::new(BrainClient::connect_with(self.addr, self.config.clone()).await?);
157 *slot.client.write().expect("pool slot lock poisoned") = Arc::clone(&fresh);
158 Ok(fresh)
159 }
160}