Files
buzz/crates/buzz-admin/tests/postgres_storage_snapshot.rs
ravarora2 a929532a04 fix(admin): allow cold storage worker DB startup (#7770)
## Summary

The standalone S3 accounting worker can exit before its scan begins
because database startup inherits the relay’s three-second connection
budget. A cold DNS probe took about five seconds.

Give `buzz-admin storage-snapshot` its own pool: one connection, no
minimum idle connections, and a 30-second acquisition budget. Retry
startup timeouts and transport failures at most twice, after two and
five seconds. Total startup wait is bounded to 97 seconds.
Configuration, authentication, TLS, and protocol errors fail
immediately.

Log each startup attempt’s elapsed time and error category without
connection credentials. Relay defaults stay unchanged. Retries stop
before lock acquisition; the same lock-owning database session still
publishes the completed snapshot.

### Related issue

Follow-up to #7543. No duplicate startup fix found.

### Testing

Archived-command check: built a nextest archive, temporarily moved
`target/debug/buzz-admin` out of the checkout, and ran
`scripts/postgres-test-run.sh --archive-file <archive>`. Both tests
failed before the path fix and passed afterward. The tests use nextest’s
relocated executable path, with the original Cargo path retained for
local runs.

Manually restored the old startup call and ran
`scripts/postgres-test-run.sh -p buzz-admin --test
postgres_storage_snapshot -E 'test(storage_snapshot_retries_then)'`. It
failed before reaching S3; restoring the fix passed. This confirms the
test exercises the actual worker command.

Local `just ci` passed all stages before mobile tests, which stopped at
SDK discovery in the `objective_c` native hook. The default Xcode tools
report an unaccepted license. Two existing PostgreSQL tests hit
connection timeouts in the parallel run and passed individually.

After merge, build a worker image and repeat the cold-pod staging smoke
test through snapshot publication before changing relay metrics mode.

Generated with Codex

---------

Signed-off-by: Ravneet Arora <rarora@squareup.com>
2026-09-22 13:32:20 -04:00

293 lines
11 KiB
Rust

//! Run the real command through delayed/broken PostgreSQL connections.
//! The PostgreSQL nextest wrapper supplies a separate database per test.
use std::process::{ExitStatus, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use serde_json::{json, Value};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::process::Command;
use tokio::task::{JoinHandle, JoinSet};
use tokio::time::{sleep, timeout};
use url::Url;
struct Server {
address: std::net::SocketAddr,
requests: Arc<AtomicUsize>,
task: JoinHandle<()>,
}
impl Drop for Server {
fn drop(&mut self) {
self.task.abort();
}
}
async fn postgres_proxy(database_url: &str, reject: usize, delay: Duration) -> Server {
let target = Url::parse(database_url).expect("test database URL");
let target = (
target.host_str().expect("database host").to_owned(),
target.port().unwrap_or(5432),
);
let listener = TcpListener::bind("127.0.0.1:0").await.expect("proxy");
let address = listener.local_addr().expect("proxy address");
let requests = Arc::new(AtomicUsize::new(0));
let connections = Arc::clone(&requests);
let task = tokio::spawn(async move {
let mut sessions = JoinSet::new();
loop {
tokio::select! {
accepted = listener.accept() => {
let (mut client, _) = accepted.expect("accept database connection");
let index = connections.fetch_add(1, Ordering::SeqCst);
let target = target.clone();
sessions.spawn(async move {
if index < reject {
// Wait for the client handshake before dropping the
// connection, so this is an I/O failure, not a refused
// connection SQLx would retry inside the same attempt.
let mut header = [0; 4];
let _ = client.read_exact(&mut header).await;
return;
}
sleep(delay).await;
let mut upstream = TcpStream::connect(target).await.expect("real Postgres");
let _ = tokio::io::copy_bidirectional(&mut client, &mut upstream).await;
});
}
result = sessions.join_next(), if !sessions.is_empty() => {
result.expect("session").expect("proxy session completed");
}
}
}
});
Server {
address,
requests,
task,
}
}
async fn s3_listing() -> Server {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("S3 listener");
let address = listener.local_addr().expect("S3 address");
let requests = Arc::new(AtomicUsize::new(0));
let observed = Arc::clone(&requests);
let task = tokio::spawn(async move {
let sha = "a".repeat(64);
let contents: String = [
(format!("{sha}.bin"), 100),
(format!("{sha}.thumb.jpg"), 10),
(
format!("_meta/00000000-0000-0000-0000-000000000001/{sha}.json"),
30,
),
]
.into_iter()
.map(|(key, size)| {
format!(
"<Contents><Key>{key}</Key><Size>{size}</Size>\
<LastModified>2026-09-20T00:00:00.000Z</LastModified>\
<ETag>\"test\"</ETag><StorageClass>STANDARD</StorageClass></Contents>"
)
})
.collect();
let body = format!(
"<ListBucketResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
<Name>test-bucket</Name><Prefix></Prefix><MaxKeys>1000</MaxKeys>\
<IsTruncated>false</IsTruncated>{contents}\
</ListBucketResult>"
);
loop {
let (mut stream, _) = listener.accept().await.expect("S3 connection");
observed.fetch_add(1, Ordering::SeqCst);
let mut request = Vec::new();
let mut chunk = [0; 1024];
while !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") {
let read = stream.read(&mut chunk).await.expect("S3 request");
assert!(read > 0 && request.len() + read <= 16 * 1024);
request.extend_from_slice(&chunk[..read]);
}
assert!(String::from_utf8_lossy(&request).contains("list-type=2"));
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream
.write_all(response.as_bytes())
.await
.expect("S3 response");
}
});
Server {
address,
requests,
task,
}
}
async fn database() -> (String, PgPool) {
let url = std::env::var("BUZZ_TEST_DATABASE_URL")
.expect("run through scripts/postgres-test-run.sh for an isolated database");
let pool = PgPoolOptions::new()
.max_connections(1)
.connect(&url)
.await
.expect("test DB");
sqlx::query(
"INSERT INTO storage_accounting_snapshots \
(singleton, snapshot, completed_at, duration_ms, max_objects, code_sha) \
VALUES (TRUE, $1, NOW(), 1, 100, 'before')",
)
.bind(json!({"previous": "complete"}))
.execute(&pool)
.await
.expect("seed last-good snapshot");
(url, pool)
}
async fn capture(reader: impl AsyncRead + Unpin) -> std::io::Result<String> {
let mut bytes = Vec::new();
reader.take(64 * 1024).read_to_end(&mut bytes).await?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
struct WorkerOutput {
status: ExitStatus,
stdout: String,
stderr: String,
}
async fn run_worker(database_url: &str, proxy: &Server, s3: &Server) -> WorkerOutput {
let mut url = Url::parse(database_url).expect("database URL");
url.set_host(Some("127.0.0.1")).expect("proxy host");
url.set_port(Some(proxy.address.port()))
.expect("proxy port");
// The proxy deliberately interrupts the PostgreSQL handshake. Keep TLS
// out of this local transport test so it exercises sqlx::Error::Io.
url.query_pairs_mut().append_pair("sslmode", "disable");
// nextest relocates executables when extracting a test archive. Keep the
// compile-time Cargo path only as the fallback for local cargo test runs.
let worker_binary = std::env::var_os("NEXTEST_BIN_EXE_buzz_admin")
.unwrap_or_else(|| env!("CARGO_BIN_EXE_buzz-admin").into());
let mut child = Command::new(worker_binary)
.args(["storage-snapshot", "--max-objects", "100"])
.env_clear()
.env("DATABASE_URL", url.as_str())
.env("BUZZ_S3_ENDPOINT", format!("http://{}", s3.address))
.env("BUZZ_S3_BUCKET", "test-bucket")
.env("BUZZ_S3_REGION", "us-east-1")
.env("BUZZ_S3_ACCESS_KEY", "test-access-key")
.env("BUZZ_S3_SECRET_KEY", "test-secret-key")
.env("BUZZ_STORAGE_SNAPSHOT_CODE_SHA", "startup-test")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.expect("start real worker");
let stdout = child.stdout.take().expect("stdout");
let stderr = child.stderr.take().expect("stderr");
let completed = timeout(Duration::from_secs(25), async {
tokio::try_join!(child.wait(), capture(stdout), capture(stderr))
})
.await;
let (status, stdout, stderr) = match completed {
Ok(output) => output.expect("worker output"),
Err(_) => {
child.kill().await.expect("kill hung worker");
panic!("worker did not finish within 25 seconds");
}
};
WorkerOutput {
status,
stdout,
stderr,
}
}
fn events(output: &str) -> Vec<Value> {
output
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect()
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn storage_snapshot_retries_then_connects_after_the_old_three_second_budget() {
let (url, pool) = database().await;
let proxy = postgres_proxy(&url, 1, Duration::from_secs(5)).await;
let s3 = s3_listing().await;
let output = run_worker(&url, &proxy, &s3).await;
assert!(
output.status.success(),
"{}\n{}",
output.stdout,
output.stderr
);
assert_eq!(
proxy.requests.load(Ordering::SeqCst),
2,
"one rejected connection, one lock-owning session, no spare pool connections"
);
assert_eq!(s3.requests.load(Ordering::SeqCst), 1);
let startup = events(&output.stderr);
assert!(startup.iter().any(
|event| event["event"] == "storage_snapshot_db_connect_failed"
&& event["attempt"] == 1
&& event["retry_in_ms"] == 2000
));
assert!(startup.iter().any(
|event| event["event"] == "storage_snapshot_db_connect_completed"
&& event["attempt"] == 2
&& event["attempt_elapsed_ms"].as_u64().unwrap() >= 5000
));
let scan = events(&output.stdout);
assert_eq!(scan.first().unwrap()["event"], "storage_snapshot_started");
assert_eq!(scan.last().unwrap()["event"], "storage_snapshot_completed");
let (snapshot, revision): (Value, String) = sqlx::query_as(
"SELECT snapshot, code_sha FROM storage_accounting_snapshots WHERE singleton = TRUE",
)
.fetch_one(&pool)
.await
.expect("saved snapshot");
assert_eq!(revision, "startup-test");
assert_eq!(snapshot["physical_objects"], 3);
assert_eq!(snapshot["physical_bytes"], 140);
assert_eq!(snapshot["logical_bytes"], 110);
assert_eq!(snapshot["logical_objects"], 1);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn storage_snapshot_exhaustion_never_lists_s3_or_replaces_the_last_good_snapshot() {
let (url, pool) = database().await;
let proxy = postgres_proxy(&url, usize::MAX, Duration::ZERO).await;
let s3 = s3_listing().await;
let output = run_worker(&url, &proxy, &s3).await;
assert_eq!(output.status.code(), Some(5), "{}", output.stderr);
assert_eq!(proxy.requests.load(Ordering::SeqCst), 3);
assert_eq!(s3.requests.load(Ordering::SeqCst), 0);
assert!(!output.stdout.contains("storage_snapshot_started"));
assert!(!output.stdout.contains("storage_snapshot_completed"));
let failures: Vec<_> = events(&output.stderr)
.into_iter()
.filter(|event| event["event"] == "storage_snapshot_db_connect_failed")
.collect();
assert_eq!(failures.len(), 3);
assert!(failures.last().unwrap()["retry_in_ms"].is_null());
let (snapshot, revision): (Value, String) = sqlx::query_as(
"SELECT snapshot, code_sha FROM storage_accounting_snapshots WHERE singleton = TRUE",
)
.fetch_one(&pool)
.await
.expect("last good snapshot");
assert_eq!(snapshot, json!({"previous": "complete"}));
assert_eq!(revision, "before");
}