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 ..start::StartWorkflowOptions::default()
291 },
292 )
293 .await;
294 drop(operation);
295 result
296 }
297
298 pub async fn adopt_shards(&self, shards: &[usize]) -> Result<(), EngineError> {
340 let operation = self.shutdown_gate.begin_start()?;
341 let result = self.adopt_shards_inner(shards).await;
342 drop(operation);
343 result
344 }
345
346 async fn adopt_shards_inner(&self, shards: &[usize]) -> Result<(), EngineError> {
362 let _recoverable = super::fence::plan_adopted_shards(
374 &super::fence::StoreFenceSeam {
375 store: &*self.store,
376 },
377 shards,
378 )?;
379 match self.store.list_paused().await {
388 Ok(paused) => self.paused_runs.extend(paused),
389 Err(error) => {
390 tracing::warn!(%error, "failed to rebuild paused-runs dispatch hold at shard adoption");
391 }
392 }
393 super::startup::recover_adopted_shards(super::startup::StartupRecoveryContext {
398 store: Arc::clone(&self.store),
399 visibility_store: Arc::clone(&self.visibility_store),
400 runtime: Arc::clone(&self.runtime),
401 catalog: Arc::clone(&self.catalog),
402 registry: Arc::clone(&self.registry),
403 supervision: Arc::clone(&self.supervision),
404 recovery: None,
405 search_attribute_schema: Arc::clone(&self.search_attribute_schema),
406 bootstrap_schedule_coordinator: false,
407 })
408 .await?;
409 super::startup::recover_timers_on_startup(self.runtime.nif_state(), Arc::clone(&self.store))
424 .await
425 }
426
427 pub fn resume_workflow(
435 &self,
436 id: &WorkflowId,
437 run: &RunId,
438 ) -> Result<WorkflowHandle, EngineError> {
439 let handle = transition::resume(self.registry(), id, run)?;
440 if let Err(error) = self.signal_handoff.deliver_deferred(self, id) {
441 tracing::warn!(
442 workflow_id = %id,
443 run_id = %run,
444 error = %error,
445 "failed to flush deferred signals after workflow resume"
446 );
447 }
448 Ok(handle)
449 }
450
451 pub async fn cancel(
459 &self,
460 id: &WorkflowId,
461 run: &RunId,
462 reason: impl Into<String>,
463 ) -> Result<(), EngineError> {
464 let operation = self.shutdown_gate.begin_operation()?;
465 self.cancel_inflight_timers(id).await;
470 let result = terminate::cancel(
471 TerminateWorkflowContext {
472 runtime: &self.runtime,
473 store: self.store(),
474 visibility_store: self.visibility_store(),
475 registry: &self.registry,
476 },
477 id,
478 run,
479 reason,
480 )
481 .await;
482 if result.is_ok() {
486 self.paused_runs.remove(id);
487 }
488 drop(operation);
489 result
490 }
491
492 async fn cancel_inflight_timers(&self, id: &WorkflowId) {
518 let timer_service = match crate::runtime::nif_timer_bridge::installed_timer_service(
519 self.runtime.nif_state(),
520 ) {
521 Ok(service) => service,
522 Err(error) => {
523 tracing::warn!(
524 %error,
525 workflow_id = %id,
526 "timer service unavailable during cancel; any in-flight timers will be skipped by recovery"
527 );
528 return;
529 }
530 };
531 let history = match self.store.read_history(id).await {
532 Ok(history) => history,
533 Err(error) => {
534 tracing::warn!(
535 %error,
536 workflow_id = %id,
537 "could not read history for timer cleanup during cancel; any in-flight timers will be skipped by recovery"
538 );
539 return;
540 }
541 };
542 for timer_id in live_timers_in_active_segment(&history) {
543 if let Err(error) = timer_service
544 .cancel(
545 id.clone(),
546 timer_id.clone(),
547 TimerCancelCause::CancelTeardown,
548 )
549 .await
550 {
551 tracing::warn!(
552 %error,
553 workflow_id = %id,
554 %timer_id,
555 "failed to cancel in-flight timer during workflow cancel; recovery will skip it if orphaned"
556 );
557 }
558 }
559 }
560
561 pub async fn continue_as_new(
569 &self,
570 id: &WorkflowId,
571 run: &RunId,
572 input: Payload,
573 workflow_type: Option<String>,
574 ) -> Result<WorkflowHandle, EngineError> {
575 let operation = self.shutdown_gate.begin_operation()?;
576 let result = continue_as_new::continue_as_new(
577 ContinueAsNewContext {
578 store: self.store(),
579 visibility_store: Arc::clone(&self.visibility_store),
580 catalog: Arc::clone(&self.catalog),
581 runtime: &self.runtime,
582 supervision: Arc::clone(&self.supervision),
583 registry: &self.registry,
584 search_attribute_schema: Arc::clone(&self.search_attribute_schema),
585 },
586 id,
587 run,
588 ContinueAsNewRequest {
589 input,
590 workflow_type,
591 },
592 )
593 .await;
594 drop(operation);
595 result
596 }
597
598 pub async fn reopen_workflow(
614 &self,
615 id: &WorkflowId,
616 run: &RunId,
617 ) -> Result<WorkflowHandle, EngineError> {
618 let operation = self.shutdown_gate.begin_operation()?;
619 let result = reopen::reopen(
620 ReopenWorkflowContext {
621 store: self.store(),
622 visibility_store: Arc::clone(&self.visibility_store),
623 catalog: Arc::clone(&self.catalog),
624 runtime: &self.runtime,
625 supervision: Arc::clone(&self.supervision),
626 registry: &self.registry,
627 search_attribute_schema: Arc::clone(&self.search_attribute_schema),
628 },
629 id,
630 run,
631 )
632 .await;
633 drop(operation);
634 result
635 }
636
637 #[must_use]
643 pub fn paused_runs(&self) -> crate::lifecycle::PausedRuns {
644 self.paused_runs.clone()
645 }
646
647 pub async fn rebuild_paused_runs(&self) -> Result<(), EngineError> {
656 let paused = self.store.list_paused().await?;
657 self.paused_runs.replace_all(paused);
658 Ok(())
659 }
660
661 fn pause_context(&self) -> crate::lifecycle::PauseWorkflowContext<'_> {
662 crate::lifecycle::PauseWorkflowContext {
663 store: self.store(),
664 visibility_store: Arc::clone(&self.visibility_store),
665 catalog: Arc::clone(&self.catalog),
666 runtime: &self.runtime,
667 supervision: Arc::clone(&self.supervision),
668 registry: &self.registry,
669 search_attribute_schema: Arc::clone(&self.search_attribute_schema),
670 paused_runs: self.paused_runs.clone(),
671 }
672 }
673
674 pub async fn pause_workflow(
687 &self,
688 id: &WorkflowId,
689 run: &RunId,
690 reason: Option<String>,
691 operator: Option<String>,
692 ) -> Result<WorkflowHandle, EngineError> {
693 let operation = self.shutdown_gate.begin_operation()?;
694 let result =
695 crate::lifecycle::pause::pause(&self.pause_context(), id, run, reason, operator).await;
696 drop(operation);
697 result
698 }
699
700 pub async fn resume_paused_workflow(
716 &self,
717 id: &WorkflowId,
718 run: &RunId,
719 operator: Option<String>,
720 ) -> Result<WorkflowHandle, EngineError> {
721 let operation = self.shutdown_gate.begin_operation()?;
722 let result =
723 crate::lifecycle::pause::resume(&self.pause_context(), id, run, operator).await;
724 drop(operation);
725 result
726 }
727
728 pub async fn result(
739 &self,
740 id: &WorkflowId,
741 run: &RunId,
742 ) -> Result<Result<Payload, WorkflowError>, EngineError> {
743 let history = self.store.read_history(id).await?;
744 if let Some(outcome) = terminal_outcome_from_history(&history) {
745 return Ok(outcome_to_result(outcome));
746 }
747
748 let handle = match self.registry.get(id, run)? {
749 Some(handle) => handle,
750 None => self
754 .handle_after_birth_window(id, run, &history)
755 .await?
756 .ok_or_else(|| workflow_not_found(id, run))?,
757 };
758 let mut receiver = handle.completion().subscribe();
759 loop {
760 if let Some(outcome) = receiver.borrow().clone() {
761 return Ok(outcome_to_result(outcome));
762 }
763 if receiver.changed().await.is_err() {
764 if let Some(outcome) =
765 terminal_outcome_from_history(&self.store.read_history(id).await?)
766 {
767 return Ok(outcome_to_result(outcome));
768 }
769 return Err(EngineError::Runtime {
770 reason: format!(
771 "completion channel closed before workflow `{id}/{run}` finished"
772 ),
773 });
774 }
775 }
776 }
777
778 pub async fn list_workflows(
787 &self,
788 filter: WorkflowFilter,
789 ) -> Result<Vec<WorkflowSummary>, EngineError> {
790 let mut summaries = self
791 .store
792 .query(&filter)
793 .await?
794 .into_iter()
795 .map(|summary| (summary.workflow_id.clone(), summary))
796 .collect::<HashMap<_, _>>();
797
798 for handle in self.registry.list()? {
799 let history = self.store.read_history(handle.workflow_id()).await?;
800 self.registry
801 .reconcile(handle.workflow_id(), handle.run_id(), &history)?;
802 if let Some(summary) = WorkflowSummary::from_history(&history) {
803 if filter.matches(&summary) {
804 summaries.insert(summary.workflow_id.clone(), summary);
805 }
806 }
807 }
808
809 let mut summaries = summaries.into_values().collect::<Vec<_>>();
810 summaries.sort_by(|left, right| {
811 left.started_at.cmp(&right.started_at).then_with(|| {
812 left.workflow_id
813 .to_string()
814 .cmp(&right.workflow_id.to_string())
815 })
816 });
817 Ok(summaries)
818 }
819
820 pub fn shutdown(&self) -> Result<(), EngineError> {
826 if let Some(task) = &self.visibility_reconciliation_task {
827 task.abort();
828 }
829 self.shutdown_gate.close_and_wait()?;
830 self.runtime.shutdown()?;
838 self.runtime.nif_state().shutdown_child_tasks();
839 self.runtime.nif_state().shutdown_timer_wheel();
847 self.runtime.nif_state().clear_engine_seams();
856 Ok(())
857 }
858}
859
860pub(crate) fn terminal_outcome_from_history(events: &[Event]) -> Option<TerminalOutcome> {
861 match aion_core::current_lease_terminal(events)? {
865 Event::WorkflowCompleted { result, .. } => Some(TerminalOutcome::Completed(result.clone())),
866 Event::WorkflowFailed { error, .. } => Some(TerminalOutcome::Failed(error.clone())),
867 Event::WorkflowCancelled { reason, .. } => Some(TerminalOutcome::Cancelled(reason.clone())),
868 Event::WorkflowTimedOut { timeout, .. } => Some(TerminalOutcome::TimedOut(timeout.clone())),
869 Event::WorkflowContinuedAsNew {
870 input,
871 workflow_type,
872 parent_run_id,
873 ..
874 } => Some(TerminalOutcome::ContinuedAsNew {
875 input: input.clone(),
876 workflow_type: workflow_type.clone(),
877 parent_run_id: parent_run_id.clone(),
878 }),
879 _ => None,
880 }
881}
882
883fn outcome_to_result(outcome: TerminalOutcome) -> Result<Payload, WorkflowError> {
884 match outcome {
885 TerminalOutcome::Completed(payload) => Ok(payload),
886 TerminalOutcome::Failed(error) => Err(error),
887 TerminalOutcome::Cancelled(reason) => Err(WorkflowError {
888 message: format!("workflow cancelled: {reason}"),
889 details: None,
890 }),
891 TerminalOutcome::TimedOut(timeout) => Err(WorkflowError {
892 message: format!("workflow timed out: {timeout}"),
893 details: None,
894 }),
895 TerminalOutcome::ContinuedAsNew { parent_run_id, .. } => Err(WorkflowError {
896 message: format!("workflow continued as new from run {parent_run_id}"),
897 details: None,
898 }),
899 }
900}
901
902pub(crate) fn workflow_not_found(id: &WorkflowId, run: &RunId) -> EngineError {
903 EngineError::WorkflowNotFound {
904 workflow_type: format!("{id}/{run}"),
905 }
906}
907
908#[cfg(test)]
909mod tests {
910 use std::collections::HashMap;
911 use std::sync::Arc;
912 use std::time::Duration;
913
914 use aion_core::{
915 Event, EventEnvelope, PackageVersion, Payload, RunId, SearchAttributeSchema,
916 TimerCancelCause, TimerId, WorkflowFilter, WorkflowId, WorkflowStatus,
917 };
918 use aion_package::ContentHash;
919 use aion_store::visibility::VisibilityStore;
920 use aion_store::{EventStore, InMemoryStore, ReadableEventStore};
921 use serde_json::json;
922
923 use super::{DelegatedSeams, Engine, EngineComponents, live_timers_in_active_segment};
924 use crate::durability::Recorder;
925 use crate::lifecycle::terminate::{self, TerminateWorkflowContext};
926 use crate::registry::{CompletionNotifier, HandleResidency, WorkflowHandleParts};
927 use crate::time::TimerRecovery;
928 use crate::{
929 EngineError, Registry, RuntimeConfig, RuntimeHandle, SupervisionTree, WorkflowCatalog,
930 WorkflowHandle,
931 };
932
933 fn payload(label: &str) -> Result<Payload, aion_core::PayloadError> {
934 Payload::from_json(&json!({ "label": label }))
935 }
936
937 fn workflow_error(message: &str) -> aion_core::WorkflowError {
938 aion_core::WorkflowError {
939 message: message.to_owned(),
940 details: None,
941 }
942 }
943
944 fn workflow_catalog(workflow_type: &str, deployed_module: &str) -> Arc<WorkflowCatalog> {
945 let catalog = Arc::new(WorkflowCatalog::new());
946 catalog.note_loaded_workflow_for_test(
947 workflow_type,
948 deployed_module,
949 "run",
950 ContentHash::from_bytes([5; 32]),
951 );
952 catalog
953 }
954
955 fn engine_with_loaded_workflow(
956 store: Arc<dyn EventStore>,
957 workflow_type: &str,
958 deployed_module: &str,
959 ) -> Result<Engine, EngineError> {
960 let runtime = RuntimeHandle::new(RuntimeConfig::new(Some(1)))?;
961 runtime.register_waiting_test_module(deployed_module, "run");
962 let visibility_store: Arc<dyn VisibilityStore> = Arc::new(InMemoryStore::default());
963 Ok(Engine::new(EngineComponents {
964 store,
965 visibility_store,
966 runtime: Arc::new(runtime),
967 catalog: workflow_catalog(workflow_type, deployed_module),
968 registry: Arc::new(Registry::default()),
969 supervision: Arc::new(SupervisionTree::new()),
970 delegated: DelegatedSeams::default(),
971 signal_handoff: Arc::new(crate::signal::SignalResumeHandoff::new()),
972 search_attribute_schema: Arc::new(SearchAttributeSchema::new()),
973 visibility_reconciliation_task: None,
974 }))
975 }
976
977 fn termination_context(engine: &Engine) -> TerminateWorkflowContext<'_> {
978 TerminateWorkflowContext {
979 runtime: engine.runtime(),
980 store: engine.store(),
981 visibility_store: engine.visibility_store(),
982 registry: engine.registry(),
983 }
984 }
985
986 async fn insert_active_handle(
987 engine: &Engine,
988 store: Arc<dyn EventStore>,
989 workflow_type: &str,
990 ) -> Result<WorkflowHandle, Box<dyn std::error::Error>> {
991 let workflow_id = aion_core::WorkflowId::new_v4();
992 let run_id = aion_core::RunId::new_v4();
993 let mut recorder = Recorder::new(workflow_id.clone(), store);
994 recorder
995 .record_workflow_started(
996 chrono::Utc::now(),
997 crate::durability::WorkflowStartRecord {
998 workflow_type: workflow_type.to_owned(),
999 input: payload("input")?,
1000 run_id: run_id.clone(),
1001 parent_run_id: None,
1002 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1003 },
1004 )
1005 .await?;
1006 let pid = engine.runtime().spawn_test_process_with_trap_exit(true)?;
1007 let handle = WorkflowHandle::new(WorkflowHandleParts {
1008 workflow_id: workflow_id.clone(),
1009 run_id: run_id.clone(),
1010 pid,
1011 workflow_type: workflow_type.to_owned(),
1012 namespace: String::from("default"),
1013 loaded_version: ContentHash::from_bytes([9; 32]),
1014 cached_status: WorkflowStatus::Running,
1015 residency: HandleResidency::Resident,
1016 recorder,
1017 completion: CompletionNotifier::new(),
1018 });
1019 engine
1020 .registry()
1021 .insert((workflow_id, run_id), handle.clone())?;
1022 Ok(handle)
1023 }
1024
1025 #[tokio::test]
1026 async fn start_then_cancel_records_started_then_cancelled()
1027 -> Result<(), Box<dyn std::error::Error>> {
1028 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1029 let engine =
1030 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1031 let handle = engine
1032 .start_workflow(
1033 "checkout",
1034 payload("input")?,
1035 HashMap::new(),
1036 String::from("default"),
1037 )
1038 .await?;
1039
1040 engine
1041 .cancel(
1042 handle.workflow_id(),
1043 handle.run_id(),
1044 "caller requested cancellation",
1045 )
1046 .await?;
1047
1048 let history = store.read_history(handle.workflow_id()).await?;
1049 match history.as_slice() {
1050 [
1051 Event::WorkflowStarted { .. },
1052 Event::WorkflowCancelled { reason, .. },
1053 ] => {
1054 assert_eq!(reason, "caller requested cancellation");
1055 }
1056 other => return Err(format!("expected started then cancelled, found {other:?}").into()),
1057 }
1058 engine.shutdown()?;
1059 Ok(())
1060 }
1061
1062 fn test_envelope(workflow_id: &WorkflowId, seq: u64) -> EventEnvelope {
1063 EventEnvelope {
1064 seq,
1065 recorded_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap_or_default(),
1066 workflow_id: workflow_id.clone(),
1067 }
1068 }
1069
1070 fn started_event(workflow_id: &WorkflowId, seq: u64) -> Event {
1071 Event::WorkflowStarted {
1072 envelope: test_envelope(workflow_id, seq),
1073 workflow_type: String::from("checkout"),
1074 input: Payload::new(aion_core::ContentType::Json, b"{}".to_vec()),
1075 run_id: RunId::new_v4(),
1076 parent_run_id: None,
1077 package_version: PackageVersion::new("a".repeat(64)),
1078 }
1079 }
1080
1081 fn timer_started_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
1082 Event::TimerStarted {
1083 envelope: test_envelope(workflow_id, seq),
1084 timer_id: timer_id.clone(),
1085 fire_at: chrono::DateTime::from_timestamp(1_700_000_500, 0).unwrap_or_default(),
1086 }
1087 }
1088
1089 fn timer_fired_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
1090 Event::TimerFired {
1091 envelope: test_envelope(workflow_id, seq),
1092 timer_id: timer_id.clone(),
1093 }
1094 }
1095
1096 fn timer_cancelled_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
1097 Event::TimerCancelled {
1098 envelope: test_envelope(workflow_id, seq),
1099 timer_id: timer_id.clone(),
1100 cause: TimerCancelCause::WorkflowIntent,
1101 }
1102 }
1103
1104 #[test]
1105 fn live_timers_lists_started_and_unterminated() {
1106 let workflow_id = WorkflowId::new_v4();
1107 let first = TimerId::anonymous(0);
1108 let second = TimerId::anonymous(1);
1109 let history = vec![
1110 started_event(&workflow_id, 0),
1111 timer_started_event(&workflow_id, 1, &first),
1112 timer_started_event(&workflow_id, 2, &second),
1113 ];
1114 assert_eq!(
1115 live_timers_in_active_segment(&history),
1116 vec![first, second],
1117 "both started, unterminated timers should be live, in start order"
1118 );
1119 }
1120
1121 #[test]
1122 fn live_timers_excludes_fired_and_cancelled() {
1123 let workflow_id = WorkflowId::new_v4();
1124 let fired = TimerId::anonymous(0);
1125 let cancelled = TimerId::anonymous(1);
1126 let live = TimerId::anonymous(2);
1127 let history = vec![
1128 started_event(&workflow_id, 0),
1129 timer_started_event(&workflow_id, 1, &fired),
1130 timer_started_event(&workflow_id, 2, &cancelled),
1131 timer_started_event(&workflow_id, 3, &live),
1132 timer_fired_event(&workflow_id, 4, &fired),
1133 timer_cancelled_event(&workflow_id, 5, &cancelled),
1134 ];
1135 assert_eq!(
1136 live_timers_in_active_segment(&history),
1137 vec![live],
1138 "only the timer with no terminal event remains live"
1139 );
1140 }
1141
1142 #[test]
1143 fn live_timers_dedups_repeated_start() {
1144 let workflow_id = WorkflowId::new_v4();
1145 let timer = TimerId::anonymous(0);
1146 let history = vec![
1147 started_event(&workflow_id, 0),
1148 timer_started_event(&workflow_id, 1, &timer),
1149 timer_started_event(&workflow_id, 2, &timer),
1150 ];
1151 assert_eq!(live_timers_in_active_segment(&history), vec![timer]);
1152 }
1153
1154 #[test]
1155 fn live_timers_scopes_to_active_run_segment() {
1156 let workflow_id = WorkflowId::new_v4();
1159 let prior_run = TimerId::anonymous(0);
1160 let current_run = TimerId::anonymous(0);
1161 let history = vec![
1162 started_event(&workflow_id, 0),
1163 timer_started_event(&workflow_id, 1, &prior_run),
1164 started_event(&workflow_id, 2),
1165 timer_started_event(&workflow_id, 3, ¤t_run),
1166 ];
1167 assert_eq!(
1168 live_timers_in_active_segment(&history),
1169 vec![current_run],
1170 "only timers from the latest WorkflowStarted segment are live"
1171 );
1172 }
1173
1174 #[test]
1175 fn live_timers_empty_history_is_empty() {
1176 assert!(live_timers_in_active_segment(&[]).is_empty());
1177 }
1178
1179 fn engine_with_timer_bridge(
1184 store: Arc<dyn EventStore>,
1185 registry: Arc<Registry>,
1186 ) -> Result<Engine, EngineError> {
1187 let runtime = RuntimeHandle::new(RuntimeConfig::new(Some(1)))?;
1188 runtime.register_waiting_test_module("checkout_deployed", "run");
1189 crate::runtime::nif_timer_bridge::install_timer_nif_bridge(
1190 runtime.nif_state(),
1191 Arc::clone(®istry),
1192 Arc::clone(&store),
1193 tokio::runtime::Handle::current(),
1194 crate::runtime::SignalDeliveryConfig::default(),
1195 );
1196 let visibility_store: Arc<dyn VisibilityStore> = Arc::new(InMemoryStore::default());
1197 Ok(Engine::new(EngineComponents {
1198 store,
1199 visibility_store,
1200 runtime: Arc::new(runtime),
1201 catalog: workflow_catalog("checkout", "checkout_deployed"),
1202 registry,
1203 supervision: Arc::new(SupervisionTree::new()),
1204 delegated: DelegatedSeams::default(),
1205 signal_handoff: Arc::new(crate::signal::SignalResumeHandoff::new()),
1206 search_attribute_schema: Arc::new(SearchAttributeSchema::new()),
1207 visibility_reconciliation_task: None,
1208 }))
1209 }
1210
1211 #[tokio::test(flavor = "multi_thread")]
1217 async fn cancel_records_timer_cancelled_before_workflow_cancelled()
1218 -> Result<(), Box<dyn std::error::Error>> {
1219 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1220 let registry = Arc::new(Registry::default());
1221 let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(®istry))?;
1222
1223 let handle = engine
1224 .start_workflow(
1225 "checkout",
1226 payload("input")?,
1227 HashMap::new(),
1228 String::from("default"),
1229 )
1230 .await?;
1231
1232 let timer_id = TimerId::anonymous(0);
1235 let fire_at = chrono::Utc::now() + chrono::Duration::hours(1);
1236 handle
1237 .recorder()
1238 .lock()
1239 .await
1240 .record_timer_started(chrono::Utc::now(), timer_id.clone(), fire_at)
1241 .await?;
1242 let timer_service =
1243 crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1244 .map_err(|error| format!("timer service unavailable: {error}"))?;
1245 timer_service
1246 .schedule(handle.workflow_id().clone(), timer_id.clone(), fire_at)
1247 .await?;
1248
1249 engine
1250 .cancel(
1251 handle.workflow_id(),
1252 handle.run_id(),
1253 "caller requested cancellation",
1254 )
1255 .await?;
1256
1257 let history = store.read_history(handle.workflow_id()).await?;
1258 match history.as_slice() {
1259 [
1260 Event::WorkflowStarted { .. },
1261 Event::TimerStarted {
1262 timer_id: started, ..
1263 },
1264 Event::TimerCancelled {
1265 timer_id: cancelled,
1266 ..
1267 },
1268 Event::WorkflowCancelled { reason, .. },
1269 ] => {
1270 assert_eq!(started, &timer_id);
1271 assert_eq!(cancelled, &timer_id, "the live timer must be cancelled");
1272 assert_eq!(reason, "caller requested cancellation");
1273 }
1274 other => {
1275 return Err(format!(
1276 "expected [started, timer-started, timer-cancelled, cancelled], found {other:?}"
1277 )
1278 .into());
1279 }
1280 }
1281 engine.shutdown()?;
1282 Ok(())
1283 }
1284
1285 #[tokio::test(flavor = "multi_thread")]
1288 async fn cancel_cancels_multiple_live_timers() -> Result<(), Box<dyn std::error::Error>> {
1289 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1290 let registry = Arc::new(Registry::default());
1291 let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(®istry))?;
1292 let handle = engine
1293 .start_workflow(
1294 "checkout",
1295 payload("input")?,
1296 HashMap::new(),
1297 String::from("default"),
1298 )
1299 .await?;
1300
1301 let first = TimerId::anonymous(0);
1302 let second = TimerId::anonymous(1);
1303 let fire_at = chrono::Utc::now() + chrono::Duration::hours(1);
1304 {
1305 let recorder = handle.recorder();
1306 let mut recorder = recorder.lock().await;
1307 recorder
1308 .record_timer_started(chrono::Utc::now(), first.clone(), fire_at)
1309 .await?;
1310 recorder
1311 .record_timer_started(chrono::Utc::now(), second.clone(), fire_at)
1312 .await?;
1313 }
1314
1315 engine
1316 .cancel(handle.workflow_id(), handle.run_id(), "stop")
1317 .await?;
1318
1319 let history = store.read_history(handle.workflow_id()).await?;
1320 match history.as_slice() {
1321 [
1322 Event::WorkflowStarted { .. },
1323 Event::TimerStarted {
1324 timer_id: started_first,
1325 ..
1326 },
1327 Event::TimerStarted {
1328 timer_id: started_second,
1329 ..
1330 },
1331 Event::TimerCancelled {
1332 timer_id: cancelled_first,
1333 ..
1334 },
1335 Event::TimerCancelled {
1336 timer_id: cancelled_second,
1337 ..
1338 },
1339 Event::WorkflowCancelled { .. },
1340 ] => {
1341 assert_eq!(started_first, &first);
1342 assert_eq!(started_second, &second);
1343 assert_eq!(cancelled_first, &first, "first live timer cancelled first");
1344 assert_eq!(
1345 cancelled_second, &second,
1346 "second live timer cancelled second"
1347 );
1348 }
1349 other => {
1350 return Err(format!(
1351 "expected two timer-cancels before workflow-cancel, found {other:?}"
1352 )
1353 .into());
1354 }
1355 }
1356 engine.shutdown()?;
1357 Ok(())
1358 }
1359
1360 #[tokio::test(flavor = "multi_thread")]
1367 async fn cancelled_workflow_leaves_no_orphan_for_recovery()
1368 -> Result<(), Box<dyn std::error::Error>> {
1369 let concrete: Arc<InMemoryStore> = Arc::new(InMemoryStore::default());
1370 let store: Arc<dyn EventStore> = concrete.clone();
1371 let registry = Arc::new(Registry::default());
1372 let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(®istry))?;
1373 let handle = engine
1374 .start_workflow(
1375 "checkout",
1376 payload("input")?,
1377 HashMap::new(),
1378 String::from("default"),
1379 )
1380 .await?;
1381 let workflow_id = handle.workflow_id().clone();
1382
1383 let timer_id = TimerId::anonymous(0);
1386 let fire_at = chrono::Utc::now() - chrono::Duration::hours(1);
1387 handle
1388 .recorder()
1389 .lock()
1390 .await
1391 .record_timer_started(chrono::Utc::now(), timer_id.clone(), fire_at)
1392 .await?;
1393 concrete
1394 .schedule_timer(&workflow_id, &timer_id, fire_at)
1395 .await?;
1396
1397 let timer_service =
1398 crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1399 .map_err(|error| format!("timer service unavailable: {error}"))?;
1400
1401 engine.cancel(&workflow_id, handle.run_id(), "stop").await?;
1402
1403 let readable: Arc<dyn ReadableEventStore> = concrete.clone();
1408 TimerRecovery::new(readable, timer_service, Duration::ZERO)
1409 .recover_on_startup(chrono::Utc::now())
1410 .await?;
1411
1412 let history = concrete.read_history(&workflow_id).await?;
1413 assert!(
1414 !history
1415 .iter()
1416 .any(|event| matches!(event, Event::TimerFired { .. })),
1417 "no timer should fire for a cancelled workflow during recovery"
1418 );
1419 assert!(
1420 history
1421 .iter()
1422 .any(|event| matches!(event, Event::TimerCancelled { .. })),
1423 "cancel must have recorded TimerCancelled at the source"
1424 );
1425 engine.shutdown()?;
1426 Ok(())
1427 }
1428
1429 #[tokio::test]
1430 async fn result_returns_completed_payload() -> Result<(), Box<dyn std::error::Error>> {
1431 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1432 let engine =
1433 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1434 let handle = engine
1435 .start_workflow(
1436 "checkout",
1437 payload("input")?,
1438 HashMap::new(),
1439 String::from("default"),
1440 )
1441 .await?;
1442 let result_payload = payload("result")?;
1443
1444 terminate::complete(
1445 termination_context(&engine),
1446 handle.workflow_id(),
1447 handle.run_id(),
1448 result_payload.clone(),
1449 )
1450 .await?;
1451
1452 assert_eq!(
1453 engine.result(handle.workflow_id(), handle.run_id()).await?,
1454 Ok(result_payload)
1455 );
1456 engine.shutdown()?;
1457 Ok(())
1458 }
1459
1460 #[tokio::test]
1461 async fn result_returns_failed_workflow_error() -> Result<(), Box<dyn std::error::Error>> {
1462 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1463 let engine =
1464 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1465 let handle = engine
1466 .start_workflow(
1467 "checkout",
1468 payload("input")?,
1469 HashMap::new(),
1470 String::from("default"),
1471 )
1472 .await?;
1473 let error = workflow_error("workflow failed");
1474
1475 terminate::fail(
1476 termination_context(&engine),
1477 handle.workflow_id(),
1478 handle.run_id(),
1479 error.clone(),
1480 )
1481 .await?;
1482
1483 assert_eq!(
1484 engine.result(handle.workflow_id(), handle.run_id()).await?,
1485 Err(error)
1486 );
1487 engine.shutdown()?;
1488 Ok(())
1489 }
1490
1491 #[tokio::test]
1492 async fn result_unknown_workflow_returns_not_found() -> Result<(), Box<dyn std::error::Error>> {
1493 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1494 let engine = engine_with_loaded_workflow(store, "checkout", "checkout_deployed")?;
1495 let workflow_id = aion_core::WorkflowId::new_v4();
1496 let run_id = aion_core::RunId::new_v4();
1497
1498 let result = engine.result(&workflow_id, &run_id).await;
1499
1500 assert!(matches!(result, Err(EngineError::WorkflowNotFound { .. })));
1501 engine.shutdown()?;
1502 Ok(())
1503 }
1504
1505 #[tokio::test]
1506 async fn continue_as_new_unknown_workflow_returns_not_found()
1507 -> Result<(), Box<dyn std::error::Error>> {
1508 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1509 let engine = engine_with_loaded_workflow(store, "checkout", "checkout_deployed")?;
1510 let workflow_id = aion_core::WorkflowId::new_v4();
1511 let run_id = aion_core::RunId::new_v4();
1512
1513 let result = engine
1514 .continue_as_new(&workflow_id, &run_id, payload("next")?, None)
1515 .await;
1516
1517 assert!(matches!(result, Err(EngineError::WorkflowNotFound { .. })));
1518 engine.shutdown()?;
1519 Ok(())
1520 }
1521
1522 #[tokio::test]
1523 async fn list_workflows_merges_live_and_terminal_without_duplicates()
1524 -> Result<(), Box<dyn std::error::Error>> {
1525 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1526 let engine =
1527 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1528 let running = insert_active_handle(&engine, Arc::clone(&store), "checkout").await?;
1529 let completed = engine
1530 .start_workflow(
1531 "checkout",
1532 payload("input")?,
1533 HashMap::new(),
1534 String::from("default"),
1535 )
1536 .await?;
1537 terminate::complete(
1538 termination_context(&engine),
1539 completed.workflow_id(),
1540 completed.run_id(),
1541 payload("result")?,
1542 )
1543 .await?;
1544
1545 let summaries = engine.list_workflows(WorkflowFilter::default()).await?;
1546 assert_eq!(summaries.len(), 2);
1547 assert!(summaries.iter().any(|summary| {
1548 &summary.workflow_id == running.workflow_id()
1549 && summary.status == WorkflowStatus::Running
1550 }));
1551 assert!(summaries.iter().any(|summary| {
1552 &summary.workflow_id == completed.workflow_id()
1553 && summary.status == WorkflowStatus::Completed
1554 }));
1555
1556 let completed_only = engine
1557 .list_workflows(WorkflowFilter {
1558 status: Some(WorkflowStatus::Completed),
1559 ..WorkflowFilter::default()
1560 })
1561 .await?;
1562 assert_eq!(completed_only.len(), 1);
1563 assert_eq!(&completed_only[0].workflow_id, completed.workflow_id());
1564 engine.shutdown()?;
1565 Ok(())
1566 }
1567
1568 #[tokio::test]
1569 async fn shutdown_rejects_subsequent_starts() -> Result<(), Box<dyn std::error::Error>> {
1570 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1571 let engine =
1572 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1573 let handle = engine
1574 .start_workflow(
1575 "checkout",
1576 payload("input")?,
1577 HashMap::new(),
1578 String::from("default"),
1579 )
1580 .await?;
1581 terminate::complete(
1582 termination_context(&engine),
1583 handle.workflow_id(),
1584 handle.run_id(),
1585 payload("result")?,
1586 )
1587 .await?;
1588
1589 engine.shutdown()?;
1590 let result = engine
1591 .start_workflow(
1592 "checkout",
1593 payload("after-shutdown")?,
1594 HashMap::new(),
1595 String::from("default"),
1596 )
1597 .await;
1598
1599 assert!(matches!(result, Err(EngineError::ShuttingDown)));
1600 Ok(())
1601 }
1602
1603 #[tokio::test]
1604 async fn shutdown_is_idempotent() -> Result<(), Box<dyn std::error::Error>> {
1605 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1606 let engine =
1607 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1608 let handle = engine
1609 .start_workflow(
1610 "checkout",
1611 payload("input")?,
1612 HashMap::new(),
1613 String::from("default"),
1614 )
1615 .await?;
1616 terminate::complete(
1617 termination_context(&engine),
1618 handle.workflow_id(),
1619 handle.run_id(),
1620 payload("result")?,
1621 )
1622 .await?;
1623
1624 engine.shutdown()?;
1625 let second = engine.shutdown();
1626
1627 assert!(
1628 second.is_ok(),
1629 "double shutdown should succeed; got {second:?}"
1630 );
1631 Ok(())
1632 }
1633
1634 #[tokio::test]
1635 async fn shutdown_rejects_schedule_creation() -> Result<(), Box<dyn std::error::Error>> {
1636 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1637 let engine =
1638 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1639 let handle = engine
1640 .start_workflow(
1641 "checkout",
1642 payload("input")?,
1643 HashMap::new(),
1644 String::from("default"),
1645 )
1646 .await?;
1647 terminate::complete(
1648 termination_context(&engine),
1649 handle.workflow_id(),
1650 handle.run_id(),
1651 payload("result")?,
1652 )
1653 .await?;
1654 engine.shutdown()?;
1655
1656 let config = aion_core::ScheduleConfig {
1657 trigger: aion_core::TriggerSpec::Interval {
1658 period: Duration::from_secs(60),
1659 },
1660 overlap_policy: aion_core::OverlapPolicy::Skip,
1661 catch_up_policy: aion_core::CatchUpPolicy::Skip,
1662 workflow_type: String::from("checkout"),
1663 input: payload("scheduled")?,
1664 search_attributes: HashMap::new(),
1665 };
1666 let result = engine.create_schedule(config).await;
1667
1668 assert!(
1669 matches!(result, Err(EngineError::ShuttingDown)),
1670 "create_schedule after shutdown should return ShuttingDown; got {result:?}"
1671 );
1672 Ok(())
1673 }
1674}