1use std::sync::Arc;
4
5use aion_core::{
6 Event, Payload, RunId, WorkflowError, WorkflowId, current_lease_terminal, run_segment,
7};
8use aion_store::EventStore;
9use aion_store::visibility::VisibilityStore;
10use chrono::Utc;
11use tokio::runtime::Handle;
12
13use crate::EngineError;
14use crate::loader::WorkflowCatalog;
15use crate::registry::{Registry, Residency, TerminalOutcome, WorkflowHandle};
16use crate::runtime::{RuntimeHandle, WorkflowProcessOutcome};
17use crate::supervision::SupervisionTree;
18
19use super::start::{self, StartWorkflowContext, StartWorkflowOptions};
20use super::visibility::upsert_workflow_visibility;
21
22#[derive(Clone)]
24pub struct ProcessExitContext {
25 pub store: Arc<dyn EventStore>,
27 pub visibility_store: Arc<dyn VisibilityStore>,
29 pub registry: Arc<Registry>,
31 pub catalog: Arc<WorkflowCatalog>,
33 pub runtime: Arc<RuntimeHandle>,
35 pub supervision: Arc<SupervisionTree>,
37 pub tokio_handle: Handle,
39 pub search_attribute_schema: Arc<aion_core::SearchAttributeSchema>,
41}
42
43pub fn handle_process_exit(
55 context: ProcessExitContext,
56 handle: WorkflowHandle,
57 outcome: Result<WorkflowProcessOutcome, EngineError>,
58) -> Result<(), EngineError> {
59 context
60 .tokio_handle
61 .clone()
62 .block_on(handle_process_exit_async(context, handle, outcome))
63}
64
65async fn handle_process_exit_async(
66 context: ProcessExitContext,
67 handle: WorkflowHandle,
68 outcome: Result<WorkflowProcessOutcome, EngineError>,
69) -> Result<(), EngineError> {
70 let recorded = {
75 let recorder = handle.recorder();
76 let mut recorder = recorder.lock().await;
77 let history = context.store.read_history(handle.workflow_id()).await?;
78 if let Some(existing) = terminal_outcome_from_history(&history, handle.run_id()) {
79 if !matches!(existing, TerminalOutcome::TimedOut(_)) {
95 crate::time::retire_run_deadline(&mut recorder, &history, handle.run_id()).await?;
96 }
97 Err(existing)
98 } else {
99 let outcome = match outcome {
100 Ok(WorkflowProcessOutcome::Completed(result)) => {
101 recorder
102 .record_workflow_completed(Utc::now(), result.clone())
103 .await?;
104 TerminalOutcome::Completed(result)
105 }
106 Ok(WorkflowProcessOutcome::Failed(error)) => {
107 recorder
108 .record_workflow_failed(Utc::now(), error.clone())
109 .await?;
110 TerminalOutcome::Failed(error)
111 }
112 Err(error) => {
113 let workflow_error = WorkflowError {
114 message: format!("workflow process monitor failed: {error}"),
115 details: None,
116 };
117 recorder
118 .record_workflow_failed(Utc::now(), workflow_error.clone())
119 .await?;
120 TerminalOutcome::Failed(workflow_error)
121 }
122 };
123 crate::time::retire_run_deadline(&mut recorder, &history, handle.run_id()).await?;
132 Ok(outcome)
133 }
134 };
135
136 let terminal = match recorded {
142 Err(existing) => {
143 handle.completion().notify(existing.clone());
144 reconcile_terminal_registry(&context, handle.workflow_id(), handle.run_id()).await?;
151 if let TerminalOutcome::ContinuedAsNew {
152 input,
153 workflow_type,
154 parent_run_id,
155 } = existing
156 {
157 start_continuation_replacement(
158 &context,
159 &handle,
160 input,
161 workflow_type,
162 parent_run_id,
163 )
164 .await?;
165 }
166 return Ok(());
167 }
168 Ok(terminal) => terminal,
169 };
170 handle.completion().notify(terminal);
171
172 upsert_workflow_visibility(
173 Arc::clone(&context.store),
174 Arc::clone(&context.visibility_store),
175 handle.workflow_id(),
176 handle.run_id(),
177 )
178 .await?;
179 reconcile_terminal_registry(&context, handle.workflow_id(), handle.run_id()).await?;
180 Ok(())
181}
182
183async fn reconcile_terminal_registry(
184 context: &ProcessExitContext,
185 id: &WorkflowId,
186 run: &RunId,
187) -> Result<(), EngineError> {
188 let history = context.store.read_history(id).await?;
189 context.registry.reconcile(id, run, &history)?;
190 context
191 .registry
192 .replace_residency(id, run, Residency::Suspended)?;
193 Ok(())
194}
195
196async fn start_continuation_replacement(
197 context: &ProcessExitContext,
198 handle: &WorkflowHandle,
199 input: Payload,
200 workflow_type: Option<String>,
201 parent_run_id: RunId,
202) -> Result<(), EngineError> {
203 let replacement_type = workflow_type.as_deref().unwrap_or(handle.workflow_type());
204 let already_started = context
205 .store
206 .read_history(handle.workflow_id())
207 .await?
208 .iter()
209 .any(|event| {
210 matches!(
211 event,
212 Event::WorkflowStarted {
213 parent_run_id: Some(existing_parent),
214 ..
215 } if existing_parent == &parent_run_id
216 )
217 });
218 if already_started {
219 return Ok(());
220 }
221
222 start::start_workflow_with_options(
223 StartWorkflowContext {
224 store: Arc::clone(&context.store),
225 visibility_store: Arc::clone(&context.visibility_store),
226 catalog: Arc::clone(&context.catalog),
227 runtime: Arc::clone(&context.runtime),
228 supervision: Arc::clone(&context.supervision),
229 registry: Arc::clone(&context.registry),
230 signal_handoff: None,
231 search_attribute_schema: Arc::clone(&context.search_attribute_schema),
232 monitor_tokio_handle: context.tokio_handle.clone(),
233 },
234 replacement_type,
235 input,
236 StartWorkflowOptions {
237 workflow_id: Some(handle.workflow_id().clone()),
238 routing_key: None,
240 parent_run_id: Some(parent_run_id),
241 loaded_version: None,
244 search_attributes: std::collections::HashMap::new(),
246 namespace: Some(handle.namespace().to_owned()),
247 },
248 )
249 .await?;
250 Ok(())
251}
252
253pub(crate) fn terminal_outcome_from_history(
254 events: &[Event],
255 run_id: &RunId,
256) -> Option<TerminalOutcome> {
257 match current_lease_terminal(run_segment(events, run_id))? {
261 Event::WorkflowCompleted { result, .. } => Some(TerminalOutcome::Completed(result.clone())),
262 Event::WorkflowFailed { error, .. } => Some(TerminalOutcome::Failed(error.clone())),
263 Event::WorkflowCancelled { reason, .. } => Some(TerminalOutcome::Cancelled(reason.clone())),
264 Event::WorkflowTimedOut { timeout, .. } => Some(TerminalOutcome::TimedOut(timeout.clone())),
265 Event::WorkflowContinuedAsNew {
266 input,
267 workflow_type,
268 parent_run_id,
269 ..
270 } if parent_run_id == run_id => Some(TerminalOutcome::ContinuedAsNew {
271 input: input.clone(),
272 workflow_type: workflow_type.clone(),
273 parent_run_id: parent_run_id.clone(),
274 }),
275 _ => None,
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use std::sync::Arc;
285
286 use aion_core::{Event, Payload, WorkflowStatus};
287 use aion_package::ContentHash;
288 use aion_store::visibility::VisibilityStore;
289 use aion_store::{EventStore, InMemoryStore};
290 use serde_json::json;
291
292 use super::{ProcessExitContext, handle_process_exit_async, terminal_outcome_from_history};
293 use crate::durability::Recorder;
294 use crate::loader::WorkflowCatalog;
295 use crate::registry::{
296 CompletionNotifier, HandleResidency, Registry, TerminalOutcome, WorkflowHandle,
297 WorkflowHandleParts,
298 };
299 use crate::runtime::{RuntimeConfig, RuntimeHandle, WorkflowProcessOutcome};
300 use crate::supervision::SupervisionTree;
301
302 struct ActiveWorkflow {
303 context: ProcessExitContext,
304 handle: WorkflowHandle,
305 }
306
307 fn payload(label: &str) -> Result<Payload, aion_core::PayloadError> {
308 Payload::from_json(&json!({ "label": label }))
309 }
310
311 fn workflow_error(message: &str) -> aion_core::WorkflowError {
312 aion_core::WorkflowError {
313 message: message.to_owned(),
314 details: None,
315 }
316 }
317
318 async fn active_workflow() -> Result<ActiveWorkflow, Box<dyn std::error::Error>> {
319 let backing = Arc::new(InMemoryStore::default());
320 let store: Arc<dyn EventStore> = Arc::clone(&backing) as Arc<dyn EventStore>;
321 let visibility_store: Arc<dyn VisibilityStore> = backing;
322 let registry = Arc::new(Registry::default());
323 let workflow_id = aion_core::WorkflowId::new_v4();
324 let run_id = aion_core::RunId::new_v4();
325 let mut recorder = Recorder::new(workflow_id.clone(), Arc::clone(&store));
326 recorder
327 .record_workflow_started(
328 chrono::Utc::now(),
329 crate::durability::WorkflowStartRecord {
330 workflow_type: "checkout".to_owned(),
331 input: payload("input")?,
332 run_id: run_id.clone(),
333 parent_run_id: None,
334 package_version: aion_core::PackageVersion::new("a".repeat(64)),
335 },
336 )
337 .await?;
338 let handle = WorkflowHandle::new(WorkflowHandleParts {
339 workflow_id: workflow_id.clone(),
340 run_id: run_id.clone(),
341 pid: 1,
342 workflow_type: "checkout".to_owned(),
343 namespace: String::from("default"),
344 loaded_version: ContentHash::from_bytes([9; 32]),
345 cached_status: WorkflowStatus::Running,
346 residency: HandleResidency::Resident,
347 recorder,
348 completion: CompletionNotifier::new(),
349 });
350 registry.insert((workflow_id, run_id), handle.clone())?;
351 Ok(ActiveWorkflow {
352 context: ProcessExitContext {
353 store,
354 visibility_store,
355 registry,
356 catalog: Arc::new(WorkflowCatalog::new()),
357 runtime: Arc::new(RuntimeHandle::new(RuntimeConfig::new(Some(1)))?),
358 supervision: Arc::new(SupervisionTree::new()),
359 tokio_handle: tokio::runtime::Handle::current(),
360 search_attribute_schema: Arc::new(aion_core::SearchAttributeSchema::new()),
361 },
362 handle,
363 })
364 }
365
366 #[tokio::test]
367 async fn normal_exit_records_completed_reconciles_and_notifies()
368 -> Result<(), Box<dyn std::error::Error>> {
369 let active = active_workflow().await?;
370 let result = payload("result")?;
371 let mut early = active.handle.completion().subscribe();
372
373 handle_process_exit_async(
374 active.context.clone(),
375 active.handle.clone(),
376 Ok(WorkflowProcessOutcome::Completed(result.clone())),
377 )
378 .await?;
379 early.changed().await?;
380
381 assert_eq!(
382 early.borrow().clone(),
383 Some(TerminalOutcome::Completed(result.clone()))
384 );
385 assert_eq!(
386 active.handle.completion().subscribe().borrow().clone(),
387 Some(TerminalOutcome::Completed(result.clone()))
388 );
389 let registered = active
390 .context
391 .registry
392 .get(active.handle.workflow_id(), active.handle.run_id())?
393 .ok_or("missing registered handle")?;
394 assert_eq!(registered.cached_status(), WorkflowStatus::Completed);
395 assert_eq!(registered.residency(), HandleResidency::Suspended);
396 let history = active
397 .context
398 .store
399 .read_history(active.handle.workflow_id())
400 .await?;
401 match history.as_slice() {
402 [
403 Event::WorkflowStarted { .. },
404 Event::WorkflowCompleted {
405 result: recorded, ..
406 },
407 ] => {
408 assert_eq!(recorded, &result);
409 }
410 other => return Err(format!("expected started then completed, found {other:?}").into()),
411 }
412 Ok(())
413 }
414
415 #[test]
416 fn terminal_outcome_is_scoped_to_requested_run_segment()
417 -> Result<(), Box<dyn std::error::Error>> {
418 let old_run_id = aion_core::RunId::new(uuid::Uuid::from_u128(1));
419 let new_run_id = aion_core::RunId::new(uuid::Uuid::from_u128(2));
420 let input = payload("next")?;
421 let result = payload("done")?;
422 let workflow_id = aion_core::WorkflowId::new_v4();
423 let envelope = |seq| aion_core::EventEnvelope {
424 seq,
425 recorded_at: chrono::Utc::now(),
426 workflow_id: workflow_id.clone(),
427 };
428 let events = vec![
429 Event::WorkflowStarted {
430 envelope: envelope(1),
431 workflow_type: "checkout".to_owned(),
432 input: payload("first")?,
433 run_id: old_run_id.clone(),
434 parent_run_id: None,
435 package_version: aion_core::PackageVersion::new("a".repeat(64)),
436 },
437 Event::WorkflowContinuedAsNew {
438 envelope: envelope(2),
439 input: input.clone(),
440 workflow_type: None,
441 parent_run_id: old_run_id.clone(),
442 },
443 Event::WorkflowStarted {
444 envelope: envelope(3),
445 workflow_type: "checkout".to_owned(),
446 input,
447 run_id: new_run_id.clone(),
448 parent_run_id: Some(old_run_id.clone()),
449 package_version: aion_core::PackageVersion::new("a".repeat(64)),
450 },
451 Event::WorkflowCompleted {
452 envelope: envelope(4),
453 result: result.clone(),
454 },
455 ];
456
457 assert_eq!(
458 terminal_outcome_from_history(&events, &old_run_id),
459 Some(TerminalOutcome::ContinuedAsNew {
460 input: payload("next")?,
461 workflow_type: None,
462 parent_run_id: old_run_id,
463 })
464 );
465 assert_eq!(
466 terminal_outcome_from_history(&events, &new_run_id),
467 Some(TerminalOutcome::Completed(result))
468 );
469 Ok(())
470 }
471
472 #[tokio::test]
473 async fn process_exit_resumes_interrupted_deadline_cancellation()
474 -> Result<(), Box<dyn std::error::Error>> {
475 let active = active_workflow().await?;
481 let run_id = active.handle.run_id().clone();
482 let deadline_id = crate::time::deadline_timer_id(&run_id)?;
483 let result = payload("result")?;
484 {
485 let recorder = active.handle.recorder();
486 let mut recorder = recorder.lock().await;
487 recorder
488 .record_timer_started(chrono::Utc::now(), deadline_id.clone(), chrono::Utc::now())
489 .await?;
490 recorder
491 .record_workflow_completed(chrono::Utc::now(), result.clone())
492 .await?;
493 }
494 let before = active
495 .context
496 .store
497 .read_history(active.handle.workflow_id())
498 .await?;
499 assert!(
500 crate::time::outstanding_deadline_timer(&before, &run_id).is_some(),
501 "the deadline is outstanding before the resume"
502 );
503
504 handle_process_exit_async(
505 active.context.clone(),
506 active.handle.clone(),
507 Ok(WorkflowProcessOutcome::Completed(result.clone())),
508 )
509 .await?;
510
511 let history = active
512 .context
513 .store
514 .read_history(active.handle.workflow_id())
515 .await?;
516 assert_eq!(
517 crate::time::outstanding_deadline_timer(&history, &run_id),
518 None,
519 "re-encountering the own terminal completes the deadline cancellation: {history:#?}"
520 );
521 Ok(())
522 }
523
524 #[tokio::test]
525 async fn process_exit_does_not_retire_a_timed_out_deadline()
526 -> Result<(), Box<dyn std::error::Error>> {
527 let active = active_workflow().await?;
533 let run_id = active.handle.run_id().clone();
534 let deadline_id = crate::time::deadline_timer_id(&run_id)?;
535 {
536 let recorder = active.handle.recorder();
537 let mut recorder = recorder.lock().await;
538 recorder
539 .record_timer_started(chrono::Utc::now(), deadline_id.clone(), chrono::Utc::now())
540 .await?;
541 recorder
542 .record_workflow_timed_out(chrono::Utc::now(), String::from("workflow"))
543 .await?;
544 }
545
546 handle_process_exit_async(
547 active.context.clone(),
548 active.handle.clone(),
549 Ok(WorkflowProcessOutcome::Completed(payload("late")?)),
550 )
551 .await?;
552
553 let history = active
554 .context
555 .store
556 .read_history(active.handle.workflow_id())
557 .await?;
558 assert!(
559 crate::time::outstanding_deadline_timer(&history, &run_id).is_some(),
560 "the monitor must leave a TimedOut deadline live for its owning teardown: {history:#?}"
561 );
562 Ok(())
563 }
564
565 #[tokio::test]
566 async fn abnormal_exit_records_failed_reconciles_and_notifies()
567 -> Result<(), Box<dyn std::error::Error>> {
568 let active = active_workflow().await?;
569 let error = workflow_error("process crashed: error");
570 let mut early = active.handle.completion().subscribe();
571
572 handle_process_exit_async(
573 active.context.clone(),
574 active.handle.clone(),
575 Ok(WorkflowProcessOutcome::Failed(error.clone())),
576 )
577 .await?;
578 early.changed().await?;
579
580 assert_eq!(
581 early.borrow().clone(),
582 Some(TerminalOutcome::Failed(error.clone()))
583 );
584 assert_eq!(
585 active.handle.completion().subscribe().borrow().clone(),
586 Some(TerminalOutcome::Failed(error.clone()))
587 );
588 let registered = active
589 .context
590 .registry
591 .get(active.handle.workflow_id(), active.handle.run_id())?
592 .ok_or("missing registered handle")?;
593 assert_eq!(registered.cached_status(), WorkflowStatus::Failed);
594 assert_eq!(registered.residency(), HandleResidency::Suspended);
595 let history = active
596 .context
597 .store
598 .read_history(active.handle.workflow_id())
599 .await?;
600 match history.as_slice() {
601 [
602 Event::WorkflowStarted { .. },
603 Event::WorkflowFailed {
604 error: recorded, ..
605 },
606 ] => {
607 assert_eq!(recorded, &error);
608 }
609 other => return Err(format!("expected started then failed, found {other:?}").into()),
610 }
611 Ok(())
612 }
613}