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 signal_delivery: SignalDeliveryConfig,
90 completion_retry: crate::runtime::CompletionRetryConfig,
91 outbox_enabled: bool,
92 bootstrap_schedule_coordinator: bool,
93 owned_shards: Option<Vec<usize>>,
94 workflow_sources: Vec<WorkflowPackageSource>,
95 host_nifs: Vec<NifEntry>,
96 recovery: Option<Arc<dyn ActiveWorkflowRecoverySeam>>,
97 delegated: DelegatedSeams,
98 signal_router_factory: Option<SignalRouterFactory>,
99 activity_dispatcher: Option<Arc<dyn ActivityDispatcher>>,
100 activity_serving: ActivityServing,
101 active_registry: Option<Arc<Registry>>,
102 visibility_reconciliation_interval: Option<Duration>,
103 search_attribute_schema: SearchAttributeSchema,
104 event_streaming_capacity: Option<NonZeroUsize>,
105 query_timeout: Option<Duration>,
106 seam_overrides: SeamOverrides,
107 defer_startup_recovery: bool,
108}
109
110impl Default for EngineBuilder {
111 fn default() -> Self {
112 Self::new()
113 }
114}
115
116impl EngineBuilder {
117 #[must_use]
120 pub fn new() -> Self {
121 Self {
122 store: None,
123 visibility_store: None,
124 scheduler_threads: None,
125 signal_delivery: SignalDeliveryConfig::default(),
126 completion_retry: crate::runtime::CompletionRetryConfig::default(),
127 outbox_enabled: false,
128 bootstrap_schedule_coordinator: true,
132 owned_shards: None,
136 workflow_sources: Vec::new(),
137 host_nifs: Vec::new(),
138 recovery: None,
139 delegated: DelegatedSeams::default(),
140 signal_router_factory: None,
141 activity_dispatcher: None,
142 activity_serving: ActivityServing::QueueRouted,
143 active_registry: None,
144 visibility_reconciliation_interval: None,
145 search_attribute_schema: SearchAttributeSchema::new(),
146 event_streaming_capacity: None,
147 query_timeout: None,
148 seam_overrides: SeamOverrides::default(),
149 defer_startup_recovery: false,
150 }
151 }
152
153 #[must_use]
161 pub const fn query_timeout(mut self, timeout: Duration) -> Self {
162 self.query_timeout = Some(timeout);
163 self
164 }
165
166 #[must_use]
168 pub const fn configured_query_timeout(&self) -> Option<Duration> {
169 self.query_timeout
170 }
171
172 #[must_use]
182 pub const fn event_streaming(mut self, capacity: NonZeroUsize) -> Self {
183 self.event_streaming_capacity = Some(capacity);
184 self
185 }
186
187 #[must_use]
193 pub fn search_attribute_schema(mut self, schema: SearchAttributeSchema) -> Self {
194 self.search_attribute_schema = schema;
195 self
196 }
197
198 #[must_use]
200 pub fn store<S>(mut self, store: S) -> Self
201 where
202 S: EventStore,
203 {
204 self.store = Some(Arc::new(store));
205 self
206 }
207
208 #[must_use]
210 pub fn store_arc(mut self, store: Arc<dyn EventStore>) -> Self {
211 self.store = Some(store);
212 self
213 }
214
215 #[must_use]
217 pub fn visibility_store<S>(mut self, visibility_store: S) -> Self
218 where
219 S: VisibilityStore,
220 {
221 self.visibility_store = Some(Arc::new(visibility_store));
222 self
223 }
224
225 #[must_use]
227 pub fn visibility_store_arc(mut self, visibility_store: Arc<dyn VisibilityStore>) -> Self {
228 self.visibility_store = Some(visibility_store);
229 self
230 }
231
232 #[must_use]
238 pub fn in_memory_visibility(mut self) -> Self {
239 self.visibility_store = Some(Arc::new(InMemoryStore::default()));
240 self
241 }
242
243 #[must_use]
247 pub const fn scheduler_threads(mut self, threads: usize) -> Self {
248 self.scheduler_threads = Some(threads);
249 self
250 }
251
252 #[must_use]
256 pub const fn visibility_reconciliation_interval(mut self, interval: Duration) -> Self {
257 self.visibility_reconciliation_interval = Some(interval);
258 self
259 }
260
261 #[must_use]
263 pub const fn signal_delivery(mut self, signal_delivery: SignalDeliveryConfig) -> Self {
264 self.signal_delivery = signal_delivery;
265 self
266 }
267
268 #[must_use]
275 pub const fn completion_retry(
276 mut self,
277 completion_retry: crate::runtime::CompletionRetryConfig,
278 ) -> Self {
279 self.completion_retry = completion_retry;
280 self
281 }
282
283 #[must_use]
285 pub fn outbox_enabled(mut self, enabled: bool) -> Self {
286 self.outbox_enabled = enabled;
287 self
288 }
289
290 #[must_use]
298 pub const fn bootstrap_schedule_coordinator(mut self, enabled: bool) -> Self {
299 self.bootstrap_schedule_coordinator = enabled;
300 self
301 }
302
303 #[must_use]
318 pub fn owned_shards(mut self, shards: impl IntoIterator<Item = usize>) -> Self {
319 self.owned_shards = Some(shards.into_iter().collect());
320 self
321 }
322
323 #[must_use]
325 pub fn configured_owned_shards(&self) -> Option<&[usize]> {
326 self.owned_shards.as_deref()
327 }
328
329 #[must_use]
331 pub fn load_workflows(mut self, source: impl Into<WorkflowPackageSource>) -> Self {
332 self.workflow_sources.push(source.into());
333 self
334 }
335
336 #[must_use]
338 pub fn load_workflow_sources<I, S>(mut self, sources: I) -> Self
339 where
340 I: IntoIterator<Item = S>,
341 S: Into<WorkflowPackageSource>,
342 {
343 self.workflow_sources
344 .extend(sources.into_iter().map(Into::into));
345 self
346 }
347
348 #[must_use]
350 pub fn register_nifs(mut self, entries: impl IntoIterator<Item = NifEntry>) -> Self {
351 self.host_nifs.extend(entries);
352 self
353 }
354
355 #[must_use]
357 pub fn recovery_seam(mut self, recovery: Arc<dyn ActiveWorkflowRecoverySeam>) -> Self {
358 self.recovery = Some(recovery);
359 self
360 }
361
362 #[must_use]
364 pub fn production_recovery_seam(mut self) -> Self {
365 self.recovery = None;
366 self
367 }
368
369 #[must_use]
380 pub fn defer_startup_recovery(mut self) -> Self {
381 self.defer_startup_recovery = true;
382 self
383 }
384
385 #[must_use]
387 pub fn signal_router(mut self, signal_router: Arc<dyn SignalRouter>) -> Self {
388 self.signal_router_factory = None;
389 self.delegated = DelegatedSeams::new(
390 signal_router,
391 self.delegated.query_service_arc(),
392 self.delegated.event_publisher_arc(),
393 );
394 self
395 }
396
397 #[must_use]
399 pub fn signal_router_factory<F>(mut self, factory: F) -> Self
400 where
401 F: Fn(Arc<RuntimeHandle>, Arc<SignalResumeHandoff>) -> Arc<dyn SignalRouter>
402 + Send
403 + Sync
404 + 'static,
405 {
406 self.signal_router_factory = Some(Arc::new(factory));
407 self
408 }
409
410 #[must_use]
415 pub fn query_service(mut self, query_service: Arc<dyn QueryService>) -> Self {
416 self.seam_overrides.query_service = true;
417 self.delegated = DelegatedSeams::new(
418 self.delegated.signal_router_arc(),
419 query_service,
420 self.delegated.event_publisher_arc(),
421 );
422 self
423 }
424
425 #[must_use]
430 pub fn event_publisher(mut self, event_publisher: Arc<dyn EventPublisher>) -> Self {
431 self.seam_overrides.event_publisher = true;
432 self.delegated = DelegatedSeams::new(
433 self.delegated.signal_router_arc(),
434 self.delegated.query_service_arc(),
435 event_publisher,
436 );
437 self
438 }
439
440 #[must_use]
446 pub fn activity_dispatcher(mut self, dispatcher: Arc<dyn ActivityDispatcher>) -> Self {
447 self.activity_dispatcher = Some(dispatcher);
448 self
449 }
450
451 #[must_use]
463 pub const fn in_process_activity_serving(mut self) -> Self {
464 self.activity_serving = ActivityServing::InProcess;
465 self
466 }
467
468 #[must_use]
473 pub fn active_registry(mut self, registry: Arc<Registry>) -> Self {
474 self.active_registry = Some(registry);
475 self
476 }
477
478 #[must_use]
480 pub const fn scheduler_thread_count(&self) -> Option<usize> {
481 self.scheduler_threads
482 }
483
484 #[must_use]
486 pub const fn configured_visibility_reconciliation_interval(&self) -> Option<Duration> {
487 self.visibility_reconciliation_interval
488 }
489
490 fn runtime_config(&self) -> RuntimeConfig {
493 RuntimeConfig::new(self.scheduler_threads)
494 .with_signal_delivery(self.signal_delivery)
495 .with_completion_retry(self.completion_retry)
496 .with_outbox_enabled(self.outbox_enabled)
497 }
498
499 fn start_runtime_with_nifs(
501 runtime_config: RuntimeConfig,
502 host_nifs: Vec<NifEntry>,
503 ) -> Result<Arc<RuntimeHandle>, EngineError> {
504 let runtime = Arc::new(RuntimeHandle::new(runtime_config)?);
505 let mut nifs = NifRegistration::new();
506 nifs.add_engine_nifs().add_host_nifs(host_nifs);
507 runtime.install_nifs(nifs)?;
508 Ok(runtime)
509 }
510
511 pub async fn build(self) -> Result<Engine, EngineError> {
519 let runtime_config = self.runtime_config();
520 let (store, streaming_publisher) = wrap_event_streaming(
521 self.store.ok_or(EngineError::MissingStore)?,
522 self.event_streaming_capacity,
523 self.seam_overrides.event_publisher,
524 )?;
525 let visibility_store = self
526 .visibility_store
527 .ok_or(EngineError::MissingVisibilityStore)?;
528 claim_owned_shards(store.as_ref(), self.owned_shards.as_deref())?;
529
530 let runtime = Self::start_runtime_with_nifs(runtime_config, self.host_nifs)?;
531
532 let catalog = assemble_startup_catalog(
536 runtime.as_ref(),
537 store.as_ref(),
538 self.workflow_sources,
539 self.activity_serving,
540 )
541 .await?;
542
543 let registry = self
544 .active_registry
545 .unwrap_or_else(|| Arc::new(Registry::default()));
546 let nif_state = Arc::clone(runtime.nif_state());
547 let query_mailbox_engine = install_engine_nif_seams(
548 &nif_state,
549 ®istry,
550 &store,
551 &runtime,
552 self.activity_dispatcher,
553 self.query_timeout,
554 );
555 let supervision = Arc::new(SupervisionTree::new());
556 let search_attribute_schema = Arc::new(self.search_attribute_schema);
557 let signal_handoff = Arc::new(SignalResumeHandoff::new());
558
559 let delegated = assemble_delegated_seams(SeamAssembly {
560 configured: self.delegated,
561 signal_router_factory: self.signal_router_factory,
562 runtime: Arc::clone(&runtime),
563 signal_handoff: Arc::clone(&signal_handoff),
564 streaming_publisher,
565 query_mailbox_engine,
566 query_timeout: self.query_timeout,
567 query_service_overridden: self.seam_overrides.query_service,
568 });
569
570 install_workflow_nif_bridges(
577 &ChildBridgeAssembly {
578 nif_state: &nif_state,
579 store: &store,
580 visibility_store: &visibility_store,
581 runtime: &runtime,
582 catalog: &catalog,
583 registry: ®istry,
584 supervision: &supervision,
585 signal_handoff: &signal_handoff,
586 search_attribute_schema: &search_attribute_schema,
587 watch_backoff: self.signal_delivery,
588 },
589 &delegated,
590 )?;
591
592 let deferred_startup_recovery = resolve_startup_recovery(
596 self.defer_startup_recovery,
597 &nif_state,
598 StartupRecoveryContext {
599 store: Arc::clone(&store),
600 visibility_store: Arc::clone(&visibility_store),
601 runtime: Arc::clone(&runtime),
602 catalog: Arc::clone(&catalog),
603 registry: Arc::clone(®istry),
604 supervision: Arc::clone(&supervision),
605 recovery: self.recovery,
606 search_attribute_schema: Arc::clone(&search_attribute_schema),
607 bootstrap_schedule_coordinator: self.bootstrap_schedule_coordinator,
608 },
609 )
610 .await?;
611
612 let visibility_reconciliation_task = maybe_spawn_visibility_reconciliation(
613 self.visibility_reconciliation_interval,
614 &store,
615 &visibility_store,
616 );
617
618 let deferred = deferred_startup_recovery.is_some();
619 let engine = Engine::new(EngineComponents {
620 store,
621 visibility_store,
622 runtime,
623 catalog,
624 registry,
625 supervision,
626 delegated,
627 signal_handoff,
628 search_attribute_schema,
629 visibility_reconciliation_task,
630 deferred_startup_recovery,
631 });
632 if !deferred {
633 engine.catchup_schedule_coordinator().await?;
634 engine.recover_schedules_on_startup(Utc::now()).await?;
635 }
636 Ok(engine)
637 }
638}
639
640#[cfg(test)]
641mod tests {
642 use super::super::gleam_test_support;
658
659 use std::{num::NonZeroUsize, path::PathBuf, process::Command, sync::Arc, time::Duration};
660
661 use aion_core::{Event, EventEnvelope, Payload, WorkflowId, WorkflowStatus};
662 use aion_package::{
663 BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, ExtractionLimits, Manifest,
664 ManifestVersion, Package, PackageBuilder,
665 };
666 use aion_store::visibility::{ListWorkflowsFilter, VisibilityStore};
667 use aion_store::{InMemoryStore, ReadableEventStore, WritableEventStore, WriteToken};
668 use chrono::Utc;
669 use futures::StreamExt;
670 use serde_json::json;
671
672 use crate::engine::api_schedule::{
673 schedule_coordinator_run_id, schedule_coordinator_workflow_id,
674 schedule_coordinator_workflow_type,
675 };
676 use crate::runtime::{Determinism, Mfa, NifEntry};
677
678 use super::EngineBuilder;
679 use crate::EngineError;
680
681 fn payload() -> Result<Payload, aion_core::PayloadError> {
682 Payload::from_json(&json!({ "input": true }))
683 }
684
685 fn started(
686 workflow_id: &WorkflowId,
687 workflow_type: &str,
688 ) -> Result<Event, aion_core::PayloadError> {
689 Ok(Event::WorkflowStarted {
690 envelope: EventEnvelope {
691 seq: 1,
692 recorded_at: Utc::now(),
693 workflow_id: workflow_id.clone(),
694 },
695 workflow_type: workflow_type.to_owned(),
696 input: payload()?,
697 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
698 parent_run_id: None,
699 parent_workflow_id: None,
700 package_version: aion_core::PackageVersion::new("a".repeat(64)),
701 })
702 }
703
704 fn completed(workflow_id: &WorkflowId) -> Result<Event, aion_core::PayloadError> {
705 Ok(Event::WorkflowCompleted {
706 envelope: EventEnvelope {
707 seq: 2,
708 recorded_at: Utc::now(),
709 workflow_id: workflow_id.clone(),
710 },
711 result: payload()?,
712 })
713 }
714
715 fn package_manifest() -> Manifest {
716 Manifest {
717 entry_module: "counter".to_owned(),
718 entry_function: "version".to_owned(),
719 input_schema: json!({ "type": "object" }),
720 output_schema: json!({ "type": "integer" }),
721 timeout: Some(Duration::from_secs(30)),
722 activities: vec![DeclaredActivity {
723 activity_type: "activity/test".to_owned(),
724 }],
725 version: ManifestVersion::new("test"),
726 format_version: CURRENT_FORMAT_VERSION,
727 additional_workflows: Vec::new(),
728 }
729 }
730
731 fn compile_counter_beam() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
732 let temp_dir =
733 std::env::temp_dir().join(format!("aion-engine-builder-{}", uuid::Uuid::new_v4()));
734 std::fs::create_dir(&temp_dir)?;
735 let source_path = temp_dir.join("counter.erl");
736 let beam_path = temp_dir.join("counter.beam");
737 std::fs::write(
738 &source_path,
739 "-module(counter).\n-export([version/0]).\nversion() -> 1.\n",
740 )?;
741 let status = Command::new("erlc")
742 .arg("-o")
743 .arg(&temp_dir)
744 .arg(&source_path)
745 .status()?;
746 if !status.success() {
747 let cleanup_result = std::fs::remove_dir_all(&temp_dir);
748 drop(cleanup_result);
749 return Err(format!("erlc failed with status {status}").into());
750 }
751 let bytes = std::fs::read(beam_path)?;
752 std::fs::remove_dir_all(temp_dir)?;
753 Ok(bytes)
754 }
755
756 fn fixture_package() -> Result<Package, Box<dyn std::error::Error>> {
757 let beams = BeamSet::new(vec![BeamModule::new("counter", compile_counter_beam()?)])?;
758 let archive = PackageBuilder::new(package_manifest(), beams).write_to_bytes()?;
759 Ok(Package::load_from_bytes(
760 archive,
761 ExtractionLimits::unbounded(),
762 )?)
763 }
764
765 fn write_fixture_package(package: &Package) -> Result<PathBuf, Box<dyn std::error::Error>> {
766 let path =
767 std::env::temp_dir().join(format!("aion-engine-builder-{}.aion", uuid::Uuid::new_v4()));
768 PackageBuilder::new(package.manifest().clone(), package.beams().clone())
769 .write_to_path(&path)?;
770 Ok(path)
771 }
772
773 #[tokio::test]
774 async fn build_without_store_returns_missing_store() {
775 let error = EngineBuilder::new().build().await.err();
776
777 assert!(matches!(error, Some(EngineError::MissingStore)));
778 }
779
780 #[tokio::test]
781 async fn build_without_visibility_store_returns_missing_visibility_store() {
782 let error = EngineBuilder::new()
783 .store(InMemoryStore::default())
784 .build()
785 .await
786 .err();
787
788 assert!(matches!(error, Some(EngineError::MissingVisibilityStore)));
789 }
790
791 #[tokio::test]
792 async fn in_memory_visibility_allows_build_without_visibility_store() -> Result<(), EngineError>
793 {
794 let engine = EngineBuilder::new()
795 .store(InMemoryStore::default())
796 .in_memory_visibility()
797 .build()
798 .await?;
799
800 engine.shutdown()?;
801 Ok(())
802 }
803
804 #[tokio::test]
838 async fn dropping_an_engine_without_shutdown_closes_the_completion_retry_epoch()
839 -> Result<(), EngineError> {
840 let visibility = Arc::new(TickCountingVisibilityStore::default());
841 let engine = EngineBuilder::new()
842 .store(InMemoryStore::default())
843 .visibility_store_arc(Arc::clone(&visibility) as Arc<dyn VisibilityStore>)
844 .visibility_reconciliation_interval(Duration::from_millis(10))
845 .build()
846 .await?;
847 let tasks = engine.runtime().engine_tasks();
851 assert!(
852 tasks.is_epoch_open(),
853 "control: a live engine's epoch must be open, or the assertion below is trivially \
854 satisfied"
855 );
856
857 let ticking = tokio::time::timeout(Duration::from_secs(10), async {
861 while visibility.ticks() < 2 {
862 tokio::time::sleep(Duration::from_millis(5)).await;
863 }
864 })
865 .await;
866 assert!(
867 ticking.is_ok(),
868 "control: the periodic reconciliation loop must be running before the engine is \
869 dropped, or this test measures nothing; it reached {} ticks",
870 visibility.ticks()
871 );
872
873 drop(engine);
874
875 assert!(
876 !tasks.is_epoch_open(),
877 "an engine released without an explicit shutdown must still close the epoch, or a \
878 completion retry can append a terminal for a run this process no longer owns"
879 );
880
881 tokio::time::sleep(Duration::from_millis(200)).await;
885 let after_abort = visibility.ticks();
886 tokio::time::sleep(Duration::from_millis(200)).await;
887 assert_eq!(
888 visibility.ticks(),
889 after_abort,
890 "an engine released without an explicit shutdown must also stop the visibility \
891 reconciliation loop; it ticked again over twenty intervals after the drop, which \
892 means a detached task is still WRITING to a visibility store this process no longer \
893 owns"
894 );
895 Ok(())
896 }
897
898 const WHEEL_TIMER_ARMING_MARGIN: std::time::Duration = std::time::Duration::from_secs(2);
909
910 const WHEEL_TIMER_OBSERVATION_SLACK: std::time::Duration = std::time::Duration::from_secs(1);
914
915 async fn arm_resident_run_with_wheel_timer(
933 engine: &crate::Engine,
934 store: &Arc<InMemoryStore>,
935 ) -> Result<(WorkflowId, chrono::DateTime<Utc>), Box<dyn std::error::Error>> {
936 use aion_store::EventStore;
937
938 use crate::durability::{Recorder, WorkflowStartRecord};
939 use crate::registry::{
940 CompletionNotifier, HandleResidency, WorkflowHandle, WorkflowHandleParts,
941 };
942
943 let workflow_id = WorkflowId::new_v4();
947 let run_id = aion_core::RunId::new_v4();
948 let timer_id = aion_core::TimerId::named("sleep")?;
949 let mut recorder = Recorder::new(
950 workflow_id.clone(),
951 Arc::clone(store) as Arc<dyn EventStore>,
952 );
953 recorder
954 .record_workflow_started(
955 Utc::now(),
956 WorkflowStartRecord {
957 workflow_type: "checkout".to_owned(),
958 input: payload()?,
959 run_id: run_id.clone(),
960 parent_run_id: None,
961 parent_workflow_id: None,
962 package_version: aion_core::PackageVersion::new("a".repeat(64)),
963 },
964 )
965 .await?;
966 recorder
967 .record_timer_started(Utc::now(), timer_id.clone(), Utc::now())
968 .await?;
969 engine.registry().insert(
970 (workflow_id.clone(), run_id.clone()),
971 WorkflowHandle::new(WorkflowHandleParts {
972 workflow_id: workflow_id.clone(),
973 run_id,
974 pid: 1,
975 workflow_type: "checkout".to_owned(),
976 namespace: String::from("default"),
977 loaded_version: aion_package::ContentHash::from_bytes([9; 32]),
978 cached_status: WorkflowStatus::Running,
979 residency: HandleResidency::Resident,
980 recorder,
981 completion: CompletionNotifier::new(),
982 }),
983 )?;
984
985 let fire_at = Utc::now() + chrono::Duration::from_std(WHEEL_TIMER_ARMING_MARGIN)?;
986 crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
987 .map_err(|error| format!("the engine installed no timer service: {error}"))?
988 .schedule(workflow_id.clone(), timer_id, fire_at)
989 .await?;
990 Ok((workflow_id, fire_at))
991 }
992
993 #[tokio::test]
1017 async fn dropping_an_engine_without_shutdown_disarms_the_live_timer_wheel()
1018 -> Result<(), Box<dyn std::error::Error>> {
1019 use aion_store::EventStore;
1020
1021 let store = Arc::new(InMemoryStore::default());
1022 let engine = EngineBuilder::new()
1023 .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1024 .visibility_store(InMemoryStore::default())
1025 .build()
1026 .await?;
1027 let nif_state = Arc::clone(engine.runtime().nif_state());
1029 let (workflow_id, fire_at) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1030 assert_eq!(
1031 nif_state.armed_wheel_timers(),
1032 1,
1033 "control: the fixture must have actually armed a wheel timer, or the assertions below \
1034 are satisfied by an engine that never had one — the exact way the sibling test above \
1035 is blind to this half of `Drop`"
1036 );
1037
1038 let before_drop = Utc::now();
1046 assert!(
1047 before_drop < fire_at,
1048 "fixture: the arming window closed before the release was reached ({before_drop} is \
1049 not before {fire_at}). This is a FIXTURE timing failure — most likely a loaded box — \
1050 and NOT evidence about cancellation. Widen `WHEEL_TIMER_ARMING_MARGIN`; do not read \
1051 this as `Drop` leaking a task."
1052 );
1053 let armed = store.read_history(&workflow_id).await?;
1054 assert!(
1055 !armed
1056 .iter()
1057 .any(|event| matches!(event, Event::TimerFired { .. })),
1058 "fixture: the timer already fired before the engine was released, so this run cannot \
1059 say anything about cancellation. Same remedy as above — widen the margin: {armed:#?}"
1060 );
1061
1062 drop(engine);
1063
1064 assert_eq!(
1065 nif_state.armed_wheel_timers(),
1066 0,
1067 "an engine released without an explicit shutdown must empty its live timer wheel"
1068 );
1069
1070 tokio::time::sleep(WHEEL_TIMER_ARMING_MARGIN + WHEEL_TIMER_OBSERVATION_SLACK).await;
1081 let history = store.read_history(&workflow_id).await?;
1082 assert!(
1083 !history
1084 .iter()
1085 .any(|event| matches!(event, Event::TimerFired { .. })),
1086 "an engine released without an explicit shutdown must CANCEL its wheel tasks, not \
1087 merely forget them: a surviving task records `TimerFired` for a run this process no \
1088 longer owns, which is a second writer for one workflow: {history:#?}"
1089 );
1090 Ok(())
1091 }
1092
1093 #[tokio::test]
1116 async fn arming_a_wheel_timer_after_the_engine_is_released_is_refused()
1117 -> Result<(), Box<dyn std::error::Error>> {
1118 use aion_store::EventStore;
1119
1120 let store = Arc::new(InMemoryStore::default());
1121 let engine = EngineBuilder::new()
1122 .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1123 .visibility_store(InMemoryStore::default())
1124 .build()
1125 .await?;
1126 let nif_state = Arc::clone(engine.runtime().nif_state());
1127 let (workflow_id, _fire_at) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1130 assert_eq!(
1131 nif_state.armed_wheel_timers(),
1132 1,
1133 "control: the fixture must have armed a wheel timer through the production path, or \
1134 the refusal below is satisfied by a fixture that could never arm one"
1135 );
1136
1137 drop(engine);
1138
1139 assert_eq!(nif_state.armed_wheel_timers(), 0);
1142
1143 let refused = crate::runtime::nif_timer_bridge::installed_timer_service(&nif_state)
1144 .map_err(|error| format!("the released engine's timer seam is gone: {error}"))?
1145 .schedule(
1146 workflow_id,
1147 aion_core::TimerId::named("sleep-after-release")?,
1148 Utc::now() + chrono::Duration::from_std(WHEEL_TIMER_ARMING_MARGIN)?,
1149 )
1150 .await;
1151 let Err(error) = refused else {
1152 return Err(
1153 "arming a wheel timer through a released engine must be REFUSED: the task \
1154 it spawns appends a durable `TimerFired` for a run this process no longer \
1155 owns, which is a second writer for one workflow"
1156 .into(),
1157 );
1158 };
1159 assert!(
1160 error.to_string().contains("torn down"),
1161 "the refusal must say WHY, so an operator reading it is not sent looking for a \
1162 missing workflow or a bad timer id: {error}"
1163 );
1164 assert_eq!(
1165 nif_state.armed_wheel_timers(),
1166 0,
1167 "the refused arm must leave nothing behind: a wheel entry inserted before the refusal \
1168 would be a durable writer with no owner to cancel it"
1169 );
1170 Ok(())
1171 }
1172
1173 #[tokio::test]
1182 async fn a_live_wheel_timer_fires_when_the_engine_is_not_released()
1183 -> Result<(), Box<dyn std::error::Error>> {
1184 use aion_store::EventStore;
1185
1186 let store = Arc::new(InMemoryStore::default());
1187 let engine = EngineBuilder::new()
1188 .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1189 .visibility_store(InMemoryStore::default())
1190 .build()
1191 .await?;
1192 let (workflow_id, _) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1193
1194 tokio::time::sleep(WHEEL_TIMER_ARMING_MARGIN + WHEEL_TIMER_OBSERVATION_SLACK).await;
1195
1196 let history = store.read_history(&workflow_id).await?;
1197 assert!(
1198 history
1199 .iter()
1200 .any(|event| matches!(event, Event::TimerFired { .. })),
1201 "the fixture's wheel timer must reach the store when the engine is still held, or \
1202 the sibling test's absence proves nothing: {history:#?}"
1203 );
1204 drop(engine);
1205 Ok(())
1206 }
1207
1208 #[derive(Default)]
1216 struct TickCountingVisibilityStore {
1217 inner: InMemoryStore,
1218 list_calls: std::sync::atomic::AtomicUsize,
1219 }
1220
1221 impl TickCountingVisibilityStore {
1222 fn ticks(&self) -> usize {
1223 self.list_calls.load(std::sync::atomic::Ordering::Acquire)
1224 }
1225 }
1226
1227 #[async_trait::async_trait]
1228 impl VisibilityStore for TickCountingVisibilityStore {
1229 async fn record_visibility(
1230 &self,
1231 record: aion_store::visibility::VisibilityRecord,
1232 ) -> Result<(), aion_store::StoreError> {
1233 self.inner.record_visibility(record).await
1234 }
1235
1236 async fn list_workflows(
1237 &self,
1238 filter: ListWorkflowsFilter,
1239 ) -> Result<Vec<aion_store::visibility::WorkflowSummary>, aion_store::StoreError> {
1240 self.list_calls
1241 .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
1242 self.inner.list_workflows(filter).await
1243 }
1244
1245 async fn count_workflows(
1246 &self,
1247 filter: ListWorkflowsFilter,
1248 ) -> Result<u64, aion_store::StoreError> {
1249 self.inner.count_workflows(filter).await
1250 }
1251 }
1252
1253 fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
1254 NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
1255 }
1256
1257 #[tokio::test]
1258 async fn event_streaming_delivers_recorder_appends_through_engine_subscribe()
1259 -> Result<(), Box<dyn std::error::Error>> {
1260 let engine = EngineBuilder::new()
1261 .store(InMemoryStore::default())
1262 .in_memory_visibility()
1263 .event_streaming(capacity(8)?)
1264 .build()
1265 .await?;
1266 let workflow_id = WorkflowId::new_v4();
1267 let mut subscription = engine.subscribe(crate::EventFilter {
1268 workflow_id: Some(workflow_id.clone()),
1269 run: None,
1270 family: None,
1271 });
1272
1273 let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1276 recorder
1277 .record_workflow_started(
1278 Utc::now(),
1279 crate::durability::WorkflowStartRecord {
1280 workflow_type: "checkout".to_owned(),
1281 input: payload()?,
1282 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(7)),
1283 parent_run_id: None,
1284 parent_workflow_id: None,
1285 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1286 },
1287 )
1288 .await?;
1289
1290 let item = tokio::time::timeout(Duration::from_secs(2), subscription.next())
1291 .await?
1292 .ok_or("subscription ended without delivering the appended event")?;
1293 let event = item?;
1294 assert_eq!(event.workflow_id(), &workflow_id);
1295 assert_eq!(event.seq(), 1);
1296 assert!(matches!(event, Event::WorkflowStarted { .. }));
1297 engine.shutdown()?;
1298 Ok(())
1299 }
1300
1301 #[tokio::test]
1302 async fn without_event_streaming_subscriptions_stay_on_deferred_empty_stream()
1303 -> Result<(), Box<dyn std::error::Error>> {
1304 let engine = EngineBuilder::new()
1305 .store(InMemoryStore::default())
1306 .in_memory_visibility()
1307 .build()
1308 .await?;
1309
1310 let mut subscription = engine.subscribe(crate::EventFilter::default());
1311 let item = tokio::time::timeout(Duration::from_secs(2), subscription.next()).await?;
1312
1313 assert!(item.is_none(), "deferred publisher streams must be empty");
1314 engine.shutdown()?;
1315 Ok(())
1316 }
1317
1318 #[tokio::test]
1319 async fn event_streaming_conflicts_with_explicit_event_publisher()
1320 -> Result<(), Box<dyn std::error::Error>> {
1321 let error = EngineBuilder::new()
1322 .store(InMemoryStore::default())
1323 .in_memory_visibility()
1324 .event_publisher(Arc::new(crate::DeferredEventPublisher))
1325 .event_streaming(capacity(8)?)
1326 .build()
1327 .await
1328 .err();
1329
1330 assert!(matches!(
1331 error,
1332 Some(EngineError::ConflictingEventPublisher)
1333 ));
1334 Ok(())
1335 }
1336
1337 #[test]
1338 fn query_timeout_is_only_set_by_caller() {
1339 assert_eq!(EngineBuilder::new().configured_query_timeout(), None);
1340 assert_eq!(
1341 EngineBuilder::new()
1342 .query_timeout(Duration::from_secs(3))
1343 .configured_query_timeout(),
1344 Some(Duration::from_secs(3))
1345 );
1346 }
1347
1348 async fn insert_running_workflow(
1349 engine: &crate::Engine,
1350 ) -> Result<(WorkflowId, aion_core::RunId), Box<dyn std::error::Error>> {
1351 let workflow_id = WorkflowId::new_v4();
1352 let run_id = aion_core::RunId::new_v4();
1353 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: run_id.clone(),
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 let handle = crate::registry::WorkflowHandle::new(crate::registry::WorkflowHandleParts {
1368 workflow_id: workflow_id.clone(),
1369 run_id: run_id.clone(),
1370 pid: engine.runtime().spawn_test_process_with_trap_exit(true)?,
1371 workflow_type: "checkout".to_owned(),
1372 namespace: String::from("default"),
1373 loaded_version: aion_package::ContentHash::from_bytes([2; 32]),
1374 cached_status: WorkflowStatus::Running,
1375 residency: crate::registry::HandleResidency::Resident,
1376 recorder,
1377 completion: crate::registry::CompletionNotifier::new(),
1378 });
1379 engine
1380 .registry()
1381 .insert((workflow_id.clone(), run_id.clone()), handle)?;
1382 Ok((workflow_id, run_id))
1383 }
1384
1385 #[tokio::test]
1386 async fn query_timeout_installs_the_concrete_query_seam()
1387 -> Result<(), Box<dyn std::error::Error>> {
1388 let engine = EngineBuilder::new()
1389 .store(InMemoryStore::default())
1390 .in_memory_visibility()
1391 .query_timeout(Duration::from_millis(250))
1392 .build()
1393 .await?;
1394 let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1395
1396 let result = engine
1400 .query(
1401 &workflow_id,
1402 &run_id,
1403 "state",
1404 aion_core::Payload::json_null(),
1405 )
1406 .await;
1407
1408 assert!(matches!(
1409 result,
1410 Err(crate::EngineError::Query(crate::QueryError::UnknownQuery(name))) if name == "state"
1411 ));
1412 engine.shutdown()?;
1413 Ok(())
1414 }
1415
1416 #[tokio::test]
1417 async fn without_query_timeout_the_query_seam_stays_deferred()
1418 -> Result<(), Box<dyn std::error::Error>> {
1419 let engine = EngineBuilder::new()
1420 .store(InMemoryStore::default())
1421 .in_memory_visibility()
1422 .build()
1423 .await?;
1424 let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1425
1426 let result = engine
1427 .query(
1428 &workflow_id,
1429 &run_id,
1430 "state",
1431 aion_core::Payload::json_null(),
1432 )
1433 .await;
1434
1435 assert!(matches!(
1436 result,
1437 Err(crate::EngineError::Runtime { reason }) if reason.contains("not configured")
1438 ));
1439 engine.shutdown()?;
1440 Ok(())
1441 }
1442
1443 #[test]
1444 fn owned_shards_are_only_set_by_caller() {
1445 assert_eq!(EngineBuilder::new().configured_owned_shards(), None);
1449 assert_eq!(
1450 EngineBuilder::new()
1451 .owned_shards([2, 0, 2, 1])
1452 .configured_owned_shards(),
1453 Some([2, 0, 2, 1].as_slice())
1454 );
1455 }
1456
1457 #[test]
1458 fn scheduler_threads_are_only_set_by_caller() {
1459 assert_eq!(EngineBuilder::new().scheduler_thread_count(), None);
1460 assert_eq!(
1461 EngineBuilder::new()
1462 .scheduler_threads(4)
1463 .scheduler_thread_count(),
1464 Some(4)
1465 );
1466 }
1467
1468 #[test]
1482 fn the_completion_retry_ladder_reaches_the_runtime_configuration()
1483 -> Result<(), Box<dyn std::error::Error>> {
1484 let chosen = crate::runtime::CompletionRetryConfig::try_new(
1485 Duration::from_millis(250),
1486 Duration::from_secs(7),
1487 )?;
1488 assert_ne!(
1489 chosen,
1490 crate::runtime::CompletionRetryConfig::default(),
1491 "a ladder equal to the default could not distinguish wiring from inheritance"
1492 );
1493
1494 assert_eq!(
1495 EngineBuilder::new()
1496 .completion_retry(chosen)
1497 .runtime_config()
1498 .completion_retry,
1499 chosen,
1500 "the runtime must be started with the operator's ladder, not the inherited default"
1501 );
1502 Ok(())
1503 }
1504
1505 #[test]
1506 fn visibility_reconciliation_interval_is_only_set_by_caller() {
1507 let interval = Duration::from_millis(250);
1508
1509 assert_eq!(
1510 EngineBuilder::new().configured_visibility_reconciliation_interval(),
1511 None
1512 );
1513 assert_eq!(
1514 EngineBuilder::new()
1515 .visibility_reconciliation_interval(interval)
1516 .configured_visibility_reconciliation_interval(),
1517 Some(interval)
1518 );
1519 }
1520
1521 #[tokio::test]
1522 async fn duplicate_host_nif_mfa_returns_typed_error() {
1523 let mfa = Mfa::new("host", "zero", 0);
1524 let error = EngineBuilder::new()
1525 .store(InMemoryStore::default())
1526 .in_memory_visibility()
1527 .register_nifs([
1528 NifEntry::new(
1529 mfa.clone(),
1530 crate::runtime::nif::test_native_zero,
1531 Determinism::Pure,
1532 ),
1533 NifEntry::dirty(
1534 mfa,
1535 crate::runtime::nif::test_native_zero,
1536 Determinism::Pure,
1537 ),
1538 ])
1539 .build()
1540 .await
1541 .err();
1542
1543 assert!(matches!(
1544 error,
1545 Some(EngineError::NifRegistration { reason }) if reason.contains("host:zero/0")
1546 ));
1547 }
1548
1549 #[tokio::test]
1550 async fn empty_store_builds_coordinator_history_without_registry_or_supervision()
1551 -> Result<(), EngineError> {
1552 let store = Arc::new(InMemoryStore::default());
1553 let engine = EngineBuilder::new()
1554 .store_arc(store.clone())
1555 .in_memory_visibility()
1556 .build()
1557 .await?;
1558
1559 assert!(engine.registry().list()?.is_empty());
1560 assert_eq!(engine.supervision().type_supervisor_count()?, 1);
1561 assert_eq!(engine.workflow_catalog().workflows()?.len(), 0);
1562
1563 let coordinator_id = schedule_coordinator_workflow_id();
1564 let active = store.list_active().await?;
1565 assert_eq!(active, vec![coordinator_id.clone()]);
1566 let history = store.read_history(&coordinator_id).await?;
1567 let [started] = history.as_slice() else {
1568 return Err(EngineError::Load {
1569 reason: format!(
1570 "expected exactly one coordinator event, found {}",
1571 history.len()
1572 ),
1573 });
1574 };
1575 match started {
1576 Event::WorkflowStarted {
1577 workflow_type,
1578 input,
1579 run_id,
1580 parent_run_id,
1581 ..
1582 } => {
1583 assert_eq!(workflow_type, schedule_coordinator_workflow_type());
1584 assert_eq!(
1585 input,
1586 &Payload::from_json(&json!({})).map_err(|error| {
1587 EngineError::Load {
1588 reason: format!("failed to build expected payload: {error}"),
1589 }
1590 })?
1591 );
1592 assert_eq!(run_id, &schedule_coordinator_run_id());
1593 assert!(parent_run_id.is_none());
1594 }
1595 other => {
1596 return Err(EngineError::Load {
1597 reason: format!("expected coordinator WorkflowStarted, found {other:?}"),
1598 });
1599 }
1600 }
1601
1602 engine.shutdown()?;
1603 let rebuilt = EngineBuilder::new()
1604 .store_arc(store.clone())
1605 .in_memory_visibility()
1606 .build()
1607 .await?;
1608 let rebuilt_history = store.read_history(&coordinator_id).await?;
1609 assert_eq!(rebuilt_history.len(), 1);
1610 rebuilt.shutdown()?;
1611
1612 Ok(())
1613 }
1614
1615 #[tokio::test]
1616 async fn build_loads_already_loaded_package() -> Result<(), Box<dyn std::error::Error>> {
1617 if gleam_test_support::skip_if_unavailable() {
1618 return Ok(());
1619 }
1620 let package = fixture_package()?;
1621 let version = package.content_hash().clone();
1622 let deployed_entry_module = package.deployed_entry_module();
1623
1624 let engine = EngineBuilder::new()
1625 .store(InMemoryStore::default())
1626 .in_memory_visibility()
1627 .load_workflows(package)
1628 .build()
1629 .await?;
1630
1631 let loaded = engine
1632 .workflow_catalog()
1633 .get("counter", &version)?
1634 .ok_or("loaded package record missing")?;
1635 assert_eq!(loaded.deployed_entry_module(), deployed_entry_module);
1636 assert!(
1637 engine
1638 .runtime()
1639 .has_registered_module(&deployed_entry_module)
1640 );
1641 Ok(())
1642 }
1643
1644 #[tokio::test]
1645 async fn startup_reconciliation_backfills_completed_visibility()
1646 -> Result<(), Box<dyn std::error::Error>> {
1647 let store = Arc::new(InMemoryStore::default());
1648 let completed_id = WorkflowId::new_v4();
1649
1650 store
1651 .append(
1652 WriteToken::recorder(),
1653 &completed_id,
1654 &[
1655 started(&completed_id, "billing")?,
1656 completed(&completed_id)?,
1657 ],
1658 0,
1659 )
1660 .await?;
1661
1662 let engine = EngineBuilder::new()
1663 .store_arc(store.clone())
1664 .visibility_store_arc(store.clone())
1665 .build()
1666 .await?;
1667
1668 let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1669 let completed_summary = summaries
1670 .iter()
1671 .find(|summary| summary.workflow_id == completed_id)
1672 .ok_or("completed workflow missing from visibility")?;
1673
1674 assert_eq!(completed_summary.status, WorkflowStatus::Completed);
1675 assert!(completed_summary.close_time.is_some());
1676 engine.shutdown()?;
1677 Ok(())
1678 }
1679
1680 #[tokio::test]
1681 async fn periodic_visibility_reconciliation_repairs_gap_after_startup()
1682 -> Result<(), Box<dyn std::error::Error>> {
1683 let store = Arc::new(InMemoryStore::default());
1684 let engine = EngineBuilder::new()
1685 .store_arc(store.clone())
1686 .visibility_store_arc(store.clone())
1687 .visibility_reconciliation_interval(Duration::from_millis(25))
1688 .build()
1689 .await?;
1690 let workflow_id = WorkflowId::new_v4();
1691
1692 store
1693 .append(
1694 WriteToken::recorder(),
1695 &workflow_id,
1696 &[started(&workflow_id, "checkout")?],
1697 0,
1698 )
1699 .await?;
1700
1701 tokio::time::timeout(Duration::from_secs(2), async {
1702 loop {
1703 let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1704 if summaries.iter().any(|summary| {
1705 summary.workflow_id == workflow_id && summary.status == WorkflowStatus::Running
1706 }) {
1707 return Ok::<(), aion_store::StoreError>(());
1708 }
1709 tokio::time::sleep(Duration::from_millis(10)).await;
1710 }
1711 })
1712 .await??;
1713
1714 engine.shutdown()?;
1715 Ok(())
1716 }
1717
1718 #[tokio::test]
1719 async fn build_loads_package_from_path() -> Result<(), Box<dyn std::error::Error>> {
1720 if gleam_test_support::skip_if_unavailable() {
1721 return Ok(());
1722 }
1723 let package = fixture_package()?;
1724 let version = package.content_hash().clone();
1725 let path = write_fixture_package(&package)?;
1726
1727 let engine = EngineBuilder::new()
1728 .store(InMemoryStore::default())
1729 .in_memory_visibility()
1730 .load_workflows(path.as_path())
1731 .build()
1732 .await?;
1733 std::fs::remove_file(path)?;
1734
1735 assert!(
1736 engine
1737 .workflow_catalog()
1738 .get("counter", &version)?
1739 .is_some()
1740 );
1741 Ok(())
1742 }
1743}