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, claim_owned_shards, install_engine_nif_seams,
24 install_workflow_nif_bridges, maybe_spawn_visibility_reconciliation,
25};
26use super::delegated::{DelegatedSeams, EventPublisher, QueryService, SignalRouter};
27use super::seams::{
28 SeamAssembly, SignalRouterFactory, assemble_delegated_seams, wrap_event_streaming,
29};
30use super::startup::{StartupRecoveryContext, resolve_startup_recovery};
31
32#[derive(Clone, Debug)]
35pub enum WorkflowPackageSource {
36 Path(PathBuf),
38 Package(Box<Package>),
40}
41
42impl From<Package> for WorkflowPackageSource {
43 fn from(package: Package) -> Self {
44 Self::Package(Box::new(package))
45 }
46}
47
48impl From<PathBuf> for WorkflowPackageSource {
49 fn from(path: PathBuf) -> Self {
50 Self::Path(path)
51 }
52}
53
54impl From<&std::path::Path> for WorkflowPackageSource {
55 fn from(path: &std::path::Path) -> Self {
56 Self::Path(path.to_path_buf())
57 }
58}
59
60impl From<&str> for WorkflowPackageSource {
61 fn from(path: &str) -> Self {
62 Self::Path(PathBuf::from(path))
63 }
64}
65
66impl From<String> for WorkflowPackageSource {
67 fn from(path: String) -> Self {
68 Self::Path(PathBuf::from(path))
69 }
70}
71
72#[derive(Default)]
77struct SeamOverrides {
78 event_publisher: bool,
80 query_service: bool,
82}
83
84pub struct EngineBuilder {
86 store: Option<Arc<dyn EventStore>>,
87 visibility_store: Option<Arc<dyn VisibilityStore>>,
88 scheduler_threads: Option<usize>,
89 scheduler_jit_threshold: Option<u32>,
90 signal_delivery: SignalDeliveryConfig,
91 completion_retry: crate::runtime::CompletionRetryConfig,
92 outbox_enabled: bool,
93 bootstrap_schedule_coordinator: bool,
94 owned_shards: Option<Vec<usize>>,
95 workflow_sources: Vec<WorkflowPackageSource>,
96 host_nifs: Vec<NifEntry>,
97 recovery: Option<Arc<dyn ActiveWorkflowRecoverySeam>>,
98 delegated: DelegatedSeams,
99 signal_router_factory: Option<SignalRouterFactory>,
100 activity_dispatcher: Option<Arc<dyn ActivityDispatcher>>,
101 activity_serving: ActivityServing,
102 active_registry: Option<Arc<Registry>>,
103 visibility_reconciliation_interval: Option<Duration>,
104 search_attribute_schema: SearchAttributeSchema,
105 event_streaming_capacity: Option<NonZeroUsize>,
106 query_timeout: Option<Duration>,
107 seam_overrides: SeamOverrides,
108 defer_startup_recovery: bool,
109}
110
111impl Default for EngineBuilder {
112 fn default() -> Self {
113 Self::new()
114 }
115}
116
117impl EngineBuilder {
118 #[must_use]
121 pub fn new() -> Self {
122 Self {
123 store: None,
124 visibility_store: None,
125 scheduler_threads: None,
126 scheduler_jit_threshold: None,
127 signal_delivery: SignalDeliveryConfig::default(),
128 completion_retry: crate::runtime::CompletionRetryConfig::default(),
129 outbox_enabled: false,
130 bootstrap_schedule_coordinator: true,
134 owned_shards: None,
138 workflow_sources: Vec::new(),
139 host_nifs: Vec::new(),
140 recovery: None,
141 delegated: DelegatedSeams::default(),
142 signal_router_factory: None,
143 activity_dispatcher: None,
144 activity_serving: ActivityServing::QueueRouted,
145 active_registry: None,
146 visibility_reconciliation_interval: None,
147 search_attribute_schema: SearchAttributeSchema::new(),
148 event_streaming_capacity: None,
149 query_timeout: None,
150 seam_overrides: SeamOverrides::default(),
151 defer_startup_recovery: false,
152 }
153 }
154
155 #[must_use]
163 pub const fn query_timeout(mut self, timeout: Duration) -> Self {
164 self.query_timeout = Some(timeout);
165 self
166 }
167
168 #[must_use]
170 pub const fn configured_query_timeout(&self) -> Option<Duration> {
171 self.query_timeout
172 }
173
174 #[must_use]
184 pub const fn event_streaming(mut self, capacity: NonZeroUsize) -> Self {
185 self.event_streaming_capacity = Some(capacity);
186 self
187 }
188
189 #[must_use]
195 pub fn search_attribute_schema(mut self, schema: SearchAttributeSchema) -> Self {
196 self.search_attribute_schema = schema;
197 self
198 }
199
200 #[must_use]
202 pub fn store<S>(mut self, store: S) -> Self
203 where
204 S: EventStore,
205 {
206 self.store = Some(Arc::new(store));
207 self
208 }
209
210 #[must_use]
212 pub fn store_arc(mut self, store: Arc<dyn EventStore>) -> Self {
213 self.store = Some(store);
214 self
215 }
216
217 #[must_use]
219 pub fn visibility_store<S>(mut self, visibility_store: S) -> Self
220 where
221 S: VisibilityStore,
222 {
223 self.visibility_store = Some(Arc::new(visibility_store));
224 self
225 }
226
227 #[must_use]
229 pub fn visibility_store_arc(mut self, visibility_store: Arc<dyn VisibilityStore>) -> Self {
230 self.visibility_store = Some(visibility_store);
231 self
232 }
233
234 #[must_use]
240 pub fn in_memory_visibility(mut self) -> Self {
241 self.visibility_store = Some(Arc::new(InMemoryStore::default()));
242 self
243 }
244
245 #[must_use]
249 pub const fn scheduler_threads(mut self, threads: usize) -> Self {
250 self.scheduler_threads = Some(threads);
251 self
252 }
253
254 #[must_use]
261 pub const fn scheduler_jit_threshold(mut self, threshold: u32) -> Self {
262 self.scheduler_jit_threshold = Some(threshold);
263 self
264 }
265
266 #[must_use]
270 pub const fn visibility_reconciliation_interval(mut self, interval: Duration) -> Self {
271 self.visibility_reconciliation_interval = Some(interval);
272 self
273 }
274
275 #[must_use]
277 pub const fn signal_delivery(mut self, signal_delivery: SignalDeliveryConfig) -> Self {
278 self.signal_delivery = signal_delivery;
279 self
280 }
281
282 #[must_use]
289 pub const fn completion_retry(
290 mut self,
291 completion_retry: crate::runtime::CompletionRetryConfig,
292 ) -> Self {
293 self.completion_retry = completion_retry;
294 self
295 }
296
297 #[must_use]
299 pub fn outbox_enabled(mut self, enabled: bool) -> Self {
300 self.outbox_enabled = enabled;
301 self
302 }
303
304 #[must_use]
312 pub const fn bootstrap_schedule_coordinator(mut self, enabled: bool) -> Self {
313 self.bootstrap_schedule_coordinator = enabled;
314 self
315 }
316
317 #[must_use]
332 pub fn owned_shards(mut self, shards: impl IntoIterator<Item = usize>) -> Self {
333 self.owned_shards = Some(shards.into_iter().collect());
334 self
335 }
336
337 #[must_use]
339 pub fn configured_owned_shards(&self) -> Option<&[usize]> {
340 self.owned_shards.as_deref()
341 }
342
343 #[must_use]
345 pub fn load_workflows(mut self, source: impl Into<WorkflowPackageSource>) -> Self {
346 self.workflow_sources.push(source.into());
347 self
348 }
349
350 #[must_use]
352 pub fn load_workflow_sources<I, S>(mut self, sources: I) -> Self
353 where
354 I: IntoIterator<Item = S>,
355 S: Into<WorkflowPackageSource>,
356 {
357 self.workflow_sources
358 .extend(sources.into_iter().map(Into::into));
359 self
360 }
361
362 #[must_use]
364 pub fn register_nifs(mut self, entries: impl IntoIterator<Item = NifEntry>) -> Self {
365 self.host_nifs.extend(entries);
366 self
367 }
368
369 #[must_use]
371 pub fn recovery_seam(mut self, recovery: Arc<dyn ActiveWorkflowRecoverySeam>) -> Self {
372 self.recovery = Some(recovery);
373 self
374 }
375
376 #[must_use]
378 pub fn production_recovery_seam(mut self) -> Self {
379 self.recovery = None;
380 self
381 }
382
383 #[must_use]
394 pub fn defer_startup_recovery(mut self) -> Self {
395 self.defer_startup_recovery = true;
396 self
397 }
398
399 #[must_use]
401 pub fn signal_router(mut self, signal_router: Arc<dyn SignalRouter>) -> Self {
402 self.signal_router_factory = None;
403 self.delegated = DelegatedSeams::new(
404 signal_router,
405 self.delegated.query_service_arc(),
406 self.delegated.event_publisher_arc(),
407 );
408 self
409 }
410
411 #[must_use]
413 pub fn signal_router_factory<F>(mut self, factory: F) -> Self
414 where
415 F: Fn(Arc<RuntimeHandle>, Arc<SignalResumeHandoff>) -> Arc<dyn SignalRouter>
416 + Send
417 + Sync
418 + 'static,
419 {
420 self.signal_router_factory = Some(Arc::new(factory));
421 self
422 }
423
424 #[must_use]
429 pub fn query_service(mut self, query_service: Arc<dyn QueryService>) -> Self {
430 self.seam_overrides.query_service = true;
431 self.delegated = DelegatedSeams::new(
432 self.delegated.signal_router_arc(),
433 query_service,
434 self.delegated.event_publisher_arc(),
435 );
436 self
437 }
438
439 #[must_use]
444 pub fn event_publisher(mut self, event_publisher: Arc<dyn EventPublisher>) -> Self {
445 self.seam_overrides.event_publisher = true;
446 self.delegated = DelegatedSeams::new(
447 self.delegated.signal_router_arc(),
448 self.delegated.query_service_arc(),
449 event_publisher,
450 );
451 self
452 }
453
454 #[must_use]
460 pub fn activity_dispatcher(mut self, dispatcher: Arc<dyn ActivityDispatcher>) -> Self {
461 self.activity_dispatcher = Some(dispatcher);
462 self
463 }
464
465 #[must_use]
477 pub const fn in_process_activity_serving(mut self) -> Self {
478 self.activity_serving = ActivityServing::InProcess;
479 self
480 }
481
482 #[must_use]
487 pub fn active_registry(mut self, registry: Arc<Registry>) -> Self {
488 self.active_registry = Some(registry);
489 self
490 }
491
492 #[must_use]
494 pub const fn scheduler_thread_count(&self) -> Option<usize> {
495 self.scheduler_threads
496 }
497
498 #[must_use]
500 pub const fn configured_jit_threshold(&self) -> Option<u32> {
501 self.scheduler_jit_threshold
502 }
503
504 #[must_use]
506 pub const fn configured_visibility_reconciliation_interval(&self) -> Option<Duration> {
507 self.visibility_reconciliation_interval
508 }
509
510 fn runtime_config(&self) -> RuntimeConfig {
513 RuntimeConfig::new(self.scheduler_threads)
514 .with_jit_threshold(self.scheduler_jit_threshold)
515 .with_signal_delivery(self.signal_delivery)
516 .with_completion_retry(self.completion_retry)
517 .with_outbox_enabled(self.outbox_enabled)
518 }
519
520 fn start_runtime_with_nifs(
522 runtime_config: RuntimeConfig,
523 host_nifs: Vec<NifEntry>,
524 ) -> Result<Arc<RuntimeHandle>, EngineError> {
525 let runtime = Arc::new(RuntimeHandle::new(runtime_config)?);
526 let mut nifs = NifRegistration::new();
527 nifs.add_engine_nifs().add_host_nifs(host_nifs);
528 runtime.install_nifs(nifs)?;
529 Ok(runtime)
530 }
531
532 pub async fn build(self) -> Result<Engine, EngineError> {
540 let runtime_config = self.runtime_config();
541 let (store, streaming_publisher) = wrap_event_streaming(
542 self.store.ok_or(EngineError::MissingStore)?,
543 self.event_streaming_capacity,
544 self.seam_overrides.event_publisher,
545 )?;
546 let visibility_store = self
547 .visibility_store
548 .ok_or(EngineError::MissingVisibilityStore)?;
549 claim_owned_shards(store.as_ref(), self.owned_shards.as_deref())?;
550
551 let runtime = Self::start_runtime_with_nifs(runtime_config, self.host_nifs)?;
552
553 let catalog = assemble_startup_catalog(
557 runtime.as_ref(),
558 store.as_ref(),
559 self.workflow_sources,
560 self.activity_serving,
561 )
562 .await?;
563
564 let registry = self
565 .active_registry
566 .unwrap_or_else(|| Arc::new(Registry::default()));
567 let nif_state = Arc::clone(runtime.nif_state());
568 let query_mailbox_engine = install_engine_nif_seams(
569 &nif_state,
570 ®istry,
571 &store,
572 &runtime,
573 self.activity_dispatcher,
574 self.query_timeout,
575 );
576 let supervision = Arc::new(SupervisionTree::new());
577 let search_attribute_schema = Arc::new(self.search_attribute_schema);
578 let signal_handoff = Arc::new(SignalResumeHandoff::new());
579
580 let delegated = assemble_delegated_seams(SeamAssembly {
581 configured: self.delegated,
582 signal_router_factory: self.signal_router_factory,
583 runtime: Arc::clone(&runtime),
584 signal_handoff: Arc::clone(&signal_handoff),
585 streaming_publisher,
586 query_mailbox_engine,
587 query_timeout: self.query_timeout,
588 query_service_overridden: self.seam_overrides.query_service,
589 });
590
591 install_workflow_nif_bridges(
598 &ChildBridgeAssembly {
599 nif_state: &nif_state,
600 store: &store,
601 visibility_store: &visibility_store,
602 runtime: &runtime,
603 catalog: &catalog,
604 registry: ®istry,
605 supervision: &supervision,
606 signal_handoff: &signal_handoff,
607 search_attribute_schema: &search_attribute_schema,
608 watch_backoff: self.signal_delivery,
609 },
610 &delegated,
611 )?;
612
613 let deferred_startup_recovery = resolve_startup_recovery(
617 self.defer_startup_recovery,
618 &nif_state,
619 StartupRecoveryContext {
620 store: Arc::clone(&store),
621 visibility_store: Arc::clone(&visibility_store),
622 runtime: Arc::clone(&runtime),
623 catalog: Arc::clone(&catalog),
624 registry: Arc::clone(®istry),
625 supervision: Arc::clone(&supervision),
626 recovery: self.recovery,
627 search_attribute_schema: Arc::clone(&search_attribute_schema),
628 bootstrap_schedule_coordinator: self.bootstrap_schedule_coordinator,
629 },
630 )
631 .await?;
632
633 let visibility_reconciliation_task = maybe_spawn_visibility_reconciliation(
634 self.visibility_reconciliation_interval,
635 &store,
636 &visibility_store,
637 );
638
639 let deferred = deferred_startup_recovery.is_some();
640 let engine = Engine::new(EngineComponents {
641 store,
642 visibility_store,
643 runtime,
644 catalog,
645 registry,
646 supervision,
647 delegated,
648 signal_handoff,
649 search_attribute_schema,
650 visibility_reconciliation_task,
651 deferred_startup_recovery,
652 });
653 if !deferred {
654 engine.catchup_schedule_coordinator().await?;
655 engine.recover_schedules_on_startup(Utc::now()).await?;
656 }
657 Ok(engine)
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use super::super::gleam_test_support;
679
680 use std::{num::NonZeroUsize, path::PathBuf, process::Command, sync::Arc, time::Duration};
681
682 use aion_core::{Event, EventEnvelope, Payload, WorkflowId, WorkflowStatus};
683 use aion_package::{
684 BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, ExtractionLimits, Manifest,
685 ManifestVersion, Package, PackageBuilder,
686 };
687 use aion_store::visibility::{ListWorkflowsFilter, VisibilityStore};
688 use aion_store::{InMemoryStore, ReadableEventStore, WritableEventStore, WriteToken};
689 use chrono::Utc;
690 use futures::StreamExt;
691 use serde_json::json;
692
693 use crate::engine::api_schedule::{
694 schedule_coordinator_run_id, schedule_coordinator_workflow_id,
695 schedule_coordinator_workflow_type,
696 };
697 use crate::runtime::{Determinism, Mfa, NifEntry};
698
699 use super::EngineBuilder;
700 use crate::EngineError;
701
702 fn payload() -> Result<Payload, aion_core::PayloadError> {
703 Payload::from_json(&json!({ "input": true }))
704 }
705
706 fn started(
707 workflow_id: &WorkflowId,
708 workflow_type: &str,
709 ) -> Result<Event, aion_core::PayloadError> {
710 Ok(Event::WorkflowStarted {
711 envelope: EventEnvelope {
712 seq: 1,
713 recorded_at: Utc::now(),
714 workflow_id: workflow_id.clone(),
715 },
716 workflow_type: workflow_type.to_owned(),
717 input: payload()?,
718 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
719 parent_run_id: None,
720 parent_workflow_id: None,
721 package_version: aion_core::PackageVersion::new("a".repeat(64)),
722 })
723 }
724
725 fn completed(workflow_id: &WorkflowId) -> Result<Event, aion_core::PayloadError> {
726 Ok(Event::WorkflowCompleted {
727 envelope: EventEnvelope {
728 seq: 2,
729 recorded_at: Utc::now(),
730 workflow_id: workflow_id.clone(),
731 },
732 result: payload()?,
733 })
734 }
735
736 fn package_manifest() -> Manifest {
737 Manifest {
738 entry_module: "counter".to_owned(),
739 entry_function: "version".to_owned(),
740 input_schema: json!({ "type": "object" }),
741 output_schema: json!({ "type": "integer" }),
742 timeout: Some(Duration::from_secs(30)),
743 activities: vec![DeclaredActivity {
744 activity_type: "activity/test".to_owned(),
745 }],
746 version: ManifestVersion::new("test"),
747 format_version: CURRENT_FORMAT_VERSION,
748 additional_workflows: Vec::new(),
749 }
750 }
751
752 fn compile_counter_beam() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
753 let temp_dir =
754 std::env::temp_dir().join(format!("aion-engine-builder-{}", uuid::Uuid::new_v4()));
755 std::fs::create_dir(&temp_dir)?;
756 let source_path = temp_dir.join("counter.erl");
757 let beam_path = temp_dir.join("counter.beam");
758 std::fs::write(
759 &source_path,
760 "-module(counter).\n-export([version/0]).\nversion() -> 1.\n",
761 )?;
762 let status = Command::new("erlc")
763 .arg("-o")
764 .arg(&temp_dir)
765 .arg(&source_path)
766 .status()?;
767 if !status.success() {
768 let cleanup_result = std::fs::remove_dir_all(&temp_dir);
769 drop(cleanup_result);
770 return Err(format!("erlc failed with status {status}").into());
771 }
772 let bytes = std::fs::read(beam_path)?;
773 std::fs::remove_dir_all(temp_dir)?;
774 Ok(bytes)
775 }
776
777 fn fixture_package() -> Result<Package, Box<dyn std::error::Error>> {
778 let beams = BeamSet::new(vec![BeamModule::new("counter", compile_counter_beam()?)])?;
779 let archive = PackageBuilder::new(package_manifest(), beams).write_to_bytes()?;
780 Ok(Package::load_from_bytes(
781 archive,
782 ExtractionLimits::unbounded(),
783 )?)
784 }
785
786 fn write_fixture_package(package: &Package) -> Result<PathBuf, Box<dyn std::error::Error>> {
787 let path =
788 std::env::temp_dir().join(format!("aion-engine-builder-{}.aion", uuid::Uuid::new_v4()));
789 PackageBuilder::new(package.manifest().clone(), package.beams().clone())
790 .write_to_path(&path)?;
791 Ok(path)
792 }
793
794 #[tokio::test]
795 async fn build_without_store_returns_missing_store() {
796 let error = EngineBuilder::new().build().await.err();
797
798 assert!(matches!(error, Some(EngineError::MissingStore)));
799 }
800
801 #[tokio::test]
802 async fn build_without_visibility_store_returns_missing_visibility_store() {
803 let error = EngineBuilder::new()
804 .store(InMemoryStore::default())
805 .build()
806 .await
807 .err();
808
809 assert!(matches!(error, Some(EngineError::MissingVisibilityStore)));
810 }
811
812 #[tokio::test]
813 async fn in_memory_visibility_allows_build_without_visibility_store() -> Result<(), EngineError>
814 {
815 let engine = EngineBuilder::new()
816 .store(InMemoryStore::default())
817 .in_memory_visibility()
818 .build()
819 .await?;
820
821 engine.shutdown()?;
822 Ok(())
823 }
824
825 #[tokio::test]
859 async fn dropping_an_engine_without_shutdown_closes_the_completion_retry_epoch()
860 -> Result<(), EngineError> {
861 let visibility = Arc::new(TickCountingVisibilityStore::default());
862 let engine = EngineBuilder::new()
863 .store(InMemoryStore::default())
864 .visibility_store_arc(Arc::clone(&visibility) as Arc<dyn VisibilityStore>)
865 .visibility_reconciliation_interval(Duration::from_millis(10))
866 .build()
867 .await?;
868 let tasks = engine.runtime().engine_tasks();
872 assert!(
873 tasks.is_epoch_open(),
874 "control: a live engine's epoch must be open, or the assertion below is trivially \
875 satisfied"
876 );
877
878 let ticking = tokio::time::timeout(Duration::from_secs(10), async {
882 while visibility.ticks() < 2 {
883 tokio::time::sleep(Duration::from_millis(5)).await;
884 }
885 })
886 .await;
887 assert!(
888 ticking.is_ok(),
889 "control: the periodic reconciliation loop must be running before the engine is \
890 dropped, or this test measures nothing; it reached {} ticks",
891 visibility.ticks()
892 );
893
894 drop(engine);
895
896 assert!(
897 !tasks.is_epoch_open(),
898 "an engine released without an explicit shutdown must still close the epoch, or a \
899 completion retry can append a terminal for a run this process no longer owns"
900 );
901
902 tokio::time::sleep(Duration::from_millis(200)).await;
906 let after_abort = visibility.ticks();
907 tokio::time::sleep(Duration::from_millis(200)).await;
908 assert_eq!(
909 visibility.ticks(),
910 after_abort,
911 "an engine released without an explicit shutdown must also stop the visibility \
912 reconciliation loop; it ticked again over twenty intervals after the drop, which \
913 means a detached task is still WRITING to a visibility store this process no longer \
914 owns"
915 );
916 Ok(())
917 }
918
919 const WHEEL_TIMER_ARMING_MARGIN: std::time::Duration = std::time::Duration::from_secs(2);
930
931 const WHEEL_TIMER_OBSERVATION_SLACK: std::time::Duration = std::time::Duration::from_secs(1);
935
936 async fn arm_resident_run_with_wheel_timer(
954 engine: &crate::Engine,
955 store: &Arc<InMemoryStore>,
956 ) -> Result<(WorkflowId, chrono::DateTime<Utc>), Box<dyn std::error::Error>> {
957 use aion_store::EventStore;
958
959 use crate::durability::{Recorder, WorkflowStartRecord};
960 use crate::registry::{
961 CompletionNotifier, HandleResidency, WorkflowHandle, WorkflowHandleParts,
962 };
963
964 let workflow_id = WorkflowId::new_v4();
968 let run_id = aion_core::RunId::new_v4();
969 let timer_id = aion_core::TimerId::named("sleep")?;
970 let mut recorder = Recorder::new(
971 workflow_id.clone(),
972 Arc::clone(store) as Arc<dyn EventStore>,
973 );
974 recorder
975 .record_workflow_started(
976 Utc::now(),
977 WorkflowStartRecord {
978 workflow_type: "checkout".to_owned(),
979 input: payload()?,
980 run_id: run_id.clone(),
981 parent_run_id: None,
982 parent_workflow_id: None,
983 package_version: aion_core::PackageVersion::new("a".repeat(64)),
984 },
985 )
986 .await?;
987 recorder
988 .record_timer_started(Utc::now(), timer_id.clone(), Utc::now())
989 .await?;
990 engine.registry().insert(
991 (workflow_id.clone(), run_id.clone()),
992 WorkflowHandle::new(WorkflowHandleParts {
993 workflow_id: workflow_id.clone(),
994 run_id,
995 pid: 1,
996 workflow_type: "checkout".to_owned(),
997 namespace: String::from("default"),
998 loaded_version: aion_package::ContentHash::from_bytes([9; 32]),
999 cached_status: WorkflowStatus::Running,
1000 residency: HandleResidency::Resident,
1001 recorder,
1002 completion: CompletionNotifier::new(),
1003 }),
1004 )?;
1005
1006 let fire_at = Utc::now() + chrono::Duration::from_std(WHEEL_TIMER_ARMING_MARGIN)?;
1007 crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1008 .map_err(|error| format!("the engine installed no timer service: {error}"))?
1009 .schedule(workflow_id.clone(), timer_id, fire_at)
1010 .await?;
1011 Ok((workflow_id, fire_at))
1012 }
1013
1014 #[tokio::test]
1038 async fn dropping_an_engine_without_shutdown_disarms_the_live_timer_wheel()
1039 -> Result<(), Box<dyn std::error::Error>> {
1040 use aion_store::EventStore;
1041
1042 let store = Arc::new(InMemoryStore::default());
1043 let engine = EngineBuilder::new()
1044 .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1045 .visibility_store(InMemoryStore::default())
1046 .build()
1047 .await?;
1048 let nif_state = Arc::clone(engine.runtime().nif_state());
1050 let (workflow_id, fire_at) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1051 assert_eq!(
1052 nif_state.armed_wheel_timers(),
1053 1,
1054 "control: the fixture must have actually armed a wheel timer, or the assertions below \
1055 are satisfied by an engine that never had one — the exact way the sibling test above \
1056 is blind to this half of `Drop`"
1057 );
1058
1059 let before_drop = Utc::now();
1067 assert!(
1068 before_drop < fire_at,
1069 "fixture: the arming window closed before the release was reached ({before_drop} is \
1070 not before {fire_at}). This is a FIXTURE timing failure — most likely a loaded box — \
1071 and NOT evidence about cancellation. Widen `WHEEL_TIMER_ARMING_MARGIN`; do not read \
1072 this as `Drop` leaking a task."
1073 );
1074 let armed = store.read_history(&workflow_id).await?;
1075 assert!(
1076 !armed
1077 .iter()
1078 .any(|event| matches!(event, Event::TimerFired { .. })),
1079 "fixture: the timer already fired before the engine was released, so this run cannot \
1080 say anything about cancellation. Same remedy as above — widen the margin: {armed:#?}"
1081 );
1082
1083 drop(engine);
1084
1085 assert_eq!(
1086 nif_state.armed_wheel_timers(),
1087 0,
1088 "an engine released without an explicit shutdown must empty its live timer wheel"
1089 );
1090
1091 tokio::time::sleep(WHEEL_TIMER_ARMING_MARGIN + WHEEL_TIMER_OBSERVATION_SLACK).await;
1102 let history = store.read_history(&workflow_id).await?;
1103 assert!(
1104 !history
1105 .iter()
1106 .any(|event| matches!(event, Event::TimerFired { .. })),
1107 "an engine released without an explicit shutdown must CANCEL its wheel tasks, not \
1108 merely forget them: a surviving task records `TimerFired` for a run this process no \
1109 longer owns, which is a second writer for one workflow: {history:#?}"
1110 );
1111 Ok(())
1112 }
1113
1114 #[tokio::test]
1137 async fn arming_a_wheel_timer_after_the_engine_is_released_is_refused()
1138 -> Result<(), Box<dyn std::error::Error>> {
1139 use aion_store::EventStore;
1140
1141 let store = Arc::new(InMemoryStore::default());
1142 let engine = EngineBuilder::new()
1143 .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1144 .visibility_store(InMemoryStore::default())
1145 .build()
1146 .await?;
1147 let nif_state = Arc::clone(engine.runtime().nif_state());
1148 let (workflow_id, _fire_at) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1151 assert_eq!(
1152 nif_state.armed_wheel_timers(),
1153 1,
1154 "control: the fixture must have armed a wheel timer through the production path, or \
1155 the refusal below is satisfied by a fixture that could never arm one"
1156 );
1157
1158 drop(engine);
1159
1160 assert_eq!(nif_state.armed_wheel_timers(), 0);
1163
1164 let refused = crate::runtime::nif_timer_bridge::installed_timer_service(&nif_state)
1165 .map_err(|error| format!("the released engine's timer seam is gone: {error}"))?
1166 .schedule(
1167 workflow_id,
1168 aion_core::TimerId::named("sleep-after-release")?,
1169 Utc::now() + chrono::Duration::from_std(WHEEL_TIMER_ARMING_MARGIN)?,
1170 )
1171 .await;
1172 let Err(error) = refused else {
1173 return Err(
1174 "arming a wheel timer through a released engine must be REFUSED: the task \
1175 it spawns appends a durable `TimerFired` for a run this process no longer \
1176 owns, which is a second writer for one workflow"
1177 .into(),
1178 );
1179 };
1180 assert!(
1181 error.to_string().contains("torn down"),
1182 "the refusal must say WHY, so an operator reading it is not sent looking for a \
1183 missing workflow or a bad timer id: {error}"
1184 );
1185 assert_eq!(
1186 nif_state.armed_wheel_timers(),
1187 0,
1188 "the refused arm must leave nothing behind: a wheel entry inserted before the refusal \
1189 would be a durable writer with no owner to cancel it"
1190 );
1191 Ok(())
1192 }
1193
1194 #[tokio::test]
1203 async fn a_live_wheel_timer_fires_when_the_engine_is_not_released()
1204 -> Result<(), Box<dyn std::error::Error>> {
1205 use aion_store::EventStore;
1206
1207 let store = Arc::new(InMemoryStore::default());
1208 let engine = EngineBuilder::new()
1209 .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1210 .visibility_store(InMemoryStore::default())
1211 .build()
1212 .await?;
1213 let (workflow_id, _) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1214
1215 tokio::time::sleep(WHEEL_TIMER_ARMING_MARGIN + WHEEL_TIMER_OBSERVATION_SLACK).await;
1216
1217 let history = store.read_history(&workflow_id).await?;
1218 assert!(
1219 history
1220 .iter()
1221 .any(|event| matches!(event, Event::TimerFired { .. })),
1222 "the fixture's wheel timer must reach the store when the engine is still held, or \
1223 the sibling test's absence proves nothing: {history:#?}"
1224 );
1225 drop(engine);
1226 Ok(())
1227 }
1228
1229 #[derive(Default)]
1237 struct TickCountingVisibilityStore {
1238 inner: InMemoryStore,
1239 list_calls: std::sync::atomic::AtomicUsize,
1240 }
1241
1242 impl TickCountingVisibilityStore {
1243 fn ticks(&self) -> usize {
1244 self.list_calls.load(std::sync::atomic::Ordering::Acquire)
1245 }
1246 }
1247
1248 #[async_trait::async_trait]
1249 impl VisibilityStore for TickCountingVisibilityStore {
1250 async fn record_visibility(
1251 &self,
1252 record: aion_store::visibility::VisibilityRecord,
1253 ) -> Result<(), aion_store::StoreError> {
1254 self.inner.record_visibility(record).await
1255 }
1256
1257 async fn list_workflows(
1258 &self,
1259 filter: ListWorkflowsFilter,
1260 ) -> Result<Vec<aion_store::visibility::WorkflowSummary>, aion_store::StoreError> {
1261 self.list_calls
1262 .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
1263 self.inner.list_workflows(filter).await
1264 }
1265
1266 async fn count_workflows(
1267 &self,
1268 filter: ListWorkflowsFilter,
1269 ) -> Result<u64, aion_store::StoreError> {
1270 self.inner.count_workflows(filter).await
1271 }
1272 }
1273
1274 fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
1275 NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
1276 }
1277
1278 #[tokio::test]
1279 async fn event_streaming_delivers_recorder_appends_through_engine_subscribe()
1280 -> Result<(), Box<dyn std::error::Error>> {
1281 let engine = EngineBuilder::new()
1282 .store(InMemoryStore::default())
1283 .in_memory_visibility()
1284 .event_streaming(capacity(8)?)
1285 .build()
1286 .await?;
1287 let workflow_id = WorkflowId::new_v4();
1288 let mut subscription = engine.subscribe(crate::EventFilter {
1289 workflow_id: Some(workflow_id.clone()),
1290 run: None,
1291 family: None,
1292 });
1293
1294 let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1297 recorder
1298 .record_workflow_started(
1299 Utc::now(),
1300 crate::durability::WorkflowStartRecord {
1301 workflow_type: "checkout".to_owned(),
1302 input: payload()?,
1303 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(7)),
1304 parent_run_id: None,
1305 parent_workflow_id: None,
1306 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1307 },
1308 )
1309 .await?;
1310
1311 let item = tokio::time::timeout(Duration::from_secs(2), subscription.next())
1312 .await?
1313 .ok_or("subscription ended without delivering the appended event")?;
1314 let event = item?;
1315 assert_eq!(event.workflow_id(), &workflow_id);
1316 assert_eq!(event.seq(), 1);
1317 assert!(matches!(event, Event::WorkflowStarted { .. }));
1318 engine.shutdown()?;
1319 Ok(())
1320 }
1321
1322 #[tokio::test]
1323 async fn without_event_streaming_subscriptions_stay_on_deferred_empty_stream()
1324 -> Result<(), Box<dyn std::error::Error>> {
1325 let engine = EngineBuilder::new()
1326 .store(InMemoryStore::default())
1327 .in_memory_visibility()
1328 .build()
1329 .await?;
1330
1331 let mut subscription = engine.subscribe(crate::EventFilter::default());
1332 let item = tokio::time::timeout(Duration::from_secs(2), subscription.next()).await?;
1333
1334 assert!(item.is_none(), "deferred publisher streams must be empty");
1335 engine.shutdown()?;
1336 Ok(())
1337 }
1338
1339 #[tokio::test]
1340 async fn event_streaming_conflicts_with_explicit_event_publisher()
1341 -> Result<(), Box<dyn std::error::Error>> {
1342 let error = EngineBuilder::new()
1343 .store(InMemoryStore::default())
1344 .in_memory_visibility()
1345 .event_publisher(Arc::new(crate::DeferredEventPublisher))
1346 .event_streaming(capacity(8)?)
1347 .build()
1348 .await
1349 .err();
1350
1351 assert!(matches!(
1352 error,
1353 Some(EngineError::ConflictingEventPublisher)
1354 ));
1355 Ok(())
1356 }
1357
1358 #[test]
1359 fn query_timeout_is_only_set_by_caller() {
1360 assert_eq!(EngineBuilder::new().configured_query_timeout(), None);
1361 assert_eq!(
1362 EngineBuilder::new()
1363 .query_timeout(Duration::from_secs(3))
1364 .configured_query_timeout(),
1365 Some(Duration::from_secs(3))
1366 );
1367 }
1368
1369 async fn insert_running_workflow(
1370 engine: &crate::Engine,
1371 ) -> Result<(WorkflowId, aion_core::RunId), Box<dyn std::error::Error>> {
1372 let workflow_id = WorkflowId::new_v4();
1373 let run_id = aion_core::RunId::new_v4();
1374 let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1375 recorder
1376 .record_workflow_started(
1377 Utc::now(),
1378 crate::durability::WorkflowStartRecord {
1379 workflow_type: "checkout".to_owned(),
1380 input: payload()?,
1381 run_id: run_id.clone(),
1382 parent_run_id: None,
1383 parent_workflow_id: None,
1384 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1385 },
1386 )
1387 .await?;
1388 let handle = crate::registry::WorkflowHandle::new(crate::registry::WorkflowHandleParts {
1389 workflow_id: workflow_id.clone(),
1390 run_id: run_id.clone(),
1391 pid: engine.runtime().spawn_test_process_with_trap_exit(true)?,
1392 workflow_type: "checkout".to_owned(),
1393 namespace: String::from("default"),
1394 loaded_version: aion_package::ContentHash::from_bytes([2; 32]),
1395 cached_status: WorkflowStatus::Running,
1396 residency: crate::registry::HandleResidency::Resident,
1397 recorder,
1398 completion: crate::registry::CompletionNotifier::new(),
1399 });
1400 engine
1401 .registry()
1402 .insert((workflow_id.clone(), run_id.clone()), handle)?;
1403 Ok((workflow_id, run_id))
1404 }
1405
1406 #[tokio::test]
1407 async fn query_timeout_installs_the_concrete_query_seam()
1408 -> Result<(), Box<dyn std::error::Error>> {
1409 let engine = EngineBuilder::new()
1410 .store(InMemoryStore::default())
1411 .in_memory_visibility()
1412 .query_timeout(Duration::from_millis(250))
1413 .build()
1414 .await?;
1415 let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1416
1417 let result = engine
1421 .query(
1422 &workflow_id,
1423 &run_id,
1424 "state",
1425 aion_core::Payload::json_null(),
1426 )
1427 .await;
1428
1429 assert!(matches!(
1430 result,
1431 Err(crate::EngineError::Query(crate::QueryError::UnknownQuery(name))) if name == "state"
1432 ));
1433 engine.shutdown()?;
1434 Ok(())
1435 }
1436
1437 #[tokio::test]
1438 async fn without_query_timeout_the_query_seam_stays_deferred()
1439 -> Result<(), Box<dyn std::error::Error>> {
1440 let engine = EngineBuilder::new()
1441 .store(InMemoryStore::default())
1442 .in_memory_visibility()
1443 .build()
1444 .await?;
1445 let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1446
1447 let result = engine
1448 .query(
1449 &workflow_id,
1450 &run_id,
1451 "state",
1452 aion_core::Payload::json_null(),
1453 )
1454 .await;
1455
1456 assert!(matches!(
1457 result,
1458 Err(crate::EngineError::Runtime { reason }) if reason.contains("not configured")
1459 ));
1460 engine.shutdown()?;
1461 Ok(())
1462 }
1463
1464 #[test]
1465 fn owned_shards_are_only_set_by_caller() {
1466 assert_eq!(EngineBuilder::new().configured_owned_shards(), None);
1470 assert_eq!(
1471 EngineBuilder::new()
1472 .owned_shards([2, 0, 2, 1])
1473 .configured_owned_shards(),
1474 Some([2, 0, 2, 1].as_slice())
1475 );
1476 }
1477
1478 #[test]
1479 fn scheduler_threads_are_only_set_by_caller() {
1480 assert_eq!(EngineBuilder::new().scheduler_thread_count(), None);
1481 assert_eq!(
1482 EngineBuilder::new()
1483 .scheduler_threads(4)
1484 .scheduler_thread_count(),
1485 Some(4)
1486 );
1487 }
1488
1489 #[test]
1503 fn the_completion_retry_ladder_reaches_the_runtime_configuration()
1504 -> Result<(), Box<dyn std::error::Error>> {
1505 let chosen = crate::runtime::CompletionRetryConfig::try_new(
1506 Duration::from_millis(250),
1507 Duration::from_secs(7),
1508 )?;
1509 assert_ne!(
1510 chosen,
1511 crate::runtime::CompletionRetryConfig::default(),
1512 "a ladder equal to the default could not distinguish wiring from inheritance"
1513 );
1514
1515 assert_eq!(
1516 EngineBuilder::new()
1517 .completion_retry(chosen)
1518 .runtime_config()
1519 .completion_retry,
1520 chosen,
1521 "the runtime must be started with the operator's ladder, not the inherited default"
1522 );
1523 Ok(())
1524 }
1525
1526 #[test]
1548 fn the_jit_threshold_reaches_the_runtime_configuration() {
1549 assert_eq!(
1550 EngineBuilder::new().runtime_config().jit_threshold,
1551 None,
1552 "an unset builder must pass None through, so beamr applies its own default rather \
1553 than a value aion invented"
1554 );
1555
1556 assert_eq!(
1557 EngineBuilder::new()
1558 .scheduler_jit_threshold(100)
1559 .runtime_config()
1560 .jit_threshold,
1561 Some(100),
1562 "the scheduler must be started with the operator's threshold, not beamr's default — \
1563 otherwise a proportionality run reports on the default while claiming to vary it"
1564 );
1565 }
1566
1567 #[test]
1568 fn visibility_reconciliation_interval_is_only_set_by_caller() {
1569 let interval = Duration::from_millis(250);
1570
1571 assert_eq!(
1572 EngineBuilder::new().configured_visibility_reconciliation_interval(),
1573 None
1574 );
1575 assert_eq!(
1576 EngineBuilder::new()
1577 .visibility_reconciliation_interval(interval)
1578 .configured_visibility_reconciliation_interval(),
1579 Some(interval)
1580 );
1581 }
1582
1583 #[tokio::test]
1584 async fn duplicate_host_nif_mfa_returns_typed_error() {
1585 let mfa = Mfa::new("host", "zero", 0);
1586 let error = EngineBuilder::new()
1587 .store(InMemoryStore::default())
1588 .in_memory_visibility()
1589 .register_nifs([
1590 NifEntry::new(
1591 mfa.clone(),
1592 crate::runtime::nif::test_native_zero,
1593 Determinism::Pure,
1594 ),
1595 NifEntry::dirty(
1596 mfa,
1597 crate::runtime::nif::test_native_zero,
1598 Determinism::Pure,
1599 ),
1600 ])
1601 .build()
1602 .await
1603 .err();
1604
1605 assert!(matches!(
1606 error,
1607 Some(EngineError::NifRegistration { reason }) if reason.contains("host:zero/0")
1608 ));
1609 }
1610
1611 #[tokio::test]
1612 async fn empty_store_builds_coordinator_history_without_registry_or_supervision()
1613 -> Result<(), EngineError> {
1614 let store = Arc::new(InMemoryStore::default());
1615 let engine = EngineBuilder::new()
1616 .store_arc(store.clone())
1617 .in_memory_visibility()
1618 .build()
1619 .await?;
1620
1621 assert!(engine.registry().list()?.is_empty());
1622 assert_eq!(engine.supervision().type_supervisor_count()?, 1);
1623 assert_eq!(engine.workflow_catalog().workflows()?.len(), 0);
1624
1625 let coordinator_id = schedule_coordinator_workflow_id();
1626 let active = store.list_active().await?;
1627 assert_eq!(active, vec![coordinator_id.clone()]);
1628 let history = store.read_history(&coordinator_id).await?;
1629 let [started] = history.as_slice() else {
1630 return Err(EngineError::Load {
1631 reason: format!(
1632 "expected exactly one coordinator event, found {}",
1633 history.len()
1634 ),
1635 });
1636 };
1637 match started {
1638 Event::WorkflowStarted {
1639 workflow_type,
1640 input,
1641 run_id,
1642 parent_run_id,
1643 ..
1644 } => {
1645 assert_eq!(workflow_type, schedule_coordinator_workflow_type());
1646 assert_eq!(
1647 input,
1648 &Payload::from_json(&json!({})).map_err(|error| {
1649 EngineError::Load {
1650 reason: format!("failed to build expected payload: {error}"),
1651 }
1652 })?
1653 );
1654 assert_eq!(run_id, &schedule_coordinator_run_id());
1655 assert!(parent_run_id.is_none());
1656 }
1657 other => {
1658 return Err(EngineError::Load {
1659 reason: format!("expected coordinator WorkflowStarted, found {other:?}"),
1660 });
1661 }
1662 }
1663
1664 engine.shutdown()?;
1665 let rebuilt = EngineBuilder::new()
1666 .store_arc(store.clone())
1667 .in_memory_visibility()
1668 .build()
1669 .await?;
1670 let rebuilt_history = store.read_history(&coordinator_id).await?;
1671 assert_eq!(rebuilt_history.len(), 1);
1672 rebuilt.shutdown()?;
1673
1674 Ok(())
1675 }
1676
1677 #[tokio::test]
1678 async fn build_loads_already_loaded_package() -> Result<(), Box<dyn std::error::Error>> {
1679 if gleam_test_support::skip_if_unavailable() {
1680 return Ok(());
1681 }
1682 let package = fixture_package()?;
1683 let version = package.content_hash().clone();
1684 let deployed_entry_module = package.deployed_entry_module();
1685
1686 let engine = EngineBuilder::new()
1687 .store(InMemoryStore::default())
1688 .in_memory_visibility()
1689 .load_workflows(package)
1690 .build()
1691 .await?;
1692
1693 let loaded = engine
1694 .workflow_catalog()
1695 .get("counter", &version)?
1696 .ok_or("loaded package record missing")?;
1697 assert_eq!(loaded.deployed_entry_module(), deployed_entry_module);
1698 assert!(
1699 engine
1700 .runtime()
1701 .has_registered_module(&deployed_entry_module)
1702 );
1703 Ok(())
1704 }
1705
1706 #[tokio::test]
1707 async fn startup_reconciliation_backfills_completed_visibility()
1708 -> Result<(), Box<dyn std::error::Error>> {
1709 let store = Arc::new(InMemoryStore::default());
1710 let completed_id = WorkflowId::new_v4();
1711
1712 store
1713 .append(
1714 WriteToken::recorder(),
1715 &completed_id,
1716 &[
1717 started(&completed_id, "billing")?,
1718 completed(&completed_id)?,
1719 ],
1720 0,
1721 )
1722 .await?;
1723
1724 let engine = EngineBuilder::new()
1725 .store_arc(store.clone())
1726 .visibility_store_arc(store.clone())
1727 .build()
1728 .await?;
1729
1730 let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1731 let completed_summary = summaries
1732 .iter()
1733 .find(|summary| summary.workflow_id == completed_id)
1734 .ok_or("completed workflow missing from visibility")?;
1735
1736 assert_eq!(completed_summary.status, WorkflowStatus::Completed);
1737 assert!(completed_summary.close_time.is_some());
1738 engine.shutdown()?;
1739 Ok(())
1740 }
1741
1742 #[tokio::test]
1743 async fn periodic_visibility_reconciliation_repairs_gap_after_startup()
1744 -> Result<(), Box<dyn std::error::Error>> {
1745 let store = Arc::new(InMemoryStore::default());
1746 let engine = EngineBuilder::new()
1747 .store_arc(store.clone())
1748 .visibility_store_arc(store.clone())
1749 .visibility_reconciliation_interval(Duration::from_millis(25))
1750 .build()
1751 .await?;
1752 let workflow_id = WorkflowId::new_v4();
1753
1754 store
1755 .append(
1756 WriteToken::recorder(),
1757 &workflow_id,
1758 &[started(&workflow_id, "checkout")?],
1759 0,
1760 )
1761 .await?;
1762
1763 tokio::time::timeout(Duration::from_secs(2), async {
1764 loop {
1765 let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1766 if summaries.iter().any(|summary| {
1767 summary.workflow_id == workflow_id && summary.status == WorkflowStatus::Running
1768 }) {
1769 return Ok::<(), aion_store::StoreError>(());
1770 }
1771 tokio::time::sleep(Duration::from_millis(10)).await;
1772 }
1773 })
1774 .await??;
1775
1776 engine.shutdown()?;
1777 Ok(())
1778 }
1779
1780 #[tokio::test]
1781 async fn build_loads_package_from_path() -> Result<(), Box<dyn std::error::Error>> {
1782 if gleam_test_support::skip_if_unavailable() {
1783 return Ok(());
1784 }
1785 let package = fixture_package()?;
1786 let version = package.content_hash().clone();
1787 let path = write_fixture_package(&package)?;
1788
1789 let engine = EngineBuilder::new()
1790 .store(InMemoryStore::default())
1791 .in_memory_visibility()
1792 .load_workflows(path.as_path())
1793 .build()
1794 .await?;
1795 std::fs::remove_file(path)?;
1796
1797 assert!(
1798 engine
1799 .workflow_catalog()
1800 .get("counter", &version)?
1801 .is_some()
1802 );
1803 Ok(())
1804 }
1805}