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}