diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index f655ae0572..a9e784413c 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -241,13 +241,53 @@ pub async fn fan_out_pubsub_event(state: &Arc, channel_event: buzz_pub } } -/// Publish a stored event to subscribers and kick off async side effects. +/// Schedule post-commit delivery/side effects for a stored event. +/// +/// This intentionally returns after spawning the post-commit task: NIP-01 `OK` +/// means the event was durably accepted, not that Redis publish, local fan-out, +/// audit enqueue, or workflow triggering have completed. The spawned task still +/// runs the same guarded fan-out path, Redis publish, `mark_local_event` echo +/// dedupe, and delivery metrics as the former inline path. pub(crate) async fn dispatch_persistent_event( tenant: &TenantContext, state: &Arc, stored_event: &StoredEvent, kind_u32: u32, actor_pubkey_hex: &str, +) -> usize { + let tenant = tenant.clone(); + let state = Arc::clone(state); + let stored_event = stored_event.clone(); + let actor_pubkey_hex = actor_pubkey_hex.to_owned(); + let event_id_hex = stored_event.event.id.to_hex(); + + metrics::counter!("buzz_post_commit_dispatch_scheduled_total").increment(1); + tokio::spawn(async move { + let recipients = dispatch_persistent_event_inner( + &tenant, + &state, + &stored_event, + kind_u32, + &actor_pubkey_hex, + ) + .await; + debug!( + event_id = %event_id_hex, + recipients, + "post-commit dispatch complete" + ); + }); + + 0 +} + +/// Run post-commit delivery/side effects for a stored event. +async fn dispatch_persistent_event_inner( + tenant: &TenantContext, + state: &Arc, + stored_event: &StoredEvent, + kind_u32: u32, + actor_pubkey_hex: &str, ) -> usize { // No `crate::conformance` emit here — the spec doesn't have a // separate fan-out action. Acceptance was already recorded at the @@ -285,8 +325,15 @@ pub(crate) async fn dispatch_persistent_event( "Fan-out" ); - let event_json = serde_json::to_string(&stored_event.event) - .expect("nostr::Event serialization is infallible for well-formed events"); + let event_json = match serde_json::to_string(&stored_event.event) { + Ok(json) => json, + Err(e) => { + error!(event_id = %event_id_hex, "Failed to serialize event for fan-out: {e}"); + metrics::counter!("buzz_post_commit_dispatch_errors_total", "stage" => "serialize") + .increment(1); + return 0; + } + }; // For viewer-private snapshots (kind:30622), live fan-out must reach only the // owner — a kindless `ids:[…]` subscription can otherwise match it. Pull paths // (HTTP /query, WS historical) are gated separately by reader_authorized_for_event. @@ -1459,7 +1506,7 @@ mod tests { let event_id_hex = event.id.to_hex(); let stored = StoredEvent::new(event, None); - super::super::dispatch_persistent_event( + super::super::dispatch_persistent_event_inner( &tenant, &state, &stored, @@ -1559,7 +1606,7 @@ mod tests { "test precondition: the two events must have distinct ids" ); - super::super::dispatch_persistent_event( + super::super::dispatch_persistent_event_inner( &ta, &state, &a_stored, @@ -1567,7 +1614,7 @@ mod tests { &actor_hex, ) .await; - super::super::dispatch_persistent_event( + super::super::dispatch_persistent_event_inner( &tb, &state, &b_stored,