Skip to main content

cratefield_core/
events.rs

1//! The in-process event bus (issue #4, architecture section 4).
2//!
3//! It exists so `waitlist.confirmed` can feed `email-signup` without a crate
4//! dependency between them. Handlers run through the request's [`Scope`]
5//! defer (`wait_until` on Workers); errors are logged with the event name
6//! and never fail the request. There is **no ordering guarantee and no
7//! persistence** — if the isolate dies between response and deferred run,
8//! the event is lost. Durable workflows are out of scope for the harness.
9//!
10//! ## The sidecar boundary and the delivery contract (issue #62, ADR 0017)
11//!
12//! The bus is one wasm instance wide. A module compiled into a sidecar
13//! Worker cannot hear an event the host emits, and the host cannot hear one
14//! the sidecar emits, so the harness forwards **inbound only**: every
15//! emission is also posted to each mounted sidecar's `POST /__events`, and
16//! a sidecar answers `202` and runs its own handlers in its *own* defer.
17//! Sidecars do not emit back — a delivery-guarantee question the in-process
18//! bus never had to answer stays unanswered rather than answered badly.
19//!
20//! What a subscriber gets across the boundary is exactly what it gets in
21//! process: at-most-once, no retry, no ordering, failures logged and never
22//! surfaced to the emitting request. No module gains a guarantee it did not
23//! already have. The forwarder is attached where ports are resolved —
24//! `Harness::router(ports)`, via [`EventBus::forwarding_to`] — because
25//! `Harness::build` has no `Env`, and that is also why [`Scope`] stays
26//! untouched and no handler signature changes, so [`HARNESS_API`] is
27//! unbumped.
28//!
29//! A module author reads the boundary at the call site: `ctx.events` is an
30//! [`EventBus`], and what `emit_in` reaches depends only on what the
31//! deployment mounts, never on the emitting module. Which side of the
32//! boundary a *subscriber* sits on is the deployment's choice
33//! (`HARNESS_SIDECARS`), not the module's — a subscription written against
34//! the bus works identically compiled in or in a sidecar.
35//!
36//! [`HARNESS_API`]: crate::module::HARNESS_API
37
38use crate::scope::Scope;
39use crate::sidecar::EventForwarder;
40use futures_core::future::BoxFuture;
41use serde_json::Value;
42use std::sync::Arc;
43use tracing::{error, warn};
44
45/// Error type for handler and scheduled-work results.
46pub type AnyError = Box<dyn std::error::Error + Send + Sync + 'static>;
47
48/// Event names are `"<module>.<event>"`, e.g. `waitlist.confirmed`.
49pub type EventName = String;
50
51/// A registered handler: receives the emitting request's scope and the
52/// payload.
53pub type EventHandler =
54    Arc<dyn Fn(&Scope, Value) -> BoxFuture<'static, Result<(), AnyError>> + Send + Sync>;
55
56/// Registry of handlers, built once by `Harness::build` from every module's
57/// `events()`. Cheap to clone (two `Arc`s).
58#[derive(Clone, Default)]
59pub struct EventBus {
60    handlers: Arc<Vec<(EventName, EventHandler)>>,
61    /// Attached where ports are resolved (`Harness::router`); `None` for a
62    /// bus with nothing to forward to. See the module docs for the
63    /// contract the attachment implies (issue #62).
64    forwarder: Option<Arc<EventForwarder>>,
65}
66
67impl EventBus {
68    /// An empty bus (a harness with no subscriptions).
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Builds a bus from collected `(name, handler)` pairs, appending the
74    /// new pairs after any existing ones (copy-on-write if the bus is
75    /// already shared). Modules register via `Module::events()` at
76    /// `Harness::build`.
77    #[must_use]
78    pub fn on(self, name: impl Into<EventName>, handler: EventHandler) -> Self {
79        let mut handlers: Vec<(EventName, EventHandler)> = match Arc::try_unwrap(self.handlers) {
80            Ok(handlers) => handlers,
81            Err(shared) => (*shared).clone(),
82        };
83        handlers.push((name.into(), handler));
84        Self {
85            handlers: Arc::new(handlers),
86            forwarder: None,
87        }
88    }
89
90    /// The registered (name, handler) pairs, in registration order.
91    pub fn handlers(&self) -> &[(EventName, EventHandler)] {
92        &self.handlers
93    }
94
95    /// Attaches the sidecar forwarder (issue #62). Internal on purpose:
96    /// who forwards, to which bindings and stamped with which key is the
97    /// deployment's decision, made once in `Harness::router` — never a
98    /// module's.
99    #[must_use]
100    pub(crate) fn forwarding_to(self, forwarder: EventForwarder) -> Self {
101        Self {
102            forwarder: Some(Arc::new(forwarder)),
103            ..self
104        }
105    }
106
107    /// Runs every local handler registered for `name` through the scope's
108    /// defer. Returns how many ran. The sidecar's `POST /__events` uses the
109    /// same path, so an inbound delivery carries the same "handler errors
110    /// are logged, never surfaced" rule as an in-process emission.
111    fn run_local(&self, scope: &Scope, name: &str, payload: &Value) -> usize {
112        let matched: Vec<&(EventName, EventHandler)> = self
113            .handlers
114            .iter()
115            .filter(|(handler_name, _)| handler_name == name)
116            .collect();
117        for (_, handler) in &matched {
118            let fut = handler(scope, payload.clone());
119            let event = name.to_string();
120            scope.defer.wait_until(Box::pin(async move {
121                if let Err(err) = fut.await {
122                    error!(event = %event, error = %err, "event handler failed");
123                    crate::logging::forward_internal_error(&format!(
124                        "event handler failed for {event}: {err}"
125                    ));
126                }
127            }));
128        }
129        matched.len()
130    }
131
132    /// The sidecar half of event forwarding (issue #62): the delivery the
133    /// host posted to `POST /__events`, run against this deployment's own
134    /// handlers and this request's defer. Returns the number of handlers
135    /// that subscribed, so the route can refuse to be silent about an
136    /// event nobody hears.
137    pub fn deliver_inbound(&self, scope: &Scope, name: &str, payload: &Value) -> usize {
138        self.run_local(scope, name, payload)
139    }
140
141    /// Runs every handler registered for `name` through the scope's defer,
142    /// in the emitting request's `wait_until`, and forwards the event to
143    /// every mounted sidecar (ADR 0017). Never fails the request; handler
144    /// errors are logged with the event name. An emission nothing heard —
145    /// no local handler and nothing forwarded — is warned about: the
146    /// silent delivery this issue was written to prevent.
147    // The by-value payload is the API fixed by issue #4; handlers each get
148    // a clone.
149    #[allow(clippy::needless_pass_by_value)]
150    pub fn emit_in(&self, scope: &Scope, name: &str, payload: Value) {
151        let handled = self.run_local(scope, name, &payload);
152        let forwarded = match self.forwarder.as_ref() {
153            Some(forwarder) if !forwarder.is_empty() => {
154                let forwarder = Arc::clone(forwarder);
155                let request_id = scope.request_id.clone();
156                let event = name.to_string();
157                scope.defer.wait_until(Box::pin(async move {
158                    forwarder.forward(&request_id, &event, &payload).await;
159                }));
160                true
161            }
162            _ => false,
163        };
164        if handled == 0 && !forwarded {
165            let detail = format!("emitted event `{name}` has no registered handler");
166            warn!(event = %name, "{detail}");
167            // Also to the forwarder, for the same reason the inbound half
168            // does it: on Workers the tracing output is dropped, so a
169            // report that exists only there is a report nobody reads —
170            // which is the failure this warning was added to prevent.
171            crate::logging::forward_internal_error(&detail);
172        }
173    }
174}