Files
buzz/crates/buzz-deletion/src/lib.rs
Luke Tornquist bd73490418 ci: run PostgreSQL tests in isolated lane (#6730)
## Why

PR #6660 introduced four PostgreSQL-backed persistence tests and a
focused CI selector, but an exact test-name list does not automatically
cover future database tests. Broad ignored-test execution also exposed
shared-schema races and ambiguity between desired-state and
migration-applied schema expectations.

## What

- Establish a discoverable convention: PostgreSQL unit modules use
`postgres_tests`, PostgreSQL integration binaries use a `postgres_`
prefix, and unrelated external-infrastructure tests use an
`external_infra_` prefix.
- Add a dedicated nextest PostgreSQL profile and archive-backed CI job
covering the relevant crates without enumerating test names.
- Create a run-scoped desired-state source database and a unique
PostgreSQL database per test process for parallel-safe isolation, with
cleanup on success, failure, or interruption.
- Route destructive migration and migration-parity tests to clean
`template0` databases while desired-state tests clone the desired-state
database.
- Document discovery, schema modes, required role privileges, and a
bounded portable local runner.
- Keep the existing infrastructure-free unit-test jobs unchanged.

This is intentionally limited to test and CI harness behavior. It does
not move production database code, change persistence semantics, or
implement issue #20's broader shared test-utility refactor.

## Risk

Low production risk because all changes are confined to tests, CI
configuration, documentation, and test harness scripts.

Remaining operational risks:

- Cleanup retries `dropdb --force` five times across roughly four
seconds. Exhaustion warns with the database name but deliberately does
not mask the test result; individual PostgreSQL diagnostics are
suppressed.
- The seven-package boundary is duplicated between the runner and CI
archive and must remain synchronized if PostgreSQL tests move to a new
crate.
- Portable SHA fallback branches were exercised on Linux; no macOS Blox
workstation was needed for this Linux CI artifact.
- GitHub's unchanged generic Unit Tests job currently fails while
cold-linking `buzz-voice` because `sherpa-onnx-c-api` is absent. The
branch changes no voice/build/toolchain inputs; the exact parent
previously passed that job, and Blox passes the same infrastructure-free
suite (1,450/1,450). A prior-head one-job retry and the final-head run
both reproduced the hosted-runner failure.

## Verification

Author Blox workstation `2020088`:

- `cargo fmt --all -- --check`
- `just clippy`
- `just test-unit`: 1,450 passed; PostgreSQL tests remain skipped in
fast jobs
- PR #6660 focused tests: 4/4 passed through the final runner
- Full lane: 282/282 passed in 10.087s with a non-superuser role limited
to `CREATEDB`, `CREATEROLE`, and `pg_read_all_stats`
  - 272 desired-state tests
  - 10 migration-applied tests
  - 6 explicitly filtered external-infrastructure tests
- Cleanup fault injection: a deliberately underprivileged 282-test run
produced the expected 277 passes and 5 failures, and the post-run
catalog audit found zero lane databases after retry cleanup. The
restored successful run also left zero lane databases.

GitHub exact final head `92c231e48f299ea2af23817763f5ae80ac013d68`:

- [PostgreSQL
Tests](https://github.com/block/buzz/actions/runs/32789030203/job/97630827823):
282/282 passed across 10 binaries in 98.886s; 1,240 skipped, including 6
via the profile filter
- The shared relay/PostgreSQL archive predecessor completed successfully
- [Unit
Tests](https://github.com/block/buzz/actions/runs/32789030203/job/97626930420):
unrelated `sherpa-onnx-c-api` native-link failure described above
- Independent exact-head review on separate Blox workstation `2022762`:
no substantive findings after all initial findings were addressed. The
reviewer independently verified cleanup retry behavior, exit-status
preservation, shell portability, syntax, and a clean exact-head
worktree.

## References

- Stacked on #6660 at exact head
`561de54be4d9c2b622b7c2aa5b61bc3068f47e2a`
- https://github.com/TheSentinel454/buzz/issues/20
-
https://github.com/TheSentinel454/buzz/issues/20#issuecomment-5398526792

Generated with Codex.

## Update — August 24, 2026 review follow-up

- Centralized the repeated PostgreSQL test URL resolution in
crate-local, test-only helpers without introducing the broader shared
utility refactor from issue #20.
- Restored descriptive hybrid/Redis test function names. Structural
`external_infra_*_tests` modules now own exclusion, and the nextest
filter only recognizes module path segments.
- Added a three-second source guard that scans every Rust file and fails
CI when an ignored PostgreSQL test would be omitted or an
external-infrastructure test would be included. Fixture tests cover
accepted modules/binaries and both failure modes.
- Removed the two implementation-plan documents.

Performance profiling on Blox workstation `2027352` (same prebuilt
archive and cargo-nextest 0.9.143 for every comparison):

- Current per-test database model, 8 workers: 10.15–10.40s across three
runs. The 283 `createdb` calls and 284 `dropdb` calls consumed
28.85–29.46 aggregate seconds, about 39–40% of aggregate test-process
duration.
- Reusing one database per worker without cleanup: 6.55–6.84s, about 35%
faster, but all three runs failed because global matcher-queue tests
inherited a quiesced community from an earlier test; the failing test
varied with scheduling.
- Reusing workers with `TRUNCATE … RESTART IDENTITY CASCADE`: all 282
tests passed, but total time regressed to 10.47–10.67s. The 265 truncate
calls consumed 24.34–25.45 aggregate seconds, so truncation merely
replaced most clone/drop cost.
- Raising concurrency to 16 workers reduced one isolated-database run to
8.79s, but 24 workers exposed a cluster-global `pg_stat_activity` race.
The profile remains at the proven-safe 8 workers.

Decision: retain unique per-test databases. Harness cleanup uses `dropdb
--if-exists --force`; it drops the database rather than truncating
tables or deleting rows.

Verification at local branch head
`60086c1f6ee34a193c342255a5e8e6293b60e988`:

- Discovery inventory: exactly 282 intended tests; all 6 hybrid/Redis
tests present and structurally excluded.
- PR #6660 focused persistence tests: 4/4 passed.
- Full PostgreSQL lane: 282/282 passed three times; 1,240 skipped each
run; wall time 9.72–9.96s without profiling shims.
- Non-ignored tests with a desired-state database: 1,234/1,234 passed;
one mesh-demo timeout from the first run passed immediately in
isolation.
- `cargo fmt --all -- --check` and all-target/all-feature clippy for
`buzz-db`, `buzz-deletion`, and `buzz-relay` passed.

Final independent re-review at exact head
`42097c0136ab3fa8f0efe5720ae829c04575b9b6` on separate Blox workstation
`2028455`: no substantive residual findings. The reviewer independently
exercised ordinary strings, zero/one/three-hash raw strings, line/block
comments, and string-contained lookalikes, then reran the full 353-file
scan, shell syntax, Python AST parsing, and diff check.

Independent review found that the source guard's initial regular
expression could miss raw-string ignore reasons and treat a commented
attribute as real. Final head `42097c0136ab3fa8f0efe5720ae829c04575b9b6`
parses valid ordinary/raw Rust string literals only at attributes found
in comment-sanitized source; regression fixtures cover both cases. The
fixture suite, Python compilation, shell syntax, diff check, and full
353-file repository scan pass on Blox.

## Update — August 25, 2026 restack

PR #6660 merged, so this follow-up was rebased from its former exact
parent onto current `origin/main`
(`8d2d0ff5ad42733e9949442c4b6358d0ba87f9a8`). The final candidate head
is `05dcc2ab28948e3ab79bb44839a69f2ba44648a2`; the PR no longer carries
#6660's pre-squash history.

Fresh exact-head verification on Blox workstation `2028572`:

- Discovery guard passed across 353 Rust files; inventory remained
exactly 282 intended PostgreSQL tests.
- PR #6660 focused persistence tests: 4/4 passed.
- Full PostgreSQL lane: 282/282 passed three times; 1,243 skipped each
run; wall time 9.70–9.87s.
- `cargo fmt --all -- --check` and all-target/all-feature clippy for
`buzz-db`, `buzz-deletion`, and `buzz-relay` passed.

## Update — August 25, 2026 final hosted verification

- [GitHub PostgreSQL
Tests](https://github.com/block/buzz/actions/runs/32801679226/job/97667835080):
282/282 passed in 53.787s; 1,243 skipped.
- The final guard scanned all 353 Rust files successfully before the
hosted lane ran.
- The shared relay/archive prerequisite and infrastructure-free Unit
Tests job both passed at exact final head
`42097c0136ab3fa8f0efe5720ae829c04575b9b6`.

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Signed-off-by: tornquist <tornquist@squareup.com>
2026-09-01 12:07:28 -04:00

2413 lines
83 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![deny(unsafe_code)]
#![warn(missing_docs)]
//! Shared durable whole-community deletion engine and store adapters.
use std::future::Future;
#[cfg(test)]
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use buzz_db::deletion::{
ClaimedDeletion, DeletionRequest, DeletionStage, DeletionStore, FrozenInventory,
KeyStreamDigest, LeaseToken, PrefixManifest, StorageManifest, StorageManifestEntry,
DEFAULT_LEASE_DURATION,
};
use buzz_db::{Db, DbConfig};
use buzz_media::{
is_tenant_owned_key, tenant_prefixes, BulkDeleteOutcome, MediaStorage, ObjectVersionKind,
ObjectVersionRef,
};
use clap::Subcommand;
use serde::Serialize;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
/// Fleet-wide object cap for one observational taxonomy sweep.
const DEFAULT_SWEEP_OBJECT_CAP: u64 = 10_000_000;
/// Keys per frozen side-table chunk (one `DeleteObjects`-sized unit × 10).
const DEFAULT_MANIFEST_CHUNK_KEYS: usize = 10_000;
/// Unknown keys retained verbatim on a sweep record for diagnosis.
const SWEEP_UNKNOWN_KEY_SAMPLE: usize = 100;
/// One S3 LIST page.
const LIST_PAGE_SIZE: usize = 1000;
const RETRY_DELAY: Duration = Duration::from_secs(30);
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10);
fn heartbeat_interval() -> Duration {
HEARTBEAT_INTERVAL
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("deletion execution lease heartbeat failed")]
struct DeletionLeaseLost;
#[derive(Debug, Clone, thiserror::Error)]
#[error("{message}")]
struct ServingWriteLeaseLost {
message: String,
}
/// Return the shared durable deletion store for relay and operator paths.
pub fn store(db: &Db) -> DeletionStore {
db.deletion_store()
}
/// Durable, heartbeated lease for a serving-path external side effect.
pub struct ServingWriteGuard {
store: DeletionStore,
lease: buzz_db::deletion::ServingWriteLease,
cancel: CancellationToken,
lost: CancellationToken,
finished: bool,
}
impl ServingWriteGuard {
/// Verify this side-effect lease is still current before an irreversible call.
pub async fn verify(&self) -> Result<()> {
if self.lost.is_cancelled() {
return Err(ServingWriteLeaseLost {
message: "serving write lease heartbeat was lost".to_string(),
}
.into());
}
self.store
.verify_serving_write_lease(&self.lease)
.await
.map_err(|error| ServingWriteLeaseLost {
message: error.to_string(),
})?;
Ok(())
}
/// Run an external side effect while observing lease-heartbeat loss.
///
/// Dropping the operation future on lease loss prevents a stale caller from
/// continuing network I/O after its durable exclusion proof disappears.
pub async fn protect<F, T>(&self, operation: F) -> Result<T>
where
F: std::future::Future<Output = T>,
{
self.verify().await?;
let output = tokio::select! {
biased;
output = operation => output,
_ = self.lost.cancelled() => {
return Err(ServingWriteLeaseLost {
message: "serving write lease heartbeat was lost".to_string(),
}
.into())
}
};
self.verify().await?;
Ok(output)
}
/// Whether an error represents loss of a durable serving-write lease.
pub fn is_lease_lost(error: &anyhow::Error) -> bool {
error.downcast_ref::<ServingWriteLeaseLost>().is_some()
}
/// Whether a serving-write acquisition failed because the tenant is fenced.
pub fn acquisition_is_fenced(error: &anyhow::Error) -> bool {
matches!(
error.downcast_ref::<buzz_db::DbError>(),
Some(buzz_db::DbError::AccessDenied(_))
)
}
/// Signal fired if the background lease heartbeat fails.
pub fn lost(&self) -> CancellationToken {
self.lost.clone()
}
/// The durable lease token presented to a final database mutation.
pub fn lease(&self) -> &buzz_db::deletion::ServingWriteLease {
&self.lease
}
/// Release the lease after the side effect completes.
pub async fn finish(mut self) -> Result<()> {
self.cancel.cancel();
let released = self.store.release_serving_write_lease(&self.lease).await?;
self.finished = true;
if !released {
return Err(ServingWriteLeaseLost {
message: "serving write lease was already stale or released".to_string(),
}
.into());
}
Ok(())
}
}
impl Drop for ServingWriteGuard {
fn drop(&mut self) {
self.cancel.cancel();
if self.finished {
return;
}
let store = self.store.clone();
let lease = self.lease.clone();
tokio::spawn(async move {
let _ = store.release_serving_write_lease(&lease).await;
});
}
}
/// Acquire a serving-side external-effect lease without holding a pool connection.
///
/// A separate short database lease per effect is intentional: it is the only
/// durable proof that deletion can drain S3/Redis/push work across replicas.
/// PostgreSQL lease-table churn is reaped and exported by the relay pool-metrics
/// task; operators should watch the deletion lease gauges documented by Helm.
pub async fn acquire_serving_write(
db: &Db,
community: buzz_core::CommunityId,
operation: &str,
) -> Result<ServingWriteGuard> {
acquire_serving_write_with_heartbeat(db, community, operation, heartbeat_interval()).await
}
async fn acquire_serving_write_with_heartbeat(
db: &Db,
community: buzz_core::CommunityId,
operation: &str,
heartbeat_period: Duration,
) -> Result<ServingWriteGuard> {
let store = store(db);
let owner = default_executor_id();
let lease = store
.acquire_serving_write_lease(community, operation, &owner, DEFAULT_LEASE_DURATION)
.await?;
let heartbeat_store = store.clone();
let mut heartbeat_lease = lease.clone();
let cancel = CancellationToken::new();
let heartbeat_cancel = cancel.clone();
let lost = CancellationToken::new();
let heartbeat_lost = lost.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(heartbeat_period);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
interval.tick().await;
loop {
tokio::select! {
_ = heartbeat_cancel.cancelled() => return,
_ = interval.tick() => {
if heartbeat_store
.renew_serving_write_lease(
&mut heartbeat_lease,
DEFAULT_LEASE_DURATION,
)
.await
.is_err()
{
heartbeat_lost.cancel();
return;
}
}
}
}
});
Ok(ServingWriteGuard {
store,
lease,
cancel,
lost,
finished: false,
})
}
/// CLI-only whole-community deletion commands.
#[derive(Subcommand)]
pub enum Command {
/// Persist a deletion request and freeze its initial cross-store inventory.
Submit {
/// Canonical community host. Defaults to RELAY_URL's authority.
#[arg(long)]
host: Option<String>,
/// Operator identity recorded on the request.
#[arg(long)]
requested_by: String,
/// Optional reason for the request.
#[arg(long)]
reason: Option<String>,
},
/// List deletion requests as JSON.
List {
/// Maximum records.
#[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u16).range(1..=1000))]
limit: u16,
},
/// Inspect one request, including approval/checkpoints/errors.
Inspect {
/// Deletion request UUID.
id: Uuid,
},
/// Explicitly approve the exact frozen inventory digest.
Approve {
/// Deletion request UUID.
id: Uuid,
/// Approving operator identity.
#[arg(long)]
approved_by: String,
/// Optional approval note.
#[arg(long)]
note: Option<String>,
},
/// Terminally cancel before irreversible object deletion begins.
Abort {
/// Deletion request UUID.
id: Uuid,
/// Aborting operator identity.
#[arg(long)]
aborted_by: String,
/// Reason recorded in immutable audit evidence.
#[arg(long)]
reason: String,
},
/// Resume a blocked request after remediating its recorded failure.
Unblock {
/// Deletion request UUID.
id: Uuid,
/// Operator identity recorded in the recovery checkpoint.
#[arg(long)]
unblocked_by: String,
/// Remediation or change reference recorded in the checkpoint.
#[arg(long)]
reason: String,
},
/// Claim and run one request until terminal/blocked.
Run {
/// Deletion request UUID.
id: Uuid,
/// Executor identity (defaults to hostname/pid).
#[arg(long)]
executor_id: Option<String>,
},
/// Drain the currently runnable deletion queue, then exit.
Drain {
/// Executor identity (defaults to hostname/pid).
#[arg(long)]
executor_id: Option<String>,
},
/// Sweep the whole bucket's key taxonomy and record observational evidence.
///
/// This is independent of community deletion. It reports unknown writer
/// shapes but never gates submission or destructive progress.
Sweep,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LoopMode {
Run,
Drain,
}
impl LoopMode {
const fn as_str(self) -> &'static str {
match self {
Self::Run => "run",
Self::Drain => "drain",
}
}
}
#[derive(Clone)]
struct Services {
store: DeletionStore,
media: Arc<MediaStorage>,
redis: deadpool_redis::Pool,
}
#[derive(Debug, thiserror::Error)]
enum EngineError {
#[error("permanent deletion safety failure: {0}")]
Permanent(String),
#[error("transient deletion dependency failure: {0}")]
Transient(String),
}
#[derive(Debug, thiserror::Error)]
#[error(transparent)]
struct PermanentSource(#[from] anyhow::Error);
fn permanent(message: impl Into<String>) -> anyhow::Error {
EngineError::Permanent(message.into()).into()
}
fn permanent_source(error: impl Into<anyhow::Error>) -> anyhow::Error {
PermanentSource(error.into()).into()
}
fn transient(message: impl Into<String>) -> anyhow::Error {
EngineError::Transient(message.into()).into()
}
fn is_permanent_error(error: &anyhow::Error) -> bool {
error.chain().any(|cause| {
cause.is::<PermanentSource>()
|| matches!(
cause.downcast_ref::<buzz_db::DbError>(),
Some(buzz_db::DbError::DeletionSafety(_))
)
|| cause
.downcast_ref::<EngineError>()
.is_some_and(|error| matches!(error, EngineError::Permanent(_)))
})
}
#[derive(Debug, Serialize)]
struct RunOutput {
request_id: Uuid,
stage: DeletionStage,
retry_count: i32,
last_error: Option<String>,
next_attempt_at: chrono::DateTime<chrono::Utc>,
blocked_reason: Option<String>,
}
/// Execute one nested deletion command.
pub async fn run(command: Command) -> Result<i32> {
match command {
Command::List { limit } => {
let store = connect_store().await?;
print_json(&store.list(i64::from(limit)).await?)?;
Ok(0)
}
Command::Inspect { id } => {
let store = connect_store().await?;
print_json(&store.inspect(id).await?)?;
Ok(0)
}
Command::Approve {
id,
approved_by,
note,
} => {
let store = connect_store().await?;
print_json(&store.approve(id, &approved_by, note.as_deref()).await?)?;
Ok(0)
}
Command::Abort {
id,
aborted_by,
reason,
} => {
let store = connect_store().await?;
print_json(&store.abort(id, &aborted_by, &reason).await?)?;
Ok(0)
}
Command::Unblock {
id,
unblocked_by,
reason,
} => {
let store = connect_store().await?;
print_json(&store.unblock(id, &unblocked_by, &reason).await?)?;
Ok(0)
}
Command::Run { id, executor_id } => {
let store = connect_store().await?;
let services = match connect_services_with_store(store.clone()).await {
Ok(services) => services,
Err(error) => {
let message = format!("{error:#}");
store
.block_preclaim_setup(id, "pre_claim:service_setup", &message)
.await
.context("record pre-claim service setup failure")?;
return Err(error);
}
};
run_loop(
services,
LoopMode::Run,
Some(id),
executor_id.unwrap_or_else(default_executor_id),
)
.await
}
command => run_with_services(command, connect_services().await?).await,
}
}
async fn run_with_services(command: Command, services: Services) -> Result<i32> {
match command {
Command::Submit {
host,
requested_by,
reason,
} => {
let relay_url = std::env::var("RELAY_URL").ok();
let host = resolve_submit_host(host.as_deref(), relay_url.as_deref())?;
let request = services
.store
.submit(&host, &requested_by, reason.as_deref())
.await?;
let inventory = build_inventory(&services, &request).await?;
let request = services
.store
.freeze_inventory(request.id, &inventory)
.await?;
print_json(&request)?;
Ok(0)
}
Command::Run { id, executor_id } => {
run_loop(
services,
LoopMode::Run,
Some(id),
executor_id.unwrap_or_else(default_executor_id),
)
.await
}
Command::Drain { executor_id } => {
run_loop(
services,
LoopMode::Drain,
None,
executor_id.unwrap_or_else(default_executor_id),
)
.await
}
Command::Sweep => {
let started_at = chrono::Utc::now();
let cap = sweep_object_cap();
let media = Arc::clone(&services.media);
let outcome =
buzz_media::sweep_bucket_taxonomy(cap, SWEEP_UNKNOWN_KEY_SAMPLE, move |token| {
let media = Arc::clone(&media);
async move { media.list_page(token, LIST_PAGE_SIZE).await }
})
.await?;
let sweep = services
.store
.record_taxonomy_sweep(
started_at,
outcome.listed_objects,
outcome.unknown_object_count,
&outcome.unknown_key_sample,
cap,
)
.await?;
print_json(&sweep)?;
Ok(i32::from(sweep.unknown_object_count > 0))
}
Command::List { .. }
| Command::Inspect { .. }
| Command::Approve { .. }
| Command::Abort { .. }
| Command::Unblock { .. } => {
anyhow::bail!("database-only command reached full-service dispatcher")
}
}
}
fn resolve_submit_host(host: Option<&str>, relay_url: Option<&str>) -> Result<String> {
if let Some(host) = host {
let host = host.trim();
if host.is_empty() {
anyhow::bail!("--host must not be empty");
}
return Ok(host.to_owned());
}
let relay_url = relay_url
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
anyhow::anyhow!("cannot derive community host; pass --host or set RELAY_URL")
})?;
let host = buzz_core::tenant::relay_url_authority(relay_url);
if host.is_empty() {
anyhow::bail!(
"cannot derive community host from RELAY_URL; pass --host or set a valid RELAY_URL"
);
}
Ok(host)
}
async fn connect_store() -> Result<DeletionStore> {
let database_url = required_env("DATABASE_URL")?;
let db = Db::new(
&DbConfig {
database_url,
max_connections: env_parse("BUZZ_DB_POOL_SIZE", 20),
..DbConfig::default()
}
.with_session_timeouts_from_env(),
)
.await?;
Ok(store(&db))
}
fn resolve_s3_region(buzz_region: Option<String>, aws_region: Option<String>) -> String {
buzz_region
.and_then(nonempty_s3_region)
.or_else(|| aws_region.and_then(nonempty_s3_region))
.unwrap_or_else(|| "us-east-1".to_string())
}
fn nonempty_s3_region(region: String) -> Option<String> {
let region = region.trim();
(!region.is_empty()).then(|| region.to_string())
}
async fn connect_services() -> Result<Services> {
let store = connect_store().await?;
connect_services_with_store(store).await
}
async fn connect_services_with_store(store: DeletionStore) -> Result<Services> {
let (s3_access_key, s3_secret_key) = s3_key_pair_from_env();
let media_config = buzz_media::MediaConfig {
s3_endpoint: required_env("BUZZ_S3_ENDPOINT")?,
s3_access_key,
s3_secret_key,
s3_bucket: required_env("BUZZ_S3_BUCKET")?,
s3_region: s3_region_from_env(),
s3_addressing_style: std::env::var("BUZZ_S3_ADDRESSING_STYLE")
.unwrap_or_else(|_| "path".to_string())
.parse()
.map_err(anyhow::Error::msg)?,
max_image_bytes: 1,
max_gif_bytes: 1,
max_video_bytes: 1,
max_file_bytes: 1,
public_base_url: "http://localhost/media".to_string(),
upload_records_enabled: false,
upload_ip_header: None,
upload_port_header: None,
};
let media = Arc::new(MediaStorage::new(&media_config)?);
let redis_url = required_env("REDIS_URL")?;
let mut redis_config = deadpool_redis::Config::from_url(&redis_url);
redis_config.pool = Some(deadpool_redis::PoolConfig::new(env_parse(
"BUZZ_REDIS_POOL_SIZE",
16,
)));
let redis = redis_config
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.context("create deletion Redis pool")?;
Ok(Services {
store,
media,
redis,
})
}
fn s3_region_from_env() -> String {
resolve_s3_region(
std::env::var("BUZZ_S3_REGION").ok(),
std::env::var("AWS_REGION").ok(),
)
}
fn s3_key_pair_from_env() -> (String, String) {
s3_key_pair_from(|name| std::env::var(name).ok())
}
fn s3_key_pair_from(get_env: impl Fn(&str) -> Option<String>) -> (String, String) {
(
optional_env_from(&get_env, "BUZZ_S3_ACCESS_KEY"),
optional_env_from(&get_env, "BUZZ_S3_SECRET_KEY"),
)
}
fn required_env(name: &str) -> Result<String> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("{name} is required for community deletion"))
}
fn optional_env_from(get_env: impl Fn(&str) -> Option<String>, name: &str) -> String {
get_env(name)
.map(|value| value.trim().to_owned())
.unwrap_or_default()
}
fn env_parse<T>(name: &str, default: T) -> T
where
T: std::str::FromStr,
{
std::env::var(name)
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(default)
}
fn validate_frozen_inventory(request: &DeletionRequest) -> Result<FrozenInventory> {
let frozen: FrozenInventory = serde_json::from_value(
request
.inventory_manifest
.clone()
.ok_or_else(|| permanent("approved request has no frozen inventory"))?,
)
.map_err(permanent_source)?;
let expected_digest = request
.inventory_digest
.as_deref()
.ok_or_else(|| permanent("approved request has no frozen inventory digest"))?;
let actual_digest = hex::encode(frozen.digest().map_err(permanent_source)?);
if actual_digest != expected_digest {
return Err(permanent("approved frozen inventory digest mismatch"));
}
validate_storage_ownership(request, &frozen.storage)?;
Ok(frozen)
}
fn validate_storage_ownership(request: &DeletionRequest, manifest: &StorageManifest) -> Result<()> {
buzz_db::deletion::validate_storage_manifest(manifest)?;
let expected = tenant_prefixes(*request.community_id.as_uuid());
let actual = manifest
.prefixes
.iter()
.map(|prefix| prefix.prefix.as_str())
.collect::<Vec<_>>();
if actual != expected.iter().map(String::as_str).collect::<Vec<_>>() {
return Err(permanent(
"storage manifest prefixes are not the deletion target's tenant prefixes",
));
}
Ok(())
}
async fn build_inventory(
services: &Services,
request: &DeletionRequest,
) -> Result<FrozenInventory> {
let schema = services
.store
.inventory_schema(request.community_id)
.await?;
let storage = enumerate_tenant_prefixes(services, request, None, None).await?;
Ok(FrozenInventory { schema, storage })
}
/// Buffered writer for frozen key chunks during the destructive freeze.
struct ChunkSink<'a> {
token: &'a LeaseToken,
next_chunk_no: i64,
buffered: Vec<String>,
}
async fn flush_chunk(services: &Services, sink: &mut ChunkSink<'_>, prefix: &str) -> Result<()> {
if sink.buffered.is_empty() {
return Ok(());
}
services
.store
.append_manifest_key_chunk(sink.token, sink.next_chunk_no, prefix, &sink.buffered)
.await?;
sink.next_chunk_no += 1;
sink.buffered.clear();
Ok(())
}
fn manifest_kind_name(kind: ObjectVersionKind) -> &'static str {
match kind {
ObjectVersionKind::Object => "object",
ObjectVersionKind::DeleteMarker => "delete_marker",
}
}
fn manifest_chunk_deleted_detail(
prefix: &str,
key_count: usize,
outcome: &buzz_media::BulkDeleteOutcome,
) -> serde_json::Value {
serde_json::json!({
"prefix": prefix,
"keys": key_count,
"deleted": outcome.deleted,
"already_missing": outcome.already_missing,
})
}
/// Enumerate the target's three tenant prefixes into per-prefix summaries.
///
/// Cost is O(tenant objects) regardless of fleet size. Unknown shapes inside
/// one of the owned prefixes fail closed; keys elsewhere in the shared bucket
/// are outside this operation's contract. Memory stays bounded at one listing
/// page plus one buffered chunk — the full key list is never materialized.
/// When `sink` is supplied, keys are also persisted as side-table chunks
/// (never spanning prefixes) for the destructive freeze to bind against these
/// digests.
async fn enumerate_tenant_prefixes(
services: &Services,
request: &DeletionRequest,
heartbeat_lost: Option<&CancellationToken>,
mut sink: Option<&mut ChunkSink<'_>>,
) -> Result<StorageManifest> {
let community = *request.community_id.as_uuid();
let chunk_keys = manifest_chunk_keys();
let mut prefixes = Vec::new();
for prefix in tenant_prefixes(community) {
let mut digest = KeyStreamDigest::new();
let mut total_bytes: u64 = 0;
let mut key_marker = None;
let mut version_id_marker = None;
loop {
if heartbeat_lost.is_some_and(CancellationToken::is_cancelled) {
return Err(DeletionLeaseLost.into());
}
let page = services
.media
.list_prefix_versions_page(
&prefix,
key_marker.take(),
version_id_marker.take(),
LIST_PAGE_SIZE,
)
.await?;
for entry in page.entries {
if !is_tenant_owned_key(community, &entry.key) {
return Err(permanent(format!(
"key under a tenant prefix is outside the exact writer taxonomy: {}",
entry.key
)));
}
let encoded = StorageManifestEntry::new(
entry.key,
entry.version_id,
manifest_kind_name(entry.kind),
)
.encode()?;
digest.fold_unordered(&encoded)?;
total_bytes = total_bytes.saturating_add(entry.size);
if let Some(sink) = sink.as_deref_mut() {
sink.buffered.push(encoded);
if sink.buffered.len() >= chunk_keys {
flush_chunk(services, sink, &prefix).await?;
}
}
}
if !page.is_truncated {
break;
}
let (next_key_marker, next_version_id_marker) = require_truncated_version_markers(
page.next_key_marker,
page.next_version_id_marker,
)?;
key_marker = Some(next_key_marker);
version_id_marker = Some(next_version_id_marker);
}
if let Some(sink) = sink.as_deref_mut() {
flush_chunk(services, sink, &prefix).await?;
}
let (keys_digest, object_count) = digest.finish();
prefixes.push(PrefixManifest {
prefix,
object_count,
total_bytes,
keys_digest,
});
}
let manifest = StorageManifest {
version: 5,
prefixes,
};
buzz_db::deletion::validate_storage_manifest(&manifest)?;
Ok(manifest)
}
fn require_truncated_version_markers(
next_key_marker: Option<String>,
next_version_id_marker: Option<String>,
) -> Result<(String, String)> {
match (next_key_marker, next_version_id_marker) {
(Some(key_marker), Some(version_id_marker)) => Ok((key_marker, version_id_marker)),
(None, Some(_)) => Err(transient(
"truncated tenant version listing page has no key marker",
)),
(Some(_), None) => Err(transient(
"truncated tenant version listing page has no version id marker",
)),
(None, None) => Err(transient(
"truncated tenant version listing page has no key marker or version id marker",
)),
}
}
async fn delete_manifest_chunk_with<F, Fut>(
chunk: &buzz_db::deletion::ManifestKeyChunk,
storage_version: i32,
delete: F,
) -> Result<BulkDeleteOutcome>
where
F: FnOnce(Vec<ObjectVersionRef>) -> Fut,
Fut: Future<Output = Result<BulkDeleteOutcome>>,
{
let versions = object_versions_from_manifest_chunk(chunk, storage_version)?;
delete(versions).await
}
fn object_versions_from_manifest_chunk(
chunk: &buzz_db::deletion::ManifestKeyChunk,
storage_version: i32,
) -> Result<Vec<ObjectVersionRef>> {
if storage_version >= 5 {
chunk
.keys
.iter()
.map(|entry| {
let entry = StorageManifestEntry::decode(entry)?;
Ok(ObjectVersionRef {
key: entry.key,
version_id: entry.version_id,
})
})
.collect()
} else {
Ok(chunk
.keys
.iter()
.map(|key| ObjectVersionRef {
key: key.clone(),
version_id: String::new(),
})
.collect())
}
}
fn manifest_chunk_deleted_checkpoint_detail(
chunk: &buzz_db::deletion::ManifestKeyChunk,
outcome: &BulkDeleteOutcome,
) -> Result<serde_json::Value> {
validate_manifest_chunk_delete_outcome(chunk, outcome)?;
Ok(manifest_chunk_deleted_detail(
&chunk.prefix,
chunk.keys.len(),
outcome,
))
}
fn validate_manifest_chunk_delete_outcome(
chunk: &buzz_db::deletion::ManifestKeyChunk,
outcome: &BulkDeleteOutcome,
) -> Result<()> {
if !outcome.versioned_keys.is_empty() {
return Err(transient(format!(
"bulk delete returned version metadata for {} explicit versions: {}",
outcome.versioned_keys.len(),
outcome.versioned_keys.join(",")
)));
}
if !outcome.failed.is_empty() {
let (key, code, message) = &outcome.failed[0];
return Err(transient(format!(
"bulk delete failed for {} key(s); first: {key}: {code}: {message}",
outcome.failed.len()
)));
}
let acknowledged = outcome.deleted.saturating_add(outcome.already_missing);
if acknowledged != chunk.keys.len() as u64 {
return Err(transient(format!(
"bulk delete acknowledged {acknowledged} of {} keys in chunk {}",
chunk.keys.len(),
chunk.chunk_no
)));
}
Ok(())
}
/// Freeze the post-fence, post-drain destructive enumeration: stream the
/// tenant prefixes into side-table chunks, then bind the chunk stream to the
/// request row's digests atomically.
async fn freeze_destructive_manifest(
services: &Services,
request: &DeletionRequest,
token: &LeaseToken,
heartbeat_lost: &CancellationToken,
) -> Result<StorageManifest> {
validate_frozen_inventory(request)?;
// A prior interrupted freeze may have left partial chunks; they were
// never bound to a committed manifest, so rewrite them from scratch.
services.store.clear_manifest_key_chunks(token).await?;
let mut sink = ChunkSink {
token,
next_chunk_no: 0,
buffered: Vec::new(),
};
let manifest =
enumerate_tenant_prefixes(services, request, Some(heartbeat_lost), Some(&mut sink)).await?;
services
.store
.freeze_destructive_storage_manifest(token, &manifest)
.await?;
Ok(manifest)
}
async fn run_loop(
services: Services,
mode: LoopMode,
request_id: Option<Uuid>,
executor_id: String,
) -> Result<i32> {
let shutdown = shutdown_token();
let mut ran = false;
loop {
if shutdown.is_cancelled() {
services
.store
.stop_executor(None, &executor_id)
.await
.context("record executor drain")?;
return Ok(0);
}
let claim = match request_id {
Some(id) => {
services
.store
.claim_specific(id, &executor_id, DEFAULT_LEASE_DURATION)
.await?
}
None => {
services
.store
.claim_next(&executor_id, DEFAULT_LEASE_DURATION)
.await?
}
};
let Some(claim) = claim else {
if mode == LoopMode::Run && !ran {
anyhow::bail!(
"deletion request is not runnable, is blocked, or is leased by another executor"
);
}
return Ok(0);
};
ran = true;
let output = execute_claim(&services, mode, claim, &shutdown).await?;
print_json(&output)?;
let failed = output.last_error.is_some() || output.blocked_reason.is_some();
if mode == LoopMode::Run || shutdown.is_cancelled() || failed {
return Ok(i32::from(failed));
}
}
}
async fn stop_claim_executor(
services: &Services,
mode: LoopMode,
token: &LeaseToken,
) -> Result<()> {
// A failed draining heartbeat must not prevent the generation-checked release
// attempt. `stop_executor` cannot clear a successor's reclaimed lease.
let _ = services
.store
.heartbeat(token, mode.as_str(), DEFAULT_LEASE_DURATION, true)
.await;
services
.store
.stop_executor(Some(token), &token.owner)
.await?;
Ok(())
}
async fn record_stage_failure(
services: &Services,
token: &LeaseToken,
stage: DeletionStage,
error: &anyhow::Error,
) -> Result<bool> {
let message = format!("{error:#}");
let result = if is_permanent_error(error) {
services.store.block(token, stage, "stage", &message).await
} else {
services
.store
.record_retry(token, stage, "stage", &message, RETRY_DELAY)
.await
};
match result {
Ok(()) => Ok(true),
Err(error) if buzz_db::deletion::is_stale_deletion_lease(&error) => Ok(false),
Err(error) => Err(error.into()),
}
}
async fn execute_claim(
services: &Services,
mode: LoopMode,
mut claim: ClaimedDeletion,
shutdown: &CancellationToken,
) -> Result<RunOutput> {
let token = claim.lease.clone();
loop {
if shutdown.is_cancelled() {
stop_claim_executor(services, mode, &token).await?;
let request = services.store.get(token.request_id).await?;
return Ok(run_output(request));
}
services
.store
.heartbeat(&token, mode.as_str(), DEFAULT_LEASE_DURATION, false)
.await?;
let stage_result = run_stage_with_heartbeat(services, mode, &claim, shutdown).await;
match stage_result {
StageOutcome::Completed => {}
StageOutcome::Shutdown => {
stop_claim_executor(services, mode, &token).await?;
let request = services.store.get(token.request_id).await?;
return Ok(run_output(request));
}
StageOutcome::Failed(error) => {
let request = services.store.get(token.request_id).await?;
if request.lease_owner.as_deref() != Some(&token.owner)
|| request.lease_generation != token.generation
{
return Ok(run_output(request));
}
if !record_stage_failure(services, &token, claim.request.stage, &error).await? {
// Ownership expired between the stage failure and durable
// error recording. A successor now owns retry/block policy.
let request = services.store.get(token.request_id).await?;
return Ok(run_output(request));
}
let request = services.store.get(token.request_id).await?;
return Ok(run_output(request));
}
}
let request = services.store.get(token.request_id).await?;
if request.stage == DeletionStage::RetentionPending || request.blocked_reason.is_some() {
return Ok(run_output(request));
}
claim.request = request.clone();
claim.lease.fence_generation = request.fence_generation;
}
}
enum StageOutcome {
Completed,
Shutdown,
Failed(anyhow::Error),
}
async fn await_stage<F>(
stage: F,
shutdown: &CancellationToken,
heartbeat_error: &CancellationToken,
) -> StageOutcome
where
F: std::future::Future<Output = Result<()>>,
{
tokio::select! {
biased;
_ = shutdown.cancelled() => StageOutcome::Shutdown,
_ = heartbeat_error.cancelled() => StageOutcome::Failed(DeletionLeaseLost.into()),
result = stage => match result {
Ok(()) => StageOutcome::Completed,
Err(error) => StageOutcome::Failed(error),
},
}
}
async fn run_stage_with_heartbeat(
services: &Services,
mode: LoopMode,
claim: &ClaimedDeletion,
shutdown: &CancellationToken,
) -> StageOutcome {
let heartbeat_services = services.clone();
let heartbeat_token = claim.lease.clone();
let heartbeat_mode = mode.as_str();
let heartbeat_shutdown = CancellationToken::new();
let heartbeat_cancel = heartbeat_shutdown.clone();
let heartbeat_error = CancellationToken::new();
let heartbeat_error_signal = heartbeat_error.clone();
let heartbeat = tokio::spawn(async move {
let mut interval = tokio::time::interval(heartbeat_interval());
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
interval.tick().await;
loop {
tokio::select! {
_ = heartbeat_cancel.cancelled() => return,
_ = interval.tick() => {
if heartbeat_services
.store
.heartbeat(
&heartbeat_token,
heartbeat_mode,
DEFAULT_LEASE_DURATION,
false,
)
.await
.is_err()
{
heartbeat_error_signal.cancel();
return;
}
}
}
}
});
let stage = await_stage(
execute_stage(services, claim, &heartbeat_error),
shutdown,
&heartbeat_error,
)
.await;
heartbeat_shutdown.cancel();
match heartbeat.await {
Ok(()) => stage,
Err(error) => {
StageOutcome::Failed(anyhow::anyhow!("deletion heartbeat task failed: {error}"))
}
}
}
async fn run_guarded_external_step<F, Fut, T>(
services: &Services,
token: &LeaseToken,
stage: DeletionStage,
heartbeat_lost: &CancellationToken,
operation: F,
) -> Result<T>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
services.store.verify_execution_token(token, stage).await?;
let result = tokio::select! {
biased;
_ = heartbeat_lost.cancelled() => {
return Err(DeletionLeaseLost.into());
}
result = operation() => result,
};
let output = result?;
services.store.verify_execution_token(token, stage).await?;
Ok(output)
}
async fn execute_stage(
services: &Services,
claim: &ClaimedDeletion,
heartbeat_lost: &CancellationToken,
) -> Result<()> {
let request = &claim.request;
let token = token_with_current_fence(&claim.lease, request);
if matches!(
request.stage,
DeletionStage::Approved
| DeletionStage::Fenced
| DeletionStage::Drained
| DeletionStage::BindingsRemoved
| DeletionStage::PostgresPurged
| DeletionStage::CachePurged
| DeletionStage::LogicallyVerified
) {
validate_frozen_inventory(request)?;
}
match request.stage {
DeletionStage::Approved => {
// Fail closed on missing version-list permission before we take the
// durable write fence. Exact-version delete permission cannot be
// proven safely here: S3 has no dry-run DeleteObjectVersion, and a
// fabricated-version delete would still be a destructive API call
// while proving less than the real tenant-prefix operation.
run_guarded_external_step(
services,
&token,
DeletionStage::Approved,
heartbeat_lost,
|| async {
for prefix in tenant_prefixes(*request.community_id.as_uuid()) {
services
.media
.preflight_version_listing(&prefix)
.await
.with_context(|| {
format!(
"S3 version-list preflight failed for prefix {prefix}; \
verify s3:ListBucketVersions and s3:DeleteObjectVersion \
on the relay bucket before fencing"
)
})?;
}
Ok(())
},
)
.await?;
// Approval binds immutable catalog + community-prefix ownership.
// Live row counts and tenant binding keys are deliberately not
// equality-bound until the durable fence closes all writers.
let live_schema = services
.store
.inventory_schema(request.community_id)
.await?;
let frozen = validate_frozen_inventory(request)?;
if live_schema.scoped_tables != frozen.schema.scoped_tables
|| live_schema.fenced_tables != frozen.schema.fenced_tables
{
return Err(permanent(
"approved structural catalog drifted before fencing",
));
}
services.store.begin_quiescing(&token).await?;
match services.store.fence(&token).await {
Ok(_) => {}
Err(buzz_db::DbError::ServingWritesNotDrained {
active_count,
operations,
..
}) => {
return Err(transient(format!(
"serving writes not drained before fence: count={active_count}, operations={operations:?}"
)));
}
Err(error) => return Err(error.into()),
}
}
DeletionStage::Fenced => {
services
.store
.verify_execution_token(&token, DeletionStage::Fenced)
.await?;
let disconnect = tokio::select! {
biased;
_ = heartbeat_lost.cancelled() => Err(DeletionLeaseLost.into()),
result = publish_disconnect_community(&services.redis, request.community_id) => result,
};
disconnect?;
services
.store
.verify_execution_token(&token, DeletionStage::Fenced)
.await?;
if !services
.store
.serving_writes_drained(request.community_id)
.await?
{
return Err(transient("serving writes have not drained"));
}
// Freeze the destructive enumeration only after the fence closed
// new writers AND every admitted serving write drained: the
// post-drain listing is the final storage state, so no tenant key
// can appear after the freeze.
let destructive = match request.destructive_storage_manifest.clone() {
Some(value) => serde_json::from_value(value)?,
None => {
freeze_destructive_manifest(services, request, &token, heartbeat_lost).await?
}
};
validate_storage_ownership(request, &destructive)?;
services.store.mark_drained(&token).await?;
}
DeletionStage::Drained => {
let storage: StorageManifest = serde_json::from_value(
request
.destructive_storage_manifest
.clone()
.context("request has no post-fence destructive storage manifest")?,
)?;
validate_storage_ownership(request, &storage)?;
// Resume = first unstamped chunk. Bulk deletes are idempotent
// (missing keys report as deleted), so re-deleting a chunk whose
// stamp was lost to a crash is safe.
let mut removed: u64 = 0;
let mut already_missing: u64 = 0;
while let Some(chunk) = services.store.next_pending_manifest_chunk(&token).await? {
let chunk_no = chunk.chunk_no;
let outcome = run_guarded_external_step(
services,
&token,
DeletionStage::Drained,
heartbeat_lost,
|| async {
delete_manifest_chunk_with(&chunk, storage.version, |versions| async {
if storage.version >= 5 {
Ok(services.media.delete_object_versions(&versions).await?)
} else {
let keys = versions
.into_iter()
.map(|version| version.key)
.collect::<Vec<_>>();
Ok(services.media.delete_objects(&keys).await?)
}
})
.await
},
)
.await?;
if heartbeat_lost.is_cancelled() {
return Err(DeletionLeaseLost.into());
}
let detail = manifest_chunk_deleted_checkpoint_detail(&chunk, &outcome)?;
services
.store
.mark_manifest_chunk_deleted(&token, chunk_no, detail)
.await?;
removed += outcome.deleted;
already_missing += outcome.already_missing;
}
let frozen_keys: u64 = storage
.prefixes
.iter()
.map(|prefix| prefix.object_count)
.sum();
services
.store
.mark_bindings_removed(
&token,
serde_json::json!({
"deleted_keys": frozen_keys,
"removed_now": removed,
"already_missing": already_missing,
}),
)
.await?;
}
DeletionStage::BindingsRemoved => {
services.store.purge_postgres(&token).await?;
}
DeletionStage::PostgresPurged => {
services
.store
.verify_execution_token(&token, DeletionStage::PostgresPurged)
.await?;
let deleted = purge_redis_namespace(&services.redis, request.community_id).await?;
services
.store
.verify_execution_token(&token, DeletionStage::PostgresPurged)
.await?;
services
.store
.mark_cache_purged(&token, serde_json::json!({"deleted_keys": deleted}))
.await?;
}
DeletionStage::CachePurged => {
services
.store
.verify_postgres_logically_deleted(&token)
.await?;
verify_storage_absence(services, request).await?;
verify_redis_absence(&services.redis, request.community_id).await?;
services
.store
.mark_logically_verified(
&token,
serde_json::json!({"postgres": true, "object_store": true, "redis": true}),
)
.await?;
}
DeletionStage::LogicallyVerified => {
validate_frozen_inventory(request)?;
services
.store
.mark_retention_pending(
&token,
serde_json::json!({
"policy": "member-erasure and fleet-wide shared-CAS GC are out of V1 scope"
}),
)
.await?;
}
DeletionStage::Submitted | DeletionStage::Inventoried => {
anyhow::bail!("request has not crossed the explicit approval boundary")
}
DeletionStage::RetentionPending | DeletionStage::Aborted => {}
}
Ok(())
}
fn token_with_current_fence(token: &LeaseToken, request: &DeletionRequest) -> LeaseToken {
LeaseToken {
fence_generation: request.fence_generation,
..token.clone()
}
}
/// Prove logical absence by listing each tenant prefix and requiring it
/// empty — O(1) requests per prefix, independent of fleet size.
async fn verify_storage_absence(services: &Services, request: &DeletionRequest) -> Result<()> {
for prefix in tenant_prefixes(*request.community_id.as_uuid()) {
let page = services
.media
.list_prefix_versions_page(&prefix, None, None, 1)
.await?;
if let Some(entry) = page.entries.first() {
return Err(transient(format!(
"logical verification found a retained target object version: {}@{} ({})",
entry.key,
entry.version_id,
manifest_kind_name(entry.kind)
)));
}
}
Ok(())
}
async fn publish_disconnect_community(
pool: &deadpool_redis::Pool,
community: buzz_core::CommunityId,
) -> Result<()> {
let mut connection = pool.get().await?;
let channel = format!("buzz:{community}:conn-control");
let _: u64 = redis::cmd("PUBLISH")
.arg(channel)
.arg(r#"{"op":"DisconnectCommunity"}"#)
.query_async(&mut *connection)
.await?;
Ok(())
}
async fn purge_redis_namespace(
pool: &deadpool_redis::Pool,
community: buzz_core::CommunityId,
) -> Result<u64> {
let mut connection = pool.get().await?;
let pattern = format!("buzz:{community}:*");
let mut cursor = 0u64;
let mut deleted = 0u64;
loop {
let (next, keys): (u64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(&pattern)
.arg("COUNT")
.arg(1000)
.query_async(&mut *connection)
.await?;
if !keys.is_empty() {
let count: u64 = redis::cmd("UNLINK")
.arg(&keys)
.query_async(&mut *connection)
.await?;
deleted = deleted.saturating_add(count);
}
if next == 0 {
break;
}
cursor = next;
}
Ok(deleted)
}
fn scan_proves_absence(pages: &[(u64, Vec<String>)]) -> bool {
pages.last().is_some_and(|(cursor, _)| *cursor == 0)
&& pages.iter().all(|(_, keys)| keys.is_empty())
}
async fn scan_redis_namespace(
connection: &mut deadpool_redis::Connection,
pattern: &str,
) -> Result<Vec<(u64, Vec<String>)>> {
let mut cursor = 0u64;
let mut pages = Vec::new();
loop {
let page: (u64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(pattern)
.arg("COUNT")
.arg(1000)
.query_async(&mut **connection)
.await?;
cursor = page.0;
pages.push(page);
if cursor == 0 {
return Ok(pages);
}
}
}
async fn verify_redis_absence(
pool: &deadpool_redis::Pool,
community: buzz_core::CommunityId,
) -> Result<()> {
let mut connection = pool.get().await?;
let pattern = format!("buzz:{community}:*");
// SCAN is weakly consistent. Two complete empty passes ensure a cursor
// rollover or concurrent expiry cannot make one sparse pass look absent.
let first = scan_redis_namespace(&mut connection, &pattern).await?;
let second = scan_redis_namespace(&mut connection, &pattern).await?;
if scan_proves_absence(&first) && scan_proves_absence(&second) {
Ok(())
} else {
Err(transient(
"logical verification found a Redis namespace key",
))
}
}
fn sweep_object_cap() -> u64 {
std::env::var("BUZZ_DELETION_SWEEP_MAX_OBJECTS")
.ok()
.and_then(|value| value.parse().ok())
.filter(|value| *value > 0)
.unwrap_or(DEFAULT_SWEEP_OBJECT_CAP)
}
fn manifest_chunk_keys() -> usize {
std::env::var("BUZZ_DELETION_MANIFEST_CHUNK_KEYS")
.ok()
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| *value > 0)
.map(|value| value.min(100_000))
.unwrap_or(DEFAULT_MANIFEST_CHUNK_KEYS)
}
fn default_executor_id() -> String {
let hostname = std::env::var("HOSTNAME").unwrap_or_else(|_| "buzz-admin".to_string());
format!("{hostname}:{}", std::process::id())
}
fn shutdown_token() -> CancellationToken {
let token = CancellationToken::new();
let signal = token.clone();
tokio::spawn(async move {
#[cfg(unix)]
{
use tokio::signal::unix::{signal as unix_signal, SignalKind};
if let Ok(mut terminate) = unix_signal(SignalKind::terminate()) {
tokio::select! {
_ = tokio::signal::ctrl_c() => {},
_ = terminate.recv() => {},
}
} else {
let _ = tokio::signal::ctrl_c().await;
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
signal.cancel();
});
token
}
fn run_output(request: DeletionRequest) -> RunOutput {
RunOutput {
request_id: request.id,
stage: request.stage,
retry_count: request.retry_count,
last_error: request.last_error,
next_attempt_at: request.next_attempt_at,
blocked_reason: request.blocked_reason,
}
}
fn print_json(value: &impl Serialize) -> Result<()> {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
#[cfg(test)]
mod postgres_tests {
use super::*;
#[test]
fn submit_host_prefers_explicit_host() {
assert_eq!(
resolve_submit_host(Some(" community.example "), Some("wss://ignored.example"))
.expect("explicit host"),
"community.example"
);
}
#[test]
fn submit_host_derives_from_relay_url() {
assert_eq!(
resolve_submit_host(None, Some("wss://relay.example:8443/path"))
.expect("relay URL host"),
"relay.example:8443"
);
}
#[test]
fn submit_host_requires_an_explicit_source() {
for relay_url in [None, Some(""), Some(" ")] {
let error = resolve_submit_host(None, relay_url).expect_err("missing host must fail");
assert!(error.to_string().contains("pass --host or set RELAY_URL"));
}
}
#[test]
fn submit_host_rejects_empty_or_invalid_values() {
assert!(resolve_submit_host(Some(" "), Some("wss://relay.example")).is_err());
assert!(resolve_submit_host(None, Some("not a URL")).is_err());
}
fn empty_storage_manifest(community: buzz_core::CommunityId) -> StorageManifest {
StorageManifest {
version: 4,
prefixes: tenant_prefixes(*community.as_uuid())
.into_iter()
.map(|prefix| PrefixManifest {
prefix,
object_count: 0,
total_bytes: 0,
keys_digest: KeyStreamDigest::new().finish().0,
})
.collect(),
}
}
async fn claimed_test_deletion(prefix: &str) -> (Db, Services, ClaimedDeletion) {
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.expect("BUZZ_TEST_DATABASE_URL or DATABASE_URL is required");
let pool = sqlx::PgPool::connect(&database_url)
.await
.expect("connect deletion engine test DB");
let db = Db::from_pool(pool);
if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") {
db.migrate().await.expect("migrate deletion engine test DB");
}
let store = db.deletion_store();
let host = format!("{prefix}-{}.example", Uuid::new_v4().simple());
let community = db
.ensure_configured_community(&host)
.await
.expect("create deletion engine test community");
let request = store
.submit(&host, "test", None)
.await
.expect("submit deletion request");
let inventory = FrozenInventory {
schema: store
.inventory_schema(community.id)
.await
.expect("inventory schema"),
storage: empty_storage_manifest(community.id),
};
store
.freeze_inventory(request.id, &inventory)
.await
.expect("freeze deletion inventory");
store
.approve(request.id, "test", None)
.await
.expect("approve deletion request");
let claim = store
.claim_specific(request.id, "test-executor", DEFAULT_LEASE_DURATION)
.await
.expect("claim deletion request")
.expect("runnable deletion request");
let services = Services {
store,
media: Arc::new(
MediaStorage::new(&buzz_media::MediaConfig {
s3_endpoint: "http://127.0.0.1:1".to_string(),
s3_access_key: "unused".to_string(),
s3_secret_key: "unused".to_string(),
s3_bucket: "unused".to_string(),
s3_region: "us-east-1".to_string(),
s3_addressing_style: buzz_media::S3AddressingStyle::Path,
max_image_bytes: 1,
max_gif_bytes: 1,
max_video_bytes: 1,
max_file_bytes: 1,
public_base_url: "http://localhost/media".to_string(),
upload_records_enabled: false,
upload_ip_header: None,
upload_port_header: None,
})
.expect("construct unused media service"),
),
redis: deadpool_redis::Config::from_url("redis://127.0.0.1:1")
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.expect("construct unused Redis pool"),
};
(db, services, claim)
}
fn env_of<'a>(set: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + use<'a> {
move |name| {
set.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| (*value).to_string())
}
}
#[test]
fn deletion_s3_key_pair_normalizes_missing_and_blank_pairs_for_default_credentials() {
assert_eq!(
s3_key_pair_from(env_of(&[])),
(String::new(), String::new())
);
assert_eq!(
s3_key_pair_from(env_of(&[
("BUZZ_S3_ACCESS_KEY", ""),
("BUZZ_S3_SECRET_KEY", " "),
])),
(String::new(), String::new())
);
}
#[test]
fn deletion_s3_key_pair_trims_static_and_preserves_partial_pairs() {
assert_eq!(
s3_key_pair_from(env_of(&[
("BUZZ_S3_ACCESS_KEY", " buzz_dev "),
("BUZZ_S3_SECRET_KEY", " buzz_dev_secret "),
])),
("buzz_dev".to_string(), "buzz_dev_secret".to_string())
);
for (env, expected) in [
(
&[("BUZZ_S3_ACCESS_KEY", " buzz_dev ")][..],
("buzz_dev".to_string(), String::new()),
),
(
&[("BUZZ_S3_SECRET_KEY", " buzz_dev_secret ")][..],
(String::new(), "buzz_dev_secret".to_string()),
),
(
&[
("BUZZ_S3_ACCESS_KEY", " buzz_dev "),
("BUZZ_S3_SECRET_KEY", " "),
][..],
("buzz_dev".to_string(), String::new()),
),
(
&[
("BUZZ_S3_ACCESS_KEY", " "),
("BUZZ_S3_SECRET_KEY", " buzz_dev_secret "),
][..],
(String::new(), "buzz_dev_secret".to_string()),
),
] {
assert_eq!(s3_key_pair_from(env_of(env)), expected);
}
}
fn deletion_test_media_storage() -> Arc<MediaStorage> {
let endpoint = std::env::var("BUZZ_TEST_S3_ENDPOINT")
.or_else(|_| std::env::var("BUZZ_S3_ENDPOINT"))
.expect("BUZZ_TEST_S3_ENDPOINT or BUZZ_S3_ENDPOINT is required");
let access_key = std::env::var("BUZZ_TEST_S3_ACCESS_KEY")
.or_else(|_| std::env::var("BUZZ_S3_ACCESS_KEY"))
.expect("BUZZ_TEST_S3_ACCESS_KEY or BUZZ_S3_ACCESS_KEY is required");
let secret_key = std::env::var("BUZZ_TEST_S3_SECRET_KEY")
.or_else(|_| std::env::var("BUZZ_S3_SECRET_KEY"))
.expect("BUZZ_TEST_S3_SECRET_KEY or BUZZ_S3_SECRET_KEY is required");
let bucket = std::env::var("BUZZ_TEST_S3_BUCKET")
.or_else(|_| std::env::var("BUZZ_S3_BUCKET"))
.expect("BUZZ_TEST_S3_BUCKET or BUZZ_S3_BUCKET is required");
Arc::new(
MediaStorage::new(&buzz_media::MediaConfig {
s3_endpoint: endpoint,
s3_access_key: access_key,
s3_secret_key: secret_key,
s3_bucket: bucket,
s3_region: std::env::var("BUZZ_TEST_S3_REGION")
.or_else(|_| std::env::var("BUZZ_S3_REGION"))
.unwrap_or_else(|_| "us-east-1".to_string()),
s3_addressing_style: buzz_media::S3AddressingStyle::Path,
max_image_bytes: 1,
max_gif_bytes: 1,
max_video_bytes: 1,
max_file_bytes: 1,
public_base_url: "http://localhost/media".to_string(),
upload_records_enabled: false,
upload_ip_header: None,
upload_port_header: None,
})
.expect("construct deletion test media service"),
)
}
async fn approved_stage_allows_post_inventory_row_churn_before_fencing() {
let (db, services, claim) = claimed_test_deletion("deletion-row-churn").await;
let frozen: FrozenInventory = serde_json::from_value(
claim
.request
.inventory_manifest
.clone()
.expect("frozen inventory"),
)
.expect("decode frozen inventory");
db.add_to_allowlist(claim.request.community_id, &[0x41; 32], &[0x42; 32], None)
.await
.expect("post-inventory serving write");
let live = services
.store
.inventory_schema(claim.request.community_id)
.await
.expect("live inventory after serving churn");
assert_eq!(live.scoped_tables, frozen.schema.scoped_tables);
assert_eq!(live.fenced_tables, frozen.schema.fenced_tables);
assert_eq!(
live.row_counts["pubkey_allowlist"],
frozen.schema.row_counts["pubkey_allowlist"] + 1
);
execute_stage(&services, &claim, &CancellationToken::new())
.await
.expect("row-count churn must not fail structural revalidation");
let fenced = services
.store
.get(claim.request.id)
.await
.expect("load fenced request");
assert_eq!(fenced.stage, DeletionStage::Fenced);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn frozen_inventory_digest_and_storage_ownership_fail_closed() {
let (_, _, claim) = claimed_test_deletion("deletion-integrity").await;
assert!(validate_frozen_inventory(&claim.request).is_ok());
let mut digest_tampered = claim.request.clone();
digest_tampered.inventory_manifest = Some(serde_json::json!({
"schema": {"scoped_tables": [], "row_counts": {}, "fenced_tables": []},
"storage": {"version": 4, "prefixes": []}
}));
assert!(validate_frozen_inventory(&digest_tampered).is_err());
// A manifest scoped to another community's prefixes is never the
// deletion target's, even when internally valid.
let foreign_manifest =
empty_storage_manifest(buzz_core::CommunityId::from_uuid(Uuid::new_v4()));
assert!(validate_storage_ownership(&claim.request, &foreign_manifest).is_err());
}
/// The non-atomic boundary under test: S3 committed a chunk's deletes,
/// then the worker died before the chunk stamp. Resume must re-delete the
/// chunk (missing keys report as deleted — idempotent), stamp it, and
/// finish the stage.
async fn drained_stage_resumes_chunk_deleted_before_stamp() {
let (_, mut services, claim) = claimed_test_deletion("deletion-chunk-resume").await;
services.media = deletion_test_media_storage();
let community = claim.request.community_id;
let meta_prefix = format!("_meta/{community}/");
let keys = vec![
format!("{meta_prefix}{}.json", "a".repeat(64)),
format!("{meta_prefix}{}.json", "b".repeat(64)),
];
for key in &keys {
services
.media
.put(key, b"chunk-resume", "application/json")
.await
.expect("seed object");
}
services
.store
.begin_quiescing(&claim.lease)
.await
.expect("quiesce");
let generation = services.store.fence(&claim.lease).await.expect("fence");
let token = LeaseToken {
fence_generation: Some(generation),
..claim.lease.clone()
};
// Two single-key chunks so resume order is observable.
services
.store
.append_manifest_key_chunk(&token, 0, &meta_prefix, &keys[..1])
.await
.expect("append chunk 0");
services
.store
.append_manifest_key_chunk(&token, 1, &meta_prefix, &keys[1..])
.await
.expect("append chunk 1");
let mut digest = KeyStreamDigest::new();
for key in &keys {
digest.fold(key).expect("fold key");
}
let (keys_digest, object_count) = digest.finish();
let mut storage = empty_storage_manifest(community);
storage.prefixes[0] = PrefixManifest {
prefix: meta_prefix.clone(),
object_count,
total_bytes: keys.len() as u64 * "chunk-resume".len() as u64,
keys_digest,
};
services
.store
.freeze_destructive_storage_manifest(&token, &storage)
.await
.expect("freeze chunked manifest");
services.store.mark_drained(&token).await.expect("drained");
// Simulate the crash window: chunk 0's key is already gone from S3
// but the chunk was never stamped.
services
.media
.delete(&keys[0])
.await
.expect("simulate committed delete before stamp");
let resumed = ClaimedDeletion {
request: services
.store
.get(token.request_id)
.await
.expect("reload drained request"),
lease: claim.lease,
};
execute_stage(&services, &resumed, &CancellationToken::new())
.await
.expect("Drained stage resumes at the unstamped chunk");
assert_eq!(
services
.store
.manifest_chunk_progress(token.request_id)
.await
.expect("chunk progress"),
(2, 2)
);
assert_eq!(
services.store.get(token.request_id).await.unwrap().stage,
DeletionStage::BindingsRemoved
);
for key in &keys {
assert!(
!services.media.head(key).await.expect("verify absence"),
"tenant binding {key} must be gone"
);
}
}
#[test]
fn truncated_version_listing_requires_key_marker() {
let error = require_truncated_version_markers(None, Some("v1".to_string()))
.expect_err("missing key marker must fail closed");
assert!(format!("{error:#}").contains("no key marker"));
}
#[test]
fn truncated_version_listing_requires_version_id_marker() {
let error = require_truncated_version_markers(Some("key".to_string()), None)
.expect_err("missing version id marker must fail closed");
assert!(format!("{error:#}").contains("no version id marker"));
}
#[test]
fn legacy_v4_manifest_chunk_decodes_bare_keys_for_resume_delete() {
let chunk = buzz_db::deletion::ManifestKeyChunk {
chunk_no: 3,
prefix: "_meta/community/".to_string(),
keys: vec![
"_meta/community/a.json".to_string(),
"_meta/community/b.json".to_string(),
],
};
let versions = object_versions_from_manifest_chunk(&chunk, 4).expect("decode v4 chunk");
assert_eq!(
versions,
vec![
ObjectVersionRef {
key: "_meta/community/a.json".to_string(),
version_id: String::new(),
},
ObjectVersionRef {
key: "_meta/community/b.json".to_string(),
version_id: String::new(),
},
]
);
}
#[tokio::test]
async fn partial_delete_ack_fails_before_checkpoint_detail() {
let chunk = buzz_db::deletion::ManifestKeyChunk {
chunk_no: 7,
prefix: "_meta/community/".to_string(),
keys: vec![
StorageManifestEntry::new("_meta/community/a.json", "v1", "object")
.encode()
.expect("encode manifest entry"),
StorageManifestEntry::new("_meta/community/b.json", "v2", "object")
.encode()
.expect("encode manifest entry"),
],
};
let delete = delete_manifest_chunk_with(&chunk, 5, |versions| async move {
assert_eq!(versions.len(), 2);
Ok(BulkDeleteOutcome {
deleted: 1,
already_missing: 0,
versioned_keys: Vec::new(),
failed: Vec::new(),
})
})
.await
.expect("delete call returns partial acknowledgement");
let checkpoint = manifest_chunk_deleted_checkpoint_detail(&chunk, &delete);
let error = checkpoint.expect_err("partial acknowledgement must be transient");
assert!(format!("{error:#}").contains("bulk delete acknowledged 1 of 2 keys in chunk 7"));
}
#[test]
fn manifest_chunk_checkpoint_detail_records_partial_delete_response_counts() {
let detail = manifest_chunk_deleted_detail(
"_meta/community/",
3,
&buzz_media::BulkDeleteOutcome {
deleted: 2,
already_missing: 1,
versioned_keys: Vec::new(),
failed: Vec::new(),
},
);
assert_eq!(detail["prefix"], "_meta/community/");
assert_eq!(detail["keys"], 3);
assert_eq!(detail["deleted"], 2);
assert_eq!(detail["already_missing"], 1);
}
#[test]
fn permanent_failures_are_typed_not_string_classified() {
let permanent_error = permanent("catalog drift");
let transient_error = transient("temporary catalog service reset");
let nested = permanent_source(anyhow::anyhow!("schema mismatch")).context("outer");
let db_permanent = anyhow::Error::from(buzz_db::DbError::DeletionSafety(
"typed catalog drift".to_string(),
));
let db_transient = anyhow::Error::from(buzz_db::DbError::Sqlx(sqlx::Error::PoolTimedOut));
assert!(is_permanent_error(&permanent_error));
assert!(is_permanent_error(&nested));
assert!(is_permanent_error(&db_permanent));
assert!(!is_permanent_error(&transient_error));
assert!(!is_permanent_error(&db_transient));
}
#[test]
fn deletion_configuration_requires_every_destructive_dependency() {
let variable = format!("BUZZ_DELETION_REQUIRED_TEST_{}", Uuid::new_v4().simple());
assert!(required_env(&variable).is_err());
std::env::set_var(&variable, " ");
assert!(required_env(&variable).is_err());
std::env::set_var(&variable, "configured");
assert_eq!(
required_env(&variable).expect("configured environment variable"),
"configured"
);
std::env::remove_var(&variable);
}
#[test]
fn deletion_s3_region_matches_relay_precedence_and_default() {
assert_eq!(resolve_s3_region(None, None), "us-east-1");
assert_eq!(
resolve_s3_region(Some(" ".to_string()), None),
"us-east-1"
);
assert_eq!(
resolve_s3_region(Some(" ".to_string()), Some(" us-west-2 ".to_string())),
"us-west-2"
);
assert_eq!(
resolve_s3_region(None, Some("us-west-2".to_string())),
"us-west-2"
);
assert_eq!(
resolve_s3_region(
Some("eu-central-1".to_string()),
Some("us-west-2".to_string())
),
"eu-central-1"
);
}
#[test]
fn redis_absence_requires_terminal_cursor_and_all_pages_empty() {
assert!(!scan_proves_absence(&[(9, Vec::new())]));
assert!(!scan_proves_absence(&[
(9, Vec::new()),
(0, vec!["buzz:tenant:late".to_string()]),
]));
assert!(scan_proves_absence(&[(9, Vec::new()), (0, Vec::new())]));
}
async fn final_storage_verification_rejects_late_target_binding() {
let (_, mut services, claim) = claimed_test_deletion("deletion-late-binding").await;
services.media = deletion_test_media_storage();
let community = claim.request.community_id;
let late_key = format!("_meta/{community}/{}.json", "a".repeat(64));
services
.media
.put(&late_key, b"late", "application/json")
.await
.expect("seed late binding");
let error = verify_storage_absence(&services, &claim.request)
.await
.expect_err("late target binding must fail verification");
assert!(format!("{error:#}").contains(&late_key));
services
.media
.delete(&late_key)
.await
.expect("remove late binding");
verify_storage_absence(&services, &claim.request)
.await
.expect("empty tenant prefixes verify clean");
}
mod external_infra_s3_tests {
#[tokio::test]
#[ignore = "requires Postgres and S3-compatible storage"]
async fn approved_stage_allows_post_inventory_row_churn_before_fencing() {
super::approved_stage_allows_post_inventory_row_churn_before_fencing().await;
}
#[tokio::test]
#[ignore = "requires Postgres and S3-compatible storage"]
async fn drained_stage_resumes_chunk_deleted_before_stamp() {
super::drained_stage_resumes_chunk_deleted_before_stamp().await;
}
#[tokio::test]
#[ignore = "requires Postgres and S3-compatible storage"]
async fn final_storage_verification_rejects_late_target_binding() {
super::final_storage_verification_rejects_late_target_binding().await;
}
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn stale_lease_during_failure_recording_is_lost_ownership() {
let (_, services, claim) = claimed_test_deletion("deletion-stale-record").await;
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.expect("test database URL");
let pool = sqlx::PgPool::connect(&database_url)
.await
.expect("connect stale-record test DB");
sqlx::query(
"UPDATE community_deletion_requests SET lease_until = now() - interval '1 second' WHERE id = $1",
)
.bind(claim.request.id)
.execute(&pool)
.await
.expect("expire claim");
let recorded = record_stage_failure(
&services,
&claim.lease,
claim.request.stage,
&transient("test failure"),
)
.await
.expect("stale ownership is not fatal");
assert!(!recorded);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn serving_guard_heartbeats_long_operation_through_quiescing() {
let (db, services, claim) = claimed_test_deletion("serving-guard-quiesce").await;
let community = claim.request.community_id;
let guard = acquire_serving_write_with_heartbeat(
&db,
community,
"test_quiesce",
Duration::from_millis(10),
)
.await
.expect("serving guard");
services
.store
.begin_quiescing(&claim.lease)
.await
.expect("quiesce");
assert!(
services
.store
.acquire_serving_write_lease(
community,
"late_external",
"late-owner",
DEFAULT_LEASE_DURATION,
)
.await
.is_err(),
"quiescing must reject newly admitted work"
);
let completed = Arc::new(AtomicBool::new(false));
let operation_completed = Arc::clone(&completed);
let result = guard
.protect(async move {
tokio::time::sleep(Duration::from_millis(75)).await;
operation_completed.store(true, Ordering::Relaxed);
})
.await;
assert!(
!guard.lost().is_cancelled(),
"quiescing must not cancel the admitted lease heartbeat"
);
result.expect("admitted operation survives quiescing heartbeats");
assert!(completed.load(Ordering::Relaxed));
assert!(matches!(
services.store.fence(&claim.lease).await,
Err(buzz_db::DbError::ServingWritesNotDrained {
active_count: 1,
..
})
));
guard.finish().await.expect("release serving guard");
assert_eq!(services.store.fence(&claim.lease).await.expect("fence"), 1);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn serving_guard_cancels_protected_operation_when_heartbeat_is_lost() {
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.expect("BUZZ_TEST_DATABASE_URL or DATABASE_URL is required");
let pool = sqlx::PgPool::connect(&database_url)
.await
.expect("connect serving guard test DB");
let db = Db::from_pool(pool.clone());
if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") {
db.migrate().await.expect("migrate serving guard test DB");
}
let community = db
.ensure_configured_community(&format!(
"serving-guard-{}.example",
Uuid::new_v4().simple()
))
.await
.expect("create test community")
.id;
let guard = acquire_serving_write_with_heartbeat(
&db,
community,
"test_cancel",
Duration::from_millis(10),
)
.await
.expect("serving guard");
sqlx::query("DELETE FROM community_serving_write_leases WHERE community_id = $1")
.bind(community.as_uuid())
.execute(&pool)
.await
.expect("force heartbeat failure");
let completed = Arc::new(AtomicBool::new(false));
let operation_completed = Arc::clone(&completed);
let result = guard
.protect(async move {
tokio::time::sleep(Duration::from_secs(1)).await;
operation_completed.store(true, Ordering::Relaxed);
})
.await;
assert!(result.is_err(), "lease loss must reject the operation");
assert!(
!completed.load(Ordering::Relaxed),
"lease loss must cancel the protected operation future"
);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn guarded_external_step_rejects_preexisting_heartbeat_loss_without_polling_operation() {
let (_, services, claim) = claimed_test_deletion("deletion-heartbeat").await;
let heartbeat_lost = CancellationToken::new();
heartbeat_lost.cancel();
let polled = Arc::new(AtomicBool::new(false));
let operation_polled = Arc::clone(&polled);
let result = run_guarded_external_step(
&services,
&claim.lease,
DeletionStage::Approved,
&heartbeat_lost,
|| async move {
operation_polled.store(true, Ordering::Relaxed);
Ok(())
},
)
.await;
assert!(result.is_err(), "heartbeat loss must abort the side effect");
assert!(
result
.expect_err("heartbeat loss error")
.downcast_ref::<DeletionLeaseLost>()
.is_some(),
"heartbeat loss must stay typed"
);
assert!(
!polled.load(Ordering::Relaxed),
"a pre-cancelled heartbeat must win before polling the operation"
);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn shutdown_during_stage_releases_claim_without_recording_retry() {
let (_, services, claim) = claimed_test_deletion("deletion-shutdown").await;
let request_id = claim.request.id;
let retry_count = claim.request.retry_count;
let shutdown = CancellationToken::new();
let cancel = shutdown.clone();
let services_for_run = services.clone();
let executor = tokio::spawn(async move {
execute_claim(&services_for_run, LoopMode::Drain, claim, &shutdown).await
});
tokio::time::sleep(Duration::from_millis(10)).await;
cancel.cancel();
let output = tokio::time::timeout(Duration::from_secs(2), executor)
.await
.expect("shutdown must cancel the active stage")
.expect("deletion executor task")
.expect("graceful deletion executor shutdown");
let request = services
.store
.get(request_id)
.await
.expect("load deletion request after shutdown");
assert_eq!(output.stage, DeletionStage::Approved);
assert_eq!(request.stage, DeletionStage::Approved);
assert_eq!(request.retry_count, retry_count);
assert!(request.last_error.is_none());
assert!(request.lease_owner.is_none());
assert!(request.lease_until.is_none());
}
#[tokio::test]
async fn stage_wait_treats_shutdown_as_control_flow() {
let shutdown = CancellationToken::new();
shutdown.cancel();
let heartbeat_lost = CancellationToken::new();
let outcome = await_stage(
std::future::pending::<Result<()>>(),
&shutdown,
&heartbeat_lost,
)
.await;
assert!(matches!(outcome, StageOutcome::Shutdown));
}
#[tokio::test]
async fn stage_wait_prioritizes_heartbeat_loss_over_a_ready_operation() {
let shutdown = CancellationToken::new();
let heartbeat_lost = CancellationToken::new();
heartbeat_lost.cancel();
let outcome = await_stage(async { Ok(()) }, &shutdown, &heartbeat_lost).await;
match outcome {
StageOutcome::Failed(error) => assert!(
error.downcast_ref::<DeletionLeaseLost>().is_some(),
"heartbeat loss must stay typed"
),
StageOutcome::Completed | StageOutcome::Shutdown => {
panic!("preexisting heartbeat loss must win")
}
}
}
}