fix(testkit): stop leaking a database per test
`test_pool` creates a database per test and nothing ever dropped it.
Invisible on the testcontainer path — the container dies with the process
and takes them with it. But `CM_TEST_DATABASE_URL` points at a SHARED
server that outlives the run, and that is the path CI uses and the path
`.cargo/config.toml` sets for local development. So on both, every
database ever created is still there, growing with every `cargo test`.
Measured before writing the fix: **3,546 databases, 38 GB** on one
developer machine. After: 391 and 4.3 GB — the remainder being today's,
still inside the window. The docker volume went 42.3 GB to 5.7 GB.
Age comes from the NAME, not the catalogue. Postgres records no creation
time for a database, but the names are `test_<uuid-v7>` and UUIDv7 puts
its millisecond timestamp in the first 48 bits — the same property
`mission_runtime::container_name` already relies on.
Three things the tests pin down:
- a database created just now must read as NEW, or the reaper deletes
one a parallel test binary is still using;
- only names we minted are reapable — `test_scratch` and `clawmates`
survive;
- the window outlasts any test run.
`WITH (FORCE)` because a single leftover session pins a database and the
drop otherwise silently does nothing. Best-effort throughout: a test must
never fail because housekeeping could not run.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
This commit is contained in:
co-authored by
Claude Opus 5
parent
72eda8b3d2
commit
b58f0347e6
@@ -30,6 +30,10 @@ async fn server() -> &'static PgServer {
|
||||
SERVER
|
||||
.get_or_init(|| async {
|
||||
if let Ok(url) = std::env::var("CM_TEST_DATABASE_URL") {
|
||||
// Only on the shared-server path. The testcontainer below is
|
||||
// torn down with the process, so it has nothing to reap and a
|
||||
// sweep there would be pure cost.
|
||||
reap_stale_databases(&url).await;
|
||||
return PgServer {
|
||||
admin_url: url,
|
||||
_container: None,
|
||||
@@ -57,6 +61,86 @@ async fn server() -> &'static PgServer {
|
||||
.await
|
||||
}
|
||||
|
||||
/// How long a test database may sit before another test process reaps it.
|
||||
///
|
||||
/// Comfortably longer than any test run, so a database in use by a
|
||||
/// concurrently-running binary is never a candidate. Nothing here needs to be
|
||||
/// prompt — the point is that the set stays bounded, not that it stays empty.
|
||||
const STALE_AFTER_MS: u64 = 2 * 60 * 60 * 1000;
|
||||
|
||||
/// Drop test databases left behind by earlier runs.
|
||||
///
|
||||
/// `test_pool` creates a database per test and nothing ever dropped it. On the
|
||||
/// testcontainer path that is invisible: the container dies with the process
|
||||
/// and takes them with it. But `CM_TEST_DATABASE_URL` points at a SHARED
|
||||
/// server that outlives the run — which is the path CI uses and the path
|
||||
/// `.cargo/config.toml` sets for local development — so on both of those every
|
||||
/// database ever created is still there.
|
||||
///
|
||||
/// Measured before writing this: **3,546 databases, 38 GB** on one developer
|
||||
/// machine. It grows with every `cargo test`.
|
||||
///
|
||||
/// Age comes from the name, not the catalogue. Postgres records no creation
|
||||
/// time for a database, but the names are `test_<uuid-v7>` and UUIDv7 puts the
|
||||
/// millisecond timestamp in its first 48 bits — the same property
|
||||
/// `mission_runtime::container_name` relies on.
|
||||
///
|
||||
/// Best-effort throughout: a test must never fail because housekeeping could
|
||||
/// not run.
|
||||
async fn reap_stale_databases(admin_url: &str) {
|
||||
let Ok(admin) = PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(admin_url)
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let names: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT datname FROM pg_database WHERE datname LIKE 'test\\_%'",
|
||||
)
|
||||
.fetch_all(&admin)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let mut dropped = 0usize;
|
||||
for name in names {
|
||||
let Some(created) = uuid_v7_millis(&name) else {
|
||||
// Not a name we minted; leave it entirely alone.
|
||||
continue;
|
||||
};
|
||||
if now_ms.saturating_sub(created) < STALE_AFTER_MS {
|
||||
continue;
|
||||
}
|
||||
// FORCE terminates any leftover connection; without it a single stale
|
||||
// session pins the database and the reap silently does nothing.
|
||||
if sqlx::query(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)"))
|
||||
.execute(&admin)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
dropped += 1;
|
||||
}
|
||||
}
|
||||
if dropped > 0 {
|
||||
eprintln!("cm-testkit: reaped {dropped} stale test database(s)");
|
||||
}
|
||||
admin.close().await;
|
||||
}
|
||||
|
||||
/// The millisecond timestamp encoded in the leading 48 bits of a
|
||||
/// `test_<uuid-v7-simple>` name.
|
||||
fn uuid_v7_millis(db_name: &str) -> Option<u64> {
|
||||
let hex = db_name.strip_prefix("test_")?;
|
||||
if hex.len() != 32 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
u64::from_str_radix(&hex[..12], 16).ok()
|
||||
}
|
||||
|
||||
/// Creates a unique database, runs all migrations, and returns a pool
|
||||
/// connected to it.
|
||||
pub async fn test_pool() -> PgPool {
|
||||
@@ -93,3 +177,46 @@ fn swap_database(url: &str, db_name: &str) -> String {
|
||||
None => format!("{head}/{db_name}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{uuid_v7_millis, STALE_AFTER_MS};
|
||||
|
||||
/// Age comes from the NAME, because Postgres records no creation time for
|
||||
/// a database. UUIDv7 puts the millisecond timestamp in its first 48 bits.
|
||||
#[test]
|
||||
fn a_test_database_name_carries_its_own_age() {
|
||||
let id = uuid::Uuid::now_v7();
|
||||
let name = format!("test_{}", id.simple());
|
||||
let ms = uuid_v7_millis(&name).expect("a name we minted parses");
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
assert!(
|
||||
now.saturating_sub(ms) < 5_000,
|
||||
"a database created just now must read as new, or the reaper drops \
|
||||
one another test process is still using"
|
||||
);
|
||||
}
|
||||
|
||||
/// Anything we did not mint is left alone.
|
||||
#[test]
|
||||
fn only_our_own_names_are_reapable() {
|
||||
assert!(uuid_v7_millis("clawmates").is_none());
|
||||
assert!(uuid_v7_millis("postgres").is_none());
|
||||
assert!(uuid_v7_millis("template1").is_none());
|
||||
// Right prefix, wrong shape — a human-made `test_scratch` survives.
|
||||
assert!(uuid_v7_millis("test_scratch").is_none());
|
||||
assert!(uuid_v7_millis("test_").is_none());
|
||||
// Right length, not hex.
|
||||
assert!(uuid_v7_millis(&format!("test_{}", "z".repeat(32))).is_none());
|
||||
}
|
||||
|
||||
/// The window has to be longer than a test run, or the reaper deletes a
|
||||
/// database out from under a binary running in parallel.
|
||||
#[test]
|
||||
fn the_stale_window_outlasts_any_test_run() {
|
||||
assert!(STALE_AFTER_MS >= 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user