Skip to main content

prospero_core/
distributed_bus.rs

1//! Clustered live distribution via Postgres `LISTEN/NOTIFY` — the doorbell.
2//!
3//! The owner replica appends an event to Postgres (durable), then
4//! `NOTIFY prospero_events '<stream_key>:<seq>'`. The payload is a *pointer*,
5//! not the event, so it sidesteps NOTIFY's ~8 KB cap and keeps Postgres the
6//! single source of truth. A subscriber on any replica holds one `LISTEN`
7//! connection; on each doorbell for its stream it `replay`s the delta from the
8//! durable store. See the topology design spec §3.2; clustered mode is
9//! durable-first (§4) — the live tail carries only what is durable.
10//!
11//! **Scaling note:** each `subscribe` holds one dedicated `LISTEN` connection
12//! from this bus's pool for the subscription's lifetime, so live subscribers and
13//! pool connections grow 1:1. The daemon gives the bus its own pool (the
14//! clustered seams use a pool each today; a single shared pool is a future
15//! tuning option), sized by [`DistributedBus::connect`] for the expected
16//! concurrent SSE fan-out. If that ceiling is ever hit, multiplex one listener
17//! across streams — deferred until measured.
18
19use std::sync::Arc;
20
21use sqlx::postgres::{PgListener, PgPool, PgPoolOptions};
22
23use crate::Result;
24use crate::bus::{BusEvent, BusSubscription, EventBus};
25use crate::event::FleetEvent;
26use crate::store::Store;
27
28/// Postgres `NOTIFY` channel for the event doorbell.
29const CHANNEL: &str = "prospero_events";
30
31/// Clustered `EventBus`: a doorbell over the durable store (spec §3.2).
32pub struct DistributedBus {
33    pool: PgPool,
34    store: Arc<dyn Store>,
35}
36
37impl DistributedBus {
38    /// Build a bus on its own pool. Each live subscription pins one connection
39    /// for its `LISTEN`, so the pool is sized above sqlx's default of 10 to
40    /// allow a useful SSE fan-out before `subscribe`/`publish` start queuing on
41    /// the pool; raise it further (or share a pool) for high-fan-out deployments.
42    pub async fn connect(url: &str, store: Arc<dyn Store>) -> Result<Self> {
43        let pool = PgPoolOptions::new()
44            .max_connections(32)
45            .connect(url)
46            .await
47            .map_err(|e| crate::error::CoreError::Store(format!("connecting to postgres: {e}")))?;
48        Ok(Self { pool, store })
49    }
50
51    /// Build a bus on an existing pool (shared-pool wiring — size the pool for
52    /// the SSE fan-out, since each subscription pins a `LISTEN` connection).
53    pub fn new(pool: PgPool, store: Arc<dyn Store>) -> Self {
54        Self { pool, store }
55    }
56}
57
58impl EventBus for DistributedBus {
59    fn publish(&self, event: FleetEvent) {
60        // Doorbell only: the event is already (best-effort) durable in Postgres.
61        // Payload is a pointer "<stream_key>:<seq>", never the event itself.
62        // Fire-and-forget (best-effort vs. the durable store, ADR-0004); a lost
63        // NOTIFY is recovered by the next doorbell's delta replay or the
64        // poll-fallback escape hatch (spec §3.2).
65        let pool = self.pool.clone();
66        let payload = format!("{}:{}", event.stream_key(), event.seq);
67        tokio::spawn(async move {
68            if let Err(e) = sqlx::query("SELECT pg_notify($1, $2)")
69                .bind(CHANNEL)
70                .bind(&payload)
71                .execute(&pool)
72                .await
73            {
74                tracing::warn!(target: "prospero_bus", error = %e, "pg_notify failed");
75            }
76        });
77    }
78
79    fn subscribe(&self, stream_key: &str) -> BusSubscription {
80        let pool = self.pool.clone();
81        let store = self.store.clone();
82        let key = stream_key.to_string();
83        Box::pin(async_stream::stream! {
84            // One dedicated LISTEN connection per subscriber.
85            let mut listener = match PgListener::connect_with(&pool).await {
86                Ok(l) => l,
87                Err(e) => {
88                    tracing::warn!(target: "prospero_bus", error = %e, "PgListener connect failed");
89                    return;
90                }
91            };
92            if let Err(e) = listener.listen(CHANNEL).await {
93                tracing::warn!(target: "prospero_bus", error = %e, "LISTEN failed");
94                return;
95            }
96
97            // Seed at 0, so the FIRST doorbell replays the whole durable stream
98            // and the consumer's `seq`-dedup drops the history overlap. Seeding
99            // from a late `high_water()` read instead would be a gap: this
100            // subscription's LISTEN + seed run lazily on first poll — AFTER the
101            // SSE handler has already read history — so an event appended in that
102            // window would be below a late high-water and never replayed by any
103            // later doorbell, yet also absent from the history snapshot. Seeding
104            // at 0 makes delivery independent of the subscribe-vs-history race
105            // (the cost is one deduped re-read of the stream on the first
106            // doorbell; subsequent doorbells replay only the delta as `last_seq`
107            // advances). A floor passed in by the consumer could bound this, but
108            // that is a future optimization, not a correctness need.
109            let mut last_seq = 0u64;
110
111            loop {
112                let notif = match listener.recv().await {
113                    Ok(n) => n,
114                    // `PgListener::recv` re-connects and re-LISTENs internally on a
115                    // dropped connection, so an `Err` here is a terminal listener
116                    // failure: end the subscription. The SSE client is responsible
117                    // for re-subscribing (and replays history on reconnect), so no
118                    // durable event is lost — consistent with the best-effort
119                    // doorbell posture (§3.2).
120                    Err(e) => {
121                        tracing::warn!(target: "prospero_bus", error = %e, "LISTEN recv failed");
122                        break;
123                    }
124                };
125                // Payload is "<stream_key>:<seq>"; stream keys may contain ':'
126                // (e.g. "repo:foo"), so split on the LAST colon.
127                let Some((nkey, _seq)) = notif.payload().rsplit_once(':') else {
128                    continue;
129                };
130                if nkey != key {
131                    continue; // doorbell for another stream
132                }
133                // Doorbell rung: replay the durable delta and advance.
134                match store.replay(&key, last_seq + 1).await {
135                    Ok(events) => {
136                        for ev in events {
137                            if ev.seq <= last_seq {
138                                continue;
139                            }
140                            last_seq = ev.seq;
141                            yield BusEvent::Event(ev);
142                        }
143                    }
144                    Err(e) => {
145                        tracing::warn!(target: "prospero_bus", error = %e, "doorbell replay failed");
146                    }
147                }
148            }
149        })
150    }
151
152    fn subscribe_all(&self) -> BusSubscription {
153        let pool = self.pool.clone();
154        let store = self.store.clone();
155        Box::pin(async_stream::stream! {
156            let mut listener = match PgListener::connect_with(&pool).await {
157                Ok(l) => l,
158                Err(e) => {
159                    tracing::warn!(target: "prospero_bus", error = %e, "PgListener connect failed");
160                    return;
161                }
162            };
163            if let Err(e) = listener.listen(CHANNEL).await {
164                tracing::warn!(target: "prospero_bus", error = %e, "LISTEN failed");
165                return;
166            }
167
168            // Unlike `subscribe` (one stream, one `last_seq`), an unfiltered
169            // doorbell can arrive for any stream key, so the high-water mark is
170            // tracked per key, seeded at 0 the same way and for the same reason
171            // (see `subscribe`'s comment): correctness over a late-seed race,
172            // at the cost of one deduped re-read per stream on its first
173            // doorbell.
174            let mut last_seq: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
175
176            loop {
177                let notif = match listener.recv().await {
178                    Ok(n) => n,
179                    Err(e) => {
180                        tracing::warn!(target: "prospero_bus", error = %e, "LISTEN recv failed");
181                        break;
182                    }
183                };
184                let Some((nkey, _seq)) = notif.payload().rsplit_once(':') else {
185                    continue;
186                };
187                let from = last_seq.get(nkey).copied().unwrap_or(0) + 1;
188                match store.replay(nkey, from).await {
189                    Ok(events) => {
190                        for ev in events {
191                            let cur = last_seq.entry(nkey.to_string()).or_insert(0);
192                            if ev.seq <= *cur {
193                                continue;
194                            }
195                            *cur = ev.seq;
196                            yield BusEvent::Event(ev);
197                        }
198                    }
199                    Err(e) => {
200                        tracing::warn!(target: "prospero_bus", error = %e, "doorbell replay failed");
201                    }
202                }
203            }
204        })
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::event::EventKind;
212    use crate::postgres_store::PostgresStore;
213    use std::time::Duration;
214    use tokio_stream::StreamExt;
215
216    fn ev(seq: u64, agent: &str) -> FleetEvent {
217        FleetEvent {
218            seq,
219            ts: "2026-06-18T00:00:00+00:00".into(),
220            repo: "r".into(),
221            agent_id: agent.into(),
222            kind: EventKind::AgentSpawned,
223        }
224    }
225
226    /// A process-unique agent id so the two gated tests — which share one
227    /// persistent Postgres DB and run in parallel — never collide on a stream
228    /// key (their NOTIFY payloads target distinct streams, and `high_water` /
229    /// `replay` are stream-scoped). Avoids a global TRUNCATE that would wipe a
230    /// sibling test's rows mid-run.
231    fn unique_agent(tag: &str) -> String {
232        use std::sync::atomic::{AtomicU64, Ordering};
233        static N: AtomicU64 = AtomicU64::new(0);
234        let n = N.fetch_add(1, Ordering::Relaxed);
235        let nanos = std::time::SystemTime::now()
236            .duration_since(std::time::UNIX_EPOCH)
237            .map(|d| d.as_nanos())
238            .unwrap_or(0);
239        format!("{tag}-{nanos}-{n}")
240    }
241
242    /// Serialize the Postgres-gated bus tests against each other. They share one
243    /// database and — critically — one NOTIFY channel: `subscribe_all` replays
244    /// from the store for *every* notification on that channel, so a sibling
245    /// test publishing concurrently floods this channel and, under the full
246    /// suite's CPU pressure, can starve `subscribe_all`'s doorbell loop until it
247    /// times out. Distinct `unique_agent` keys keep their *data* from colliding;
248    /// this guard keeps their *doorbell traffic* from colliding. Held across
249    /// awaits, so it must be a `tokio` mutex. Each test takes it right after the
250    /// `DATABASE_URL` guard (an unset-DB skip never contends).
251    static BUS_TEST_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
252
253    // Multi-threaded runtime: these tests spawn a consumer task and the bus
254    // spawns a pg_notify task per publish, all of which must make progress
255    // concurrently with the publish loop. On the default current-thread runtime
256    // they contend cooperatively on one thread and — under the slow, instrumented
257    // coverage build especially — can starve the listener so no doorbell is ever
258    // processed. Real threads keep the listener draining while we publish.
259    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
260    async fn doorbell_delivers_a_live_event_to_a_subscriber() {
261        let Ok(url) = std::env::var("DATABASE_URL") else {
262            eprintln!("SKIP doorbell_delivers_a_live_event_to_a_subscriber: DATABASE_URL unset");
263            return;
264        };
265        let _serial = BUS_TEST_SERIAL.lock().await;
266
267        let store = PostgresStore::connect(&url).await.unwrap();
268        let store: Arc<dyn Store> = Arc::new(store);
269        let bus = DistributedBus::connect(&url, store.clone()).await.unwrap();
270
271        let agent = unique_agent("agent-deliver");
272        let mut sub = bus.subscribe(&agent);
273
274        // The subscriber establishes its LISTEN connection lazily on first poll;
275        // that moment isn't observable, and a single fixed sleep races it under a
276        // slow/instrumented build (e.g. coverage). So drive delivery with a
277        // bounded retry — append an event and ring the doorbell until the
278        // (now-live) listener replays it.
279        let recv =
280            tokio::spawn(
281                async move { tokio::time::timeout(Duration::from_secs(30), sub.next()).await },
282            );
283
284        // Keep nudging until the subscriber actually receives an event, NOT for a
285        // fixed number of tries: under the full suite's CPU pressure the lazy
286        // LISTEN can take many seconds to come up, and if the nudges stop before
287        // then, nothing is ever replayed. The cap (~25s of nudging) sits under
288        // the 30s recv timeout so a genuine hang still fails rather than hangs.
289        let mut delivered = None;
290        for seq in 1..=250u64 {
291            let e = ev(seq, &agent);
292            store.append(&e).await.unwrap();
293            bus.publish(e);
294            tokio::time::sleep(Duration::from_millis(100)).await;
295            if recv.is_finished() {
296                delivered = Some(recv.await.unwrap().expect("doorbell timed out"));
297                break;
298            }
299        }
300
301        match delivered.expect("subscriber never received a doorbell event") {
302            Some(BusEvent::Event(ev)) => assert_eq!(ev.agent_id, agent),
303            other => panic!("expected a live event, got {other:?}"),
304        }
305    }
306
307    /// Regression for the cross-replica subscribe-window gap: the subscriber's
308    /// LISTEN + seed run lazily on first poll, so an event already durable
309    /// BEFORE that first doorbell (e.g. appended on the owner replica between a
310    /// reader replica's history read and its first poll) must still be
311    /// delivered. Seeding `last_seq` from 0 replays it; a late `high_water` seed
312    /// would skip it forever.
313    // Multi-threaded runtime: these tests spawn a consumer task and the bus
314    // spawns a pg_notify task per publish, all of which must make progress
315    // concurrently with the publish loop. On the default current-thread runtime
316    // they contend cooperatively on one thread and — under the slow, instrumented
317    // coverage build especially — can starve the listener so no doorbell is ever
318    // processed. Real threads keep the listener draining while we publish.
319    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
320    async fn delivers_an_event_that_predates_the_first_doorbell() {
321        let Ok(url) = std::env::var("DATABASE_URL") else {
322            eprintln!(
323                "SKIP delivers_an_event_that_predates_the_first_doorbell: DATABASE_URL unset"
324            );
325            return;
326        };
327        let _serial = BUS_TEST_SERIAL.lock().await;
328
329        let store = PostgresStore::connect(&url).await.unwrap();
330        let store: Arc<dyn Store> = Arc::new(store);
331        let bus = DistributedBus::connect(&url, store.clone()).await.unwrap();
332
333        let agent = unique_agent("agent-predate");
334        // Append the "window" event BEFORE the subscriber's first poll.
335        let early = ev(1, &agent);
336        store.append(&early).await.unwrap();
337
338        let mut sub = bus.subscribe(&agent);
339        let recv =
340            tokio::spawn(
341                async move { tokio::time::timeout(Duration::from_secs(30), sub.next()).await },
342            );
343
344        // Ring the doorbell until the (now-live) listener replays the delta;
345        // re-NOTIFY is idempotent (replay starts from last_seq+1 = 1). Keep
346        // nudging until it's delivered, not for a fixed window: under the full
347        // suite's CPU pressure the lazy LISTEN can come up well after a short
348        // fixed window would have stopped nudging, leaving nothing to replay it.
349        let mut delivered = None;
350        for _ in 0..250 {
351            bus.publish(early.clone());
352            tokio::time::sleep(Duration::from_millis(100)).await;
353            if recv.is_finished() {
354                delivered = Some(recv.await.unwrap().expect("doorbell timed out"));
355                break;
356            }
357        }
358
359        match delivered.expect("never delivered the pre-doorbell event") {
360            Some(BusEvent::Event(ev)) => {
361                assert_eq!(ev.agent_id, agent);
362                assert_eq!(
363                    ev.seq, 1,
364                    "the event appended before the first doorbell must arrive"
365                );
366            }
367            other => panic!("expected the early event, got {other:?}"),
368        }
369    }
370
371    // Multi-threaded runtime: these tests spawn a consumer task and the bus
372    // spawns a pg_notify task per publish, all of which must make progress
373    // concurrently with the publish loop. On the default current-thread runtime
374    // they contend cooperatively on one thread and — under the slow, instrumented
375    // coverage build especially — can starve the listener so no doorbell is ever
376    // processed. Real threads keep the listener draining while we publish.
377    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
378    async fn doorbell_ignores_other_streams() {
379        let Ok(url) = std::env::var("DATABASE_URL") else {
380            eprintln!("SKIP doorbell_ignores_other_streams: DATABASE_URL unset");
381            return;
382        };
383        let _serial = BUS_TEST_SERIAL.lock().await;
384
385        let store = PostgresStore::connect(&url).await.unwrap();
386        let store: Arc<dyn Store> = Arc::new(store);
387        let bus = DistributedBus::connect(&url, store.clone()).await.unwrap();
388
389        let ours = unique_agent("agent-ours");
390        let theirs = unique_agent("agent-theirs");
391        let mut sub = bus.subscribe(&ours);
392        let recv = tokio::spawn(async move {
393            tokio::time::timeout(Duration::from_millis(800), sub.next()).await
394        });
395        tokio::time::sleep(Duration::from_millis(400)).await;
396
397        // An event on a DIFFERENT stream: its doorbell must not wake our sub.
398        let other = ev(1, &theirs);
399        store.append(&other).await.unwrap();
400        bus.publish(other);
401
402        assert!(
403            recv.await.unwrap().is_err(),
404            "should have timed out (no event on our stream)"
405        );
406    }
407
408    /// `subscribe_all` (unlike `subscribe`) has no stream-key filter, and
409    /// tracks a `last_seq` per discovered key rather than one fixed key. This
410    /// is the opposite assertion from `doorbell_ignores_other_streams`: two
411    /// DIFFERENT streams must both reach one unfiltered subscription, and
412    /// ringing either doorbell repeatedly must not re-deliver an already-seen
413    /// event on either key (per-key high-water advances independently).
414    // Multi-threaded runtime: these tests spawn a consumer task and the bus
415    // spawns a pg_notify task per publish, all of which must make progress
416    // concurrently with the publish loop. On the default current-thread runtime
417    // they contend cooperatively on one thread and — under the slow, instrumented
418    // coverage build especially — can starve the listener so no doorbell is ever
419    // processed. Real threads keep the listener draining while we publish.
420    // Manual/local integration test (run with `cargo test -- --ignored`).
421    // `subscribe_all` replays from the store for EVERY doorbell on the shared
422    // NOTIFY channel, so on an oversubscribed CI runner — where many test
423    // binaries hammer one Postgres in parallel — its listener can be starved
424    // long enough that no doorbell is ever processed (observed: zero deliveries
425    // in 45s). Serializing the bus tests and a multi-thread runtime made it far
426    // more reliable but not deterministic on CI, so we keep it out of the CI
427    // gate rather than let it flake unrelated PRs. The `subscribe()` doorbell
428    // path (the same LISTEN/replay machinery, filtered) stays covered by the
429    // other three bus tests, which run in CI. Deterministic redesign: #132.
430    #[ignore = "environment-sensitive live-doorbell integration test; run with --ignored (see comment)"]
431    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
432    async fn subscribe_all_delivers_events_from_multiple_streams() {
433        let Ok(url) = std::env::var("DATABASE_URL") else {
434            eprintln!(
435                "SKIP subscribe_all_delivers_events_from_multiple_streams: DATABASE_URL unset"
436            );
437            return;
438        };
439        let _serial = BUS_TEST_SERIAL.lock().await;
440
441        let store = PostgresStore::connect(&url).await.unwrap();
442        let store: Arc<dyn Store> = Arc::new(store);
443        let bus = DistributedBus::connect(&url, store.clone()).await.unwrap();
444
445        let agent_a = unique_agent("agent-all-a");
446        let agent_b = unique_agent("agent-all-b");
447
448        // `subscribe_all` is global and unfiltered by design, so under a shared
449        // test database it also observes events from *sibling* tests running
450        // concurrently (their own `unique_agent(...)` streams). This test is
451        // only about OUR two streams: consume until both have arrived, skipping
452        // any foreign stream key (and lag signals), while asserting neither of
453        // ours is ever delivered twice. Taking "the first two events" verbatim
454        // would flake whenever a concurrent test's event interleaves first.
455        let mut sub = bus.subscribe_all();
456        let a_recv = agent_a.clone();
457        let b_recv = agent_b.clone();
458        let recv = tokio::spawn(async move {
459            let mut seen = std::collections::HashSet::new();
460            let deadline = tokio::time::Instant::now() + Duration::from_secs(45);
461            while seen.len() < 2 {
462                let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
463                match tokio::time::timeout(remaining, sub.next()).await {
464                    Ok(Some(BusEvent::Event(ev))) => {
465                        if ev.agent_id == a_recv || ev.agent_id == b_recv {
466                            assert!(
467                                seen.insert(ev.agent_id.clone()),
468                                "duplicate delivery for stream {} (per-key high-water not advancing)",
469                                ev.agent_id
470                            );
471                        }
472                        // Foreign stream keys (concurrent tests) are expected — skip them.
473                    }
474                    // Lag signals aren't a delivery of one of our streams — keep waiting.
475                    Ok(Some(BusEvent::Lagged(_))) => {}
476                    Ok(None) => panic!("subscription closed before both streams arrived"),
477                    Err(_) => panic!("doorbell timed out; saw {seen:?} of our two streams"),
478                }
479            }
480            seen
481        });
482
483        // Same bounded-retry doorbell-ring pattern as
484        // `doorbell_delivers_a_live_event_to_a_subscriber`: the LISTEN
485        // connection is established lazily on first poll, so ring both
486        // doorbells repeatedly (idempotent — replay starts from last_seq+1 per
487        // key). Crucially, keep ringing until the subscriber has actually
488        // consumed both events (`recv.is_finished()`), NOT for a fixed window:
489        // under a saturated runtime (the full parallel test suite) the
490        // subscribe_all task's lazy LISTEN can take several seconds to come up,
491        // and if the nudges stop before then, no later doorbell ever replays our
492        // rows and the subscriber times out having seen nothing.
493        let event_a = ev(1, &agent_a);
494        let event_b = ev(1, &agent_b);
495        store.append(&event_a).await.unwrap();
496        store.append(&event_b).await.unwrap();
497        for _ in 0..440 {
498            if recv.is_finished() {
499                break;
500            }
501            bus.publish(event_a.clone());
502            bus.publish(event_b.clone());
503            tokio::time::sleep(Duration::from_millis(100)).await;
504        }
505
506        let seen = recv.await.unwrap();
507        assert_eq!(
508            seen.len(),
509            2,
510            "subscribe_all must deliver events from both streams, unfiltered"
511        );
512    }
513}