1use aion_core::{Event, EventEnvelope, Payload, WorkflowId};
16use chrono::{DateTime, Utc};
17
18use crate::engine_seam::{ChildWorkflowSpawnRequest, EngineHandle, EngineSeamError};
19
20#[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 #[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#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct SpawnedChildWorkflow {
61 pub child_workflow_id: WorkflowId,
63 pub child_process: crate::engine_seam::WorkflowProcessHandle,
65}
66
67#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
69pub enum ChildWorkflowError {
70 #[error(transparent)]
72 Engine(#[from] EngineSeamError),
73}
74
75pub 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 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 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}