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 ®istry,
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 ®istry,
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 ®istry,
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(®istry, 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}