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/// Executes an activity request originating from workflow code.
76///
77/// The return value is the JSON-encoded activity result or a prefixed error
78/// string matching the SDK's error-decoding convention.
79pub trait ActivityDispatcher: Send + Sync + 'static {
80    /// Dispatch the activity and block until completion.
81    ///
82    /// Returns `Ok(encoded_output)` on success or `Err(error_string)` on
83    /// failure. Both sides are strings matching the Gleam SDK's
84    /// `Result(String, String)` FFI contract.
85    ///
86    /// # Errors
87    ///
88    /// Returns the error string surfaced by the activity execution path —
89    /// worker rejection, decode failure, timeout, or activity body error.
90    fn dispatch(&self, request: ActivityDispatch) -> Result<String, String>;
91
92    /// Dispatch the activity from a Tokio task.
93    ///
94    /// The default runs the synchronous [`Self::dispatch`] on the runtime's
95    /// blocking pool via [`tokio::task::spawn_blocking`], so a dispatcher that
96    /// blocks its calling thread cannot wedge the engine's async workers (a
97    /// single-threaded engine runtime keeps servicing queries and completions
98    /// while the dispatch waits). Nonblocking dispatchers can override this.
99    ///
100    /// Must be awaited inside a Tokio runtime context; the engine's
101    /// completion task guarantees that.
102    ///
103    /// # Errors
104    ///
105    /// Returns the same errors as [`Self::dispatch`], plus a dispatch-failure
106    /// reason when the blocking task itself is cancelled or panics.
107    fn dispatch_async(
108        self: Arc<Self>,
109        request: ActivityDispatch,
110    ) -> BoxFuture<'static, Result<String, String>> {
111        Box::pin(async move {
112            let blocking = tokio::task::spawn_blocking(move || self.dispatch(request));
113            match blocking.await {
114                Ok(result) => result,
115                Err(join_error) => Err(format!("activity dispatch task failed: {join_error}")),
116            }
117        })
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use std::sync::Arc;
124
125    use std::collections::BTreeMap;
126
127    use aion_core::{ActivityId, RunId, WorkflowId};
128
129    use super::{ActivityDispatch, ActivityDispatcher};
130    use crate::runtime::EngineNifState;
131
132    struct Echo;
133
134    impl ActivityDispatcher for Echo {
135        fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
136            Ok(request.input)
137        }
138    }
139
140    fn echo_request(input: &str) -> ActivityDispatch {
141        ActivityDispatch {
142            namespace: "default".to_owned(),
143            task_queue: "default".to_owned(),
144            node: None,
145            workflow_id: WorkflowId::new_v4(),
146            run_id: RunId::new_v4(),
147            activity_id: ActivityId::from_sequence_position(0),
148            name: "test".to_owned(),
149            input: input.to_owned(),
150            config: "{}".to_owned(),
151            attempt: 1,
152            labels: BTreeMap::new(),
153            advisory: false,
154        }
155    }
156
157    #[test]
158    fn dispatcher_is_accessible_after_install_on_engine_state() {
159        let state = EngineNifState::default();
160        state.set_activity_dispatcher(Arc::new(Echo));
161        let dispatcher = state.activity_dispatcher();
162        assert!(dispatcher.is_some());
163        assert_eq!(
164            dispatcher
165                .as_ref()
166                .and_then(|d| d.dispatch(echo_request("hello")).ok()),
167            Some("hello".to_owned())
168        );
169    }
170}