Files
buzz/crates/buzz-relay/tests/boot_lifecycle.rs
Matt Toohey ef4130da4b fix(db): keep lease releases durable and deflake PostgreSQL lane tests (#8676)
## Summary

When the user-deletion worker dropped a lease heartbeat, its pooled
connection could be left inside an open transaction. The worker's final
lease `release` then reported success but never committed, and a rerun
saw the request as "leased by another worker" for up to 60 s. The
heartbeat is now one autocommit `UPDATE`.

The PR also fixes six PostgreSQL-lane test flakes from the 21–28 Oct
2026 CI audit. It moves one relay test off the 100 ms-acquire test
helper before that test starts flaking the same way.

This is one of seven PRs split out of the `flakey-tests` branch
(siblings below). It touches only `buzz-db`, `buzz-deletion` and
`buzz-relay`. Every relay file except `connection.rs` and
`boot_lifecycle.rs` changes only for the helper rename in the last row.

### Related issue

None found. Nothing on `main` (eda26b942) or in an open PR covers these.
G, a single hermit `pgschema` 504 that needs a workflow change, is out
of scope.

### Flaky tests fixed

| Test | Failing runs | Cause | Change |
|---|---|---|---|
| `buzz-deletion`
`user::worker::postgres_tests::dummy_executor_failure_retries_same_community_stage`
(:47) and `next_step_advances_one_handler_at_a_time_through_completion`
(:131) | 40439041234 (main), 40399635380#3, 40413732817#1 | **Product
bug.** `run_stage_with_heartbeat` drops the renewal future when the
stage handler wins `select!`. The heartbeat opened a transaction. sqlx
0.9 bumps `transaction_depth` only after `BEGIN` returns, so a `begin()`
dropped mid-`BEGIN` queues no `ROLLBACK`, and the connection goes back
to the pool inside an open transaction. The worker's autocommit
`release` then ran inside it and never committed. |
`EntityDeletionStore::heartbeat` is one autocommit `UPDATE`, guarded by
`lock_owned`'s ownership/status/expiry predicate. A superseded lease is
never renewed. A new regression test,
`dropped_heartbeat_leaves_no_open_transaction_for_the_release`, drops
the real heartbeat mid-`BEGIN` through a pausing proxy. |
| `buzz-db`
`session_statement_timeouts_bound_writer_and_reader_sessions`
(runtime/tests.rs:3138, `PoolTimedOut`) | 40128463987, 40314950864#1,
40374286094 (main), 40394821009#1, 40413732817#1 | Each config's lazy
reader pool dialled cold inside the 150 ms `READER_ACQUIRE_TIMEOUT`.
`Db::new` never runs the boot ping. | Warm the reader the way the relay
boots it (`read_pool_boot_ping_for_tests` + `await_idle_floor`). |
| `buzz-relay` `startup_owns_the_dependency_sampler` (boot_lifecycle.rs)
| Torn scrape: 39865872891 (main), 39624074244#2, 40385799750#1,
40413293801#1. Early exit: 39704588623 | metrics-exporter-prometheus
0.18.3 snapshots counters, then gauges. The scrape that first shows the
completion gauge can therefore predate the `checks_total` counters. |
Assert on a second scrape, taken after the gauge is seen. On an early
relay exit, the helper now panics with the exit status, stdout and
stderr. The early exit itself was not reproduced. |
| `buzz-db`
`sample_writer_waits_for_shared_floor_lock_before_sampling_time`
(replica_fence.rs:1086) and
`replica_floor_probe_waits_for_shared_writer_and_records_after_release`
(runtime/tests.rs:3452) | 39601763602 (main), 40391445284#1,
39825846222, 40301667418 | Both scan cluster-wide `pg_stat_activity` but
ran outside the serialized `postgres-cluster-global` group. Connection
churn from other tests masked activity (`MaskedActivity { masked: 1 }`).
| Rename both with a `cluster_global_` prefix. `nextest show-config
test-groups --profile postgres-ci` lists both in the group. |
| `buzz-db`
`stalled_reader_sessions_release_their_slots_for_new_connections`
(reader_pool_postgres_tests.rs:354) | 40399635380#2 | The floor warms
one connection, so the second warm-up acquire dials cold inside 150 ms.
| Retry only that warm-up acquire on `PoolTimedOut`, for up to 5 s
(#8472's precedent). The stall under test keeps the production budget. |
| `buzz-db`
`readiness_check_cancellation_balances_waiter_and_inflight_connection`
(runtime/tests.rs:391, `Elapsed`) | 39785370353 | The test aborted
`pg_sleep(5)` after a fixed 25 ms. Recovery lands at server start + 5 s,
but the bound is abort + 5 s, a ~22 ms margin locally. | Poll
`pg_stat_activity` until the query is active, then abort. Use
`pg_sleep(1)` within the unchanged 5 s bound. |
| `buzz-relay` `upgrade_would_deny_line_is_written_at_auth_while_open`
(connection.rs) | None yet (preventive, added by #8634) | It used the
100 ms-acquire `test_state_with_database_url` against the real test
database, the same shape as the `PoolTimedOut` flakes above. | Use
`test_state_with_database_url_and_pool_options` with sqlx's default
timeout. The 100 ms helper is renamed
`test_state_with_unreachable_database_url`; all its remaining callers
use closed ports. |

Local before/after counts are from the original `flakey-tests` commit
(1f0451915), on macOS with OrbStack Postgres:

- Regression test: 3/3 FAIL before, 3/3 PASS after.
- Worker stress: 3 failures in 288 iterations before; 2,100 passing runs
after.
- Statement timeouts: 44/50 FAIL before, 50/50 PASS after.
- Sampler: 2/200 FAIL before, 200/200 PASS after.
- `MaskedActivity` under `pgbench -C`: 2/50 iterations FAIL before.
After, the lane passed 3× (613/613) but was not re-run under pgbench.
- Stalled reader: 10/30 FAIL before, 30/30 PASS after.
- Forced late-`Execute` cancellation: 10/10 FAIL before, 10/10 PASS
after.

### Verification

This branch was pushed without local re-verification, by the user's
choice: local test runs were overloading the machine and failing for
that reason. **Hosted CI is the check.** It was pushed with `git push
--no-verify`, because the pre-push hook runs clippy and unit tests.

- Every file on this branch is byte-identical to its final version on
`flakey-tests` (67185df6a), where the counts above were measured.
- Not re-run for this PR: clippy, nextest and the Postgres lanes.
- Checked for this PR: `git diff --check`, `rustfmt --check` on the
changed files, and a conflict-free merge with all six siblings onto
`main`.

### Readiness

This PR has a product fix (the `buzz-db` lease heartbeat). Under
AGENTS.md "Before opening a PR", it needs an agent review and a **human
check against a local relay** before `buzz-review-completed` is added.
Suggested check:

1. With local Postgres and a relay, submit and approve a user deletion
with `buzz-admin user-deletions submit` / `approve`.
2. Run `buzz-admin user-deletions run` until it completes, then run it
again at once.
3. Working looks like this: both runs complete, neither reports the
request as leased by another worker, and `buzz-admin user-deletions
inspect <id>` shows no leftover lease. The original race is rare and is
not expected to reproduce by hand; this check confirms releases still
commit.

### Merge order

Independent of the other six. It merges cleanly onto `main`, and with
every sibling, in any order.

### Sibling PRs

Split from `flakey-tests` (one PR per area): #8677 (relay E2E helper +
Windows shell), #8678 (mobile), #8679 (Tauri apt cache + Windows Rust
cap), #8680 (desktop product races), #8681 (desktop E2E state gates),
#8682 (desktop E2E composer + row actions).

🤖 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-30 10:00:41 +11:00

1133 lines
45 KiB
Rust

use std::{
collections::BTreeMap,
io::{Read as _, Write as _},
net::{TcpListener, TcpStream},
process::{Child, Command, ExitStatus, Output, Stdio},
sync::mpsc,
thread::{self, JoinHandle},
time::{Duration, Instant},
};
use serde_json::Value;
use buzz_relay::lifecycle::StartupPhase;
use buzz_relay::startup_steps;
use buzz_relay::state::REDIS_BOOTSTRAP_FAILURE;
const VALID_RELAY_PRIVATE_KEY: &str =
"0000000000000000000000000000000000000000000000000000000000000001";
const CHILD_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_CAPTURE_BYTES: u64 = 1024 * 1024;
/// Bound on every wait for the relay to export a metric family.
const METRICS_SCRAPE_DEADLINE: Duration = Duration::from_secs(8);
struct RelayProcess {
child: Option<Child>,
stdout: Option<JoinHandle<CapturedStream>>,
stderr: Option<JoinHandle<CapturedStream>>,
scratch_dir: std::path::PathBuf,
}
struct CapturedStream {
retained: Vec<u8>,
total_bytes: u64,
}
impl RelayProcess {
fn spawn(environment: &[(&str, &str)]) -> Self {
let scratch_dir =
std::env::temp_dir().join(format!("buzz-boot-lifecycle-{}", uuid::Uuid::new_v4()));
let mut command = Command::new(env!("CARGO_BIN_EXE_buzz-relay"));
command
.env_clear()
.env("RUST_BACKTRACE", "0")
.env("RUST_LOG", "buzz_relay=info")
.env("BUZZ_GIT_REPO_PATH", scratch_dir.join("repos"))
.env("BUZZ_GIT_PACK_CACHE_PATH", scratch_dir.join("pack-cache"))
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (name, value) in environment {
command.env(name, value);
}
let mut child = command.spawn().expect("spawn buzz-relay child process");
let stdout = child.stdout.take().expect("relay stdout pipe");
let stderr = child.stderr.take().expect("relay stderr pipe");
Self {
child: Some(child),
stdout: Some(thread::spawn(move || capture_stream(stdout))),
stderr: Some(thread::spawn(move || capture_stream(stderr))),
scratch_dir,
}
}
fn try_wait(&mut self) -> Option<ExitStatus> {
self.child
.as_mut()
.expect("relay child")
.try_wait()
.expect("poll relay child")
}
fn wait(mut self, timeout: Duration) -> Output {
let deadline = Instant::now() + timeout;
let status = loop {
if let Some(status) = self.try_wait() {
break status;
}
if Instant::now() >= deadline {
let child = self.child.as_mut().expect("relay child");
let _ = child.kill();
let _ = child.wait();
panic!("buzz-relay child exceeded {timeout:?}");
}
thread::sleep(Duration::from_millis(10));
};
self.collect(status)
}
/// The captured output of a child that has already exited as `status`.
fn collect(&mut self, status: ExitStatus) -> Output {
self.child.take();
let output = Output {
status,
stdout: join_capture(self.stdout.take(), "stdout"),
stderr: join_capture(self.stderr.take(), "stderr"),
};
let _ = std::fs::remove_dir_all(&self.scratch_dir);
output
}
fn terminate(mut self) -> Output {
self.child
.as_mut()
.expect("relay child")
.kill()
.expect("terminate exact relay child");
self.wait(Duration::from_secs(2))
}
}
impl Drop for RelayProcess {
fn drop(&mut self) {
if let Some(child) = self.child.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
let _ = std::fs::remove_dir_all(&self.scratch_dir);
}
}
fn capture_stream(mut stream: impl std::io::Read) -> CapturedStream {
let mut retained = Vec::new();
let mut total_bytes = 0_u64;
let mut chunk = [0_u8; 8192];
loop {
let read = stream.read(&mut chunk).expect("read relay output pipe");
if read == 0 {
break;
}
total_bytes = total_bytes.saturating_add(u64::try_from(read).expect("read size fits u64"));
let remaining = usize::try_from(MAX_CAPTURE_BYTES)
.expect("capture ceiling fits usize")
.saturating_sub(retained.len());
retained.extend_from_slice(&chunk[..read.min(remaining)]);
}
CapturedStream {
retained,
total_bytes,
}
}
fn join_capture(capture: Option<JoinHandle<CapturedStream>>, stream: &str) -> Vec<u8> {
let capture = capture
.expect("relay capture thread")
.join()
.expect("relay capture thread must not panic");
assert!(
capture.total_bytes <= MAX_CAPTURE_BYTES,
"relay {stream} exceeded {MAX_CAPTURE_BYTES} bytes: {}",
capture.total_bytes,
);
capture.retained
}
fn run_relay(environment: &[(&str, &str)]) -> Output {
RelayProcess::spawn(environment).wait(CHILD_TIMEOUT)
}
fn scrape_metrics(port: u16) -> std::io::Result<String> {
let address = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let mut stream = TcpStream::connect_timeout(&address, Duration::from_millis(100))?;
stream.set_read_timeout(Some(Duration::from_millis(500)))?;
stream.write_all(b"GET /metrics HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
Ok(response)
}
fn wait_for_relay_metrics(process: &mut RelayProcess, port: u16) -> String {
wait_for_scraped_metric(process, port, "buzz_audit_enabled")
}
/// Polls the relay's own `/metrics` until `needle` appears, bounded by
/// [`METRICS_SCRAPE_DEADLINE`]. A relay that exits first is a failure, not a
/// timeout, so the panic names the real cause and carries the relay's output.
fn wait_for_scraped_metric(process: &mut RelayProcess, port: u16, needle: &str) -> String {
wait_for_scraped_metrics(process, port, &[needle])
}
/// Waits for one scrape to contain every requested sample, bounded by
/// [`METRICS_SCRAPE_DEADLINE`]. A scrape can overlap the sampler's sequential
/// metric writes, so related series may appear in different responses.
fn wait_for_scraped_metrics(process: &mut RelayProcess, port: u16, needles: &[&str]) -> String {
let requested = needles.join(", ");
let mut last_scrape = String::new();
let mut last_error: Option<String>;
let deadline = Instant::now() + METRICS_SCRAPE_DEADLINE;
loop {
if let Some(status) = process.try_wait() {
let output = process.collect(status);
panic!(
"relay exited ({status}) before exporting {requested}\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
match scrape_metrics(port) {
Ok(response) => {
if needles
.iter()
.all(|needle| response.lines().any(|line| line.starts_with(needle)))
{
return response;
}
last_scrape = response;
last_error = None;
}
Err(error) => last_error = Some(error.to_string()),
}
assert!(
Instant::now() < deadline,
"relay did not export {requested} within {METRICS_SCRAPE_DEADLINE:?}; last error: {last_error:?}; last scrape: {last_scrape}"
);
thread::sleep(Duration::from_millis(20));
}
}
/// The early lifecycle phases (crypto through metrics bind) are log-only.
/// The only startup metric families are the post-metrics-bind step gauges,
/// and no metric family or label value may name an early phase.
fn assert_no_early_lifecycle_metrics(scrape: &str) {
let step_families = [startup_steps::CURRENT_METRIC, startup_steps::SECONDS_METRIC];
for line in scrape.lines() {
for phase in StartupPhase::ALL {
assert!(
!line.contains(&format!("\"{}\"", phase.as_str())),
"logs-only lifecycle contract exported early phase {}: {line}",
phase.as_str()
);
}
let Some(name) = line
.strip_prefix("# HELP ")
.or_else(|| line.strip_prefix("# TYPE "))
.and_then(|rest| rest.split_ascii_whitespace().next())
else {
continue;
};
if step_families.contains(&name) {
continue;
}
assert!(
!["startup", "boot", "lifecycle"]
.iter()
.any(|term| name.contains(term))
&& !StartupPhase::ALL
.iter()
.any(|phase| name.contains(phase.as_str())),
"logs-only lifecycle contract emitted metric family {name}"
);
}
}
fn assert_auth_metrics_have_stable_boot_zeros(scrape: &str) {
const OUTCOMES: [&str; 11] = [
"success",
"invalid",
"banned",
"ban_check_error",
"allowlist_check_error",
"allowlist_denied",
"relay_membership_check_error",
"not_relay_member",
"timeout",
"disconnect",
"shutdown",
];
assert!(scrape.contains("# TYPE buzz_auth_attempts_total counter"));
assert!(scrape.contains("buzz_auth_attempts_total{method=\"nip42\"} 0"));
assert!(scrape.contains("# TYPE buzz_auth_outcomes_total counter"));
assert!(scrape.contains("# TYPE buzz_auth_duration_seconds histogram"));
assert!(scrape.contains("# TYPE buzz_auth_post_terminal_frames_total counter"));
assert!(scrape.contains("buzz_auth_post_terminal_frames_total{state=\"authenticated\"} 0"));
assert!(scrape.contains("buzz_auth_post_terminal_frames_total{state=\"failed\"} 0"));
assert!(scrape.contains("buzz_ws_authenticated_connections_active 0"));
for outcome in OUTCOMES {
assert!(scrape.contains(&format!(
"buzz_auth_outcomes_total{{method=\"nip42\",outcome=\"{outcome}\"}} 0"
)));
assert!(scrape.contains(&format!(
"buzz_auth_duration_seconds_count{{method=\"nip42\",outcome=\"{outcome}\"}} 0"
)));
assert!(scrape.contains(&format!(
"buzz_auth_duration_seconds_sum{{method=\"nip42\",outcome=\"{outcome}\"}} 0"
)));
assert!(scrape.lines().any(|line| {
line.starts_with("buzz_auth_duration_seconds_bucket{")
&& line.contains("le=\"+Inf\"")
&& line.contains("method=\"nip42\"")
&& line.contains(&format!("outcome=\"{outcome}\""))
&& line.ends_with(" 0")
}));
}
}
fn lifecycle_events(output: &Output) -> Vec<Value> {
let mut events: Vec<Value> = output
.stdout
.split(|byte| *byte == b'\n')
.chain(output.stderr.split(|byte| *byte == b'\n'))
.filter_map(|line| serde_json::from_slice::<Value>(line).ok())
.filter(|event| event["event_name"] == "buzz_process_lifecycle")
.collect();
events.sort_by_key(|event| event["sequence"].as_u64());
events
}
fn lifecycle_events_from(bytes: &[u8]) -> Vec<Value> {
bytes
.split(|byte| *byte == b'\n')
.filter_map(|line| serde_json::from_slice::<Value>(line).ok())
.filter(|event| event["event_name"] == "buzz_process_lifecycle")
.collect()
}
fn assert_accounting(events: &[Value]) {
assert!(!events.is_empty(), "child emitted no lifecycle events");
let boot_id = events[0]["process_boot_id"]
.as_str()
.expect("process_boot_id");
let mut counts = BTreeMap::<String, (usize, usize)>::new();
for (index, event) in events.iter().enumerate() {
assert_eq!(event["schema_version"], 1);
assert_eq!(event["sequence"], u64::try_from(index + 1).unwrap());
assert_eq!(event["process_boot_id"], boot_id);
assert_eq!(event["track"], "startup");
let count = counts
.entry(event["phase"].as_str().expect("phase").to_owned())
.or_default();
match event["edge"].as_str() {
Some("started") => count.0 += 1,
Some("terminal") => count.1 += 1,
other => panic!("unexpected lifecycle edge: {other:?}"),
}
}
assert!(
counts
.values()
.all(|(started, terminal)| *started == 1 && *terminal == 1),
"every started phase must have one terminal: {counts:?}"
);
}
fn assert_terminal(events: &[Value], phase: &str, status: &str, reason: Option<&str>) {
let terminal = events
.iter()
.find(|event| event["phase"] == phase && event["edge"] == "terminal")
.unwrap_or_else(|| panic!("missing {phase} terminal"));
assert_eq!(terminal["status"], status);
match reason {
Some(reason) => assert_eq!(terminal["reason"], reason),
None => assert!(terminal["reason"].is_null()),
}
}
fn phases(events: &[Value]) -> Vec<&str> {
events
.iter()
.filter(|event| event["edge"] == "started")
.map(|event| event["phase"].as_str().expect("phase"))
.collect()
}
#[test]
fn invalid_config_terminalizes_at_main_even_with_logs_disabled() {
let output = run_relay(&[
("RUST_LOG", "off"),
("BUZZ_BIND_ADDR", "not-a-socket-address"),
]);
assert!(!output.status.success());
let events = lifecycle_events(&output);
assert_accounting(&events);
assert_eq!(
phases(&events),
[
"process_telemetry",
"crypto_init",
"tracing_init",
"config_load"
]
);
assert_terminal(&events, "config_load", "failed", Some("config_invalid"));
assert_terminal(
&events,
"process_telemetry",
"failed",
Some("config_invalid"),
);
assert_eq!(lifecycle_events_from(&output.stderr), events);
assert!(lifecycle_events_from(&output.stdout).is_empty());
}
#[test]
#[cfg(unix)]
fn config_filesystem_failure_has_a_bounded_terminal() {
let output = run_relay(&[
("RUST_LOG", "off"),
("BUZZ_GIT_REPO_PATH", "/dev/null/not-a-directory"),
]);
assert!(!output.status.success());
let events = lifecycle_events(&output);
assert_accounting(&events);
assert_terminal(&events, "config_load", "failed", Some("config_invalid"));
assert_terminal(
&events,
"process_telemetry",
"failed",
Some("config_invalid"),
);
}
#[test]
fn invalid_config_value_has_the_same_bounded_terminal() {
let output = run_relay(&[("RUST_LOG", "off"), ("BUZZ_DRAIN_JITTER_MS", "bogus")]);
assert!(!output.status.success());
let events = lifecycle_events(&output);
assert_accounting(&events);
assert_terminal(&events, "config_load", "failed", Some("config_invalid"));
assert_terminal(
&events,
"process_telemetry",
"failed",
Some("config_invalid"),
);
}
#[test]
fn configured_otlp_terminalizes_tracing_before_a_later_failure() {
let output = run_relay(&[
("RUST_LOG", "off"),
("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:4317"),
("BUZZ_BIND_ADDR", "not-a-socket-address"),
]);
assert!(!output.status.success());
let events = lifecycle_events(&output);
assert_accounting(&events);
assert_terminal(&events, "tracing_init", "succeeded", None);
assert_terminal(&events, "config_load", "failed", Some("config_invalid"));
}
#[test]
fn missing_key_stops_before_metrics_bind() {
let output = run_relay(&[]);
assert!(!output.status.success());
let events = lifecycle_events(&output);
assert_accounting(&events);
assert_eq!(
phases(&events),
[
"process_telemetry",
"crypto_init",
"tracing_init",
"config_load",
"key_load"
]
);
assert_terminal(&events, "key_load", "failed", Some("missing"));
assert_terminal(&events, "process_telemetry", "failed", Some("missing"));
}
#[test]
fn invalid_key_uses_a_bounded_reason_without_leaking_the_value() {
let secret = "private-key-material-that-must-not-appear";
let output = run_relay(&[("BUZZ_RELAY_PRIVATE_KEY", secret)]);
assert!(!output.status.success());
let events = lifecycle_events(&output);
assert_accounting(&events);
assert_terminal(&events, "key_load", "failed", Some("required_invalid"));
let combined = [output.stdout, output.stderr].concat();
assert!(!String::from_utf8_lossy(&combined).contains(secret));
}
#[test]
fn occupied_metrics_port_has_a_typed_bind_terminal() {
let occupied = TcpListener::bind(("0.0.0.0", 0)).expect("bind occupied port");
let port = occupied.local_addr().expect("occupied address").port();
let port = port.to_string();
let output = run_relay(&[
("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY),
("BUZZ_METRICS_PORT", &port),
]);
assert!(!output.status.success());
let events = lifecycle_events(&output);
assert_accounting(&events);
assert_terminal(&events, "metrics_bind", "failed", Some("bind"));
assert_terminal(&events, "process_telemetry", "failed", Some("bind"));
}
#[test]
fn otlp_build_failure_is_degraded_without_leaking_endpoint_credentials() {
let secret = "telemetry-secret-marker";
let endpoint = format!("https://telemetry-user:{secret}@[");
let fake_database = TcpListener::bind(("127.0.0.1", 0)).expect("bind fake database");
let database_url = format!(
"postgres://buzz@127.0.0.1:{}/buzz",
fake_database.local_addr().expect("database address").port()
);
let reserved = TcpListener::bind(("127.0.0.1", 0)).expect("reserve metrics port");
let port = reserved.local_addr().expect("metrics address").port();
drop(reserved);
let port_value = port.to_string();
let mut process = RelayProcess::spawn(&[
("OTEL_EXPORTER_OTLP_ENDPOINT", &endpoint),
("RUST_LOG", "buzz_relay=warn"),
("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY),
("BUZZ_METRICS_PORT", &port_value),
("DATABASE_URL", &database_url),
]);
let scrape = wait_for_relay_metrics(&mut process, port);
assert_no_early_lifecycle_metrics(&scrape);
let output = process.terminate();
let events = lifecycle_events(&output);
assert_accounting(&events);
assert_terminal(&events, "tracing_init", "degraded", Some("exporter_build"));
assert_terminal(
&events,
"process_telemetry",
"degraded",
Some("exporter_build"),
);
let combined = [output.stdout, output.stderr].concat();
assert!(!String::from_utf8_lossy(&combined).contains(secret));
}
#[test]
fn successful_main_emits_complete_lifecycle_and_reports_current_startup_step() {
let fake_database = TcpListener::bind(("127.0.0.1", 0)).expect("bind fake database");
let database_url = format!(
"postgres://buzz@127.0.0.1:{}/buzz",
fake_database.local_addr().expect("database address").port()
);
let reserved = TcpListener::bind(("127.0.0.1", 0)).expect("reserve metrics port");
let port = reserved.local_addr().expect("metrics address").port();
drop(reserved);
let port_value = port.to_string();
let mut process = RelayProcess::spawn(&[
("RUST_LOG", "off"),
("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY),
("BUZZ_METRICS_PORT", &port_value),
("DATABASE_URL", &database_url),
]);
let scrape = wait_for_relay_metrics(&mut process, port);
assert_no_early_lifecycle_metrics(&scrape);
assert_auth_metrics_have_stable_boot_zeros(&scrape);
// The fake database accepts TCP but never speaks Postgres, so the relay
// stays in its first post-metrics-bind step and must say so on /metrics.
let current = format!(
"{}{{phase=\"db_connect\"}} 1",
startup_steps::CURRENT_METRIC
);
let scrape = wait_for_scraped_metric(&mut process, port, &current);
assert!(
!scrape.contains(&format!("{}{{", startup_steps::SECONDS_METRIC)),
"no step has finished while the relay waits on Postgres"
);
assert_no_early_lifecycle_metrics(&scrape);
let output = process.terminate();
let events = lifecycle_events(&output);
assert_accounting(&events);
assert_terminal(&events, "crypto_init", "succeeded", None);
assert_terminal(&events, "tracing_init", "succeeded", None);
assert_terminal(&events, "config_load", "succeeded", None);
assert_terminal(&events, "key_load", "succeeded", None);
assert_terminal(&events, "metrics_bind", "succeeded", None);
assert_terminal(&events, "process_telemetry", "succeeded", None);
}
/// Boot gates that need a live Postgres to reach the code under test. Named
/// `postgres_tests` so `.config/nextest.toml`'s `postgres-ci` default filter
/// discovers them structurally; the wrapper hands each test its own database
/// through `DATABASE_URL`.
mod postgres_tests {
use super::*;
const REDIS_BOOTSTRAP_BUDGET: Duration = Duration::from_secs(5);
const REDIS_BOOTSTRAP_SCHEDULING_SLACK: Duration = Duration::from_secs(3);
/// A TCP peer that completes Redis's metadata handshake, then reads and
/// holds PING without replying. This distinguishes a bounded checkout +
/// PING from a refused connection, which returns before the bootstrap
/// timeout is exercised.
struct HangingRedisPeer {
redis_url: String,
request_received: mpsc::Receiver<Vec<u8>>,
stop: mpsc::Sender<()>,
worker: Option<JoinHandle<()>>,
}
impl HangingRedisPeer {
fn spawn() -> Self {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind fake Redis peer");
listener
.set_nonblocking(true)
.expect("set fake Redis listener nonblocking");
let port = listener.local_addr().expect("fake Redis address").port();
let (request_tx, request_received) = mpsc::channel();
let (stop, stop_rx) = mpsc::channel();
let worker = thread::spawn(move || loop {
if stop_rx.try_recv().is_ok() {
return;
}
let (mut stream, _) = match listener.accept() {
Ok(accepted) => accepted,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
continue;
}
Err(error) => panic!("accept fake Redis connection: {error}"),
};
stream
.set_read_timeout(Some(Duration::from_millis(100)))
.expect("bound fake Redis read");
let mut request = Vec::new();
loop {
let mut chunk = [0_u8; 4096];
match stream.read(&mut chunk) {
Ok(0) => return,
Ok(read) => {
request.extend_from_slice(&chunk[..read]);
assert!(
request.len() <= 4096,
"fake Redis peer received an oversized request"
);
let setinfo_commands = request
.windows(b"SETINFO".len())
.filter(|window| *window == b"SETINFO")
.count();
if setinfo_commands >= 2 {
// redis-rs pipelines CLIENT SETINFO lib-name
// and lib-ver while establishing a connection.
// Complete that handshake so the relay reaches
// its explicit bootstrap PING, then hold it.
stream
.write_all(b"+OK\r\n+OK\r\n")
.expect("reply to Redis client handshake");
request.clear();
continue;
}
if request
.windows(b"PING".len())
.any(|window| window == b"PING")
{
let _ = request_tx.send(request);
let _ = stop_rx.recv_timeout(CHILD_TIMEOUT);
return;
}
}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) =>
{
if stop_rx.try_recv().is_ok() {
return;
}
}
Err(error) => panic!("read fake Redis request: {error}"),
}
}
});
Self {
redis_url: format!("redis://127.0.0.1:{port}"),
request_received,
stop,
worker: Some(worker),
}
}
fn redis_url(&self) -> &str {
&self.redis_url
}
fn assert_request_received(&self, logs: &str) {
let request = self
.request_received
.recv_timeout(Duration::from_secs(1))
.unwrap_or_else(|_| panic!("relay must reach the fake Redis peer: {logs}"));
assert!(!request.is_empty(), "fake Redis peer read an empty request");
}
}
impl Drop for HangingRedisPeer {
fn drop(&mut self) {
let _ = self.stop.send(());
if let Some(worker) = self.worker.take() {
worker.join().expect("fake Redis peer must not panic");
}
}
}
fn reserve_closed_port() -> u16 {
let reserved = TcpListener::bind(("127.0.0.1", 0)).expect("reserve port");
let port = reserved.local_addr().expect("reserved address").port();
drop(reserved);
port
}
/// Runs the relay until it exits on its own, or kills it once `timeout`
/// passes. Unlike `run_relay`, a relay that keeps serving is a result to
/// assert on rather than a panic, which is the whole point here.
fn run_until_exit(environment: &[(&str, &str)], timeout: Duration) -> (bool, Output) {
wait_for_exit(RelayProcess::spawn(environment), timeout)
}
fn wait_for_exit(mut process: RelayProcess, timeout: Duration) -> (bool, Output) {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if process.try_wait().is_some() {
return (true, process.wait(Duration::from_secs(2)));
}
thread::sleep(Duration::from_millis(20));
}
(false, process.terminate())
}
/// Redis is required for pub/sub fan-out, presence, and typing, but nothing
/// in boot ever opened a command connection: `deadpool_redis` pools dial
/// lazily and `PubSubManager::new` only allocates channels, so "Redis
/// pub/sub connected" was logged against a dead port. A relay could
/// therefore boot with Redis unreachable, bind its health listener, and —
/// now that readiness answers from local lifecycle alone — advertise ready
/// for the rest of its life. The bootstrap gate is the one-time proof that
/// the command path has connected at least once, and it has to land before
/// the listener binds, because binding is the one-way latch that makes this
/// pod routable.
///
/// The git conformance probe is disabled so the only remaining startup-fatal
/// gate is the one under test.
#[test]
#[ignore = "requires PostgreSQL"]
fn unreachable_redis_fails_boot_before_the_health_listener_binds() {
let database_url = std::env::var("DATABASE_URL")
.expect("postgres lane provides DATABASE_URL for each test process");
let redis_url = format!("redis://127.0.0.1:{}", reserve_closed_port());
let metrics_port = reserve_closed_port().to_string();
let health_port = reserve_closed_port();
let health_port_value = health_port.to_string();
let (exited, output) = run_until_exit(
&[
("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY),
("BUZZ_METRICS_PORT", &metrics_port),
("BUZZ_HEALTH_PORT", &health_port_value),
("DATABASE_URL", &database_url),
("REDIS_URL", &redis_url),
("BUZZ_GIT_CONFORMANCE_PROBE", "false"),
],
CHILD_TIMEOUT,
);
let logs = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
!logs.contains("Health probe listener started"),
"the Redis bootstrap gate must run before the health listener binds: {logs}"
);
assert!(
exited && !output.status.success(),
"an unreachable Redis command path must be startup-fatal: {logs}"
);
assert!(
logs.contains(REDIS_BOOTSTRAP_FAILURE),
"the failure must name the gate that rejected boot: {logs}"
);
assert!(
TcpListener::bind(("0.0.0.0", health_port)).is_ok(),
"the health port must never have been bound"
);
}
/// A peer that accepts the socket but withholds its Redis response exercises
/// the outer timeout around both lazy pool checkout and PING. Removing or
/// narrowing that timeout makes this test kill a still-running relay at the
/// deadline instead of observing a startup failure.
#[test]
#[ignore = "requires PostgreSQL"]
fn hanging_redis_peer_times_out_before_the_health_listener_binds() {
let database_url = std::env::var("DATABASE_URL")
.expect("postgres lane provides DATABASE_URL for each test process");
let redis_peer = HangingRedisPeer::spawn();
let metrics_port = reserve_closed_port().to_string();
let health_port = reserve_closed_port();
let health_port_value = health_port.to_string();
let timeout = REDIS_BOOTSTRAP_BUDGET + REDIS_BOOTSTRAP_SCHEDULING_SLACK;
let started_at = Instant::now();
let (exited, output) = run_until_exit(
&[
("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY),
("BUZZ_METRICS_PORT", &metrics_port),
("BUZZ_HEALTH_PORT", &health_port_value),
("DATABASE_URL", &database_url),
("REDIS_URL", redis_peer.redis_url()),
("BUZZ_GIT_CONFORMANCE_PROBE", "false"),
],
timeout,
);
let elapsed = started_at.elapsed();
let logs = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
redis_peer.assert_request_received(&logs);
assert!(
elapsed >= REDIS_BOOTSTRAP_BUDGET,
"the fake peer must hold the request through the bootstrap budget: {elapsed:?}: {logs}"
);
assert!(
exited && elapsed < timeout && !output.status.success(),
"the outer bootstrap timeout must terminate the relay within scheduling slack: {elapsed:?}: {logs}"
);
assert!(
logs.contains(REDIS_BOOTSTRAP_FAILURE),
"the timeout must report the bounded Redis bootstrap failure: {logs}"
);
assert!(
!logs.contains("Health probe listener started"),
"the health listener must not bind before Redis bootstrap succeeds: {logs}"
);
assert!(
TcpListener::bind(("0.0.0.0", health_port)).is_ok(),
"the health port must never have been bound"
);
}
/// Startup is the only owner of dependency evaluation. `/_status` just reads
/// the cache, a readiness probe records no dependency attempt at all, and no
/// request path may start a check — so if `main` stops spawning the sampler,
/// this pod evaluates Postgres, Redis, and the deletion catalog exactly
/// never: the dependency families stay absent from its scrape and `/_status`
/// answers `not_yet_sampled` for the pod's whole life.
///
/// No in-process test can fail on that, because each one drives `sample`
/// itself. This one boots the real binary against real dependencies and
/// reads only the relay's own `/metrics` — no probe, no `/_status`, nothing
/// that could evaluate a dependency on the test's behalf. The first tick
/// fires immediately, so the wait is bounded by
/// [`METRICS_SCRAPE_DEADLINE`] and never a fixed sleep.
///
/// The sampler writes counters and histograms before the completion gauge,
/// but a concurrent `/metrics` scrape is not an atomic snapshot. Wait for
/// the actual timestamp, counter, and duration samples together so a scrape
/// that overlaps those sequential writes cannot report a false failure.
#[test]
#[ignore = "requires PostgreSQL"]
fn startup_owns_the_dependency_sampler() {
let database_url = std::env::var("DATABASE_URL")
.expect("postgres lane provides DATABASE_URL for each test process");
let redis_url = std::env::var("REDIS_URL")
.expect("the postgres lane runs alongside Redis and exports REDIS_URL");
let metrics_port = reserve_closed_port();
let metrics_port_value = metrics_port.to_string();
let health_port_value = reserve_closed_port().to_string();
let bind_addr = format!("127.0.0.1:{}", reserve_closed_port());
let mut process = RelayProcess::spawn(&[
("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY),
("BUZZ_METRICS_PORT", &metrics_port_value),
("BUZZ_HEALTH_PORT", &health_port_value),
("BUZZ_BIND_ADDR", &bind_addr),
("DATABASE_URL", &database_url),
("REDIS_URL", &redis_url),
("BUZZ_GIT_CONFORMANCE_PROBE", "false"),
]);
let scrape = wait_for_scraped_metrics(
&mut process,
metrics_port,
&[
"buzz_readiness_dependency_sample_completed_timestamp_seconds ",
"buzz_readiness_dependency_checks_total{",
"buzz_readiness_check_duration_seconds_count{check=\"overall\"}",
],
);
let output = process.terminate();
let logs = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
!scrape.contains("buzz_readiness_checks_total{"),
"no readiness probe was sent, so the sampler alone produced this: {scrape}"
);
assert!(
logs.contains("Health probe listener started"),
"the sampler must be owned by a relay that finished booting: {logs}"
);
}
/// A read replica that is down at boot must not block startup: the main
/// read pool is lazy and degrades to the writer, and the search pool must
/// be lazy too. An eager search-pool connect waits out sqlx's 30s acquire
/// timeout and then fails boot, so this relay would never turn ready
/// inside `CHILD_TIMEOUT`.
#[test]
#[ignore = "requires PostgreSQL"]
fn unreachable_read_replica_does_not_block_boot() {
let database_url = std::env::var("DATABASE_URL")
.expect("postgres lane provides DATABASE_URL for each test process");
let redis_url = std::env::var("REDIS_URL")
.expect("the postgres lane runs alongside Redis and exports REDIS_URL");
let read_database_url = format!(
"postgres://buzz:buzz@127.0.0.1:{}/buzz", // sadscan:disable np.postgres.1
reserve_closed_port()
);
let metrics_port_value = reserve_closed_port().to_string();
let health_port = reserve_closed_port();
let health_port_value = health_port.to_string();
let bind_addr = format!("127.0.0.1:{}", reserve_closed_port());
let mut process = RelayProcess::spawn(&[
("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY),
("BUZZ_METRICS_PORT", &metrics_port_value),
("BUZZ_HEALTH_PORT", &health_port_value),
("BUZZ_BIND_ADDR", &bind_addr),
("DATABASE_URL", &database_url),
("READ_DATABASE_URL", &read_database_url),
("REDIS_URL", &redis_url),
("BUZZ_GIT_CONFORMANCE_PROBE", "false"),
]);
let ready = wait_for_readiness(&mut process, health_port);
let output = process.terminate();
let logs = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
ready.starts_with("HTTP/1.1 200"),
"a dead read replica must not block boot: {ready}: {logs}"
);
assert!(
logs.contains("Search service ready"),
"boot must have passed the search pool: {logs}"
);
}
/// Polls `/_readiness` until it answers 200, the relay exits, or
/// `CHILD_TIMEOUT` passes, and returns the last response.
fn wait_for_readiness(process: &mut RelayProcess, health_port: u16) -> String {
let deadline = Instant::now() + CHILD_TIMEOUT;
loop {
if process.try_wait().is_some() {
return "relay exited before turning ready".to_string();
}
let response = TcpStream::connect(("127.0.0.1", health_port))
.and_then(|mut stream| {
stream.set_read_timeout(Some(Duration::from_secs(1)))?;
stream.write_all(
b"GET /_readiness HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
)?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
Ok(response)
})
.unwrap_or_default();
if response.starts_with("HTTP/1.1 200") || Instant::now() >= deadline {
return response;
}
thread::sleep(Duration::from_millis(50));
}
}
const NIP_FI_ISSUERS: &str = r#"[{
"issuer": "https://idp.example.com",
"command_audiences": ["https://relay.example.com"],
"token_class": "nip-fi+jwt",
"algorithms": ["ES256"],
"maximum_assertion_age_seconds": 3600,
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
"jwks_refresh_interval_seconds": 300,
"jwks_hard_deadline_seconds": 86400,
"maximum_command_age_seconds": 30,
"authorized_principals": ["https://idp.example.com/operator"]
}]"#;
/// A suffix that never covers the relay's own host, so deployment
/// bootstrap writes no binding and the startup load is the first reader
/// of `community_nip_fi_issuers`.
const NIP_FI_PROVISION_BINDING: &str = r#"{"host_suffix":"communities.example.com","authorized_issuers":["https://idp.example.com"]}"#;
/// Boots the relay with the database community source; `extra` adds or
/// overrides variables. Returns the health port alongside the process.
fn spawn_database_source(extra: &[(&str, &str)]) -> (RelayProcess, u16) {
let database_url = std::env::var("DATABASE_URL")
.expect("postgres lane provides DATABASE_URL for each test process");
let redis_url = std::env::var("REDIS_URL")
.expect("the postgres lane runs alongside Redis and exports REDIS_URL");
let metrics_port = reserve_closed_port().to_string();
let health_port = reserve_closed_port();
let health_port_value = health_port.to_string();
let bind_addr = format!("127.0.0.1:{}", reserve_closed_port());
let mut environment = vec![
("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY),
("BUZZ_METRICS_PORT", metrics_port.as_str()),
("BUZZ_HEALTH_PORT", health_port_value.as_str()),
("BUZZ_BIND_ADDR", bind_addr.as_str()),
("DATABASE_URL", database_url.as_str()),
("REDIS_URL", redis_url.as_str()),
("BUZZ_GIT_CONFORMANCE_PROBE", "false"),
// Shadow: off builds no resolver, so it loads no snapshot.
("BUZZ_NIP_FI_MODE", "shadow"),
("BUZZ_NIP_FI_COMMUNITY_SOURCE", "database"),
("BUZZ_NIP_FI_ISSUERS", NIP_FI_ISSUERS),
("BUZZ_NIP_FI_PROVISION_BINDING", NIP_FI_PROVISION_BINDING),
("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"),
];
environment.extend_from_slice(extra);
(RelayProcess::spawn(&environment), health_port)
}
/// Binds `count` active communities under the provision suffix to the
/// registered issuer, on the test's own database.
fn seed_bindings(count: u32) {
use sqlx::Connection as _;
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime");
runtime.block_on(async {
let mut connection = sqlx::PgConnection::connect(&database_url)
.await
.expect("connect to the test database");
// One statement per thousand rows: each insert takes a write-fence
// advisory lock per community, held to the end of its transaction.
for start in (1..=count).step_by(1000) {
sqlx::query(
"WITH c AS (
INSERT INTO communities (id, host)
SELECT gen_random_uuid(), 'c' || n || '.communities.example.com'
FROM generate_series($1, $2) AS n
RETURNING id
)
INSERT INTO community_nip_fi_issuers (community_id, issuer)
SELECT id, 'https://idp.example.com' FROM c",
)
.bind(i64::from(start))
.bind(i64::from(count.min(start + 999)))
.execute(&mut connection)
.await
.expect("seed NIP-FI bindings");
}
});
}
fn assert_failed_before_listeners(
exited: bool,
output: &Output,
health_port: u16,
failure: &str,
) {
let logs = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
exited && !output.status.success(),
"a failed startup binding load must be startup-fatal: {logs}"
);
assert!(
logs.contains(&format!("startup binding snapshot load failed: {failure}")),
"the failure must name the snapshot load and why it failed: {logs}"
);
assert!(
!logs.contains("Health probe listener started"),
"no listener may bind before the snapshot loads: {logs}"
);
assert!(
TcpListener::bind(("0.0.0.0", health_port)).is_ok(),
"the health port must never have been bound"
);
}
/// The database source must never let a pod serve without a snapshot: a
/// load that fails exits non-zero before any listener binds, so the
/// orchestrator restarts the pod instead of routing to it. Two bindings
/// over a one-row cap make the load itself fail after every earlier
/// startup step has read the same table successfully.
#[test]
#[ignore = "requires PostgreSQL"]
fn database_source_failed_snapshot_stops_boot_before_listeners_bind() {
seed_bindings(2);
let (process, health_port) =
spawn_database_source(&[("BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS", "1")]);
let (exited, output) = wait_for_exit(process, CHILD_TIMEOUT);
assert_failed_before_listeners(exited, &output, health_port, "OverCap");
}
/// A load that cannot finish in time must hit the refresh timeout and fail
/// the same way, rather than leaving the pod waiting unbound. A
/// millisecond budget cannot cover a scan of this many rows.
#[test]
#[ignore = "requires PostgreSQL"]
fn database_source_slow_snapshot_times_out_before_listeners_bind() {
seed_bindings(20_000);
let (process, health_port) =
spawn_database_source(&[("BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS", "1")]);
let (exited, output) = wait_for_exit(process, CHILD_TIMEOUT);
assert_failed_before_listeners(exited, &output, health_port, "Timeout");
}
/// With a loadable snapshot the same configuration boots and readiness
/// turns ready, so the two failure tests above fail for the load alone. One
/// seeded binding proves the snapshot reads rows, not just an empty table.
#[test]
#[ignore = "requires PostgreSQL"]
fn database_source_snapshot_load_lets_readiness_turn_ready() {
seed_bindings(1);
let (mut process, health_port) = spawn_database_source(&[]);
let ready = wait_for_readiness(&mut process, health_port);
let output = process.terminate();
let logs = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
ready.starts_with("HTTP/1.1 200"),
"readiness never turned ready: {ready}: {logs}"
);
assert!(
logs.contains("NIP-FI: binding snapshot loaded"),
"readiness must follow the snapshot load: {logs}"
);
}
}