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}