use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use cratefield_core::{
Clock, Database, DbError, Rows, Statement, Tenant, TenantDatabases, TenantDbError,
};
use crate::{PoolLimits, Postgres};
#[async_trait]
pub trait TenantDsns: Send + Sync {
async fn dsn(&self, tenant: &str) -> Result<Option<String>, String>;
}
pub const TENANT_POOL_TOTAL: usize = 64;
pub const TENANT_POOL_IDLE: Duration = Duration::from_secs(300);
struct Cached {
db: Arc<CappedDb>,
last_used: i64,
}
type Open = Arc<
dyn Fn(String, PoolLimits) -> cratefield_core::BoxFuture<'static, Result<Postgres, DbError>>
+ Send
+ Sync,
>;
pub struct PoolRegistry {
dsns: Arc<dyn TenantDsns>,
limits: PoolLimits,
idle: Duration,
clock: Arc<dyn Clock>,
total: Arc<tokio::sync::Semaphore>,
open: Open,
#[allow(clippy::disallowed_types)]
pools: std::sync::Mutex<HashMap<String, Cached>>,
#[allow(clippy::disallowed_types)]
inflight: std::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
}
impl PoolRegistry {
#[must_use]
pub fn new(dsns: Arc<dyn TenantDsns>, clock: Arc<dyn Clock>) -> Self {
Self::with_limits(
dsns,
clock,
PoolLimits::default(),
TENANT_POOL_TOTAL,
TENANT_POOL_IDLE,
)
}
#[allow(clippy::disallowed_types)]
#[must_use]
pub fn with_limits(
dsns: Arc<dyn TenantDsns>,
clock: Arc<dyn Clock>,
limits: PoolLimits,
total: usize,
idle: Duration,
) -> Self {
Self {
dsns,
limits,
idle,
clock,
total: Arc::new(tokio::sync::Semaphore::new(total.max(1))),
open: Arc::new(|dsn: String, limits: PoolLimits| {
Box::pin(async move { Postgres::connect_with(&dsn, limits).await })
}),
pools: std::sync::Mutex::new(HashMap::new()),
inflight: std::sync::Mutex::new(HashMap::new()),
}
}
#[cfg(test)]
fn with_open(mut self, open: Open) -> Self {
self.open = open;
self
}
#[must_use]
pub fn open_pools(&self) -> usize {
self.pools.lock().expect("pool lock").len()
}
fn evict_idle(&self, now: i64) -> Vec<Arc<CappedDb>> {
let cutoff = now.saturating_sub(i64::try_from(self.idle.as_secs()).unwrap_or(i64::MAX));
let mut pools = self.pools.lock().expect("pool lock");
let stale: Vec<String> = pools
.iter()
.filter(|(_, cached)| cached.last_used < cutoff)
.map(|(tenant, _)| tenant.clone())
.collect();
stale
.into_iter()
.filter_map(|tenant| pools.remove(&tenant).map(|cached| cached.db))
.collect()
}
fn now_secs(&self) -> i64 {
self.clock.now().unix_timestamp()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PoolError {
NoDsn,
Lookup,
Connect,
}
impl PoolRegistry {
pub(crate) async fn pool_for(&self, key: &str) -> Result<Arc<CappedDb>, PoolError> {
let now = self.now_secs();
for stale in self.evict_idle(now) {
let _ = stale.inner.close().await;
}
if let Some(cached) = self.pools.lock().expect("pool lock").get_mut(key) {
cached.last_used = now;
return Ok(Arc::clone(&cached.db));
}
let flight = self.flight(key);
let _opening = flight.lock().await;
if let Some(cached) = self.pools.lock().expect("pool lock").get_mut(key) {
cached.last_used = now;
return Ok(Arc::clone(&cached.db));
}
let dsn = self
.dsns
.dsn(key)
.await
.map_err(|_| PoolError::Lookup)?
.ok_or(PoolError::NoDsn)?;
let pool = (self.open)(dsn.clone(), self.limits)
.await
.map_err(|_| PoolError::Connect)?;
drop(dsn);
let db = Arc::new(CappedDb {
inner: pool,
total: Arc::clone(&self.total),
acquire_timeout: self.limits.acquire_timeout,
clock: Arc::clone(&self.clock),
key: key.to_owned(),
});
self.pools.lock().expect("pool lock").insert(
key.to_owned(),
Cached {
db: Arc::clone(&db),
last_used: now,
},
);
Ok(db)
}
fn flight(&self, key: &str) -> Arc<tokio::sync::Mutex<()>> {
let mut inflight = self.inflight.lock().expect("pool lock");
inflight.retain(|_, flight| Arc::strong_count(flight) > 1);
Arc::clone(
inflight
.entry(key.to_owned())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
)
}
}
#[async_trait]
impl TenantDatabases for PoolRegistry {
async fn database(&self, tenant: &Tenant) -> Result<Arc<dyn Database>, TenantDbError> {
match self.pool_for(tenant.id().as_str()).await {
Ok(db) => Ok(db as Arc<dyn Database>),
Err(why) => {
tracing::warn!(tenant = %tenant.id(), reason = ?why, "no pool for tenant");
Err(TenantDbError::Unreachable {
tenant: tenant.id().to_string(),
})
}
}
}
}
pub(crate) struct CappedDb {
inner: Postgres,
total: Arc<tokio::sync::Semaphore>,
acquire_timeout: Duration,
clock: Arc<dyn Clock>,
key: String,
}
impl CappedDb {
async fn permit(&self) -> Result<tokio::sync::OwnedSemaphorePermit, DbError> {
let total = Arc::clone(&self.total);
let acquire = async move { total.acquire_owned().await };
match cratefield_core::timeout(&*self.clock, acquire, self.acquire_timeout).await {
Some(Ok(permit)) => Ok(permit),
Some(Err(_)) | None => Err(DbError::Execute(format!(
"`{}` waited out the connection budget",
self.key
))),
}
}
}
#[async_trait]
impl Database for CappedDb {
async fn execute(&self, stmt: &Statement) -> Result<u64, DbError> {
let _permit = self.permit().await?;
self.inner.execute(stmt).await
}
async fn query(&self, stmt: &Statement) -> Result<Rows, DbError> {
let _permit = self.permit().await?;
self.inner.query(stmt).await
}
async fn batch_atomic(&self, stmts: &[Statement]) -> Result<(), DbError> {
let _permit = self.permit().await?;
self.inner.batch_atomic(stmts).await
}
}
#[cfg(test)]
#[allow(clippy::disallowed_types)] mod tests {
use super::*;
use crate::testing::{TempDb, base_url, skip_reason};
use cratefield_core::Statement;
struct StepClock {
offset: std::sync::atomic::AtomicI64,
}
impl StepClock {
fn new() -> Arc<Self> {
Arc::new(Self {
offset: std::sync::atomic::AtomicI64::new(0),
})
}
fn advance(&self, secs: i64) {
self.offset
.fetch_add(secs, std::sync::atomic::Ordering::SeqCst);
}
}
#[async_trait]
impl Clock for StepClock {
fn now(&self) -> time::OffsetDateTime {
time::OffsetDateTime::now_utc()
+ time::Duration::seconds(self.offset.load(std::sync::atomic::Ordering::SeqCst))
}
async fn timeout_any(
&self,
fut: cratefield_core::BoxFuture<'static, Box<dyn std::any::Any + Send>>,
after: Duration,
) -> Option<Box<dyn std::any::Any + Send>> {
tokio::time::timeout(after, fut).await.ok()
}
}
struct Dsns {
url: String,
known: Vec<String>,
lookups: std::sync::Mutex<Vec<String>>,
}
impl Dsns {
fn new(url: &str, known: &[&str]) -> Arc<Self> {
Arc::new(Self {
url: url.to_owned(),
known: known.iter().map(|k| (*k).to_owned()).collect(),
lookups: std::sync::Mutex::new(Vec::new()),
})
}
fn lookups(&self) -> Vec<String> {
self.lookups.lock().expect("lookup lock").clone()
}
}
#[async_trait]
impl TenantDsns for Dsns {
async fn dsn(&self, key: &str) -> Result<Option<String>, String> {
self.lookups
.lock()
.expect("lookup lock")
.push(key.to_owned());
Ok(self
.known
.iter()
.any(|known| known == key)
.then(|| self.url.clone()))
}
}
#[tokio::test]
async fn a_pool_opens_on_first_use_and_is_reused() {
let Some(url) = base_url() else {
eprintln!("SKIPPED: {}", skip_reason());
return;
};
let Some(temp) = TempDb::create(&url, "reglazy").await else {
eprintln!("SKIPPED: {}", skip_reason());
return;
};
let dsns = Dsns::new(&temp.url, &["a"]);
let registry =
PoolRegistry::new(Arc::clone(&dsns) as Arc<dyn TenantDsns>, StepClock::new());
assert_eq!(registry.open_pools(), 0, "nothing opens at construction");
for _ in 0..3 {
let db = registry.pool_for("a").await.expect("opens");
db.query(&Statement::new("SELECT 1"))
.await
.expect("queries");
}
assert_eq!(registry.open_pools(), 1);
assert_eq!(
dsns.lookups(),
vec!["a".to_owned()],
"the DSN is read once, not per request"
);
temp.finish().await;
}
#[tokio::test]
async fn an_idle_pool_is_closed_and_reopens_on_the_next_request() {
let Some(url) = base_url() else {
eprintln!("SKIPPED: {}", skip_reason());
return;
};
let Some(temp) = TempDb::create(&url, "regidle").await else {
eprintln!("SKIPPED: {}", skip_reason());
return;
};
let dsns = Dsns::new(&temp.url, &["a"]);
let clock = StepClock::new();
let registry = PoolRegistry::with_limits(
Arc::clone(&dsns) as Arc<dyn TenantDsns>,
Arc::clone(&clock) as Arc<dyn Clock>,
PoolLimits::default(),
8,
Duration::from_secs(60),
);
registry.pool_for("a").await.expect("opens");
assert_eq!(registry.open_pools(), 1);
clock.advance(120);
registry.pool_for("a").await.expect("reopens");
assert_eq!(registry.open_pools(), 1, "reopened, not accumulated");
assert_eq!(
dsns.lookups().len(),
2,
"a reopen reads the DSN again, which is what makes eviction real"
);
temp.finish().await;
}
#[tokio::test]
async fn one_key_cannot_starve_the_others_of_the_total() {
let Some(url) = base_url() else {
eprintln!("SKIPPED: {}", skip_reason());
return;
};
let Some(temp) = TempDb::create(&url, "regstarve").await else {
eprintln!("SKIPPED: {}", skip_reason());
return;
};
let dsns = Dsns::new(&temp.url, &["a", "b"]);
let registry = Arc::new(PoolRegistry::with_limits(
Arc::clone(&dsns) as Arc<dyn TenantDsns>,
StepClock::new(),
PoolLimits {
max_connections: 4,
idle_timeout: None,
acquire_timeout: Duration::from_millis(400),
},
1,
Duration::from_secs(300),
));
let a = registry.pool_for("a").await.expect("a opens");
let b = registry.pool_for("b").await.expect("b opens");
let hold = tokio::spawn(async move {
let _ = a.query(&Statement::new("SELECT pg_sleep(5)")).await;
});
let contended = async {
loop {
if b.query(&Statement::new("SELECT 1")).await.is_err() {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
};
tokio::time::timeout(Duration::from_secs(4), contended)
.await
.expect("A holds the only permit within four seconds");
let started = std::time::Instant::now();
let refused = b.query(&Statement::new("SELECT 1")).await;
let waited = started.elapsed();
assert!(
refused.is_err(),
"B must be refused rather than queued behind A: {refused:?}"
);
assert!(
waited < Duration::from_secs(2),
"and refused on its own deadline, not after A finished: {waited:?}"
);
hold.abort();
let _ = hold.await;
b.query(&Statement::new("SELECT 1"))
.await
.expect("B recovers once the budget frees");
temp.finish().await;
}
#[tokio::test]
async fn an_unknown_key_opens_nothing_and_says_which_kind_of_miss() {
let Some(url) = base_url() else {
eprintln!("SKIPPED: {}", skip_reason());
return;
};
let Some(temp) = TempDb::create(&url, "regmissing").await else {
eprintln!("SKIPPED: {}", skip_reason());
return;
};
let dsns = Dsns::new(&temp.url, &["a"]);
let registry =
PoolRegistry::new(Arc::clone(&dsns) as Arc<dyn TenantDsns>, StepClock::new());
assert_eq!(
registry.pool_for("nobody").await.err(),
Some(PoolError::NoDsn)
);
assert_eq!(registry.open_pools(), 0, "and opened nothing");
temp.finish().await;
}
fn lazy_postgres(limits: PoolLimits) -> Postgres {
Postgres {
pool: sqlx::postgres::PgPoolOptions::new()
.max_connections(limits.max_connections)
.connect_lazy("postgres://unused@127.0.0.1:1/unused")
.expect("a lazy pool needs no server"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn two_callers_racing_one_cold_key_open_one_pool() {
let opens = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let gate = Arc::new(tokio::sync::Semaphore::new(0));
let counted = Arc::clone(&opens);
let held = Arc::clone(&gate);
let dsns = Dsns::new("postgres://unused/unused", &["a"]);
let registry = Arc::new(
PoolRegistry::new(Arc::clone(&dsns) as Arc<dyn TenantDsns>, StepClock::new())
.with_open(Arc::new(move |_dsn: String, limits: PoolLimits| {
let counted = Arc::clone(&counted);
let held = Arc::clone(&held);
Box::pin(async move {
counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let _permit = held.acquire().await.expect("gate is open");
Ok(lazy_postgres(limits))
})
})),
);
let mut racers: Vec<_> = (0..2)
.map(|_| {
let registry = Arc::clone(®istry);
tokio::spawn(async move { registry.pool_for("a").await })
})
.collect();
tokio::time::sleep(Duration::from_millis(200)).await;
gate.add_permits(2);
let first = racers.remove(0).await.expect("racer").expect("a pool");
let second = racers.remove(0).await.expect("racer").expect("a pool");
assert_eq!(
opens.load(std::sync::atomic::Ordering::SeqCst),
1,
"one key, one pool — a second open is a second pool for one tenant"
);
assert!(
Arc::ptr_eq(&first, &second),
"and both callers get that one pool, not one of two"
);
assert_eq!(registry.open_pools(), 1);
assert_eq!(
dsns.lookups(),
vec!["a".to_owned()],
"and its DSN is read once, not once per racing caller"
);
}
}