Files
buzz/crates/buzz-relay/src/push_runtime.rs
Luke Tornquist d25384a4f3 buzz-db: admit every fenced-table writer in app code (#8219)
## Summary

This makes every serving writer of a community-write-fenced table take
community admission in application code. That's the "Dual enforcement"
step of the Supported Writer Contract in `ARCHITECTURE.md`. Admission
means the shared community deletion lock plus the active/not-deleted
check from `deletion::guard_community_write`. It's taken before the
transaction's first tenant write or row lock and held until commit.

The 32 `community_write_fence_*` triggers, the six fence SQL functions
and `validate_serving_catalog` are unchanged. They stay authoritative.
Removing them is a separate migration that will be checked table by
table.

## Changes

- **New admission entry point.** `begin_community_write_transaction`
(`runtime/mod.rs`) builds an `AdmittedTx` for non-event writers. It sits
next to the existing event-write entry points and shares their
implementation.
- **Writers moved onto it:**
- `api_tokens`, `archived_identities`, `channels`, `channel_members`
(including DM and admin paths), `community_bans`, `git_repo_names`
- `moderation_actions` and `moderation_reports`,
`personal_read_accounts` and `personal_read_frontiers`,
`pubkey_allowlist`
  - `push_leases`, `push_match_queue`, `push_wake_outbox`
- `relay_invites`, `relay_members` (including the new-community owner
bootstrap and the startup `pubkey_allowlist` backfill),
`join_policy_acceptances`
- `thread_metadata`, `users`, `workflows`, `workflow_runs`,
`workflow_approvals`, `scheduled_workflow_fires`
  - Personal-read helpers now require `&mut AdmittedTx`.
- **`buzz-audit`.** An append now passes admission first, then takes a
transaction-scoped audit-chain lock (`pg_advisory_xact_lock`). This
replaces the session lock and the `catch_unwind` unlock cleanup. The
relay audit worker retries a `lock_timeout` on either lock and keeps the
entry; an admission refusal is terminal, as the trigger's refusal was.
- **Fleet-wide statements.** These keep `community_write_allowed(...)`
in the same SQL statement, because there's no single community to admit
up front:
  - scheduled-fire prune
  - invite expiry
  - push matcher claim and exhausted-job cleanup
  - startup `d_tag` backfill
  - channel TTL cleanup
- **Relay-member removal.** `remove_relay_member` and
`remove_relay_member_if_role` run `delete_member_with_acceptances`
inside their admitted transaction. The helper no longer opens its own
transaction, so the member lock, the `join_policy_acceptances` delete
and the member delete all hold admission until commit. `delete_workflow`
and `delete_workflow_for_owner` are gone on `main`, and the coordinate
deletion path they used to back was already admitted.
- **Unchanged exceptions.** The deletion-executor generation exception
and the serving-lease path work as before.
- **Tables with no production writer.** `delivery_log` and
`subscriptions` are only written by a historical cutover script and a
test fixture. Their triggers stay.
- **No cross-community moves.** No production `community_id` move exists
on a fenced table. The only `SET community_id = NULL` is the
deletion-executor purge. So no dual-community lock ordering was needed.

## What got simpler

- Per-store ad-hoc writer helpers are gone:
`begin_event_write_transaction` and `acquire_event_write_connection` in
`channel.rs`, and `begin_operation_transaction` in `push.rs`.
- Audit append drops the session advisory unlock, the
`catch_unwind`/`resume_unwind` panic handling, `log_inner` and the
`futures-util` dependency.

## Notes for reviewers

- **New crate dependency.** `buzz-audit` now depends on `buzz-db` only
to call the free function `buzz_db::deletion::guard_community_write`.
`ARCHITECTURE.md` names this as the one sanctioned subsystem edge, and
its audit section is updated to match.
- **Hot paths.** `ensure_user` first checks whether the user exists and
returns without admission when it does (no write happens). Only a real
insert takes admission. `touch_api_token` has no production caller
today.
- **No-op writers stay read-only.** The startup allowlist backfill
returns early on plain reads when the legacy table is gone, it has no
rows for the community, or the community already has members, so a
quiescing deployment community still starts. It re-checks under
admission before inserting.
- **Owner mutations.** When admission refuses a transfer or a
provisioning and a deletion request exists, both report
`DeletionPending`, as before. For a transfer, the operator API keeps its
coded `deletion_lifecycle_conflict` 409. For provisioning, it keeps the
existing uncoded 409, `owner_conflict: community deletion is pending`.
- **Per-community worker loops.** `claim_due_push_wakes` and similar now
return `AccessDenied` for a community that goes quiescing between the
active-host scan and the claim. The worker logs it at `debug` and the
next scan skips the community.
- **Kick side effects.** `membership_removal_fence` admits before the
membership lock to keep the lock order of `add_member`. A community that
leaves `active` between the kick and the fence skips live subscription
eviction; the membership cache is already invalidated and fan-out
re-checks membership. This is documented on the function.

## Testing

- **New refusal tests.** 19 tests: the 18 in
`store::admission_postgres_tests`, plus
`audit_append_requires_application_admission_with_triggers_bypassed`.
Each runs with ordinary triggers disabled (`session_replication_role =
replica`), checks that a raw fenced insert would succeed, and asserts
that the writer is refused for both `quiescing` and `fenced`
communities. Active writes still succeed.
- **The tests catch a missing check.** Measured in review at
`6fc073c3f`: with the guard in `AdmittedTx::admit` bypassed, 25 tests
fail. That includes 16 of the 18 database admission tests. The audit
test stays green there, because audit calls `guard_community_write`
directly.
- **Affected PG suites, with triggers on:** 183/183 passed across 16
store suites, push 18/18 run isolated, audit 7/7. The existing artifact,
reaction and replaceable admission tests pass.
- **Review fixes, each checked to fail without its fix:**
- `allowlist_backfill_requires_application_admission` and
`owner_mutations_report_pending_deletion_when_admission_refuses` fail
with the old `relay_members.rs`.
- `empty_allowlist_backfill_skips_admission` fails (`AccessDenied …
write-fenced (quiescing)`) without the allowlist-rows early return.
-
`audit_worker_retries_admission_lock_timeout_until_original_entry_is_appended_once`
holds the community deletion lock exclusive and fails without the
admission retry arm.
- The existing audit-chain retry test now looks for the
transaction-scoped lock and passes.
- The two owner-revoke mini-schema tests (`bridge.rs`, `ingest.rs`) now
include `communities` and `community_deletion_lock_key`, which admission
reads; both failed without that.
- **Static checks:** `cargo fmt --check` and workspace `cargo clippy
--all-targets -D warnings` pass.
- **Not run locally:** the `mobile-check` step of `just ci` (the build
machine couldn't reach pub.dev, and clean base fails the same way) and
the full `just test` lane. CI on this PR covers both.
`mesh_demo::demo_join_forwarded_arm_round_trips_echo` fails on the build
machine at base `394d4a434` as well; it uses no database.

Refs BUZZ-314.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-14 10:58:12 -04:00

885 lines
31 KiB
Rust

//! Durable NIP-PL event matcher and gateway delivery worker.
use std::{
sync::Arc,
time::{Duration, Instant},
};
use buzz_core::filter::{filters_match, reader_authorized_for_event};
use chrono::{TimeDelta, Utc};
use nostr::Filter;
use serde::{Deserialize, Serialize};
use tracing::{debug, error, warn};
use crate::{handlers::push_lease::Subscription, nip98::nip98_header, state::AppState};
// Dropping a SQLx transaction rolls it back; committed claims remain leased
// until expiry. Dropping HTTP cannot recall requests already accepted by the
// gateway. Do not acknowledge, delete, or retry a job merely on cancellation.
async fn run_push_task(
enabled: bool,
cancel: &tokio_util::sync::CancellationToken,
work: impl std::future::Future<Output = ()>,
) {
if !enabled {
return;
}
tokio::select! {
biased;
_ = cancel.cancelled() => {},
_ = work => {},
}
}
const CLAIM_SECS: i64 = 30;
const EVENT_USEFUL_SECS: i64 = 3600;
const MAX_ATTEMPTS: i32 = 8;
/// Upper bound on one claimed matcher batch. Bounded well under
/// `get_events_by_ids`' 500-id batch-fetch contract.
const MATCH_BATCH_LIMIT: i64 = 64;
/// Idle poll floor and ceiling. An idle matcher previously issued a claim
/// transaction every 250ms forever; backing off to the ceiling folds that
/// steady-state cost while keeping first-message latency at the floor.
const IDLE_POLL_FLOOR: Duration = Duration::from_millis(250);
const IDLE_POLL_CEILING: Duration = Duration::from_secs(2);
/// Cadence of the poison-job sweep, which lives off the claim path: its scan
/// is not served by the due partial index, so running it inside every claim
/// made claims slower exactly when a backlog needed them fastest.
const REAP_INTERVAL: Duration = Duration::from_secs(30);
#[derive(Serialize)]
struct DeliveryRequest<'a> {
v: u8,
endpoint_grant: &'a str,
request_id: uuid::Uuid,
expires_at: i64,
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case", tag = "status")]
enum DeliveryResponse {
Accepted,
InvalidEndpoint {
generation: i64,
invalid_at: Option<i64>,
},
Retry {
retry_after_seconds: Option<i64>,
},
}
/// Continuously claim accepted events in per-community batches and match
/// them against active leases (T2b). One batch costs one claim statement,
/// one event load, one lease scan, one membership scan, and one wake-enqueue
/// transaction — plus one complete/retry statement each — regardless of
/// batch size or how many (event, lease) pairs match.
pub async fn run_matcher(state: Arc<AppState>) {
run_push_task(
state.config.push_enabled,
&state.push_cancel,
matcher_loop(&state),
)
.await;
}
async fn matcher_loop(state: &AppState) {
let mut idle_delay = IDLE_POLL_FLOOR;
let mut last_reap = tokio::time::Instant::now();
loop {
if last_reap.elapsed() >= REAP_INTERVAL {
match state.db.reap_exhausted_push_matches().await {
Ok(reaped) if reaped > 0 => warn!(reaped, "reaped exhausted push match jobs"),
Ok(_) => {}
Err(e) => error!("push match reap failed: {e}"),
}
last_reap = tokio::time::Instant::now();
}
let until = Utc::now() + TimeDelta::seconds(CLAIM_SECS);
match state
.db
.claim_due_push_match_batch(MATCH_BATCH_LIMIT, until)
.await
{
Ok(Some(batch)) => {
idle_delay = IDLE_POLL_FLOOR;
process_match_batch(state, batch).await;
}
Ok(None) => {
tokio::time::sleep(idle_delay).await;
idle_delay = (idle_delay * 2).min(IDLE_POLL_CEILING);
}
Err(e) => {
error!("push matcher claim failed: {e}");
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
}
}
/// Per-batch state shared by every job: the community's active leases and
/// the exact (channel, lease author) membership pairs the jobs can consult.
struct MatchContext {
leases: Vec<buzz_db::push::MatchLease>,
memberships: std::collections::HashSet<(uuid::Uuid, Vec<u8>)>,
}
async fn load_match_context(
state: &AppState,
batch: &buzz_db::push::ClaimedMatchBatch,
) -> anyhow::Result<MatchContext> {
let leases = state.db.active_push_match_leases(batch.community).await?;
let mut channels: Vec<uuid::Uuid> = batch
.jobs
.iter()
.filter_map(|job| job.event.channel_id)
.collect();
channels.sort_unstable();
channels.dedup();
let mut authors: Vec<Vec<u8>> = leases.iter().map(|lease| lease.author.clone()).collect();
authors.sort_unstable();
authors.dedup();
let memberships = state
.db
.membership_pairs(batch.community, &channels, &authors)
.await?
.into_iter()
.collect();
Ok(MatchContext {
leases,
memberships,
})
}
async fn process_match_batch(state: &AppState, batch: buzz_db::push::ClaimedMatchBatch) {
let community = batch.community;
let context = match load_match_context(state, &batch).await {
Ok(context) => context,
Err(e) => {
// Shared context failed to load, so no job was evaluated: release
// the whole batch for retry. Jobs that keep failing are reaped by
// the periodic sweep once their attempts are exhausted.
warn!(%community, "push match context load failed: {e}");
metrics::counter!("buzz_push_match_jobs_total", "result" => "context_error")
.increment(batch.jobs.len() as u64);
let ids: Vec<Vec<u8>> = batch
.jobs
.iter()
.map(|job| job.event.event.id.as_bytes().to_vec())
.collect();
if let Err(e) = state
.db
.retry_push_match_batch(
community,
batch.claim_id,
&ids,
Utc::now() + TimeDelta::seconds(2),
)
.await
{
warn!(%community, "push match batch retry failed: {e}");
}
return;
}
};
let mut completed = Vec::new();
let mut retry = Vec::new();
// Jobs whose wakes are pending the set-wise enqueue below: their
// completion is decided by the flush, not by match evaluation.
let mut pending = Vec::new();
let mut wakes: Vec<buzz_db::push::WakeRequest> = Vec::new();
for job in &batch.jobs {
let match_queue_seconds = Utc::now()
.signed_duration_since(job.event.received_at)
.num_milliseconds()
.max(0) as f64
/ 1_000.0;
metrics::histogram!("buzz_push_match_queue_seconds").record(match_queue_seconds);
let event_id = job.event.event.id.as_bytes().to_vec();
match match_job(job, &context) {
Ok(job_wakes) if job_wakes.is_empty() => {
metrics::counter!("buzz_push_match_jobs_total", "result" => "unmatched")
.increment(1);
completed.push(event_id);
}
Ok(job_wakes) => {
metrics::counter!("buzz_push_match_jobs_total", "result" => "matched").increment(1);
pending.push((event_id, job.attempt));
wakes.extend(job_wakes);
}
Err(e) => {
metrics::counter!("buzz_push_match_jobs_total", "result" => "error").increment(1);
warn!(event_id=%job.event.event.id, attempt=job.attempt, "push match failed: {e}");
if job.attempt >= buzz_db::push::MAX_MATCH_ATTEMPTS {
// A poison event/lease must not retry forever or pin
// delivered outbox retention through the rematch guard.
completed.push(event_id);
} else {
retry.push(event_id);
}
}
}
}
// One transaction for every wake in the batch (T2b). InactiveLease and
// Duplicate outcomes are per-request non-errors; only a failed enqueue
// transaction sends the contributing jobs back for an idempotent rematch
// (the outbox dedup key absorbs any wakes that did commit elsewhere).
match state.db.enqueue_push_wakes(community, &wakes).await {
Ok(outcomes) => {
for outcome in outcomes {
let result = match outcome {
buzz_db::push::EnqueueWakeOutcome::Enqueued(_) => "enqueued",
buzz_db::push::EnqueueWakeOutcome::Duplicate(_) => "duplicate",
buzz_db::push::EnqueueWakeOutcome::InactiveLease => "inactive_lease",
};
metrics::counter!("buzz_push_wakes_total", "result" => result).increment(1);
}
completed.extend(pending.into_iter().map(|(event_id, _)| event_id));
}
Err(e) => {
metrics::counter!("buzz_push_wake_enqueue_errors_total").increment(1);
warn!(%community, "push wake batch enqueue failed: {e}");
for (event_id, attempt) in pending {
if attempt >= buzz_db::push::MAX_MATCH_ATTEMPTS {
completed.push(event_id);
} else {
retry.push(event_id);
}
}
}
}
if let Err(e) = state
.db
.complete_push_match_batch(community, batch.claim_id, &completed)
.await
{
warn!(%community, "push match batch completion failed: {e}");
}
if let Err(e) = state
.db
.retry_push_match_batch(
community,
batch.claim_id,
&retry,
Utc::now() + TimeDelta::seconds(2),
)
.await
{
warn!(%community, "push match batch retry failed: {e}");
}
}
/// Pure match evaluation: no DB access. Returns the wake requests this job
/// owes, which the caller flushes set-wise for the whole batch.
fn match_job(
job: &buzz_db::push::BatchedMatch,
context: &MatchContext,
) -> anyhow::Result<Vec<buzz_db::push::WakeRequest>> {
let mut wakes = Vec::new();
for lease in &context.leases {
let author_hex = hex::encode(&lease.author);
if !reader_authorized_for_event(&job.event.event, &author_hex) {
continue;
}
if let Some(channel) = job.event.channel_id {
if !context
.memberships
.contains(&(channel, lease.author.clone()))
{
continue;
}
}
let subscriptions: Vec<Subscription> = serde_json::from_value(lease.subscriptions.clone())?;
let mut class: Option<&str> = None;
for sub in &subscriptions {
let filter: Filter =
serde_json::from_value(serde_json::Value::Object(sub.filter.clone()))?;
if !push_filter_authorized_for_event(&filter, &job.event.event, &author_hex)
|| !filters_match(std::slice::from_ref(&filter), &job.event)
{
continue;
}
let ignored = sub.ignore.iter().any(|raw| {
serde_json::from_value::<Filter>(serde_json::Value::Object(raw.clone()))
.is_ok_and(|f| filters_match(&[f], &job.event))
});
let p_count = job
.event
.event
.tags
.iter()
.filter(|t| t.kind().to_string() == "p")
.count() as u64;
if ignored
|| sub
.suppress
.as_ref()
.is_some_and(|s| p_count > s.p_tags_max)
{
continue;
}
if class.is_none_or(|old| class_rank(&sub.class) > class_rank(old)) {
class = Some(&sub.class);
}
}
let Some(class) = class else { continue };
let event_deadline = job.event.event.created_at.as_secs() as i64 + EVENT_USEFUL_SECS;
let expires_at = lease.expires_at.min(event_deadline);
if expires_at <= Utc::now().timestamp() {
continue;
}
wakes.push(buzz_db::push::WakeRequest {
author: lease.author.clone(),
installation_id: lease.installation_id.clone(),
lease_generation: lease.generation,
event_id: job.event.event.id.as_bytes().to_vec(),
class: class.to_string(),
expires_at,
});
}
Ok(wakes)
}
/// Match-time counterpart of REQ's filter-level `#p` authorization gate.
/// Kind 1059 is globally stored and leaks recipient activity through wake
/// timing, so a lease may only match gift wraps addressed to its own author.
fn push_filter_authorized_for_event(
filter: &Filter,
event: &nostr::Event,
lease_author_hex: &str,
) -> bool {
if buzz_core::kind::event_kind_u32(event) != buzz_core::kind::KIND_GIFT_WRAP {
return true;
}
let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P);
filter.generic_tags.get(&p).is_some_and(|values| {
!values.is_empty()
&& values.iter().all(|value| value == lease_author_hex)
&& event
.tags
.filter(nostr::TagKind::SingleLetter(p))
.any(|tag| tag.content() == Some(lease_author_hex))
})
}
/// Continuously claim due wakes and deliver them through the push gateway.
pub async fn run_delivery_worker(state: Arc<AppState>) {
run_push_task(
state.config.push_enabled,
&state.push_cancel,
delivery_worker_loop(&state),
)
.await;
}
async fn delivery_worker_loop(state: &AppState) {
let http = match reqwest::Client::builder()
.timeout(state.config.push_gateway_timeout)
.build()
{
Ok(http) => http,
Err(error) => {
error!(%error, "push HTTP client initialization failed");
record_delivery("configuration_error");
return;
}
};
let mut idle_delay = Duration::from_millis(500);
loop {
let mut found = false;
match state.db.active_community_hosts().await {
Ok(communities) => {
for community in communities {
let community = buzz_core::CommunityId::from_uuid(community.id);
let until = Utc::now() + TimeDelta::seconds(CLAIM_SECS);
match state.db.claim_due_push_wakes(community, 16, until).await {
Ok(wakes) => {
for wake in wakes {
found = true;
deliver_one(state, &http, wake).await;
}
}
// The community left `active` after the host scan; the
// next scan skips it, so this is not worth a warning.
Err(e @ buzz_db::DbError::AccessDenied(_)) => {
debug!(%community, "push wake claim refused: {e}")
}
Err(e) => warn!(%community, "push wake claim failed: {e}"),
}
}
}
Err(e) => error!("push worker community scan failed: {e}"),
}
if found {
idle_delay = Duration::from_millis(500);
} else {
// Empty sweeps back off so an idle worker stops paying a full
// per-community claim scan every 500ms forever.
tokio::time::sleep(idle_delay).await;
idle_delay = (idle_delay * 2).min(IDLE_POLL_CEILING);
}
}
}
async fn deliver_one(
state: &AppState,
http: &reqwest::Client,
claimed: buzz_db::push::ClaimedWake,
) {
let outcome = match state
.db
.revalidate_push_wake(claimed.community, claimed.id, claimed.claim_id)
.await
{
Ok(buzz_db::push::RevalidateWakeOutcome::Deliver(wake)) => wake,
Ok(buzz_db::push::RevalidateWakeOutcome::Suppressed) => {
let _ = state
.db
.fail_push_wake(claimed.community, claimed.id, claimed.claim_id)
.await;
record_delivery("suppressed");
return;
}
Err(e) => {
warn!(wake=%claimed.id, "push revalidation failed: {e}");
record_delivery("worker_error");
return;
}
};
if outcome.attempt == 1 {
let wake_queue_seconds = Utc::now()
.signed_duration_since(outcome.queued_at)
.num_milliseconds()
.max(0) as f64
/ 1_000.0;
metrics::histogram!("buzz_push_wake_queue_seconds").record(wake_queue_seconds);
}
if let Some(channel) = outcome.channel_id {
match state
.db
.is_member(outcome.community, channel, &outcome.author)
.await
{
Ok(true) => {}
Ok(false) => {
let _ = state
.db
.fail_push_wake(outcome.community, outcome.id, outcome.claim_id)
.await;
record_delivery("suppressed");
return;
}
Err(e) => {
warn!(wake=%outcome.id, "push membership revalidation failed: {e}");
let _ = state
.db
.retry_push_wake(
outcome.community,
outcome.id,
outcome.claim_id,
Utc::now() + TimeDelta::seconds(2),
)
.await;
record_delivery("retry");
return;
}
}
}
// Membership I/O above can race lease replacement. Re-run the generation
// fence as the final database operation before transport.
let outcome = match state
.db
.revalidate_push_wake(outcome.community, outcome.id, outcome.claim_id)
.await
{
Ok(buzz_db::push::RevalidateWakeOutcome::Deliver(wake)) => wake,
Ok(buzz_db::push::RevalidateWakeOutcome::Suppressed) => {
let _ = state
.db
.fail_push_wake(outcome.community, outcome.id, outcome.claim_id)
.await;
record_delivery("suppressed");
return;
}
Err(e) => {
warn!(wake=%outcome.id, "final push revalidation failed: {e}");
record_delivery("worker_error");
return;
}
};
let serving_write = match buzz_deletion::acquire_serving_write(
&state.db,
outcome.community,
"push_delivery",
)
.await
{
Ok(guard) => guard,
Err(error) => {
warn!(wake=%outcome.id, %error, "push delivery suppressed by community deletion fence");
let _ = state
.db
.fail_push_wake(outcome.community, outcome.id, outcome.claim_id)
.await;
record_delivery("suppressed");
return;
}
};
let Some(url) = state.config.push_gateway_delivery_url.as_ref() else {
record_delivery("configuration_error");
return;
};
let body = match delivery_body(&outcome.endpoint_grant, outcome.id, outcome.expires_at) {
Ok(body) => body,
Err(error) => {
warn!(wake=%outcome.id, %error, "push delivery body encoding failed");
record_delivery("worker_error");
return;
}
};
let auth = match nip98_header(&state.relay_keypair, url.as_str(), &body) {
Ok(auth) => auth,
Err(e) => {
warn!(wake=%outcome.id, "push auth failed: {e}");
record_delivery("worker_error");
return;
}
};
if let Err(error) = serving_write.verify().await {
warn!(wake=%outcome.id, %error, "push serving lease lost before delivery");
record_delivery("suppressed");
return;
}
metrics::counter!("buzz_push_gateway_requests_total").increment(1);
let gateway_started = Instant::now();
let protected = serving_write
.protect(send_gateway_request(http, url, body, auth))
.await;
metrics::histogram!("buzz_push_gateway_request_seconds")
.record(gateway_started.elapsed().as_secs_f64());
let response = match protected {
Ok(response) => response,
Err(error) => {
warn!(wake=%outcome.id, %error, "push serving lease lost during delivery");
record_delivery("suppressed");
return;
}
};
match response {
Ok(r) if r.status().is_success() => match r.json::<DeliveryResponse>().await {
Ok(DeliveryResponse::Accepted) => {
let _ = state
.db
.complete_push_wake(outcome.community, outcome.id, outcome.claim_id)
.await;
record_delivery("accepted");
}
_ => {
let _ = state
.db
.fail_push_wake(outcome.community, outcome.id, outcome.claim_id)
.await;
record_delivery("failed");
}
},
Ok(r) if r.status() == reqwest::StatusCode::GONE => {
match r.json::<DeliveryResponse>().await {
Ok(DeliveryResponse::InvalidEndpoint {
generation,
invalid_at,
}) => {
if generation == outcome.lease_generation {
let _ = state
.db
.disable_push_endpoint(
outcome.community,
&outcome.author,
&outcome.installation_id,
generation,
)
.await;
}
warn!(wake=%outcome.id, ?invalid_at, "push endpoint permanently invalid");
}
_ => warn!(wake=%outcome.id, "invalid closed-protocol 410 response"),
}
let _ = state
.db
.fail_push_wake(outcome.community, outcome.id, outcome.claim_id)
.await;
record_delivery("invalid_endpoint");
}
Ok(r) if r.status() == reqwest::StatusCode::SERVICE_UNAVAILABLE => {
let delay = match r.json::<DeliveryResponse>().await {
Ok(DeliveryResponse::Retry {
retry_after_seconds,
}) => retry_after_seconds
.filter(|seconds| *seconds > 0)
.unwrap_or(2),
_ => 2,
};
record_delivery(retry_or_fail(state, &outcome, delay).await);
}
Ok(r) if r.status() == reqwest::StatusCode::TOO_MANY_REQUESTS => {
record_delivery(retry_or_fail(state, &outcome, 2).await);
}
// A timed-out terminal attempt burns the stable request id. Its replay
// is indistinguishable from another invalid-grant 404, but sending a
// fresh id would double-deliver and defeat the gateway replay fence.
Ok(r) if r.status() == reqwest::StatusCode::NOT_FOUND && outcome.attempt > 1 => {
let _ = state
.db
.complete_push_wake(outcome.community, outcome.id, outcome.claim_id)
.await;
record_delivery("replay_terminal");
}
Err(e) if e.is_timeout() || e.is_connect() => {
record_delivery(retry_or_fail(state, &outcome, 2).await);
}
_ => {
let _ = state
.db
.fail_push_wake(outcome.community, outcome.id, outcome.claim_id)
.await;
record_delivery("failed");
}
}
if let Err(error) = serving_write.finish().await {
warn!(wake=%outcome.id, %error, "failed to release community serving lease after push delivery");
}
}
fn delivery_body(
endpoint_grant: &str,
request_id: uuid::Uuid,
expires_at: i64,
) -> anyhow::Result<Vec<u8>> {
Ok(serde_json::to_vec(&DeliveryRequest {
v: 1,
endpoint_grant,
request_id,
expires_at,
})?)
}
async fn send_gateway_request(
http: &reqwest::Client,
url: &url::Url,
body: Vec<u8>,
auth: String,
) -> reqwest::Result<reqwest::Response> {
http.post(url.clone())
.header("Authorization", auth)
.header("Content-Type", "application/json")
.body(body)
.send()
.await
}
fn record_delivery(outcome: &'static str) {
metrics::counter!("buzz_push_deliveries_total", "outcome" => outcome).increment(1);
}
async fn retry_or_fail(
state: &AppState,
wake: &buzz_db::push::ClaimedWake,
delay: i64,
) -> &'static str {
if wake.attempt >= MAX_ATTEMPTS {
let _ = state
.db
.fail_push_wake(wake.community, wake.id, wake.claim_id)
.await;
"exhausted"
} else {
let secs = delay * (1_i64 << (wake.attempt - 1).clamp(0, 6));
let _ = state
.db
.retry_push_wake(
wake.community,
wake.id,
wake.claim_id,
Utc::now() + TimeDelta::seconds(secs),
)
.await;
"retry"
}
}
fn class_rank(_: &str) -> u8 {
1
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::Bytes,
extract::{OriginalUri, State},
http::HeaderMap,
routing::post,
Json, Router,
};
use nostr::{EventBuilder, Kind, Tag};
use serde_json::Value;
use std::{future::IntoFuture, sync::Arc};
use tokio::sync::Mutex;
#[test]
fn gift_wrap_match_requires_self_p_filter_and_recipient() {
let recipient = nostr::Keys::generate();
let other = nostr::Keys::generate();
let sender = nostr::Keys::generate();
let recipient_hex = recipient.public_key().to_hex();
let event = EventBuilder::new(Kind::GiftWrap, "ciphertext")
.tag(Tag::public_key(other.public_key()))
.sign_with_keys(&sender)
.unwrap();
let self_filter = Filter::new().pubkey(recipient.public_key());
assert!(!push_filter_authorized_for_event(
&self_filter,
&event,
&recipient_hex
));
let event = EventBuilder::new(Kind::GiftWrap, "ciphertext")
.tag(Tag::public_key(recipient.public_key()))
.sign_with_keys(&sender)
.unwrap();
assert!(push_filter_authorized_for_event(
&self_filter,
&event,
&recipient_hex
));
assert!(!push_filter_authorized_for_event(
&Filter::new().author(sender.public_key()),
&event,
&recipient_hex
));
}
async fn capture(
State(seen): State<Arc<Mutex<Vec<Value>>>>,
OriginalUri(uri): OriginalUri,
headers: HeaderMap,
body: Bytes,
) -> Json<Value> {
let url: url::Url = format!("http://{}{}", headers["host"].to_str().unwrap(), uri)
.parse()
.unwrap();
assert!(!headers.contains_key("x-forwarded-proto"));
nostr::nips::nip98::verify_auth_header(
headers["authorization"].to_str().unwrap(),
&url,
nostr::nips::nip98::HttpMethod::POST,
nostr::Timestamp::now(),
Some(&body),
)
.unwrap();
seen.lock()
.await
.push(serde_json::from_slice(&body).unwrap());
Json(serde_json::json!({"status":"accepted"}))
}
#[tokio::test]
async fn gateway_retries_send_the_same_request_id_over_http() {
let seen = Arc::new(Mutex::new(Vec::new()));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(
axum::serve(
listener,
Router::new()
.route("/v1/deliveries/apns", post(capture))
.with_state(seen.clone()),
)
.into_future(),
);
let url: url::Url = format!("http://{address}/v1/deliveries/apns")
.parse()
.unwrap();
let http = reqwest::Client::new();
let keys = nostr::Keys::generate();
let request_id = uuid::Uuid::new_v4();
for _ in 0..2 {
let body =
delivery_body("opaque-grant", request_id, Utc::now().timestamp() + 60).unwrap();
let auth = nip98_header(&keys, url.as_str(), &body).unwrap();
let response = send_gateway_request(&http, &url, body, auth).await.unwrap();
assert!(response.status().is_success());
}
server.abort();
let bodies = seen.lock().await;
assert_eq!(bodies.len(), 2);
assert_eq!(bodies[0]["request_id"], request_id.to_string());
assert_eq!(bodies[1]["request_id"], request_id.to_string());
}
}
#[cfg(test)]
mod shutdown_tests {
use super::run_push_task;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use tokio_util::sync::CancellationToken;
#[tokio::test]
async fn relay_shutdown_stops_both_worker_entry_points() {
let mut state = crate::state::tests::test_state().await;
let config = &mut Arc::get_mut(&mut state).unwrap().config;
Arc::get_mut(config).unwrap().push_enabled = true;
state.begin_shutdown();
tokio::time::timeout(std::time::Duration::from_secs(1), async {
tokio::join!(
super::run_matcher(state.clone()),
super::run_delivery_worker(state.clone())
);
})
.await
.expect("both real workers must honor shutdown before touching dependencies");
}
#[tokio::test]
async fn disabled_or_cancelled_worker_never_polls_work() {
for enabled in [false, true] {
let cancel = CancellationToken::new();
if enabled {
cancel.cancel();
}
run_push_task(enabled, &cancel, async { panic!("worker must not start") }).await;
}
}
#[tokio::test]
async fn shutdown_drops_in_flight_work_without_waiting_for_it() {
struct OnDrop(Arc<AtomicBool>);
impl Drop for OnDrop {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
let cancel = CancellationToken::new();
let worker_cancel = cancel.clone();
let dropped = Arc::new(AtomicBool::new(false));
let guard = OnDrop(dropped.clone());
let (started, ready) = tokio::sync::oneshot::channel();
let worker = tokio::spawn(async move {
run_push_task(true, &worker_cancel, async move {
let _guard = guard;
started.send(()).unwrap();
std::future::pending::<()>().await;
})
.await;
});
ready.await.unwrap();
cancel.cancel();
tokio::time::timeout(std::time::Duration::from_secs(1), worker)
.await
.unwrap()
.unwrap();
assert!(dropped.load(Ordering::SeqCst));
}
}