Skip to main content

ironflow_engine/notify/
publisher.rs

1//! [`EventPublisher`] -- broadcasts events to filtered subscribers.
2
3use std::sync::Arc;
4
5use tokio::spawn;
6
7use super::{Event, EventSubscriber};
8
9/// A subscriber paired with its event type filter.
10struct Subscription {
11    subscriber: Arc<dyn EventSubscriber>,
12    event_types: Vec<&'static str>,
13}
14
15impl Subscription {
16    /// Returns `true` if this subscription accepts the given event.
17    fn accepts(&self, event: &Event) -> bool {
18        self.event_types.contains(&event.event_type())
19    }
20}
21
22/// Broadcasts [`Event`]s to registered [`EventSubscriber`]s.
23///
24/// Each subscriber is paired with an event type filter at subscription
25/// time. Only matching events are dispatched. Each call runs in a
26/// spawned task so that slow subscribers do not block the engine.
27///
28/// # Examples
29///
30/// ```no_run
31/// use ironflow_engine::notify::{EventPublisher, WebhookSubscriber, Event};
32///
33/// let mut publisher = EventPublisher::new();
34/// publisher.subscribe(
35///     WebhookSubscriber::new("https://hooks.example.com/events"),
36///     &[Event::RUN_STATUS_CHANGED, Event::STEP_FAILED],
37/// );
38/// ```
39pub struct EventPublisher {
40    subscriptions: Vec<Subscription>,
41}
42
43impl EventPublisher {
44    /// Create an empty publisher with no subscribers.
45    ///
46    /// # Examples
47    ///
48    /// ```
49    /// use ironflow_engine::notify::EventPublisher;
50    ///
51    /// let publisher = EventPublisher::new();
52    /// assert_eq!(publisher.subscriber_count(), 0);
53    /// ```
54    pub fn new() -> Self {
55        Self {
56            subscriptions: Vec::new(),
57        }
58    }
59
60    /// Register a subscriber with an event type filter.
61    ///
62    /// The subscriber is called only for events whose
63    /// [`event_type()`](Event::event_type) is in `event_types`.
64    /// Pass [`Event::ALL`] to receive every event.
65    ///
66    /// Use the `Event::*` constants for the filter values.
67    ///
68    /// # Examples
69    ///
70    /// ```no_run
71    /// use ironflow_engine::notify::{EventPublisher, WebhookSubscriber, Event};
72    ///
73    /// let mut publisher = EventPublisher::new();
74    ///
75    /// // Only on specific event types:
76    /// publisher.subscribe(
77    ///     WebhookSubscriber::new("https://example.com/hook"),
78    ///     &[Event::RUN_STATUS_CHANGED, Event::STEP_FAILED],
79    /// );
80    ///
81    /// // On all events:
82    /// publisher.subscribe(
83    ///     WebhookSubscriber::new("https://example.com/all"),
84    ///     Event::ALL,
85    /// );
86    /// ```
87    pub fn subscribe(
88        &mut self,
89        subscriber: impl EventSubscriber + 'static,
90        event_types: &[&'static str],
91    ) {
92        self.subscriptions.push(Subscription {
93            subscriber: Arc::new(subscriber),
94            event_types: event_types.to_vec(),
95        });
96    }
97
98    /// Number of registered subscribers.
99    pub fn subscriber_count(&self) -> usize {
100        self.subscriptions.len()
101    }
102
103    /// Broadcast an event to all matching subscribers.
104    ///
105    /// Each matching subscriber runs in its own spawned task. This
106    /// method returns immediately and never blocks.
107    pub fn publish(&self, event: Event) {
108        for subscription in &self.subscriptions {
109            if !subscription.accepts(&event) {
110                continue;
111            }
112            let subscriber = subscription.subscriber.clone();
113            let event = event.clone();
114            spawn(async move {
115                subscriber.handle(&event).await;
116            });
117        }
118    }
119}
120
121impl Default for EventPublisher {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use std::collections::HashMap;
130    use std::sync::atomic::{AtomicU32, Ordering};
131    use std::time::Duration;
132
133    use super::*;
134    use crate::notify::{SubscriberFuture, WebhookSubscriber};
135    use rust_decimal::Decimal;
136    use tokio::time::sleep;
137
138    use chrono::Utc;
139    use ironflow_store::models::RunStatus;
140    use uuid::Uuid;
141
142    fn sample_run_status_changed() -> Event {
143        Event::RunStatusChanged {
144            run_id: Uuid::now_v7(),
145            workflow_name: "deploy".to_string(),
146            from: RunStatus::Running,
147            to: RunStatus::Completed,
148            error: None,
149            cost_usd: Decimal::new(42, 2),
150            duration_ms: 5000,
151            labels: HashMap::new(),
152            at: Utc::now(),
153        }
154    }
155
156    fn sample_user_signed_in() -> Event {
157        Event::UserSignedIn {
158            user_id: Uuid::now_v7(),
159            username: "alice".to_string(),
160            at: Utc::now(),
161        }
162    }
163
164    #[test]
165    fn starts_empty() {
166        let publisher = EventPublisher::new();
167        assert_eq!(publisher.subscriber_count(), 0);
168    }
169
170    #[test]
171    fn subscribe_increments_count() {
172        let mut publisher = EventPublisher::new();
173        publisher.subscribe(
174            WebhookSubscriber::new("https://example.com"),
175            &[Event::RUN_STATUS_CHANGED],
176        );
177        assert_eq!(publisher.subscriber_count(), 1);
178    }
179
180    #[test]
181    fn publish_with_no_subscribers_is_noop() {
182        let publisher = EventPublisher::new();
183        publisher.publish(sample_run_status_changed());
184    }
185
186    #[test]
187    fn default_is_empty() {
188        let publisher = EventPublisher::default();
189        assert_eq!(publisher.subscriber_count(), 0);
190    }
191
192    struct CountingSubscriber {
193        count: AtomicU32,
194    }
195
196    impl CountingSubscriber {
197        fn new() -> Self {
198            Self {
199                count: AtomicU32::new(0),
200            }
201        }
202
203        fn count(&self) -> u32 {
204            self.count.load(Ordering::SeqCst)
205        }
206    }
207
208    impl EventSubscriber for CountingSubscriber {
209        fn name(&self) -> &str {
210            "counting"
211        }
212
213        fn handle<'a>(&'a self, _event: &'a Event) -> SubscriberFuture<'a> {
214            Box::pin(async move {
215                self.count.fetch_add(1, Ordering::SeqCst);
216            })
217        }
218    }
219
220    #[tokio::test]
221    async fn subscriber_receives_matching_events() {
222        let subscriber = Arc::new(CountingSubscriber::new());
223        let mut publisher = EventPublisher::new();
224
225        struct ArcSub(Arc<CountingSubscriber>);
226        impl EventSubscriber for ArcSub {
227            fn name(&self) -> &str {
228                self.0.name()
229            }
230            fn handle<'a>(&'a self, event: &'a Event) -> SubscriberFuture<'a> {
231                self.0.handle(event)
232            }
233        }
234
235        publisher.subscribe(ArcSub(subscriber.clone()), &[Event::RUN_STATUS_CHANGED]);
236
237        publisher.publish(sample_run_status_changed()); // matches
238        publisher.publish(sample_user_signed_in()); // filtered out
239
240        sleep(Duration::from_millis(50)).await;
241
242        assert_eq!(subscriber.count(), 1);
243    }
244
245    #[tokio::test]
246    async fn all_filter_matches_everything() {
247        let subscriber = Arc::new(CountingSubscriber::new());
248        let mut publisher = EventPublisher::new();
249
250        struct ArcSub(Arc<CountingSubscriber>);
251        impl EventSubscriber for ArcSub {
252            fn name(&self) -> &str {
253                self.0.name()
254            }
255            fn handle<'a>(&'a self, event: &'a Event) -> SubscriberFuture<'a> {
256                self.0.handle(event)
257            }
258        }
259
260        publisher.subscribe(ArcSub(subscriber.clone()), Event::ALL);
261
262        publisher.publish(sample_run_status_changed());
263        publisher.publish(sample_user_signed_in());
264
265        sleep(Duration::from_millis(50)).await;
266
267        assert_eq!(subscriber.count(), 2);
268    }
269
270    #[tokio::test]
271    async fn empty_filter_matches_nothing() {
272        let subscriber = Arc::new(CountingSubscriber::new());
273        let mut publisher = EventPublisher::new();
274
275        struct ArcSub(Arc<CountingSubscriber>);
276        impl EventSubscriber for ArcSub {
277            fn name(&self) -> &str {
278                self.0.name()
279            }
280            fn handle<'a>(&'a self, event: &'a Event) -> SubscriberFuture<'a> {
281                self.0.handle(event)
282            }
283        }
284
285        publisher.subscribe(ArcSub(subscriber.clone()), &[]);
286
287        publisher.publish(sample_run_status_changed());
288        publisher.publish(sample_user_signed_in());
289
290        sleep(Duration::from_millis(50)).await;
291
292        assert_eq!(subscriber.count(), 0);
293    }
294}