Skip to main content

somatize_runtime/
event_bus.rs

1//! Broadcast event bus for runtime observability.
2//!
3//! Emits [`Event`]s (node started/completed/failed, cache hits, run lifecycle)
4//! to all subscribers via a tokio broadcast channel.
5
6use somatize_core::event::Event;
7use somatize_core::tracking::EventSink;
8use std::sync::{Arc, RwLock};
9use tokio::sync::broadcast;
10
11/// Async event bus for broadcasting execution events to multiple subscribers.
12///
13/// Uses tokio's broadcast channel internally. Subscribers receive all events
14/// emitted after they subscribe. Events are cloned for each subscriber.
15///
16/// Two delivery paths with different guarantees:
17/// - **Sinks** ([`add_sink`](Self::add_sink)) are invoked synchronously on
18///   the emitting thread before the broadcast — lossless and ordered.
19///   Trackers persist events through this path.
20/// - **Subscribers** ([`subscribe`](Self::subscribe)) receive via the
21///   broadcast channel — live but lossy under lag. Display/relay only.
22pub struct EventBus {
23    sender: broadcast::Sender<Event>,
24    sinks: RwLock<Vec<Arc<dyn EventSink>>>,
25}
26
27impl EventBus {
28    /// Create a new event bus with the given channel capacity.
29    pub fn new(capacity: usize) -> Self {
30        let (sender, _) = broadcast::channel(capacity);
31        Self {
32            sender,
33            sinks: RwLock::new(Vec::new()),
34        }
35    }
36
37    /// Register a lossless sink, called synchronously on every emit.
38    pub fn add_sink(&self, sink: Arc<dyn EventSink>) {
39        match self.sinks.write() {
40            Ok(mut sinks) => sinks.push(sink),
41            Err(poisoned) => poisoned.into_inner().push(sink),
42        }
43    }
44
45    /// Unregister a previously added sink (matched by identity). The
46    /// sink is flushed before removal.
47    pub fn remove_sink(&self, sink: &Arc<dyn EventSink>) {
48        sink.flush();
49        let mut sinks = match self.sinks.write() {
50            Ok(s) => s,
51            Err(poisoned) => poisoned.into_inner(),
52        };
53        sinks.retain(|s| !Arc::ptr_eq(s, sink));
54    }
55
56    /// Emit an event: sinks first (lossless), then all subscribers.
57    /// Returns the number of broadcast receivers that received the event.
58    /// If there are no subscribers, the broadcast is silently dropped.
59    pub fn emit(&self, event: Event) -> usize {
60        for sink in self.snapshot_sinks() {
61            sink.record(&event);
62        }
63        self.sender.send(event).unwrap_or(0)
64    }
65
66    /// Subscribe to receive events.
67    pub fn subscribe(&self) -> broadcast::Receiver<Event> {
68        self.sender.subscribe()
69    }
70
71    /// Number of active subscribers.
72    pub fn subscriber_count(&self) -> usize {
73        self.sender.receiver_count()
74    }
75
76    /// Flush all registered sinks.
77    pub fn flush_sinks(&self) {
78        for sink in self.snapshot_sinks() {
79            sink.flush();
80        }
81    }
82
83    /// Copy the sink list out from under the lock.
84    ///
85    /// A sink is user code, and calling it with the read guard alive means a
86    /// sink that registers or removes another one deadlocks: `RwLock` is not
87    /// reentrant. Cloning is an atomic increment per sink, paid once per
88    /// event — cheaper than the class of hang it removes.
89    fn snapshot_sinks(&self) -> Vec<Arc<dyn EventSink>> {
90        match self.sinks.read() {
91            Ok(s) => s.clone(),
92            Err(poisoned) => poisoned.into_inner().clone(),
93        }
94    }
95}
96
97impl Default for EventBus {
98    fn default() -> Self {
99        Self::new(256)
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use somatize_core::event::PlanSummary;
107    use std::time::Duration;
108
109    #[tokio::test]
110    async fn emit_without_subscribers_succeeds() {
111        let bus = EventBus::new(16);
112        let count = bus.emit(Event::RunStarted {
113            run_id: "r1".into(),
114            plan_summary: PlanSummary {
115                total_nodes: 1,
116                cached_nodes: 0,
117                parallel_branches: 0,
118            },
119        });
120        assert_eq!(count, 0);
121    }
122
123    #[tokio::test]
124    async fn subscriber_receives_events() {
125        let bus = EventBus::new(16);
126        let mut rx = bus.subscribe();
127
128        bus.emit(Event::RunStarted {
129            run_id: "r1".into(),
130            plan_summary: PlanSummary {
131                total_nodes: 2,
132                cached_nodes: 0,
133                parallel_branches: 0,
134            },
135        });
136        bus.emit(Event::RunCompleted {
137            run_id: "r1".into(),
138            duration: Duration::from_millis(100),
139        });
140
141        let e1 = rx.recv().await.unwrap();
142        assert!(matches!(e1, Event::RunStarted { .. }));
143
144        let e2 = rx.recv().await.unwrap();
145        assert!(matches!(e2, Event::RunCompleted { .. }));
146    }
147
148    #[tokio::test]
149    async fn multiple_subscribers() {
150        let bus = EventBus::new(16);
151        let mut rx1 = bus.subscribe();
152        let mut rx2 = bus.subscribe();
153
154        assert_eq!(bus.subscriber_count(), 2);
155
156        bus.emit(Event::RunCompleted {
157            run_id: "r1".into(),
158            duration: Duration::from_secs(1),
159        });
160
161        let e1 = rx1.recv().await.unwrap();
162        let e2 = rx2.recv().await.unwrap();
163        assert!(matches!(e1, Event::RunCompleted { .. }));
164        assert!(matches!(e2, Event::RunCompleted { .. }));
165    }
166
167    use std::sync::atomic::{AtomicUsize, Ordering};
168
169    /// Spy sink counting records and flushes.
170    #[derive(Default)]
171    struct CountingSink {
172        records: AtomicUsize,
173        flushes: AtomicUsize,
174    }
175
176    impl somatize_core::tracking::EventSink for CountingSink {
177        fn record(&self, _event: &Event) {
178            self.records.fetch_add(1, Ordering::SeqCst);
179        }
180        fn flush(&self) {
181            self.flushes.fetch_add(1, Ordering::SeqCst);
182        }
183    }
184
185    fn run_completed(id: &str) -> Event {
186        Event::RunCompleted {
187            run_id: id.into(),
188            duration: Duration::from_millis(1),
189        }
190    }
191
192    /// A sink that touches the bus from inside `record`.
193    ///
194    /// `RwLock` is not reentrant, so this used to deadlock: `emit` held the
195    /// read guard while calling user code, and the user code asked for the
196    /// write guard.
197    struct ReentrantSink {
198        bus: std::sync::Weak<EventBus>,
199        added: AtomicUsize,
200    }
201
202    impl somatize_core::tracking::EventSink for ReentrantSink {
203        fn record(&self, _event: &Event) {
204            // Only once, or the bus would grow a sink per event.
205            if self.added.fetch_add(1, Ordering::SeqCst) == 0
206                && let Some(bus) = self.bus.upgrade()
207            {
208                bus.add_sink(Arc::new(CountingSink::default()));
209            }
210        }
211        fn flush(&self) {}
212    }
213
214    #[test]
215    fn a_sink_may_touch_the_bus_from_inside_record() {
216        let bus = Arc::new(EventBus::new(16));
217        bus.add_sink(Arc::new(ReentrantSink {
218            bus: Arc::downgrade(&bus),
219            added: AtomicUsize::new(0),
220        }));
221
222        // Deadlocking here hangs the test binary rather than failing it,
223        // which is the loudest signal available without a watchdog thread.
224        bus.emit(run_completed("r1"));
225        bus.emit(run_completed("r2"));
226
227        bus.flush_sinks();
228    }
229
230    #[test]
231    fn sinks_observe_events_synchronously_before_emit_returns() {
232        let bus = EventBus::new(16);
233        let sink = Arc::new(CountingSink::default());
234        bus.add_sink(sink.clone());
235
236        bus.emit(run_completed("r1"));
237        // No polling, no await: the sink path is synchronous.
238        assert_eq!(sink.records.load(Ordering::SeqCst), 1);
239    }
240
241    #[test]
242    fn remove_sink_flushes_detaches_and_respects_identity() {
243        let bus = EventBus::new(16);
244        let sink = Arc::new(CountingSink::default());
245        let as_dyn: Arc<dyn somatize_core::tracking::EventSink> = sink.clone();
246        bus.add_sink(as_dyn.clone());
247
248        bus.emit(run_completed("r1"));
249        assert_eq!(sink.flushes.load(Ordering::SeqCst), 0);
250
251        // A DIFFERENT Arc wrapping an equal-valued sink is not removed.
252        let other: Arc<dyn somatize_core::tracking::EventSink> = Arc::new(CountingSink::default());
253        bus.remove_sink(&other);
254        bus.emit(run_completed("r2"));
255        assert_eq!(sink.records.load(Ordering::SeqCst), 2, "still attached");
256
257        // Removing by identity flushes first, then detaches.
258        bus.remove_sink(&as_dyn);
259        assert_eq!(sink.flushes.load(Ordering::SeqCst), 1, "flushed on removal");
260        bus.emit(run_completed("r3"));
261        assert_eq!(
262            sink.records.load(Ordering::SeqCst),
263            2,
264            "no events after removal"
265        );
266
267        // Removing a never-added sink is a harmless no-op.
268        bus.remove_sink(&as_dyn);
269    }
270
271    #[test]
272    fn remove_sink_drops_every_clone_of_a_doubly_registered_arc() {
273        // CONTRACT: registering the same Arc twice means two deliveries
274        // per event, and remove_sink detaches BOTH registrations.
275        let bus = EventBus::new(16);
276        let sink = Arc::new(CountingSink::default());
277        let as_dyn: Arc<dyn somatize_core::tracking::EventSink> = sink.clone();
278        bus.add_sink(as_dyn.clone());
279        bus.add_sink(as_dyn.clone());
280
281        bus.emit(run_completed("r1"));
282        assert_eq!(sink.records.load(Ordering::SeqCst), 2);
283
284        bus.remove_sink(&as_dyn);
285        bus.emit(run_completed("r2"));
286        assert_eq!(sink.records.load(Ordering::SeqCst), 2);
287    }
288
289    #[test]
290    fn flush_sinks_flushes_all_registered_sinks() {
291        let bus = EventBus::new(16);
292        let a = Arc::new(CountingSink::default());
293        let b = Arc::new(CountingSink::default());
294        bus.add_sink(a.clone());
295        bus.add_sink(b.clone());
296        bus.flush_sinks();
297        assert_eq!(a.flushes.load(Ordering::SeqCst), 1);
298        assert_eq!(b.flushes.load(Ordering::SeqCst), 1);
299    }
300
301    #[tokio::test]
302    async fn sinks_stay_lossless_while_subscribers_lag() {
303        // The documented contrast between the two delivery paths: a
304        // lagging broadcast subscriber drops events; sinks never do.
305        let bus = EventBus::new(4); // tiny broadcast capacity
306        let sink = Arc::new(CountingSink::default());
307        bus.add_sink(sink.clone());
308        let mut rx = bus.subscribe();
309
310        for i in 0..100 {
311            bus.emit(run_completed(&format!("r{i}")));
312        }
313        assert_eq!(sink.records.load(Ordering::SeqCst), 100, "sink is lossless");
314        match rx.recv().await {
315            Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
316                assert!(n > 0, "subscriber lost {n} events");
317            }
318            other => panic!("expected Lagged, got {other:?}"),
319        }
320    }
321
322    #[tokio::test]
323    async fn subscriber_after_emit_misses_earlier_events() {
324        let bus = EventBus::new(16);
325        bus.emit(Event::RunCompleted {
326            run_id: "r1".into(),
327            duration: Duration::from_secs(1),
328        });
329
330        let mut rx = bus.subscribe();
331        bus.emit(Event::RunCompleted {
332            run_id: "r2".into(),
333            duration: Duration::from_secs(2),
334        });
335
336        let event = rx.recv().await.unwrap();
337        if let Event::RunCompleted { run_id, .. } = event {
338            assert_eq!(run_id, "r2"); // only sees r2, not r1
339        } else {
340            panic!("wrong event type");
341        }
342    }
343}