//! `RealtimeBroadcaster` — the in-process fan-out seam for SSE realtime //! delivery (v1.1.6). //! //! Structurally a sibling of [`crate::inbox::InboxResolver`]: the trait //! lives here in `picloud-shared` because the publish side //! (`PubsubServiceImpl` in manager-core) and the subscribe side (the SSE //! handler in orchestrator-core) live in different crates and both need //! one shared instance. The in-process impl lives in orchestrator-core //! (`Mutex>`); cluster mode //! (v1.3+) swaps it for a Postgres `LISTEN/NOTIFY`-backed resolver behind //! the same trait without touching either caller. //! //! Delivery is **best-effort, at-most-once**: this is the realtime path, //! NOT the durable one. Durable trigger fan-out (retry / dead-letter) //! goes through the outbox and is the publish caller's separate concern. //! A slow SSE consumer loses the oldest events (bounded broadcast //! buffer); SSE's own transport-layer auto-reconnect is the recovery //! mechanism (no server-side replay in v1.1.6). use async_trait::async_trait; use chrono::{DateTime, Utc}; use thiserror::Error; use tokio::sync::broadcast; use crate::AppId; /// A single realtime event delivered to in-process SSE subscribers. The /// SSE handler serializes this to `data: {...}\n\n` on the wire. #[derive(Debug, Clone)] pub struct RealtimeEvent { pub topic: String, pub message: serde_json::Value, pub published_at: DateTime, } #[derive(Debug, Error)] pub enum BroadcasterError { /// Reserved for backends that can fail to register a subscriber /// (e.g. the cluster-mode `LISTEN/NOTIFY` resolver). The in-process /// impl never returns this. #[error("realtime broadcaster unavailable: {0}")] Unavailable(String), } #[async_trait] pub trait RealtimeBroadcaster: Send + Sync { /// Subscribe to events on `(app_id, topic)`. Returns a receiver that /// yields events until dropped. Channels are created lazily on first /// subscribe. async fn subscribe( &self, app_id: AppId, topic: &str, ) -> Result, BroadcasterError>; /// Publish an event to in-process subscribers. NOT durable — the /// outbox-backed durable fan-out is the publish caller's separate /// concern. A publish with no live subscribers is a silent no-op. async fn publish(&self, app_id: AppId, topic: &str, event: RealtimeEvent); /// Drop every subscriber for a topic (called on topic DELETE). Live /// receivers observe a closed channel and disconnect cleanly. async fn drop_topic(&self, app_id: AppId, topic: &str); } /// Bootstrap / test impl: subscribe yields a receiver on a throwaway /// channel, publish is a no-op. Lets a `Services`-style bundle build /// without the real registry wired in. #[derive(Debug, Default, Clone, Copy)] pub struct NoopRealtimeBroadcaster; #[async_trait] impl RealtimeBroadcaster for NoopRealtimeBroadcaster { async fn subscribe( &self, _app_id: AppId, _topic: &str, ) -> Result, BroadcasterError> { let (_tx, rx) = broadcast::channel(1); Ok(rx) } async fn publish(&self, _app_id: AppId, _topic: &str, _event: RealtimeEvent) {} async fn drop_topic(&self, _app_id: AppId, _topic: &str) {} }