Skip to main content

autumn_web/
events.rs

1//! Typed domain event bus with decoupled, durable listeners.
2//!
3//! A *domain event* is a typed value (declared with `#[event]`) describing
4//! something that happened in your application — `UserSignedUp { user_id }`,
5//! `OrderPlaced { .. }`. *Listeners* (declared with `#[listener]`) react to an
6//! event independently of the code that emitted it: adding a new reaction is a
7//! new listener and **zero edits** to the emitter.
8//!
9//! ```ignore
10//! use autumn_web::prelude::*;
11//!
12//! #[event]
13//! struct UserSignedUp { user_id: i64 }
14//!
15//! // Durable: rides the #[job] queue, survives restarts, retried on failure.
16//! #[listener(UserSignedUp, durable)]
17//! async fn send_welcome_email(state: AppState, event: UserSignedUp) -> AutumnResult<()> {
18//!     // ...
19//!     Ok(())
20//! }
21//!
22//! #[post("/signup")]
23//! async fn signup(events: Events) -> AutumnResult<&'static str> {
24//!     events.publish(UserSignedUp { user_id: 42 }).await?;
25//!     Ok("ok")
26//! }
27//! ```
28//!
29//! # Dispatch
30//!
31//! - **Sync** listeners run in-request, before the response is returned — use
32//!   these for invariants the caller depends on. Each runs independently with
33//!   panic/error isolation: one failing listener never blocks the others, and
34//!   never fails the publish.
35//! - **Durable** listeners are enqueued onto the existing `#[job]` queue, so
36//!   they survive a process restart and inherit the queue's retry + DLQ
37//!   semantics (at-least-once delivery).
38//!
39//! A published event with no registered listeners is a **no-op**, not an error.
40
41use std::collections::HashMap;
42use std::future::Future;
43use std::pin::Pin;
44use std::sync::{Arc, Mutex, RwLock};
45
46use serde::Serialize;
47use serde::de::DeserializeOwned;
48use serde_json::Value;
49
50use crate::{AppState, AutumnError, AutumnResult};
51
52/// A typed domain event.
53///
54/// Implemented by the `#[event]` macro, which also derives the serde +
55/// `Clone`/`Debug` impls the bus needs to carry the payload across the durable
56/// job queue.
57pub trait Event: Serialize + DeserializeOwned + Send + Sync + 'static {
58    /// Stable identifier used to route the event to its listeners and to name
59    /// the durable listener jobs.
60    const NAME: &'static str;
61}
62
63/// How a listener is dispatched when its event is published.
64#[derive(Clone, Copy, PartialEq, Eq, Debug)]
65pub enum DispatchMode {
66    /// Runs in-request, before the response, with panic/error isolation.
67    Sync,
68    /// Enqueued onto the `#[job]` queue (retry + DLQ + restart-safe).
69    Durable,
70}
71
72/// The async function signature for an event listener.
73///
74/// Intentionally identical to [`crate::job::JobHandler`] so a durable listener
75/// becomes a [`crate::job::JobInfo`] with no adapter.
76pub type ListenerHandler =
77    fn(AppState, Value) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send + 'static>>;
78
79/// Metadata describing a registered event listener.
80///
81/// Produced by the `#[listener]` macro's `__autumn_listener_info_*` companion
82/// and collected by `listeners![]`.
83#[derive(Clone)]
84pub struct ListenerInfo {
85    /// The [`Event::NAME`] this listener subscribes to.
86    pub event_name: &'static str,
87    /// Fully-qualified, per-listener identity (`module::fn`).
88    pub listener_name: String,
89    /// Sync (in-request) or Durable (job queue).
90    pub mode: DispatchMode,
91    /// For durable listeners, the registered job name; `None` for sync.
92    pub job_name: Option<String>,
93    /// Durable retry cap (mirrors [`crate::job::JobInfo`]); ignored for sync.
94    pub max_attempts: u32,
95    /// Durable initial backoff in ms; ignored for sync.
96    pub initial_backoff_ms: u64,
97    /// Runs the listener: deserialize the event payload, call the function.
98    pub handler: ListenerHandler,
99}
100
101/// Routes published events to their registered listeners.
102///
103/// Built from the listeners registered with `AppBuilder::listeners` and
104/// installed onto [`AppState`] as a typed extension.
105#[derive(Clone, Default)]
106pub struct EventRegistry {
107    by_event: Arc<HashMap<&'static str, Vec<ListenerInfo>>>,
108}
109
110impl EventRegistry {
111    /// Group listeners by event name, preserving registration order.
112    #[must_use]
113    pub fn from_listeners(listeners: Vec<ListenerInfo>) -> Self {
114        let mut by_event: HashMap<&'static str, Vec<ListenerInfo>> = HashMap::new();
115        for listener in listeners {
116            by_event
117                .entry(listener.event_name)
118                .or_default()
119                .push(listener);
120        }
121        Self {
122            by_event: Arc::new(by_event),
123        }
124    }
125
126    /// Listeners registered for `event_name` (empty slice if none).
127    #[must_use]
128    pub fn listeners_for(&self, event_name: &str) -> &[ListenerInfo] {
129        self.by_event.get(event_name).map_or(&[][..], Vec::as_slice)
130    }
131
132    /// Synthesize a [`crate::job::JobInfo`] for each durable listener so the
133    /// app builder can register them with the job runtime.
134    ///
135    /// # Panics
136    ///
137    /// Panics if a durable listener is missing its `job_name` (the `#[listener]`
138    /// macro always sets one, so this only fires on a hand-built `ListenerInfo`).
139    #[must_use]
140    pub fn durable_job_infos(&self) -> Vec<crate::job::JobInfo> {
141        self.by_event
142            .values()
143            .flatten()
144            .filter(|listener| listener.mode == DispatchMode::Durable)
145            .map(|listener| crate::job::JobInfo {
146                name: listener
147                    .job_name
148                    .clone()
149                    .expect("durable listener must carry a job_name"),
150                max_attempts: listener.max_attempts,
151                initial_backoff_ms: listener.initial_backoff_ms,
152                queue: "default".to_string(),
153                uniqueness: None,
154                concurrency: None,
155                version: 1,
156                handler: listener.handler,
157            })
158            .collect()
159    }
160}
161
162/// A single recorded publication, captured by [`EventRecorder`] in tests.
163#[derive(Clone, Debug)]
164pub struct RecordedEvent {
165    /// The [`Event::NAME`] of the published event.
166    pub event_name: &'static str,
167    /// The serialized event payload.
168    pub payload: Value,
169}
170
171/// Records published events so tests can assert on them without standing up the
172/// job runner. Installed onto [`AppState`] by the test client.
173#[derive(Default)]
174pub struct EventRecorder {
175    events: Mutex<Vec<RecordedEvent>>,
176}
177
178impl EventRecorder {
179    fn record(&self, event_name: &'static str, payload: Value) {
180        self.events
181            .lock()
182            .expect("event recorder lock poisoned")
183            .push(RecordedEvent {
184                event_name,
185                payload,
186            });
187    }
188
189    /// Deserialize every recorded publication of event type `E`.
190    ///
191    /// # Panics
192    ///
193    /// Panics if the recorder's internal lock is poisoned.
194    #[must_use]
195    pub fn published<E: Event>(&self) -> Vec<E> {
196        self.events
197            .lock()
198            .expect("event recorder lock poisoned")
199            .iter()
200            .filter(|recorded| recorded.event_name == E::NAME)
201            .filter_map(|recorded| serde_json::from_value(recorded.payload.clone()).ok())
202            .collect()
203    }
204
205    /// How many times event type `E` was published.
206    ///
207    /// # Panics
208    ///
209    /// Panics if the recorder's internal lock is poisoned.
210    #[must_use]
211    pub fn count<E: Event>(&self) -> usize {
212        self.events
213            .lock()
214            .expect("event recorder lock poisoned")
215            .iter()
216            .filter(|recorded| recorded.event_name == E::NAME)
217            .count()
218    }
219
220    /// All recorded events, in publication order.
221    ///
222    /// # Panics
223    ///
224    /// Panics if the recorder's internal lock is poisoned.
225    #[must_use]
226    pub fn all(&self) -> Vec<RecordedEvent> {
227        self.events
228            .lock()
229            .expect("event recorder lock poisoned")
230            .clone()
231    }
232}
233
234/// Injectable event publisher.
235///
236/// Extracted in handlers/services just like the `Mailer`. Call
237/// [`Events::publish`] to emit a typed event to its listeners.
238#[derive(Clone)]
239pub struct Events {
240    registry: Arc<EventRegistry>,
241    recorder: Option<Arc<EventRecorder>>,
242    state: AppState,
243}
244
245impl Events {
246    /// Publish a typed event to its registered listeners.
247    ///
248    /// Durable listeners are enqueued onto the job queue; sync listeners run
249    /// in-request with panic/error isolation. A missing-listener event is a
250    /// no-op. Returns `Ok(())` even when sync listeners fail (the emitter stays
251    /// decoupled); only a durable **enqueue** failure propagates.
252    ///
253    /// # Errors
254    ///
255    /// Returns an error if the event cannot be serialized, or if enqueueing a
256    /// durable listener onto the job queue fails.
257    pub async fn publish<E: Event>(&self, event: E) -> AutumnResult<()> {
258        let payload = serialize_event(&event)?;
259        dispatch(
260            &self.registry,
261            self.recorder.as_deref(),
262            &self.state,
263            E::NAME,
264            payload,
265        )
266        .await
267    }
268}
269
270impl axum::extract::FromRequestParts<AppState> for Events {
271    type Rejection = AutumnError;
272
273    async fn from_request_parts(
274        _parts: &mut http::request::Parts,
275        state: &AppState,
276    ) -> Result<Self, Self::Rejection> {
277        // A missing registry is not an error — publishing is then a safe no-op.
278        let registry = state
279            .extension::<EventRegistry>()
280            .unwrap_or_else(|| Arc::new(EventRegistry::default()));
281        let recorder = state.extension::<EventRecorder>();
282        Ok(Self {
283            registry,
284            recorder,
285            state: state.clone(),
286        })
287    }
288}
289
290fn serialize_event<E: Event>(event: &E) -> AutumnResult<Value> {
291    serde_json::to_value(event).map_err(|e| {
292        AutumnError::internal_server_error(std::io::Error::other(format!(
293            "event serialization failed: {e}"
294        )))
295    })
296}
297
298/// Core dispatch shared by [`Events::publish`] and the module-level [`publish`].
299async fn dispatch(
300    registry: &EventRegistry,
301    recorder: Option<&EventRecorder>,
302    state: &AppState,
303    event_name: &'static str,
304    payload: Value,
305) -> AutumnResult<()> {
306    // 1. Record first so tests observe the event even without a job runner.
307    if let Some(recorder) = recorder {
308        recorder.record(event_name, payload.clone());
309    }
310
311    let listeners = registry.listeners_for(event_name);
312    if listeners.is_empty() {
313        return Ok(());
314    }
315
316    // 2. Durable listeners: enqueue onto the job queue (at-least-once).
317    //
318    // Prefer this app's own `JobClient` (installed onto `AppState` by the job
319    // runtime) over the process-global client, so durable dispatch is scoped to
320    // the publishing app rather than whichever app last started a runtime. This
321    // keeps parallel in-process apps (notably tests) from contending. We fall
322    // back to the global client only if no app-local one is present.
323    let app_client = state.extension::<crate::job::JobClient>();
324    let mut durable_error = None;
325    for listener in listeners
326        .iter()
327        .filter(|listener| listener.mode == DispatchMode::Durable)
328    {
329        let job_name = listener
330            .job_name
331            .as_deref()
332            .expect("durable listener must carry a job_name");
333        // Enqueue *after commit* so publishing inside a `Db::tx` defers the
334        // durable reaction until the transaction commits — a rolled-back event
335        // never fires its listeners, and (on Postgres) the job is not claimed on
336        // another connection before the event's data is visible. Outside a
337        // transaction this enqueues immediately.
338        let enqueued = if let Some(client) = &app_client {
339            client.enqueue_after_commit(job_name, payload.clone()).await
340        } else {
341            crate::job::enqueue_after_commit(job_name, payload.clone()).await
342        };
343        // Don't let one durable enqueue failure skip the in-request sync
344        // listeners (or the remaining durable enqueues); remember the first
345        // error and surface it after sync listeners have run.
346        if let Err(error) = enqueued
347            && durable_error.is_none()
348        {
349            durable_error = Some(error);
350        }
351    }
352
353    // 3. Sync listeners: run independently, isolated from each other — these run
354    // even if a durable enqueue above failed.
355    run_sync_listeners(state, listeners, &payload).await;
356
357    durable_error.map_or(Ok(()), Err)
358}
359
360/// Run every sync listener concurrently on the caller's task, isolating each
361/// from its siblings with `catch_unwind` (the same panic-isolation the job
362/// runtime uses), then await them all (they finish before the response).
363///
364/// Running directly — rather than `tokio::spawn` — keeps the ambient app context
365/// (`CURRENT_EVENT_APP`) and tracing span in scope, so a listener that itself
366/// calls the free [`publish`] dispatches against the right app and stays
367/// log-correlated. It also ties the listeners to the publish future's lifecycle,
368/// so cancelling the request (timeout, disconnect) cancels the listeners instead
369/// of leaving detached tasks running after the response is abandoned.
370async fn run_sync_listeners(state: &AppState, listeners: &[ListenerInfo], payload: &Value) {
371    use futures::FutureExt as _;
372
373    let runs = listeners
374        .iter()
375        .filter(|listener| listener.mode == DispatchMode::Sync)
376        .map(|listener| {
377            let state = state.clone();
378            let payload = payload.clone();
379            let run = listener.handler;
380            let name = listener.listener_name.clone();
381            async move {
382                match std::panic::AssertUnwindSafe(run(state, payload))
383                    .catch_unwind()
384                    .await
385                {
386                    Ok(Ok(())) => {}
387                    Ok(Err(error)) => {
388                        tracing::error!(listener = %name, %error, "sync event listener failed");
389                    }
390                    Err(_panic) => {
391                        tracing::error!(listener = %name, "sync event listener panicked");
392                    }
393                }
394            }
395        });
396    futures::future::join_all(runs).await;
397}
398
399struct GlobalBus {
400    registry: Arc<EventRegistry>,
401    recorder: Option<Arc<EventRecorder>>,
402    state: AppState,
403}
404
405static GLOBAL_EVENT_BUS: RwLock<Option<Arc<GlobalBus>>> = RwLock::new(None);
406
407fn global_bus() -> Option<Arc<GlobalBus>> {
408    GLOBAL_EVENT_BUS.read().ok().and_then(|guard| guard.clone())
409}
410
411/// Install the process-global event bus used by the module-level [`publish`].
412///
413/// Called by the app builder (and the test client) after the registry is built.
414pub(crate) fn init_global_event_bus(
415    registry: &EventRegistry,
416    state: &AppState,
417    recorder: Option<Arc<EventRecorder>>,
418) {
419    let bus = Arc::new(GlobalBus {
420        registry: Arc::new(registry.clone()),
421        recorder,
422        state: state.clone(),
423    });
424    if let Ok(mut guard) = GLOBAL_EVENT_BUS.write() {
425        *guard = Some(bus);
426    }
427}
428
429/// Reset the process-global event bus (used for test isolation).
430pub fn clear_global_event_bus() {
431    if let Ok(mut guard) = GLOBAL_EVENT_BUS.write() {
432        *guard = None;
433    }
434}
435
436tokio::task_local! {
437    /// The ambient app for the current request or job, used by the free
438    /// [`publish`] so it resolves *this* app rather than the process-global bus.
439    static CURRENT_EVENT_APP: AppState;
440}
441
442/// Run `future` with `state` installed as the ambient app for the free
443/// [`publish`]. Scoped by the request pipeline and the job runtime so a handler,
444/// service, or job that calls `publish` dispatches against its own app —
445/// keeping parallel in-process apps (notably tests) isolated.
446pub(crate) fn scope_event_app<F>(
447    state: AppState,
448    future: F,
449) -> tokio::task::futures::TaskLocalFuture<AppState, F>
450where
451    F: Future,
452{
453    CURRENT_EVENT_APP.scope(state, future)
454}
455
456fn current_event_app() -> Option<AppState> {
457    CURRENT_EVENT_APP.try_with(AppState::clone).ok()
458}
459
460/// Publish an event without a request context (services, jobs, scheduled tasks).
461///
462/// Resolves the **current app** from the ambient request/job context when one is
463/// set (so parallel apps stay isolated), falling back to the process-global bus
464/// installed at startup. Inside a request, the injectable [`Events`] extractor is
465/// equivalent and slightly more explicit.
466///
467/// # Errors
468///
469/// Returns an error if the event cannot be serialized or if enqueueing a
470/// durable listener fails. If no app context is available the call is a no-op.
471pub async fn publish<E: Event>(event: E) -> AutumnResult<()> {
472    let payload = serialize_event(&event)?;
473
474    // Prefer the ambient app context (set per request and per job) for isolation.
475    if let Some(state) = current_event_app() {
476        let registry = state.extension::<EventRegistry>();
477        let empty = EventRegistry::default();
478        let registry_ref = registry.as_deref().unwrap_or(&empty);
479        let recorder = state.extension::<EventRecorder>();
480        return dispatch(registry_ref, recorder.as_deref(), &state, E::NAME, payload).await;
481    }
482
483    // No ambient app (e.g. a startup hook or bare task) — fall back to the
484    // process-global bus, or no-op if nothing wired it.
485    let Some(bus) = global_bus() else {
486        return Ok(());
487    };
488    dispatch(
489        &bus.registry,
490        bus.recorder.as_deref(),
491        &bus.state,
492        E::NAME,
493        payload,
494    )
495    .await
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use serde::Deserialize;
502    use std::sync::atomic::{AtomicU32, Ordering};
503
504    #[derive(Serialize, Deserialize, Clone, Debug)]
505    struct Ping {
506        n: i64,
507    }
508    impl Event for Ping {
509        const NAME: &'static str = "Ping";
510    }
511
512    fn ok_handler() -> ListenerHandler {
513        |_state, _payload| Box::pin(async { Ok(()) })
514    }
515
516    fn sync_listener(name: &str, handler: ListenerHandler) -> ListenerInfo {
517        ListenerInfo {
518            event_name: Ping::NAME,
519            listener_name: name.to_string(),
520            mode: DispatchMode::Sync,
521            job_name: None,
522            max_attempts: 0,
523            initial_backoff_ms: 0,
524            handler,
525        }
526    }
527
528    fn durable_listener(name: &str) -> ListenerInfo {
529        ListenerInfo {
530            event_name: Ping::NAME,
531            listener_name: name.to_string(),
532            mode: DispatchMode::Durable,
533            job_name: Some(format!("__event_listener::{name}")),
534            max_attempts: 4,
535            initial_backoff_ms: 250,
536            handler: ok_handler(),
537        }
538    }
539
540    #[test]
541    fn registry_groups_by_event_name() {
542        let registry = EventRegistry::from_listeners(vec![
543            sync_listener("a", ok_handler()),
544            sync_listener("b", ok_handler()),
545        ]);
546        assert_eq!(registry.listeners_for("Ping").len(), 2);
547        assert!(registry.listeners_for("Other").is_empty());
548    }
549
550    #[test]
551    fn durable_listeners_become_job_infos() {
552        let registry = EventRegistry::from_listeners(vec![
553            sync_listener("a", ok_handler()),
554            durable_listener("seed_workspace"),
555        ]);
556        let jobs = registry.durable_job_infos();
557        assert_eq!(jobs.len(), 1, "only durable listeners become jobs");
558        assert_eq!(jobs[0].name, "__event_listener::seed_workspace");
559        assert_eq!(jobs[0].max_attempts, 4);
560        assert_eq!(jobs[0].initial_backoff_ms, 250);
561    }
562
563    #[tokio::test]
564    async fn sync_listeners_are_isolated_from_panics_and_errors() {
565        static RAN: AtomicU32 = AtomicU32::new(0);
566        RAN.store(0, Ordering::SeqCst);
567
568        let panicking: ListenerHandler = |_state, _payload| Box::pin(async { panic!("boom") });
569        let erroring: ListenerHandler = |_state, _payload| {
570            Box::pin(async {
571                Err(AutumnError::internal_server_error(std::io::Error::other(
572                    "nope",
573                )))
574            })
575        };
576        let counting: ListenerHandler = |_state, _payload| {
577            Box::pin(async {
578                RAN.fetch_add(1, Ordering::SeqCst);
579                Ok(())
580            })
581        };
582
583        let registry = EventRegistry::from_listeners(vec![
584            sync_listener("panics", panicking),
585            sync_listener("errors", erroring),
586            sync_listener("counts", counting),
587        ]);
588        let state = AppState::for_test();
589
590        // No recorder, no durable listeners: a panicking/erroring sibling must
591        // not stop the third listener from running, and publish still succeeds.
592        let result = dispatch(
593            &registry,
594            None,
595            &state,
596            Ping::NAME,
597            serde_json::json!({"n": 1}),
598        )
599        .await;
600        assert!(result.is_ok(), "publish stays Ok despite listener failures");
601        assert_eq!(RAN.load(Ordering::SeqCst), 1, "surviving listener ran");
602    }
603
604    #[tokio::test]
605    async fn sync_listeners_run_even_when_a_durable_enqueue_fails() {
606        // With no app-local client and no global job runtime, the durable
607        // enqueue fails — but the sync listener must still run (and the error
608        // is surfaced afterwards rather than short-circuiting dispatch).
609        static RAN: AtomicU32 = AtomicU32::new(0);
610        crate::job::clear_global_job_client();
611        RAN.store(0, Ordering::SeqCst);
612        let counting: ListenerHandler = |_state, _payload| {
613            Box::pin(async {
614                RAN.fetch_add(1, Ordering::SeqCst);
615                Ok(())
616            })
617        };
618        let registry = EventRegistry::from_listeners(vec![
619            durable_listener("seed_workspace"),
620            sync_listener("counts", counting),
621        ]);
622        let state = AppState::for_test();
623        let _ = dispatch(
624            &registry,
625            None,
626            &state,
627            Ping::NAME,
628            serde_json::json!({"n": 1}),
629        )
630        .await;
631        // The key guarantee: the durable failure did not skip the sync listener.
632        assert_eq!(RAN.load(Ordering::SeqCst), 1, "sync listener ran anyway");
633    }
634
635    #[tokio::test]
636    async fn missing_listener_is_a_noop() {
637        let registry = EventRegistry::default();
638        let state = AppState::for_test();
639        let result = dispatch(
640            &registry,
641            None,
642            &state,
643            Ping::NAME,
644            serde_json::json!({"n": 1}),
645        )
646        .await;
647        assert!(result.is_ok());
648    }
649
650    #[tokio::test]
651    async fn recorder_captures_published_events() {
652        let registry = EventRegistry::default();
653        let recorder = EventRecorder::default();
654        let state = AppState::for_test();
655        let payload = serialize_event(&Ping { n: 7 }).unwrap();
656        dispatch(&registry, Some(&recorder), &state, Ping::NAME, payload)
657            .await
658            .unwrap();
659        assert_eq!(recorder.count::<Ping>(), 1);
660        let published = recorder.published::<Ping>();
661        assert_eq!(published.len(), 1);
662        assert_eq!(published[0].n, 7);
663    }
664}