1use std::collections::HashMap;
4use std::sync::Arc;
5
6use aion_core::{
7 Event, Payload, RunId, SearchAttributeSchema, SearchAttributeValue, WorkflowError,
8 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::start::{self, StartWorkflowContext};
20use crate::lifecycle::terminate::{self, TerminateWorkflowContext};
21use crate::lifecycle::transition;
22use crate::registry::{TerminalOutcome, WorkflowHandle};
23use crate::{
24 EngineError, Registry, RuntimeHandle, SupervisionTree, WorkflowCatalog,
25 signal::SignalResumeHandoff,
26};
27
28use super::api_schedule::{
29 ScheduleRuntimeDeps, default_schedule_evaluator, schedule_coordinator_workflow_id,
30};
31use super::delegated::DelegatedSeams;
32use super::shutdown_gate::ShutdownGate;
33use crate::time::timer_service::live_timers_in_active_segment;
34
35pub struct Engine {
37 store: Arc<dyn EventStore>,
38 visibility_store: Arc<dyn VisibilityStore>,
39 pub(super) schedule_recorder: Arc<AsyncMutex<Recorder>>,
40 pub(super) schedule_evaluator: Arc<AsyncMutex<ScheduleEvaluator>>,
41 pub(super) schedule_coordinator_workflow_id: WorkflowId,
42 runtime: Arc<RuntimeHandle>,
43 catalog: Arc<WorkflowCatalog>,
44 registry: Arc<Registry>,
45 supervision: Arc<SupervisionTree>,
46 delegated: DelegatedSeams,
47 signal_handoff: Arc<SignalResumeHandoff>,
48 search_attribute_schema: Arc<SearchAttributeSchema>,
49 pub(super) shutdown_gate: ShutdownGate,
50 pub(super) deploy_mutations: AsyncMutex<()>,
57 visibility_reconciliation_task: Option<JoinHandle<()>>,
58}
59
60pub(crate) struct EngineComponents {
62 pub(crate) store: Arc<dyn EventStore>,
63 pub(crate) visibility_store: Arc<dyn VisibilityStore>,
64 pub(crate) runtime: Arc<RuntimeHandle>,
65 pub(crate) catalog: Arc<WorkflowCatalog>,
66 pub(crate) registry: Arc<Registry>,
67 pub(crate) supervision: Arc<SupervisionTree>,
68 pub(crate) delegated: DelegatedSeams,
69 pub(crate) signal_handoff: Arc<SignalResumeHandoff>,
70 pub(crate) search_attribute_schema: Arc<SearchAttributeSchema>,
71 pub(crate) visibility_reconciliation_task: Option<JoinHandle<()>>,
72}
73
74impl Engine {
75 #[must_use]
77 pub(crate) fn new(components: EngineComponents) -> Self {
78 let EngineComponents {
79 store,
80 visibility_store,
81 runtime,
82 catalog,
83 registry,
84 supervision,
85 delegated,
86 signal_handoff,
87 search_attribute_schema,
88 visibility_reconciliation_task,
89 } = components;
90 let schedule_coordinator_workflow_id = schedule_coordinator_workflow_id();
91 let schedule_recorder = Arc::new(AsyncMutex::new(Recorder::new(
92 schedule_coordinator_workflow_id.clone(),
93 Arc::clone(&store),
94 )));
95 let runtime_arc = runtime;
96 let registry_arc = registry;
97 let supervision_arc = supervision;
98 let schedule_evaluator = Arc::new(AsyncMutex::new(default_schedule_evaluator(
99 schedule_coordinator_workflow_id.clone(),
100 Arc::clone(&schedule_recorder),
101 ScheduleRuntimeDeps {
102 store: Arc::clone(&store),
103 visibility_store: Arc::clone(&visibility_store),
104 runtime: Arc::clone(&runtime_arc),
105 catalog: Arc::clone(&catalog),
106 registry: Arc::clone(®istry_arc),
107 supervision: Arc::clone(&supervision_arc),
108 search_attribute_schema: Arc::clone(&search_attribute_schema),
109 },
110 )));
111 Self {
112 store,
113 visibility_store,
114 schedule_recorder,
115 schedule_evaluator,
116 schedule_coordinator_workflow_id,
117 runtime: runtime_arc,
118 catalog,
119 registry: registry_arc,
120 supervision: supervision_arc,
121 delegated,
122 signal_handoff,
123 search_attribute_schema,
124 shutdown_gate: ShutdownGate::default(),
125 deploy_mutations: AsyncMutex::new(()),
126 visibility_reconciliation_task,
127 }
128 }
129
130 pub(crate) async fn catchup_schedule_coordinator(&self) -> Result<(), EngineError> {
138 let history = self
139 .store
140 .read_history(&self.schedule_coordinator_workflow_id)
141 .await?;
142 let head = u64::try_from(history.len()).unwrap_or(u64::MAX);
143 if head > 0 {
144 let mut recorder = self.schedule_recorder.lock().await;
145 *recorder = Recorder::resume_at(
146 self.schedule_coordinator_workflow_id.clone(),
147 Arc::clone(&self.store),
148 head,
149 );
150 }
151 Ok(())
152 }
153
154 #[must_use]
156 pub fn store(&self) -> Arc<dyn EventStore> {
157 Arc::clone(&self.store)
158 }
159
160 #[must_use]
162 pub fn visibility_store(&self) -> Arc<dyn VisibilityStore> {
163 Arc::clone(&self.visibility_store)
164 }
165
166 #[must_use]
168 pub fn runtime(&self) -> &RuntimeHandle {
169 &self.runtime
170 }
171
172 #[must_use]
174 pub fn workflow_catalog(&self) -> &Arc<WorkflowCatalog> {
175 &self.catalog
176 }
177
178 #[must_use]
180 pub fn registry(&self) -> &Registry {
181 &self.registry
182 }
183
184 #[must_use]
186 pub fn supervision(&self) -> &SupervisionTree {
187 &self.supervision
188 }
189
190 #[must_use]
192 pub const fn delegated(&self) -> &DelegatedSeams {
193 &self.delegated
194 }
195
196 #[must_use]
198 pub fn signal_handoff(&self) -> Arc<SignalResumeHandoff> {
199 Arc::clone(&self.signal_handoff)
200 }
201
202 pub async fn start_workflow(
216 &self,
217 workflow_type: &str,
218 input: Payload,
219 search_attributes: HashMap<String, SearchAttributeValue>,
220 namespace: String,
221 ) -> Result<WorkflowHandle, EngineError> {
222 self.start_workflow_with_id(
223 workflow_type,
224 input,
225 search_attributes,
226 namespace,
227 None,
228 None,
229 )
230 .await
231 }
232
233 pub async fn start_workflow_with_id(
255 &self,
256 workflow_type: &str,
257 input: Payload,
258 search_attributes: HashMap<String, SearchAttributeValue>,
259 namespace: String,
260 workflow_id: Option<WorkflowId>,
261 routing_key: Option<String>,
262 ) -> Result<WorkflowHandle, EngineError> {
263 let operation = self.shutdown_gate.begin_start()?;
264 let result = start::start_workflow_with_options(
265 StartWorkflowContext {
266 store: self.store(),
267 visibility_store: self.visibility_store(),
268 catalog: Arc::clone(&self.catalog),
269 runtime: Arc::clone(&self.runtime),
270 supervision: Arc::clone(&self.supervision),
271 registry: Arc::clone(&self.registry),
272 signal_handoff: Some(self.signal_handoff()),
273 search_attribute_schema: Arc::clone(&self.search_attribute_schema),
274 monitor_tokio_handle: tokio::runtime::Handle::current(),
275 },
276 workflow_type,
277 input,
278 start::StartWorkflowOptions {
279 namespace: Some(namespace),
280 search_attributes,
281 workflow_id,
282 routing_key,
283 ..start::StartWorkflowOptions::default()
284 },
285 )
286 .await;
287 drop(operation);
288 result
289 }
290
291 pub async fn adopt_shards(&self, shards: &[usize]) -> Result<(), EngineError> {
333 let operation = self.shutdown_gate.begin_start()?;
334 let result = self.adopt_shards_inner(shards).await;
335 drop(operation);
336 result
337 }
338
339 async fn adopt_shards_inner(&self, shards: &[usize]) -> Result<(), EngineError> {
355 let _recoverable =
367 super::fence::plan_adopted_shards(&super::fence::StoreFenceSeam { store: &*self.store }, shards)?;
368 super::startup::recover_adopted_shards(super::startup::StartupRecoveryContext {
373 store: Arc::clone(&self.store),
374 visibility_store: Arc::clone(&self.visibility_store),
375 runtime: Arc::clone(&self.runtime),
376 catalog: Arc::clone(&self.catalog),
377 registry: Arc::clone(&self.registry),
378 supervision: Arc::clone(&self.supervision),
379 recovery: None,
380 search_attribute_schema: Arc::clone(&self.search_attribute_schema),
381 bootstrap_schedule_coordinator: false,
382 })
383 .await?;
384 super::startup::recover_timers_on_startup(self.runtime.nif_state(), Arc::clone(&self.store))
385 .await
386 }
387
388 pub fn resume_workflow(
396 &self,
397 id: &WorkflowId,
398 run: &RunId,
399 ) -> Result<WorkflowHandle, EngineError> {
400 let handle = transition::resume(self.registry(), id, run)?;
401 if let Err(error) = self.signal_handoff.deliver_deferred(self, id) {
402 tracing::warn!(
403 workflow_id = %id,
404 run_id = %run,
405 error = %error,
406 "failed to flush deferred signals after workflow resume"
407 );
408 }
409 Ok(handle)
410 }
411
412 pub async fn cancel(
420 &self,
421 id: &WorkflowId,
422 run: &RunId,
423 reason: impl Into<String>,
424 ) -> Result<(), EngineError> {
425 let operation = self.shutdown_gate.begin_operation()?;
426 self.cancel_inflight_timers(id).await;
431 let result = terminate::cancel(
432 TerminateWorkflowContext {
433 runtime: &self.runtime,
434 store: self.store(),
435 visibility_store: self.visibility_store(),
436 registry: &self.registry,
437 },
438 id,
439 run,
440 reason,
441 )
442 .await;
443 drop(operation);
444 result
445 }
446
447 async fn cancel_inflight_timers(&self, id: &WorkflowId) {
473 let timer_service = match crate::runtime::nif_timer_bridge::installed_timer_service(
474 self.runtime.nif_state(),
475 ) {
476 Ok(service) => service,
477 Err(error) => {
478 tracing::warn!(
479 %error,
480 workflow_id = %id,
481 "timer service unavailable during cancel; any in-flight timers will be skipped by recovery"
482 );
483 return;
484 }
485 };
486 let history = match self.store.read_history(id).await {
487 Ok(history) => history,
488 Err(error) => {
489 tracing::warn!(
490 %error,
491 workflow_id = %id,
492 "could not read history for timer cleanup during cancel; any in-flight timers will be skipped by recovery"
493 );
494 return;
495 }
496 };
497 for timer_id in live_timers_in_active_segment(&history) {
498 if let Err(error) = timer_service.cancel(id.clone(), timer_id.clone()).await {
499 tracing::warn!(
500 %error,
501 workflow_id = %id,
502 %timer_id,
503 "failed to cancel in-flight timer during workflow cancel; recovery will skip it if orphaned"
504 );
505 }
506 }
507 }
508
509 pub async fn continue_as_new(
517 &self,
518 id: &WorkflowId,
519 run: &RunId,
520 input: Payload,
521 workflow_type: Option<String>,
522 ) -> Result<WorkflowHandle, EngineError> {
523 let operation = self.shutdown_gate.begin_operation()?;
524 let result = continue_as_new::continue_as_new(
525 ContinueAsNewContext {
526 store: self.store(),
527 visibility_store: Arc::clone(&self.visibility_store),
528 catalog: Arc::clone(&self.catalog),
529 runtime: &self.runtime,
530 supervision: Arc::clone(&self.supervision),
531 registry: &self.registry,
532 search_attribute_schema: Arc::clone(&self.search_attribute_schema),
533 },
534 id,
535 run,
536 ContinueAsNewRequest {
537 input,
538 workflow_type,
539 },
540 )
541 .await;
542 drop(operation);
543 result
544 }
545
546 pub async fn result(
557 &self,
558 id: &WorkflowId,
559 run: &RunId,
560 ) -> Result<Result<Payload, WorkflowError>, EngineError> {
561 let history = self.store.read_history(id).await?;
562 if let Some(outcome) = terminal_outcome_from_history(&history) {
563 return Ok(outcome_to_result(outcome));
564 }
565
566 let handle = match self.registry.get(id, run)? {
567 Some(handle) => handle,
568 None => self
572 .handle_after_birth_window(id, run, &history)
573 .await?
574 .ok_or_else(|| workflow_not_found(id, run))?,
575 };
576 let mut receiver = handle.completion().subscribe();
577 loop {
578 if let Some(outcome) = receiver.borrow().clone() {
579 return Ok(outcome_to_result(outcome));
580 }
581 if receiver.changed().await.is_err() {
582 if let Some(outcome) =
583 terminal_outcome_from_history(&self.store.read_history(id).await?)
584 {
585 return Ok(outcome_to_result(outcome));
586 }
587 return Err(EngineError::Runtime {
588 reason: format!(
589 "completion channel closed before workflow `{id}/{run}` finished"
590 ),
591 });
592 }
593 }
594 }
595
596 pub async fn list_workflows(
605 &self,
606 filter: WorkflowFilter,
607 ) -> Result<Vec<WorkflowSummary>, EngineError> {
608 let mut summaries = self
609 .store
610 .query(&filter)
611 .await?
612 .into_iter()
613 .map(|summary| (summary.workflow_id.clone(), summary))
614 .collect::<HashMap<_, _>>();
615
616 for handle in self.registry.list()? {
617 let history = self.store.read_history(handle.workflow_id()).await?;
618 self.registry
619 .reconcile(handle.workflow_id(), handle.run_id(), &history)?;
620 if let Some(summary) = WorkflowSummary::from_history(&history) {
621 if filter.matches(&summary) {
622 summaries.insert(summary.workflow_id.clone(), summary);
623 }
624 }
625 }
626
627 let mut summaries = summaries.into_values().collect::<Vec<_>>();
628 summaries.sort_by(|left, right| {
629 left.started_at.cmp(&right.started_at).then_with(|| {
630 left.workflow_id
631 .to_string()
632 .cmp(&right.workflow_id.to_string())
633 })
634 });
635 Ok(summaries)
636 }
637
638 pub fn shutdown(&self) -> Result<(), EngineError> {
644 if let Some(task) = &self.visibility_reconciliation_task {
645 task.abort();
646 }
647 self.shutdown_gate.close_and_wait()?;
648 self.runtime.shutdown()?;
656 self.runtime.nif_state().shutdown_child_tasks();
657 Ok(())
658 }
659}
660
661pub(crate) fn terminal_outcome_from_history(events: &[Event]) -> Option<TerminalOutcome> {
662 match aion_core::current_lease_terminal(events)? {
666 Event::WorkflowCompleted { result, .. } => Some(TerminalOutcome::Completed(result.clone())),
667 Event::WorkflowFailed { error, .. } => Some(TerminalOutcome::Failed(error.clone())),
668 Event::WorkflowCancelled { reason, .. } => Some(TerminalOutcome::Cancelled(reason.clone())),
669 Event::WorkflowTimedOut { timeout, .. } => Some(TerminalOutcome::TimedOut(timeout.clone())),
670 Event::WorkflowContinuedAsNew {
671 input,
672 workflow_type,
673 parent_run_id,
674 ..
675 } => Some(TerminalOutcome::ContinuedAsNew {
676 input: input.clone(),
677 workflow_type: workflow_type.clone(),
678 parent_run_id: parent_run_id.clone(),
679 }),
680 _ => None,
681 }
682}
683
684fn outcome_to_result(outcome: TerminalOutcome) -> Result<Payload, WorkflowError> {
685 match outcome {
686 TerminalOutcome::Completed(payload) => Ok(payload),
687 TerminalOutcome::Failed(error) => Err(error),
688 TerminalOutcome::Cancelled(reason) => Err(WorkflowError {
689 message: format!("workflow cancelled: {reason}"),
690 details: None,
691 }),
692 TerminalOutcome::TimedOut(timeout) => Err(WorkflowError {
693 message: format!("workflow timed out: {timeout}"),
694 details: None,
695 }),
696 TerminalOutcome::ContinuedAsNew { parent_run_id, .. } => Err(WorkflowError {
697 message: format!("workflow continued as new from run {parent_run_id}"),
698 details: None,
699 }),
700 }
701}
702
703pub(crate) fn workflow_not_found(id: &WorkflowId, run: &RunId) -> EngineError {
704 EngineError::WorkflowNotFound {
705 workflow_type: format!("{id}/{run}"),
706 }
707}
708
709#[cfg(test)]
710mod tests {
711 use std::collections::HashMap;
712 use std::sync::Arc;
713 use std::time::Duration;
714
715 use aion_core::{
716 Event, EventEnvelope, PackageVersion, Payload, RunId, SearchAttributeSchema, TimerId,
717 WorkflowFilter, WorkflowId, WorkflowStatus,
718 };
719 use aion_package::ContentHash;
720 use aion_store::visibility::VisibilityStore;
721 use aion_store::{EventStore, InMemoryStore, ReadableEventStore};
722 use serde_json::json;
723
724 use super::{DelegatedSeams, Engine, EngineComponents, live_timers_in_active_segment};
725 use crate::durability::Recorder;
726 use crate::lifecycle::terminate::{self, TerminateWorkflowContext};
727 use crate::registry::{CompletionNotifier, HandleResidency, WorkflowHandleParts};
728 use crate::time::TimerRecovery;
729 use crate::{
730 EngineError, Registry, RuntimeConfig, RuntimeHandle, SupervisionTree, WorkflowCatalog,
731 WorkflowHandle,
732 };
733
734 fn payload(label: &str) -> Result<Payload, aion_core::PayloadError> {
735 Payload::from_json(&json!({ "label": label }))
736 }
737
738 fn workflow_error(message: &str) -> aion_core::WorkflowError {
739 aion_core::WorkflowError {
740 message: message.to_owned(),
741 details: None,
742 }
743 }
744
745 fn workflow_catalog(workflow_type: &str, deployed_module: &str) -> Arc<WorkflowCatalog> {
746 let catalog = Arc::new(WorkflowCatalog::new());
747 catalog.note_loaded_workflow_for_test(
748 workflow_type,
749 deployed_module,
750 "run",
751 ContentHash::from_bytes([5; 32]),
752 );
753 catalog
754 }
755
756 fn engine_with_loaded_workflow(
757 store: Arc<dyn EventStore>,
758 workflow_type: &str,
759 deployed_module: &str,
760 ) -> Result<Engine, EngineError> {
761 let runtime = RuntimeHandle::new(RuntimeConfig::new(Some(1)))?;
762 runtime.register_waiting_test_module(deployed_module, "run");
763 let visibility_store: Arc<dyn VisibilityStore> = Arc::new(InMemoryStore::default());
764 Ok(Engine::new(EngineComponents {
765 store,
766 visibility_store,
767 runtime: Arc::new(runtime),
768 catalog: workflow_catalog(workflow_type, deployed_module),
769 registry: Arc::new(Registry::default()),
770 supervision: Arc::new(SupervisionTree::new()),
771 delegated: DelegatedSeams::default(),
772 signal_handoff: Arc::new(crate::signal::SignalResumeHandoff::new()),
773 search_attribute_schema: Arc::new(SearchAttributeSchema::new()),
774 visibility_reconciliation_task: None,
775 }))
776 }
777
778 fn termination_context(engine: &Engine) -> TerminateWorkflowContext<'_> {
779 TerminateWorkflowContext {
780 runtime: engine.runtime(),
781 store: engine.store(),
782 visibility_store: engine.visibility_store(),
783 registry: engine.registry(),
784 }
785 }
786
787 async fn insert_active_handle(
788 engine: &Engine,
789 store: Arc<dyn EventStore>,
790 workflow_type: &str,
791 ) -> Result<WorkflowHandle, Box<dyn std::error::Error>> {
792 let workflow_id = aion_core::WorkflowId::new_v4();
793 let run_id = aion_core::RunId::new_v4();
794 let mut recorder = Recorder::new(workflow_id.clone(), store);
795 recorder
796 .record_workflow_started(
797 chrono::Utc::now(),
798 crate::durability::WorkflowStartRecord {
799 workflow_type: workflow_type.to_owned(),
800 input: payload("input")?,
801 run_id: run_id.clone(),
802 parent_run_id: None,
803 package_version: aion_core::PackageVersion::new("a".repeat(64)),
804 },
805 )
806 .await?;
807 let pid = engine.runtime().spawn_test_process_with_trap_exit(true)?;
808 let handle = WorkflowHandle::new(WorkflowHandleParts {
809 workflow_id: workflow_id.clone(),
810 run_id: run_id.clone(),
811 pid,
812 workflow_type: workflow_type.to_owned(),
813 namespace: String::from("default"),
814 loaded_version: ContentHash::from_bytes([9; 32]),
815 cached_status: WorkflowStatus::Running,
816 residency: HandleResidency::Resident,
817 recorder,
818 completion: CompletionNotifier::new(),
819 });
820 engine
821 .registry()
822 .insert((workflow_id, run_id), handle.clone())?;
823 Ok(handle)
824 }
825
826 #[tokio::test]
827 async fn start_then_cancel_records_started_then_cancelled()
828 -> Result<(), Box<dyn std::error::Error>> {
829 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
830 let engine =
831 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
832 let handle = engine
833 .start_workflow(
834 "checkout",
835 payload("input")?,
836 HashMap::new(),
837 String::from("default"),
838 )
839 .await?;
840
841 engine
842 .cancel(
843 handle.workflow_id(),
844 handle.run_id(),
845 "caller requested cancellation",
846 )
847 .await?;
848
849 let history = store.read_history(handle.workflow_id()).await?;
850 match history.as_slice() {
851 [
852 Event::WorkflowStarted { .. },
853 Event::WorkflowCancelled { reason, .. },
854 ] => {
855 assert_eq!(reason, "caller requested cancellation");
856 }
857 other => return Err(format!("expected started then cancelled, found {other:?}").into()),
858 }
859 engine.shutdown()?;
860 Ok(())
861 }
862
863 fn test_envelope(workflow_id: &WorkflowId, seq: u64) -> EventEnvelope {
864 EventEnvelope {
865 seq,
866 recorded_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap_or_default(),
867 workflow_id: workflow_id.clone(),
868 }
869 }
870
871 fn started_event(workflow_id: &WorkflowId, seq: u64) -> Event {
872 Event::WorkflowStarted {
873 envelope: test_envelope(workflow_id, seq),
874 workflow_type: String::from("checkout"),
875 input: Payload::new(aion_core::ContentType::Json, b"{}".to_vec()),
876 run_id: RunId::new_v4(),
877 parent_run_id: None,
878 package_version: PackageVersion::new("a".repeat(64)),
879 }
880 }
881
882 fn timer_started_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
883 Event::TimerStarted {
884 envelope: test_envelope(workflow_id, seq),
885 timer_id: timer_id.clone(),
886 fire_at: chrono::DateTime::from_timestamp(1_700_000_500, 0).unwrap_or_default(),
887 }
888 }
889
890 fn timer_fired_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
891 Event::TimerFired {
892 envelope: test_envelope(workflow_id, seq),
893 timer_id: timer_id.clone(),
894 }
895 }
896
897 fn timer_cancelled_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
898 Event::TimerCancelled {
899 envelope: test_envelope(workflow_id, seq),
900 timer_id: timer_id.clone(),
901 }
902 }
903
904 #[test]
905 fn live_timers_lists_started_and_unterminated() {
906 let workflow_id = WorkflowId::new_v4();
907 let first = TimerId::anonymous(0);
908 let second = TimerId::anonymous(1);
909 let history = vec![
910 started_event(&workflow_id, 0),
911 timer_started_event(&workflow_id, 1, &first),
912 timer_started_event(&workflow_id, 2, &second),
913 ];
914 assert_eq!(
915 live_timers_in_active_segment(&history),
916 vec![first, second],
917 "both started, unterminated timers should be live, in start order"
918 );
919 }
920
921 #[test]
922 fn live_timers_excludes_fired_and_cancelled() {
923 let workflow_id = WorkflowId::new_v4();
924 let fired = TimerId::anonymous(0);
925 let cancelled = TimerId::anonymous(1);
926 let live = TimerId::anonymous(2);
927 let history = vec![
928 started_event(&workflow_id, 0),
929 timer_started_event(&workflow_id, 1, &fired),
930 timer_started_event(&workflow_id, 2, &cancelled),
931 timer_started_event(&workflow_id, 3, &live),
932 timer_fired_event(&workflow_id, 4, &fired),
933 timer_cancelled_event(&workflow_id, 5, &cancelled),
934 ];
935 assert_eq!(
936 live_timers_in_active_segment(&history),
937 vec![live],
938 "only the timer with no terminal event remains live"
939 );
940 }
941
942 #[test]
943 fn live_timers_dedups_repeated_start() {
944 let workflow_id = WorkflowId::new_v4();
945 let timer = TimerId::anonymous(0);
946 let history = vec![
947 started_event(&workflow_id, 0),
948 timer_started_event(&workflow_id, 1, &timer),
949 timer_started_event(&workflow_id, 2, &timer),
950 ];
951 assert_eq!(live_timers_in_active_segment(&history), vec![timer]);
952 }
953
954 #[test]
955 fn live_timers_scopes_to_active_run_segment() {
956 let workflow_id = WorkflowId::new_v4();
959 let prior_run = TimerId::anonymous(0);
960 let current_run = TimerId::anonymous(0);
961 let history = vec![
962 started_event(&workflow_id, 0),
963 timer_started_event(&workflow_id, 1, &prior_run),
964 started_event(&workflow_id, 2),
965 timer_started_event(&workflow_id, 3, ¤t_run),
966 ];
967 assert_eq!(
968 live_timers_in_active_segment(&history),
969 vec![current_run],
970 "only timers from the latest WorkflowStarted segment are live"
971 );
972 }
973
974 #[test]
975 fn live_timers_empty_history_is_empty() {
976 assert!(live_timers_in_active_segment(&[]).is_empty());
977 }
978
979 fn engine_with_timer_bridge(
984 store: Arc<dyn EventStore>,
985 registry: Arc<Registry>,
986 ) -> Result<Engine, EngineError> {
987 let runtime = RuntimeHandle::new(RuntimeConfig::new(Some(1)))?;
988 runtime.register_waiting_test_module("checkout_deployed", "run");
989 crate::runtime::nif_timer_bridge::install_timer_nif_bridge(
990 runtime.nif_state(),
991 Arc::clone(®istry),
992 Arc::clone(&store),
993 tokio::runtime::Handle::current(),
994 crate::runtime::SignalDeliveryConfig::default(),
995 );
996 let visibility_store: Arc<dyn VisibilityStore> = Arc::new(InMemoryStore::default());
997 Ok(Engine::new(EngineComponents {
998 store,
999 visibility_store,
1000 runtime: Arc::new(runtime),
1001 catalog: workflow_catalog("checkout", "checkout_deployed"),
1002 registry,
1003 supervision: Arc::new(SupervisionTree::new()),
1004 delegated: DelegatedSeams::default(),
1005 signal_handoff: Arc::new(crate::signal::SignalResumeHandoff::new()),
1006 search_attribute_schema: Arc::new(SearchAttributeSchema::new()),
1007 visibility_reconciliation_task: None,
1008 }))
1009 }
1010
1011 #[tokio::test(flavor = "multi_thread")]
1017 async fn cancel_records_timer_cancelled_before_workflow_cancelled()
1018 -> Result<(), Box<dyn std::error::Error>> {
1019 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1020 let registry = Arc::new(Registry::default());
1021 let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(®istry))?;
1022
1023 let handle = engine
1024 .start_workflow(
1025 "checkout",
1026 payload("input")?,
1027 HashMap::new(),
1028 String::from("default"),
1029 )
1030 .await?;
1031
1032 let timer_id = TimerId::anonymous(0);
1035 let fire_at = chrono::Utc::now() + chrono::Duration::hours(1);
1036 handle
1037 .recorder()
1038 .lock()
1039 .await
1040 .record_timer_started(chrono::Utc::now(), timer_id.clone(), fire_at)
1041 .await?;
1042 let timer_service =
1043 crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1044 .map_err(|error| format!("timer service unavailable: {error}"))?;
1045 timer_service
1046 .schedule(handle.workflow_id().clone(), timer_id.clone(), fire_at)
1047 .await?;
1048
1049 engine
1050 .cancel(
1051 handle.workflow_id(),
1052 handle.run_id(),
1053 "caller requested cancellation",
1054 )
1055 .await?;
1056
1057 let history = store.read_history(handle.workflow_id()).await?;
1058 match history.as_slice() {
1059 [
1060 Event::WorkflowStarted { .. },
1061 Event::TimerStarted {
1062 timer_id: started, ..
1063 },
1064 Event::TimerCancelled {
1065 timer_id: cancelled,
1066 ..
1067 },
1068 Event::WorkflowCancelled { reason, .. },
1069 ] => {
1070 assert_eq!(started, &timer_id);
1071 assert_eq!(cancelled, &timer_id, "the live timer must be cancelled");
1072 assert_eq!(reason, "caller requested cancellation");
1073 }
1074 other => {
1075 return Err(format!(
1076 "expected [started, timer-started, timer-cancelled, cancelled], found {other:?}"
1077 )
1078 .into());
1079 }
1080 }
1081 engine.shutdown()?;
1082 Ok(())
1083 }
1084
1085 #[tokio::test(flavor = "multi_thread")]
1088 async fn cancel_cancels_multiple_live_timers() -> Result<(), Box<dyn std::error::Error>> {
1089 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1090 let registry = Arc::new(Registry::default());
1091 let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(®istry))?;
1092 let handle = engine
1093 .start_workflow(
1094 "checkout",
1095 payload("input")?,
1096 HashMap::new(),
1097 String::from("default"),
1098 )
1099 .await?;
1100
1101 let first = TimerId::anonymous(0);
1102 let second = TimerId::anonymous(1);
1103 let fire_at = chrono::Utc::now() + chrono::Duration::hours(1);
1104 {
1105 let recorder = handle.recorder();
1106 let mut recorder = recorder.lock().await;
1107 recorder
1108 .record_timer_started(chrono::Utc::now(), first.clone(), fire_at)
1109 .await?;
1110 recorder
1111 .record_timer_started(chrono::Utc::now(), second.clone(), fire_at)
1112 .await?;
1113 }
1114
1115 engine
1116 .cancel(handle.workflow_id(), handle.run_id(), "stop")
1117 .await?;
1118
1119 let history = store.read_history(handle.workflow_id()).await?;
1120 match history.as_slice() {
1121 [
1122 Event::WorkflowStarted { .. },
1123 Event::TimerStarted {
1124 timer_id: started_first,
1125 ..
1126 },
1127 Event::TimerStarted {
1128 timer_id: started_second,
1129 ..
1130 },
1131 Event::TimerCancelled {
1132 timer_id: cancelled_first,
1133 ..
1134 },
1135 Event::TimerCancelled {
1136 timer_id: cancelled_second,
1137 ..
1138 },
1139 Event::WorkflowCancelled { .. },
1140 ] => {
1141 assert_eq!(started_first, &first);
1142 assert_eq!(started_second, &second);
1143 assert_eq!(cancelled_first, &first, "first live timer cancelled first");
1144 assert_eq!(
1145 cancelled_second, &second,
1146 "second live timer cancelled second"
1147 );
1148 }
1149 other => {
1150 return Err(format!(
1151 "expected two timer-cancels before workflow-cancel, found {other:?}"
1152 )
1153 .into());
1154 }
1155 }
1156 engine.shutdown()?;
1157 Ok(())
1158 }
1159
1160 #[tokio::test(flavor = "multi_thread")]
1167 async fn cancelled_workflow_leaves_no_orphan_for_recovery()
1168 -> Result<(), Box<dyn std::error::Error>> {
1169 let concrete: Arc<InMemoryStore> = Arc::new(InMemoryStore::default());
1170 let store: Arc<dyn EventStore> = concrete.clone();
1171 let registry = Arc::new(Registry::default());
1172 let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(®istry))?;
1173 let handle = engine
1174 .start_workflow(
1175 "checkout",
1176 payload("input")?,
1177 HashMap::new(),
1178 String::from("default"),
1179 )
1180 .await?;
1181 let workflow_id = handle.workflow_id().clone();
1182
1183 let timer_id = TimerId::anonymous(0);
1186 let fire_at = chrono::Utc::now() - chrono::Duration::hours(1);
1187 handle
1188 .recorder()
1189 .lock()
1190 .await
1191 .record_timer_started(chrono::Utc::now(), timer_id.clone(), fire_at)
1192 .await?;
1193 concrete
1194 .schedule_timer(&workflow_id, &timer_id, fire_at)
1195 .await?;
1196
1197 let timer_service =
1198 crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1199 .map_err(|error| format!("timer service unavailable: {error}"))?;
1200
1201 engine.cancel(&workflow_id, handle.run_id(), "stop").await?;
1202
1203 let readable: Arc<dyn ReadableEventStore> = concrete.clone();
1208 TimerRecovery::new(readable, timer_service, Duration::ZERO)
1209 .recover_on_startup(chrono::Utc::now())
1210 .await?;
1211
1212 let history = concrete.read_history(&workflow_id).await?;
1213 assert!(
1214 !history
1215 .iter()
1216 .any(|event| matches!(event, Event::TimerFired { .. })),
1217 "no timer should fire for a cancelled workflow during recovery"
1218 );
1219 assert!(
1220 history
1221 .iter()
1222 .any(|event| matches!(event, Event::TimerCancelled { .. })),
1223 "cancel must have recorded TimerCancelled at the source"
1224 );
1225 engine.shutdown()?;
1226 Ok(())
1227 }
1228
1229 #[tokio::test]
1230 async fn result_returns_completed_payload() -> Result<(), Box<dyn std::error::Error>> {
1231 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1232 let engine =
1233 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1234 let handle = engine
1235 .start_workflow(
1236 "checkout",
1237 payload("input")?,
1238 HashMap::new(),
1239 String::from("default"),
1240 )
1241 .await?;
1242 let result_payload = payload("result")?;
1243
1244 terminate::complete(
1245 termination_context(&engine),
1246 handle.workflow_id(),
1247 handle.run_id(),
1248 result_payload.clone(),
1249 )
1250 .await?;
1251
1252 assert_eq!(
1253 engine.result(handle.workflow_id(), handle.run_id()).await?,
1254 Ok(result_payload)
1255 );
1256 engine.shutdown()?;
1257 Ok(())
1258 }
1259
1260 #[tokio::test]
1261 async fn result_returns_failed_workflow_error() -> Result<(), Box<dyn std::error::Error>> {
1262 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1263 let engine =
1264 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1265 let handle = engine
1266 .start_workflow(
1267 "checkout",
1268 payload("input")?,
1269 HashMap::new(),
1270 String::from("default"),
1271 )
1272 .await?;
1273 let error = workflow_error("workflow failed");
1274
1275 terminate::fail(
1276 termination_context(&engine),
1277 handle.workflow_id(),
1278 handle.run_id(),
1279 error.clone(),
1280 )
1281 .await?;
1282
1283 assert_eq!(
1284 engine.result(handle.workflow_id(), handle.run_id()).await?,
1285 Err(error)
1286 );
1287 engine.shutdown()?;
1288 Ok(())
1289 }
1290
1291 #[tokio::test]
1292 async fn result_unknown_workflow_returns_not_found() -> Result<(), Box<dyn std::error::Error>> {
1293 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1294 let engine = engine_with_loaded_workflow(store, "checkout", "checkout_deployed")?;
1295 let workflow_id = aion_core::WorkflowId::new_v4();
1296 let run_id = aion_core::RunId::new_v4();
1297
1298 let result = engine.result(&workflow_id, &run_id).await;
1299
1300 assert!(matches!(result, Err(EngineError::WorkflowNotFound { .. })));
1301 engine.shutdown()?;
1302 Ok(())
1303 }
1304
1305 #[tokio::test]
1306 async fn continue_as_new_unknown_workflow_returns_not_found()
1307 -> Result<(), Box<dyn std::error::Error>> {
1308 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1309 let engine = engine_with_loaded_workflow(store, "checkout", "checkout_deployed")?;
1310 let workflow_id = aion_core::WorkflowId::new_v4();
1311 let run_id = aion_core::RunId::new_v4();
1312
1313 let result = engine
1314 .continue_as_new(&workflow_id, &run_id, payload("next")?, None)
1315 .await;
1316
1317 assert!(matches!(result, Err(EngineError::WorkflowNotFound { .. })));
1318 engine.shutdown()?;
1319 Ok(())
1320 }
1321
1322 #[tokio::test]
1323 async fn list_workflows_merges_live_and_terminal_without_duplicates()
1324 -> Result<(), Box<dyn std::error::Error>> {
1325 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1326 let engine =
1327 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1328 let running = insert_active_handle(&engine, Arc::clone(&store), "checkout").await?;
1329 let completed = engine
1330 .start_workflow(
1331 "checkout",
1332 payload("input")?,
1333 HashMap::new(),
1334 String::from("default"),
1335 )
1336 .await?;
1337 terminate::complete(
1338 termination_context(&engine),
1339 completed.workflow_id(),
1340 completed.run_id(),
1341 payload("result")?,
1342 )
1343 .await?;
1344
1345 let summaries = engine.list_workflows(WorkflowFilter::default()).await?;
1346 assert_eq!(summaries.len(), 2);
1347 assert!(summaries.iter().any(|summary| {
1348 &summary.workflow_id == running.workflow_id()
1349 && summary.status == WorkflowStatus::Running
1350 }));
1351 assert!(summaries.iter().any(|summary| {
1352 &summary.workflow_id == completed.workflow_id()
1353 && summary.status == WorkflowStatus::Completed
1354 }));
1355
1356 let completed_only = engine
1357 .list_workflows(WorkflowFilter {
1358 status: Some(WorkflowStatus::Completed),
1359 ..WorkflowFilter::default()
1360 })
1361 .await?;
1362 assert_eq!(completed_only.len(), 1);
1363 assert_eq!(&completed_only[0].workflow_id, completed.workflow_id());
1364 engine.shutdown()?;
1365 Ok(())
1366 }
1367
1368 #[tokio::test]
1369 async fn shutdown_rejects_subsequent_starts() -> Result<(), Box<dyn std::error::Error>> {
1370 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1371 let engine =
1372 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1373 let handle = engine
1374 .start_workflow(
1375 "checkout",
1376 payload("input")?,
1377 HashMap::new(),
1378 String::from("default"),
1379 )
1380 .await?;
1381 terminate::complete(
1382 termination_context(&engine),
1383 handle.workflow_id(),
1384 handle.run_id(),
1385 payload("result")?,
1386 )
1387 .await?;
1388
1389 engine.shutdown()?;
1390 let result = engine
1391 .start_workflow(
1392 "checkout",
1393 payload("after-shutdown")?,
1394 HashMap::new(),
1395 String::from("default"),
1396 )
1397 .await;
1398
1399 assert!(matches!(result, Err(EngineError::ShuttingDown)));
1400 Ok(())
1401 }
1402
1403 #[tokio::test]
1404 async fn shutdown_is_idempotent() -> Result<(), Box<dyn std::error::Error>> {
1405 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1406 let engine =
1407 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1408 let handle = engine
1409 .start_workflow(
1410 "checkout",
1411 payload("input")?,
1412 HashMap::new(),
1413 String::from("default"),
1414 )
1415 .await?;
1416 terminate::complete(
1417 termination_context(&engine),
1418 handle.workflow_id(),
1419 handle.run_id(),
1420 payload("result")?,
1421 )
1422 .await?;
1423
1424 engine.shutdown()?;
1425 let second = engine.shutdown();
1426
1427 assert!(
1428 second.is_ok(),
1429 "double shutdown should succeed; got {second:?}"
1430 );
1431 Ok(())
1432 }
1433
1434 #[tokio::test]
1435 async fn shutdown_rejects_schedule_creation() -> Result<(), Box<dyn std::error::Error>> {
1436 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1437 let engine =
1438 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1439 let handle = engine
1440 .start_workflow(
1441 "checkout",
1442 payload("input")?,
1443 HashMap::new(),
1444 String::from("default"),
1445 )
1446 .await?;
1447 terminate::complete(
1448 termination_context(&engine),
1449 handle.workflow_id(),
1450 handle.run_id(),
1451 payload("result")?,
1452 )
1453 .await?;
1454 engine.shutdown()?;
1455
1456 let config = aion_core::ScheduleConfig {
1457 trigger: aion_core::TriggerSpec::Interval {
1458 period: Duration::from_secs(60),
1459 },
1460 overlap_policy: aion_core::OverlapPolicy::Skip,
1461 catch_up_policy: aion_core::CatchUpPolicy::Skip,
1462 workflow_type: String::from("checkout"),
1463 input: payload("scheduled")?,
1464 search_attributes: HashMap::new(),
1465 };
1466 let result = engine.create_schedule(config).await;
1467
1468 assert!(
1469 matches!(result, Err(EngineError::ShuttingDown)),
1470 "create_schedule after shutdown should return ShuttingDown; got {result:?}"
1471 );
1472 Ok(())
1473 }
1474}