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::sync::Arc;
9
10use futures::future::BoxFuture;
11
12/// Executes an activity request originating from workflow code.
13///
14/// Implementations receive the activity name, JSON-encoded input, and
15/// JSON-encoded config as strings — the same wire format that the Gleam SDK
16/// sends through the `aion_flow_ffi:dispatch_activity/3` binding — plus the
17/// one-based delivery attempt stamped by the engine. The return value is the
18/// JSON-encoded activity result or a prefixed error string matching the
19/// SDK's error-decoding convention.
20pub trait ActivityDispatcher: Send + Sync + 'static {
21 /// Dispatch the named activity and block until completion.
22 ///
23 /// `attempt` is the one-based delivery attempt for this dispatch; the
24 /// dispatcher stamps it onto whatever transport carries the task (the
25 /// worker wire's `ActivityTask.attempt`), so consumers can distinguish
26 /// retries without guessing.
27 ///
28 /// Returns `Ok(encoded_output)` on success or `Err(error_string)` on
29 /// failure. Both sides are strings matching the Gleam SDK's
30 /// `Result(String, String)` FFI contract.
31 ///
32 /// # Errors
33 ///
34 /// Returns the error string surfaced by the activity execution path —
35 /// worker rejection, decode failure, timeout, or activity body error.
36 fn dispatch(
37 &self,
38 name: &str,
39 input: &str,
40 config: &str,
41 attempt: u32,
42 ) -> Result<String, String>;
43
44 /// Dispatch the named activity with the calling workflow process id when
45 /// the runtime can provide it.
46 ///
47 /// Implementations that need to correlate a raw NIF call back to an active
48 /// workflow handle can override this method. The default preserves the
49 /// original dispatcher contract for tests and non-workflow callers.
50 ///
51 /// # Errors
52 ///
53 /// Returns the same errors as [`Self::dispatch`].
54 fn dispatch_from_process(
55 &self,
56 name: &str,
57 input: &str,
58 config: &str,
59 attempt: u32,
60 caller_pid: Option<u64>,
61 ) -> Result<String, String> {
62 let _ = caller_pid;
63 self.dispatch(name, input, config, attempt)
64 }
65
66 /// Dispatch the named activity from a Tokio task.
67 ///
68 /// The default runs the synchronous [`Self::dispatch_from_process`] on
69 /// the runtime's blocking pool via [`tokio::task::spawn_blocking`], so a
70 /// dispatcher implementation that blocks its calling thread cannot wedge
71 /// the engine's async workers (a single-threaded engine runtime keeps
72 /// servicing queries and completions while the dispatch waits).
73 /// Nonblocking dispatchers can override this method directly.
74 ///
75 /// Must be awaited inside a Tokio runtime context; the engine's
76 /// completion task guarantees that.
77 ///
78 /// # Errors
79 ///
80 /// Returns the same errors as [`Self::dispatch_from_process`], plus a
81 /// dispatch-failure reason when the blocking task itself is cancelled or
82 /// panics.
83 fn dispatch_async_from_process(
84 self: Arc<Self>,
85 name: String,
86 input: String,
87 config: String,
88 attempt: u32,
89 caller_pid: Option<u64>,
90 ) -> BoxFuture<'static, Result<String, String>> {
91 Box::pin(async move {
92 let blocking = tokio::task::spawn_blocking(move || {
93 self.dispatch_from_process(&name, &input, &config, attempt, caller_pid)
94 });
95 match blocking.await {
96 Ok(result) => result,
97 Err(join_error) => Err(format!("activity dispatch task failed: {join_error}")),
98 }
99 })
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use std::sync::Arc;
106
107 use super::ActivityDispatcher;
108 use crate::runtime::EngineNifState;
109
110 struct Echo;
111
112 impl ActivityDispatcher for Echo {
113 fn dispatch(
114 &self,
115 _name: &str,
116 input: &str,
117 _config: &str,
118 _attempt: u32,
119 ) -> Result<String, String> {
120 Ok(input.to_owned())
121 }
122 }
123
124 #[test]
125 fn dispatcher_is_accessible_after_install_on_engine_state() {
126 let state = EngineNifState::default();
127 state.set_activity_dispatcher(Arc::new(Echo));
128 let dispatcher = state.activity_dispatcher();
129 assert!(dispatcher.is_some());
130 assert_eq!(
131 dispatcher
132 .as_ref()
133 .and_then(|d| d.dispatch("test", "hello", "{}", 1).ok()),
134 Some("hello".to_owned())
135 );
136 }
137}