1use std::{num::NonZeroUsize, path::PathBuf, sync::Arc, time::Duration};
4
5use chrono::Utc;
6
7use aion_core::SearchAttributeSchema;
8use aion_package::Package;
9use aion_store::visibility::VisibilityStore;
10use aion_store::{EventStore, InMemoryStore};
11
12use crate::{
13 ActivityServing, EngineError, Registry, RuntimeConfig, RuntimeHandle, SignalDeliveryConfig,
14 SupervisionTree,
15 activity::bridge::ActivityDispatcher,
16 durability::ActiveWorkflowRecoverySeam,
17 runtime::{NifEntry, NifRegistration},
18 signal::SignalResumeHandoff,
19};
20
21use super::api::{Engine, EngineComponents};
22use super::builder_assembly::{
23 ChildBridgeAssembly, assemble_startup_catalog, assemble_workloop_runtime, claim_owned_shards,
24 install_engine_nif_seams, install_workflow_nif_bridges, reserved_search_attribute_schema,
25 spawn_visibility_reconciliation,
26};
27use super::delegated::{DelegatedSeams, EventPublisher, QueryService, SignalRouter};
28use super::seams::{
29 SeamAssembly, SignalRouterFactory, assemble_delegated_seams, wrap_event_streaming,
30};
31use super::startup::{StartupRecoveryContext, resolve_startup_recovery};
32
33#[derive(Clone, Debug)]
36pub enum WorkflowPackageSource {
37 Path(PathBuf),
39 Package(Box<Package>),
41}
42
43impl From<Package> for WorkflowPackageSource {
44 fn from(package: Package) -> Self {
45 Self::Package(Box::new(package))
46 }
47}
48
49impl From<PathBuf> for WorkflowPackageSource {
50 fn from(path: PathBuf) -> Self {
51 Self::Path(path)
52 }
53}
54
55impl From<&std::path::Path> for WorkflowPackageSource {
56 fn from(path: &std::path::Path) -> Self {
57 Self::Path(path.to_path_buf())
58 }
59}
60
61impl From<&str> for WorkflowPackageSource {
62 fn from(path: &str) -> Self {
63 Self::Path(PathBuf::from(path))
64 }
65}
66
67impl From<String> for WorkflowPackageSource {
68 fn from(path: String) -> Self {
69 Self::Path(PathBuf::from(path))
70 }
71}
72
73#[derive(Default)]
78struct SeamOverrides {
79 event_publisher: bool,
81 query_service: bool,
83}
84
85pub struct EngineBuilder {
87 store: Option<Arc<dyn EventStore>>,
88 visibility_store: Option<Arc<dyn VisibilityStore>>,
89 scheduler_threads: Option<usize>,
90 scheduler_jit_threshold: Option<u32>,
91 signal_delivery: SignalDeliveryConfig,
92 completion_retry: crate::runtime::CompletionRetryConfig,
93 stop_drain_timeout: Option<Duration>,
94 outbox_enabled: bool,
95 bootstrap_schedule_coordinator: bool,
96 owned_shards: Option<Vec<usize>>,
97 workflow_sources: Vec<WorkflowPackageSource>,
98 host_nifs: Vec<NifEntry>,
99 recovery: Option<Arc<dyn ActiveWorkflowRecoverySeam>>,
100 delegated: DelegatedSeams,
101 signal_router_factory: Option<SignalRouterFactory>,
102 activity_dispatcher: Option<Arc<dyn ActivityDispatcher>>,
103 activity_serving: ActivityServing,
104 active_registry: Option<Arc<Registry>>,
105 visibility_reconciliation_interval: Option<Duration>,
106 search_attribute_schema: SearchAttributeSchema,
107 event_streaming_capacity: Option<NonZeroUsize>,
108 query_timeout: Option<Duration>,
109 seam_overrides: SeamOverrides,
110 defer_startup_recovery: bool,
111 workloop: Option<(Arc<dyn aion_store::workloop::WorkloopStore>, Duration)>,
112}
113
114impl Default for EngineBuilder {
115 fn default() -> Self {
116 Self::new()
117 }
118}
119
120impl EngineBuilder {
121 #[must_use]
124 pub fn new() -> Self {
125 Self {
126 store: None,
127 visibility_store: None,
128 scheduler_threads: None,
129 scheduler_jit_threshold: None,
130 signal_delivery: SignalDeliveryConfig::default(),
131 completion_retry: crate::runtime::CompletionRetryConfig::default(),
132 stop_drain_timeout: None,
133 outbox_enabled: false,
134 bootstrap_schedule_coordinator: true,
138 owned_shards: None,
142 workflow_sources: Vec::new(),
143 host_nifs: Vec::new(),
144 recovery: None,
145 delegated: DelegatedSeams::default(),
146 signal_router_factory: None,
147 activity_dispatcher: None,
148 activity_serving: ActivityServing::QueueRouted,
149 active_registry: None,
150 visibility_reconciliation_interval: None,
151 search_attribute_schema: SearchAttributeSchema::new(),
152 event_streaming_capacity: None,
153 query_timeout: None,
154 seam_overrides: SeamOverrides::default(),
155 defer_startup_recovery: false,
156 workloop: None,
157 }
158 }
159
160 #[must_use]
166 pub fn with_workloop_service(
167 mut self,
168 store: Arc<dyn aion_store::workloop::WorkloopStore>,
169 sweep_interval: Duration,
170 ) -> Self {
171 self.workloop = Some((store, sweep_interval));
172 self
173 }
174
175 #[must_use]
183 pub const fn query_timeout(mut self, timeout: Duration) -> Self {
184 self.query_timeout = Some(timeout);
185 self
186 }
187
188 #[must_use]
190 pub const fn configured_query_timeout(&self) -> Option<Duration> {
191 self.query_timeout
192 }
193
194 #[must_use]
204 pub const fn event_streaming(mut self, capacity: NonZeroUsize) -> Self {
205 self.event_streaming_capacity = Some(capacity);
206 self
207 }
208
209 #[must_use]
215 pub fn search_attribute_schema(mut self, schema: SearchAttributeSchema) -> Self {
216 self.search_attribute_schema = schema;
217 self
218 }
219
220 #[must_use]
222 pub fn store<S>(mut self, store: S) -> Self
223 where
224 S: EventStore,
225 {
226 self.store = Some(Arc::new(store));
227 self
228 }
229
230 #[must_use]
232 pub fn store_arc(mut self, store: Arc<dyn EventStore>) -> Self {
233 self.store = Some(store);
234 self
235 }
236
237 #[must_use]
239 pub fn visibility_store<S>(mut self, visibility_store: S) -> Self
240 where
241 S: VisibilityStore,
242 {
243 self.visibility_store = Some(Arc::new(visibility_store));
244 self
245 }
246
247 #[must_use]
249 pub fn visibility_store_arc(mut self, visibility_store: Arc<dyn VisibilityStore>) -> Self {
250 self.visibility_store = Some(visibility_store);
251 self
252 }
253
254 #[must_use]
260 pub fn in_memory_visibility(mut self) -> Self {
261 self.visibility_store = Some(Arc::new(InMemoryStore::default()));
262 self
263 }
264
265 #[must_use]
269 pub const fn scheduler_threads(mut self, threads: usize) -> Self {
270 self.scheduler_threads = Some(threads);
271 self
272 }
273
274 #[must_use]
281 pub const fn scheduler_jit_threshold(mut self, threshold: u32) -> Self {
282 self.scheduler_jit_threshold = Some(threshold);
283 self
284 }
285
286 #[must_use]
290 pub const fn visibility_reconciliation_interval(mut self, interval: Duration) -> Self {
291 self.visibility_reconciliation_interval = Some(interval);
292 self
293 }
294
295 #[must_use]
302 pub const fn stop_drain_timeout(mut self, bound: Duration) -> Self {
303 self.stop_drain_timeout = Some(bound);
304 self
305 }
306
307 #[must_use]
309 pub const fn signal_delivery(mut self, signal_delivery: SignalDeliveryConfig) -> Self {
310 self.signal_delivery = signal_delivery;
311 self
312 }
313
314 #[must_use]
321 pub const fn completion_retry(
322 mut self,
323 completion_retry: crate::runtime::CompletionRetryConfig,
324 ) -> Self {
325 self.completion_retry = completion_retry;
326 self
327 }
328
329 #[must_use]
331 pub fn outbox_enabled(mut self, enabled: bool) -> Self {
332 self.outbox_enabled = enabled;
333 self
334 }
335
336 #[must_use]
344 pub const fn bootstrap_schedule_coordinator(mut self, enabled: bool) -> Self {
345 self.bootstrap_schedule_coordinator = enabled;
346 self
347 }
348
349 #[must_use]
364 pub fn owned_shards(mut self, shards: impl IntoIterator<Item = usize>) -> Self {
365 self.owned_shards = Some(shards.into_iter().collect());
366 self
367 }
368
369 #[must_use]
371 pub fn configured_owned_shards(&self) -> Option<&[usize]> {
372 self.owned_shards.as_deref()
373 }
374
375 #[must_use]
377 pub fn load_workflows(mut self, source: impl Into<WorkflowPackageSource>) -> Self {
378 self.workflow_sources.push(source.into());
379 self
380 }
381
382 #[must_use]
384 pub fn load_workflow_sources<I, S>(mut self, sources: I) -> Self
385 where
386 I: IntoIterator<Item = S>,
387 S: Into<WorkflowPackageSource>,
388 {
389 self.workflow_sources
390 .extend(sources.into_iter().map(Into::into));
391 self
392 }
393
394 #[must_use]
396 pub fn register_nifs(mut self, entries: impl IntoIterator<Item = NifEntry>) -> Self {
397 self.host_nifs.extend(entries);
398 self
399 }
400
401 #[must_use]
403 pub fn recovery_seam(mut self, recovery: Arc<dyn ActiveWorkflowRecoverySeam>) -> Self {
404 self.recovery = Some(recovery);
405 self
406 }
407
408 #[must_use]
410 pub fn production_recovery_seam(mut self) -> Self {
411 self.recovery = None;
412 self
413 }
414
415 #[must_use]
426 pub fn defer_startup_recovery(mut self) -> Self {
427 self.defer_startup_recovery = true;
428 self
429 }
430
431 #[must_use]
433 pub fn signal_router(mut self, signal_router: Arc<dyn SignalRouter>) -> Self {
434 self.signal_router_factory = None;
435 self.delegated = DelegatedSeams::new(
436 signal_router,
437 self.delegated.query_service_arc(),
438 self.delegated.event_publisher_arc(),
439 );
440 self
441 }
442
443 #[must_use]
445 pub fn signal_router_factory<F>(mut self, factory: F) -> Self
446 where
447 F: Fn(Arc<RuntimeHandle>, Arc<SignalResumeHandoff>) -> Arc<dyn SignalRouter>
448 + Send
449 + Sync
450 + 'static,
451 {
452 self.signal_router_factory = Some(Arc::new(factory));
453 self
454 }
455
456 #[must_use]
461 pub fn query_service(mut self, query_service: Arc<dyn QueryService>) -> Self {
462 self.seam_overrides.query_service = true;
463 self.delegated = DelegatedSeams::new(
464 self.delegated.signal_router_arc(),
465 query_service,
466 self.delegated.event_publisher_arc(),
467 );
468 self
469 }
470
471 #[must_use]
476 pub fn event_publisher(mut self, event_publisher: Arc<dyn EventPublisher>) -> Self {
477 self.seam_overrides.event_publisher = true;
478 self.delegated = DelegatedSeams::new(
479 self.delegated.signal_router_arc(),
480 self.delegated.query_service_arc(),
481 event_publisher,
482 );
483 self
484 }
485
486 #[must_use]
492 pub fn activity_dispatcher(mut self, dispatcher: Arc<dyn ActivityDispatcher>) -> Self {
493 self.activity_dispatcher = Some(dispatcher);
494 self
495 }
496
497 #[must_use]
509 pub const fn in_process_activity_serving(mut self) -> Self {
510 self.activity_serving = ActivityServing::InProcess;
511 self
512 }
513
514 #[must_use]
519 pub fn active_registry(mut self, registry: Arc<Registry>) -> Self {
520 self.active_registry = Some(registry);
521 self
522 }
523
524 #[must_use]
526 pub const fn scheduler_thread_count(&self) -> Option<usize> {
527 self.scheduler_threads
528 }
529
530 #[must_use]
532 pub const fn configured_jit_threshold(&self) -> Option<u32> {
533 self.scheduler_jit_threshold
534 }
535
536 #[must_use]
538 pub const fn configured_visibility_reconciliation_interval(&self) -> Option<Duration> {
539 self.visibility_reconciliation_interval
540 }
541
542 fn runtime_config(&self) -> Result<RuntimeConfig, EngineError> {
545 let stop_drain_timeout = self
546 .stop_drain_timeout
547 .ok_or(EngineError::MissingStopDrainTimeout)?;
548 Ok(
549 RuntimeConfig::new(self.scheduler_threads, stop_drain_timeout)
550 .with_jit_threshold(self.scheduler_jit_threshold)
551 .with_signal_delivery(self.signal_delivery)
552 .with_completion_retry(self.completion_retry)
553 .with_outbox_enabled(self.outbox_enabled),
554 )
555 }
556
557 fn start_runtime_with_nifs(
559 runtime_config: RuntimeConfig,
560 host_nifs: Vec<NifEntry>,
561 ) -> Result<Arc<RuntimeHandle>, EngineError> {
562 let runtime = Arc::new(RuntimeHandle::new(runtime_config)?);
563 let mut nifs = NifRegistration::new();
564 nifs.add_engine_nifs().add_host_nifs(host_nifs);
565 runtime.install_nifs(nifs)?;
566 Ok(runtime)
567 }
568
569 pub async fn build(self) -> Result<Engine, EngineError> {
577 let runtime_config = self.runtime_config()?;
578 let (store, streaming_publisher) = wrap_event_streaming(
579 self.store.ok_or(EngineError::MissingStore)?,
580 self.event_streaming_capacity,
581 self.seam_overrides.event_publisher,
582 )?;
583 let visibility_store = self
584 .visibility_store
585 .ok_or(EngineError::MissingVisibilityStore)?;
586 claim_owned_shards(store.as_ref(), self.owned_shards.as_deref())?;
587
588 let runtime = Self::start_runtime_with_nifs(runtime_config, self.host_nifs)?;
589
590 let catalog = assemble_startup_catalog(
594 runtime.as_ref(),
595 store.as_ref(),
596 self.workflow_sources,
597 self.activity_serving,
598 )
599 .await?;
600
601 let registry = self
602 .active_registry
603 .unwrap_or_else(|| Arc::new(Registry::default()));
604 let nif_state = Arc::clone(runtime.nif_state());
605 let query_mailbox_engine = install_engine_nif_seams(
606 &nif_state,
607 ®istry,
608 &store,
609 &runtime,
610 self.activity_dispatcher,
611 self.query_timeout,
612 );
613 let supervision = Arc::new(SupervisionTree::new());
614 let search_attribute_schema =
615 reserved_search_attribute_schema(self.search_attribute_schema)?;
616 let signal_handoff = Arc::new(SignalResumeHandoff::new());
617
618 let delegated = assemble_delegated_seams(SeamAssembly {
619 configured: self.delegated,
620 signal_router_factory: self.signal_router_factory,
621 runtime: Arc::clone(&runtime),
622 signal_handoff: Arc::clone(&signal_handoff),
623 streaming_publisher,
624 query_mailbox_engine,
625 query_timeout: self.query_timeout,
626 query_service_overridden: self.seam_overrides.query_service,
627 });
628
629 let bridge_assembly = ChildBridgeAssembly {
636 nif_state: &nif_state,
637 store: &store,
638 visibility_store: &visibility_store,
639 runtime: &runtime,
640 catalog: &catalog,
641 registry: ®istry,
642 supervision: &supervision,
643 signal_handoff: &signal_handoff,
644 search_attribute_schema: &search_attribute_schema,
645 watch_backoff: self.signal_delivery,
646 };
647 install_workflow_nif_bridges(&bridge_assembly, &delegated)?;
648
649 let deferred_startup_recovery = resolve_startup_recovery(
653 self.defer_startup_recovery,
654 &nif_state,
655 StartupRecoveryContext {
656 store: Arc::clone(&store),
657 visibility_store: Arc::clone(&visibility_store),
658 runtime: Arc::clone(&runtime),
659 catalog: Arc::clone(&catalog),
660 registry: Arc::clone(®istry),
661 supervision: Arc::clone(&supervision),
662 recovery: self.recovery,
663 search_attribute_schema: Arc::clone(&search_attribute_schema),
664 bootstrap_schedule_coordinator: self.bootstrap_schedule_coordinator,
665 },
666 )
667 .await?;
668
669 let visibility_reconciliation_task = Some(spawn_visibility_reconciliation(
670 self.visibility_reconciliation_interval,
671 &store,
672 &visibility_store,
673 ));
674
675 let workloop = Self::assemble_workloops(self.workloop, &bridge_assembly, &store).await?;
676
677 let deferred = deferred_startup_recovery.is_some();
678 let engine = Engine::new(EngineComponents {
679 store,
680 visibility_store,
681 runtime,
682 catalog,
683 registry,
684 supervision,
685 delegated,
686 signal_handoff,
687 search_attribute_schema,
688 visibility_reconciliation_task,
689 deferred_startup_recovery,
690 workloop,
691 });
692 if !deferred {
693 Self::recover_engine_schedules(&engine).await?;
694 }
695 Ok(engine)
696 }
697
698 async fn assemble_workloops(
715 configured: Option<(Arc<dyn aion_store::workloop::WorkloopStore>, Duration)>,
716 bridge_assembly: &ChildBridgeAssembly<'_>,
717 store: &Arc<dyn EventStore>,
718 ) -> Result<Option<super::api_workloop::WorkloopEngineRuntime>, EngineError> {
719 if let Some((workloop_store, _)) = configured.as_ref() {
720 crate::workloop::service::withdraw_unstarted_registrations(workloop_store, store)
721 .await
722 .map_err(EngineError::from)?;
723 }
724 assemble_workloop_runtime(configured, bridge_assembly)
725 }
726
727 async fn recover_engine_schedules(engine: &Engine) -> Result<(), EngineError> {
730 engine.catchup_schedule_coordinator().await?;
731 engine.recover_schedules_on_startup(Utc::now()).await?;
732 Ok(())
733 }
734}
735
736#[cfg(test)]
737mod tests {
738 use super::super::gleam_test_support;
754
755 use std::{num::NonZeroUsize, path::PathBuf, process::Command, sync::Arc, time::Duration};
756
757 use aion_core::{Event, EventEnvelope, Payload, WorkflowId, WorkflowStatus};
758 use aion_package::{
759 BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, ExtractionLimits, Manifest,
760 ManifestVersion, Package, PackageBuilder,
761 };
762 use aion_store::visibility::VisibilityStore;
763 use aion_store::{InMemoryStore, ReadableEventStore, WritableEventStore, WriteToken};
764 use chrono::Utc;
765 use futures::StreamExt;
766 use serde_json::json;
767
768 use crate::engine::api_schedule::{
769 schedule_coordinator_run_id, schedule_coordinator_workflow_id,
770 schedule_coordinator_workflow_type,
771 };
772 use crate::runtime::{Determinism, Mfa, NifEntry};
773
774 use super::EngineBuilder;
775 use crate::EngineError;
776
777 fn payload() -> Result<Payload, aion_core::PayloadError> {
778 Payload::from_json(&json!({ "input": true }))
779 }
780
781 fn started(
782 workflow_id: &WorkflowId,
783 workflow_type: &str,
784 ) -> Result<Event, aion_core::PayloadError> {
785 Ok(Event::WorkflowStarted {
786 envelope: EventEnvelope {
787 seq: 1,
788 recorded_at: Utc::now(),
789 workflow_id: workflow_id.clone(),
790 },
791 workflow_type: workflow_type.to_owned(),
792 input: payload()?,
793 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
794 parent_run_id: None,
795 parent_workflow_id: None,
796 package_version: aion_core::PackageVersion::new("a".repeat(64)),
797 })
798 }
799
800 fn completed(workflow_id: &WorkflowId) -> Result<Event, aion_core::PayloadError> {
801 Ok(Event::WorkflowCompleted {
802 envelope: EventEnvelope {
803 seq: 2,
804 recorded_at: Utc::now(),
805 workflow_id: workflow_id.clone(),
806 },
807 result: payload()?,
808 })
809 }
810
811 fn package_manifest() -> Manifest {
812 Manifest {
813 entry_module: "counter".to_owned(),
814 entry_function: "version".to_owned(),
815 input_schema: json!({ "type": "object" }),
816 output_schema: json!({ "type": "integer" }),
817 timeout: Some(Duration::from_secs(30)),
818 activities: vec![DeclaredActivity {
819 activity_type: "activity/test".to_owned(),
820 }],
821 version: ManifestVersion::new("test"),
822 format_version: CURRENT_FORMAT_VERSION,
823 additional_workflows: Vec::new(),
824 }
825 }
826
827 fn compile_counter_beam() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
828 let temp_dir =
829 std::env::temp_dir().join(format!("aion-engine-builder-{}", uuid::Uuid::new_v4()));
830 std::fs::create_dir(&temp_dir)?;
831 let source_path = temp_dir.join("counter.erl");
832 let beam_path = temp_dir.join("counter.beam");
833 std::fs::write(
834 &source_path,
835 "-module(counter).\n-export([version/0]).\nversion() -> 1.\n",
836 )?;
837 let status = Command::new("erlc")
838 .arg("-o")
839 .arg(&temp_dir)
840 .arg(&source_path)
841 .status()?;
842 if !status.success() {
843 let cleanup_result = std::fs::remove_dir_all(&temp_dir);
844 drop(cleanup_result);
845 return Err(format!("erlc failed with status {status}").into());
846 }
847 let bytes = std::fs::read(beam_path)?;
848 std::fs::remove_dir_all(temp_dir)?;
849 Ok(bytes)
850 }
851
852 fn fixture_package() -> Result<Package, Box<dyn std::error::Error>> {
853 let beams = BeamSet::new(vec![BeamModule::new("counter", compile_counter_beam()?)])?;
854 let archive = PackageBuilder::new(package_manifest(), beams).write_to_bytes()?;
855 Ok(Package::load_from_bytes(
856 archive,
857 ExtractionLimits::unbounded(),
858 )?)
859 }
860
861 fn write_fixture_package(package: &Package) -> Result<PathBuf, Box<dyn std::error::Error>> {
862 let path =
863 std::env::temp_dir().join(format!("aion-engine-builder-{}.aion", uuid::Uuid::new_v4()));
864 PackageBuilder::new(package.manifest().clone(), package.beams().clone())
865 .write_to_path(&path)?;
866 Ok(path)
867 }
868
869 #[tokio::test]
870 async fn build_without_store_returns_missing_store() {
871 let error = EngineBuilder::new()
872 .stop_drain_timeout(std::time::Duration::from_secs(5))
873 .build()
874 .await
875 .err();
876
877 assert!(matches!(error, Some(EngineError::MissingStore)));
878 }
879
880 #[tokio::test]
881 async fn build_without_visibility_store_returns_missing_visibility_store() {
882 let error = EngineBuilder::new()
883 .stop_drain_timeout(std::time::Duration::from_secs(5))
884 .store(InMemoryStore::default())
885 .build()
886 .await
887 .err();
888
889 assert!(matches!(error, Some(EngineError::MissingVisibilityStore)));
890 }
891
892 #[tokio::test]
893 async fn in_memory_visibility_allows_build_without_visibility_store() -> Result<(), EngineError>
894 {
895 let engine = EngineBuilder::new()
896 .stop_drain_timeout(std::time::Duration::from_secs(5))
897 .store(InMemoryStore::default())
898 .in_memory_visibility()
899 .build()
900 .await?;
901
902 engine.shutdown()?;
903 Ok(())
904 }
905
906 #[tokio::test]
940 async fn dropping_an_engine_without_shutdown_closes_the_completion_retry_epoch()
941 -> Result<(), EngineError> {
942 let visibility = Arc::new(TickCountingVisibilityStore::default());
943 let engine = EngineBuilder::new()
944 .stop_drain_timeout(std::time::Duration::from_secs(5))
945 .store(InMemoryStore::default())
946 .visibility_store_arc(Arc::clone(&visibility) as Arc<dyn VisibilityStore>)
947 .visibility_reconciliation_interval(Duration::from_millis(10))
948 .build()
949 .await?;
950 let tasks = engine.runtime().engine_tasks();
954 assert!(
955 tasks.is_epoch_open(),
956 "control: a live engine's epoch must be open, or the assertion below is trivially \
957 satisfied"
958 );
959
960 let ticking = tokio::time::timeout(Duration::from_secs(10), async {
964 while visibility.ticks() < 2 {
965 tokio::time::sleep(Duration::from_millis(5)).await;
966 }
967 })
968 .await;
969 assert!(
970 ticking.is_ok(),
971 "control: the periodic reconciliation loop must be running before the engine is \
972 dropped, or this test measures nothing; it reached {} ticks",
973 visibility.ticks()
974 );
975
976 drop(engine);
977
978 assert!(
979 !tasks.is_epoch_open(),
980 "an engine released without an explicit shutdown must still close the epoch, or a \
981 completion retry can append a terminal for a run this process no longer owns"
982 );
983
984 tokio::time::sleep(Duration::from_millis(200)).await;
988 let after_abort = visibility.ticks();
989 tokio::time::sleep(Duration::from_millis(200)).await;
990 assert_eq!(
991 visibility.ticks(),
992 after_abort,
993 "an engine released without an explicit shutdown must also stop the visibility \
994 reconciliation loop; it ticked again over twenty intervals after the drop, which \
995 means a detached task is still WRITING to a visibility store this process no longer \
996 owns"
997 );
998 Ok(())
999 }
1000
1001 const WHEEL_TIMER_ARMING_MARGIN: std::time::Duration = std::time::Duration::from_secs(2);
1012
1013 const WHEEL_TIMER_OBSERVATION_SLACK: std::time::Duration = std::time::Duration::from_secs(1);
1017
1018 async fn arm_resident_run_with_wheel_timer(
1036 engine: &crate::Engine,
1037 store: &Arc<InMemoryStore>,
1038 ) -> Result<(WorkflowId, chrono::DateTime<Utc>), Box<dyn std::error::Error>> {
1039 use aion_store::EventStore;
1040
1041 use crate::durability::{Recorder, WorkflowStartRecord};
1042 use crate::registry::{
1043 CompletionNotifier, HandleResidency, WorkflowHandle, WorkflowHandleParts,
1044 };
1045
1046 let workflow_id = WorkflowId::new_v4();
1050 let run_id = aion_core::RunId::new_v4();
1051 let timer_id = aion_core::TimerId::named("sleep")?;
1052 let mut recorder = Recorder::new(
1053 workflow_id.clone(),
1054 Arc::clone(store) as Arc<dyn EventStore>,
1055 );
1056 recorder
1057 .record_workflow_started(
1058 Utc::now(),
1059 WorkflowStartRecord {
1060 workflow_type: "checkout".to_owned(),
1061 input: payload()?,
1062 run_id: run_id.clone(),
1063 parent_run_id: None,
1064 parent_workflow_id: None,
1065 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1066 },
1067 )
1068 .await?;
1069 recorder
1070 .record_timer_started(Utc::now(), timer_id.clone(), Utc::now())
1071 .await?;
1072 engine.registry().insert(
1073 (workflow_id.clone(), run_id.clone()),
1074 WorkflowHandle::new(WorkflowHandleParts {
1075 workflow_id: workflow_id.clone(),
1076 run_id,
1077 pid: 1,
1078 workflow_type: "checkout".to_owned(),
1079 namespace: String::from("default"),
1080 loaded_version: aion_package::ContentHash::from_bytes([9; 32]),
1081 cached_status: WorkflowStatus::Running,
1082 residency: HandleResidency::Resident,
1083 recorder,
1084 completion: CompletionNotifier::new(),
1085 }),
1086 )?;
1087
1088 let fire_at = Utc::now() + chrono::Duration::from_std(WHEEL_TIMER_ARMING_MARGIN)?;
1089 crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1090 .map_err(|error| format!("the engine installed no timer service: {error}"))?
1091 .schedule(workflow_id.clone(), timer_id, fire_at, 1)
1092 .await?;
1093 Ok((workflow_id, fire_at))
1094 }
1095
1096 #[tokio::test]
1120 async fn dropping_an_engine_without_shutdown_disarms_the_live_timer_wheel()
1121 -> Result<(), Box<dyn std::error::Error>> {
1122 use aion_store::EventStore;
1123
1124 let store = Arc::new(InMemoryStore::default());
1125 let engine = EngineBuilder::new()
1126 .stop_drain_timeout(std::time::Duration::from_secs(5))
1127 .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1128 .visibility_store(InMemoryStore::default())
1129 .build()
1130 .await?;
1131 let nif_state = Arc::clone(engine.runtime().nif_state());
1133 let (workflow_id, fire_at) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1134 assert_eq!(
1135 nif_state.armed_wheel_timers(),
1136 1,
1137 "control: the fixture must have actually armed a wheel timer, or the assertions below \
1138 are satisfied by an engine that never had one — the exact way the sibling test above \
1139 is blind to this half of `Drop`"
1140 );
1141
1142 let before_drop = Utc::now();
1150 assert!(
1151 before_drop < fire_at,
1152 "fixture: the arming window closed before the release was reached ({before_drop} is \
1153 not before {fire_at}). This is a FIXTURE timing failure — most likely a loaded box — \
1154 and NOT evidence about cancellation. Widen `WHEEL_TIMER_ARMING_MARGIN`; do not read \
1155 this as `Drop` leaking a task."
1156 );
1157 let armed = store.read_history(&workflow_id).await?;
1158 assert!(
1159 !armed
1160 .iter()
1161 .any(|event| matches!(event, Event::TimerFired { .. })),
1162 "fixture: the timer already fired before the engine was released, so this run cannot \
1163 say anything about cancellation. Same remedy as above — widen the margin: {armed:#?}"
1164 );
1165
1166 drop(engine);
1167
1168 assert_eq!(
1169 nif_state.armed_wheel_timers(),
1170 0,
1171 "an engine released without an explicit shutdown must empty its live timer wheel"
1172 );
1173
1174 tokio::time::sleep(WHEEL_TIMER_ARMING_MARGIN + WHEEL_TIMER_OBSERVATION_SLACK).await;
1185 let history = store.read_history(&workflow_id).await?;
1186 assert!(
1187 !history
1188 .iter()
1189 .any(|event| matches!(event, Event::TimerFired { .. })),
1190 "an engine released without an explicit shutdown must CANCEL its wheel tasks, not \
1191 merely forget them: a surviving task records `TimerFired` for a run this process no \
1192 longer owns, which is a second writer for one workflow: {history:#?}"
1193 );
1194 Ok(())
1195 }
1196
1197 #[tokio::test]
1220 async fn arming_a_wheel_timer_after_the_engine_is_released_is_refused()
1221 -> Result<(), Box<dyn std::error::Error>> {
1222 use aion_store::EventStore;
1223
1224 let store = Arc::new(InMemoryStore::default());
1225 let engine = EngineBuilder::new()
1226 .stop_drain_timeout(std::time::Duration::from_secs(5))
1227 .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1228 .visibility_store(InMemoryStore::default())
1229 .build()
1230 .await?;
1231 let nif_state = Arc::clone(engine.runtime().nif_state());
1232 let (workflow_id, _fire_at) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1235 assert_eq!(
1236 nif_state.armed_wheel_timers(),
1237 1,
1238 "control: the fixture must have armed a wheel timer through the production path, or \
1239 the refusal below is satisfied by a fixture that could never arm one"
1240 );
1241
1242 drop(engine);
1243
1244 assert_eq!(nif_state.armed_wheel_timers(), 0);
1247
1248 let refused = crate::runtime::nif_timer_bridge::installed_timer_service(&nif_state)
1249 .map_err(|error| format!("the released engine's timer seam is gone: {error}"))?
1250 .schedule(
1251 workflow_id,
1252 aion_core::TimerId::named("sleep-after-release")?,
1253 Utc::now() + chrono::Duration::from_std(WHEEL_TIMER_ARMING_MARGIN)?,
1254 1,
1255 )
1256 .await;
1257 let Err(error) = refused else {
1258 return Err(
1259 "arming a wheel timer through a released engine must be REFUSED: the task \
1260 it spawns appends a durable `TimerFired` for a run this process no longer \
1261 owns, which is a second writer for one workflow"
1262 .into(),
1263 );
1264 };
1265 assert!(
1266 error.to_string().contains("torn down"),
1267 "the refusal must say WHY, so an operator reading it is not sent looking for a \
1268 missing workflow or a bad timer id: {error}"
1269 );
1270 assert_eq!(
1271 nif_state.armed_wheel_timers(),
1272 0,
1273 "the refused arm must leave nothing behind: a wheel entry inserted before the refusal \
1274 would be a durable writer with no owner to cancel it"
1275 );
1276 Ok(())
1277 }
1278
1279 #[tokio::test]
1288 async fn a_live_wheel_timer_fires_when_the_engine_is_not_released()
1289 -> Result<(), Box<dyn std::error::Error>> {
1290 use aion_store::EventStore;
1291
1292 let store = Arc::new(InMemoryStore::default());
1293 let engine = EngineBuilder::new()
1294 .stop_drain_timeout(std::time::Duration::from_secs(5))
1295 .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1296 .visibility_store(InMemoryStore::default())
1297 .build()
1298 .await?;
1299 let (workflow_id, _) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1300
1301 tokio::time::sleep(WHEEL_TIMER_ARMING_MARGIN + WHEEL_TIMER_OBSERVATION_SLACK).await;
1302
1303 let history = store.read_history(&workflow_id).await?;
1304 assert!(
1305 history
1306 .iter()
1307 .any(|event| matches!(event, Event::TimerFired { .. })),
1308 "the fixture's wheel timer must reach the store when the engine is still held, or \
1309 the sibling test's absence proves nothing: {history:#?}"
1310 );
1311 drop(engine);
1312 Ok(())
1313 }
1314
1315 #[derive(Default)]
1325 struct TickCountingVisibilityStore {
1326 inner: InMemoryStore,
1327 get_calls: std::sync::atomic::AtomicUsize,
1328 }
1329
1330 impl TickCountingVisibilityStore {
1331 fn ticks(&self) -> usize {
1332 self.get_calls.load(std::sync::atomic::Ordering::Acquire)
1333 }
1334 }
1335
1336 #[async_trait::async_trait]
1337 impl VisibilityStore for TickCountingVisibilityStore {
1338 async fn record_visibility(
1339 &self,
1340 record: aion_store::visibility::VisibilityRecord,
1341 ) -> Result<(), aion_store::StoreError> {
1342 self.inner.record_visibility(record).await
1343 }
1344
1345 async fn get_visibility(
1346 &self,
1347 workflow_id: &WorkflowId,
1348 ) -> Result<Option<aion_store::visibility::VisibilityRecord>, aion_store::StoreError>
1349 {
1350 self.get_calls
1351 .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
1352 self.inner.get_visibility(workflow_id).await
1353 }
1354
1355 async fn remove_visibility(
1356 &self,
1357 workflow_id: &WorkflowId,
1358 run_id: &aion_core::RunId,
1359 ) -> Result<bool, aion_store::StoreError> {
1360 self.inner.remove_visibility(workflow_id, run_id).await
1361 }
1362
1363 async fn list_workflows(
1364 &self,
1365 request: &aion_core::WorkflowListRequest,
1366 ) -> Result<aion_store::visibility::VisibilityPage, aion_store::StoreError> {
1367 self.inner.list_workflows(request).await
1368 }
1369 }
1370
1371 fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
1372 NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
1373 }
1374
1375 #[tokio::test]
1376 async fn event_streaming_delivers_recorder_appends_through_engine_subscribe()
1377 -> Result<(), Box<dyn std::error::Error>> {
1378 let engine = EngineBuilder::new()
1379 .stop_drain_timeout(std::time::Duration::from_secs(5))
1380 .store(InMemoryStore::default())
1381 .in_memory_visibility()
1382 .event_streaming(capacity(8)?)
1383 .build()
1384 .await?;
1385 let workflow_id = WorkflowId::new_v4();
1386 let mut subscription = engine.subscribe(crate::EventFilter {
1387 workflow_id: Some(workflow_id.clone()),
1388 run: None,
1389 family: None,
1390 });
1391
1392 let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1395 recorder
1396 .record_workflow_started(
1397 Utc::now(),
1398 crate::durability::WorkflowStartRecord {
1399 workflow_type: "checkout".to_owned(),
1400 input: payload()?,
1401 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(7)),
1402 parent_run_id: None,
1403 parent_workflow_id: None,
1404 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1405 },
1406 )
1407 .await?;
1408
1409 let item = tokio::time::timeout(Duration::from_secs(2), subscription.next())
1410 .await?
1411 .ok_or("subscription ended without delivering the appended event")?;
1412 let event = item?;
1413 assert_eq!(event.workflow_id(), &workflow_id);
1414 assert_eq!(event.seq(), 1);
1415 assert!(matches!(event, Event::WorkflowStarted { .. }));
1416 engine.shutdown()?;
1417 Ok(())
1418 }
1419
1420 #[tokio::test]
1421 async fn without_event_streaming_subscriptions_stay_on_deferred_empty_stream()
1422 -> Result<(), Box<dyn std::error::Error>> {
1423 let engine = EngineBuilder::new()
1424 .stop_drain_timeout(std::time::Duration::from_secs(5))
1425 .store(InMemoryStore::default())
1426 .in_memory_visibility()
1427 .build()
1428 .await?;
1429
1430 let mut subscription = engine.subscribe(crate::EventFilter::default());
1431 let item = tokio::time::timeout(Duration::from_secs(2), subscription.next()).await?;
1432
1433 assert!(item.is_none(), "deferred publisher streams must be empty");
1434 engine.shutdown()?;
1435 Ok(())
1436 }
1437
1438 #[tokio::test]
1439 async fn event_streaming_conflicts_with_explicit_event_publisher()
1440 -> Result<(), Box<dyn std::error::Error>> {
1441 let error = EngineBuilder::new()
1442 .stop_drain_timeout(std::time::Duration::from_secs(5))
1443 .store(InMemoryStore::default())
1444 .in_memory_visibility()
1445 .event_publisher(Arc::new(crate::DeferredEventPublisher))
1446 .event_streaming(capacity(8)?)
1447 .build()
1448 .await
1449 .err();
1450
1451 assert!(matches!(
1452 error,
1453 Some(EngineError::ConflictingEventPublisher)
1454 ));
1455 Ok(())
1456 }
1457
1458 #[test]
1459 fn query_timeout_is_only_set_by_caller() {
1460 assert_eq!(
1461 EngineBuilder::new()
1462 .stop_drain_timeout(std::time::Duration::from_secs(5))
1463 .configured_query_timeout(),
1464 None
1465 );
1466 assert_eq!(
1467 EngineBuilder::new()
1468 .stop_drain_timeout(std::time::Duration::from_secs(5))
1469 .query_timeout(Duration::from_secs(3))
1470 .configured_query_timeout(),
1471 Some(Duration::from_secs(3))
1472 );
1473 }
1474
1475 async fn insert_running_workflow(
1476 engine: &crate::Engine,
1477 ) -> Result<(WorkflowId, aion_core::RunId), Box<dyn std::error::Error>> {
1478 let workflow_id = WorkflowId::new_v4();
1479 let run_id = aion_core::RunId::new_v4();
1480 let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1481 recorder
1482 .record_workflow_started(
1483 Utc::now(),
1484 crate::durability::WorkflowStartRecord {
1485 workflow_type: "checkout".to_owned(),
1486 input: payload()?,
1487 run_id: run_id.clone(),
1488 parent_run_id: None,
1489 parent_workflow_id: None,
1490 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1491 },
1492 )
1493 .await?;
1494 let handle = crate::registry::WorkflowHandle::new(crate::registry::WorkflowHandleParts {
1495 workflow_id: workflow_id.clone(),
1496 run_id: run_id.clone(),
1497 pid: engine.runtime().spawn_test_process_with_trap_exit(true)?,
1498 workflow_type: "checkout".to_owned(),
1499 namespace: String::from("default"),
1500 loaded_version: aion_package::ContentHash::from_bytes([2; 32]),
1501 cached_status: WorkflowStatus::Running,
1502 residency: crate::registry::HandleResidency::Resident,
1503 recorder,
1504 completion: crate::registry::CompletionNotifier::new(),
1505 });
1506 engine
1507 .registry()
1508 .insert((workflow_id.clone(), run_id.clone()), handle)?;
1509 Ok((workflow_id, run_id))
1510 }
1511
1512 #[tokio::test]
1513 async fn query_timeout_installs_the_concrete_query_seam()
1514 -> Result<(), Box<dyn std::error::Error>> {
1515 let engine = EngineBuilder::new()
1516 .stop_drain_timeout(std::time::Duration::from_secs(5))
1517 .store(InMemoryStore::default())
1518 .in_memory_visibility()
1519 .query_timeout(Duration::from_millis(250))
1520 .build()
1521 .await?;
1522 let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1523
1524 let result = engine
1528 .query(
1529 &workflow_id,
1530 &run_id,
1531 "state",
1532 aion_core::Payload::json_null(),
1533 )
1534 .await;
1535
1536 assert!(matches!(
1537 result,
1538 Err(crate::EngineError::Query(crate::QueryError::UnknownQuery(name))) if name == "state"
1539 ));
1540 engine.shutdown()?;
1541 Ok(())
1542 }
1543
1544 #[tokio::test]
1545 async fn without_query_timeout_the_query_seam_stays_deferred()
1546 -> Result<(), Box<dyn std::error::Error>> {
1547 let engine = EngineBuilder::new()
1548 .stop_drain_timeout(std::time::Duration::from_secs(5))
1549 .store(InMemoryStore::default())
1550 .in_memory_visibility()
1551 .build()
1552 .await?;
1553 let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1554
1555 let result = engine
1556 .query(
1557 &workflow_id,
1558 &run_id,
1559 "state",
1560 aion_core::Payload::json_null(),
1561 )
1562 .await;
1563
1564 assert!(matches!(
1565 result,
1566 Err(crate::EngineError::Runtime { reason }) if reason.contains("not configured")
1567 ));
1568 engine.shutdown()?;
1569 Ok(())
1570 }
1571
1572 #[test]
1573 fn owned_shards_are_only_set_by_caller() {
1574 assert_eq!(
1578 EngineBuilder::new()
1579 .stop_drain_timeout(std::time::Duration::from_secs(5))
1580 .configured_owned_shards(),
1581 None
1582 );
1583 assert_eq!(
1584 EngineBuilder::new()
1585 .stop_drain_timeout(std::time::Duration::from_secs(5))
1586 .owned_shards([2, 0, 2, 1])
1587 .configured_owned_shards(),
1588 Some([2, 0, 2, 1].as_slice())
1589 );
1590 }
1591
1592 #[test]
1593 fn scheduler_threads_are_only_set_by_caller() {
1594 assert_eq!(
1595 EngineBuilder::new()
1596 .stop_drain_timeout(std::time::Duration::from_secs(5))
1597 .scheduler_thread_count(),
1598 None
1599 );
1600 assert_eq!(
1601 EngineBuilder::new()
1602 .stop_drain_timeout(std::time::Duration::from_secs(5))
1603 .scheduler_threads(4)
1604 .scheduler_thread_count(),
1605 Some(4)
1606 );
1607 }
1608
1609 #[test]
1623 fn the_completion_retry_ladder_reaches_the_runtime_configuration()
1624 -> Result<(), Box<dyn std::error::Error>> {
1625 let chosen = crate::runtime::CompletionRetryConfig::try_new(
1626 Duration::from_millis(250),
1627 Duration::from_secs(7),
1628 )?;
1629 assert_ne!(
1630 chosen,
1631 crate::runtime::CompletionRetryConfig::default(),
1632 "a ladder equal to the default could not distinguish wiring from inheritance"
1633 );
1634
1635 assert_eq!(
1636 EngineBuilder::new()
1637 .stop_drain_timeout(std::time::Duration::from_secs(5))
1638 .completion_retry(chosen)
1639 .runtime_config()?
1640 .completion_retry,
1641 chosen,
1642 "the runtime must be started with the operator's ladder, not the inherited default"
1643 );
1644 Ok(())
1645 }
1646
1647 #[test]
1669 fn the_jit_threshold_reaches_the_runtime_configuration() -> Result<(), EngineError> {
1670 assert_eq!(
1671 EngineBuilder::new()
1672 .stop_drain_timeout(std::time::Duration::from_secs(5))
1673 .runtime_config()?
1674 .jit_threshold,
1675 None,
1676 "an unset builder must pass None through, so beamr applies its own default rather \
1677 than a value aion invented"
1678 );
1679
1680 assert_eq!(
1681 EngineBuilder::new()
1682 .stop_drain_timeout(std::time::Duration::from_secs(5))
1683 .scheduler_jit_threshold(100)
1684 .runtime_config()?
1685 .jit_threshold,
1686 Some(100),
1687 "the scheduler must be started with the operator's threshold, not beamr's default — \
1688 otherwise a proportionality run reports on the default while claiming to vary it"
1689 );
1690 Ok(())
1691 }
1692
1693 #[test]
1698 fn the_stop_drain_bound_is_required_and_reaches_the_runtime_configuration()
1699 -> Result<(), EngineError> {
1700 assert!(matches!(
1701 EngineBuilder::new().runtime_config(),
1702 Err(EngineError::MissingStopDrainTimeout)
1703 ));
1704 assert_eq!(
1705 EngineBuilder::new()
1706 .stop_drain_timeout(std::time::Duration::from_millis(1234))
1707 .runtime_config()?
1708 .stop_drain_timeout,
1709 std::time::Duration::from_millis(1234)
1710 );
1711 Ok(())
1712 }
1713
1714 #[test]
1715 fn visibility_reconciliation_interval_is_only_set_by_caller() {
1716 let interval = Duration::from_millis(250);
1717
1718 assert_eq!(
1719 EngineBuilder::new()
1720 .stop_drain_timeout(std::time::Duration::from_secs(5))
1721 .configured_visibility_reconciliation_interval(),
1722 None
1723 );
1724 assert_eq!(
1725 EngineBuilder::new()
1726 .stop_drain_timeout(std::time::Duration::from_secs(5))
1727 .visibility_reconciliation_interval(interval)
1728 .configured_visibility_reconciliation_interval(),
1729 Some(interval)
1730 );
1731 }
1732
1733 #[tokio::test]
1734 async fn duplicate_host_nif_mfa_returns_typed_error() {
1735 let mfa = Mfa::new("host", "zero", 0);
1736 let error = EngineBuilder::new()
1737 .stop_drain_timeout(std::time::Duration::from_secs(5))
1738 .store(InMemoryStore::default())
1739 .in_memory_visibility()
1740 .register_nifs([
1741 NifEntry::new(
1742 mfa.clone(),
1743 crate::runtime::nif::test_native_zero,
1744 Determinism::Pure,
1745 ),
1746 NifEntry::dirty(
1747 mfa,
1748 crate::runtime::nif::test_native_zero,
1749 Determinism::Pure,
1750 ),
1751 ])
1752 .build()
1753 .await
1754 .err();
1755
1756 assert!(matches!(
1757 error,
1758 Some(EngineError::NifRegistration { reason }) if reason.contains("host:zero/0")
1759 ));
1760 }
1761
1762 #[tokio::test]
1763 async fn empty_store_builds_coordinator_history_without_registry_or_supervision()
1764 -> Result<(), EngineError> {
1765 let store = Arc::new(InMemoryStore::default());
1766 let engine = EngineBuilder::new()
1767 .stop_drain_timeout(std::time::Duration::from_secs(5))
1768 .store_arc(store.clone())
1769 .in_memory_visibility()
1770 .build()
1771 .await?;
1772
1773 assert!(engine.registry().list()?.is_empty());
1774 assert_eq!(engine.supervision().type_supervisor_count()?, 1);
1775 assert_eq!(engine.workflow_catalog().workflows()?.len(), 0);
1776
1777 let coordinator_id = schedule_coordinator_workflow_id();
1778 let active = store.list_active().await?;
1779 assert_eq!(active, vec![coordinator_id.clone()]);
1780 let history = store.read_history(&coordinator_id).await?;
1781 let [started] = history.as_slice() else {
1782 return Err(EngineError::Load {
1783 reason: format!(
1784 "expected exactly one coordinator event, found {}",
1785 history.len()
1786 ),
1787 });
1788 };
1789 match started {
1790 Event::WorkflowStarted {
1791 workflow_type,
1792 input,
1793 run_id,
1794 parent_run_id,
1795 ..
1796 } => {
1797 assert_eq!(workflow_type, schedule_coordinator_workflow_type());
1798 assert_eq!(
1799 input,
1800 &Payload::from_json(&json!({})).map_err(|error| {
1801 EngineError::Load {
1802 reason: format!("failed to build expected payload: {error}"),
1803 }
1804 })?
1805 );
1806 assert_eq!(run_id, &schedule_coordinator_run_id());
1807 assert!(parent_run_id.is_none());
1808 }
1809 other => {
1810 return Err(EngineError::Load {
1811 reason: format!("expected coordinator WorkflowStarted, found {other:?}"),
1812 });
1813 }
1814 }
1815
1816 engine.shutdown()?;
1817 let rebuilt = EngineBuilder::new()
1818 .stop_drain_timeout(std::time::Duration::from_secs(5))
1819 .store_arc(store.clone())
1820 .in_memory_visibility()
1821 .build()
1822 .await?;
1823 let rebuilt_history = store.read_history(&coordinator_id).await?;
1824 assert_eq!(rebuilt_history.len(), 1);
1825 rebuilt.shutdown()?;
1826
1827 Ok(())
1828 }
1829
1830 #[tokio::test]
1831 async fn build_loads_already_loaded_package() -> Result<(), Box<dyn std::error::Error>> {
1832 if gleam_test_support::skip_if_unavailable() {
1833 return Ok(());
1834 }
1835 let package = fixture_package()?;
1836 let version = package.content_hash().clone();
1837 let deployed_entry_module = package.deployed_entry_module();
1838
1839 let engine = EngineBuilder::new()
1840 .stop_drain_timeout(std::time::Duration::from_secs(5))
1841 .store(InMemoryStore::default())
1842 .in_memory_visibility()
1843 .load_workflows(package)
1844 .build()
1845 .await?;
1846
1847 let loaded = engine
1848 .workflow_catalog()
1849 .get("counter", &version)?
1850 .ok_or("loaded package record missing")?;
1851 assert_eq!(loaded.deployed_entry_module(), deployed_entry_module);
1852 assert!(
1853 engine
1854 .runtime()
1855 .has_registered_module(&deployed_entry_module)
1856 );
1857 Ok(())
1858 }
1859
1860 #[tokio::test]
1861 async fn startup_reconciliation_backfills_completed_visibility()
1862 -> Result<(), Box<dyn std::error::Error>> {
1863 let store = Arc::new(InMemoryStore::default());
1864 let completed_id = WorkflowId::new_v4();
1865
1866 store
1867 .append(
1868 WriteToken::recorder(),
1869 &completed_id,
1870 &[
1871 started(&completed_id, "billing")?,
1872 completed(&completed_id)?,
1873 ],
1874 0,
1875 )
1876 .await?;
1877
1878 let engine = EngineBuilder::new()
1879 .stop_drain_timeout(std::time::Duration::from_secs(5))
1880 .store_arc(store.clone())
1881 .visibility_store_arc(store.clone())
1882 .build()
1883 .await?;
1884
1885 let completed_row = store
1886 .get_visibility(&completed_id)
1887 .await?
1888 .ok_or("completed workflow missing from visibility")?;
1889
1890 assert_eq!(completed_row.status, WorkflowStatus::Completed);
1891 assert!(completed_row.ended_at.is_some());
1892 engine.shutdown()?;
1893 Ok(())
1894 }
1895
1896 #[tokio::test]
1897 async fn periodic_visibility_reconciliation_repairs_gap_after_startup()
1898 -> Result<(), Box<dyn std::error::Error>> {
1899 let store = Arc::new(InMemoryStore::default());
1900 let engine = EngineBuilder::new()
1901 .stop_drain_timeout(std::time::Duration::from_secs(5))
1902 .store_arc(store.clone())
1903 .visibility_store_arc(store.clone())
1904 .visibility_reconciliation_interval(Duration::from_millis(25))
1905 .build()
1906 .await?;
1907 let workflow_id = WorkflowId::new_v4();
1908
1909 store
1910 .append(
1911 WriteToken::recorder(),
1912 &workflow_id,
1913 &[started(&workflow_id, "checkout")?],
1914 0,
1915 )
1916 .await?;
1917
1918 tokio::time::timeout(Duration::from_secs(2), async {
1919 loop {
1920 if let Some(row) = store.get_visibility(&workflow_id).await?
1921 && row.status == WorkflowStatus::Running
1922 {
1923 return Ok::<(), aion_store::StoreError>(());
1924 }
1925 tokio::time::sleep(Duration::from_millis(10)).await;
1926 }
1927 })
1928 .await??;
1929
1930 engine.shutdown()?;
1931 Ok(())
1932 }
1933
1934 #[tokio::test]
1935 async fn build_loads_package_from_path() -> Result<(), Box<dyn std::error::Error>> {
1936 if gleam_test_support::skip_if_unavailable() {
1937 return Ok(());
1938 }
1939 let package = fixture_package()?;
1940 let version = package.content_hash().clone();
1941 let path = write_fixture_package(&package)?;
1942
1943 let engine = EngineBuilder::new()
1944 .stop_drain_timeout(std::time::Duration::from_secs(5))
1945 .store(InMemoryStore::default())
1946 .in_memory_visibility()
1947 .load_workflows(path.as_path())
1948 .build()
1949 .await?;
1950 std::fs::remove_file(path)?;
1951
1952 assert!(
1953 engine
1954 .workflow_catalog()
1955 .get("counter", &version)?
1956 .is_some()
1957 );
1958 Ok(())
1959 }
1960}