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                timeout: None,
158            })
159            .collect()
160    }
161}
162
163/// A single recorded publication, captured by [`EventRecorder`] in tests.
164#[derive(Clone, Debug)]
165pub struct RecordedEvent {
166    /// The [`Event::NAME`] of the published event.
167    pub event_name: &'static str,
168    /// The serialized event payload.
169    pub payload: Value,
170}
171
172/// Records published events so tests can assert on them without standing up the
173/// job runner. Installed onto [`AppState`] by the test client.
174#[derive(Default)]
175pub struct EventRecorder {
176    events: Mutex<Vec<RecordedEvent>>,
177}
178
179impl EventRecorder {
180    pub(crate) fn record(&self, event_name: &'static str, payload: Value) {
181        self.events
182            .lock()
183            .expect("event recorder lock poisoned")
184            .push(RecordedEvent {
185                event_name,
186                payload,
187            });
188    }
189
190    /// Deserialize every recorded publication of event type `E`.
191    ///
192    /// # Panics
193    ///
194    /// Panics if the recorder's internal lock is poisoned.
195    #[must_use]
196    pub fn published<E: Event>(&self) -> Vec<E> {
197        self.events
198            .lock()
199            .expect("event recorder lock poisoned")
200            .iter()
201            .filter(|recorded| recorded.event_name == E::NAME)
202            .filter_map(|recorded| serde_json::from_value(recorded.payload.clone()).ok())
203            .collect()
204    }
205
206    /// How many times event type `E` was published.
207    ///
208    /// # Panics
209    ///
210    /// Panics if the recorder's internal lock is poisoned.
211    #[must_use]
212    pub fn count<E: Event>(&self) -> usize {
213        self.events
214            .lock()
215            .expect("event recorder lock poisoned")
216            .iter()
217            .filter(|recorded| recorded.event_name == E::NAME)
218            .count()
219    }
220
221    /// All recorded events, in publication order.
222    ///
223    /// # Panics
224    ///
225    /// Panics if the recorder's internal lock is poisoned.
226    #[must_use]
227    pub fn all(&self) -> Vec<RecordedEvent> {
228        self.events
229            .lock()
230            .expect("event recorder lock poisoned")
231            .clone()
232    }
233}
234
235/// Injectable event publisher.
236///
237/// Extracted in handlers/services just like the `Mailer`. Call
238/// [`Events::publish`] to emit a typed event to its listeners.
239#[derive(Clone)]
240pub struct Events {
241    registry: Arc<EventRegistry>,
242    recorder: Option<Arc<EventRecorder>>,
243    state: AppState,
244}
245
246impl Events {
247    /// Publish a typed event to its registered listeners.
248    ///
249    /// Durable listeners are enqueued onto the job queue; sync listeners run
250    /// in-request with panic/error isolation. A missing-listener event is a
251    /// no-op. Returns `Ok(())` even when sync listeners fail (the emitter stays
252    /// decoupled); only a durable **enqueue** failure propagates.
253    ///
254    /// # Errors
255    ///
256    /// Returns an error if the event cannot be serialized, or if enqueueing a
257    /// durable listener onto the job queue fails.
258    pub async fn publish<E: Event>(&self, event: E) -> AutumnResult<()> {
259        let payload = serialize_event(&event)?;
260        dispatch(
261            &self.registry,
262            self.recorder.as_deref(),
263            &self.state,
264            E::NAME,
265            payload,
266        )
267        .await
268    }
269
270    /// Publish through the transactional outbox, on the connection of the
271    /// open transaction (issue #3062).
272    ///
273    /// Durable listeners run only if the transaction commits, and a crash
274    /// after commit cannot drop them. Needs `outbox.enabled = true`. See
275    /// [`Outbox::publish`](crate::outbox::Outbox::publish).
276    ///
277    /// # Errors
278    ///
279    /// Returns an error when the event does not serialize or the outbox
280    /// insert fails.
281    #[cfg(feature = "db")]
282    pub async fn publish_in_tx<E: Event>(
283        &self,
284        conn: &mut crate::db::RuntimeConnection,
285        event: E,
286    ) -> AutumnResult<()> {
287        crate::outbox::Outbox::new(&self.state)
288            .publish(conn, &event)
289            .await
290    }
291}
292
293impl axum::extract::FromRequestParts<AppState> for Events {
294    type Rejection = AutumnError;
295
296    async fn from_request_parts(
297        _parts: &mut http::request::Parts,
298        state: &AppState,
299    ) -> Result<Self, Self::Rejection> {
300        // A missing registry is not an error — publishing is then a safe no-op.
301        let registry = state
302            .extension::<EventRegistry>()
303            .unwrap_or_else(|| Arc::new(EventRegistry::default()));
304        let recorder = state.extension::<EventRecorder>();
305        Ok(Self {
306            registry,
307            recorder,
308            state: state.clone(),
309        })
310    }
311}
312
313fn serialize_event<E: Event>(event: &E) -> AutumnResult<Value> {
314    serde_json::to_value(event).map_err(|e| {
315        AutumnError::internal_server_error(std::io::Error::other(format!(
316            "event serialization failed: {e}"
317        )))
318    })
319}
320
321/// Core dispatch shared by [`Events::publish`] and the module-level [`publish`].
322async fn dispatch(
323    registry: &EventRegistry,
324    recorder: Option<&EventRecorder>,
325    state: &AppState,
326    event_name: &'static str,
327    payload: Value,
328) -> AutumnResult<()> {
329    // 1. Record first so tests observe the event even without a job runner.
330    if let Some(recorder) = recorder {
331        recorder.record(event_name, payload.clone());
332    }
333
334    let listeners = registry.listeners_for(event_name);
335    if listeners.is_empty() {
336        return Ok(());
337    }
338
339    // 2. Durable listeners: enqueue onto the job queue (at-least-once).
340    //
341    // Prefer this app's own `JobClient` (installed onto `AppState` by the job
342    // runtime) over the process-global client, so durable dispatch is scoped to
343    // the publishing app rather than whichever app last started a runtime. This
344    // keeps parallel in-process apps (notably tests) from contending. We fall
345    // back to the global client only if no app-local one is present.
346    let app_client = state.extension::<crate::job::JobClient>();
347    let mut durable_error = None;
348    for listener in listeners
349        .iter()
350        .filter(|listener| listener.mode == DispatchMode::Durable)
351    {
352        let job_name = listener
353            .job_name
354            .as_deref()
355            .expect("durable listener must carry a job_name");
356        // Enqueue *after commit* so publishing inside a `Db::tx` defers the
357        // durable reaction until the transaction commits — a rolled-back event
358        // never fires its listeners, and (on Postgres) the job is not claimed on
359        // another connection before the event's data is visible. Outside a
360        // transaction this enqueues immediately.
361        let enqueued = if let Some(client) = &app_client {
362            client.enqueue_after_commit(job_name, payload.clone()).await
363        } else {
364            crate::job::enqueue_after_commit(job_name, payload.clone()).await
365        };
366        // Don't let one durable enqueue failure skip the in-request sync
367        // listeners (or the remaining durable enqueues); remember the first
368        // error and surface it after sync listeners have run.
369        if let Err(error) = enqueued
370            && durable_error.is_none()
371        {
372            durable_error = Some(error);
373        }
374    }
375
376    // 3. Sync listeners: run independently, isolated from each other — these run
377    // even if a durable enqueue above failed.
378    run_sync_listeners(state, listeners, &payload).await;
379
380    durable_error.map_or(Ok(()), Err)
381}
382
383/// Run every sync listener concurrently on the caller's task, isolating each
384/// from its siblings with `catch_unwind` (the same panic-isolation the job
385/// runtime uses), then await them all (they finish before the response).
386///
387/// Running directly — rather than `tokio::spawn` — keeps the ambient app context
388/// (`CURRENT_EVENT_APP`) and tracing span in scope, so a listener that itself
389/// calls the free [`publish`] dispatches against the right app and stays
390/// log-correlated. It also ties the listeners to the publish future's lifecycle,
391/// so cancelling the request (timeout, disconnect) cancels the listeners instead
392/// of leaving detached tasks running after the response is abandoned.
393pub(crate) async fn run_sync_listeners(
394    state: &AppState,
395    listeners: &[ListenerInfo],
396    payload: &Value,
397) {
398    use futures::FutureExt as _;
399
400    let runs = listeners
401        .iter()
402        .filter(|listener| listener.mode == DispatchMode::Sync)
403        .map(|listener| {
404            let state = state.clone();
405            let payload = payload.clone();
406            let run = listener.handler;
407            let name = listener.listener_name.clone();
408            async move {
409                match std::panic::AssertUnwindSafe(run(state, payload))
410                    .catch_unwind()
411                    .await
412                {
413                    Ok(Ok(())) => {}
414                    Ok(Err(error)) => {
415                        tracing::error!(listener = %name, %error, "sync event listener failed");
416                    }
417                    Err(_panic) => {
418                        tracing::error!(listener = %name, "sync event listener panicked");
419                    }
420                }
421            }
422        });
423    futures::future::join_all(runs).await;
424}
425
426struct GlobalBus {
427    registry: Arc<EventRegistry>,
428    recorder: Option<Arc<EventRecorder>>,
429    state: AppState,
430}
431
432static GLOBAL_EVENT_BUS: RwLock<Option<Arc<GlobalBus>>> = RwLock::new(None);
433
434fn global_bus() -> Option<Arc<GlobalBus>> {
435    GLOBAL_EVENT_BUS.read().ok().and_then(|guard| guard.clone())
436}
437
438/// Install the process-global event bus used by the module-level [`publish`].
439///
440/// Called by the app builder (and the test client) after the registry is built.
441pub(crate) fn init_global_event_bus(
442    registry: &EventRegistry,
443    state: &AppState,
444    recorder: Option<Arc<EventRecorder>>,
445) {
446    let bus = Arc::new(GlobalBus {
447        registry: Arc::new(registry.clone()),
448        recorder,
449        state: state.clone(),
450    });
451    if let Ok(mut guard) = GLOBAL_EVENT_BUS.write() {
452        *guard = Some(bus);
453    }
454}
455
456/// Reset the process-global event bus (used for test isolation).
457pub fn clear_global_event_bus() {
458    if let Ok(mut guard) = GLOBAL_EVENT_BUS.write() {
459        *guard = None;
460    }
461}
462
463tokio::task_local! {
464    /// The ambient app for the current request or job, used by the free
465    /// [`publish`] so it resolves *this* app rather than the process-global bus.
466    static CURRENT_EVENT_APP: AppState;
467}
468
469/// Run `future` with `state` installed as the ambient app for the free
470/// [`publish`]. Scoped by the request pipeline and the job runtime so a handler,
471/// service, or job that calls `publish` dispatches against its own app —
472/// keeping parallel in-process apps (notably tests) isolated.
473pub(crate) fn scope_event_app<F>(
474    state: AppState,
475    future: F,
476) -> tokio::task::futures::TaskLocalFuture<AppState, F>
477where
478    F: Future,
479{
480    CURRENT_EVENT_APP.scope(state, future)
481}
482
483fn current_event_app() -> Option<AppState> {
484    CURRENT_EVENT_APP.try_with(AppState::clone).ok()
485}
486
487/// Tower [`Layer`](tower::Layer) installing the request's app as the ambient
488/// event-bus context for the whole downstream stack.
489///
490/// This was an `axum::middleware::from_fn_with_state` until #2214. `from_fn`
491/// has to `Box::pin` the async block it generates, because that block's type
492/// cannot be named — one heap allocation on every request, and this layer sits
493/// near the outside of the ingress stack, so the block captured the entire
494/// downstream continuation. [`scope_event_app`] already returns a *named*
495/// future ([`tokio::task::futures::TaskLocalFuture`]), so a hand-rolled service
496/// can hand it straight back and allocate nothing.
497///
498/// The inner future is built inside a `sync_scope` and then polled inside a
499/// `scope`, rather than simply being passed to `scope` as an argument.
500/// Arguments are evaluated first, so the plain form would run the whole
501/// synchronous `Service::call` chain beneath this layer — which includes
502/// `TrustedProxiesService` and every operator- or plugin-registered Tower layer
503/// — with the task-local unset, where the `from_fn` form it replaces ran it
504/// inside the scope. `crate::capsule::capture` documents the same hazard and
505/// guards it with a dedicated test.
506#[derive(Clone)]
507pub(crate) struct EventAppContextLayer {
508    state: AppState,
509}
510
511impl EventAppContextLayer {
512    pub(crate) const fn new(state: AppState) -> Self {
513        Self { state }
514    }
515}
516
517impl<S> tower::Layer<S> for EventAppContextLayer {
518    type Service = EventAppContextService<S>;
519
520    fn layer(&self, inner: S) -> Self::Service {
521        EventAppContextService {
522            inner,
523            state: self.state.clone(),
524        }
525    }
526}
527
528/// Tower [`Service`](tower::Service) produced by [`EventAppContextLayer`].
529#[derive(Clone)]
530pub(crate) struct EventAppContextService<S> {
531    inner: S,
532    state: AppState,
533}
534
535impl<S, ReqBody> tower::Service<axum::http::Request<ReqBody>> for EventAppContextService<S>
536where
537    S: tower::Service<axum::http::Request<ReqBody>>,
538{
539    type Response = S::Response;
540    type Error = S::Error;
541    type Future = tokio::task::futures::TaskLocalFuture<AppState, S::Future>;
542
543    fn poll_ready(
544        &mut self,
545        cx: &mut std::task::Context<'_>,
546    ) -> std::task::Poll<Result<(), Self::Error>> {
547        self.inner.poll_ready(cx)
548    }
549
550    fn call(&mut self, req: axum::http::Request<ReqBody>) -> Self::Future {
551        let inner = CURRENT_EVENT_APP.sync_scope(self.state.clone(), || self.inner.call(req));
552        scope_event_app(self.state.clone(), inner)
553    }
554}
555
556/// Publish an event without a request context (services, jobs, scheduled tasks).
557///
558/// Resolves the **current app** from the ambient request/job context when one is
559/// set (so parallel apps stay isolated), falling back to the process-global bus
560/// installed at startup. Inside a request, the injectable [`Events`] extractor is
561/// equivalent and slightly more explicit.
562///
563/// # Errors
564///
565/// Returns an error if the event cannot be serialized or if enqueueing a
566/// durable listener fails. If no app context is available the call is a no-op.
567pub async fn publish<E: Event>(event: E) -> AutumnResult<()> {
568    let payload = serialize_event(&event)?;
569
570    // Prefer the ambient app context (set per request and per job) for isolation.
571    if let Some(state) = current_event_app() {
572        let registry = state.extension::<EventRegistry>();
573        let empty = EventRegistry::default();
574        let registry_ref = registry.as_deref().unwrap_or(&empty);
575        let recorder = state.extension::<EventRecorder>();
576        return dispatch(registry_ref, recorder.as_deref(), &state, E::NAME, payload).await;
577    }
578
579    // No ambient app (e.g. a startup hook or bare task) — fall back to the
580    // process-global bus, or no-op if nothing wired it.
581    let Some(bus) = global_bus() else {
582        return Ok(());
583    };
584    dispatch(
585        &bus.registry,
586        bus.recorder.as_deref(),
587        &bus.state,
588        E::NAME,
589        payload,
590    )
591    .await
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use serde::Deserialize;
598    use std::sync::atomic::{AtomicU32, Ordering};
599
600    #[derive(Serialize, Deserialize, Clone, Debug)]
601    struct Ping {
602        n: i64,
603    }
604    impl Event for Ping {
605        const NAME: &'static str = "Ping";
606    }
607
608    fn ok_handler() -> ListenerHandler {
609        |_state, _payload| Box::pin(async { Ok(()) })
610    }
611
612    fn sync_listener(name: &str, handler: ListenerHandler) -> ListenerInfo {
613        ListenerInfo {
614            event_name: Ping::NAME,
615            listener_name: name.to_string(),
616            mode: DispatchMode::Sync,
617            job_name: None,
618            max_attempts: 0,
619            initial_backoff_ms: 0,
620            handler,
621        }
622    }
623
624    fn durable_listener(name: &str) -> ListenerInfo {
625        ListenerInfo {
626            event_name: Ping::NAME,
627            listener_name: name.to_string(),
628            mode: DispatchMode::Durable,
629            job_name: Some(format!("__event_listener::{name}")),
630            max_attempts: 4,
631            initial_backoff_ms: 250,
632            handler: ok_handler(),
633        }
634    }
635
636    #[test]
637    fn registry_groups_by_event_name() {
638        let registry = EventRegistry::from_listeners(vec![
639            sync_listener("a", ok_handler()),
640            sync_listener("b", ok_handler()),
641        ]);
642        assert_eq!(registry.listeners_for("Ping").len(), 2);
643        assert!(registry.listeners_for("Other").is_empty());
644    }
645
646    #[test]
647    fn durable_listeners_become_job_infos() {
648        let registry = EventRegistry::from_listeners(vec![
649            sync_listener("a", ok_handler()),
650            durable_listener("seed_workspace"),
651        ]);
652        let jobs = registry.durable_job_infos();
653        assert_eq!(jobs.len(), 1, "only durable listeners become jobs");
654        assert_eq!(jobs[0].name, "__event_listener::seed_workspace");
655        assert_eq!(jobs[0].max_attempts, 4);
656        assert_eq!(jobs[0].initial_backoff_ms, 250);
657    }
658
659    #[tokio::test]
660    async fn sync_listeners_are_isolated_from_panics_and_errors() {
661        static RAN: AtomicU32 = AtomicU32::new(0);
662        RAN.store(0, Ordering::SeqCst);
663
664        let panicking: ListenerHandler = |_state, _payload| Box::pin(async { panic!("boom") });
665        let erroring: ListenerHandler = |_state, _payload| {
666            Box::pin(async {
667                Err(AutumnError::internal_server_error(std::io::Error::other(
668                    "nope",
669                )))
670            })
671        };
672        let counting: ListenerHandler = |_state, _payload| {
673            Box::pin(async {
674                RAN.fetch_add(1, Ordering::SeqCst);
675                Ok(())
676            })
677        };
678
679        let registry = EventRegistry::from_listeners(vec![
680            sync_listener("panics", panicking),
681            sync_listener("errors", erroring),
682            sync_listener("counts", counting),
683        ]);
684        let state = AppState::for_test();
685
686        // No recorder, no durable listeners: a panicking/erroring sibling must
687        // not stop the third listener from running, and publish still succeeds.
688        let result = dispatch(
689            &registry,
690            None,
691            &state,
692            Ping::NAME,
693            serde_json::json!({"n": 1}),
694        )
695        .await;
696        assert!(result.is_ok(), "publish stays Ok despite listener failures");
697        assert_eq!(RAN.load(Ordering::SeqCst), 1, "surviving listener ran");
698    }
699
700    #[tokio::test]
701    async fn sync_listeners_run_even_when_a_durable_enqueue_fails() {
702        // With no app-local client and no global job runtime, the durable
703        // enqueue fails — but the sync listener must still run (and the error
704        // is surfaced afterwards rather than short-circuiting dispatch).
705        static RAN: AtomicU32 = AtomicU32::new(0);
706        crate::job::clear_global_job_client();
707        RAN.store(0, Ordering::SeqCst);
708        let counting: ListenerHandler = |_state, _payload| {
709            Box::pin(async {
710                RAN.fetch_add(1, Ordering::SeqCst);
711                Ok(())
712            })
713        };
714        let registry = EventRegistry::from_listeners(vec![
715            durable_listener("seed_workspace"),
716            sync_listener("counts", counting),
717        ]);
718        let state = AppState::for_test();
719        let _ = dispatch(
720            &registry,
721            None,
722            &state,
723            Ping::NAME,
724            serde_json::json!({"n": 1}),
725        )
726        .await;
727        // The key guarantee: the durable failure did not skip the sync listener.
728        assert_eq!(RAN.load(Ordering::SeqCst), 1, "sync listener ran anyway");
729    }
730
731    #[tokio::test]
732    async fn missing_listener_is_a_noop() {
733        let registry = EventRegistry::default();
734        let state = AppState::for_test();
735        let result = dispatch(
736            &registry,
737            None,
738            &state,
739            Ping::NAME,
740            serde_json::json!({"n": 1}),
741        )
742        .await;
743        assert!(result.is_ok());
744    }
745
746    #[tokio::test]
747    async fn recorder_captures_published_events() {
748        let registry = EventRegistry::default();
749        let recorder = EventRecorder::default();
750        let state = AppState::for_test();
751        let payload = serialize_event(&Ping { n: 7 }).unwrap();
752        dispatch(&registry, Some(&recorder), &state, Ping::NAME, payload)
753            .await
754            .unwrap();
755        assert_eq!(recorder.count::<Ping>(), 1);
756        let published = recorder.published::<Ping>();
757        assert_eq!(published.len(), 1);
758        assert_eq!(published[0].n, 7);
759    }
760}