fix(relay): persist huddle lifecycle events to DB (#341)

This commit is contained in:
tlongwell-block
2026-04-16 15:37:59 -04:00
committed by GitHub
parent 12dca80e1a
commit 2506341a28
+67 -1
View File
@@ -32,6 +32,8 @@ use uuid::Uuid;
use sprout_auth::generate_challenge;
use sprout_db::channel::MemberRole;
use sprout_core::StoredEvent;
use crate::audio::room::PeerCtrl;
use crate::state::AppState;
@@ -595,7 +597,71 @@ async fn emit_participant_event(
}
};
let event_id_hex = event.id.to_hex();
// 1. Persist to DB so late-joining clients can reconstruct huddle state
// from historical queries. Without this, lifecycle events only exist
// for the duration of the Redis pub/sub delivery and are lost forever.
let stored = match state.db.insert_event(&event, Some(parent_channel_id)).await {
Ok((stored, true)) => stored,
Ok((_, false)) => {
// Duplicate — already persisted (e.g. concurrent emit). Skip fan-out
// to avoid double-delivery, matching the side_effects.rs pattern.
debug!(
event_id = %event_id_hex,
channel_id = %parent_channel_id,
"audio lifecycle event already persisted — skipping fan-out"
);
return;
}
Err(e) => {
// DB failure during disconnect cleanup. Still broadcast so live
// subscribers see the leave/end event immediately — suppressing it
// would leave connected clients stale. Late joiners will have an
// inconsistent view until the next huddle lifecycle event lands.
warn!(
event_id = %event_id_hex,
channel_id = %parent_channel_id,
kind = %event.kind.as_u16(),
"audio: failed to persist lifecycle event: {e}"
);
StoredEvent::new(event.clone(), Some(parent_channel_id))
}
};
// 2. Mark as locally-published before Redis broadcast to prevent
// double-delivery when the event echoes back through the subscriber loop.
state.mark_local_event(&event.id);
// 3. Local fan-out to WS subscribers on this node (same pattern as
// dispatch_persistent_event in the ingest handler).
let matches = state.sub_registry.fan_out(&stored);
if !matches.is_empty() {
let event_json = serde_json::to_string(&event)
.expect("nostr::Event serialization is infallible for well-formed events");
let mut drop_count = 0u32;
for (target_conn_id, sub_id) in &matches {
let msg = format!(r#"["EVENT","{}",{}]"#, sub_id, event_json);
if !state.conn_manager.send_to(*target_conn_id, msg) {
drop_count += 1;
}
}
if drop_count > 0 {
warn!(
event_id = %event_id_hex,
drop_count,
"audio lifecycle fan-out: {drop_count} connection(s) dropped"
);
}
}
// 4. Cross-node broadcast via Redis pub/sub.
if let Err(e) = state.pubsub.publish_event(parent_channel_id, &event).await {
warn!("audio: failed to publish lifecycle event: {e}");
state.local_event_ids.invalidate(&event.id.to_bytes());
warn!(
event_id = %event_id_hex,
channel_id = %parent_channel_id,
"audio: failed to publish lifecycle event: {e}"
);
}
}