Skip to main content

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}