mod.rs 242 lignes · 8213 octets
//! Generic pub/sub bus for real-time notifications.
//!
//! Central plumbing for features that need to push events to subscribed
//! consumers — Server-Sent Events streams, live progress bars, CI logs,
//! wall-activity feeds, etc. Each consumer app picks an event type `T` and
//! gets `publish` + filtered `subscribe` on top of a single in-memory
//! [`tokio::sync::broadcast`] channel.
//!
//! # Multi-instance deployments
//!
//! The built-in `InMemoryBus` is single-process only. For multi-instance
//! (horizontal scaling) the trait is stable enough to add a `redis-bus`
//! feature later with the same signatures — out of scope for v1.
//!
//! # Example
//!
//! ```no_run
//! use std::sync::Arc;
//! use rustwarden_core::notifications::{InMemoryBus, NotificationBus};
//!
//! #[derive(Clone, serde::Serialize)]
//! enum AppEvent {
//!     ImportProgress { job_id: uuid::Uuid, pct: u8 },
//!     Ping,
//! }
//!
//! # async fn usage() {
//! let bus: Arc<dyn NotificationBus<AppEvent>> = Arc::new(InMemoryBus::<AppEvent>::new(128));
//!
//! // Consumer: only "ImportProgress" events.
//! let filter = Arc::new(|e: &AppEvent| matches!(e, AppEvent::ImportProgress { .. }));
//! let _stream = bus.subscribe(filter);
//!
//! bus.publish(AppEvent::Ping).await;
//! # }
//! ```

pub mod sse;

use async_trait::async_trait;
use futures::stream::{Stream, StreamExt};
use std::pin::Pin;
use std::sync::Arc;
use tokio::sync::broadcast;
use tokio_stream::wrappers::BroadcastStream;

/// Filter predicate applied to each published event before delivery to a
/// subscriber. `Arc<dyn Fn>` so subscribers can share the filter without
/// boxing it again and so it is trivially clonable.
pub type EventFilter<T> = Arc<dyn Fn(&T) -> bool + Send + Sync>;

/// Abstract publish/subscribe bus for cloneable event types.
///
/// Consumers hold an `Arc<dyn NotificationBus<T>>` so the concrete backend
/// (in-memory today, Redis tomorrow) is swappable without touching call sites.
#[async_trait]
pub trait NotificationBus<T>: Send + Sync
where
    T: Clone + Send + Sync + 'static,
{
    /// Broadcast an event to every live subscriber. Drops the event silently
    /// when no subscriber is registered — the bus does not persist.
    async fn publish(&self, event: T);

    /// Subscribe with a filter predicate. Only events for which `filter`
    /// returns `true` reach the returned stream.
    ///
    /// A subscriber that falls behind the channel capacity drops the oldest
    /// missed events (standard `tokio::sync::broadcast` semantics); the
    /// stream keeps running.
    fn subscribe(&self, filter: EventFilter<T>) -> Pin<Box<dyn Stream<Item = T> + Send>>;
}

/// In-memory broadcast-backed implementation. Cheap, lock-free on the hot
/// path, suitable for single-process deployments.
pub struct InMemoryBus<T: Clone + Send + Sync + 'static> {
    tx: broadcast::Sender<T>,
}

impl<T: Clone + Send + Sync + 'static> InMemoryBus<T> {
    /// Build a bus with the given channel capacity.
    ///
    /// When more than `capacity` events pile up for a slow subscriber, the
    /// oldest are dropped — the stream yields nothing for them but does not
    /// terminate. Pick capacity proportional to peak burst rate × worst-case
    /// subscriber latency.
    pub fn new(capacity: usize) -> Self {
        let (tx, _) = broadcast::channel(capacity);
        Self { tx }
    }

    /// Current number of live subscribers. Useful for metrics / tests.
    pub fn receiver_count(&self) -> usize {
        self.tx.receiver_count()
    }
}

impl<T: Clone + Send + Sync + 'static> Default for InMemoryBus<T> {
    fn default() -> Self {
        Self::new(256)
    }
}

#[async_trait]
impl<T> NotificationBus<T> for InMemoryBus<T>
where
    T: Clone + Send + Sync + 'static,
{
    async fn publish(&self, event: T) {
        // `send` returns Err only when there are zero receivers — that's fine
        // for a transient bus (no persistence guarantee).
        let _ = self.tx.send(event);
    }

    fn subscribe(&self, filter: EventFilter<T>) -> Pin<Box<dyn Stream<Item = T> + Send>> {
        let rx = self.tx.subscribe();
        let stream = BroadcastStream::new(rx)
            // Drop Lagged events (slow consumer) instead of ending the stream.
            .filter_map(|res| async move { res.ok() })
            .filter(move |event| {
                let keep = filter(event);
                async move { keep }
            });
        Box::pin(stream)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::Duration;
    use tokio::time::timeout;

    #[derive(Clone, Debug, PartialEq, Eq)]
    enum TestEvent {
        Ping,
        Import { job: u32, pct: u8 },
    }

    fn pass_all<T>() -> EventFilter<T> {
        Arc::new(|_| true)
    }

    #[tokio::test]
    async fn publish_with_no_subscribers_is_noop() {
        let bus: InMemoryBus<TestEvent> = InMemoryBus::new(4);
        bus.publish(TestEvent::Ping).await;
        assert_eq!(bus.receiver_count(), 0);
    }

    #[tokio::test]
    async fn subscriber_receives_published_events() {
        let bus: InMemoryBus<TestEvent> = InMemoryBus::new(4);
        let mut stream = bus.subscribe(pass_all());

        bus.publish(TestEvent::Ping).await;
        bus.publish(TestEvent::Import { job: 1, pct: 50 }).await;

        let first = timeout(Duration::from_millis(200), stream.next())
            .await
            .unwrap()
            .unwrap();
        let second = timeout(Duration::from_millis(200), stream.next())
            .await
            .unwrap()
            .unwrap();

        assert_eq!(first, TestEvent::Ping);
        assert_eq!(second, TestEvent::Import { job: 1, pct: 50 });
    }

    #[tokio::test]
    async fn multiple_subscribers_each_receive_the_event() {
        let bus: InMemoryBus<TestEvent> = InMemoryBus::new(4);
        let mut a = bus.subscribe(pass_all());
        let mut b = bus.subscribe(pass_all());

        bus.publish(TestEvent::Ping).await;

        let a_got = timeout(Duration::from_millis(200), a.next())
            .await
            .unwrap()
            .unwrap();
        let b_got = timeout(Duration::from_millis(200), b.next())
            .await
            .unwrap()
            .unwrap();
        assert_eq!(a_got, TestEvent::Ping);
        assert_eq!(b_got, TestEvent::Ping);
    }

    #[tokio::test]
    async fn filter_drops_non_matching_events() {
        let bus: InMemoryBus<TestEvent> = InMemoryBus::new(4);
        let only_imports: EventFilter<TestEvent> =
            Arc::new(|e| matches!(e, TestEvent::Import { .. }));
        let mut stream = bus.subscribe(only_imports);

        bus.publish(TestEvent::Ping).await;
        bus.publish(TestEvent::Import { job: 7, pct: 10 }).await;

        let got = timeout(Duration::from_millis(200), stream.next())
            .await
            .unwrap()
            .unwrap();
        assert_eq!(got, TestEvent::Import { job: 7, pct: 10 });
    }

    #[tokio::test]
    async fn subscriber_drop_does_not_block_publisher() {
        let bus: InMemoryBus<TestEvent> = InMemoryBus::new(4);
        {
            let _stream = bus.subscribe(pass_all());
            // _stream drops at end of scope.
        }
        // Publisher must not hang or panic after the receiver is gone.
        bus.publish(TestEvent::Ping).await;
        assert_eq!(bus.receiver_count(), 0);
    }

    #[tokio::test]
    async fn slow_subscriber_loses_old_events_but_stream_keeps_going() {
        // Capacity 2 → third event evicts the first for a stalled consumer.
        let bus: InMemoryBus<TestEvent> = InMemoryBus::new(2);
        let mut stream = bus.subscribe(pass_all());

        for pct in [10, 20, 30, 40] {
            bus.publish(TestEvent::Import { job: 1, pct }).await;
        }

        // We should still eventually get a tail slice of events (exact number
        // depends on runtime scheduling, but it must be ≥ 1).
        let first = timeout(Duration::from_millis(200), stream.next())
            .await
            .unwrap()
            .unwrap();
        match first {
            TestEvent::Import { .. } => {}
            other => panic!("expected Import event, got {other:?}"),
        }
    }
}