Skip to main content

cratestack_core/events/
bus.rs

1//! [`CoolEventBus`] itself: the in-process pub/sub registry
2//! `emit`/`subscribe` operate on, plus [`SubscriptionHandle`] /
3//! [`SubscriptionGuard`] for removing a registered handler again —
4//! needed once a subscription's lifecycle is shorter than the process's
5//! (e.g. one `GET /rpc/subscribe/{op_id}` connection, §3.4a).
6
7use std::collections::BTreeMap;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, RwLock};
10
11use super::{CoolEventEnvelope, CoolEventFuture, ModelEventKind, event_topic};
12use crate::error::CoolError;
13
14type EventHandler = Arc<dyn Fn(CoolEventEnvelope) -> CoolEventFuture + Send + Sync>;
15
16/// Opaque token returned by [`CoolEventBus::subscribe`], needed to later
17/// remove that exact handler via [`CoolEventBus::unsubscribe`]. Fields
18/// are private — the only way to obtain one is `subscribe`, and the only
19/// thing it's good for is passing back to `unsubscribe`.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct SubscriptionHandle {
22    topic: String,
23    id: u64,
24}
25
26#[derive(Clone, Default)]
27pub struct CoolEventBus {
28    handlers: Arc<RwLock<BTreeMap<String, Vec<(u64, EventHandler)>>>>,
29    next_id: Arc<AtomicU64>,
30}
31
32impl std::fmt::Debug for CoolEventBus {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        let handler_count = self
35            .handlers
36            .read()
37            .map(|handlers| handlers.values().map(Vec::len).sum::<usize>())
38            .unwrap_or_default();
39        f.debug_struct("CoolEventBus")
40            .field("handler_count", &handler_count)
41            .finish()
42    }
43}
44
45impl CoolEventBus {
46    pub fn subscribe<F>(
47        &self,
48        model: &'static str,
49        operation: ModelEventKind,
50        handler: F,
51    ) -> SubscriptionHandle
52    where
53        F: Fn(CoolEventEnvelope) -> CoolEventFuture + Send + Sync + 'static,
54    {
55        let topic = event_topic(model, operation);
56        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
57        let mut handlers = self
58            .handlers
59            .write()
60            .expect("event bus handler registry should not be poisoned");
61        handlers
62            .entry(topic.clone())
63            .or_default()
64            .push((id, Arc::new(handler)));
65        SubscriptionHandle { topic, id }
66    }
67
68    /// Removes the handler registered by a prior [`Self::subscribe`]
69    /// call. A no-op if the handle's topic/id pair is no longer present
70    /// (already removed, or from a different `CoolEventBus` instance) —
71    /// callers don't need to track whether they've already unsubscribed.
72    pub fn unsubscribe(&self, handle: SubscriptionHandle) {
73        let mut handlers = self
74            .handlers
75            .write()
76            .expect("event bus handler registry should not be poisoned");
77        if let Some(topic_handlers) = handlers.get_mut(&handle.topic) {
78            topic_handlers.retain(|(id, _)| *id != handle.id);
79        }
80    }
81
82    pub async fn emit(&self, envelope: CoolEventEnvelope) -> Result<(), CoolError> {
83        let handlers: Vec<EventHandler> = self
84            .handlers
85            .read()
86            .expect("event bus handler registry should not be poisoned")
87            .get(&event_topic(&envelope.model, envelope.operation))
88            .map(|entries| entries.iter().map(|(_, handler)| handler.clone()).collect())
89            .unwrap_or_default();
90
91        for handler in handlers {
92            handler(envelope.clone()).await?;
93        }
94
95        Ok(())
96    }
97}
98
99/// RAII cleanup for one or more [`CoolEventBus`] subscriptions that all
100/// share one lifecycle — e.g. the per-operation handlers a single
101/// `GET /rpc/subscribe/{op_id}` connection registers for the duration of
102/// its SSE stream (`docs/design/rpc-transport.md` §3.4a, cratestack#390).
103/// Every tracked handle is unsubscribed when the guard drops, whether
104/// that's because the underlying stream ended normally (backpressure
105/// overflow) or because it was cancelled mid-poll (an ordinary client
106/// disconnect) — both just drop this guard the same way, so cleanup
107/// doesn't need to special-case which one happened. Without this, a
108/// long-running server would accumulate one permanently-registered,
109/// permanently-a-no-op handler per historical connection — a real
110/// unbounded-memory footgun for a public, freely-reconnectable endpoint,
111/// not a hypothetical one.
112#[derive(Default)]
113pub struct SubscriptionGuard {
114    bus: Option<CoolEventBus>,
115    handles: Vec<SubscriptionHandle>,
116}
117
118impl SubscriptionGuard {
119    pub fn new(bus: CoolEventBus) -> Self {
120        Self {
121            bus: Some(bus),
122            handles: Vec::new(),
123        }
124    }
125
126    /// Adds a handle to the set this guard unsubscribes on drop.
127    pub fn track(&mut self, handle: SubscriptionHandle) {
128        self.handles.push(handle);
129    }
130}
131
132impl Drop for SubscriptionGuard {
133    fn drop(&mut self) {
134        let Some(bus) = &self.bus else {
135            return;
136        };
137        for handle in self.handles.drain(..) {
138            bus.unsubscribe(handle);
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use std::sync::Mutex;
146
147    use super::*;
148    use crate::events::ModelEvent;
149
150    fn envelope(model: &str, operation: ModelEventKind) -> CoolEventEnvelope {
151        CoolEventEnvelope {
152            event_id: uuid::Uuid::new_v4(),
153            model: model.to_owned(),
154            operation,
155            occurred_at: chrono::Utc::now(),
156            data: serde_json::json!({"id": 1}),
157        }
158    }
159
160    #[tokio::test]
161    async fn unsubscribe_stops_further_delivery() {
162        let bus = CoolEventBus::default();
163        let received = Arc::new(Mutex::new(0u32));
164        let received_clone = Arc::clone(&received);
165        let handle = bus.subscribe("Widget", ModelEventKind::Created, move |_event| {
166            let received = Arc::clone(&received_clone);
167            Box::pin(async move {
168                *received.lock().unwrap() += 1;
169                Ok(())
170            })
171        });
172
173        bus.emit(envelope("Widget", ModelEventKind::Created))
174            .await
175            .unwrap();
176        assert_eq!(*received.lock().unwrap(), 1);
177
178        bus.unsubscribe(handle);
179
180        bus.emit(envelope("Widget", ModelEventKind::Created))
181            .await
182            .unwrap();
183        assert_eq!(
184            *received.lock().unwrap(),
185            1,
186            "no further delivery after unsubscribe"
187        );
188    }
189
190    #[tokio::test]
191    async fn unsubscribe_does_not_affect_other_handlers_on_the_same_topic() {
192        let bus = CoolEventBus::default();
193        let count_a = Arc::new(Mutex::new(0u32));
194        let count_b = Arc::new(Mutex::new(0u32));
195
196        let handle_a = {
197            let count_a = Arc::clone(&count_a);
198            bus.subscribe("Widget", ModelEventKind::Created, move |_event| {
199                let count_a = Arc::clone(&count_a);
200                Box::pin(async move {
201                    *count_a.lock().unwrap() += 1;
202                    Ok(())
203                })
204            })
205        };
206        {
207            let count_b = Arc::clone(&count_b);
208            bus.subscribe("Widget", ModelEventKind::Created, move |_event| {
209                let count_b = Arc::clone(&count_b);
210                Box::pin(async move {
211                    *count_b.lock().unwrap() += 1;
212                    Ok(())
213                })
214            });
215        }
216
217        bus.unsubscribe(handle_a);
218        bus.emit(envelope("Widget", ModelEventKind::Created))
219            .await
220            .unwrap();
221
222        assert_eq!(*count_a.lock().unwrap(), 0);
223        assert_eq!(*count_b.lock().unwrap(), 1);
224    }
225
226    #[tokio::test]
227    async fn unsubscribe_is_a_no_op_for_an_unknown_handle() {
228        let bus = CoolEventBus::default();
229        // Never subscribed anywhere; must not panic.
230        bus.unsubscribe(SubscriptionHandle {
231            topic: "Widget.created".to_owned(),
232            id: 42,
233        });
234    }
235
236    #[tokio::test]
237    async fn subscription_guard_unsubscribes_every_tracked_handle_on_drop() {
238        let bus = CoolEventBus::default();
239        let received = Arc::new(Mutex::new(0u32));
240
241        let mut guard = SubscriptionGuard::new(bus.clone());
242        for _ in 0..2 {
243            let received = Arc::clone(&received);
244            let handle = bus.subscribe("Widget", ModelEventKind::Created, move |_event| {
245                let received = Arc::clone(&received);
246                Box::pin(async move {
247                    *received.lock().unwrap() += 1;
248                    Ok(())
249                })
250            });
251            guard.track(handle);
252        }
253
254        bus.emit(envelope("Widget", ModelEventKind::Created))
255            .await
256            .unwrap();
257        assert_eq!(*received.lock().unwrap(), 2, "both handlers fired");
258
259        drop(guard);
260
261        bus.emit(envelope("Widget", ModelEventKind::Created))
262            .await
263            .unwrap();
264        assert_eq!(
265            *received.lock().unwrap(),
266            2,
267            "dropping the guard unsubscribed both handlers"
268        );
269    }
270
271    #[test]
272    fn model_event_try_from_envelope_still_works_alongside_the_bus_types() {
273        // Sanity check that this submodule split didn't break the
274        // sibling `ModelEvent`/`TryFrom` path in `events.rs`.
275        #[derive(serde::Deserialize)]
276        struct Widget {
277            id: i64,
278        }
279        let event =
280            ModelEvent::<Widget>::try_from(envelope("Widget", ModelEventKind::Created)).unwrap();
281        assert_eq!(event.data.id, 1);
282    }
283}