Skip to main content

aion/activity/
dispatch.rs

1//! Tier-2 in-VM activity child spawn and outcome propagation.
2//!
3//! This module only spawns an activity process, links it to the workflow
4//! process, and provides the plumbing that surfaces the child outcome back to
5//! that workflow. The AD append path records Activity events. AT policy
6//! machinery consumes surfaced activity errors to decide any future retry.
7//!
8//! PRODUCTION PATH NOTE (CUT 3): the production in-VM tier now dispatches
9//! through `aion_flow_ffi:dispatch_activity_in_vm/4`
10//! (`runtime::nif_activity_in_vm`), which spawns the SDK-supplied runner thunk
11//! via [`RuntimeHandle::spawn_activity_closure`] (a beamr closure spawn whose
12//! environment is deep-copied into the child heap) and delivers the outcome on
13//! the correlation/ordinal-keyed two-phase maps — NOT through this module's
14//! `(parent, child_pid)`-keyed propagation. The closure variant deliberately
15//! lives on the runtime boundary rather than here: this module's contract is
16//! beamr-type-free (payloads in, pids out), while a closure is irreducibly a
17//! runtime `Term`. The named-function variant below remains the seam for
18//! registered/generated in-VM entrypoints and owns the dirty-scheduling
19//! decision for that path.
20
21use aion_core::{ActivityError, Payload};
22
23use crate::{EngineError, Pid, RuntimeHandle, RuntimeInput};
24
25/// Dispatch an in-VM activity as a child process linked to `parent_pid`.
26///
27/// Dirty scheduling is resolved by [`RuntimeHandle::spawn_activity`], which
28/// looks up the installed NIF registration metadata for the module/function and
29/// entry arity. The caller does not provide or guess a dirty flag.
30///
31/// # Errors
32///
33/// Returns [`EngineError::Runtime`] when payload conversion fails, the parent is
34/// not live, or the runtime cannot spawn the linked child.
35pub fn dispatch_activity(
36    runtime: &RuntimeHandle,
37    parent_pid: Pid,
38    deployed_module: &str,
39    function: &str,
40    input: &Payload,
41) -> Result<Pid, EngineError> {
42    runtime.spawn_activity(
43        parent_pid,
44        deployed_module,
45        function,
46        RuntimeInput::from_payload(input)?,
47    )
48}
49
50/// Surface an already exited activity child's outcome to its workflow parent.
51///
52/// Normal completion stores a result [`Payload`] for the parent and queues a
53/// runtime-owned result marker in the workflow mailbox. Abnormal completion
54/// stores the typed [`ActivityError`] associated with the trapped exit signal;
55/// dispatch preserves the error classification and makes no policy decision.
56///
57/// # Errors
58///
59/// Returns [`EngineError::Runtime`] when the parent is no longer live or the
60/// runtime cannot translate/deliver the activity outcome.
61pub fn propagate_activity_outcome(
62    runtime: &RuntimeHandle,
63    parent_pid: Pid,
64    activity_pid: Pid,
65) -> Result<(), EngineError> {
66    runtime.propagate_activity_outcome(parent_pid, activity_pid)
67}
68
69/// Attach a typed activity error to a trapped activity EXIT signal.
70///
71/// This helper is used by the runtime/AT seam when an activity implementation
72/// has produced a classified [`ActivityError`] before the linked process exits.
73///
74/// # Errors
75///
76/// Returns [`EngineError::Runtime`] when the workflow process is no longer live.
77pub fn surface_activity_error(
78    runtime: &RuntimeHandle,
79    parent_pid: Pid,
80    activity_pid: Pid,
81    error: ActivityError,
82) -> Result<(), EngineError> {
83    runtime.deliver_activity_error(parent_pid, activity_pid, error)
84}
85
86#[cfg(test)]
87mod tests {
88    use aion_core::{ActivityErrorKind, ContentType};
89    use serde_json::json;
90
91    use super::{dispatch_activity, propagate_activity_outcome, surface_activity_error};
92    use crate::runtime::RuntimeConfig;
93    use crate::{EngineError, RuntimeHandle};
94
95    fn runtime() -> Result<RuntimeHandle, EngineError> {
96        RuntimeHandle::new(RuntimeConfig::new(Some(1)))
97    }
98
99    fn payload() -> Result<aion_core::Payload, aion_core::PayloadError> {
100        aion_core::Payload::from_json(&json!(null))
101    }
102
103    fn fixture_workflow_beam() -> &'static [u8] {
104        include_bytes!("../../tests/fixtures/aion_fixture_workflow.beam")
105    }
106
107    #[test]
108    fn dispatch_spawns_linked_child_and_uses_dirty_registration()
109    -> Result<(), Box<dyn std::error::Error>> {
110        let runtime = runtime()?;
111        runtime.install_test_activity_nif("activity_host", "answer", true, true)?;
112        runtime.register_native_call_module_for_test(
113            "activity_mod",
114            "run",
115            "activity_host",
116            "answer",
117        );
118        let workflow = runtime.spawn_test_process_with_trap_exit(true)?;
119
120        let activity = dispatch_activity(&runtime, workflow, "activity_mod", "run", &payload()?)?;
121
122        if let Ok(linked) = runtime.is_linked(workflow, activity) {
123            assert!(linked);
124        }
125        assert!(runtime.is_dirty("activity_host", "answer"));
126        runtime.shutdown()?;
127        Ok(())
128    }
129
130    #[test]
131    fn successful_activity_result_is_delivered_to_workflow()
132    -> Result<(), Box<dyn std::error::Error>> {
133        let runtime = runtime()?;
134        runtime.install_test_activity_nif("activity_host", "answer", false, true)?;
135        runtime.register_native_call_module_for_test(
136            "activity_ok",
137            "run",
138            "activity_host",
139            "answer",
140        );
141        let workflow = runtime.spawn_test_process_with_trap_exit(true)?;
142        let activity = dispatch_activity(&runtime, workflow, "activity_ok", "run", &payload()?)?;
143
144        propagate_activity_outcome(&runtime, workflow, activity)?;
145
146        let result = runtime.activity_result(workflow, activity);
147        assert_eq!(result, Some(aion_core::Payload::from_json(&json!(42))?));
148        runtime.shutdown()?;
149        Ok(())
150    }
151
152    #[test]
153    fn failing_activity_surfaces_typed_error_with_trapped_exit()
154    -> Result<(), Box<dyn std::error::Error>> {
155        let runtime = runtime()?;
156        runtime.register_module("aion_fixture_workflow", fixture_workflow_beam())?;
157        let workflow = runtime.spawn_test_process_with_trap_exit(true)?;
158        let activity = dispatch_activity(
159            &runtime,
160            workflow,
161            "aion_fixture_workflow",
162            "activity",
163            &payload()?,
164        )?;
165        assert!(runtime.is_linked(workflow, activity)?);
166        let details = aion_core::Payload::new(ContentType::Json, br#"{"code":"boom"}"#.to_vec());
167        let error = aion_core::ActivityError {
168            kind: ActivityErrorKind::Retryable,
169            message: String::from("boom"),
170            details: Some(details),
171        };
172        surface_activity_error(&runtime, workflow, activity, error.clone())?;
173        runtime.wait_for_process_ready(workflow)?;
174        runtime.wait_for_process_ready(activity)?;
175        runtime.terminate_test_process_with_error(activity)?;
176
177        propagate_activity_outcome(&runtime, workflow, activity)?;
178        runtime.wait_for_trapped_exit(workflow, activity)?;
179
180        assert!(runtime.has_trapped_exit_message(workflow, activity)?);
181        assert_eq!(runtime.activity_error(workflow, activity), Some(error));
182        runtime.shutdown()?;
183        Ok(())
184    }
185}