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, 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)`. There is no workflow-level
35    /// selection yet, so the engine seam stamps the named `"default"` pool until
36    /// the Gleam SDK selection (NSTQ-4) lands.
37    pub task_queue: String,
38    /// OPTIONAL node affinity (NODE-4): the concrete worker host this dispatch is
39    /// pinned to within the `(namespace, task_queue)` pool. `None` = no affinity
40    /// (any worker in the pool). Resolved once at the schedule seam from the
41    /// SDK's per-activity `node` selection; there is no workflow-level default.
42    pub node: Option<String>,
43    /// Real owning workflow id, recorded in history at `WorkflowStarted`.
44    pub workflow_id: WorkflowId,
45    /// Real per-workflow activity ordinal, recorded at `ActivityScheduled`.
46    pub activity_id: ActivityId,
47    /// Registered activity-type name to match against worker registrations.
48    pub name: String,
49    /// JSON-encoded activity input.
50    pub input: String,
51    /// JSON-encoded dispatch config (retry/timeout/heartbeat policy).
52    pub config: String,
53    /// One-based delivery attempt for this dispatch.
54    pub attempt: u32,
55    /// Human-meaningful display labels the workflow attached to the activity
56    /// (for example `brief=IP-001`, `repo=ablative-io/yggdrasil`). The engine
57    /// never interprets these; they ride to the worker purely so its logs and
58    /// the ops console can show what a dispatch is working on. `BTreeMap` keeps
59    /// the rendered order stable.
60    pub labels: BTreeMap<String, String>,
61}
62
63/// Executes an activity request originating from workflow code.
64///
65/// The return value is the JSON-encoded activity result or a prefixed error
66/// string matching the SDK's error-decoding convention.
67pub trait ActivityDispatcher: Send + Sync + 'static {
68    /// Dispatch the activity and block until completion.
69    ///
70    /// Returns `Ok(encoded_output)` on success or `Err(error_string)` on
71    /// failure. Both sides are strings matching the Gleam SDK's
72    /// `Result(String, String)` FFI contract.
73    ///
74    /// # Errors
75    ///
76    /// Returns the error string surfaced by the activity execution path —
77    /// worker rejection, decode failure, timeout, or activity body error.
78    fn dispatch(&self, request: ActivityDispatch) -> Result<String, String>;
79
80    /// Dispatch the activity from a Tokio task.
81    ///
82    /// The default runs the synchronous [`Self::dispatch`] on the runtime's
83    /// blocking pool via [`tokio::task::spawn_blocking`], so a dispatcher that
84    /// blocks its calling thread cannot wedge the engine's async workers (a
85    /// single-threaded engine runtime keeps servicing queries and completions
86    /// while the dispatch waits). Nonblocking dispatchers can override this.
87    ///
88    /// Must be awaited inside a Tokio runtime context; the engine's
89    /// completion task guarantees that.
90    ///
91    /// # Errors
92    ///
93    /// Returns the same errors as [`Self::dispatch`], plus a dispatch-failure
94    /// reason when the blocking task itself is cancelled or panics.
95    fn dispatch_async(
96        self: Arc<Self>,
97        request: ActivityDispatch,
98    ) -> BoxFuture<'static, Result<String, String>> {
99        Box::pin(async move {
100            let blocking = tokio::task::spawn_blocking(move || self.dispatch(request));
101            match blocking.await {
102                Ok(result) => result,
103                Err(join_error) => Err(format!("activity dispatch task failed: {join_error}")),
104            }
105        })
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use std::sync::Arc;
112
113    use std::collections::BTreeMap;
114
115    use aion_core::{ActivityId, WorkflowId};
116
117    use super::{ActivityDispatch, ActivityDispatcher};
118    use crate::runtime::EngineNifState;
119
120    struct Echo;
121
122    impl ActivityDispatcher for Echo {
123        fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
124            Ok(request.input)
125        }
126    }
127
128    fn echo_request(input: &str) -> ActivityDispatch {
129        ActivityDispatch {
130            namespace: "default".to_owned(),
131            task_queue: "default".to_owned(),
132            node: None,
133            workflow_id: WorkflowId::new_v4(),
134            activity_id: ActivityId::from_sequence_position(0),
135            name: "test".to_owned(),
136            input: input.to_owned(),
137            config: "{}".to_owned(),
138            attempt: 1,
139            labels: BTreeMap::new(),
140        }
141    }
142
143    #[test]
144    fn dispatcher_is_accessible_after_install_on_engine_state() {
145        let state = EngineNifState::default();
146        state.set_activity_dispatcher(Arc::new(Echo));
147        let dispatcher = state.activity_dispatcher();
148        assert!(dispatcher.is_some());
149        assert_eq!(
150            dispatcher
151                .as_ref()
152                .and_then(|d| d.dispatch(echo_request("hello")).ok()),
153            Some("hello".to_owned())
154        );
155    }
156}