Skip to main content

aion/child/
spawn.rs

1//! Child workflow spawn mechanics over the AE engine seam.
2//!
3//! Spawning is record-then-spawn: the parent pre-allocates the child
4//! workflow identifier, durably records `ChildWorkflowStarted` through its
5//! own single Recorder, and only then asks AE to start the child under that
6//! exact identifier. A crash between the record and the start leaves a
7//! recoverable `ChildWorkflowStarted` (repaired by the engine's startup
8//! recovery sweep) instead of an unrecorded duplicate-prone orphan.
9//!
10//! Children are not process-linked to their parents. Parent death leaves
11//! children running, and awaited child terminals are observed by the
12//! engine's child-terminal watcher, which records the parent-side
13//! `ChildWorkflowCompleted`/`ChildWorkflowFailed` before waking the parent.
14
15use aion_core::{Event, EventEnvelope, Payload, WorkflowId};
16use chrono::{DateTime, Utc};
17
18use crate::engine_seam::{ChildWorkflowSpawnRequest, EngineHandle, EngineSeamError};
19
20/// Metadata used to envelope child-workflow events before routing them through the recorder seam.
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct ChildWorkflowRecordingContext {
23    parent_workflow_id: WorkflowId,
24    next_seq: u64,
25    recorded_at: DateTime<Utc>,
26}
27
28impl ChildWorkflowRecordingContext {
29    /// Creates a recording context with caller-controlled sequence and time.
30    #[must_use]
31    pub const fn new(
32        parent_workflow_id: WorkflowId,
33        next_seq: u64,
34        recorded_at: DateTime<Utc>,
35    ) -> Self {
36        Self {
37            parent_workflow_id,
38            next_seq,
39            recorded_at,
40        }
41    }
42
43    fn next_envelope(&mut self) -> EventEnvelope {
44        let envelope = EventEnvelope {
45            seq: self.next_seq,
46            recorded_at: self.recorded_at,
47            workflow_id: self.parent_workflow_id.clone(),
48        };
49        self.next_seq = self.next_seq.saturating_add(1);
50        envelope
51    }
52
53    fn parent_workflow_id(&self) -> &WorkflowId {
54        &self.parent_workflow_id
55    }
56}
57
58/// Result returned after AE accepts a child-workflow spawn.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct SpawnedChildWorkflow {
61    /// Parent-allocated child workflow identity. The child has its own history.
62    pub child_workflow_id: WorkflowId,
63    /// AE live-process handle for the child execution.
64    pub child_process: crate::engine_seam::WorkflowProcessHandle,
65}
66
67/// Errors produced by child workflow spawn operations.
68#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
69pub enum ChildWorkflowError {
70    /// AE or AD rejected a seam operation.
71    #[error(transparent)]
72    Engine(#[from] EngineSeamError),
73}
74
75/// Records `ChildWorkflowStarted` in the parent's history, then requests the child start.
76///
77/// The caller pre-allocates `child_workflow_id` (recorded nondeterminism:
78/// drawn once on the live path, returned from history on replay) and it is
79/// recorded durably *before* AE is asked to start the child, so the start
80/// is exactly-once recoverable: replay resolves the spawn from the recorded
81/// event, and a crash before the child process exists is repaired by the
82/// startup recovery sweep from the same record.
83///
84/// # Errors
85///
86/// Returns [`ChildWorkflowError`] if the parent recorder rejects the start
87/// event, AE cannot start the child, or AE starts the child under a
88/// different identifier than the recorded one. A failure after the record
89/// leaves the durable `ChildWorkflowStarted` in place by design.
90pub fn spawn(
91    engine: &impl EngineHandle,
92    recording: &mut ChildWorkflowRecordingContext,
93    child_type: impl Into<String>,
94    input: Payload,
95    child_workflow_id: WorkflowId,
96    package_version: aion_core::PackageVersion,
97) -> Result<SpawnedChildWorkflow, ChildWorkflowError> {
98    let workflow_type = child_type.into();
99
100    let event = Event::ChildWorkflowStarted {
101        envelope: recording.next_envelope(),
102        child_workflow_id: child_workflow_id.clone(),
103        workflow_type: workflow_type.clone(),
104        input: input.clone(),
105        package_version: package_version.clone(),
106    };
107    engine.record_workflow_event(recording.parent_workflow_id(), event)?;
108
109    let request = ChildWorkflowSpawnRequest {
110        parent_workflow_id: recording.parent_workflow_id().clone(),
111        child_workflow_id: child_workflow_id.clone(),
112        workflow_type,
113        input,
114        package_version,
115    };
116    let result = engine.spawn_child_workflow(request)?;
117    if result.child_workflow_id != child_workflow_id {
118        return Err(ChildWorkflowError::Engine(EngineSeamError::ChildSpawn {
119            reason: format!(
120                "engine started child {} instead of the recorded id {child_workflow_id}",
121                result.child_workflow_id
122            ),
123        }));
124    }
125    Ok(SpawnedChildWorkflow {
126        child_workflow_id,
127        child_process: result.child_process,
128    })
129}
130
131#[cfg(test)]
132mod tests {
133    use aion_core::{ContentType, Event, Payload, WorkflowId};
134    use chrono::DateTime;
135
136    use super::{ChildWorkflowError, ChildWorkflowRecordingContext, spawn};
137    use crate::engine_seam::test_support::{FakeEngineHandle, FakeEngineOperation};
138    use crate::engine_seam::{ChildWorkflowSpawnResult, EngineSeamError, WorkflowProcessHandle};
139
140    fn payload(bytes: &'static [u8]) -> Payload {
141        Payload::new(ContentType::Json, bytes.to_vec())
142    }
143
144    fn recording(
145        parent: WorkflowId,
146    ) -> Result<ChildWorkflowRecordingContext, Box<dyn std::error::Error>> {
147        let recorded_at =
148            DateTime::parse_from_rfc3339("2026-06-04T12:00:00Z").map(DateTime::from)?;
149        Ok(ChildWorkflowRecordingContext::new(parent, 7, recorded_at))
150    }
151
152    #[test]
153    fn spawn_records_started_before_requesting_the_child_start()
154    -> Result<(), Box<dyn std::error::Error>> {
155        let engine = FakeEngineHandle::new();
156        let parent = WorkflowId::new_v4();
157        let child = WorkflowId::new_v4();
158        let input = payload(br#"{"item":1}"#);
159        engine.push_child_spawn_response(Ok(ChildWorkflowSpawnResult {
160            child_workflow_id: child.clone(),
161            child_process: WorkflowProcessHandle::new(11),
162        }))?;
163        let mut recording = recording(parent.clone())?;
164
165        let spawned = spawn(
166            &engine,
167            &mut recording,
168            "child.worker",
169            input.clone(),
170            child.clone(),
171            aion_core::PackageVersion::new("a".repeat(64)),
172        )?;
173
174        assert_eq!(spawned.child_workflow_id, child);
175        assert_eq!(spawned.child_process, WorkflowProcessHandle::new(11));
176        // #56 contract: the durable record precedes the spawn request, and
177        // both carry the same pre-allocated child id.
178        let operations = engine.operations()?;
179        match (&operations[0], &operations[1]) {
180            (
181                FakeEngineOperation::EventRecorded { workflow_id, event },
182                FakeEngineOperation::ChildSpawnRequested(request),
183            ) => {
184                assert_eq!(workflow_id, &parent);
185                match event {
186                    Event::ChildWorkflowStarted {
187                        child_workflow_id,
188                        workflow_type,
189                        input: recorded_input,
190                        ..
191                    } => {
192                        assert_eq!(child_workflow_id, &child);
193                        assert_eq!(workflow_type, "child.worker");
194                        assert_eq!(recorded_input, &input);
195                    }
196                    other => return Err(format!("unexpected event: {other:?}").into()),
197                }
198                assert_eq!(request.parent_workflow_id, parent);
199                assert_eq!(request.child_workflow_id, child);
200                assert_eq!(request.workflow_type, "child.worker");
201                assert_eq!(request.input, input);
202            }
203            other => {
204                return Err(format!("expected record-then-spawn order, found {other:?}").into());
205            }
206        }
207        Ok(())
208    }
209
210    #[test]
211    fn spawn_failure_after_record_keeps_the_durable_start() -> Result<(), Box<dyn std::error::Error>>
212    {
213        let engine = FakeEngineHandle::new();
214        let parent = WorkflowId::new_v4();
215        let child = WorkflowId::new_v4();
216        engine.push_child_spawn_response(Err(EngineSeamError::ChildSpawn {
217            reason: "engine declined".to_owned(),
218        }))?;
219        let mut recording = recording(parent)?;
220
221        let observed = spawn(
222            &engine,
223            &mut recording,
224            "child.worker",
225            payload(b"null"),
226            child.clone(),
227            aion_core::PackageVersion::new("a".repeat(64)),
228        );
229
230        assert!(matches!(observed, Err(ChildWorkflowError::Engine(_))));
231        // The recorded start survives the failed start request: this is the
232        // crash-window record the recovery sweep repairs from.
233        let recorded = engine.recorded_events()?;
234        assert_eq!(recorded.len(), 1);
235        assert!(matches!(
236            &recorded[0].1,
237            Event::ChildWorkflowStarted { child_workflow_id, .. } if child_workflow_id == &child
238        ));
239        Ok(())
240    }
241
242    #[test]
243    fn spawn_rejects_engine_echoing_a_different_child_identity()
244    -> Result<(), Box<dyn std::error::Error>> {
245        let engine = FakeEngineHandle::new();
246        let parent = WorkflowId::new_v4();
247        engine.push_child_spawn_response(Ok(ChildWorkflowSpawnResult {
248            child_workflow_id: WorkflowId::new_v4(),
249            child_process: WorkflowProcessHandle::new(16),
250        }))?;
251        let mut recording = recording(parent)?;
252
253        let observed = spawn(
254            &engine,
255            &mut recording,
256            "child.worker",
257            payload(b"null"),
258            WorkflowId::new_v4(),
259            aion_core::PackageVersion::new("a".repeat(64)),
260        );
261
262        match observed {
263            Err(ChildWorkflowError::Engine(EngineSeamError::ChildSpawn { reason })) => {
264                assert!(
265                    reason.contains("instead of the recorded id"),
266                    "unexpected reason: {reason}"
267                );
268            }
269            other => return Err(format!("expected echo-mismatch failure: {other:?}").into()),
270        }
271        Ok(())
272    }
273}