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, maybe_spawn_visibility_reconciliation,
25 reserved_search_attribute_schema,
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 outbox_enabled: bool,
94 bootstrap_schedule_coordinator: bool,
95 owned_shards: Option<Vec<usize>>,
96 workflow_sources: Vec<WorkflowPackageSource>,
97 host_nifs: Vec<NifEntry>,
98 recovery: Option<Arc<dyn ActiveWorkflowRecoverySeam>>,
99 delegated: DelegatedSeams,
100 signal_router_factory: Option<SignalRouterFactory>,
101 activity_dispatcher: Option<Arc<dyn ActivityDispatcher>>,
102 activity_serving: ActivityServing,
103 active_registry: Option<Arc<Registry>>,
104 visibility_reconciliation_interval: Option<Duration>,
105 search_attribute_schema: SearchAttributeSchema,
106 event_streaming_capacity: Option<NonZeroUsize>,
107 query_timeout: Option<Duration>,
108 seam_overrides: SeamOverrides,
109 defer_startup_recovery: bool,
110 workloop: Option<(Arc<dyn aion_store::workloop::WorkloopStore>, Duration)>,
111}
112
113impl Default for EngineBuilder {
114 fn default() -> Self {
115 Self::new()
116 }
117}
118
119impl EngineBuilder {
120 #[must_use]
123 pub fn new() -> Self {
124 Self {
125 store: None,
126 visibility_store: None,
127 scheduler_threads: None,
128 scheduler_jit_threshold: None,
129 signal_delivery: SignalDeliveryConfig::default(),
130 completion_retry: crate::runtime::CompletionRetryConfig::default(),
131 outbox_enabled: false,
132 bootstrap_schedule_coordinator: true,
136 owned_shards: None,
140 workflow_sources: Vec::new(),
141 host_nifs: Vec::new(),
142 recovery: None,
143 delegated: DelegatedSeams::default(),
144 signal_router_factory: None,
145 activity_dispatcher: None,
146 activity_serving: ActivityServing::QueueRouted,
147 active_registry: None,
148 visibility_reconciliation_interval: None,
149 search_attribute_schema: SearchAttributeSchema::new(),
150 event_streaming_capacity: None,
151 query_timeout: None,
152 seam_overrides: SeamOverrides::default(),
153 defer_startup_recovery: false,
154 workloop: None,
155 }
156 }
157
158 #[must_use]
164 pub fn with_workloop_service(
165 mut self,
166 store: Arc<dyn aion_store::workloop::WorkloopStore>,
167 sweep_interval: Duration,
168 ) -> Self {
169 self.workloop = Some((store, sweep_interval));
170 self
171 }
172
173 #[must_use]
181 pub const fn query_timeout(mut self, timeout: Duration) -> Self {
182 self.query_timeout = Some(timeout);
183 self
184 }
185
186 #[must_use]
188 pub const fn configured_query_timeout(&self) -> Option<Duration> {
189 self.query_timeout
190 }
191
192 #[must_use]
202 pub const fn event_streaming(mut self, capacity: NonZeroUsize) -> Self {
203 self.event_streaming_capacity = Some(capacity);
204 self
205 }
206
207 #[must_use]
213 pub fn search_attribute_schema(mut self, schema: SearchAttributeSchema) -> Self {
214 self.search_attribute_schema = schema;
215 self
216 }
217
218 #[must_use]
220 pub fn store<S>(mut self, store: S) -> Self
221 where
222 S: EventStore,
223 {
224 self.store = Some(Arc::new(store));
225 self
226 }
227
228 #[must_use]
230 pub fn store_arc(mut self, store: Arc<dyn EventStore>) -> Self {
231 self.store = Some(store);
232 self
233 }
234
235 #[must_use]
237 pub fn visibility_store<S>(mut self, visibility_store: S) -> Self
238 where
239 S: VisibilityStore,
240 {
241 self.visibility_store = Some(Arc::new(visibility_store));
242 self
243 }
244
245 #[must_use]
247 pub fn visibility_store_arc(mut self, visibility_store: Arc<dyn VisibilityStore>) -> Self {
248 self.visibility_store = Some(visibility_store);
249 self
250 }
251
252 #[must_use]
258 pub fn in_memory_visibility(mut self) -> Self {
259 self.visibility_store = Some(Arc::new(InMemoryStore::default()));
260 self
261 }
262
263 #[must_use]
267 pub const fn scheduler_threads(mut self, threads: usize) -> Self {
268 self.scheduler_threads = Some(threads);
269 self
270 }
271
272 #[must_use]
279 pub const fn scheduler_jit_threshold(mut self, threshold: u32) -> Self {
280 self.scheduler_jit_threshold = Some(threshold);
281 self
282 }
283
284 #[must_use]
288 pub const fn visibility_reconciliation_interval(mut self, interval: Duration) -> Self {
289 self.visibility_reconciliation_interval = Some(interval);
290 self
291 }
292
293 #[must_use]
295 pub const fn signal_delivery(mut self, signal_delivery: SignalDeliveryConfig) -> Self {
296 self.signal_delivery = signal_delivery;
297 self
298 }
299
300 #[must_use]
307 pub const fn completion_retry(
308 mut self,
309 completion_retry: crate::runtime::CompletionRetryConfig,
310 ) -> Self {
311 self.completion_retry = completion_retry;
312 self
313 }
314
315 #[must_use]
317 pub fn outbox_enabled(mut self, enabled: bool) -> Self {
318 self.outbox_enabled = enabled;
319 self
320 }
321
322 #[must_use]
330 pub const fn bootstrap_schedule_coordinator(mut self, enabled: bool) -> Self {
331 self.bootstrap_schedule_coordinator = enabled;
332 self
333 }
334
335 #[must_use]
350 pub fn owned_shards(mut self, shards: impl IntoIterator<Item = usize>) -> Self {
351 self.owned_shards = Some(shards.into_iter().collect());
352 self
353 }
354
355 #[must_use]
357 pub fn configured_owned_shards(&self) -> Option<&[usize]> {
358 self.owned_shards.as_deref()
359 }
360
361 #[must_use]
363 pub fn load_workflows(mut self, source: impl Into<WorkflowPackageSource>) -> Self {
364 self.workflow_sources.push(source.into());
365 self
366 }
367
368 #[must_use]
370 pub fn load_workflow_sources<I, S>(mut self, sources: I) -> Self
371 where
372 I: IntoIterator<Item = S>,
373 S: Into<WorkflowPackageSource>,
374 {
375 self.workflow_sources
376 .extend(sources.into_iter().map(Into::into));
377 self
378 }
379
380 #[must_use]
382 pub fn register_nifs(mut self, entries: impl IntoIterator<Item = NifEntry>) -> Self {
383 self.host_nifs.extend(entries);
384 self
385 }
386
387 #[must_use]
389 pub fn recovery_seam(mut self, recovery: Arc<dyn ActiveWorkflowRecoverySeam>) -> Self {
390 self.recovery = Some(recovery);
391 self
392 }
393
394 #[must_use]
396 pub fn production_recovery_seam(mut self) -> Self {
397 self.recovery = None;
398 self
399 }
400
401 #[must_use]
412 pub fn defer_startup_recovery(mut self) -> Self {
413 self.defer_startup_recovery = true;
414 self
415 }
416
417 #[must_use]
419 pub fn signal_router(mut self, signal_router: Arc<dyn SignalRouter>) -> Self {
420 self.signal_router_factory = None;
421 self.delegated = DelegatedSeams::new(
422 signal_router,
423 self.delegated.query_service_arc(),
424 self.delegated.event_publisher_arc(),
425 );
426 self
427 }
428
429 #[must_use]
431 pub fn signal_router_factory<F>(mut self, factory: F) -> Self
432 where
433 F: Fn(Arc<RuntimeHandle>, Arc<SignalResumeHandoff>) -> Arc<dyn SignalRouter>
434 + Send
435 + Sync
436 + 'static,
437 {
438 self.signal_router_factory = Some(Arc::new(factory));
439 self
440 }
441
442 #[must_use]
447 pub fn query_service(mut self, query_service: Arc<dyn QueryService>) -> Self {
448 self.seam_overrides.query_service = true;
449 self.delegated = DelegatedSeams::new(
450 self.delegated.signal_router_arc(),
451 query_service,
452 self.delegated.event_publisher_arc(),
453 );
454 self
455 }
456
457 #[must_use]
462 pub fn event_publisher(mut self, event_publisher: Arc<dyn EventPublisher>) -> Self {
463 self.seam_overrides.event_publisher = true;
464 self.delegated = DelegatedSeams::new(
465 self.delegated.signal_router_arc(),
466 self.delegated.query_service_arc(),
467 event_publisher,
468 );
469 self
470 }
471
472 #[must_use]
478 pub fn activity_dispatcher(mut self, dispatcher: Arc<dyn ActivityDispatcher>) -> Self {
479 self.activity_dispatcher = Some(dispatcher);
480 self
481 }
482
483 #[must_use]
495 pub const fn in_process_activity_serving(mut self) -> Self {
496 self.activity_serving = ActivityServing::InProcess;
497 self
498 }
499
500 #[must_use]
505 pub fn active_registry(mut self, registry: Arc<Registry>) -> Self {
506 self.active_registry = Some(registry);
507 self
508 }
509
510 #[must_use]
512 pub const fn scheduler_thread_count(&self) -> Option<usize> {
513 self.scheduler_threads
514 }
515
516 #[must_use]
518 pub const fn configured_jit_threshold(&self) -> Option<u32> {
519 self.scheduler_jit_threshold
520 }
521
522 #[must_use]
524 pub const fn configured_visibility_reconciliation_interval(&self) -> Option<Duration> {
525 self.visibility_reconciliation_interval
526 }
527
528 fn runtime_config(&self) -> RuntimeConfig {
531 RuntimeConfig::new(self.scheduler_threads)
532 .with_jit_threshold(self.scheduler_jit_threshold)
533 .with_signal_delivery(self.signal_delivery)
534 .with_completion_retry(self.completion_retry)
535 .with_outbox_enabled(self.outbox_enabled)
536 }
537
538 fn start_runtime_with_nifs(
540 runtime_config: RuntimeConfig,
541 host_nifs: Vec<NifEntry>,
542 ) -> Result<Arc<RuntimeHandle>, EngineError> {
543 let runtime = Arc::new(RuntimeHandle::new(runtime_config)?);
544 let mut nifs = NifRegistration::new();
545 nifs.add_engine_nifs().add_host_nifs(host_nifs);
546 runtime.install_nifs(nifs)?;
547 Ok(runtime)
548 }
549
550 pub async fn build(self) -> Result<Engine, EngineError> {
558 let runtime_config = self.runtime_config();
559 let (store, streaming_publisher) = wrap_event_streaming(
560 self.store.ok_or(EngineError::MissingStore)?,
561 self.event_streaming_capacity,
562 self.seam_overrides.event_publisher,
563 )?;
564 let visibility_store = self
565 .visibility_store
566 .ok_or(EngineError::MissingVisibilityStore)?;
567 claim_owned_shards(store.as_ref(), self.owned_shards.as_deref())?;
568
569 let runtime = Self::start_runtime_with_nifs(runtime_config, self.host_nifs)?;
570
571 let catalog = assemble_startup_catalog(
575 runtime.as_ref(),
576 store.as_ref(),
577 self.workflow_sources,
578 self.activity_serving,
579 )
580 .await?;
581
582 let registry = self
583 .active_registry
584 .unwrap_or_else(|| Arc::new(Registry::default()));
585 let nif_state = Arc::clone(runtime.nif_state());
586 let query_mailbox_engine = install_engine_nif_seams(
587 &nif_state,
588 ®istry,
589 &store,
590 &runtime,
591 self.activity_dispatcher,
592 self.query_timeout,
593 );
594 let supervision = Arc::new(SupervisionTree::new());
595 let search_attribute_schema =
596 reserved_search_attribute_schema(self.search_attribute_schema)?;
597 let signal_handoff = Arc::new(SignalResumeHandoff::new());
598
599 let delegated = assemble_delegated_seams(SeamAssembly {
600 configured: self.delegated,
601 signal_router_factory: self.signal_router_factory,
602 runtime: Arc::clone(&runtime),
603 signal_handoff: Arc::clone(&signal_handoff),
604 streaming_publisher,
605 query_mailbox_engine,
606 query_timeout: self.query_timeout,
607 query_service_overridden: self.seam_overrides.query_service,
608 });
609
610 let bridge_assembly = ChildBridgeAssembly {
617 nif_state: &nif_state,
618 store: &store,
619 visibility_store: &visibility_store,
620 runtime: &runtime,
621 catalog: &catalog,
622 registry: ®istry,
623 supervision: &supervision,
624 signal_handoff: &signal_handoff,
625 search_attribute_schema: &search_attribute_schema,
626 watch_backoff: self.signal_delivery,
627 };
628 install_workflow_nif_bridges(&bridge_assembly, &delegated)?;
629
630 let deferred_startup_recovery = resolve_startup_recovery(
634 self.defer_startup_recovery,
635 &nif_state,
636 StartupRecoveryContext {
637 store: Arc::clone(&store),
638 visibility_store: Arc::clone(&visibility_store),
639 runtime: Arc::clone(&runtime),
640 catalog: Arc::clone(&catalog),
641 registry: Arc::clone(®istry),
642 supervision: Arc::clone(&supervision),
643 recovery: self.recovery,
644 search_attribute_schema: Arc::clone(&search_attribute_schema),
645 bootstrap_schedule_coordinator: self.bootstrap_schedule_coordinator,
646 },
647 )
648 .await?;
649
650 let visibility_reconciliation_task = maybe_spawn_visibility_reconciliation(
651 self.visibility_reconciliation_interval,
652 &store,
653 &visibility_store,
654 );
655
656 let workloop = Self::assemble_workloops(self.workloop, &bridge_assembly, &store).await?;
657
658 let deferred = deferred_startup_recovery.is_some();
659 let engine = Engine::new(EngineComponents {
660 store,
661 visibility_store,
662 runtime,
663 catalog,
664 registry,
665 supervision,
666 delegated,
667 signal_handoff,
668 search_attribute_schema,
669 visibility_reconciliation_task,
670 deferred_startup_recovery,
671 workloop,
672 });
673 if !deferred {
674 Self::recover_engine_schedules(&engine).await?;
675 }
676 Ok(engine)
677 }
678
679 async fn assemble_workloops(
696 configured: Option<(Arc<dyn aion_store::workloop::WorkloopStore>, Duration)>,
697 bridge_assembly: &ChildBridgeAssembly<'_>,
698 store: &Arc<dyn EventStore>,
699 ) -> Result<Option<super::api_workloop::WorkloopEngineRuntime>, EngineError> {
700 if let Some((workloop_store, _)) = configured.as_ref() {
701 crate::workloop::service::withdraw_unstarted_registrations(workloop_store, store)
702 .await
703 .map_err(EngineError::from)?;
704 }
705 assemble_workloop_runtime(configured, bridge_assembly)
706 }
707
708 async fn recover_engine_schedules(engine: &Engine) -> Result<(), EngineError> {
711 engine.catchup_schedule_coordinator().await?;
712 engine.recover_schedules_on_startup(Utc::now()).await?;
713 Ok(())
714 }
715}
716
717#[cfg(test)]
718mod tests {
719 use super::super::gleam_test_support;
735
736 use std::{num::NonZeroUsize, path::PathBuf, process::Command, sync::Arc, time::Duration};
737
738 use aion_core::{Event, EventEnvelope, Payload, WorkflowId, WorkflowStatus};
739 use aion_package::{
740 BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, ExtractionLimits, Manifest,
741 ManifestVersion, Package, PackageBuilder,
742 };
743 use aion_store::visibility::{ListWorkflowsFilter, VisibilityStore};
744 use aion_store::{InMemoryStore, ReadableEventStore, WritableEventStore, WriteToken};
745 use chrono::Utc;
746 use futures::StreamExt;
747 use serde_json::json;
748
749 use crate::engine::api_schedule::{
750 schedule_coordinator_run_id, schedule_coordinator_workflow_id,
751 schedule_coordinator_workflow_type,
752 };
753 use crate::runtime::{Determinism, Mfa, NifEntry};
754
755 use super::EngineBuilder;
756 use crate::EngineError;
757
758 fn payload() -> Result<Payload, aion_core::PayloadError> {
759 Payload::from_json(&json!({ "input": true }))
760 }
761
762 fn started(
763 workflow_id: &WorkflowId,
764 workflow_type: &str,
765 ) -> Result<Event, aion_core::PayloadError> {
766 Ok(Event::WorkflowStarted {
767 envelope: EventEnvelope {
768 seq: 1,
769 recorded_at: Utc::now(),
770 workflow_id: workflow_id.clone(),
771 },
772 workflow_type: workflow_type.to_owned(),
773 input: payload()?,
774 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
775 parent_run_id: None,
776 parent_workflow_id: None,
777 package_version: aion_core::PackageVersion::new("a".repeat(64)),
778 })
779 }
780
781 fn completed(workflow_id: &WorkflowId) -> Result<Event, aion_core::PayloadError> {
782 Ok(Event::WorkflowCompleted {
783 envelope: EventEnvelope {
784 seq: 2,
785 recorded_at: Utc::now(),
786 workflow_id: workflow_id.clone(),
787 },
788 result: payload()?,
789 })
790 }
791
792 fn package_manifest() -> Manifest {
793 Manifest {
794 entry_module: "counter".to_owned(),
795 entry_function: "version".to_owned(),
796 input_schema: json!({ "type": "object" }),
797 output_schema: json!({ "type": "integer" }),
798 timeout: Some(Duration::from_secs(30)),
799 activities: vec![DeclaredActivity {
800 activity_type: "activity/test".to_owned(),
801 }],
802 version: ManifestVersion::new("test"),
803 format_version: CURRENT_FORMAT_VERSION,
804 additional_workflows: Vec::new(),
805 }
806 }
807
808 fn compile_counter_beam() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
809 let temp_dir =
810 std::env::temp_dir().join(format!("aion-engine-builder-{}", uuid::Uuid::new_v4()));
811 std::fs::create_dir(&temp_dir)?;
812 let source_path = temp_dir.join("counter.erl");
813 let beam_path = temp_dir.join("counter.beam");
814 std::fs::write(
815 &source_path,
816 "-module(counter).\n-export([version/0]).\nversion() -> 1.\n",
817 )?;
818 let status = Command::new("erlc")
819 .arg("-o")
820 .arg(&temp_dir)
821 .arg(&source_path)
822 .status()?;
823 if !status.success() {
824 let cleanup_result = std::fs::remove_dir_all(&temp_dir);
825 drop(cleanup_result);
826 return Err(format!("erlc failed with status {status}").into());
827 }
828 let bytes = std::fs::read(beam_path)?;
829 std::fs::remove_dir_all(temp_dir)?;
830 Ok(bytes)
831 }
832
833 fn fixture_package() -> Result<Package, Box<dyn std::error::Error>> {
834 let beams = BeamSet::new(vec![BeamModule::new("counter", compile_counter_beam()?)])?;
835 let archive = PackageBuilder::new(package_manifest(), beams).write_to_bytes()?;
836 Ok(Package::load_from_bytes(
837 archive,
838 ExtractionLimits::unbounded(),
839 )?)
840 }
841
842 fn write_fixture_package(package: &Package) -> Result<PathBuf, Box<dyn std::error::Error>> {
843 let path =
844 std::env::temp_dir().join(format!("aion-engine-builder-{}.aion", uuid::Uuid::new_v4()));
845 PackageBuilder::new(package.manifest().clone(), package.beams().clone())
846 .write_to_path(&path)?;
847 Ok(path)
848 }
849
850 #[tokio::test]
851 async fn build_without_store_returns_missing_store() {
852 let error = EngineBuilder::new().build().await.err();
853
854 assert!(matches!(error, Some(EngineError::MissingStore)));
855 }
856
857 #[tokio::test]
858 async fn build_without_visibility_store_returns_missing_visibility_store() {
859 let error = EngineBuilder::new()
860 .store(InMemoryStore::default())
861 .build()
862 .await
863 .err();
864
865 assert!(matches!(error, Some(EngineError::MissingVisibilityStore)));
866 }
867
868 #[tokio::test]
869 async fn in_memory_visibility_allows_build_without_visibility_store() -> Result<(), EngineError>
870 {
871 let engine = EngineBuilder::new()
872 .store(InMemoryStore::default())
873 .in_memory_visibility()
874 .build()
875 .await?;
876
877 engine.shutdown()?;
878 Ok(())
879 }
880
881 #[tokio::test]
915 async fn dropping_an_engine_without_shutdown_closes_the_completion_retry_epoch()
916 -> Result<(), EngineError> {
917 let visibility = Arc::new(TickCountingVisibilityStore::default());
918 let engine = EngineBuilder::new()
919 .store(InMemoryStore::default())
920 .visibility_store_arc(Arc::clone(&visibility) as Arc<dyn VisibilityStore>)
921 .visibility_reconciliation_interval(Duration::from_millis(10))
922 .build()
923 .await?;
924 let tasks = engine.runtime().engine_tasks();
928 assert!(
929 tasks.is_epoch_open(),
930 "control: a live engine's epoch must be open, or the assertion below is trivially \
931 satisfied"
932 );
933
934 let ticking = tokio::time::timeout(Duration::from_secs(10), async {
938 while visibility.ticks() < 2 {
939 tokio::time::sleep(Duration::from_millis(5)).await;
940 }
941 })
942 .await;
943 assert!(
944 ticking.is_ok(),
945 "control: the periodic reconciliation loop must be running before the engine is \
946 dropped, or this test measures nothing; it reached {} ticks",
947 visibility.ticks()
948 );
949
950 drop(engine);
951
952 assert!(
953 !tasks.is_epoch_open(),
954 "an engine released without an explicit shutdown must still close the epoch, or a \
955 completion retry can append a terminal for a run this process no longer owns"
956 );
957
958 tokio::time::sleep(Duration::from_millis(200)).await;
962 let after_abort = visibility.ticks();
963 tokio::time::sleep(Duration::from_millis(200)).await;
964 assert_eq!(
965 visibility.ticks(),
966 after_abort,
967 "an engine released without an explicit shutdown must also stop the visibility \
968 reconciliation loop; it ticked again over twenty intervals after the drop, which \
969 means a detached task is still WRITING to a visibility store this process no longer \
970 owns"
971 );
972 Ok(())
973 }
974
975 const WHEEL_TIMER_ARMING_MARGIN: std::time::Duration = std::time::Duration::from_secs(2);
986
987 const WHEEL_TIMER_OBSERVATION_SLACK: std::time::Duration = std::time::Duration::from_secs(1);
991
992 async fn arm_resident_run_with_wheel_timer(
1010 engine: &crate::Engine,
1011 store: &Arc<InMemoryStore>,
1012 ) -> Result<(WorkflowId, chrono::DateTime<Utc>), Box<dyn std::error::Error>> {
1013 use aion_store::EventStore;
1014
1015 use crate::durability::{Recorder, WorkflowStartRecord};
1016 use crate::registry::{
1017 CompletionNotifier, HandleResidency, WorkflowHandle, WorkflowHandleParts,
1018 };
1019
1020 let workflow_id = WorkflowId::new_v4();
1024 let run_id = aion_core::RunId::new_v4();
1025 let timer_id = aion_core::TimerId::named("sleep")?;
1026 let mut recorder = Recorder::new(
1027 workflow_id.clone(),
1028 Arc::clone(store) as Arc<dyn EventStore>,
1029 );
1030 recorder
1031 .record_workflow_started(
1032 Utc::now(),
1033 WorkflowStartRecord {
1034 workflow_type: "checkout".to_owned(),
1035 input: payload()?,
1036 run_id: run_id.clone(),
1037 parent_run_id: None,
1038 parent_workflow_id: None,
1039 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1040 },
1041 )
1042 .await?;
1043 recorder
1044 .record_timer_started(Utc::now(), timer_id.clone(), Utc::now())
1045 .await?;
1046 engine.registry().insert(
1047 (workflow_id.clone(), run_id.clone()),
1048 WorkflowHandle::new(WorkflowHandleParts {
1049 workflow_id: workflow_id.clone(),
1050 run_id,
1051 pid: 1,
1052 workflow_type: "checkout".to_owned(),
1053 namespace: String::from("default"),
1054 loaded_version: aion_package::ContentHash::from_bytes([9; 32]),
1055 cached_status: WorkflowStatus::Running,
1056 residency: HandleResidency::Resident,
1057 recorder,
1058 completion: CompletionNotifier::new(),
1059 }),
1060 )?;
1061
1062 let fire_at = Utc::now() + chrono::Duration::from_std(WHEEL_TIMER_ARMING_MARGIN)?;
1063 crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1064 .map_err(|error| format!("the engine installed no timer service: {error}"))?
1065 .schedule(workflow_id.clone(), timer_id, fire_at, 1)
1066 .await?;
1067 Ok((workflow_id, fire_at))
1068 }
1069
1070 #[tokio::test]
1094 async fn dropping_an_engine_without_shutdown_disarms_the_live_timer_wheel()
1095 -> Result<(), Box<dyn std::error::Error>> {
1096 use aion_store::EventStore;
1097
1098 let store = Arc::new(InMemoryStore::default());
1099 let engine = EngineBuilder::new()
1100 .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1101 .visibility_store(InMemoryStore::default())
1102 .build()
1103 .await?;
1104 let nif_state = Arc::clone(engine.runtime().nif_state());
1106 let (workflow_id, fire_at) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1107 assert_eq!(
1108 nif_state.armed_wheel_timers(),
1109 1,
1110 "control: the fixture must have actually armed a wheel timer, or the assertions below \
1111 are satisfied by an engine that never had one — the exact way the sibling test above \
1112 is blind to this half of `Drop`"
1113 );
1114
1115 let before_drop = Utc::now();
1123 assert!(
1124 before_drop < fire_at,
1125 "fixture: the arming window closed before the release was reached ({before_drop} is \
1126 not before {fire_at}). This is a FIXTURE timing failure — most likely a loaded box — \
1127 and NOT evidence about cancellation. Widen `WHEEL_TIMER_ARMING_MARGIN`; do not read \
1128 this as `Drop` leaking a task."
1129 );
1130 let armed = store.read_history(&workflow_id).await?;
1131 assert!(
1132 !armed
1133 .iter()
1134 .any(|event| matches!(event, Event::TimerFired { .. })),
1135 "fixture: the timer already fired before the engine was released, so this run cannot \
1136 say anything about cancellation. Same remedy as above — widen the margin: {armed:#?}"
1137 );
1138
1139 drop(engine);
1140
1141 assert_eq!(
1142 nif_state.armed_wheel_timers(),
1143 0,
1144 "an engine released without an explicit shutdown must empty its live timer wheel"
1145 );
1146
1147 tokio::time::sleep(WHEEL_TIMER_ARMING_MARGIN + WHEEL_TIMER_OBSERVATION_SLACK).await;
1158 let history = store.read_history(&workflow_id).await?;
1159 assert!(
1160 !history
1161 .iter()
1162 .any(|event| matches!(event, Event::TimerFired { .. })),
1163 "an engine released without an explicit shutdown must CANCEL its wheel tasks, not \
1164 merely forget them: a surviving task records `TimerFired` for a run this process no \
1165 longer owns, which is a second writer for one workflow: {history:#?}"
1166 );
1167 Ok(())
1168 }
1169
1170 #[tokio::test]
1193 async fn arming_a_wheel_timer_after_the_engine_is_released_is_refused()
1194 -> Result<(), Box<dyn std::error::Error>> {
1195 use aion_store::EventStore;
1196
1197 let store = Arc::new(InMemoryStore::default());
1198 let engine = EngineBuilder::new()
1199 .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1200 .visibility_store(InMemoryStore::default())
1201 .build()
1202 .await?;
1203 let nif_state = Arc::clone(engine.runtime().nif_state());
1204 let (workflow_id, _fire_at) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1207 assert_eq!(
1208 nif_state.armed_wheel_timers(),
1209 1,
1210 "control: the fixture must have armed a wheel timer through the production path, or \
1211 the refusal below is satisfied by a fixture that could never arm one"
1212 );
1213
1214 drop(engine);
1215
1216 assert_eq!(nif_state.armed_wheel_timers(), 0);
1219
1220 let refused = crate::runtime::nif_timer_bridge::installed_timer_service(&nif_state)
1221 .map_err(|error| format!("the released engine's timer seam is gone: {error}"))?
1222 .schedule(
1223 workflow_id,
1224 aion_core::TimerId::named("sleep-after-release")?,
1225 Utc::now() + chrono::Duration::from_std(WHEEL_TIMER_ARMING_MARGIN)?,
1226 1,
1227 )
1228 .await;
1229 let Err(error) = refused else {
1230 return Err(
1231 "arming a wheel timer through a released engine must be REFUSED: the task \
1232 it spawns appends a durable `TimerFired` for a run this process no longer \
1233 owns, which is a second writer for one workflow"
1234 .into(),
1235 );
1236 };
1237 assert!(
1238 error.to_string().contains("torn down"),
1239 "the refusal must say WHY, so an operator reading it is not sent looking for a \
1240 missing workflow or a bad timer id: {error}"
1241 );
1242 assert_eq!(
1243 nif_state.armed_wheel_timers(),
1244 0,
1245 "the refused arm must leave nothing behind: a wheel entry inserted before the refusal \
1246 would be a durable writer with no owner to cancel it"
1247 );
1248 Ok(())
1249 }
1250
1251 #[tokio::test]
1260 async fn a_live_wheel_timer_fires_when_the_engine_is_not_released()
1261 -> Result<(), Box<dyn std::error::Error>> {
1262 use aion_store::EventStore;
1263
1264 let store = Arc::new(InMemoryStore::default());
1265 let engine = EngineBuilder::new()
1266 .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1267 .visibility_store(InMemoryStore::default())
1268 .build()
1269 .await?;
1270 let (workflow_id, _) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1271
1272 tokio::time::sleep(WHEEL_TIMER_ARMING_MARGIN + WHEEL_TIMER_OBSERVATION_SLACK).await;
1273
1274 let history = store.read_history(&workflow_id).await?;
1275 assert!(
1276 history
1277 .iter()
1278 .any(|event| matches!(event, Event::TimerFired { .. })),
1279 "the fixture's wheel timer must reach the store when the engine is still held, or \
1280 the sibling test's absence proves nothing: {history:#?}"
1281 );
1282 drop(engine);
1283 Ok(())
1284 }
1285
1286 #[derive(Default)]
1294 struct TickCountingVisibilityStore {
1295 inner: InMemoryStore,
1296 list_calls: std::sync::atomic::AtomicUsize,
1297 }
1298
1299 impl TickCountingVisibilityStore {
1300 fn ticks(&self) -> usize {
1301 self.list_calls.load(std::sync::atomic::Ordering::Acquire)
1302 }
1303 }
1304
1305 #[async_trait::async_trait]
1306 impl VisibilityStore for TickCountingVisibilityStore {
1307 async fn record_visibility(
1308 &self,
1309 record: aion_store::visibility::VisibilityRecord,
1310 ) -> Result<(), aion_store::StoreError> {
1311 self.inner.record_visibility(record).await
1312 }
1313
1314 async fn list_workflows(
1315 &self,
1316 filter: ListWorkflowsFilter,
1317 ) -> Result<Vec<aion_store::visibility::WorkflowSummary>, aion_store::StoreError> {
1318 self.list_calls
1319 .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
1320 self.inner.list_workflows(filter).await
1321 }
1322
1323 async fn count_workflows(
1324 &self,
1325 filter: ListWorkflowsFilter,
1326 ) -> Result<u64, aion_store::StoreError> {
1327 self.inner.count_workflows(filter).await
1328 }
1329 }
1330
1331 fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
1332 NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
1333 }
1334
1335 #[tokio::test]
1336 async fn event_streaming_delivers_recorder_appends_through_engine_subscribe()
1337 -> Result<(), Box<dyn std::error::Error>> {
1338 let engine = EngineBuilder::new()
1339 .store(InMemoryStore::default())
1340 .in_memory_visibility()
1341 .event_streaming(capacity(8)?)
1342 .build()
1343 .await?;
1344 let workflow_id = WorkflowId::new_v4();
1345 let mut subscription = engine.subscribe(crate::EventFilter {
1346 workflow_id: Some(workflow_id.clone()),
1347 run: None,
1348 family: None,
1349 });
1350
1351 let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1354 recorder
1355 .record_workflow_started(
1356 Utc::now(),
1357 crate::durability::WorkflowStartRecord {
1358 workflow_type: "checkout".to_owned(),
1359 input: payload()?,
1360 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(7)),
1361 parent_run_id: None,
1362 parent_workflow_id: None,
1363 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1364 },
1365 )
1366 .await?;
1367
1368 let item = tokio::time::timeout(Duration::from_secs(2), subscription.next())
1369 .await?
1370 .ok_or("subscription ended without delivering the appended event")?;
1371 let event = item?;
1372 assert_eq!(event.workflow_id(), &workflow_id);
1373 assert_eq!(event.seq(), 1);
1374 assert!(matches!(event, Event::WorkflowStarted { .. }));
1375 engine.shutdown()?;
1376 Ok(())
1377 }
1378
1379 #[tokio::test]
1380 async fn without_event_streaming_subscriptions_stay_on_deferred_empty_stream()
1381 -> Result<(), Box<dyn std::error::Error>> {
1382 let engine = EngineBuilder::new()
1383 .store(InMemoryStore::default())
1384 .in_memory_visibility()
1385 .build()
1386 .await?;
1387
1388 let mut subscription = engine.subscribe(crate::EventFilter::default());
1389 let item = tokio::time::timeout(Duration::from_secs(2), subscription.next()).await?;
1390
1391 assert!(item.is_none(), "deferred publisher streams must be empty");
1392 engine.shutdown()?;
1393 Ok(())
1394 }
1395
1396 #[tokio::test]
1397 async fn event_streaming_conflicts_with_explicit_event_publisher()
1398 -> Result<(), Box<dyn std::error::Error>> {
1399 let error = EngineBuilder::new()
1400 .store(InMemoryStore::default())
1401 .in_memory_visibility()
1402 .event_publisher(Arc::new(crate::DeferredEventPublisher))
1403 .event_streaming(capacity(8)?)
1404 .build()
1405 .await
1406 .err();
1407
1408 assert!(matches!(
1409 error,
1410 Some(EngineError::ConflictingEventPublisher)
1411 ));
1412 Ok(())
1413 }
1414
1415 #[test]
1416 fn query_timeout_is_only_set_by_caller() {
1417 assert_eq!(EngineBuilder::new().configured_query_timeout(), None);
1418 assert_eq!(
1419 EngineBuilder::new()
1420 .query_timeout(Duration::from_secs(3))
1421 .configured_query_timeout(),
1422 Some(Duration::from_secs(3))
1423 );
1424 }
1425
1426 async fn insert_running_workflow(
1427 engine: &crate::Engine,
1428 ) -> Result<(WorkflowId, aion_core::RunId), Box<dyn std::error::Error>> {
1429 let workflow_id = WorkflowId::new_v4();
1430 let run_id = aion_core::RunId::new_v4();
1431 let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1432 recorder
1433 .record_workflow_started(
1434 Utc::now(),
1435 crate::durability::WorkflowStartRecord {
1436 workflow_type: "checkout".to_owned(),
1437 input: payload()?,
1438 run_id: run_id.clone(),
1439 parent_run_id: None,
1440 parent_workflow_id: None,
1441 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1442 },
1443 )
1444 .await?;
1445 let handle = crate::registry::WorkflowHandle::new(crate::registry::WorkflowHandleParts {
1446 workflow_id: workflow_id.clone(),
1447 run_id: run_id.clone(),
1448 pid: engine.runtime().spawn_test_process_with_trap_exit(true)?,
1449 workflow_type: "checkout".to_owned(),
1450 namespace: String::from("default"),
1451 loaded_version: aion_package::ContentHash::from_bytes([2; 32]),
1452 cached_status: WorkflowStatus::Running,
1453 residency: crate::registry::HandleResidency::Resident,
1454 recorder,
1455 completion: crate::registry::CompletionNotifier::new(),
1456 });
1457 engine
1458 .registry()
1459 .insert((workflow_id.clone(), run_id.clone()), handle)?;
1460 Ok((workflow_id, run_id))
1461 }
1462
1463 #[tokio::test]
1464 async fn query_timeout_installs_the_concrete_query_seam()
1465 -> Result<(), Box<dyn std::error::Error>> {
1466 let engine = EngineBuilder::new()
1467 .store(InMemoryStore::default())
1468 .in_memory_visibility()
1469 .query_timeout(Duration::from_millis(250))
1470 .build()
1471 .await?;
1472 let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1473
1474 let result = engine
1478 .query(
1479 &workflow_id,
1480 &run_id,
1481 "state",
1482 aion_core::Payload::json_null(),
1483 )
1484 .await;
1485
1486 assert!(matches!(
1487 result,
1488 Err(crate::EngineError::Query(crate::QueryError::UnknownQuery(name))) if name == "state"
1489 ));
1490 engine.shutdown()?;
1491 Ok(())
1492 }
1493
1494 #[tokio::test]
1495 async fn without_query_timeout_the_query_seam_stays_deferred()
1496 -> Result<(), Box<dyn std::error::Error>> {
1497 let engine = EngineBuilder::new()
1498 .store(InMemoryStore::default())
1499 .in_memory_visibility()
1500 .build()
1501 .await?;
1502 let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1503
1504 let result = engine
1505 .query(
1506 &workflow_id,
1507 &run_id,
1508 "state",
1509 aion_core::Payload::json_null(),
1510 )
1511 .await;
1512
1513 assert!(matches!(
1514 result,
1515 Err(crate::EngineError::Runtime { reason }) if reason.contains("not configured")
1516 ));
1517 engine.shutdown()?;
1518 Ok(())
1519 }
1520
1521 #[test]
1522 fn owned_shards_are_only_set_by_caller() {
1523 assert_eq!(EngineBuilder::new().configured_owned_shards(), None);
1527 assert_eq!(
1528 EngineBuilder::new()
1529 .owned_shards([2, 0, 2, 1])
1530 .configured_owned_shards(),
1531 Some([2, 0, 2, 1].as_slice())
1532 );
1533 }
1534
1535 #[test]
1536 fn scheduler_threads_are_only_set_by_caller() {
1537 assert_eq!(EngineBuilder::new().scheduler_thread_count(), None);
1538 assert_eq!(
1539 EngineBuilder::new()
1540 .scheduler_threads(4)
1541 .scheduler_thread_count(),
1542 Some(4)
1543 );
1544 }
1545
1546 #[test]
1560 fn the_completion_retry_ladder_reaches_the_runtime_configuration()
1561 -> Result<(), Box<dyn std::error::Error>> {
1562 let chosen = crate::runtime::CompletionRetryConfig::try_new(
1563 Duration::from_millis(250),
1564 Duration::from_secs(7),
1565 )?;
1566 assert_ne!(
1567 chosen,
1568 crate::runtime::CompletionRetryConfig::default(),
1569 "a ladder equal to the default could not distinguish wiring from inheritance"
1570 );
1571
1572 assert_eq!(
1573 EngineBuilder::new()
1574 .completion_retry(chosen)
1575 .runtime_config()
1576 .completion_retry,
1577 chosen,
1578 "the runtime must be started with the operator's ladder, not the inherited default"
1579 );
1580 Ok(())
1581 }
1582
1583 #[test]
1605 fn the_jit_threshold_reaches_the_runtime_configuration() {
1606 assert_eq!(
1607 EngineBuilder::new().runtime_config().jit_threshold,
1608 None,
1609 "an unset builder must pass None through, so beamr applies its own default rather \
1610 than a value aion invented"
1611 );
1612
1613 assert_eq!(
1614 EngineBuilder::new()
1615 .scheduler_jit_threshold(100)
1616 .runtime_config()
1617 .jit_threshold,
1618 Some(100),
1619 "the scheduler must be started with the operator's threshold, not beamr's default — \
1620 otherwise a proportionality run reports on the default while claiming to vary it"
1621 );
1622 }
1623
1624 #[test]
1625 fn visibility_reconciliation_interval_is_only_set_by_caller() {
1626 let interval = Duration::from_millis(250);
1627
1628 assert_eq!(
1629 EngineBuilder::new().configured_visibility_reconciliation_interval(),
1630 None
1631 );
1632 assert_eq!(
1633 EngineBuilder::new()
1634 .visibility_reconciliation_interval(interval)
1635 .configured_visibility_reconciliation_interval(),
1636 Some(interval)
1637 );
1638 }
1639
1640 #[tokio::test]
1641 async fn duplicate_host_nif_mfa_returns_typed_error() {
1642 let mfa = Mfa::new("host", "zero", 0);
1643 let error = EngineBuilder::new()
1644 .store(InMemoryStore::default())
1645 .in_memory_visibility()
1646 .register_nifs([
1647 NifEntry::new(
1648 mfa.clone(),
1649 crate::runtime::nif::test_native_zero,
1650 Determinism::Pure,
1651 ),
1652 NifEntry::dirty(
1653 mfa,
1654 crate::runtime::nif::test_native_zero,
1655 Determinism::Pure,
1656 ),
1657 ])
1658 .build()
1659 .await
1660 .err();
1661
1662 assert!(matches!(
1663 error,
1664 Some(EngineError::NifRegistration { reason }) if reason.contains("host:zero/0")
1665 ));
1666 }
1667
1668 #[tokio::test]
1669 async fn empty_store_builds_coordinator_history_without_registry_or_supervision()
1670 -> Result<(), EngineError> {
1671 let store = Arc::new(InMemoryStore::default());
1672 let engine = EngineBuilder::new()
1673 .store_arc(store.clone())
1674 .in_memory_visibility()
1675 .build()
1676 .await?;
1677
1678 assert!(engine.registry().list()?.is_empty());
1679 assert_eq!(engine.supervision().type_supervisor_count()?, 1);
1680 assert_eq!(engine.workflow_catalog().workflows()?.len(), 0);
1681
1682 let coordinator_id = schedule_coordinator_workflow_id();
1683 let active = store.list_active().await?;
1684 assert_eq!(active, vec![coordinator_id.clone()]);
1685 let history = store.read_history(&coordinator_id).await?;
1686 let [started] = history.as_slice() else {
1687 return Err(EngineError::Load {
1688 reason: format!(
1689 "expected exactly one coordinator event, found {}",
1690 history.len()
1691 ),
1692 });
1693 };
1694 match started {
1695 Event::WorkflowStarted {
1696 workflow_type,
1697 input,
1698 run_id,
1699 parent_run_id,
1700 ..
1701 } => {
1702 assert_eq!(workflow_type, schedule_coordinator_workflow_type());
1703 assert_eq!(
1704 input,
1705 &Payload::from_json(&json!({})).map_err(|error| {
1706 EngineError::Load {
1707 reason: format!("failed to build expected payload: {error}"),
1708 }
1709 })?
1710 );
1711 assert_eq!(run_id, &schedule_coordinator_run_id());
1712 assert!(parent_run_id.is_none());
1713 }
1714 other => {
1715 return Err(EngineError::Load {
1716 reason: format!("expected coordinator WorkflowStarted, found {other:?}"),
1717 });
1718 }
1719 }
1720
1721 engine.shutdown()?;
1722 let rebuilt = EngineBuilder::new()
1723 .store_arc(store.clone())
1724 .in_memory_visibility()
1725 .build()
1726 .await?;
1727 let rebuilt_history = store.read_history(&coordinator_id).await?;
1728 assert_eq!(rebuilt_history.len(), 1);
1729 rebuilt.shutdown()?;
1730
1731 Ok(())
1732 }
1733
1734 #[tokio::test]
1735 async fn build_loads_already_loaded_package() -> Result<(), Box<dyn std::error::Error>> {
1736 if gleam_test_support::skip_if_unavailable() {
1737 return Ok(());
1738 }
1739 let package = fixture_package()?;
1740 let version = package.content_hash().clone();
1741 let deployed_entry_module = package.deployed_entry_module();
1742
1743 let engine = EngineBuilder::new()
1744 .store(InMemoryStore::default())
1745 .in_memory_visibility()
1746 .load_workflows(package)
1747 .build()
1748 .await?;
1749
1750 let loaded = engine
1751 .workflow_catalog()
1752 .get("counter", &version)?
1753 .ok_or("loaded package record missing")?;
1754 assert_eq!(loaded.deployed_entry_module(), deployed_entry_module);
1755 assert!(
1756 engine
1757 .runtime()
1758 .has_registered_module(&deployed_entry_module)
1759 );
1760 Ok(())
1761 }
1762
1763 #[tokio::test]
1764 async fn startup_reconciliation_backfills_completed_visibility()
1765 -> Result<(), Box<dyn std::error::Error>> {
1766 let store = Arc::new(InMemoryStore::default());
1767 let completed_id = WorkflowId::new_v4();
1768
1769 store
1770 .append(
1771 WriteToken::recorder(),
1772 &completed_id,
1773 &[
1774 started(&completed_id, "billing")?,
1775 completed(&completed_id)?,
1776 ],
1777 0,
1778 )
1779 .await?;
1780
1781 let engine = EngineBuilder::new()
1782 .store_arc(store.clone())
1783 .visibility_store_arc(store.clone())
1784 .build()
1785 .await?;
1786
1787 let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1788 let completed_summary = summaries
1789 .iter()
1790 .find(|summary| summary.workflow_id == completed_id)
1791 .ok_or("completed workflow missing from visibility")?;
1792
1793 assert_eq!(completed_summary.status, WorkflowStatus::Completed);
1794 assert!(completed_summary.close_time.is_some());
1795 engine.shutdown()?;
1796 Ok(())
1797 }
1798
1799 #[tokio::test]
1800 async fn periodic_visibility_reconciliation_repairs_gap_after_startup()
1801 -> Result<(), Box<dyn std::error::Error>> {
1802 let store = Arc::new(InMemoryStore::default());
1803 let engine = EngineBuilder::new()
1804 .store_arc(store.clone())
1805 .visibility_store_arc(store.clone())
1806 .visibility_reconciliation_interval(Duration::from_millis(25))
1807 .build()
1808 .await?;
1809 let workflow_id = WorkflowId::new_v4();
1810
1811 store
1812 .append(
1813 WriteToken::recorder(),
1814 &workflow_id,
1815 &[started(&workflow_id, "checkout")?],
1816 0,
1817 )
1818 .await?;
1819
1820 tokio::time::timeout(Duration::from_secs(2), async {
1821 loop {
1822 let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1823 if summaries.iter().any(|summary| {
1824 summary.workflow_id == workflow_id && summary.status == WorkflowStatus::Running
1825 }) {
1826 return Ok::<(), aion_store::StoreError>(());
1827 }
1828 tokio::time::sleep(Duration::from_millis(10)).await;
1829 }
1830 })
1831 .await??;
1832
1833 engine.shutdown()?;
1834 Ok(())
1835 }
1836
1837 #[tokio::test]
1838 async fn build_loads_package_from_path() -> Result<(), Box<dyn std::error::Error>> {
1839 if gleam_test_support::skip_if_unavailable() {
1840 return Ok(());
1841 }
1842 let package = fixture_package()?;
1843 let version = package.content_hash().clone();
1844 let path = write_fixture_package(&package)?;
1845
1846 let engine = EngineBuilder::new()
1847 .store(InMemoryStore::default())
1848 .in_memory_visibility()
1849 .load_workflows(path.as_path())
1850 .build()
1851 .await?;
1852 std::fs::remove_file(path)?;
1853
1854 assert!(
1855 engine
1856 .workflow_catalog()
1857 .get("counter", &version)?
1858 .is_some()
1859 );
1860 Ok(())
1861 }
1862}