feat(msg-bus): prepare engine

This commit is contained in:
Edouard Vanbelle
2026-09-10 00:02:30 +02:00
parent 711b00db2a
commit 1b824cb45c
13 changed files with 2650 additions and 17 deletions
@@ -0,0 +1,347 @@
//! In-process `RealtimeBus` — one `broadcast::Sender` per active topic,
//! held in a [`DashMap`] keyed by [`Topic`]. Publish is fire-and-forget,
//! subscribe returns a `Stream` backed by [`BroadcastStream`].
//!
//! # Slow-subscriber policy
//!
//! `tokio::sync::broadcast` drops the oldest queued message when a
//! subscriber can't keep up (ring is bounded to [`BROADCAST_RING_CAPACITY`]).
//! When a subscriber's stream sees a `Lagged` marker, the WS handler kills
//! that session with a JSON-RPC `rt.revoked` notification (reason
//! `slow_consumer`) and lets the client reconnect + refetch. That policy
//! lives in the handler; this module just surfaces the `Lagged` variant.
//!
//! # Topic GC
//!
//! When the last subscriber of a topic drops, `broadcast::Sender::receiver_count`
//! falls to zero. New publishes on that topic still succeed (they hit the
//! now-orphaned sender), but the entry stays in the map. A background GC
//! task periodically sweeps entries whose `receiver_count == 0`. Kept
//! simple: no ref-count tracking, no watchdog — a sweep every
//! [`GC_INTERVAL`] is enough for our fan-out volume.
use std::sync::Arc;
use std::time::Duration;
use dashmap::DashMap;
use futures::StreamExt;
use tokio::sync::broadcast;
use tokio_stream::wrappers::BroadcastStream;
use crate::application::ports::realtime_ports::{
BusReplicator, BusStream, RealtimeBus, RealtimeEvent, Topic,
};
/// Per-topic ring-buffer size for slow subscribers. When a subscriber lags
/// past this, the broadcast channel starts dropping the oldest messages and
/// signals `Lagged`. Sized generously — fan-out volume per topic is low
/// (folder mutations, job step ticks) so pressure comes from a genuinely
/// dead consumer, not from a normal traffic spike.
pub const BROADCAST_RING_CAPACITY: usize = 256;
/// How often the GC task sweeps empty topics. Short enough that a burst of
/// short-lived subs (folder navigations) doesn't grow the map indefinitely,
/// long enough that GC overhead stays trivial.
pub const GC_INTERVAL: Duration = Duration::from_secs(60);
/// The in-process implementation of [`RealtimeBus`].
///
/// Callers hold `Arc<InProcessRealtimeBus>` (or `Arc<dyn RealtimeBus>`).
/// The struct owns its topic map and — when constructed via
/// [`InProcessRealtimeBus::with_replicator`] — an [`Arc<dyn BusReplicator>`]
/// that gets fed every local publish for outbound broker forwarding.
pub struct InProcessRealtimeBus {
topics: DashMap<Topic, broadcast::Sender<RealtimeEvent>>,
replicator: Arc<dyn BusReplicator>,
}
impl InProcessRealtimeBus {
/// Construct with a replicator. In v1 that's a
/// [`crate::application::ports::realtime_ports::NoopReplicator`]; when
/// multi-instance ships, it becomes the pg-NOTIFY or broker impl.
///
/// The GC task holds a [`Weak`] handle so it exits naturally when the
/// last outer `Arc` drops — matches OxiCloud's DI convention that
/// background tasks are dropped on runtime shutdown, no explicit
/// signal needed.
pub fn with_replicator(replicator: Arc<dyn BusReplicator>) -> Arc<Self> {
let bus = Arc::new(Self {
topics: DashMap::new(),
replicator,
});
bus.spawn_gc();
bus
}
/// Spawn the periodic GC task. Holds `Weak<Self>` so it does not keep
/// the bus alive past the last outer `Arc` drop; the next
/// upgrade-and-sweep call after that returns `None` and the loop
/// exits.
fn spawn_gc(self: &Arc<Self>) {
let weak = Arc::downgrade(self);
tokio::spawn(async move {
let mut ticker = tokio::time::interval(GC_INTERVAL);
// First tick fires immediately; skip it so we don't sweep an
// empty map on startup.
ticker.tick().await;
loop {
ticker.tick().await;
match weak.upgrade() {
Some(bus) => bus.gc_empty_topics(),
None => break,
}
}
});
}
/// Remove topics whose broadcast sender has no live receivers. Called
/// on the GC ticker.
fn gc_empty_topics(&self) {
self.topics
.retain(|_topic, sender| sender.receiver_count() > 0);
}
/// For tests + observability: how many topics currently have a
/// broadcast sender in the map.
pub fn active_topic_count(&self) -> usize {
self.topics.len()
}
/// Get-or-insert the broadcast sender for `topic`, returning a fresh
/// receiver. Used by both `publish` (for the sender) and `subscribe`
/// (for the receiver) — one code path for the map insert avoids a race
/// where publish creates a sender concurrent subscribers miss.
fn sender_for(&self, topic: &Topic) -> broadcast::Sender<RealtimeEvent> {
self.topics
.entry(*topic)
.or_insert_with(|| broadcast::channel(BROADCAST_RING_CAPACITY).0)
.clone()
}
}
impl RealtimeBus for InProcessRealtimeBus {
fn publish(&self, topic: &Topic, event: RealtimeEvent) {
// Feed the replicator FIRST — if it were called after local fan-out,
// an unwind on a broken subscriber could skip broker forwarding.
// `on_local_publish` is a sync fire-and-forget contract; slow
// replicators must background their I/O themselves.
self.replicator.on_local_publish(topic, &event);
// If nobody is subscribed, don't allocate a sender just to drop
// its message. `broadcast::Sender::send` returns Err when there
// are no receivers — cheaper still to short-circuit here.
if let Some(sender) = self.topics.get(topic) {
// `send` never blocks; it drops the oldest when the ring is
// full, signalling `Lagged` on that subscriber's next recv.
let _ = sender.send(event);
}
// Else: no active subs. Event is lost by design (see
// `docs/plan/message-bus.md § Failure modes`).
}
fn subscribe(&self, topic: &Topic) -> BusStream {
let receiver = self.sender_for(topic).subscribe();
// `BroadcastStream` yields `Result<T, BroadcastStreamRecvError>`;
// filter out the `Lagged` variant here and terminate the stream on
// it so the WS handler sees a clean "the stream ended" signal
// rather than having to match on the error. The handler is
// responsible for emitting the `rt.revoked` notification with
// `slow_consumer` reason on such a termination.
let stream = BroadcastStream::new(receiver).take_while(|item| {
let keep = item.is_ok();
async move { keep }
});
Box::pin(stream.filter_map(|item| async move { item.ok() }))
}
}
// ════════════════════════════════════════════════════════════════════════════
// Tests
// ════════════════════════════════════════════════════════════════════════════
#[cfg(test)]
mod tests {
use super::*;
use crate::application::ports::realtime_ports::NoopReplicator;
use futures::StreamExt;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::Notify;
use uuid::Uuid;
fn make_bus() -> Arc<InProcessRealtimeBus> {
InProcessRealtimeBus::with_replicator(Arc::new(NoopReplicator))
}
fn folder_topic() -> Topic {
Topic::Folder(Uuid::new_v4())
}
fn file_created(parent_id: Uuid) -> RealtimeEvent {
RealtimeEvent::FileCreated {
file_id: Uuid::new_v4(),
name: "a.txt".into(),
parent_id,
actor: Uuid::new_v4(),
}
}
/// Positive fan-out: a subscriber to a topic receives an event
/// published on that same topic.
#[tokio::test]
async fn subscriber_receives_publish_on_same_topic() {
let bus = make_bus();
let topic = folder_topic();
let mut stream = bus.subscribe(&topic);
// Give the subscriber a moment to install (broadcast::Sender::send
// silently fails against a not-yet-installed receiver; the
// subscribe() call above is synchronous but the receiver still
// needs to be registered on the sender's side before publish).
let parent = match topic {
Topic::Folder(id) => id,
_ => unreachable!(),
};
let event = file_created(parent);
bus.publish(&topic, event.clone());
let received = tokio::time::timeout(Duration::from_millis(200), stream.next())
.await
.expect("event should arrive within 200ms")
.expect("stream must yield at least once");
assert_eq!(received, event);
}
/// Topic isolation: a subscriber to folder A does not receive events
/// published on folder B. This is the invariant the smoke test's
/// Scenario 2 asserts end-to-end; verifying it in-unit here catches
/// bugs early.
#[tokio::test]
async fn subscriber_does_not_receive_other_topic() {
let bus = make_bus();
let topic_a = folder_topic();
let topic_b = folder_topic();
assert_ne!(topic_a, topic_b);
let mut stream_a = bus.subscribe(&topic_a);
let parent_b = match topic_b {
Topic::Folder(id) => id,
_ => unreachable!(),
};
let event_b = file_created(parent_b);
bus.publish(&topic_b, event_b);
// The A subscriber must NOT see B's event. Poll with a short
// timeout — if the isolation is broken we'll see the event; if
// it holds we'll time out.
let result = tokio::time::timeout(Duration::from_millis(100), stream_a.next()).await;
assert!(
result.is_err(),
"subscriber to topic A must not observe events published on topic B \
(got {:?})",
result.ok().flatten()
);
}
/// Multiple subscribers to the same topic all see each publish.
#[tokio::test]
async fn multi_subscriber_fanout() {
let bus = make_bus();
let topic = folder_topic();
let mut s1 = bus.subscribe(&topic);
let mut s2 = bus.subscribe(&topic);
let parent = match topic {
Topic::Folder(id) => id,
_ => unreachable!(),
};
let event = file_created(parent);
bus.publish(&topic, event.clone());
let r1 = tokio::time::timeout(Duration::from_millis(200), s1.next())
.await
.unwrap()
.unwrap();
let r2 = tokio::time::timeout(Duration::from_millis(200), s2.next())
.await
.unwrap()
.unwrap();
assert_eq!(r1, event);
assert_eq!(r2, event);
}
/// Publishing with no subscribers is a no-op (does not panic, does not
/// grow the map into an orphan-sender state we later have to sweep).
#[tokio::test]
async fn publish_with_no_subscribers_is_noop() {
let bus = make_bus();
let topic = folder_topic();
let parent = match topic {
Topic::Folder(id) => id,
_ => unreachable!(),
};
bus.publish(&topic, file_created(parent));
assert_eq!(
bus.active_topic_count(),
0,
"publish without any subscribe must not insert into topics map"
);
}
/// The replicator sees every publish, exactly once per publish.
/// Locks in the "feed replicator FIRST" contract without asserting on
/// broker semantics we don't control from unit-tests.
#[tokio::test]
async fn replicator_is_notified_on_publish() {
struct CountingReplicator {
count: AtomicUsize,
}
#[async_trait::async_trait]
impl BusReplicator for CountingReplicator {
fn on_local_publish(&self, _topic: &Topic, _event: &RealtimeEvent) {
self.count.fetch_add(1, Ordering::SeqCst);
}
async fn run(
self: Arc<Self>,
shutdown: Arc<Notify>,
) -> Result<(), crate::common::errors::DomainError> {
shutdown.notified().await;
Ok(())
}
}
let counter = Arc::new(CountingReplicator {
count: AtomicUsize::new(0),
});
let bus = InProcessRealtimeBus::with_replicator(Arc::clone(&counter) as Arc<_>);
let topic = folder_topic();
let _sub = bus.subscribe(&topic);
let parent = match topic {
Topic::Folder(id) => id,
_ => unreachable!(),
};
bus.publish(&topic, file_created(parent));
bus.publish(&topic, file_created(parent));
bus.publish(&topic, file_created(parent));
assert_eq!(counter.count.load(Ordering::SeqCst), 3);
}
/// Dropping the last subscriber leaves the topic sender orphaned until
/// the GC sweeps it. We don't wait for the timer here (that would make
/// the test slow); instead we call `gc_empty_topics` directly to
/// verify the sweep does what it promises.
#[tokio::test]
async fn gc_removes_empty_topics() {
let bus = make_bus();
let topic = folder_topic();
{
let _sub = bus.subscribe(&topic);
assert_eq!(bus.active_topic_count(), 1);
}
// Subscriber dropped. Sender is still in the map, but receiver
// count is zero — the sweep should reclaim it.
bus.gc_empty_topics();
assert_eq!(bus.active_topic_count(), 0);
}
}
+1
View File
@@ -27,6 +27,7 @@ pub mod files_consistency_service;
pub mod folders_consistency_service;
pub mod grant_cleanup_service;
pub mod image_transcode_service;
pub mod in_process_realtime_bus;
pub mod jwt_service;
pub mod last_seen_tracker;
pub mod local_blob_backend;