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}