Skip to main content

cratefield_adapter_postgres/
registry.rs

1//! [`PoolRegistry`]: one connection pool per tenant, opened on first use
2//! and closed when idle (TENANT-ROUTING.md §4, issue #32).
3//!
4//! **Lazy, not eager.** Boot reconciliation already touches every tenant
5//! database; holding those connections afterwards would make a replica's
6//! idle cost scale with the tenant count rather than with its traffic.
7//! The registry is a cache, not an inventory.
8//!
9//! **Two caps, because one is not enough.** `sqlx` caps *per pool*, which
10//! is exactly the per-tenant boundary — a pool belongs to one tenant, and
11//! that cap is what stops one busy tenant taking every connection on a
12//! shared cluster. It cannot express a total across pools, which is the
13//! failure the per-tenant cap does not prevent: the process taking every
14//! connection on the *server*. So the total is a semaphore this registry
15//! holds, acquired around every `execute`, `query` and `batch` — and for
16//! a `batch` the permit spans the whole batch, because `batch` holds its
17//! connection across `pool.begin()`.
18//!
19//! Without that semaphore `TENANT_POOL_TOTAL` would be a config key that
20//! does nothing, which is worse than not having it: it reads as
21//! protection.
22
23use std::collections::HashMap;
24use std::sync::Arc;
25use std::time::Duration;
26
27use async_trait::async_trait;
28use cratefield_core::{
29    Clock, Database, DbError, Rows, Statement, Tenant, TenantDatabases, TenantDbError,
30};
31
32use crate::{PoolLimits, Postgres};
33
34/// Where a tenant's connection string comes from.
35///
36/// A trait rather than a hard-wired lookup, because the answer is not
37/// settled: the control database's registry carries a `dsn` column, and
38/// the secrets design (#39) puts it at `tenants/<id>/db_ref` behind a
39/// `HarnessOnly` proof. Both are defensible and the choice is a
40/// deployment's, so the registry takes whichever and never decides.
41///
42/// Whatever supplies it, the DSN stops here: [`PoolRegistry`] is the only
43/// thing that holds one, and it maps a connect failure to
44/// [`TenantDbError::Unreachable`] before the URL can reach a log line or
45/// a response body.
46#[async_trait]
47pub trait TenantDsns: Send + Sync {
48    /// The connection string for `tenant`, or `None` if it has none.
49    ///
50    /// # Errors
51    ///
52    /// Any lookup failure, as a message that must not contain the DSN.
53    async fn dsn(&self, tenant: &str) -> Result<Option<String>, String>;
54}
55
56/// The default total across every tenant pool in this process.
57///
58/// A guess pending a measurement against a real cluster's
59/// `max_connections` and the replica count — recorded as one in
60/// TENANT-ROUTING.md §12 rather than presented as a finding. Note that
61/// boot reconciliation runs 8 tenants concurrently and the control
62/// database has its own pool; both draw on the same server limit and
63/// neither is inside this cap.
64pub const TENANT_POOL_TOTAL: usize = 64;
65
66/// How long a pool with no checkout survives before it is closed.
67pub const TENANT_POOL_IDLE: Duration = Duration::from_secs(300);
68
69struct Cached {
70    db: Arc<CappedDb>,
71    /// Unix seconds of the last handout. Coarse on purpose: this decides
72    /// eviction, not correctness.
73    last_used: i64,
74}
75
76/// Pools per tenant, with both caps applied.
77pub struct PoolRegistry {
78    dsns: Arc<dyn TenantDsns>,
79    limits: PoolLimits,
80    idle: Duration,
81    clock: Arc<dyn Clock>,
82    total: Arc<tokio::sync::Semaphore>,
83    // Not request state (ADR 0007): deployment-scoped, outlives every
84    // request, and shared by all of them.
85    #[allow(clippy::disallowed_types)]
86    pools: std::sync::Mutex<HashMap<String, Cached>>,
87}
88
89impl PoolRegistry {
90    /// A registry with the default caps.
91    #[must_use]
92    pub fn new(dsns: Arc<dyn TenantDsns>, clock: Arc<dyn Clock>) -> Self {
93        Self::with_limits(
94            dsns,
95            clock,
96            PoolLimits::default(),
97            TENANT_POOL_TOTAL,
98            TENANT_POOL_IDLE,
99        )
100    }
101
102    /// A registry with explicit caps.
103    // The pool map is deployment-scoped shared state that outlives every
104    // request, not the ambient request state ADR 0007 bans — the same
105    // category as the sidecar probe cache in core's harness.
106    #[allow(clippy::disallowed_types)]
107    #[must_use]
108    pub fn with_limits(
109        dsns: Arc<dyn TenantDsns>,
110        clock: Arc<dyn Clock>,
111        limits: PoolLimits,
112        total: usize,
113        idle: Duration,
114    ) -> Self {
115        Self {
116            dsns,
117            limits,
118            idle,
119            clock,
120            total: Arc::new(tokio::sync::Semaphore::new(total.max(1))),
121            pools: std::sync::Mutex::new(HashMap::new()),
122        }
123    }
124
125    /// How many pools are open right now. For tests and `/__health`;
126    /// never a correctness signal — TENANT-ROUTING.md §12 makes the
127    /// *registry row* authoritative, precisely so that pool presence
128    /// cannot be mistaken for tenant health.
129    ///
130    /// # Panics
131    ///
132    /// If the pool map's lock was poisoned by a panic in another thread.
133    /// Poisoning here means a previous handout panicked mid-map, and
134    /// continuing on a map in an unknown state is worse than stopping.
135    #[must_use]
136    pub fn open_pools(&self) -> usize {
137        self.pools.lock().expect("pool lock").len()
138    }
139
140    /// Closes every pool whose last handout is older than the idle
141    /// window. Called on each handout, so a busy deployment sweeps itself
142    /// and an idle one costs nothing.
143    fn evict_idle(&self, now: i64) -> Vec<Arc<CappedDb>> {
144        let cutoff = now.saturating_sub(i64::try_from(self.idle.as_secs()).unwrap_or(i64::MAX));
145        let mut pools = self.pools.lock().expect("pool lock");
146        let stale: Vec<String> = pools
147            .iter()
148            .filter(|(_, cached)| cached.last_used < cutoff)
149            .map(|(tenant, _)| tenant.clone())
150            .collect();
151        // Returned rather than closed under the lock: `close` awaits
152        // in-flight queries, and holding a std Mutex across an await is
153        // the deadlock this avoids by construction.
154        stale
155            .into_iter()
156            .filter_map(|tenant| pools.remove(&tenant).map(|cached| cached.db))
157            .collect()
158    }
159
160    fn now_secs(&self) -> i64 {
161        self.clock.now().unix_timestamp()
162    }
163}
164
165/// Why a keyed pool could not be produced. Internal: every variant is a
166/// [`TenantDbError::Unreachable`] to a caller, but which one it was is
167/// worth having in a log line.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub(crate) enum PoolError {
170    /// The source has no connection string under that key.
171    NoDsn,
172    /// The source itself failed.
173    Lookup,
174    /// The server refused or did not answer.
175    Connect,
176}
177
178impl PoolRegistry {
179    /// The pool for `key`, opened on first use and reused after.
180    ///
181    /// **Keyed, not tenanted.** Lazy open, idle eviction and both caps are
182    /// pool bookkeeping and have nothing to do with tenancy, so they live
183    /// here and are tested by string. What makes a key a *tenant* is the
184    /// [`TenantDatabases`] impl below, which is short enough to be correct
185    /// by inspection — the split is deliberate, so that the part with the
186    /// interesting behaviour does not need a `Tenant` to test and core's
187    /// private constructor can stay private.
188    pub(crate) async fn pool_for(&self, key: &str) -> Result<Arc<CappedDb>, PoolError> {
189        let now = self.now_secs();
190
191        for stale in self.evict_idle(now) {
192            let _ = stale.inner.close().await;
193        }
194
195        if let Some(cached) = self.pools.lock().expect("pool lock").get_mut(key) {
196            cached.last_used = now;
197            return Ok(Arc::clone(&cached.db));
198        }
199
200        // The DSN exists only inside this block, and the error it could be
201        // folded into is discarded rather than formatted.
202        let dsn = self
203            .dsns
204            .dsn(key)
205            .await
206            .map_err(|_| PoolError::Lookup)?
207            .ok_or(PoolError::NoDsn)?;
208        let pool = Postgres::connect_with(&dsn, self.limits)
209            .await
210            .map_err(|_| PoolError::Connect)?;
211        drop(dsn);
212
213        let db = Arc::new(CappedDb {
214            inner: pool,
215            total: Arc::clone(&self.total),
216            acquire_timeout: self.limits.acquire_timeout,
217            clock: Arc::clone(&self.clock),
218            key: key.to_owned(),
219        });
220        self.pools.lock().expect("pool lock").insert(
221            key.to_owned(),
222            Cached {
223                db: Arc::clone(&db),
224                last_used: now,
225            },
226        );
227        Ok(db)
228    }
229}
230
231#[async_trait]
232impl TenantDatabases for PoolRegistry {
233    /// The tenant half, and all of it: a tenant's key is its id.
234    ///
235    /// Every way the lookup can miss is one answer to the caller — the
236    /// tenant's database is unreachable — because the distinction between
237    /// "no DSN
238    /// recorded" and "the server refused" is an operator's question, not
239    /// the caller's, and answering it in a response body would describe
240    /// the deployment to a stranger.
241    async fn database(&self, tenant: &Tenant) -> Result<Arc<dyn Database>, TenantDbError> {
242        match self.pool_for(tenant.id().as_str()).await {
243            Ok(db) => Ok(db as Arc<dyn Database>),
244            Err(why) => {
245                tracing::warn!(tenant = %tenant.id(), reason = ?why, "no pool for tenant");
246                Err(TenantDbError::Unreachable {
247                    tenant: tenant.id().to_string(),
248                })
249            }
250        }
251    }
252}
253
254/// A tenant's pool with the process-wide total applied around every
255/// operation.
256pub(crate) struct CappedDb {
257    inner: Postgres,
258    total: Arc<tokio::sync::Semaphore>,
259    acquire_timeout: Duration,
260    clock: Arc<dyn Clock>,
261    key: String,
262}
263
264impl CappedDb {
265    /// A permit from the process-wide total, or a refusal on the deadline.
266    ///
267    /// Refuses rather than queues: an unbounded queue turns a connection
268    /// shortage into an unbounded latency, which is the failure #136's
269    /// outbound budget refuses for the same reason.
270    async fn permit(&self) -> Result<tokio::sync::OwnedSemaphorePermit, DbError> {
271        let total = Arc::clone(&self.total);
272        let acquire = async move { total.acquire_owned().await };
273        match cratefield_core::timeout(&*self.clock, acquire, self.acquire_timeout).await {
274            Some(Ok(permit)) => Ok(permit),
275            // The semaphore is never closed, so `Err` is unreachable; it
276            // is mapped rather than unwrapped because an unreachable
277            // panic in a database path is not worth the line it saves.
278            Some(Err(_)) | None => Err(DbError::Execute(format!(
279                "`{}` waited out the connection budget",
280                self.key
281            ))),
282        }
283    }
284}
285
286#[async_trait]
287impl Database for CappedDb {
288    async fn execute(&self, stmt: &Statement) -> Result<u64, DbError> {
289        let _permit = self.permit().await?;
290        self.inner.execute(stmt).await
291    }
292
293    async fn query(&self, stmt: &Statement) -> Result<Rows, DbError> {
294        let _permit = self.permit().await?;
295        self.inner.query(stmt).await
296    }
297
298    async fn batch_atomic(&self, stmts: &[Statement]) -> Result<(), DbError> {
299        // One permit for the whole batch: `batch` holds its connection
300        // across `pool.begin()`, so a per-statement permit would let the
301        // total be exceeded by exactly the number of open transactions.
302        let _permit = self.permit().await?;
303        self.inner.batch_atomic(stmts).await
304    }
305}
306
307#[cfg(test)]
308#[allow(clippy::disallowed_types)] // recording fakes, not request state
309mod tests {
310    use super::*;
311    use crate::testing::{TempDb, base_url, skip_reason};
312    use cratefield_core::Statement;
313
314    /// A clock the test moves, so idle eviction is deterministic rather
315    /// than a sleep.
316    struct StepClock {
317        offset: std::sync::atomic::AtomicI64,
318    }
319
320    impl StepClock {
321        fn new() -> Arc<Self> {
322            Arc::new(Self {
323                offset: std::sync::atomic::AtomicI64::new(0),
324            })
325        }
326
327        fn advance(&self, secs: i64) {
328            self.offset
329                .fetch_add(secs, std::sync::atomic::Ordering::SeqCst);
330        }
331    }
332
333    #[async_trait]
334    impl Clock for StepClock {
335        fn now(&self) -> time::OffsetDateTime {
336            time::OffsetDateTime::now_utc()
337                + time::Duration::seconds(self.offset.load(std::sync::atomic::Ordering::SeqCst))
338        }
339
340        async fn timeout_any(
341            &self,
342            fut: cratefield_core::BoxFuture<'static, Box<dyn std::any::Any + Send>>,
343            after: Duration,
344        ) -> Option<Box<dyn std::any::Any + Send>> {
345            tokio::time::timeout(after, fut).await.ok()
346        }
347    }
348
349    /// Hands out one DSN for the keys it knows, and records every lookup
350    /// so a test can prove a pool opened once rather than per request.
351    struct Dsns {
352        url: String,
353        known: Vec<String>,
354        lookups: std::sync::Mutex<Vec<String>>,
355    }
356
357    impl Dsns {
358        fn new(url: &str, known: &[&str]) -> Arc<Self> {
359            Arc::new(Self {
360                url: url.to_owned(),
361                known: known.iter().map(|k| (*k).to_owned()).collect(),
362                lookups: std::sync::Mutex::new(Vec::new()),
363            })
364        }
365
366        fn lookups(&self) -> Vec<String> {
367            self.lookups.lock().expect("lookup lock").clone()
368        }
369    }
370
371    #[async_trait]
372    impl TenantDsns for Dsns {
373        async fn dsn(&self, key: &str) -> Result<Option<String>, String> {
374            self.lookups
375                .lock()
376                .expect("lookup lock")
377                .push(key.to_owned());
378            Ok(self
379                .known
380                .iter()
381                .any(|known| known == key)
382                .then(|| self.url.clone()))
383        }
384    }
385
386    #[tokio::test]
387    async fn a_pool_opens_on_first_use_and_is_reused() {
388        let Some(url) = base_url() else {
389            eprintln!("SKIPPED: {}", skip_reason());
390            return;
391        };
392        let Some(temp) = TempDb::create(&url, "reglazy").await else {
393            eprintln!("SKIPPED: {}", skip_reason());
394            return;
395        };
396        let dsns = Dsns::new(&temp.url, &["a"]);
397        let registry =
398            PoolRegistry::new(Arc::clone(&dsns) as Arc<dyn TenantDsns>, StepClock::new());
399
400        assert_eq!(registry.open_pools(), 0, "nothing opens at construction");
401        for _ in 0..3 {
402            let db = registry.pool_for("a").await.expect("opens");
403            db.query(&Statement::new("SELECT 1"))
404                .await
405                .expect("queries");
406        }
407
408        assert_eq!(registry.open_pools(), 1);
409        assert_eq!(
410            dsns.lookups(),
411            vec!["a".to_owned()],
412            "the DSN is read once, not per request"
413        );
414        temp.finish().await;
415    }
416
417    #[tokio::test]
418    async fn an_idle_pool_is_closed_and_reopens_on_the_next_request() {
419        let Some(url) = base_url() else {
420            eprintln!("SKIPPED: {}", skip_reason());
421            return;
422        };
423        let Some(temp) = TempDb::create(&url, "regidle").await else {
424            eprintln!("SKIPPED: {}", skip_reason());
425            return;
426        };
427        let dsns = Dsns::new(&temp.url, &["a"]);
428        let clock = StepClock::new();
429        let registry = PoolRegistry::with_limits(
430            Arc::clone(&dsns) as Arc<dyn TenantDsns>,
431            Arc::clone(&clock) as Arc<dyn Clock>,
432            PoolLimits::default(),
433            8,
434            Duration::from_secs(60),
435        );
436
437        registry.pool_for("a").await.expect("opens");
438        assert_eq!(registry.open_pools(), 1);
439
440        clock.advance(120);
441        registry.pool_for("a").await.expect("reopens");
442        assert_eq!(registry.open_pools(), 1, "reopened, not accumulated");
443        assert_eq!(
444            dsns.lookups().len(),
445            2,
446            "a reopen reads the DSN again, which is what makes eviction real"
447        );
448        temp.finish().await;
449    }
450
451    #[tokio::test]
452    async fn one_key_cannot_starve_the_others_of_the_total() {
453        // #32's starvation criterion, in the terms it actually lives in:
454        // the total is one, held by A, so B must refuse on its deadline
455        // rather than queue behind A.
456        let Some(url) = base_url() else {
457            eprintln!("SKIPPED: {}", skip_reason());
458            return;
459        };
460        let Some(temp) = TempDb::create(&url, "regstarve").await else {
461            eprintln!("SKIPPED: {}", skip_reason());
462            return;
463        };
464        let dsns = Dsns::new(&temp.url, &["a", "b"]);
465        let registry = Arc::new(PoolRegistry::with_limits(
466            Arc::clone(&dsns) as Arc<dyn TenantDsns>,
467            StepClock::new(),
468            PoolLimits {
469                max_connections: 4,
470                idle_timeout: None,
471                acquire_timeout: Duration::from_millis(400),
472            },
473            1,
474            Duration::from_secs(300),
475        ));
476
477        let a = registry.pool_for("a").await.expect("a opens");
478        let b = registry.pool_for("b").await.expect("b opens");
479
480        let hold = tokio::spawn(async move {
481            let _ = a.query(&Statement::new("SELECT pg_sleep(5)")).await;
482        });
483
484        // Wait until the permit is *observably* taken rather than sleeping
485        // a guessed interval and hoping.
486        //
487        // The first version slept 200ms and assumed A had won the single
488        // permit by then. Under load it had not, B took the permit first,
489        // and the refusal this test exists to see never happened — it
490        // failed on a machine running two builds at once, which is exactly
491        // when a timing assumption is worth least.
492        let contended = async {
493            loop {
494                if b.query(&Statement::new("SELECT 1")).await.is_err() {
495                    return;
496                }
497                tokio::time::sleep(Duration::from_millis(20)).await;
498            }
499        };
500        tokio::time::timeout(Duration::from_secs(4), contended)
501            .await
502            .expect("A holds the only permit within four seconds");
503
504        // Now that contention is established, the deadline is the claim:
505        // B refuses on its own 400ms rather than waiting out A's five
506        // seconds.
507        let started = std::time::Instant::now();
508        let refused = b.query(&Statement::new("SELECT 1")).await;
509        let waited = started.elapsed();
510
511        assert!(
512            refused.is_err(),
513            "B must be refused rather than queued behind A: {refused:?}"
514        );
515        assert!(
516            waited < Duration::from_secs(2),
517            "and refused on its own deadline, not after A finished: {waited:?}"
518        );
519        hold.abort();
520        let _ = hold.await;
521
522        // A refusal is backpressure, not a broken pool.
523        b.query(&Statement::new("SELECT 1"))
524            .await
525            .expect("B recovers once the budget frees");
526        temp.finish().await;
527    }
528
529    #[tokio::test]
530    async fn an_unknown_key_opens_nothing_and_says_which_kind_of_miss() {
531        let Some(url) = base_url() else {
532            eprintln!("SKIPPED: {}", skip_reason());
533            return;
534        };
535        let Some(temp) = TempDb::create(&url, "regmissing").await else {
536            eprintln!("SKIPPED: {}", skip_reason());
537            return;
538        };
539        let dsns = Dsns::new(&temp.url, &["a"]);
540        let registry =
541            PoolRegistry::new(Arc::clone(&dsns) as Arc<dyn TenantDsns>, StepClock::new());
542
543        assert_eq!(
544            registry.pool_for("nobody").await.err(),
545            Some(PoolError::NoDsn)
546        );
547        assert_eq!(registry.open_pools(), 0, "and opened nothing");
548        temp.finish().await;
549    }
550}