1use std::collections::HashMap;
4use std::sync::Arc;
5
6use aion_core::{
7 Event, Payload, RunId, SearchAttributeSchema, SearchAttributeValue, TimerCancelCause,
8 WorkflowError, WorkflowFilter, WorkflowId, WorkflowSummary,
9};
10use tokio::sync::Mutex as AsyncMutex;
11use tokio::task::JoinHandle;
12
13use crate::durability::Recorder;
14use crate::schedule::ScheduleEvaluator;
15use aion_store::EventStore;
16use aion_store::visibility::VisibilityStore;
17
18use crate::lifecycle::continue_as_new::{self, ContinueAsNewContext, ContinueAsNewRequest};
19use crate::lifecycle::reopen::{self, ReopenWorkflowContext};
20use crate::lifecycle::start::{self, StartWorkflowContext};
21use crate::lifecycle::terminate::{self, TerminateWorkflowContext};
22use crate::lifecycle::transition;
23use crate::registry::{TerminalOutcome, WorkflowHandle};
24use crate::{
25 EngineError, Registry, RuntimeHandle, SupervisionTree, WorkflowCatalog,
26 signal::SignalResumeHandoff,
27};
28
29use super::api_schedule::{
30 ScheduleRuntimeDeps, default_schedule_evaluator, schedule_coordinator_workflow_id,
31};
32use super::delegated::DelegatedSeams;
33use super::shutdown_gate::ShutdownGate;
34use crate::time::timer_service::live_timers_in_active_segment;
35
36pub struct Engine {
38 store: Arc<dyn EventStore>,
39 visibility_store: Arc<dyn VisibilityStore>,
40 pub(super) schedule_recorder: Arc<AsyncMutex<Recorder>>,
41 pub(super) schedule_evaluator: Arc<AsyncMutex<ScheduleEvaluator>>,
42 pub(super) schedule_coordinator_workflow_id: WorkflowId,
43 runtime: Arc<RuntimeHandle>,
44 catalog: Arc<WorkflowCatalog>,
45 registry: Arc<Registry>,
46 supervision: Arc<SupervisionTree>,
47 delegated: DelegatedSeams,
48 signal_handoff: Arc<SignalResumeHandoff>,
49 search_attribute_schema: Arc<SearchAttributeSchema>,
50 pub(super) shutdown_gate: ShutdownGate,
51 pub(super) deploy_mutations: AsyncMutex<()>,
58 visibility_reconciliation_task: Option<JoinHandle<()>>,
59 paused_runs: crate::lifecycle::PausedRuns,
64}
65
66pub(crate) struct EngineComponents {
68 pub(crate) store: Arc<dyn EventStore>,
69 pub(crate) visibility_store: Arc<dyn VisibilityStore>,
70 pub(crate) runtime: Arc<RuntimeHandle>,
71 pub(crate) catalog: Arc<WorkflowCatalog>,
72 pub(crate) registry: Arc<Registry>,
73 pub(crate) supervision: Arc<SupervisionTree>,
74 pub(crate) delegated: DelegatedSeams,
75 pub(crate) signal_handoff: Arc<SignalResumeHandoff>,
76 pub(crate) search_attribute_schema: Arc<SearchAttributeSchema>,
77 pub(crate) visibility_reconciliation_task: Option<JoinHandle<()>>,
78}
79
80impl Engine {
81 #[must_use]
83 pub(crate) fn new(components: EngineComponents) -> Self {
84 let EngineComponents {
85 store,
86 visibility_store,
87 runtime,
88 catalog,
89 registry,
90 supervision,
91 delegated,
92 signal_handoff,
93 search_attribute_schema,
94 visibility_reconciliation_task,
95 } = components;
96 let schedule_coordinator_workflow_id = schedule_coordinator_workflow_id();
97 let schedule_recorder = Arc::new(AsyncMutex::new(Recorder::new(
98 schedule_coordinator_workflow_id.clone(),
99 Arc::clone(&store),
100 )));
101 let runtime_arc = runtime;
102 let registry_arc = registry;
103 let supervision_arc = supervision;
104 let schedule_evaluator = Arc::new(AsyncMutex::new(default_schedule_evaluator(
105 schedule_coordinator_workflow_id.clone(),
106 Arc::clone(&schedule_recorder),
107 ScheduleRuntimeDeps {
108 store: Arc::clone(&store),
109 visibility_store: Arc::clone(&visibility_store),
110 runtime: Arc::clone(&runtime_arc),
111 catalog: Arc::clone(&catalog),
112 registry: Arc::clone(®istry_arc),
113 supervision: Arc::clone(&supervision_arc),
114 search_attribute_schema: Arc::clone(&search_attribute_schema),
115 },
116 )));
117 Self {
118 store,
119 visibility_store,
120 schedule_recorder,
121 schedule_evaluator,
122 schedule_coordinator_workflow_id,
123 runtime: runtime_arc,
124 catalog,
125 registry: registry_arc,
126 supervision: supervision_arc,
127 delegated,
128 signal_handoff,
129 search_attribute_schema,
130 shutdown_gate: ShutdownGate::default(),
131 deploy_mutations: AsyncMutex::new(()),
132 visibility_reconciliation_task,
133 paused_runs: crate::lifecycle::PausedRuns::default(),
134 }
135 }
136
137 pub(crate) async fn catchup_schedule_coordinator(&self) -> Result<(), EngineError> {
145 let history = self
146 .store
147 .read_history(&self.schedule_coordinator_workflow_id)
148 .await?;
149 let head = u64::try_from(history.len()).unwrap_or(u64::MAX);
150 if head > 0 {
151 let mut recorder = self.schedule_recorder.lock().await;
152 *recorder = Recorder::resume_at(
153 self.schedule_coordinator_workflow_id.clone(),
154 Arc::clone(&self.store),
155 head,
156 );
157 }
158 Ok(())
159 }
160
161 #[must_use]
163 pub fn store(&self) -> Arc<dyn EventStore> {
164 Arc::clone(&self.store)
165 }
166
167 #[must_use]
169 pub fn visibility_store(&self) -> Arc<dyn VisibilityStore> {
170 Arc::clone(&self.visibility_store)
171 }
172
173 #[must_use]
175 pub fn runtime(&self) -> &RuntimeHandle {
176 &self.runtime
177 }
178
179 #[must_use]
181 pub fn workflow_catalog(&self) -> &Arc<WorkflowCatalog> {
182 &self.catalog
183 }
184
185 #[must_use]
187 pub fn registry(&self) -> &Registry {
188 &self.registry
189 }
190
191 #[must_use]
193 pub fn supervision(&self) -> &SupervisionTree {
194 &self.supervision
195 }
196
197 #[must_use]
199 pub const fn delegated(&self) -> &DelegatedSeams {
200 &self.delegated
201 }
202
203 #[must_use]
205 pub fn signal_handoff(&self) -> Arc<SignalResumeHandoff> {
206 Arc::clone(&self.signal_handoff)
207 }
208
209 pub async fn start_workflow(
223 &self,
224 workflow_type: &str,
225 input: Payload,
226 search_attributes: HashMap<String, SearchAttributeValue>,
227 namespace: String,
228 ) -> Result<WorkflowHandle, EngineError> {
229 self.start_workflow_with_id(
230 workflow_type,
231 input,
232 search_attributes,
233 namespace,
234 None,
235 None,
236 )
237 .await
238 }
239
240 pub async fn start_workflow_with_id(
262 &self,
263 workflow_type: &str,
264 input: Payload,
265 search_attributes: HashMap<String, SearchAttributeValue>,
266 namespace: String,
267 workflow_id: Option<WorkflowId>,
268 routing_key: Option<String>,
269 ) -> Result<WorkflowHandle, EngineError> {
270 let operation = self.shutdown_gate.begin_start()?;
271 let result = start::start_workflow_with_options(
272 StartWorkflowContext {
273 store: self.store(),
274 visibility_store: self.visibility_store(),
275 catalog: Arc::clone(&self.catalog),
276 runtime: Arc::clone(&self.runtime),
277 supervision: Arc::clone(&self.supervision),
278 registry: Arc::clone(&self.registry),
279 signal_handoff: Some(self.signal_handoff()),
280 search_attribute_schema: Arc::clone(&self.search_attribute_schema),
281 monitor_tokio_handle: tokio::runtime::Handle::current(),
282 },
283 workflow_type,
284 input,
285 start::StartWorkflowOptions {
286 namespace: Some(namespace),
287 search_attributes,
288 workflow_id,
289 routing_key,
290 input_admission: start::InputAdmission::Declared,
294 ..start::StartWorkflowOptions::default()
295 },
296 )
297 .await;
298 drop(operation);
299 result
300 }
301
302 pub async fn adopt_shards(&self, shards: &[usize]) -> Result<(), EngineError> {
344 let operation = self.shutdown_gate.begin_start()?;
345 let result = self.adopt_shards_inner(shards).await;
346 drop(operation);
347 result
348 }
349
350 async fn adopt_shards_inner(&self, shards: &[usize]) -> Result<(), EngineError> {
366 let _recoverable = super::fence::plan_adopted_shards(
378 &super::fence::StoreFenceSeam {
379 store: &*self.store,
380 },
381 shards,
382 )?;
383 match self.store.list_paused().await {
392 Ok(paused) => self.paused_runs.extend(paused),
393 Err(error) => {
394 tracing::warn!(%error, "failed to rebuild paused-runs dispatch hold at shard adoption");
395 }
396 }
397 super::startup::recover_adopted_shards(super::startup::StartupRecoveryContext {
402 store: Arc::clone(&self.store),
403 visibility_store: Arc::clone(&self.visibility_store),
404 runtime: Arc::clone(&self.runtime),
405 catalog: Arc::clone(&self.catalog),
406 registry: Arc::clone(&self.registry),
407 supervision: Arc::clone(&self.supervision),
408 recovery: None,
409 search_attribute_schema: Arc::clone(&self.search_attribute_schema),
410 bootstrap_schedule_coordinator: false,
411 })
412 .await?;
413 super::startup::recover_timers_on_startup(self.runtime.nif_state(), Arc::clone(&self.store))
428 .await
429 }
430
431 pub fn resume_workflow(
439 &self,
440 id: &WorkflowId,
441 run: &RunId,
442 ) -> Result<WorkflowHandle, EngineError> {
443 let handle = transition::resume(self.registry(), id, run)?;
444 if let Err(error) = self.signal_handoff.deliver_deferred(self, id) {
445 tracing::warn!(
446 workflow_id = %id,
447 run_id = %run,
448 error = %error,
449 "failed to flush deferred signals after workflow resume"
450 );
451 }
452 Ok(handle)
453 }
454
455 pub async fn cancel(
463 &self,
464 id: &WorkflowId,
465 run: &RunId,
466 reason: impl Into<String>,
467 ) -> Result<(), EngineError> {
468 let operation = self.shutdown_gate.begin_operation()?;
469 self.cancel_inflight_timers(id).await;
474 let result = terminate::cancel(
475 TerminateWorkflowContext {
476 runtime: &self.runtime,
477 store: self.store(),
478 visibility_store: self.visibility_store(),
479 registry: &self.registry,
480 catalog: &self.catalog,
481 },
482 id,
483 run,
484 reason,
485 )
486 .await;
487 if result.is_ok() {
491 self.paused_runs.remove(id);
492 }
493 drop(operation);
494 result
495 }
496
497 async fn cancel_inflight_timers(&self, id: &WorkflowId) {
523 let timer_service = match crate::runtime::nif_timer_bridge::installed_timer_service(
524 self.runtime.nif_state(),
525 ) {
526 Ok(service) => service,
527 Err(error) => {
528 tracing::warn!(
529 %error,
530 workflow_id = %id,
531 "timer service unavailable during cancel; any in-flight timers will be skipped by recovery"
532 );
533 return;
534 }
535 };
536 let history = match self.store.read_history(id).await {
537 Ok(history) => history,
538 Err(error) => {
539 tracing::warn!(
540 %error,
541 workflow_id = %id,
542 "could not read history for timer cleanup during cancel; any in-flight timers will be skipped by recovery"
543 );
544 return;
545 }
546 };
547 for timer_id in live_timers_in_active_segment(&history) {
548 let cause = if crate::time::is_deadline_timer(&timer_id) {
554 TimerCancelCause::WorkflowIntent
555 } else {
556 TimerCancelCause::CancelTeardown
557 };
558 if let Err(error) = timer_service
559 .cancel(id.clone(), timer_id.clone(), cause)
560 .await
561 {
562 tracing::warn!(
563 %error,
564 workflow_id = %id,
565 %timer_id,
566 "failed to cancel in-flight timer during workflow cancel; recovery will skip it if orphaned"
567 );
568 }
569 }
570 }
571
572 pub async fn continue_as_new(
580 &self,
581 id: &WorkflowId,
582 run: &RunId,
583 input: Payload,
584 workflow_type: Option<String>,
585 ) -> Result<WorkflowHandle, EngineError> {
586 let operation = self.shutdown_gate.begin_operation()?;
587 let result = continue_as_new::continue_as_new(
588 ContinueAsNewContext {
589 store: self.store(),
590 visibility_store: Arc::clone(&self.visibility_store),
591 catalog: Arc::clone(&self.catalog),
592 runtime: &self.runtime,
593 supervision: Arc::clone(&self.supervision),
594 registry: &self.registry,
595 search_attribute_schema: Arc::clone(&self.search_attribute_schema),
596 },
597 id,
598 run,
599 ContinueAsNewRequest {
600 input,
601 workflow_type,
602 },
603 )
604 .await;
605 drop(operation);
606 result
607 }
608
609 pub async fn reopen_workflow(
625 &self,
626 id: &WorkflowId,
627 run: &RunId,
628 ) -> Result<WorkflowHandle, EngineError> {
629 let operation = self.shutdown_gate.begin_operation()?;
630 let result = reopen::reopen(
631 ReopenWorkflowContext {
632 store: self.store(),
633 visibility_store: Arc::clone(&self.visibility_store),
634 catalog: Arc::clone(&self.catalog),
635 runtime: &self.runtime,
636 supervision: Arc::clone(&self.supervision),
637 registry: &self.registry,
638 search_attribute_schema: Arc::clone(&self.search_attribute_schema),
639 },
640 id,
641 run,
642 )
643 .await;
644 drop(operation);
645 result
646 }
647
648 #[must_use]
654 pub fn paused_runs(&self) -> crate::lifecycle::PausedRuns {
655 self.paused_runs.clone()
656 }
657
658 pub async fn rebuild_paused_runs(&self) -> Result<(), EngineError> {
667 let paused = self.store.list_paused().await?;
668 self.paused_runs.replace_all(paused);
669 Ok(())
670 }
671
672 fn pause_context(&self) -> crate::lifecycle::PauseWorkflowContext<'_> {
673 crate::lifecycle::PauseWorkflowContext {
674 store: self.store(),
675 visibility_store: Arc::clone(&self.visibility_store),
676 catalog: Arc::clone(&self.catalog),
677 runtime: &self.runtime,
678 supervision: Arc::clone(&self.supervision),
679 registry: &self.registry,
680 search_attribute_schema: Arc::clone(&self.search_attribute_schema),
681 paused_runs: self.paused_runs.clone(),
682 }
683 }
684
685 pub async fn pause_workflow(
698 &self,
699 id: &WorkflowId,
700 run: &RunId,
701 reason: Option<String>,
702 operator: Option<String>,
703 ) -> Result<WorkflowHandle, EngineError> {
704 let operation = self.shutdown_gate.begin_operation()?;
705 let result =
706 crate::lifecycle::pause::pause(&self.pause_context(), id, run, reason, operator).await;
707 drop(operation);
708 result
709 }
710
711 pub async fn resume_paused_workflow(
727 &self,
728 id: &WorkflowId,
729 run: &RunId,
730 operator: Option<String>,
731 ) -> Result<WorkflowHandle, EngineError> {
732 let operation = self.shutdown_gate.begin_operation()?;
733 let result =
734 crate::lifecycle::pause::resume(&self.pause_context(), id, run, operator).await;
735 drop(operation);
736 result
737 }
738
739 pub async fn result(
750 &self,
751 id: &WorkflowId,
752 run: &RunId,
753 ) -> Result<Result<Payload, WorkflowError>, EngineError> {
754 let history = self.store.read_history(id).await?;
755 if let Some(outcome) = terminal_outcome_from_history(&history) {
756 return Ok(outcome_to_result(outcome));
757 }
758
759 let handle = match self.registry.get(id, run)? {
760 Some(handle) => handle,
761 None => self
765 .handle_after_birth_window(id, run, &history)
766 .await?
767 .ok_or_else(|| workflow_not_found(id, run))?,
768 };
769 let mut receiver = handle.completion().subscribe();
770 loop {
771 if let Some(outcome) = receiver.borrow().clone() {
772 return Ok(outcome_to_result(outcome));
773 }
774 if receiver.changed().await.is_err() {
775 if let Some(outcome) =
776 terminal_outcome_from_history(&self.store.read_history(id).await?)
777 {
778 return Ok(outcome_to_result(outcome));
779 }
780 return Err(EngineError::Runtime {
781 reason: format!(
782 "completion channel closed before workflow `{id}/{run}` finished"
783 ),
784 });
785 }
786 }
787 }
788
789 pub async fn list_workflows(
798 &self,
799 filter: WorkflowFilter,
800 ) -> Result<Vec<WorkflowSummary>, EngineError> {
801 let mut summaries = self
802 .store
803 .query(&filter)
804 .await?
805 .into_iter()
806 .map(|summary| (summary.workflow_id.clone(), summary))
807 .collect::<HashMap<_, _>>();
808
809 for handle in self.registry.list()? {
810 let history = self.store.read_history(handle.workflow_id()).await?;
811 self.registry
812 .reconcile(handle.workflow_id(), handle.run_id(), &history)?;
813 if let Some(summary) = WorkflowSummary::from_history(&history) {
814 if filter.matches(&summary) {
815 summaries.insert(summary.workflow_id.clone(), summary);
816 }
817 }
818 }
819
820 let mut summaries = summaries.into_values().collect::<Vec<_>>();
821 summaries.sort_by(|left, right| {
822 left.started_at.cmp(&right.started_at).then_with(|| {
823 left.workflow_id
824 .to_string()
825 .cmp(&right.workflow_id.to_string())
826 })
827 });
828 Ok(summaries)
829 }
830
831 pub fn shutdown(&self) -> Result<(), EngineError> {
837 if let Some(task) = &self.visibility_reconciliation_task {
838 task.abort();
839 }
840 self.shutdown_gate.close_and_wait()?;
841 self.runtime.shutdown()?;
849 self.runtime.nif_state().shutdown_child_tasks();
850 self.runtime.nif_state().shutdown_timer_wheel();
858 self.runtime.nif_state().clear_engine_seams();
867 Ok(())
868 }
869}
870
871pub(crate) fn terminal_outcome_from_history(events: &[Event]) -> Option<TerminalOutcome> {
872 match aion_core::current_lease_terminal(events)? {
876 Event::WorkflowCompleted { result, .. } => Some(TerminalOutcome::Completed(result.clone())),
877 Event::WorkflowFailed { error, .. } => Some(TerminalOutcome::Failed(error.clone())),
878 Event::WorkflowCancelled { reason, .. } => Some(TerminalOutcome::Cancelled(reason.clone())),
879 Event::WorkflowTimedOut { timeout, .. } => Some(TerminalOutcome::TimedOut(timeout.clone())),
880 Event::WorkflowContinuedAsNew {
881 input,
882 workflow_type,
883 parent_run_id,
884 ..
885 } => Some(TerminalOutcome::ContinuedAsNew {
886 input: input.clone(),
887 workflow_type: workflow_type.clone(),
888 parent_run_id: parent_run_id.clone(),
889 }),
890 _ => None,
891 }
892}
893
894fn outcome_to_result(outcome: TerminalOutcome) -> Result<Payload, WorkflowError> {
895 match outcome {
896 TerminalOutcome::Completed(payload) => Ok(payload),
897 TerminalOutcome::Failed(error) => Err(error),
898 TerminalOutcome::Cancelled(reason) => Err(WorkflowError {
899 message: format!("workflow cancelled: {reason}"),
900 details: None,
901 }),
902 TerminalOutcome::TimedOut(timeout) => Err(WorkflowError {
903 message: format!("workflow timed out: {timeout}"),
904 details: None,
905 }),
906 TerminalOutcome::ContinuedAsNew { parent_run_id, .. } => Err(WorkflowError {
907 message: format!("workflow continued as new from run {parent_run_id}"),
908 details: None,
909 }),
910 }
911}
912
913pub(crate) fn workflow_not_found(id: &WorkflowId, run: &RunId) -> EngineError {
914 EngineError::WorkflowNotFound {
915 workflow_type: format!("{id}/{run}"),
916 }
917}
918
919#[cfg(test)]
920mod tests {
921 use std::collections::HashMap;
922 use std::sync::Arc;
923 use std::time::Duration;
924
925 use aion_core::{
926 Event, EventEnvelope, PackageVersion, Payload, RunId, SearchAttributeSchema,
927 TimerCancelCause, TimerId, WorkflowFilter, WorkflowId, WorkflowStatus,
928 };
929 use aion_package::ContentHash;
930 use aion_store::visibility::VisibilityStore;
931 use aion_store::{EventStore, InMemoryStore, ReadableEventStore};
932 use serde_json::json;
933
934 use super::{DelegatedSeams, Engine, EngineComponents, live_timers_in_active_segment};
935 use crate::durability::Recorder;
936 use crate::lifecycle::terminate::{self, TerminateWorkflowContext};
937 use crate::registry::{CompletionNotifier, HandleResidency, WorkflowHandleParts};
938 use crate::time::TimerRecovery;
939 use crate::{
940 EngineError, Registry, RuntimeConfig, RuntimeHandle, SupervisionTree, WorkflowCatalog,
941 WorkflowHandle,
942 };
943
944 fn payload(label: &str) -> Result<Payload, aion_core::PayloadError> {
945 Payload::from_json(&json!({ "label": label }))
946 }
947
948 fn workflow_error(message: &str) -> aion_core::WorkflowError {
949 aion_core::WorkflowError {
950 message: message.to_owned(),
951 details: None,
952 }
953 }
954
955 fn workflow_catalog(workflow_type: &str, deployed_module: &str) -> Arc<WorkflowCatalog> {
956 let catalog = Arc::new(WorkflowCatalog::new());
957 catalog.note_loaded_workflow_for_test(
958 workflow_type,
959 deployed_module,
960 "run",
961 ContentHash::from_bytes([5; 32]),
962 );
963 catalog
964 }
965
966 fn engine_with_loaded_workflow(
967 store: Arc<dyn EventStore>,
968 workflow_type: &str,
969 deployed_module: &str,
970 ) -> Result<Engine, EngineError> {
971 let runtime = RuntimeHandle::new(RuntimeConfig::new(Some(1)))?;
972 runtime.register_waiting_test_module(deployed_module, "run");
973 let visibility_store: Arc<dyn VisibilityStore> = Arc::new(InMemoryStore::default());
974 Ok(Engine::new(EngineComponents {
975 store,
976 visibility_store,
977 runtime: Arc::new(runtime),
978 catalog: workflow_catalog(workflow_type, deployed_module),
979 registry: Arc::new(Registry::default()),
980 supervision: Arc::new(SupervisionTree::new()),
981 delegated: DelegatedSeams::default(),
982 signal_handoff: Arc::new(crate::signal::SignalResumeHandoff::new()),
983 search_attribute_schema: Arc::new(SearchAttributeSchema::new()),
984 visibility_reconciliation_task: None,
985 }))
986 }
987
988 fn termination_context(engine: &Engine) -> TerminateWorkflowContext<'_> {
989 TerminateWorkflowContext {
990 runtime: engine.runtime(),
991 store: engine.store(),
992 visibility_store: engine.visibility_store(),
993 registry: engine.registry(),
994 catalog: engine.workflow_catalog(),
995 }
996 }
997
998 async fn insert_active_handle(
999 engine: &Engine,
1000 store: Arc<dyn EventStore>,
1001 workflow_type: &str,
1002 ) -> Result<WorkflowHandle, Box<dyn std::error::Error>> {
1003 let workflow_id = aion_core::WorkflowId::new_v4();
1004 let run_id = aion_core::RunId::new_v4();
1005 let mut recorder = Recorder::new(workflow_id.clone(), store);
1006 recorder
1007 .record_workflow_started(
1008 chrono::Utc::now(),
1009 crate::durability::WorkflowStartRecord {
1010 workflow_type: workflow_type.to_owned(),
1011 input: payload("input")?,
1012 run_id: run_id.clone(),
1013 parent_run_id: None,
1014 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1015 },
1016 )
1017 .await?;
1018 let pid = engine.runtime().spawn_test_process_with_trap_exit(true)?;
1019 let handle = WorkflowHandle::new(WorkflowHandleParts {
1020 workflow_id: workflow_id.clone(),
1021 run_id: run_id.clone(),
1022 pid,
1023 workflow_type: workflow_type.to_owned(),
1024 namespace: String::from("default"),
1025 loaded_version: ContentHash::from_bytes([9; 32]),
1026 cached_status: WorkflowStatus::Running,
1027 residency: HandleResidency::Resident,
1028 recorder,
1029 completion: CompletionNotifier::new(),
1030 });
1031 engine
1032 .registry()
1033 .insert((workflow_id, run_id), handle.clone())?;
1034 Ok(handle)
1035 }
1036
1037 #[tokio::test]
1038 async fn start_then_cancel_records_started_then_cancelled()
1039 -> Result<(), Box<dyn std::error::Error>> {
1040 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1041 let engine =
1042 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1043 let handle = engine
1044 .start_workflow(
1045 "checkout",
1046 payload("input")?,
1047 HashMap::new(),
1048 String::from("default"),
1049 )
1050 .await?;
1051
1052 engine
1053 .cancel(
1054 handle.workflow_id(),
1055 handle.run_id(),
1056 "caller requested cancellation",
1057 )
1058 .await?;
1059
1060 let history = store.read_history(handle.workflow_id()).await?;
1061 match history.as_slice() {
1062 [
1063 Event::WorkflowStarted { .. },
1064 Event::WorkflowCancelled { reason, .. },
1065 ] => {
1066 assert_eq!(reason, "caller requested cancellation");
1067 }
1068 other => return Err(format!("expected started then cancelled, found {other:?}").into()),
1069 }
1070 engine.shutdown()?;
1071 Ok(())
1072 }
1073
1074 fn test_envelope(workflow_id: &WorkflowId, seq: u64) -> EventEnvelope {
1075 EventEnvelope {
1076 seq,
1077 recorded_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap_or_default(),
1078 workflow_id: workflow_id.clone(),
1079 }
1080 }
1081
1082 fn started_event(workflow_id: &WorkflowId, seq: u64) -> Event {
1083 Event::WorkflowStarted {
1084 envelope: test_envelope(workflow_id, seq),
1085 workflow_type: String::from("checkout"),
1086 input: Payload::new(aion_core::ContentType::Json, b"{}".to_vec()),
1087 run_id: RunId::new_v4(),
1088 parent_run_id: None,
1089 package_version: PackageVersion::new("a".repeat(64)),
1090 }
1091 }
1092
1093 fn timer_started_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
1094 Event::TimerStarted {
1095 envelope: test_envelope(workflow_id, seq),
1096 timer_id: timer_id.clone(),
1097 fire_at: chrono::DateTime::from_timestamp(1_700_000_500, 0).unwrap_or_default(),
1098 }
1099 }
1100
1101 fn timer_fired_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
1102 Event::TimerFired {
1103 envelope: test_envelope(workflow_id, seq),
1104 timer_id: timer_id.clone(),
1105 }
1106 }
1107
1108 fn timer_cancelled_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
1109 Event::TimerCancelled {
1110 envelope: test_envelope(workflow_id, seq),
1111 timer_id: timer_id.clone(),
1112 cause: TimerCancelCause::WorkflowIntent,
1113 }
1114 }
1115
1116 #[test]
1117 fn live_timers_lists_started_and_unterminated() {
1118 let workflow_id = WorkflowId::new_v4();
1119 let first = TimerId::anonymous(0);
1120 let second = TimerId::anonymous(1);
1121 let history = vec![
1122 started_event(&workflow_id, 0),
1123 timer_started_event(&workflow_id, 1, &first),
1124 timer_started_event(&workflow_id, 2, &second),
1125 ];
1126 assert_eq!(
1127 live_timers_in_active_segment(&history),
1128 vec![first, second],
1129 "both started, unterminated timers should be live, in start order"
1130 );
1131 }
1132
1133 #[test]
1134 fn live_timers_excludes_fired_and_cancelled() {
1135 let workflow_id = WorkflowId::new_v4();
1136 let fired = TimerId::anonymous(0);
1137 let cancelled = TimerId::anonymous(1);
1138 let live = TimerId::anonymous(2);
1139 let history = vec![
1140 started_event(&workflow_id, 0),
1141 timer_started_event(&workflow_id, 1, &fired),
1142 timer_started_event(&workflow_id, 2, &cancelled),
1143 timer_started_event(&workflow_id, 3, &live),
1144 timer_fired_event(&workflow_id, 4, &fired),
1145 timer_cancelled_event(&workflow_id, 5, &cancelled),
1146 ];
1147 assert_eq!(
1148 live_timers_in_active_segment(&history),
1149 vec![live],
1150 "only the timer with no terminal event remains live"
1151 );
1152 }
1153
1154 #[test]
1155 fn live_timers_dedups_repeated_start() {
1156 let workflow_id = WorkflowId::new_v4();
1157 let timer = TimerId::anonymous(0);
1158 let history = vec![
1159 started_event(&workflow_id, 0),
1160 timer_started_event(&workflow_id, 1, &timer),
1161 timer_started_event(&workflow_id, 2, &timer),
1162 ];
1163 assert_eq!(live_timers_in_active_segment(&history), vec![timer]);
1164 }
1165
1166 #[test]
1167 fn live_timers_scopes_to_active_run_segment() {
1168 let workflow_id = WorkflowId::new_v4();
1171 let prior_run = TimerId::anonymous(0);
1172 let current_run = TimerId::anonymous(0);
1173 let history = vec![
1174 started_event(&workflow_id, 0),
1175 timer_started_event(&workflow_id, 1, &prior_run),
1176 started_event(&workflow_id, 2),
1177 timer_started_event(&workflow_id, 3, ¤t_run),
1178 ];
1179 assert_eq!(
1180 live_timers_in_active_segment(&history),
1181 vec![current_run],
1182 "only timers from the latest WorkflowStarted segment are live"
1183 );
1184 }
1185
1186 #[test]
1187 fn live_timers_empty_history_is_empty() {
1188 assert!(live_timers_in_active_segment(&[]).is_empty());
1189 }
1190
1191 fn engine_with_timer_bridge(
1196 store: Arc<dyn EventStore>,
1197 registry: Arc<Registry>,
1198 ) -> Result<Engine, EngineError> {
1199 let runtime = RuntimeHandle::new(RuntimeConfig::new(Some(1)))?;
1200 runtime.register_waiting_test_module("checkout_deployed", "run");
1201 crate::runtime::nif_timer_bridge::install_timer_nif_bridge(
1202 runtime.nif_state(),
1203 Arc::clone(®istry),
1204 Arc::clone(&store),
1205 tokio::runtime::Handle::current(),
1206 crate::runtime::SignalDeliveryConfig::default(),
1207 );
1208 let visibility_store: Arc<dyn VisibilityStore> = Arc::new(InMemoryStore::default());
1209 Ok(Engine::new(EngineComponents {
1210 store,
1211 visibility_store,
1212 runtime: Arc::new(runtime),
1213 catalog: workflow_catalog("checkout", "checkout_deployed"),
1214 registry,
1215 supervision: Arc::new(SupervisionTree::new()),
1216 delegated: DelegatedSeams::default(),
1217 signal_handoff: Arc::new(crate::signal::SignalResumeHandoff::new()),
1218 search_attribute_schema: Arc::new(SearchAttributeSchema::new()),
1219 visibility_reconciliation_task: None,
1220 }))
1221 }
1222
1223 #[tokio::test(flavor = "multi_thread")]
1229 async fn cancel_records_timer_cancelled_before_workflow_cancelled()
1230 -> Result<(), Box<dyn std::error::Error>> {
1231 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1232 let registry = Arc::new(Registry::default());
1233 let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(®istry))?;
1234
1235 let handle = engine
1236 .start_workflow(
1237 "checkout",
1238 payload("input")?,
1239 HashMap::new(),
1240 String::from("default"),
1241 )
1242 .await?;
1243
1244 let timer_id = TimerId::anonymous(0);
1247 let fire_at = chrono::Utc::now() + chrono::Duration::hours(1);
1248 handle
1249 .recorder()
1250 .lock()
1251 .await
1252 .record_timer_started(chrono::Utc::now(), timer_id.clone(), fire_at)
1253 .await?;
1254 let timer_service =
1255 crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1256 .map_err(|error| format!("timer service unavailable: {error}"))?;
1257 timer_service
1258 .schedule(handle.workflow_id().clone(), timer_id.clone(), fire_at)
1259 .await?;
1260
1261 engine
1262 .cancel(
1263 handle.workflow_id(),
1264 handle.run_id(),
1265 "caller requested cancellation",
1266 )
1267 .await?;
1268
1269 let history = store.read_history(handle.workflow_id()).await?;
1270 match history.as_slice() {
1271 [
1272 Event::WorkflowStarted { .. },
1273 Event::TimerStarted {
1274 timer_id: started, ..
1275 },
1276 Event::TimerCancelled {
1277 timer_id: cancelled,
1278 ..
1279 },
1280 Event::WorkflowCancelled { reason, .. },
1281 ] => {
1282 assert_eq!(started, &timer_id);
1283 assert_eq!(cancelled, &timer_id, "the live timer must be cancelled");
1284 assert_eq!(reason, "caller requested cancellation");
1285 }
1286 other => {
1287 return Err(format!(
1288 "expected [started, timer-started, timer-cancelled, cancelled], found {other:?}"
1289 )
1290 .into());
1291 }
1292 }
1293 engine.shutdown()?;
1294 Ok(())
1295 }
1296
1297 #[tokio::test(flavor = "multi_thread")]
1300 async fn cancel_cancels_multiple_live_timers() -> Result<(), Box<dyn std::error::Error>> {
1301 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1302 let registry = Arc::new(Registry::default());
1303 let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(®istry))?;
1304 let handle = engine
1305 .start_workflow(
1306 "checkout",
1307 payload("input")?,
1308 HashMap::new(),
1309 String::from("default"),
1310 )
1311 .await?;
1312
1313 let first = TimerId::anonymous(0);
1314 let second = TimerId::anonymous(1);
1315 let fire_at = chrono::Utc::now() + chrono::Duration::hours(1);
1316 {
1317 let recorder = handle.recorder();
1318 let mut recorder = recorder.lock().await;
1319 recorder
1320 .record_timer_started(chrono::Utc::now(), first.clone(), fire_at)
1321 .await?;
1322 recorder
1323 .record_timer_started(chrono::Utc::now(), second.clone(), fire_at)
1324 .await?;
1325 }
1326
1327 engine
1328 .cancel(handle.workflow_id(), handle.run_id(), "stop")
1329 .await?;
1330
1331 let history = store.read_history(handle.workflow_id()).await?;
1332 match history.as_slice() {
1333 [
1334 Event::WorkflowStarted { .. },
1335 Event::TimerStarted {
1336 timer_id: started_first,
1337 ..
1338 },
1339 Event::TimerStarted {
1340 timer_id: started_second,
1341 ..
1342 },
1343 Event::TimerCancelled {
1344 timer_id: cancelled_first,
1345 ..
1346 },
1347 Event::TimerCancelled {
1348 timer_id: cancelled_second,
1349 ..
1350 },
1351 Event::WorkflowCancelled { .. },
1352 ] => {
1353 assert_eq!(started_first, &first);
1354 assert_eq!(started_second, &second);
1355 assert_eq!(cancelled_first, &first, "first live timer cancelled first");
1356 assert_eq!(
1357 cancelled_second, &second,
1358 "second live timer cancelled second"
1359 );
1360 }
1361 other => {
1362 return Err(format!(
1363 "expected two timer-cancels before workflow-cancel, found {other:?}"
1364 )
1365 .into());
1366 }
1367 }
1368 engine.shutdown()?;
1369 Ok(())
1370 }
1371
1372 #[tokio::test(flavor = "multi_thread")]
1379 async fn cancelled_workflow_leaves_no_orphan_for_recovery()
1380 -> Result<(), Box<dyn std::error::Error>> {
1381 let concrete: Arc<InMemoryStore> = Arc::new(InMemoryStore::default());
1382 let store: Arc<dyn EventStore> = concrete.clone();
1383 let registry = Arc::new(Registry::default());
1384 let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(®istry))?;
1385 let handle = engine
1386 .start_workflow(
1387 "checkout",
1388 payload("input")?,
1389 HashMap::new(),
1390 String::from("default"),
1391 )
1392 .await?;
1393 let workflow_id = handle.workflow_id().clone();
1394
1395 let timer_id = TimerId::anonymous(0);
1398 let fire_at = chrono::Utc::now() - chrono::Duration::hours(1);
1399 handle
1400 .recorder()
1401 .lock()
1402 .await
1403 .record_timer_started(chrono::Utc::now(), timer_id.clone(), fire_at)
1404 .await?;
1405 concrete
1406 .schedule_timer(&workflow_id, &timer_id, fire_at)
1407 .await?;
1408
1409 let timer_service =
1410 crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1411 .map_err(|error| format!("timer service unavailable: {error}"))?;
1412
1413 engine.cancel(&workflow_id, handle.run_id(), "stop").await?;
1414
1415 let readable: Arc<dyn ReadableEventStore> = concrete.clone();
1420 TimerRecovery::new(readable, timer_service, Duration::ZERO)
1421 .recover_on_startup(chrono::Utc::now())
1422 .await?;
1423
1424 let history = concrete.read_history(&workflow_id).await?;
1425 assert!(
1426 !history
1427 .iter()
1428 .any(|event| matches!(event, Event::TimerFired { .. })),
1429 "no timer should fire for a cancelled workflow during recovery"
1430 );
1431 assert!(
1432 history
1433 .iter()
1434 .any(|event| matches!(event, Event::TimerCancelled { .. })),
1435 "cancel must have recorded TimerCancelled at the source"
1436 );
1437 engine.shutdown()?;
1438 Ok(())
1439 }
1440
1441 #[tokio::test]
1442 async fn result_returns_completed_payload() -> Result<(), Box<dyn std::error::Error>> {
1443 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1444 let engine =
1445 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1446 let handle = engine
1447 .start_workflow(
1448 "checkout",
1449 payload("input")?,
1450 HashMap::new(),
1451 String::from("default"),
1452 )
1453 .await?;
1454 let result_payload = payload("result")?;
1455
1456 terminate::complete(
1457 termination_context(&engine),
1458 handle.workflow_id(),
1459 handle.run_id(),
1460 result_payload.clone(),
1461 )
1462 .await?;
1463
1464 assert_eq!(
1465 engine.result(handle.workflow_id(), handle.run_id()).await?,
1466 Ok(result_payload)
1467 );
1468 engine.shutdown()?;
1469 Ok(())
1470 }
1471
1472 #[tokio::test]
1473 async fn result_returns_failed_workflow_error() -> Result<(), Box<dyn std::error::Error>> {
1474 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1475 let engine =
1476 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1477 let handle = engine
1478 .start_workflow(
1479 "checkout",
1480 payload("input")?,
1481 HashMap::new(),
1482 String::from("default"),
1483 )
1484 .await?;
1485 let error = workflow_error("workflow failed");
1486
1487 terminate::fail(
1488 termination_context(&engine),
1489 handle.workflow_id(),
1490 handle.run_id(),
1491 error.clone(),
1492 )
1493 .await?;
1494
1495 assert_eq!(
1496 engine.result(handle.workflow_id(), handle.run_id()).await?,
1497 Err(error)
1498 );
1499 engine.shutdown()?;
1500 Ok(())
1501 }
1502
1503 #[tokio::test]
1504 async fn result_unknown_workflow_returns_not_found() -> Result<(), Box<dyn std::error::Error>> {
1505 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1506 let engine = engine_with_loaded_workflow(store, "checkout", "checkout_deployed")?;
1507 let workflow_id = aion_core::WorkflowId::new_v4();
1508 let run_id = aion_core::RunId::new_v4();
1509
1510 let result = engine.result(&workflow_id, &run_id).await;
1511
1512 assert!(matches!(result, Err(EngineError::WorkflowNotFound { .. })));
1513 engine.shutdown()?;
1514 Ok(())
1515 }
1516
1517 #[tokio::test]
1518 async fn continue_as_new_unknown_workflow_returns_not_found()
1519 -> Result<(), Box<dyn std::error::Error>> {
1520 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1521 let engine = engine_with_loaded_workflow(store, "checkout", "checkout_deployed")?;
1522 let workflow_id = aion_core::WorkflowId::new_v4();
1523 let run_id = aion_core::RunId::new_v4();
1524
1525 let result = engine
1526 .continue_as_new(&workflow_id, &run_id, payload("next")?, None)
1527 .await;
1528
1529 assert!(matches!(result, Err(EngineError::WorkflowNotFound { .. })));
1530 engine.shutdown()?;
1531 Ok(())
1532 }
1533
1534 #[tokio::test]
1535 async fn list_workflows_merges_live_and_terminal_without_duplicates()
1536 -> Result<(), Box<dyn std::error::Error>> {
1537 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1538 let engine =
1539 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1540 let running = insert_active_handle(&engine, Arc::clone(&store), "checkout").await?;
1541 let completed = engine
1542 .start_workflow(
1543 "checkout",
1544 payload("input")?,
1545 HashMap::new(),
1546 String::from("default"),
1547 )
1548 .await?;
1549 terminate::complete(
1550 termination_context(&engine),
1551 completed.workflow_id(),
1552 completed.run_id(),
1553 payload("result")?,
1554 )
1555 .await?;
1556
1557 let summaries = engine.list_workflows(WorkflowFilter::default()).await?;
1558 assert_eq!(summaries.len(), 2);
1559 assert!(summaries.iter().any(|summary| {
1560 &summary.workflow_id == running.workflow_id()
1561 && summary.status == WorkflowStatus::Running
1562 }));
1563 assert!(summaries.iter().any(|summary| {
1564 &summary.workflow_id == completed.workflow_id()
1565 && summary.status == WorkflowStatus::Completed
1566 }));
1567
1568 let completed_only = engine
1569 .list_workflows(WorkflowFilter {
1570 status: Some(WorkflowStatus::Completed),
1571 ..WorkflowFilter::default()
1572 })
1573 .await?;
1574 assert_eq!(completed_only.len(), 1);
1575 assert_eq!(&completed_only[0].workflow_id, completed.workflow_id());
1576 engine.shutdown()?;
1577 Ok(())
1578 }
1579
1580 #[tokio::test]
1581 async fn shutdown_rejects_subsequent_starts() -> Result<(), Box<dyn std::error::Error>> {
1582 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1583 let engine =
1584 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1585 let handle = engine
1586 .start_workflow(
1587 "checkout",
1588 payload("input")?,
1589 HashMap::new(),
1590 String::from("default"),
1591 )
1592 .await?;
1593 terminate::complete(
1594 termination_context(&engine),
1595 handle.workflow_id(),
1596 handle.run_id(),
1597 payload("result")?,
1598 )
1599 .await?;
1600
1601 engine.shutdown()?;
1602 let result = engine
1603 .start_workflow(
1604 "checkout",
1605 payload("after-shutdown")?,
1606 HashMap::new(),
1607 String::from("default"),
1608 )
1609 .await;
1610
1611 assert!(matches!(result, Err(EngineError::ShuttingDown)));
1612 Ok(())
1613 }
1614
1615 #[tokio::test]
1616 async fn shutdown_is_idempotent() -> Result<(), Box<dyn std::error::Error>> {
1617 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1618 let engine =
1619 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1620 let handle = engine
1621 .start_workflow(
1622 "checkout",
1623 payload("input")?,
1624 HashMap::new(),
1625 String::from("default"),
1626 )
1627 .await?;
1628 terminate::complete(
1629 termination_context(&engine),
1630 handle.workflow_id(),
1631 handle.run_id(),
1632 payload("result")?,
1633 )
1634 .await?;
1635
1636 engine.shutdown()?;
1637 let second = engine.shutdown();
1638
1639 assert!(
1640 second.is_ok(),
1641 "double shutdown should succeed; got {second:?}"
1642 );
1643 Ok(())
1644 }
1645
1646 #[tokio::test]
1647 async fn shutdown_rejects_schedule_creation() -> Result<(), Box<dyn std::error::Error>> {
1648 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1649 let engine =
1650 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1651 let handle = engine
1652 .start_workflow(
1653 "checkout",
1654 payload("input")?,
1655 HashMap::new(),
1656 String::from("default"),
1657 )
1658 .await?;
1659 terminate::complete(
1660 termination_context(&engine),
1661 handle.workflow_id(),
1662 handle.run_id(),
1663 payload("result")?,
1664 )
1665 .await?;
1666 engine.shutdown()?;
1667
1668 let config = aion_core::ScheduleConfig {
1669 trigger: aion_core::TriggerSpec::Interval {
1670 period: Duration::from_secs(60),
1671 },
1672 overlap_policy: aion_core::OverlapPolicy::Skip,
1673 catch_up_policy: aion_core::CatchUpPolicy::Skip,
1674 workflow_type: String::from("checkout"),
1675 input: payload("scheduled")?,
1676 search_attributes: HashMap::new(),
1677 };
1678 let result = engine.create_schedule(config).await;
1679
1680 assert!(
1681 matches!(result, Err(EngineError::ShuttingDown)),
1682 "create_schedule after shutdown should return ShuttingDown; got {result:?}"
1683 );
1684 Ok(())
1685 }
1686}