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