Files
buzz/crates/buzz-deletion/src/lib.rs
ravarora2 2d289f720d BUZZ-301: Add user deletion request intake (#8496)
## Summary

Add the first testable piece of user deletion: submit a synthetic
request, list requests, and inspect one by UUID. Operators can verify
that intake reaches PostgreSQL before we add inventory, approval, and
execution.

Add one operator-global `entity_deletion_requests` table with an
additive migration and matching desired-state schema. Identical
submissions return the same row. Conflicting submissions fail without
changing it. Only `demo:` targets are accepted, and requests stay
`submitted`.

Keep the commands in `buzz-deletion`, reached through a thin `buzz-admin
user-deletions` adapter. Reuse the existing database connection setup.
This change adds no worker, stage history, community gates, or deletion
operations.

### Rollout plan

This is an expand-only schema change plus an operator CLI. Existing
relay code does not use the new request table. No backfill or real
deletion is part of this release.

1. **Order the PRs.** Land #8496 first, using 0068; main now owns 0067
for channel join roles. Rework #8489 to build on this table with the
next available migration number. Renumbering its existing CREATE TABLE
migration alone is insufficient; both PRs currently create the same
request table.
2. **Apply on staging.** For deployments that manage schema separately,
apply the reviewed additive migration before using the new CLI. Where
auto-migration is enabled, the new relay binary applies it at startup.
Keep existing tables and records unchanged.
3. **Verify on staging.** Use the new `buzz-admin` binary with
`DATABASE_URL` pointing to staging. Submit a unique synthetic label,
list requests, and inspect the returned UUID:

   ```bash
buzz-admin user-deletions submit --target demo:staging-intake-1
--requested-by <operator>
   buzz-admin user-deletions list --limit 5
   buzz-admin user-deletions inspect <returned-uuid>
   ```

Verify one `entity_deletion_requests` row with status `submitted`.
Repeat the identical submission and verify the same UUID and timestamps.
Use a new label if this label was used by an earlier staging test.
4. **Apply beyond staging.** After verification, use the deployment's
reviewed schema/release workflow to add the table in the remaining
environments. Requests run only through the operator CLI; this release
starts no deletion worker. No feature flag is needed for this synthetic
intake: the database accepts only `demo:` targets, and real deletion is
absent.
5. **Rollback.** Keep the additive table and saved requests; stop using
the new commands and roll back the binary if needed. After 0068 is
applied, an older binary with auto-migration enabled can fail at startup
with sqlx `VersionMissing`. A binary rollback therefore requires
migration execution to be disabled on the older relay
(`BUZZ_AUTO_MIGRATE=false`, including the corresponding deployment
configuration).

### Related work

This is a smaller first slice of the full-workflow draft
[#8489](https://github.com/block/buzz/pull/8489), which is preserved for
later extraction. The two PRs overlap and should not both be merged as
written.

### Testing

The author submitted `demo:rarora-manual-1` to a separate local
PostgreSQL 17 database. Repeating the same submission returned its
original UUID and timestamps. List, inspect, and a direct SQL query all
returned the same saved request.

<details>
<summary>Actual command inputs and outputs</summary>

Commands ran from the new worktree with `DATABASE_URL` set to the local
`buzz_user_deletion_submit` database. Outputs below are stdout;
connection diagnostics on stderr are omitted.

**Submit again — returns the existing request without creating a
duplicate**

```bash
./target/debug/buzz-admin user-deletions submit \
  --target demo:rarora-manual-1 --requested-by rarora
```

```json
{
  "id": "2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89",
  "entity_type": "user",
  "target": "demo:rarora-manual-1",
  "status": "submitted",
  "requested_by": "rarora",
  "reason": null,
  "created_at": "2026-10-20T19:57:44.516698Z",
  "updated_at": "2026-10-20T19:57:44.516698Z"
}
```

**List saved requests**

```bash
./target/debug/buzz-admin user-deletions list --limit 5
```

```json
[
  {
    "id": "2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89",
    "entity_type": "user",
    "target": "demo:rarora-manual-1",
    "status": "submitted",
    "requested_by": "rarora",
    "reason": null,
    "created_at": "2026-10-20T19:57:44.516698Z",
    "updated_at": "2026-10-20T19:57:44.516698Z"
  }
]
```

**Inspect one request**

```bash
./target/debug/buzz-admin user-deletions inspect 2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89
```

```json
{
  "id": "2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89",
  "entity_type": "user",
  "target": "demo:rarora-manual-1",
  "status": "submitted",
  "requested_by": "rarora",
  "reason": null,
  "created_at": "2026-10-20T19:57:44.516698Z",
  "updated_at": "2026-10-20T19:57:44.516698Z"
}
```

**Read the table directly**

```bash
docker exec buzz-user-deletion-dev-20261020 \
  psql -X -U buzz -d buzz_user_deletion_submit -c \
  "SELECT id, entity_type, target, status, requested_by
   FROM entity_deletion_requests ORDER BY created_at DESC;"
```

```text
                  id                  | entity_type |        target        |  status   | requested_by 
--------------------------------------+-------------+----------------------+-----------+--------------
 2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89 | user        | demo:rarora-manual-1 | submitted | rarora
(1 row)
```

</details>

A separate CLI smoke test also verified that a conflicting operator and
a non-demo target fail without changing the saved request. The existing
community-deletion `list --limit 1` command passed against the smoke
database.

See [the intake
walkthrough](729233c726/docs/operator-user-deletion-intake.md)
for setup, commands, expected fields, and the SQL query.

Generated with Codex

---------

Signed-off-by: Ravneet Arora <rarora@squareup.com>
2026-10-22 10:49:27 -04:00

3020 lines
105 KiB
Rust
Raw 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.
/// Synthetic user-deletion request intake.
pub mod user;
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>,
{
let (output, verified) = self.protect_landed(operation).await?;
verified?;
Ok(output)
}
/// Like [`Self::protect`], but a completed operation's output survives a
/// failed post-operation verification, returned beside that failure, so a
/// caller can compensate for an effect that landed before the proof lapsed.
pub async fn protect_landed<F, T>(&self, operation: F) -> Result<(T, Result<()>)>
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())
}
};
let verified = self.verify().await;
Ok((output, verified))
}
/// 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>,
/// Identity recorded on the request.
///
/// Also the convergence key for an existing `submitted` request: to
/// take over an owner-origin request, pass its owner pubkey. The
/// operator's own pubkey conflicts with it instead of converging.
#[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 runnable work, preparing operator-attested owner submissions when idle.
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, None).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> {
Ok(store(&connect_database().await?))
}
async fn connect_database() -> Result<Db> {
let database_url = required_env("DATABASE_URL")?;
// The scheduled drain starts in a fresh pod whose first connection can
// outlast the relay's three-second budget.
let db = Db::connect_cold_start(
DbConfig {
database_url,
max_connections: env_parse("BUZZ_DB_POOL_SIZE", 20),
..DbConfig::default()
}
.with_session_timeouts_from_env(),
"deletion",
)
.await?;
Ok(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,
heartbeat_lost: Option<&CancellationToken>,
) -> Result<FrozenInventory> {
let schema = services
.store
.inventory_schema(request.community_id)
.await?;
let storage = enumerate_tenant_prefixes(services, request, heartbeat_lost, 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 claim = if claim.is_none() && mode == LoopMode::Drain && request_id.is_none() {
services
.store
.claim_next_owner_submission(&executor_id, DEFAULT_LEASE_DURATION)
.await?
} else {
claim
};
let Some(mut 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;
if claim.request.stage == DeletionStage::Submitted {
let preparation_request = claim.request.clone();
let preparation_services = &services;
match prepare_owner_claim_with(
&services,
mode,
claim,
&shutdown,
HEARTBEAT_INTERVAL,
|heartbeat_lost| async move {
build_inventory(
preparation_services,
&preparation_request,
Some(&heartbeat_lost),
)
.await
},
)
.await?
{
OwnerPreparationOutcome::Prepared(prepared) => claim = *prepared,
OwnerPreparationOutcome::Stopped => return Ok(0),
OwnerPreparationOutcome::Failed(output) => {
print_json(&output)?;
return Ok(1);
}
}
}
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));
}
}
}
enum OwnerPreparationOutcome {
Prepared(Box<ClaimedDeletion>),
Stopped,
Failed(RunOutput),
}
async fn record_owner_preparation_failure(
services: &Services,
token: &LeaseToken,
error: &anyhow::Error,
) -> Result<()> {
let message = format!("{error:#}");
let result = if is_permanent_error(error) {
services
.store
.block_owner_preparation(token, "inventory", &message)
.await
} else {
services
.store
.record_owner_preparation_retry(token, "inventory", &message, RETRY_DELAY)
.await
};
match result {
Ok(()) => Ok(()),
Err(error) if buzz_db::deletion::is_stale_deletion_lease(&error) => Ok(()),
Err(error) => Err(error.into()),
}
}
async fn prepare_owner_claim_with<F, Fut>(
services: &Services,
mode: LoopMode,
claim: ClaimedDeletion,
shutdown: &CancellationToken,
heartbeat_period: Duration,
build: F,
) -> Result<OwnerPreparationOutcome>
where
F: FnOnce(CancellationToken) -> Fut,
Fut: Future<Output = Result<FrozenInventory>>,
{
let token = claim.lease.clone();
if let Err(error) = services
.store
.heartbeat_owner_submission(&token, mode.as_str(), DEFAULT_LEASE_DURATION, false)
.await
{
if buzz_db::deletion::is_stale_deletion_lease(&error) {
let request = services.store.get(token.request_id).await?;
return Ok(OwnerPreparationOutcome::Failed(run_output(request)));
}
return Err(error.into());
}
let heartbeat_services = services.clone();
let heartbeat_token = token.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_period);
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_owner_submission(
&heartbeat_token,
heartbeat_mode,
DEFAULT_LEASE_DURATION,
false,
)
.await
.is_err()
{
heartbeat_error_signal.cancel();
return;
}
}
}
}
});
enum PreparationStage {
Prepared(Box<DeletionRequest>),
Stopped,
Failed(anyhow::Error),
}
let preparation = async {
let inventory = build(heartbeat_error.clone()).await?;
services
.store
.complete_owner_preparation(&token, &inventory)
.await
.map_err(Into::into)
};
let stage = tokio::select! {
biased;
_ = shutdown.cancelled() => PreparationStage::Stopped,
_ = heartbeat_error.cancelled() => PreparationStage::Failed(DeletionLeaseLost.into()),
result = preparation => match result {
Ok(request) => PreparationStage::Prepared(Box::new(request)),
Err(error) => PreparationStage::Failed(error),
},
};
heartbeat_shutdown.cancel();
let stage = match heartbeat.await {
Ok(()) => stage,
Err(error) => PreparationStage::Failed(anyhow::anyhow!(
"owner deletion preparation heartbeat task failed: {error}"
)),
};
match stage {
PreparationStage::Prepared(request) => Ok(OwnerPreparationOutcome::Prepared(Box::new(
ClaimedDeletion {
request: *request,
lease: token,
},
))),
PreparationStage::Stopped => {
let _ = services
.store
.heartbeat_owner_submission(&token, mode.as_str(), DEFAULT_LEASE_DURATION, true)
.await;
services
.store
.stop_executor(Some(&token), &token.owner)
.await?;
Ok(OwnerPreparationOutcome::Stopped)
}
PreparationStage::Failed(error) => {
let request = services.store.get(token.request_id).await?;
if request.stage == DeletionStage::Approved
&& request.lease_owner.as_deref() == Some(token.owner.as_str())
&& request.lease_generation == token.generation
&& services
.store
.verify_execution_token(&token, DeletionStage::Approved)
.await
.is_ok()
{
return Ok(OwnerPreparationOutcome::Prepared(Box::new(
ClaimedDeletion {
request,
lease: token,
},
)));
}
if request.lease_owner.as_deref() != Some(token.owner.as_str())
|| request.lease_generation != token.generation
{
return Ok(OwnerPreparationOutcome::Failed(run_output(request)));
}
record_owner_preparation_failure(services, &token, &error).await?;
let request = services.store.get(token.request_id).await?;
Ok(OwnerPreparationOutcome::Failed(run_output(request)))
}
}
}
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)
}
fn validate_approved_schema(
live: &buzz_db::deletion::SchemaManifest,
frozen: &FrozenInventory,
) -> Result<()> {
if live.scoped_tables != frozen.schema.scoped_tables {
return Err(permanent(
"approved structural catalog drifted before fencing",
));
}
Ok(())
}
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)?;
validate_approved_schema(&live_schema, &frozen)?;
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)
}
async fn owner_preparation_fixture(
prefix: &str,
) -> (Db, Services, DeletionRequest, FrozenInventory) {
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 owner preparation 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 owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
let buzz_db::CreateCommunityWithOwnerResult::Created(community) = db
.create_community_with_owner(&host, &owner, &nostr::Keys::generate())
.await
.map(|write| write.outcome)
.expect("create owner preparation community")
else {
panic!("expected fresh owner preparation community")
};
db.archive_community_owned_by(&host, &owner, "protected.example")
.await
.expect("archive owner preparation community")
.expect("owned community");
let operator = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let buzz_db::deletion::OwnerDeletionAdmission::Accepted(request) = store
.admit_owner_request(&host, &owner, operator, 1, Uuid::new_v4(), None)
.await
.expect("admit owner request")
else {
panic!("owner request must be accepted")
};
let inventory = FrozenInventory {
schema: store
.inventory_schema(community.id)
.await
.expect("inventory owner schema"),
storage: empty_storage_manifest(community.id),
};
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, *request, inventory)
}
async fn claimed_owner_preparation(
prefix: &str,
) -> (Db, Services, ClaimedDeletion, FrozenInventory) {
let (db, services, request, inventory) = owner_preparation_fixture(prefix).await;
let claim = services
.store
.claim_specific_owner_submission(request.id, "test-preparer", DEFAULT_LEASE_DURATION)
.await
.expect("claim owner request")
.expect("owner request is preparable");
(db, services, claim, inventory)
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn drain_loop_prioritizes_approved_work_then_dispatches_owner_submission() {
let (db, services, owner_request, _) =
owner_preparation_fixture("owner-preparation-dispatch").await;
let approved_host = format!("approved-{}.example", Uuid::new_v4().simple());
let approved_community = db
.ensure_configured_community(&approved_host)
.await
.expect("create approved community");
let approved = services
.store
.submit(&approved_host, "manual-operator", None)
.await
.expect("submit approved request");
let inventory = FrozenInventory {
schema: services
.store
.inventory_schema(approved_community.id)
.await
.expect("inventory approved community"),
storage: empty_storage_manifest(approved_community.id),
};
services
.store
.freeze_inventory(approved.id, &inventory)
.await
.expect("freeze approved request");
services
.store
.approve(approved.id, "manual-approver", None)
.await
.expect("approve request");
assert_eq!(
run_loop(
services.clone(),
LoopMode::Drain,
None,
"priority-executor".to_string(),
)
.await
.expect("approved work failure remains typed"),
1
);
let approved_after = services
.store
.get(approved.id)
.await
.expect("approved request after drain");
assert!(
approved_after.attempts > 0,
"approved work must be claimed first"
);
let owner_after_priority = services
.store
.get(owner_request.id)
.await
.expect("owner request after approved work");
assert_eq!(owner_after_priority.stage, DeletionStage::Submitted);
assert_eq!(owner_after_priority.attempts, 0);
assert!(owner_after_priority.lease_owner.is_none());
assert_eq!(
run_loop(
services.clone(),
LoopMode::Drain,
None,
"owner-dispatch-executor".to_string(),
)
.await
.expect("owner inventory failure remains typed"),
1
);
let owner_after_dispatch = services
.store
.get(owner_request.id)
.await
.expect("owner request after dispatch");
assert_eq!(owner_after_dispatch.attempts, 1);
assert_eq!(
owner_after_dispatch.retry_stage,
Some(DeletionStage::Submitted)
);
assert!(owner_after_dispatch.lease_owner.is_none());
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn drain_owner_preparation_approves_for_existing_execution_only() {
let (db, services, claim, inventory) =
claimed_owner_preparation("owner-preparation-success").await;
let operator_host = format!("manual-{}.example", Uuid::new_v4().simple());
db.ensure_configured_community(&operator_host)
.await
.expect("create manual community");
let manual = services
.store
.submit(&operator_host, "manual-operator", None)
.await
.expect("submit manual request");
let outcome = prepare_owner_claim_with(
&services,
LoopMode::Drain,
claim,
&CancellationToken::new(),
HEARTBEAT_INTERVAL,
|_| async move { Ok(inventory) },
)
.await
.expect("prepare owner claim");
let OwnerPreparationOutcome::Prepared(prepared) = outcome else {
panic!("owner preparation should produce an approved execution claim")
};
assert_eq!(prepared.request.stage, DeletionStage::Approved);
services
.store
.verify_execution_token(&prepared.lease, DeletionStage::Approved)
.await
.expect("prepared claim enters unchanged execution boundary");
assert_eq!(
services
.store
.get(manual.id)
.await
.expect("manual request")
.stage,
DeletionStage::Submitted
);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn drain_owner_preparation_persists_transient_and_permanent_failures() {
let (_, services, claim, _) = claimed_owner_preparation("owner-preparation-failure").await;
let request_id = claim.request.id;
let outcome = prepare_owner_claim_with(
&services,
LoopMode::Drain,
claim,
&CancellationToken::new(),
HEARTBEAT_INTERVAL,
|_| async { Err(transient("temporary inventory failure")) },
)
.await
.expect("record transient preparation failure");
assert!(matches!(outcome, OwnerPreparationOutcome::Failed(_)));
let retried = services
.store
.get(request_id)
.await
.expect("retried request");
assert_eq!(retried.retry_stage, Some(DeletionStage::Submitted));
assert_eq!(retried.retry_count, 1);
let (_, blocked_services, claim, _) =
claimed_owner_preparation("owner-preparation-permanent").await;
let blocked_request_id = claim.request.id;
let outcome = prepare_owner_claim_with(
&blocked_services,
LoopMode::Drain,
claim,
&CancellationToken::new(),
HEARTBEAT_INTERVAL,
|_| async { Err(permanent("unsafe inventory taxonomy")) },
)
.await
.expect("record permanent preparation failure");
assert!(matches!(outcome, OwnerPreparationOutcome::Failed(_)));
assert!(blocked_services
.store
.get(blocked_request_id)
.await
.expect("blocked request")
.blocked_reason
.is_some());
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn drain_owner_preparation_rejects_stale_lease_before_inventory() {
let (_, services, claim, inventory) =
claimed_owner_preparation("owner-preparation-stale").await;
services
.store
.stop_executor(Some(&claim.lease), &claim.lease.owner)
.await
.expect("release preparation lease");
let built = Arc::new(AtomicBool::new(false));
let observed = Arc::clone(&built);
let outcome = prepare_owner_claim_with(
&services,
LoopMode::Drain,
claim,
&CancellationToken::new(),
HEARTBEAT_INTERVAL,
move |_| async move {
observed.store(true, Ordering::SeqCst);
Ok(inventory)
},
)
.await
.expect("stale preparation converges without mutation");
assert!(matches!(outcome, OwnerPreparationOutcome::Failed(_)));
assert!(!built.load(Ordering::SeqCst));
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn drain_owner_preparation_cancels_inventory_after_lease_loss() {
struct DropSignal(Arc<AtomicBool>);
impl Drop for DropSignal {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
let (_, services, claim, _) = claimed_owner_preparation("owner-preparation-cancel").await;
let token = claim.lease.clone();
let dropped = Arc::new(AtomicBool::new(false));
let observed = Arc::clone(&dropped);
let shutdown = CancellationToken::new();
let (inventory_started_tx, inventory_started_rx) = tokio::sync::oneshot::channel();
let preparation = prepare_owner_claim_with(
&services,
LoopMode::Drain,
claim,
&shutdown,
Duration::from_millis(10),
move |_| async move {
let _drop_signal = DropSignal(observed);
inventory_started_tx
.send(())
.expect("signal inventory started");
std::future::pending::<Result<FrozenInventory>>().await
},
);
let revoke = async {
inventory_started_rx.await.expect("inventory started");
services
.store
.stop_executor(Some(&token), &token.owner)
.await
.expect("revoke preparation lease");
};
let (outcome, ()) = tokio::join!(preparation, revoke);
assert!(matches!(
outcome.expect("lease loss is typed control flow"),
OwnerPreparationOutcome::Failed(_)
));
assert!(dropped.load(Ordering::SeqCst));
}
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 approved_legacy_fence_inventory_accepts_current_catalog() {
let (_, services, mut claim) = claimed_test_deletion("deletion-legacy-fences").await;
let mut frozen = validate_frozen_inventory(&claim.request).expect("frozen inventory");
frozen.schema.fenced_tables = buzz_db::deletion::EXPECTED_SCOPED_TABLES
.iter()
.map(|table| (*table).to_owned())
.collect();
claim.request.inventory_digest = Some(hex::encode(frozen.digest().expect("legacy digest")));
claim.request.inventory_manifest =
Some(serde_json::to_value(&frozen).expect("encode legacy inventory"));
let decoded =
validate_frozen_inventory(&claim.request).expect("legacy digest remains valid");
let mut live = services
.store
.inventory_schema(claim.request.community_id)
.await
.expect("live catalog");
// Simulate the retired catalog while retaining the legacy digest above.
// While the fence objects exist, inventory still reports them so
// executors that predate fence-optional catalogs keep validating.
live.fenced_tables.clear();
validate_approved_schema(&live, &decoded)
.expect("legacy fence list is no longer structural");
live.scoped_tables.push("unknown_tenant_table".to_string());
assert!(validate_approved_schema(&live, &decoded).is_err());
}
#[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");
let error = services
.store
.acquire_serving_write_lease(
community,
"late_external",
"late-owner",
DEFAULT_LEASE_DURATION,
)
.await
.expect_err("quiescing must reject newly admitted work");
assert!(
matches!(error, buzz_db::DbError::AccessDenied(_)),
"{error:?}"
);
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")
}
}
}
}