Skip to main content

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}