Files
buzz/crates/buzz-db/src/test_support.rs
Matt Toohey b7b545f090 test(db): drop scratch databases and roles even when a test fails (#8747)
## Summary

`buzz-db` tests that create their own databases or roles dropped them as
the last line of the test body. A failed assertion or a timeout skipped
that line and left the database or role on the shared Postgres server.
Seven files carried their own copy of `create_scratch_db_through` and
its drop helper.

- Adds `ScratchDb` and `ScratchRole` guards to
`crates/buzz-db/src/test_support.rs`. Their `Drop` runs `DROP DATABASE
IF EXISTS ... WITH (FORCE)` or `DROP ROLE IF EXISTS` on a fresh admin
connection, on a separate thread with its own runtime. `ScratchSchema`
already works this way, so the guards work under both runtime flavours
and while a panicking test unwinds. `WITH (FORCE)` ends any sessions
still open on the database, so pools need not be closed first.
- A failed drop prints a warning instead of panicking, because a panic
during unwinding would abort the test binary.
- The seven per-file helpers and every end-of-test `DROP DATABASE` in
`buzz-db` now use the guard. The replica-fence masked-activity test's
least-privilege role uses `ScratchRole`. Admin pools and `close()` calls
that only served cleanup are removed. Test behaviour is otherwise
unchanged.
- `crates/buzz-db/TESTING.md` now points new tests at the guards.
- The second commit fixes a comment in
`armed_pool_rejects_old_channel_inserts_through_public_api` that still
described the old end-of-test drop.

**Existing leftovers need dropping by hand.** This change stops new
leftovers but does not remove the ones earlier failed runs already left
in dev `buzz-postgres` containers. There were 24 on the author's
machine, named `stall_r_*`, `buzz_admin_*`, `floor_probe_*`, `fence_*`
and `cxl_*`. List them, check the list, then drop each one:

```sql
SELECT datname FROM pg_database
WHERE datname ~ '^(stall_r|buzz_admin|floor_probe|fence|cxl)_';

DROP DATABASE "<name>" WITH (FORCE);
```

The guards are private to `buzz-db`. `buzz-push-gateway`, `buzz-admin`
and `buzz-relay` tests still create and drop scratch databases by hand,
which could get a shared copy of the guards in a follow-up.

### Related issue

None found. This continues the test-cleanup work in #8727.

### Testing

- Ran the 70 affected `buzz-db` tests against the local `buzz-postgres`
container: 68 passed. Neither failure comes from this change.
`cluster_global_probe_commits_tokens_and_sessions_prove_coverage` hit
the known `MaskedActivity { masked: 1 }` flake under load and passed
when run alone. Because it panicked while holding a `ScratchDb`, it also
showed a panicking test leaves nothing behind.
`serving_lease_statement_timeout_cancels_slow_statement` failed with
`VersionMismatch(56)`, because the shared dev database already has
another branch's migration 56 applied.
- After the run, the server's database and role lists matched the
snapshot taken before it.
- A temporary test panicked while holding an open transaction, an
exclusive table lock and a live session as the scratch role. It left no
database or role behind under either runtime flavour and printed no drop
warnings. That test is not part of this PR.
- `cargo fmt --check` and `cargo clippy -p buzz-db --all-targets -- -D
warnings` pass. The tests ran before the commits were cherry-picked onto
current main. Only clippy was re-run afterwards.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:35:59 +11:00

606 lines
23 KiB
Rust

const DEFAULT_DATABASE_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials
/// Resolve the database URL shared by PostgreSQL-backed unit tests.
pub(crate) fn database_url() -> String {
std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("TEST_DATABASE_URL"))
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| DEFAULT_DATABASE_URL.to_owned())
}
/// A uniquely named database on the test server that is dropped when the
/// guard goes out of scope, including when the test panics.
///
/// The drop uses `WITH (FORCE)`, which ends every session still connected,
/// so pools on the database need not be closed first. Bind the guard to a
/// named variable for the whole test: `let (pool, _) = ScratchDb::migrated(..)`
/// drops the database immediately.
pub(crate) struct ScratchDb {
name: String,
url: String,
admin_url: String,
}
impl ScratchDb {
/// Create an empty database named `{prefix}_{uuid}` on the server
/// `admin_url` connects to.
pub(crate) async fn create(admin_url: &str, prefix: &str) -> Self {
use sqlx::Connection as _;
let name = format!("{prefix}_{}", uuid::Uuid::new_v4().simple());
let mut admin = sqlx::PgConnection::connect(admin_url)
.await
.expect("connect admin database");
sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}")))
.execute(&mut admin)
.await
.expect("create scratch database");
let _ = admin.close().await;
// Swap the database path segment of the admin URL for the scratch name.
let idx = admin_url.rfind('/').expect("db url has a path segment");
let url = format!("{}/{name}", &admin_url[..idx]);
Self {
name,
url,
admin_url: admin_url.to_owned(),
}
}
/// [`ScratchDb::create`], then connect a pool and run every migration.
pub(crate) async fn migrated(admin_url: &str, prefix: &str) -> (sqlx::PgPool, Self) {
let scratch = Self::create(admin_url, prefix).await;
let pool = scratch.connect().await;
crate::migration::run_migrations(&pool)
.await
.expect("migrate scratch db");
(pool, scratch)
}
/// [`ScratchDb::create`], then connect a pool and run migrations through
/// `target`.
pub(crate) async fn migrated_through(
admin_url: &str,
prefix: &str,
target: i64,
) -> (sqlx::PgPool, Self) {
let scratch = Self::create(admin_url, prefix).await;
let pool = scratch.connect().await;
crate::migration::run_migrations_through(&pool, target)
.await
.expect("migrate scratch db through target");
(pool, scratch)
}
/// A default pool on this database.
pub(crate) async fn connect(&self) -> sqlx::PgPool {
sqlx::PgPool::connect(&self.url)
.await
.expect("connect scratch db")
}
pub(crate) fn name(&self) -> &str {
&self.name
}
pub(crate) fn url(&self) -> &str {
&self.url
}
}
impl Drop for ScratchDb {
fn drop(&mut self) {
drop_cluster_object(
&self.admin_url,
&format!("scratch database {}", self.name),
format!("DROP DATABASE IF EXISTS {} WITH (FORCE)", self.name),
);
}
}
/// A uniquely named role on the test server that is dropped when the guard
/// goes out of scope, including when the test panics. The role must not own
/// objects or hold privileges by then, or the drop fails with a warning.
pub(crate) struct ScratchRole {
name: String,
admin_url: String,
}
impl ScratchRole {
/// Create a role named `{prefix}_{uuid}` with `options` (for example
/// `LOGIN PASSWORD '...'`) on the server `admin_url` connects to.
pub(crate) async fn create(admin_url: &str, prefix: &str, options: &str) -> Self {
use sqlx::Connection as _;
let name = format!("{prefix}_{}", uuid::Uuid::new_v4().simple());
let mut admin = sqlx::PgConnection::connect(admin_url)
.await
.expect("connect admin database");
sqlx::query(sqlx::AssertSqlSafe(format!("CREATE ROLE {name} {options}")))
.execute(&mut admin)
.await
.expect("create scratch role");
let _ = admin.close().await;
Self {
name,
admin_url: admin_url.to_owned(),
}
}
pub(crate) fn name(&self) -> &str {
&self.name
}
}
impl Drop for ScratchRole {
fn drop(&mut self) {
drop_cluster_object(
&self.admin_url,
&format!("scratch role {}", self.name),
format!("DROP ROLE IF EXISTS {}", self.name),
);
}
}
/// Run one cleanup statement from `Drop` over a fresh admin connection.
///
/// Drop runs on the test's runtime thread, which cannot drive the test's
/// pools while it blocks here, so the statement runs on a thread with its
/// own runtime. Failures are printed rather than raised: a panic in Drop
/// while a failing test unwinds would abort the test binary.
fn drop_cluster_object(admin_url: &str, object: &str, statement: String) {
use sqlx::Connection as _;
let url = admin_url.to_owned();
let cleanup = std::thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| error.to_string())?
.block_on(async {
let mut connection = sqlx::PgConnection::connect(&url)
.await
.map_err(|error| error.to_string())?;
let result = sqlx::query(sqlx::AssertSqlSafe(statement))
.execute(&mut connection)
.await;
let _ = connection.close().await;
result.map(drop).map_err(|error| error.to_string())
})
})
.join();
let error = match cleanup {
Ok(Ok(())) => return,
Ok(Err(error)) => error,
Err(_) => "cleanup thread panicked".to_owned(),
};
eprintln!("warning: failed to drop {object}: {error}");
}
/// One-session fixture that bypasses ordinary write-fence triggers while
/// retaining application admission. Communities are active, quiescing, fenced.
pub(crate) async fn admission_fixture() -> (crate::Db, [buzz_core::CommunityId; 3]) {
use buzz_core::CommunityId;
use uuid::Uuid;
let db = crate::Db::new(&crate::DbConfig {
database_url: database_url(),
max_connections: 1,
min_connections: 1,
..crate::DbConfig::default()
})
.await
.expect("connect test database");
let pool = &db.pool;
let mut communities = [CommunityId::from_uuid(Uuid::nil()); 3];
for community in &mut communities {
let id = Uuid::new_v4();
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
.bind(id)
.bind(format!("admission-{id}.example"))
.execute(pool)
.await
.expect("create community");
*community = CommunityId::from_uuid(id);
}
set_deletion_state(pool, *communities[1].as_uuid(), "quiescing").await;
set_deletion_state(pool, *communities[2].as_uuid(), "fenced").await;
// The one physical session serves every API call in this fixture.
sqlx::query("SET session_replication_role = replica")
.execute(pool)
.await
.expect("disable ordinary triggers");
let role: String = sqlx::query_scalar("SHOW session_replication_role")
.fetch_one(pool)
.await
.expect("read replication role");
assert_eq!(role, "replica");
// A raw write to the fenced tenant proves the DB backstop is bypassed.
sqlx::query("INSERT INTO users (community_id, pubkey) VALUES ($1, $2)")
.bind(communities[2].as_uuid())
.bind(vec![0xfe_u8; 32])
.execute(pool)
.await
.expect("raw fenced insert succeeds with triggers disabled");
(db, communities)
}
/// In one per-test database, make any return to the retired SQL predicate
/// observable without changing the production function in the public schema.
pub(crate) async fn reject_legacy_sql_fence_predicate(pool: &sqlx::PgPool) -> String {
let schema = format!("admission_test_{}", uuid::Uuid::new_v4().simple());
sqlx::query(sqlx::AssertSqlSafe(format!("CREATE SCHEMA {schema}")))
.execute(pool)
.await
.expect("create test-only function schema");
sqlx::query(sqlx::AssertSqlSafe(format!(
"CREATE FUNCTION {schema}.community_write_allowed(uuid) RETURNS boolean \
LANGUAGE plpgsql VOLATILE AS $$ BEGIN \
RAISE EXCEPTION 'legacy SQL fence predicate called'; END $$",
)))
.execute(pool)
.await
.expect("shadow legacy SQL predicate");
sqlx::query(sqlx::AssertSqlSafe(format!(
"SET search_path = {schema}, public"
)))
.execute(pool)
.await
.expect("resolve test-local predicate first");
let shadowed: bool = sqlx::query_scalar(
"SELECT to_regprocedure('community_write_allowed(uuid)') = to_regprocedure($1)",
)
.bind(format!("{schema}.community_write_allowed(uuid)"))
.fetch_one(pool)
.await
.expect("resolve shadowed predicate");
assert!(shadowed);
schema
}
/// Hold the executor's exclusive lock until a sweep reaches shared admission,
/// then quiesce in that same transaction before releasing it. The ordinary
/// second pool keeps lifecycle triggers enabled; the sweep fixture bypasses them.
pub(crate) async fn fence_while_sweep_waits<F, T>(community: buzz_core::CommunityId, sweep: F) -> T
where
F: std::future::Future<Output = T> + Send + 'static,
T: Send + 'static,
{
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&database_url())
.await
.expect("connect deletion executor pool");
let mut tx = pool.begin().await.expect("begin deletion executor");
sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))")
.bind(community.as_uuid())
.execute(&mut *tx)
.await
.expect("hold exclusive deletion lock");
let mut task = tokio::spawn(sweep);
let waiting = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
let blocked: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM pg_locks \
WHERE locktype = 'advisory' AND NOT granted \
AND database = (SELECT oid FROM pg_database WHERE datname = current_database()) \
AND pg_backend_pid() = ANY(pg_blocking_pids(pid)))",
)
.fetch_one(&mut *tx)
.await
.expect("inspect blocked advisory admission");
if blocked {
break;
}
tokio::task::yield_now().await;
}
})
.await;
if waiting.is_err() {
task.abort();
panic!("sweep did not block in admission before deadline");
}
set_deletion_state_in_transaction(&mut tx, *community.as_uuid(), "quiescing").await;
tx.commit().await.expect("commit deletion fence");
let result = tokio::time::timeout(std::time::Duration::from_secs(5), &mut task).await;
if result.is_err() {
task.abort();
panic!("sweep did not finish after deletion fence committed");
}
pool.close().await;
result.unwrap().expect("sweep task")
}
/// Move a community fixture into a deletion lifecycle state the way the
/// executor does: the tombstone trigger only accepts lifecycle changes from a
/// transaction carrying the executor GUCs for that community, including its
/// current fence generation.
pub(crate) async fn set_deletion_state(pool: &sqlx::PgPool, id: uuid::Uuid, state: &str) {
let mut tx = pool.begin().await.expect("begin lifecycle fixture");
set_deletion_state_in_transaction(&mut tx, id, state).await;
tx.commit().await.expect("commit lifecycle fixture");
}
async fn set_deletion_state_in_transaction(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: uuid::Uuid,
state: &str,
) {
sqlx::query(
"SELECT set_config('buzz.deletion_executor_community', $1, true), \
set_config('buzz.deletion_fence_generation', \
(SELECT deletion_fence_generation::text FROM communities WHERE id = $2), true)",
)
.bind(id.to_string())
.bind(id)
.execute(&mut **tx)
.await
.expect("authorize lifecycle fixture");
sqlx::query(
"UPDATE communities SET deletion_state = $2, \
deleted_at = CASE WHEN $2 = 'tombstone' THEN now() END \
WHERE id = $1",
)
.bind(id)
.bind(state)
.execute(&mut **tx)
.await
.expect("set lifecycle state");
}
/// Move a disposable test community into `quiescing`, the state in which
/// admission must reject new serving writes.
pub(crate) async fn quiesce_community_for_tests(
pool: &sqlx::PgPool,
community: buzz_core::CommunityId,
) {
set_deletion_state(pool, *community.as_uuid(), "quiescing").await;
}
/// Whether `error` is the community-admission rejection taken at transaction
/// entry, as opposed to a later commit-time trigger rejection.
pub(crate) fn is_admission_rejection(error: &crate::DbError) -> bool {
matches!(error, crate::DbError::AccessDenied(message) if message.contains("write-fenced (quiescing)"))
}
/// The desired-state schema as one SQL script.
///
/// `schema/schema.sql` is a manifest of `\i` includes resolved relative to the
/// including file (as `pgschema` does). This expands every include in place,
/// so text assertions and `sqlx::raw_sql` bootstraps see the same statements,
/// in the same order, that `pgschema apply` loads.
pub(crate) fn desired_state_schema_sql() -> String {
fn expand(path: &std::path::Path, out: &mut String) {
let text = std::fs::read_to_string(path)
.unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
let dir = path.parent().expect("schema file has a parent directory");
for line in text.split_inclusive('\n') {
match line.trim_end().strip_prefix("\\i ") {
Some(include) => expand(&dir.join(include.trim()), out),
None => out.push_str(line),
}
}
if !out.ends_with('\n') {
out.push('\n');
}
}
let mut out = String::new();
expand(&desired_state_schema_dir().join("schema.sql"), &mut out);
out
}
/// `schema/` at the workspace root.
pub(crate) fn desired_state_schema_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schema")
}
/// Rewrite every `ON DELETE CASCADE`, `SET NULL` and `SET DEFAULT` foreign key
/// in this test's database as `NO ACTION`: the schema the cascade retirement
/// targets. Every trigger stays live, including the foreign-key checks and the
/// community fence, so a delete path exercised afterwards must remove its
/// children itself, in an order the remaining constraints accept.
///
/// Each PostgreSQL test process owns its database (`crates/buzz-db/TESTING.md`),
/// so the rewrite does not reach other tests. It refuses any database whose
/// name does not match the per-test wrapper's (`buzz_nt_` plus 24 hex digits),
/// which excludes the run's `_desired` template. The name is the only check:
/// a manually created database with that shape would pass.
pub(crate) async fn retire_foreign_key_delete_actions(pool: &sqlx::PgPool) {
let database: String = sqlx::query_scalar("SELECT current_database()")
.fetch_one(pool)
.await
.expect("read current database");
assert!(
database
.strip_prefix("buzz_nt_")
.is_some_and(|hash| hash.len() == 24
&& hash
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))),
"refusing to rewrite foreign keys in shared database {database}; \
run this test through scripts/postgres-test-run.sh"
);
sqlx::raw_sql(
r#"DO $$
DECLARE c record;
BEGIN
FOR c IN SELECT conrelid::regclass AS tbl, conname, pg_get_constraintdef(oid) AS def
FROM pg_constraint
WHERE contype = 'f' AND confdeltype IN ('c', 'n', 'd') AND conparentid = 0
LOOP
EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I, ADD CONSTRAINT %I %s',
c.tbl, c.conname, c.conname,
regexp_replace(c.def, ' ON DELETE (CASCADE|SET NULL|SET DEFAULT)( \([^)]*\))?', ''));
END LOOP;
END $$"#,
)
.execute(pool)
.await
.expect("retire foreign-key delete actions");
let remaining: i64 = sqlx::query_scalar(
"SELECT count(*) FROM pg_constraint WHERE contype = 'f' AND confdeltype IN ('c', 'n', 'd')",
)
.fetch_one(pool)
.await
.expect("count foreign-key delete actions");
assert_eq!(remaining, 0, "every foreign-key delete action is retired");
}
/// A single-connection pool whose session carries `application_name`, so
/// [`wait_for_lock_wait`] can tell when its statement is blocked.
pub(crate) async fn named_pool(application_name: &str) -> sqlx::PgPool {
use std::str::FromStr as _;
let options = sqlx::postgres::PgConnectOptions::from_str(&database_url())
.expect("parse test database URL")
.application_name(application_name);
sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.expect("connect named test pool")
}
/// Wait until the session named `application_name` in this test's database is
/// blocked on a lock.
pub(crate) async fn wait_for_lock_wait(pool: &sqlx::PgPool, application_name: &str) {
tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
let waiting: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM pg_stat_activity \
WHERE datname = current_database() AND application_name = $1 \
AND wait_event_type = 'Lock')",
)
.bind(application_name)
.fetch_one(pool)
.await
.expect("inspect lock wait");
if waiting {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("session reached a lock wait");
}
/// Loopback TCP proxy that can pause the upstream database, like
/// `docker pause` on a replica. While paused, every session (established or
/// new) holds the bytes it receives with both sockets open; on resume the
/// held bytes are delivered in order and the sessions carry on.
///
/// [`PausingProxy::stall_established`] instead holds only the sessions open
/// at that moment, for good, while later sessions relay normally: a failover
/// whose old sockets stop answering without closing.
/// [`PausingProxy::stall_from`] is the reverse: sessions from a given number
/// on hold for good, so dials past that point stall mid-handshake.
pub(crate) struct PausingProxy {
pub(crate) port: u16,
hold: tokio::sync::watch::Sender<ProxyHold>,
next_session: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
/// Which proxy sessions hold their bytes.
#[derive(Clone, Copy, Default)]
struct ProxyHold {
all: bool,
/// Sessions numbered below this hold their bytes.
below: u64,
/// Sessions numbered at or above this hold their bytes.
from: Option<u64>,
}
impl ProxyHold {
fn holds(self, session: u64) -> bool {
self.all || session < self.below || self.from.is_some_and(|from| session >= from)
}
}
impl PausingProxy {
pub(crate) async fn spawn(upstream_host: String, upstream_port: u16) -> Self {
use std::sync::atomic::Ordering;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn pump(
mut from: tokio::net::tcp::OwnedReadHalf,
mut to: tokio::net::tcp::OwnedWriteHalf,
mut hold: tokio::sync::watch::Receiver<ProxyHold>,
session: u64,
) {
let mut buf = [0u8; 8192];
loop {
let Ok(n) = from.read(&mut buf).await else {
return;
};
if n == 0 {
return;
}
if hold.wait_for(|hold| !hold.holds(session)).await.is_err() {
return;
}
if to.write_all(&buf[..n]).await.is_err() {
return;
}
}
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind proxy");
let port = listener.local_addr().expect("proxy addr").port();
let (hold, _) = tokio::sync::watch::channel(ProxyHold::default());
let accept_hold = hold.subscribe();
let next_session = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let accept_next = next_session.clone();
tokio::spawn(async move {
while let Ok((client, _)) = listener.accept().await {
let session = accept_next.fetch_add(1, Ordering::SeqCst);
let Ok(server) =
tokio::net::TcpStream::connect((upstream_host.as_str(), upstream_port)).await
else {
continue;
};
let (client_read, client_write) = client.into_split();
let (server_read, server_write) = server.into_split();
tokio::spawn(pump(
client_read,
server_write,
accept_hold.clone(),
session,
));
tokio::spawn(pump(
server_read,
client_write,
accept_hold.clone(),
session,
));
}
});
Self {
port,
hold,
next_session,
}
}
pub(crate) fn set_paused(&self, paused: bool) {
self.hold.send_modify(|hold| hold.all = paused);
}
/// Hold every session open now, for good; later sessions relay normally.
pub(crate) fn stall_established(&self) {
let below = self.next_session.load(std::sync::atomic::Ordering::SeqCst);
self.hold.send_modify(|hold| hold.below = below);
}
/// Relay the first `from` sessions normally and hold every later one,
/// for good.
pub(crate) fn stall_from(&self, from: u64) {
self.hold.send_modify(|hold| hold.from = Some(from));
}
/// Sessions accepted so far.
pub(crate) fn sessions(&self) -> u64 {
self.next_session.load(std::sync::atomic::Ordering::SeqCst)
}
}