Skip to main content

prospero_core/
bus.rs

1//! Live event distribution behind a trait.
2//!
3//! Standalone uses [`InProcessBus`] (a tokio broadcast channel); the clustered
4//! [`crate::DistributedBus`] (Postgres `LISTEN/NOTIFY`) drops in behind the same
5//! trait — see the topology design spec §3.2. Both expose the live tail as a
6//! per-stream [`BusSubscription`]; consumers dedup the history/live overlap on
7//! `seq`.
8
9use std::pin::Pin;
10
11use tokio::sync::broadcast;
12use tokio_stream::Stream;
13
14use crate::event::{FleetEvent, stream_key_for};
15
16/// One item from a per-stream live subscription (transport-agnostic).
17#[derive(Debug, Clone, PartialEq)]
18// `Event` is far larger than `Lagged`, which the lint flags because every
19// message costs the larger size. Boxing the payload is the usual fix and is
20// the wrong trade here: it would add an allocation to the path *every* agent's
21// output flows through, to save a handful of bytes on `Lagged` — which only
22// `InProcessBus` emits, and only when a subscriber falls behind.
23#[allow(clippy::large_enum_variant)]
24pub enum BusEvent {
25    /// A live event on the subscribed stream.
26    Event(FleetEvent),
27    /// `skipped` events were dropped for a slow local subscriber; the consumer
28    /// must self-heal by replaying from the durable store. Only [`InProcessBus`]
29    /// emits this — the clustered bus reads the store on every doorbell, so it
30    /// cannot lag.
31    Lagged(u64),
32}
33
34/// A live, per-stream subscription: an ordered stream of [`BusEvent`]s for one
35/// stream key. Ends (`None`) when the bus is gone.
36pub type BusSubscription = Pin<Box<dyn Stream<Item = BusEvent> + Send>>;
37
38/// Publishes events to live subscribers.
39pub trait EventBus: Send + Sync {
40    /// Fan an event out to current subscribers. Never blocks on slow/absent
41    /// receivers (delivery is best-effort relative to the durable store).
42    fn publish(&self, event: FleetEvent);
43
44    /// A live subscription to one stream's events. The returned stream yields
45    /// only events whose stream key equals `stream_key`; consumers dedup the
46    /// initial-history/live overlap on `seq`.
47    fn subscribe(&self, stream_key: &str) -> BusSubscription;
48
49    /// A live subscription to EVERY stream's events, unfiltered. Needed by
50    /// fleet-wide watchers (e.g. `FleetManager::watch_changes`) that can't name
51    /// a stream key in advance — a brand-new agent's own id keys its
52    /// `AgentDiscovered` event, so no one can pre-subscribe to it by key.
53    fn subscribe_all(&self) -> BusSubscription;
54}
55
56/// In-process broadcast bus — the standalone implementation.
57pub struct InProcessBus {
58    tx: broadcast::Sender<FleetEvent>,
59}
60
61impl InProcessBus {
62    /// A bus buffering up to `capacity` events for slow subscribers.
63    pub fn new(capacity: usize) -> Self {
64        let (tx, _rx) = broadcast::channel(capacity);
65        Self { tx }
66    }
67}
68
69impl EventBus for InProcessBus {
70    fn publish(&self, event: FleetEvent) {
71        // No subscribers is fine; ignore the send error.
72        let _ = self.tx.send(event);
73    }
74
75    fn subscribe(&self, stream_key: &str) -> BusSubscription {
76        // Register the broadcast receiver EAGERLY (synchronously, here) so it
77        // captures events from this point — before the caller reads initial
78        // history — even though the stream body below is polled lazily.
79        let mut rx = self.tx.subscribe();
80        let key = stream_key.to_string();
81        Box::pin(async_stream::stream! {
82            loop {
83                match rx.recv().await {
84                    Ok(ev) if stream_key_for(&ev.repo, &ev.agent_id) == key => {
85                        yield BusEvent::Event(ev);
86                    }
87                    Ok(_) => continue, // an event on a different stream
88                    Err(broadcast::error::RecvError::Lagged(n)) => {
89                        yield BusEvent::Lagged(n);
90                    }
91                    Err(broadcast::error::RecvError::Closed) => break,
92                }
93            }
94        })
95    }
96
97    fn subscribe_all(&self) -> BusSubscription {
98        // Same eager-registration discipline as `subscribe`, just without the
99        // per-key filter.
100        let mut rx = self.tx.subscribe();
101        Box::pin(async_stream::stream! {
102            loop {
103                match rx.recv().await {
104                    Ok(ev) => yield BusEvent::Event(ev),
105                    Err(broadcast::error::RecvError::Lagged(n)) => {
106                        yield BusEvent::Lagged(n);
107                    }
108                    Err(broadcast::error::RecvError::Closed) => break,
109                }
110            }
111        })
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::event::EventKind;
119    use tokio_stream::StreamExt;
120
121    fn ev_for(seq: u64, agent: &str) -> FleetEvent {
122        FleetEvent {
123            seq,
124            ts: "t".into(),
125            repo: "r".into(),
126            agent_id: agent.into(),
127            kind: EventKind::AgentSpawned,
128            actor: None,
129            on_behalf_of: None,
130        }
131    }
132
133    #[tokio::test]
134    async fn publish_reaches_a_subscriber_on_its_stream() {
135        let bus = InProcessBus::new(8);
136        let mut sub = bus.subscribe("a");
137        bus.publish(ev_for(1, "a"));
138        assert_eq!(sub.next().await, Some(BusEvent::Event(ev_for(1, "a"))));
139    }
140
141    #[tokio::test]
142    async fn subscriber_only_sees_its_own_stream() {
143        let bus = InProcessBus::new(8);
144        let mut sub = bus.subscribe("a");
145        bus.publish(ev_for(1, "b")); // other stream — filtered out
146        bus.publish(ev_for(2, "a")); // our stream — delivered
147        assert_eq!(sub.next().await, Some(BusEvent::Event(ev_for(2, "a"))));
148    }
149
150    #[tokio::test]
151    async fn publish_with_no_subscriber_is_a_noop() {
152        let bus = InProcessBus::new(8);
153        bus.publish(ev_for(1, "a")); // must not panic
154    }
155
156    #[tokio::test]
157    async fn subscribe_all_sees_every_stream_unfiltered() {
158        let bus = InProcessBus::new(8);
159        let mut sub = bus.subscribe_all();
160        bus.publish(ev_for(1, "a"));
161        bus.publish(ev_for(2, "b"));
162        assert_eq!(sub.next().await, Some(BusEvent::Event(ev_for(1, "a"))));
163        assert_eq!(sub.next().await, Some(BusEvent::Event(ev_for(2, "b"))));
164    }
165
166    // ---- #8: bounded-buffer behaviour under pressure -----------------------
167    //
168    // These are deliberately *not* timing tests. `broadcast` buffers on `send`,
169    // so a receiver that is registered and then not polled falls behind by an
170    // amount the test chooses exactly. That makes the drop count, the surviving
171    // window, and the recovery all assertable without sleeping or racing —
172    // which is the mistake that got the last soak-shaped test `#[ignore]`d
173    // (#132).
174
175    /// Capacity 4, 10 events published before a single poll: the subscriber
176    /// must be told it lost the 6 oldest rather than silently skipping them,
177    /// must then receive exactly the 4 the buffer still holds, and must keep
178    /// working afterwards. That last part is the one that matters — a lag is
179    /// only acceptable if the subscription survives it.
180    #[tokio::test]
181    async fn a_slow_subscriber_drops_a_bounded_prefix_then_keeps_working() {
182        let bus = InProcessBus::new(4);
183        let mut sub = bus.subscribe_all();
184
185        for seq in 1..=10 {
186            bus.publish(ev_for(seq, "a"));
187        }
188
189        assert_eq!(
190            sub.next().await,
191            Some(BusEvent::Lagged(6)),
192            "10 published into a 4-deep buffer must report exactly the 6 dropped"
193        );
194        for seq in 7..=10 {
195            assert_eq!(
196                sub.next().await,
197                Some(BusEvent::Event(ev_for(seq, "a"))),
198                "the buffer's surviving window is the newest `capacity` events"
199            );
200        }
201
202        // Liveness after the drop: the subscription self-heals rather than
203        // wedging or ending.
204        bus.publish(ev_for(11, "a"));
205        assert_eq!(
206            sub.next().await,
207            Some(BusEvent::Event(ev_for(11, "a"))),
208            "a subscription must survive its own lag"
209        );
210    }
211
212    /// The other half of the contract: a consumer that keeps up is never
213    /// penalised. Interleaving publish and receive keeps the receiver at most
214    /// one event behind, so no `Lagged` may appear at any point.
215    #[tokio::test]
216    async fn a_subscriber_that_keeps_up_never_lags() {
217        let bus = InProcessBus::new(4);
218        let mut sub = bus.subscribe_all();
219
220        for seq in 1..=64 {
221            bus.publish(ev_for(seq, "a"));
222            assert_eq!(
223                sub.next().await,
224                Some(BusEvent::Event(ev_for(seq, "a"))),
225                "a consumer draining each event must never be told it lagged"
226            );
227        }
228    }
229
230    /// Fan-out under pressure: every subscriber has its own read position, so
231    /// one slow consumer must not corrupt or starve the others. All 64 fall
232    /// behind identically here, and each must independently report the same
233    /// bounded loss and then recover — no shared cursor, no cross-talk.
234    #[tokio::test]
235    async fn many_concurrent_subscribers_each_lag_independently_and_recover() {
236        const SUBSCRIBERS: usize = 64;
237        let bus = InProcessBus::new(4);
238        let mut subs: Vec<_> = (0..SUBSCRIBERS).map(|_| bus.subscribe_all()).collect();
239
240        for seq in 1..=10 {
241            bus.publish(ev_for(seq, "a"));
242        }
243        bus.publish(ev_for(11, "a"));
244
245        for (i, sub) in subs.iter_mut().enumerate() {
246            assert_eq!(
247                sub.next().await,
248                Some(BusEvent::Lagged(7)),
249                "subscriber {i} must report its own loss, not a shared one"
250            );
251            for seq in 8..=11 {
252                assert_eq!(
253                    sub.next().await,
254                    Some(BusEvent::Event(ev_for(seq, "a"))),
255                    "subscriber {i} must still receive the surviving window"
256                );
257            }
258        }
259    }
260
261    /// A subscriber that goes away mid-pressure must not affect the ones that
262    /// remain — dropping a receiver is normal SSE client behaviour (a closed
263    /// browser tab), not an error the bus should propagate.
264    #[tokio::test]
265    async fn dropping_one_subscriber_under_pressure_does_not_disturb_the_rest() {
266        let bus = InProcessBus::new(4);
267        let mut staying = bus.subscribe_all();
268        let leaving = bus.subscribe_all();
269
270        bus.publish(ev_for(1, "a"));
271        drop(leaving);
272        bus.publish(ev_for(2, "a"));
273
274        assert_eq!(staying.next().await, Some(BusEvent::Event(ev_for(1, "a"))));
275        assert_eq!(staying.next().await, Some(BusEvent::Event(ev_for(2, "a"))));
276    }
277}