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}