brain_edge/pool/mod.rs
1//! `BrainPool` — a bounded, per-credential connection-pool cache to Brain.
2//!
3//! Brain binds a connection's identity at handshake from the credential it
4//! presents, so one shared pool cannot serve many credentials without crossing
5//! isolation. `BrainPool` therefore keeps a bounded **per-credential pool
6//! cache**: the first call for a credential opens a `pool_size`-wide
7//! [`brain_db_sdk::Pool`] authenticated as that credential and reuses it for
8//! every later call (pooled, never connect-per-request).
9//!
10//! The cache is held in an [`ArcSwap`] so the hot path (pool already built) is a
11//! lock-free load + map lookup with a relaxed-atomic recency bump — no mutex, no
12//! `.await` under a lock. The cold path builds the pool OUTSIDE any critical
13//! section and publishes it via copy-on-write [`ArcSwap::rcu`].
14//!
15//! It is bounded two ways so a long-lived process serving many credentials
16//! doesn't accumulate one `Arc<Pool>` (and `pool_size` live TCP connections) per
17//! credential forever: a periodic idle sweep ([`BrainPool::sweep_idle`]) drops
18//! pools unused past `idle_ttl`, and a hard cap (`max_credentials`) evicts the
19//! least-recently-used entry opportunistically on insert as a burst-protection
20//! backstop between sweeps.
21//!
22//! This is the shared wire-engine both brain-edge (self-host) and the Arc cloud
23//! gateway use. Budgets and timeouts are deliberately **not** here — they are the
24//! caller's concern.
25//!
26//! ## Two modes
27//!
28//! - **Per-credential** ([`BrainPool::new`]) — the self-host bearer-passthrough
29//! default described above. Each distinct wire credential gets its own pool, so
30//! Brain binds each to its own key-bound identity at handshake.
31//! - **Shared / `act_as`** ([`BrainPool::shared`]) — one pool authenticated once
32//! as a trusted **service principal** (a `can_act_as` key). Every
33//! [`BrainPool::client_for`] hands back a client from that single pool
34//! regardless of the `credential` argument; the gateway then resolves each API
35//! key to a `(namespace, space_id)` and sets `act_as` per request so one
36//! connection pool serves every tenant. This mode is for the gateway only — the
37//! self-host edge doesn't resolve keys to identities, so it stays per-credential.
38
39use std::collections::HashMap;
40use std::net::SocketAddr;
41use std::sync::Arc;
42use std::sync::atomic::{AtomicU64, Ordering};
43use std::time::{Duration, SystemTime, UNIX_EPOCH};
44
45use arc_swap::ArcSwap;
46use brain_db_sdk::{Auth, BrainClient, BrainError, Pool};
47use tokio::sync::OnceCell;
48use tracing::{info, warn};
49
50/// Configuration for a [`BrainPool`].
51#[derive(Clone, Debug)]
52pub struct BrainPoolConfig {
53 /// The Brain database listener address every pool connects to.
54 pub brain_addr: SocketAddr,
55 /// Sockets per credential (the [`brain_db_sdk::Pool`] width).
56 pub pool_size: usize,
57 /// Hard cap on distinct cached credentials; the least-recently-used entry is
58 /// evicted on insert once the cache is full.
59 pub max_credentials: usize,
60 /// Credentials unused longer than this are dropped by [`BrainPool::sweep_idle`].
61 pub idle_ttl: Duration,
62}
63
64/// A cached credential pool plus its last-touch time, so recency can be tracked
65/// without taking a lock on every hot-path hit — the atomic is bumped in place;
66/// only eviction (cold path) needs to read across entries.
67struct PoolEntry {
68 pool: Arc<Pool>,
69 last_used_secs: AtomicU64,
70}
71
72fn now_secs() -> u64 {
73 SystemTime::now()
74 .duration_since(UNIX_EPOCH)
75 .map_or(0, |d| d.as_secs())
76}
77
78/// The key of the least-recently-used entry to evict, or `None` if the cache is
79/// below `cap`. Pure over `(key, last_used_secs)` recencies so the cap/LRU
80/// bookkeeping is testable without constructing a live [`Pool`].
81fn lru_victim_key<'a>(
82 recencies: impl Iterator<Item = (&'a str, u64)>,
83 len: usize,
84 cap: usize,
85) -> Option<String> {
86 if len < cap {
87 return None;
88 }
89 recencies
90 .min_by_key(|&(_, last_used)| last_used)
91 .map(|(key, _)| key.to_string())
92}
93
94/// The keys of every entry whose last-use is strictly before `cutoff`. Pure over
95/// `(key, last_used_secs)` recencies so the idle-sweep selection is testable
96/// without a live [`Pool`].
97fn stale_keys<'a>(recencies: impl Iterator<Item = (&'a str, u64)>, cutoff: u64) -> Vec<String> {
98 recencies
99 .filter(|&(_, last_used)| last_used < cutoff)
100 .map(|(key, _)| key.to_string())
101 .collect()
102}
103
104/// Which pooling discipline a [`BrainPool`] runs, kept as an internal enum so
105/// the two modes stay cleanly separated rather than tangled behind option
106/// fields. Both are honored by [`BrainPool::client_for`], [`BrainPool::sweep_idle`],
107/// and [`BrainPool::probe`].
108enum Mode {
109 /// Self-host bearer passthrough: one pool per distinct wire credential, held
110 /// in an [`ArcSwap`]-guarded LRU cache. Pools are built lazily on first use.
111 PerCredential(ArcSwap<HashMap<String, Arc<PoolEntry>>>),
112 /// Gateway shared / `act_as`: ONE pool authenticated as a trusted service
113 /// principal (a `can_act_as` key). Every `client_for` returns a client from
114 /// this pool regardless of the `credential` argument. Built lazily on first
115 /// use so the constructor stays synchronous and infallible, exactly like
116 /// [`BrainPool::new`]; the [`OnceCell`] serializes concurrent initializers so
117 /// only one handshake set is ever opened.
118 Shared {
119 service_credential: String,
120 pool: OnceCell<Arc<Pool>>,
121 },
122}
123
124/// A bounded connection-pool cache to Brain — per-credential (self-host) or a
125/// single shared service-principal pool with per-request `act_as` (gateway).
126///
127/// In per-credential mode the cache is keyed by the wire credential string and
128/// held in an [`ArcSwap`] so the hot path (pool already built) is a lock-free
129/// load + map lookup with no per-request hashing beyond the lookup itself. In
130/// shared mode there is one pool for every caller. Pools are created lazily, on
131/// first use.
132pub struct BrainPool {
133 config: BrainPoolConfig,
134 mode: Mode,
135}
136
137impl std::fmt::Debug for BrainPool {
138 /// Reports the mode and the cache ceiling — never a credential, and never
139 /// the pooled connections themselves.
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 f.debug_struct("BrainPool")
142 .field(
143 "mode",
144 &match self.mode {
145 Mode::PerCredential(_) => "per-credential",
146 Mode::Shared { .. } => "shared",
147 },
148 )
149 .field("max_credentials", &self.config.max_credentials)
150 .finish_non_exhaustive()
151 }
152}
153
154impl BrainPool {
155 /// Build an empty per-credential pool cache (the self-host bearer default).
156 /// Pools are created lazily, on first use per credential. `max_credentials`
157 /// is floored at 1 so the cache can always hold at least one entry.
158 #[must_use]
159 pub fn new(config: BrainPoolConfig) -> Self {
160 let config = BrainPoolConfig {
161 max_credentials: config.max_credentials.max(1),
162 ..config
163 };
164 Self {
165 config,
166 mode: Mode::PerCredential(ArcSwap::from_pointee(HashMap::new())),
167 }
168 }
169
170 /// Build a shared / `act_as` pool authenticated as `service_credential` — a
171 /// trusted service principal holding the `can_act_as` grant. Every
172 /// [`BrainPool::client_for`] call returns a client from this ONE pool
173 /// regardless of the `credential` argument, so a single connection pool can
174 /// serve every tenant when the gateway sets `act_as` per request.
175 ///
176 /// The pool is opened lazily on first use, so this constructor is
177 /// synchronous and infallible just like [`BrainPool::new`]; a bad service
178 /// credential or an unreachable Brain surfaces from the first `client_for`.
179 #[must_use]
180 pub fn shared(config: BrainPoolConfig, service_credential: String) -> Self {
181 Self {
182 config,
183 mode: Mode::Shared {
184 service_credential,
185 pool: OnceCell::new(),
186 },
187 }
188 }
189
190 /// Get (or lazily build) a pooled client.
191 ///
192 /// In per-credential mode the client is authenticated as `credential`: hot
193 /// path is a lock-free load + lookup, bumping recency in place with a relaxed
194 /// atomic; cold path builds the [`brain_db_sdk::Pool`] OUTSIDE any lock,
195 /// publishes it via [`ArcSwap::rcu`], and LRU-evicts the least-recently-used
196 /// entry first if the cache is at `max_credentials`.
197 ///
198 /// In shared mode `credential` is **ignored**: the returned client always
199 /// comes from the single service-principal pool. The gateway then sets
200 /// `act_as` per request to select the effective tenant identity.
201 ///
202 /// # Errors
203 /// Returns the underlying [`BrainError`] if a fresh pool fails to connect or
204 /// handshake.
205 pub async fn client_for(&self, credential: &str) -> Result<Arc<BrainClient>, BrainError> {
206 match &self.mode {
207 Mode::PerCredential(pools) => self.client_for_credential(pools, credential).await,
208 Mode::Shared {
209 service_credential,
210 pool,
211 } => {
212 // Ignore `credential`: one shared pool serves every caller. The
213 // OnceCell serializes concurrent initializers, so at most one set
214 // of handshakes is ever opened even under a cold-start burst.
215 let shared = pool
216 .get_or_try_init(|| async {
217 let auth = Auth::Token(service_credential.as_bytes().to_vec());
218 Pool::connect(self.config.brain_addr, self.config.pool_size, auth)
219 .await
220 .map(Arc::new)
221 .map_err(|e| {
222 warn!(error = %e, "brain shared service pool connect failed");
223 e
224 })
225 })
226 .await?;
227 // Reconnects a member Brain closed (e.g. on restart) instead
228 // of handing out the dead socket forever.
229 shared.get_healthy().await
230 }
231 }
232 }
233
234 /// Per-credential `client_for` (the self-host path). Split out so the shared
235 /// arm stays legible; the cache-load/RCU-insert logic is unchanged.
236 async fn client_for_credential(
237 &self,
238 pools: &ArcSwap<HashMap<String, Arc<PoolEntry>>>,
239 credential: &str,
240 ) -> Result<Arc<BrainClient>, BrainError> {
241 // Hot path: lock-free read. A built pool is the overwhelming common case.
242 let hit = pools.load().get(credential).map(Arc::clone);
243 if let Some(entry) = hit {
244 entry.last_used_secs.store(now_secs(), Ordering::Relaxed);
245 return entry.pool.get_healthy().await;
246 }
247
248 // Cold path: build outside any critical section so a slow handshake never
249 // blocks other credentials, and no lock is held across `.await`.
250 let auth = Auth::Token(credential.as_bytes().to_vec());
251 let built = Pool::connect(self.config.brain_addr, self.config.pool_size, auth)
252 .await
253 .map_err(|e| {
254 warn!(error = %e, "brain pool connect failed");
255 e
256 })?;
257 let entry = Arc::new(PoolEntry {
258 pool: Arc::new(built),
259 last_used_secs: AtomicU64::new(now_secs()),
260 });
261
262 // Publish via RCU (copy-on-write + compare-and-swap retry). If a
263 // concurrent task already inserted this key, leave the winner in place;
264 // our freshly built pool is then dropped at end of scope. Evict the LRU
265 // entry first if we're at the cap, so the map never grows past
266 // max_credentials.
267 pools.rcu(|current| {
268 if current.contains_key(credential) {
269 Arc::clone(current)
270 } else {
271 let mut next = HashMap::clone(current);
272 if let Some(lru_key) = lru_victim_key(
273 next.iter()
274 .map(|(k, e)| (k.as_str(), e.last_used_secs.load(Ordering::Relaxed))),
275 next.len(),
276 self.config.max_credentials,
277 ) {
278 next.remove(&lru_key);
279 }
280 next.insert(credential.to_string(), Arc::clone(&entry));
281 Arc::new(next)
282 }
283 });
284
285 // After the RCU the key is guaranteed present (ours or the winner's).
286 let entry = pools
287 .load()
288 .get(credential)
289 .map(Arc::clone)
290 .expect("invariant: pool for credential is present after rcu insert");
291 entry.pool.get_healthy().await
292 }
293
294 /// Drop credentials idle beyond `config.idle_ttl`. Called by a background
295 /// sweeper, never from the request path. One RCU pass; a no-op if nothing has
296 /// gone idle. In shared mode there is a single long-lived pool with no
297 /// per-credential entries to sweep, so this is a no-op.
298 pub fn sweep_idle(&self) {
299 let Mode::PerCredential(pools) = &self.mode else {
300 return;
301 };
302 let cutoff = now_secs().saturating_sub(self.config.idle_ttl.as_secs());
303 let current = pools.load();
304 let evicted = stale_keys(
305 current
306 .iter()
307 .map(|(k, e)| (k.as_str(), e.last_used_secs.load(Ordering::Relaxed))),
308 cutoff,
309 );
310 if evicted.is_empty() {
311 return;
312 }
313 pools.rcu(|current| {
314 let mut next = HashMap::clone(current);
315 for key in &evicted {
316 next.remove(key);
317 }
318 Arc::new(next)
319 });
320 info!(count = evicted.len(), "swept idle brain credential pools");
321 }
322
323 /// Cheap reachability probe used by readiness: can we open a TCP socket to
324 /// the Brain listener within `timeout`? Avoids a full handshake or a
325 /// credential.
326 pub async fn probe(&self, timeout: Duration) -> bool {
327 matches!(
328 tokio::time::timeout(
329 timeout,
330 tokio::net::TcpStream::connect(self.config.brain_addr),
331 )
332 .await,
333 Ok(Ok(_))
334 )
335 }
336
337 /// The number of distinct credentials currently cached (test/introspection).
338 /// Shared mode keeps no per-credential cache, so it always reports 0.
339 #[cfg(test)]
340 fn cached_len(&self) -> usize {
341 match &self.mode {
342 Mode::PerCredential(pools) => pools.load().len(),
343 Mode::Shared { .. } => 0,
344 }
345 }
346
347 /// The service credential a shared-mode pool authenticates as, or `None` in
348 /// per-credential mode (test/introspection).
349 #[cfg(test)]
350 fn shared_service_credential(&self) -> Option<&str> {
351 match &self.mode {
352 Mode::Shared {
353 service_credential, ..
354 } => Some(service_credential),
355 Mode::PerCredential(_) => None,
356 }
357 }
358
359 /// Whether the shared-mode service pool has been lazily built yet
360 /// (test/introspection). Always `None` in per-credential mode.
361 #[cfg(test)]
362 fn shared_pool_initialized(&self) -> Option<bool> {
363 match &self.mode {
364 Mode::Shared { pool, .. } => Some(pool.initialized()),
365 Mode::PerCredential(_) => None,
366 }
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 fn test_config(max_credentials: usize, idle_ttl: Duration) -> BrainPoolConfig {
375 BrainPoolConfig {
376 brain_addr: "127.0.0.1:1".parse().expect("addr"),
377 pool_size: 1,
378 max_credentials,
379 idle_ttl,
380 }
381 }
382
383 #[test]
384 fn max_credentials_is_floored_at_one() {
385 let pool = BrainPool::new(test_config(0, Duration::from_secs(900)));
386 assert_eq!(pool.config.max_credentials, 1);
387 }
388
389 #[test]
390 fn a_fresh_pool_caches_nothing() {
391 let pool = BrainPool::new(test_config(8, Duration::from_secs(900)));
392 assert_eq!(pool.cached_len(), 0);
393 }
394
395 // The cap/LRU and idle-sweep bookkeeping is exercised against the pure
396 // selection helpers, which is exactly the logic `client_for` (cold path) and
397 // `sweep_idle` drive — hermetic, no live Brain, no `Pool` construction.
398
399 #[test]
400 fn lru_victim_is_none_below_the_cap() {
401 let recencies = [("a", 100u64), ("b", 200)];
402 assert_eq!(
403 lru_victim_key(recencies.iter().copied(), recencies.len(), 4),
404 None
405 );
406 }
407
408 #[test]
409 fn lru_victim_picks_the_stalest_at_the_cap() {
410 // Three candidates, cap 2: at/over the cap, evict the least-recently-used.
411 let recencies = [("a", 100u64), ("b", 300), ("c", 200)];
412 assert_eq!(
413 lru_victim_key(recencies.iter().copied(), recencies.len(), 2),
414 Some("a".to_string()),
415 "stalest entry ('a', oldest last-used) is the eviction victim",
416 );
417 }
418
419 #[test]
420 fn stale_keys_selects_only_entries_past_the_cutoff() {
421 // cutoff 250: 100 and 200 are stale; 300 and 400 are fresh.
422 let recencies = [
423 ("old1", 100u64),
424 ("fresh1", 300),
425 ("old2", 200),
426 ("fresh2", 400),
427 ];
428 let mut stale = stale_keys(recencies.iter().copied(), 250);
429 stale.sort();
430 assert_eq!(stale, vec!["old1".to_string(), "old2".to_string()]);
431 }
432
433 #[test]
434 fn stale_keys_is_empty_when_all_are_fresh() {
435 let recencies = [("a", 300u64), ("b", 400)];
436 assert_eq!(
437 stale_keys(recencies.iter().copied(), 250),
438 [] as [String; 0]
439 );
440 }
441
442 #[test]
443 fn sweep_idle_on_an_empty_cache_is_a_noop() {
444 let pool = BrainPool::new(test_config(8, Duration::from_secs(100)));
445 pool.sweep_idle();
446 assert_eq!(pool.cached_len(), 0);
447 }
448
449 #[tokio::test]
450 async fn probe_a_dead_address_is_false() {
451 // Port 1 on loopback is not listening; the probe fails fast.
452 let pool = BrainPool::new(test_config(8, Duration::from_secs(900)));
453 assert!(!pool.probe(Duration::from_millis(200)).await);
454 }
455
456 #[test]
457 fn shared_mode_records_its_service_credential_and_no_percredential_cache() {
458 let pool = BrainPool::shared(
459 test_config(8, Duration::from_secs(900)),
460 "service-key".to_string(),
461 );
462 assert_eq!(pool.shared_service_credential(), Some("service-key"));
463 assert_eq!(
464 pool.cached_len(),
465 0,
466 "shared mode keeps no per-credential cache"
467 );
468 assert_eq!(pool.shared_pool_initialized(), Some(false), "built lazily");
469 }
470
471 #[test]
472 fn new_is_per_credential_not_shared() {
473 let pool = BrainPool::new(test_config(8, Duration::from_secs(900)));
474 assert_eq!(pool.shared_service_credential(), None);
475 assert_eq!(pool.shared_pool_initialized(), None);
476 }
477
478 #[tokio::test]
479 async fn shared_mode_ignores_the_credential_argument() {
480 // Dead address: both calls fail to connect, but the point is that shared
481 // mode routes EVERY credential to the ONE service-principal pool and never
482 // spins up a per-credential entry. Hermetic — no live Brain.
483 let pool = BrainPool::shared(
484 test_config(8, Duration::from_secs(900)),
485 "service-key".to_string(),
486 );
487 let a = pool.client_for("tenant-a").await;
488 let b = pool.client_for("tenant-b").await;
489 assert!(a.is_err(), "dead addr: connect fails");
490 assert!(b.is_err(), "dead addr: connect fails");
491 // Regardless of the (differing) credential args, no per-credential cache
492 // ever forms — both were served by the single shared pool.
493 assert_eq!(pool.cached_len(), 0);
494 assert_eq!(pool.shared_service_credential(), Some("service-key"));
495 }
496
497 #[test]
498 fn sweep_idle_in_shared_mode_is_a_noop() {
499 let pool = BrainPool::shared(
500 test_config(8, Duration::from_secs(1)),
501 "service-key".to_string(),
502 );
503 pool.sweep_idle();
504 assert_eq!(pool.cached_len(), 0);
505 }
506}