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/// How a pool is opened. Always [`Postgres::connect_with`] in production;
77/// a field rather than a bare call so a test can count opens and race
78/// them without a server behind them — the race this exists for is
79/// between two opens, so it is only observable if a test can perform
80/// one.
81type Open = Arc<
82 dyn Fn(String, PoolLimits) -> cratefield_core::BoxFuture<'static, Result<Postgres, DbError>>
83 + Send
84 + Sync,
85>;
86
87/// Pools per tenant, with both caps applied.
88pub struct PoolRegistry {
89 dsns: Arc<dyn TenantDsns>,
90 limits: PoolLimits,
91 idle: Duration,
92 clock: Arc<dyn Clock>,
93 total: Arc<tokio::sync::Semaphore>,
94 /// Always [`Postgres::connect_with`] outside tests.
95 open: Open,
96 // Not request state (ADR 0007): deployment-scoped, outlives every
97 // request, and shared by all of them.
98 #[allow(clippy::disallowed_types)]
99 pools: std::sync::Mutex<HashMap<String, Cached>>,
100 /// One async lock per key, held only while that key's pool is being
101 /// opened — the single-flight that stops two cold callers opening
102 /// one pool each and the second `insert` orphaning the first. A
103 /// lock is per key, not one for the registry: opening tenant A must
104 /// not hold up tenant B.
105 #[allow(clippy::disallowed_types)]
106 inflight: std::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
107}
108
109impl PoolRegistry {
110 /// A registry with the default caps.
111 #[must_use]
112 pub fn new(dsns: Arc<dyn TenantDsns>, clock: Arc<dyn Clock>) -> Self {
113 Self::with_limits(
114 dsns,
115 clock,
116 PoolLimits::default(),
117 TENANT_POOL_TOTAL,
118 TENANT_POOL_IDLE,
119 )
120 }
121
122 /// A registry with explicit caps.
123 // The pool map is deployment-scoped shared state that outlives every
124 // request, not the ambient request state ADR 0007 bans — the same
125 // category as the sidecar probe cache in core's harness.
126 #[allow(clippy::disallowed_types)]
127 #[must_use]
128 pub fn with_limits(
129 dsns: Arc<dyn TenantDsns>,
130 clock: Arc<dyn Clock>,
131 limits: PoolLimits,
132 total: usize,
133 idle: Duration,
134 ) -> Self {
135 Self {
136 dsns,
137 limits,
138 idle,
139 clock,
140 total: Arc::new(tokio::sync::Semaphore::new(total.max(1))),
141 open: Arc::new(|dsn: String, limits: PoolLimits| {
142 Box::pin(async move { Postgres::connect_with(&dsn, limits).await })
143 }),
144 pools: std::sync::Mutex::new(HashMap::new()),
145 inflight: std::sync::Mutex::new(HashMap::new()),
146 }
147 }
148
149 /// A registry whose pools come from `open` instead of a real server.
150 /// Test-only: the single-flight below is only observable if a test
151 /// can count opens and race them, which needs a connect the test
152 /// controls.
153 #[cfg(test)]
154 fn with_open(mut self, open: Open) -> Self {
155 self.open = open;
156 self
157 }
158
159 /// How many pools are open right now. For tests and `/__health`;
160 /// never a correctness signal — TENANT-ROUTING.md §12 makes the
161 /// *registry row* authoritative, precisely so that pool presence
162 /// cannot be mistaken for tenant health.
163 ///
164 /// # Panics
165 ///
166 /// If the pool map's lock was poisoned by a panic in another thread.
167 /// Poisoning here means a previous handout panicked mid-map, and
168 /// continuing on a map in an unknown state is worse than stopping.
169 #[must_use]
170 pub fn open_pools(&self) -> usize {
171 self.pools.lock().expect("pool lock").len()
172 }
173
174 /// Closes every pool whose last handout is older than the idle
175 /// window. Called on each handout, so a busy deployment sweeps itself
176 /// and an idle one costs nothing.
177 fn evict_idle(&self, now: i64) -> Vec<Arc<CappedDb>> {
178 let cutoff = now.saturating_sub(i64::try_from(self.idle.as_secs()).unwrap_or(i64::MAX));
179 let mut pools = self.pools.lock().expect("pool lock");
180 let stale: Vec<String> = pools
181 .iter()
182 .filter(|(_, cached)| cached.last_used < cutoff)
183 .map(|(tenant, _)| tenant.clone())
184 .collect();
185 // Returned rather than closed under the lock: `close` awaits
186 // in-flight queries, and holding a std Mutex across an await is
187 // the deadlock this avoids by construction.
188 stale
189 .into_iter()
190 .filter_map(|tenant| pools.remove(&tenant).map(|cached| cached.db))
191 .collect()
192 }
193
194 fn now_secs(&self) -> i64 {
195 self.clock.now().unix_timestamp()
196 }
197}
198
199/// Why a keyed pool could not be produced. Internal: every variant is a
200/// [`TenantDbError::Unreachable`] to a caller, but which one it was is
201/// worth having in a log line.
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub(crate) enum PoolError {
204 /// The source has no connection string under that key.
205 NoDsn,
206 /// The source itself failed.
207 Lookup,
208 /// The server refused or did not answer.
209 Connect,
210}
211
212impl PoolRegistry {
213 /// The pool for `key`, opened on first use and reused after.
214 ///
215 /// **Keyed, not tenanted.** Lazy open, idle eviction and both caps are
216 /// pool bookkeeping and have nothing to do with tenancy, so they live
217 /// here and are tested by string. What makes a key a *tenant* is the
218 /// [`TenantDatabases`] impl below, which is short enough to be correct
219 /// by inspection — the split is deliberate, so that the part with the
220 /// interesting behaviour does not need a `Tenant` to test and core's
221 /// private constructor can stay private.
222 pub(crate) async fn pool_for(&self, key: &str) -> Result<Arc<CappedDb>, PoolError> {
223 let now = self.now_secs();
224
225 for stale in self.evict_idle(now) {
226 let _ = stale.inner.close().await;
227 }
228
229 if let Some(cached) = self.pools.lock().expect("pool lock").get_mut(key) {
230 cached.last_used = now;
231 return Ok(Arc::clone(&cached.db));
232 }
233
234 // Cold. One opener per key, so the check above and the insert
235 // below are not a check-then-act across two awaits: without
236 // this, two callers that both missed open two pools, and the
237 // second `insert` replaced the first — closing it out from
238 // under whoever still held it, and putting one tenant on two
239 // pools for as long as the orphaned one stayed alive.
240 let flight = self.flight(key);
241 let _opening = flight.lock().await;
242
243 // Re-check under the flight: whoever held it may have opened
244 // this key while we waited, and opening a second pool for a key
245 // that now has one is the race this lock exists to stop.
246 if let Some(cached) = self.pools.lock().expect("pool lock").get_mut(key) {
247 cached.last_used = now;
248 return Ok(Arc::clone(&cached.db));
249 }
250
251 // The DSN exists only inside this block, and the error it could be
252 // folded into is discarded rather than formatted.
253 let dsn = self
254 .dsns
255 .dsn(key)
256 .await
257 .map_err(|_| PoolError::Lookup)?
258 .ok_or(PoolError::NoDsn)?;
259 let pool = (self.open)(dsn.clone(), self.limits)
260 .await
261 .map_err(|_| PoolError::Connect)?;
262 drop(dsn);
263
264 let db = Arc::new(CappedDb {
265 inner: pool,
266 total: Arc::clone(&self.total),
267 acquire_timeout: self.limits.acquire_timeout,
268 clock: Arc::clone(&self.clock),
269 key: key.to_owned(),
270 });
271 self.pools.lock().expect("pool lock").insert(
272 key.to_owned(),
273 Cached {
274 db: Arc::clone(&db),
275 last_used: now,
276 },
277 );
278 Ok(db)
279 }
280
281 /// The lock that serialises opens of `key`.
282 ///
283 /// Entries are swept on every open, keeping only a key somebody is
284 /// still opening: a registry outlives its tenants and must not
285 /// accumulate a lock for every tenant it ever served. Sweeping on
286 /// `Arc::strong_count` is what makes that safe — the map holds one
287 /// reference, so a count of one means nobody is opening it or
288 /// waiting to, while a caller still holding a clone keeps its lock
289 /// alive and keeps waiting on it.
290 fn flight(&self, key: &str) -> Arc<tokio::sync::Mutex<()>> {
291 let mut inflight = self.inflight.lock().expect("pool lock");
292 inflight.retain(|_, flight| Arc::strong_count(flight) > 1);
293 Arc::clone(
294 inflight
295 .entry(key.to_owned())
296 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
297 )
298 }
299}
300
301#[async_trait]
302impl TenantDatabases for PoolRegistry {
303 /// The tenant half, and all of it: a tenant's key is its id.
304 ///
305 /// Every way the lookup can miss is one answer to the caller — the
306 /// tenant's database is unreachable — because the distinction between
307 /// "no DSN
308 /// recorded" and "the server refused" is an operator's question, not
309 /// the caller's, and answering it in a response body would describe
310 /// the deployment to a stranger.
311 async fn database(&self, tenant: &Tenant) -> Result<Arc<dyn Database>, TenantDbError> {
312 match self.pool_for(tenant.id().as_str()).await {
313 Ok(db) => Ok(db as Arc<dyn Database>),
314 Err(why) => {
315 tracing::warn!(tenant = %tenant.id(), reason = ?why, "no pool for tenant");
316 Err(TenantDbError::Unreachable {
317 tenant: tenant.id().to_string(),
318 })
319 }
320 }
321 }
322}
323
324/// A tenant's pool with the process-wide total applied around every
325/// operation.
326pub(crate) struct CappedDb {
327 inner: Postgres,
328 total: Arc<tokio::sync::Semaphore>,
329 acquire_timeout: Duration,
330 clock: Arc<dyn Clock>,
331 key: String,
332}
333
334impl CappedDb {
335 /// A permit from the process-wide total, or a refusal on the deadline.
336 ///
337 /// Refuses rather than queues: an unbounded queue turns a connection
338 /// shortage into an unbounded latency, which is the failure #136's
339 /// outbound budget refuses for the same reason.
340 async fn permit(&self) -> Result<tokio::sync::OwnedSemaphorePermit, DbError> {
341 let total = Arc::clone(&self.total);
342 let acquire = async move { total.acquire_owned().await };
343 match cratefield_core::timeout(&*self.clock, acquire, self.acquire_timeout).await {
344 Some(Ok(permit)) => Ok(permit),
345 // The semaphore is never closed, so `Err` is unreachable; it
346 // is mapped rather than unwrapped because an unreachable
347 // panic in a database path is not worth the line it saves.
348 Some(Err(_)) | None => Err(DbError::Execute(format!(
349 "`{}` waited out the connection budget",
350 self.key
351 ))),
352 }
353 }
354}
355
356#[async_trait]
357impl Database for CappedDb {
358 async fn execute(&self, stmt: &Statement) -> Result<u64, DbError> {
359 let _permit = self.permit().await?;
360 self.inner.execute(stmt).await
361 }
362
363 async fn query(&self, stmt: &Statement) -> Result<Rows, DbError> {
364 let _permit = self.permit().await?;
365 self.inner.query(stmt).await
366 }
367
368 async fn batch_atomic(&self, stmts: &[Statement]) -> Result<(), DbError> {
369 // One permit for the whole batch: `batch` holds its connection
370 // across `pool.begin()`, so a per-statement permit would let the
371 // total be exceeded by exactly the number of open transactions.
372 let _permit = self.permit().await?;
373 self.inner.batch_atomic(stmts).await
374 }
375}
376
377#[cfg(test)]
378#[allow(clippy::disallowed_types)] // recording fakes, not request state
379mod tests {
380 use super::*;
381 use crate::testing::{TempDb, base_url, skip_reason};
382 use cratefield_core::Statement;
383
384 /// A clock the test moves, so idle eviction is deterministic rather
385 /// than a sleep.
386 struct StepClock {
387 offset: std::sync::atomic::AtomicI64,
388 }
389
390 impl StepClock {
391 fn new() -> Arc<Self> {
392 Arc::new(Self {
393 offset: std::sync::atomic::AtomicI64::new(0),
394 })
395 }
396
397 fn advance(&self, secs: i64) {
398 self.offset
399 .fetch_add(secs, std::sync::atomic::Ordering::SeqCst);
400 }
401 }
402
403 #[async_trait]
404 impl Clock for StepClock {
405 fn now(&self) -> time::OffsetDateTime {
406 time::OffsetDateTime::now_utc()
407 + time::Duration::seconds(self.offset.load(std::sync::atomic::Ordering::SeqCst))
408 }
409
410 async fn timeout_any(
411 &self,
412 fut: cratefield_core::BoxFuture<'static, Box<dyn std::any::Any + Send>>,
413 after: Duration,
414 ) -> Option<Box<dyn std::any::Any + Send>> {
415 tokio::time::timeout(after, fut).await.ok()
416 }
417 }
418
419 /// Hands out one DSN for the keys it knows, and records every lookup
420 /// so a test can prove a pool opened once rather than per request.
421 struct Dsns {
422 url: String,
423 known: Vec<String>,
424 lookups: std::sync::Mutex<Vec<String>>,
425 }
426
427 impl Dsns {
428 fn new(url: &str, known: &[&str]) -> Arc<Self> {
429 Arc::new(Self {
430 url: url.to_owned(),
431 known: known.iter().map(|k| (*k).to_owned()).collect(),
432 lookups: std::sync::Mutex::new(Vec::new()),
433 })
434 }
435
436 fn lookups(&self) -> Vec<String> {
437 self.lookups.lock().expect("lookup lock").clone()
438 }
439 }
440
441 #[async_trait]
442 impl TenantDsns for Dsns {
443 async fn dsn(&self, key: &str) -> Result<Option<String>, String> {
444 self.lookups
445 .lock()
446 .expect("lookup lock")
447 .push(key.to_owned());
448 Ok(self
449 .known
450 .iter()
451 .any(|known| known == key)
452 .then(|| self.url.clone()))
453 }
454 }
455
456 #[tokio::test]
457 async fn a_pool_opens_on_first_use_and_is_reused() {
458 let Some(url) = base_url() else {
459 eprintln!("SKIPPED: {}", skip_reason());
460 return;
461 };
462 let Some(temp) = TempDb::create(&url, "reglazy").await else {
463 eprintln!("SKIPPED: {}", skip_reason());
464 return;
465 };
466 let dsns = Dsns::new(&temp.url, &["a"]);
467 let registry =
468 PoolRegistry::new(Arc::clone(&dsns) as Arc<dyn TenantDsns>, StepClock::new());
469
470 assert_eq!(registry.open_pools(), 0, "nothing opens at construction");
471 for _ in 0..3 {
472 let db = registry.pool_for("a").await.expect("opens");
473 db.query(&Statement::new("SELECT 1"))
474 .await
475 .expect("queries");
476 }
477
478 assert_eq!(registry.open_pools(), 1);
479 assert_eq!(
480 dsns.lookups(),
481 vec!["a".to_owned()],
482 "the DSN is read once, not per request"
483 );
484 temp.finish().await;
485 }
486
487 #[tokio::test]
488 async fn an_idle_pool_is_closed_and_reopens_on_the_next_request() {
489 let Some(url) = base_url() else {
490 eprintln!("SKIPPED: {}", skip_reason());
491 return;
492 };
493 let Some(temp) = TempDb::create(&url, "regidle").await else {
494 eprintln!("SKIPPED: {}", skip_reason());
495 return;
496 };
497 let dsns = Dsns::new(&temp.url, &["a"]);
498 let clock = StepClock::new();
499 let registry = PoolRegistry::with_limits(
500 Arc::clone(&dsns) as Arc<dyn TenantDsns>,
501 Arc::clone(&clock) as Arc<dyn Clock>,
502 PoolLimits::default(),
503 8,
504 Duration::from_secs(60),
505 );
506
507 registry.pool_for("a").await.expect("opens");
508 assert_eq!(registry.open_pools(), 1);
509
510 clock.advance(120);
511 registry.pool_for("a").await.expect("reopens");
512 assert_eq!(registry.open_pools(), 1, "reopened, not accumulated");
513 assert_eq!(
514 dsns.lookups().len(),
515 2,
516 "a reopen reads the DSN again, which is what makes eviction real"
517 );
518 temp.finish().await;
519 }
520
521 #[tokio::test]
522 async fn one_key_cannot_starve_the_others_of_the_total() {
523 // #32's starvation criterion, in the terms it actually lives in:
524 // the total is one, held by A, so B must refuse on its deadline
525 // rather than queue behind A.
526 let Some(url) = base_url() else {
527 eprintln!("SKIPPED: {}", skip_reason());
528 return;
529 };
530 let Some(temp) = TempDb::create(&url, "regstarve").await else {
531 eprintln!("SKIPPED: {}", skip_reason());
532 return;
533 };
534 let dsns = Dsns::new(&temp.url, &["a", "b"]);
535 let registry = Arc::new(PoolRegistry::with_limits(
536 Arc::clone(&dsns) as Arc<dyn TenantDsns>,
537 StepClock::new(),
538 PoolLimits {
539 max_connections: 4,
540 idle_timeout: None,
541 acquire_timeout: Duration::from_millis(400),
542 },
543 1,
544 Duration::from_secs(300),
545 ));
546
547 let a = registry.pool_for("a").await.expect("a opens");
548 let b = registry.pool_for("b").await.expect("b opens");
549
550 let hold = tokio::spawn(async move {
551 let _ = a.query(&Statement::new("SELECT pg_sleep(5)")).await;
552 });
553
554 // Wait until the permit is *observably* taken rather than sleeping
555 // a guessed interval and hoping.
556 //
557 // The first version slept 200ms and assumed A had won the single
558 // permit by then. Under load it had not, B took the permit first,
559 // and the refusal this test exists to see never happened — it
560 // failed on a machine running two builds at once, which is exactly
561 // when a timing assumption is worth least.
562 let contended = async {
563 loop {
564 if b.query(&Statement::new("SELECT 1")).await.is_err() {
565 return;
566 }
567 tokio::time::sleep(Duration::from_millis(20)).await;
568 }
569 };
570 tokio::time::timeout(Duration::from_secs(4), contended)
571 .await
572 .expect("A holds the only permit within four seconds");
573
574 // Now that contention is established, the deadline is the claim:
575 // B refuses on its own 400ms rather than waiting out A's five
576 // seconds.
577 let started = std::time::Instant::now();
578 let refused = b.query(&Statement::new("SELECT 1")).await;
579 let waited = started.elapsed();
580
581 assert!(
582 refused.is_err(),
583 "B must be refused rather than queued behind A: {refused:?}"
584 );
585 assert!(
586 waited < Duration::from_secs(2),
587 "and refused on its own deadline, not after A finished: {waited:?}"
588 );
589 hold.abort();
590 let _ = hold.await;
591
592 // A refusal is backpressure, not a broken pool.
593 b.query(&Statement::new("SELECT 1"))
594 .await
595 .expect("B recovers once the budget frees");
596 temp.finish().await;
597 }
598
599 #[tokio::test]
600 async fn an_unknown_key_opens_nothing_and_says_which_kind_of_miss() {
601 let Some(url) = base_url() else {
602 eprintln!("SKIPPED: {}", skip_reason());
603 return;
604 };
605 let Some(temp) = TempDb::create(&url, "regmissing").await else {
606 eprintln!("SKIPPED: {}", skip_reason());
607 return;
608 };
609 let dsns = Dsns::new(&temp.url, &["a"]);
610 let registry =
611 PoolRegistry::new(Arc::clone(&dsns) as Arc<dyn TenantDsns>, StepClock::new());
612
613 assert_eq!(
614 registry.pool_for("nobody").await.err(),
615 Some(PoolError::NoDsn)
616 );
617 assert_eq!(registry.open_pools(), 0, "and opened nothing");
618 temp.finish().await;
619 }
620
621 /// A `Postgres` over a pool that has not connected.
622 ///
623 /// `connect_lazy` is what makes the race testable without a
624 /// server: it parses the URL and opens nothing, so a test can
625 /// exercise which pool was *handed out* without one being open.
626 fn lazy_postgres(limits: PoolLimits) -> Postgres {
627 Postgres {
628 pool: sqlx::postgres::PgPoolOptions::new()
629 .max_connections(limits.max_connections)
630 .connect_lazy("postgres://unused@127.0.0.1:1/unused")
631 .expect("a lazy pool needs no server"),
632 }
633 }
634
635 /// Two callers racing one cold key open one pool between them.
636 ///
637 /// "A key has *a* pool" is the registry's whole promise, and two
638 /// opens break it: one tenant on two pools, the first orphaned by
639 /// the second's `insert` while whoever held it is still querying.
640 ///
641 /// Counted at the connect rather than observed on a server, so the
642 /// assertion is on the thing the race is about rather than on a
643 /// symptom downstream of it.
644 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
645 async fn two_callers_racing_one_cold_key_open_one_pool() {
646 let opens = Arc::new(std::sync::atomic::AtomicUsize::new(0));
647 // Holds every open until the test releases it, so the second
648 // caller reaches the map's insert before the first one has.
649 let gate = Arc::new(tokio::sync::Semaphore::new(0));
650 let counted = Arc::clone(&opens);
651 let held = Arc::clone(&gate);
652
653 let dsns = Dsns::new("postgres://unused/unused", &["a"]);
654 let registry = Arc::new(
655 PoolRegistry::new(Arc::clone(&dsns) as Arc<dyn TenantDsns>, StepClock::new())
656 .with_open(Arc::new(move |_dsn: String, limits: PoolLimits| {
657 let counted = Arc::clone(&counted);
658 let held = Arc::clone(&held);
659 Box::pin(async move {
660 counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
661 let _permit = held.acquire().await.expect("gate is open");
662 Ok(lazy_postgres(limits))
663 })
664 })),
665 );
666
667 let mut racers: Vec<_> = (0..2)
668 .map(|_| {
669 let registry = Arc::clone(®istry);
670 tokio::spawn(async move { registry.pool_for("a").await })
671 })
672 .collect();
673
674 // Let both callers run as far as they can before releasing.
675 tokio::time::sleep(Duration::from_millis(200)).await;
676 gate.add_permits(2);
677
678 let first = racers.remove(0).await.expect("racer").expect("a pool");
679 let second = racers.remove(0).await.expect("racer").expect("a pool");
680
681 assert_eq!(
682 opens.load(std::sync::atomic::Ordering::SeqCst),
683 1,
684 "one key, one pool — a second open is a second pool for one tenant"
685 );
686 assert!(
687 Arc::ptr_eq(&first, &second),
688 "and both callers get that one pool, not one of two"
689 );
690 assert_eq!(registry.open_pools(), 1);
691 assert_eq!(
692 dsns.lookups(),
693 vec!["a".to_owned()],
694 "and its DSN is read once, not once per racing caller"
695 );
696 }
697}