aion/activity/bridge.rs
1//! Activity dispatch bridge for the `aion_flow_ffi` activity NIFs.
2//!
3//! The bridge decouples the raw NIF function pointer (which cannot capture
4//! state) from the engine's activity execution path. A concrete dispatcher
5//! is installed on the engine's NIF state during build; the NIF recovers it
6//! from its calling context's NIF private data.
7
8use std::collections::BTreeMap;
9use std::sync::Arc;
10
11use aion_core::{ActivityId, RunId, WorkflowId};
12use futures::future::BoxFuture;
13
14/// A fully-resolved activity dispatch crossing the engine→transport seam.
15///
16/// The engine builds this once the durability layer has assigned the
17/// activity its ordinal, so it carries the *real* owning [`WorkflowId`] and
18/// the *real* per-workflow [`ActivityId`] recorded in history — not a
19/// transport-fabricated correlation token. A worker that logs these ids can
20/// therefore be correlated directly against the event store, and a result
21/// re-reported from a previous worker session keys to the exact execution it
22/// belongs to.
23///
24/// `input` and `config` are the JSON strings the Gleam SDK sends through the
25/// `aion_flow_ffi:dispatch_activity/3` binding; `attempt` is the one-based
26/// delivery attempt the engine stamps onto the transport (the worker wire's
27/// `ActivityTask.attempt`), so consumers distinguish retries without guessing.
28#[derive(Clone, Debug)]
29pub struct ActivityDispatch {
30 /// Namespace selected for worker matching — the correctness/isolation
31 /// boundary the activity may dispatch within.
32 pub namespace: String,
33 /// Task queue (pool/flavour) selected within the namespace. The worker-pool
34 /// address is `(namespace, task_queue)`. The engine resolves the activity
35 /// override, workflow default, or recorded start-time queue before this seam.
36 pub task_queue: String,
37 /// OPTIONAL node affinity (NODE-4): the concrete worker host this dispatch is
38 /// pinned to within the `(namespace, task_queue)` pool. `None` = no affinity
39 /// (any worker in the pool). Resolved once at the schedule seam from the
40 /// SDK's per-activity `node` selection; there is no workflow-level default.
41 pub node: Option<String>,
42 /// Real owning workflow id, recorded in history at `WorkflowStarted`.
43 pub workflow_id: WorkflowId,
44 /// Concrete workflow run. Required for a run-scoped idempotency key and
45 /// echoed by the worker on completion.
46 pub run_id: RunId,
47 /// Real per-workflow activity ordinal, recorded at `ActivityScheduled`.
48 pub activity_id: ActivityId,
49 /// Registered activity-type name to match against worker registrations.
50 pub name: String,
51 /// JSON-encoded activity input.
52 pub input: String,
53 /// JSON-encoded dispatch config (retry/timeout/heartbeat policy).
54 pub config: String,
55 /// One-based delivery attempt for this dispatch.
56 pub attempt: u32,
57 /// Human-meaningful display labels the workflow attached to the activity
58 /// (for example `brief=IP-001`, `repo=ablative-io/yggdrasil`). The engine
59 /// never interprets these; they ride to the worker purely so its logs and
60 /// the ops console can show what a dispatch is working on. `BTreeMap` keeps
61 /// the rendered order stable.
62 pub labels: BTreeMap<String, String>,
63 /// Whether the DECLARATION classes this activity as advisory: a side
64 /// channel whose exhaustion warns on the run and never faults the calling
65 /// step (RUNTIME-OPERATIONS.md R5).
66 ///
67 /// Resolved at the schedule seam from the package contract this run is
68 /// pinned to (`nif_activity_advisory`), never from the dispatch config the
69 /// SDK builds — the class is declaration-owned and a call site cannot
70 /// forge it. Dispatchers ignore it; it exists so the retry loop knows to
71 /// record the warning when the attempt budget is spent.
72 pub advisory: bool,
73}
74
75/// The one-shot "a worker has taken this attempt" event, carried across the
76/// engine→transport seam.
77///
78/// # Why an event and not an instant
79///
80/// The per-attempt bound is authored in the document and lives in the engine;
81/// the lease happens in the server, on the far side of a `spawn_blocking`
82/// boundary. Handing the lease INSTANT back would be handing back a value that
83/// arrives after the thing it dates, and the timer would still have to decide
84/// what to do with the gap. Handing back the EVENT lets the clock start where
85/// the lease happens, by construction.
86///
87/// # What it fixes
88///
89/// The per-attempt bound used to wrap the whole dispatch future, and the first
90/// thing inside that future is an unbounded park waiting for a worker to exist.
91/// So schedule-to-start time was charged to a bound the document author wrote
92/// to describe EXECUTION: an activity that waited eleven minutes for a worker
93/// and then ran for four seconds could exceed a five-minute bound without ever
94/// having been slow. Worse, the expiry DISCARDS a result the worker really
95/// produced — a completed measurement thrown away by its own clock.
96///
97/// # The park is now bounded ONLY if an operator bounds it
98///
99/// Say this plainly, because it is a behaviour change and not a refinement. An
100/// authored per-attempt timeout used to end a dispatch that was still waiting
101/// for a worker; it no longer does. It bounds the ATTEMPT, from the lease, and
102/// nothing else.
103///
104/// What bounds the wait instead is the queue-service policy — the
105/// service-availability and schedule-to-start clocks — and both of those are
106/// `Option<Duration>` that default to `None`. So on a server whose operator has
107/// set neither, a dispatch to a queue with no eligible worker waits
108/// indefinitely where it used to fail at the per-attempt bound.
109///
110/// That is deliberate and, on balance, the better behaviour: the old expiry
111/// could not cancel the blocking dispatch it gave up on, so each one stacked
112/// another parked thread on top of the first — the bound amplified threads
113/// rather than freeing them. But no default is invented here to replace it.
114/// Deciding how long work may wait for a worker that does not exist is an
115/// operator's call about their own fleet, and the honest thing is to say the
116/// clock is theirs to set rather than to pick one for them.
117/// Cloning shares one signal: the transport that fires it and the caller that
118/// waits on it hold the same notification, and a clone that outlives the
119/// dispatch simply never fires.
120#[derive(Clone, Debug)]
121pub struct LeaseSignal {
122 leased: Arc<tokio::sync::Notify>,
123}
124
125impl LeaseSignal {
126 /// A signal and the future that waits for it.
127 ///
128 /// The wait retains a fire that lands before it is awaited (the notify
129 /// stores one permit), so a lease that happens while the caller is still
130 /// setting up cannot be missed.
131 #[must_use]
132 pub fn channel() -> (Self, Leased) {
133 let leased = Arc::new(tokio::sync::Notify::new());
134 (
135 Self {
136 leased: Arc::clone(&leased),
137 },
138 Leased { leased },
139 )
140 }
141
142 /// A signal nobody is waiting on.
143 ///
144 /// For the paths that dispatch WITHOUT a bound to start — the synchronous
145 /// [`ActivityDispatcher::dispatch`] entry, which has no timer above it.
146 /// Firing it is a no-op rather than an error: the signal's meaning is "a
147 /// worker took this attempt", which is true whether or not anybody is
148 /// timing it.
149 #[must_use]
150 pub fn none() -> Self {
151 Self {
152 leased: Arc::new(tokio::sync::Notify::new()),
153 }
154 }
155
156 /// A worker holds this attempt. The per-attempt clock starts HERE.
157 ///
158 /// Idempotent and cheap: `Notify::notify_one` stores AT MOST ONE permit, so
159 /// a second fire on an already-fired signal is a no-op. Deliberately takes `&self` so it can be fired from inside the
160 /// transport's own accept hook, which is the instant that also produces the
161 /// durable `ActivityLeased` record — one anchor for both, by construction
162 /// rather than by comment.
163 pub fn fire(&self) {
164 self.leased.notify_one();
165 }
166}
167
168/// The waiting half of a [`LeaseSignal`].
169#[derive(Debug)]
170pub struct Leased {
171 leased: Arc<tokio::sync::Notify>,
172}
173
174impl Leased {
175 /// Wait for the attempt to be leased.
176 ///
177 /// This future never completes on its own if no lease ever happens, and
178 /// that is correct: its only caller races it against the dispatch itself,
179 /// so a dispatch that fails before any worker takes it resolves through the
180 /// other arm. A bound must never start on a dispatch that never began.
181 pub async fn wait(self) {
182 self.leased.notified().await;
183 }
184}
185
186/// Executes an activity request originating from workflow code.
187///
188/// The return value is the JSON-encoded activity result or a prefixed error
189/// string matching the SDK's error-decoding convention.
190pub trait ActivityDispatcher: Send + Sync + 'static {
191 /// Dispatch the activity and block until completion.
192 ///
193 /// Returns `Ok(encoded_output)` on success or `Err(error_string)` on
194 /// failure. Both sides are strings matching the Gleam SDK's
195 /// `Result(String, String)` FFI contract.
196 ///
197 /// # Errors
198 ///
199 /// Returns the error string surfaced by the activity execution path —
200 /// worker rejection, decode failure, timeout, or activity body error.
201 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String>;
202
203 /// Dispatch the activity from a Tokio task.
204 ///
205 /// The default runs the synchronous [`Self::dispatch`] on the runtime's
206 /// blocking pool via [`tokio::task::spawn_blocking`], so a dispatcher that
207 /// blocks its calling thread cannot wedge the engine's async workers (a
208 /// single-threaded engine runtime keeps servicing queries and completions
209 /// while the dispatch waits). Nonblocking dispatchers can override this.
210 ///
211 /// `lease` is fired at the instant a worker HOLDS this attempt. It is what
212 /// starts the authored per-attempt bound, so an implementation that parks
213 /// waiting for a worker keeps that park outside the bound — the park has
214 /// its own clocks (service-availability and schedule-to-start), and
215 /// charging schedule time to an execution bound is what let a completed
216 /// activity be discarded by its own timer.
217 ///
218 /// Must be awaited inside a Tokio runtime context; the engine's
219 /// completion task guarantees that.
220 ///
221 /// # Errors
222 ///
223 /// Returns the same errors as [`Self::dispatch`], plus a dispatch-failure
224 /// reason when the blocking task itself is cancelled or panics.
225 fn dispatch_async(
226 self: Arc<Self>,
227 request: ActivityDispatch,
228 lease: LeaseSignal,
229 ) -> BoxFuture<'static, Result<String, String>> {
230 Box::pin(async move {
231 // A dispatcher on this default has NO lease step: it runs the
232 // synchronous entry above, which begins executing the activity the
233 // moment it is called. So the attempt begins when the call begins,
234 // and the signal fires HERE — the honest anchor for a dispatcher
235 // that never parks waiting for a worker.
236 //
237 // A dispatcher that DOES park — one that selects a worker, waits
238 // for one to exist, and hands the work over — overrides this method
239 // and fires at its own handover instead. Leaving the signal unfired
240 // is not an option for either: the bound above would then never
241 // start, and an authored per-attempt timeout would silently stop
242 // bounding anything.
243 lease.fire();
244 let blocking = tokio::task::spawn_blocking(move || self.dispatch(request));
245 match blocking.await {
246 Ok(result) => result,
247 Err(join_error) => Err(format!("activity dispatch task failed: {join_error}")),
248 }
249 })
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use std::sync::Arc;
256
257 use std::collections::BTreeMap;
258
259 use aion_core::{ActivityId, RunId, WorkflowId};
260
261 use super::{ActivityDispatch, ActivityDispatcher};
262 use crate::runtime::EngineNifState;
263
264 struct Echo;
265
266 impl ActivityDispatcher for Echo {
267 fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
268 Ok(request.input)
269 }
270 }
271
272 fn echo_request(input: &str) -> ActivityDispatch {
273 ActivityDispatch {
274 namespace: "default".to_owned(),
275 task_queue: "default".to_owned(),
276 node: None,
277 workflow_id: WorkflowId::new_v4(),
278 run_id: RunId::new_v4(),
279 activity_id: ActivityId::from_sequence_position(0),
280 name: "test".to_owned(),
281 input: input.to_owned(),
282 config: "{}".to_owned(),
283 attempt: 1,
284 labels: BTreeMap::new(),
285 advisory: false,
286 }
287 }
288
289 #[test]
290 fn dispatcher_is_accessible_after_install_on_engine_state() {
291 let state = EngineNifState::default();
292 state.set_activity_dispatcher(Arc::new(Echo));
293 let dispatcher = state.activity_dispatcher();
294 assert!(dispatcher.is_some());
295 assert_eq!(
296 dispatcher
297 .as_ref()
298 .and_then(|d| d.dispatch(echo_request("hello")).ok()),
299 Some("hello".to_owned())
300 );
301 }
302}