Skip to main content

oximedia_distributed/
notifications.rs

1//! WebSocket-style real-time job status notification bus.
2//!
3//! Provides an in-process publish/subscribe channel for job lifecycle events.
4//! Subscribers receive events via [`tokio::sync::broadcast`] receivers, which
5//! are cheaply cloneable and work across async tasks.
6//!
7//! # Example
8//!
9//! ```rust
10//! use oximedia_distributed::notifications::{NotificationBus, JobEventType, JobEvent};
11//! use uuid::Uuid;
12//! use std::time::SystemTime;
13//!
14//! let bus = NotificationBus::new(64);
15//! let mut rx = bus.subscribe();
16//!
17//! let event = JobEvent {
18//!     job_id: Uuid::new_v4(),
19//!     event_type: JobEventType::Queued,
20//!     timestamp: SystemTime::now(),
21//! };
22//! bus.send(event.clone());
23//! ```
24
25use std::time::SystemTime;
26use tokio::sync::broadcast;
27use uuid::Uuid;
28
29/// Event types for job lifecycle transitions.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
31pub enum JobEventType {
32    /// Job has been accepted into the queue.
33    Queued,
34    /// Job has been assigned to a worker and started.
35    Started,
36    /// Job finished successfully.
37    Completed,
38    /// Job failed (exceeded retries or fatal error).
39    Failed,
40    /// Job was explicitly cancelled.
41    Cancelled,
42}
43
44impl JobEventType {
45    /// Human-readable label for this event type.
46    #[must_use]
47    pub fn label(self) -> &'static str {
48        match self {
49            Self::Queued => "queued",
50            Self::Started => "started",
51            Self::Completed => "completed",
52            Self::Failed => "failed",
53            Self::Cancelled => "cancelled",
54        }
55    }
56
57    /// Returns `true` if this event represents a terminal state.
58    #[must_use]
59    pub fn is_terminal(self) -> bool {
60        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
61    }
62}
63
64impl std::fmt::Display for JobEventType {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        write!(f, "{}", self.label())
67    }
68}
69
70/// A job lifecycle event published on the [`NotificationBus`].
71#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
72pub struct JobEvent {
73    /// The job this event refers to.
74    pub job_id: Uuid,
75    /// What happened to the job.
76    pub event_type: JobEventType,
77    /// Wall-clock time of the event.
78    #[serde(with = "system_time_serde")]
79    pub timestamp: SystemTime,
80}
81
82impl JobEvent {
83    /// Create a new event with the current system time.
84    #[must_use]
85    pub fn new(job_id: Uuid, event_type: JobEventType) -> Self {
86        Self {
87            job_id,
88            event_type,
89            timestamp: SystemTime::now(),
90        }
91    }
92
93    /// Returns `true` if this is a terminal event.
94    #[must_use]
95    pub fn is_terminal(&self) -> bool {
96        self.event_type.is_terminal()
97    }
98}
99
100mod system_time_serde {
101    use serde::{Deserialize, Deserializer, Serializer};
102    use std::time::{Duration, SystemTime, UNIX_EPOCH};
103
104    pub fn serialize<S: Serializer>(t: &SystemTime, s: S) -> Result<S::Ok, S::Error> {
105        let epoch_secs = t
106            .duration_since(UNIX_EPOCH)
107            .unwrap_or(Duration::ZERO)
108            .as_secs();
109        s.serialize_u64(epoch_secs)
110    }
111
112    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SystemTime, D::Error> {
113        let secs = u64::deserialize(d)?;
114        Ok(UNIX_EPOCH + Duration::from_secs(secs))
115    }
116}
117
118/// Publish/subscribe bus for [`JobEvent`]s.
119///
120/// Built on a [`tokio::sync::broadcast`] channel; all subscribers receive every
121/// event. Lagged subscribers (that fall more than `capacity` events behind) will
122/// receive a [`broadcast::error::RecvError::Lagged`] error and must re-subscribe
123/// if they need to continue receiving.
124pub struct NotificationBus {
125    tx: broadcast::Sender<JobEvent>,
126}
127
128impl NotificationBus {
129    /// Create a new bus with the given ring-buffer capacity.
130    ///
131    /// A reasonable default is 64–256 events.  When the buffer fills, the oldest
132    /// events are dropped and late subscribers receive a `Lagged` error.
133    #[must_use]
134    pub fn new(capacity: usize) -> Self {
135        let (tx, _) = broadcast::channel(capacity);
136        Self { tx }
137    }
138
139    /// Publish an event to all current subscribers.
140    ///
141    /// Returns the number of active receivers that received the event.
142    /// Returns 0 if no subscribers are active (not an error).
143    pub fn send(&self, event: JobEvent) -> usize {
144        self.tx.send(event).unwrap_or(0)
145    }
146
147    /// Subscribe to future events.
148    ///
149    /// The returned receiver will receive all events published **after** this
150    /// call.  Clone the receiver to fan it out to multiple async tasks.
151    #[must_use]
152    pub fn subscribe(&self) -> broadcast::Receiver<JobEvent> {
153        self.tx.subscribe()
154    }
155
156    /// Returns the number of active subscribers.
157    #[must_use]
158    pub fn subscriber_count(&self) -> usize {
159        self.tx.receiver_count()
160    }
161
162    /// Create a convenience event and send it in one call.
163    pub fn notify(&self, job_id: Uuid, event_type: JobEventType) -> usize {
164        self.send(JobEvent::new(job_id, event_type))
165    }
166}
167
168impl std::fmt::Debug for NotificationBus {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        f.debug_struct("NotificationBus")
171            .field("subscriber_count", &self.tx.receiver_count())
172            .finish()
173    }
174}
175
176impl Default for NotificationBus {
177    fn default() -> Self {
178        Self::new(64)
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use tokio::sync::broadcast::error::TryRecvError;
186
187    #[tokio::test]
188    async fn test_notification_bus_subscriber_receives_events() {
189        let bus = NotificationBus::new(16);
190        let mut rx = bus.subscribe();
191
192        let job_id = Uuid::new_v4();
193        bus.notify(job_id, JobEventType::Queued);
194
195        let event = rx.recv().await.expect("should receive event");
196        assert_eq!(event.job_id, job_id);
197        assert_eq!(event.event_type, JobEventType::Queued);
198    }
199
200    #[tokio::test]
201    async fn test_notification_multiple_subscribers() {
202        let bus = NotificationBus::new(16);
203        let mut rx1 = bus.subscribe();
204        let mut rx2 = bus.subscribe();
205
206        let job_id = Uuid::new_v4();
207        let sent = bus.notify(job_id, JobEventType::Started);
208        // Both receivers registered before send
209        assert_eq!(sent, 2);
210
211        let e1 = rx1.recv().await.expect("rx1 should receive");
212        let e2 = rx2.recv().await.expect("rx2 should receive");
213        assert_eq!(e1.event_type, JobEventType::Started);
214        assert_eq!(e2.event_type, JobEventType::Started);
215    }
216
217    #[test]
218    fn test_no_subscriber_send_returns_zero() {
219        let bus = NotificationBus::new(8);
220        // No subscribers registered
221        let sent = bus.notify(Uuid::new_v4(), JobEventType::Completed);
222        assert_eq!(sent, 0);
223    }
224
225    #[tokio::test]
226    async fn test_terminal_event_types() {
227        assert!(JobEventType::Completed.is_terminal());
228        assert!(JobEventType::Failed.is_terminal());
229        assert!(JobEventType::Cancelled.is_terminal());
230        assert!(!JobEventType::Queued.is_terminal());
231        assert!(!JobEventType::Started.is_terminal());
232    }
233
234    #[tokio::test]
235    async fn test_event_sequence_in_order() {
236        let bus = NotificationBus::new(16);
237        let mut rx = bus.subscribe();
238
239        let job_id = Uuid::new_v4();
240        let types = [
241            JobEventType::Queued,
242            JobEventType::Started,
243            JobEventType::Completed,
244        ];
245        for &t in &types {
246            bus.notify(job_id, t);
247        }
248
249        for &expected in &types {
250            let event = rx.recv().await.expect("should receive");
251            assert_eq!(event.event_type, expected);
252        }
253    }
254
255    #[test]
256    fn test_subscriber_count_tracks_receivers() {
257        let bus = NotificationBus::new(8);
258        assert_eq!(bus.subscriber_count(), 0);
259        let _rx1 = bus.subscribe();
260        assert_eq!(bus.subscriber_count(), 1);
261        let _rx2 = bus.subscribe();
262        assert_eq!(bus.subscriber_count(), 2);
263    }
264
265    #[tokio::test]
266    async fn test_dropped_subscriber_does_not_receive() {
267        let bus = NotificationBus::new(8);
268        let mut rx = bus.subscribe();
269
270        // Drop a second subscriber; it should not affect the first
271        {
272            let _dropped = bus.subscribe();
273        }
274
275        bus.notify(Uuid::new_v4(), JobEventType::Failed);
276        let event = rx.try_recv();
277        assert!(event.is_ok() || matches!(event, Err(TryRecvError::Empty)));
278    }
279
280    #[test]
281    fn test_event_labels() {
282        assert_eq!(JobEventType::Queued.label(), "queued");
283        assert_eq!(JobEventType::Started.label(), "started");
284        assert_eq!(JobEventType::Completed.label(), "completed");
285        assert_eq!(JobEventType::Failed.label(), "failed");
286        assert_eq!(JobEventType::Cancelled.label(), "cancelled");
287    }
288
289    #[test]
290    fn test_default_bus_capacity() {
291        let bus = NotificationBus::default();
292        // Should not panic
293        let _rx = bus.subscribe();
294    }
295}