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, install_signal_nif_bridge},
18 signal::SignalResumeHandoff,
19};
20
21use super::api::{Engine, EngineComponents};
22use super::builder_assembly::{
23 ChildBridgeAssembly, assemble_startup_catalog, claim_owned_shards,
24 install_configured_child_nif_bridge, install_engine_nif_seams,
25 maybe_spawn_visibility_reconciliation,
26};
27use super::delegated::{DelegatedSeams, EventPublisher, QueryService, SignalRouter};
28use super::seams::{
29 SeamAssembly, SignalRouterFactory, assemble_delegated_seams, wrap_event_streaming,
30};
31use super::startup::{
32 StartupRecoveryContext, recover_active_workflows_on_startup, recover_timers_on_startup,
33};
34
35#[derive(Clone, Debug)]
38pub enum WorkflowPackageSource {
39 Path(PathBuf),
41 Package(Box<Package>),
43}
44
45impl From<Package> for WorkflowPackageSource {
46 fn from(package: Package) -> Self {
47 Self::Package(Box::new(package))
48 }
49}
50
51impl From<PathBuf> for WorkflowPackageSource {
52 fn from(path: PathBuf) -> Self {
53 Self::Path(path)
54 }
55}
56
57impl From<&std::path::Path> for WorkflowPackageSource {
58 fn from(path: &std::path::Path) -> Self {
59 Self::Path(path.to_path_buf())
60 }
61}
62
63impl From<&str> for WorkflowPackageSource {
64 fn from(path: &str) -> Self {
65 Self::Path(PathBuf::from(path))
66 }
67}
68
69impl From<String> for WorkflowPackageSource {
70 fn from(path: String) -> Self {
71 Self::Path(PathBuf::from(path))
72 }
73}
74
75#[derive(Default)]
80struct SeamOverrides {
81 event_publisher: bool,
83 query_service: bool,
85}
86
87pub struct EngineBuilder {
89 store: Option<Arc<dyn EventStore>>,
90 visibility_store: Option<Arc<dyn VisibilityStore>>,
91 scheduler_threads: Option<usize>,
92 signal_delivery: SignalDeliveryConfig,
93 completion_retry: crate::runtime::CompletionRetryConfig,
94 outbox_enabled: bool,
95 bootstrap_schedule_coordinator: bool,
96 owned_shards: Option<Vec<usize>>,
97 workflow_sources: Vec<WorkflowPackageSource>,
98 host_nifs: Vec<NifEntry>,
99 recovery: Option<Arc<dyn ActiveWorkflowRecoverySeam>>,
100 delegated: DelegatedSeams,
101 signal_router_factory: Option<SignalRouterFactory>,
102 activity_dispatcher: Option<Arc<dyn ActivityDispatcher>>,
103 activity_serving: ActivityServing,
104 active_registry: Option<Arc<Registry>>,
105 visibility_reconciliation_interval: Option<Duration>,
106 search_attribute_schema: SearchAttributeSchema,
107 event_streaming_capacity: Option<NonZeroUsize>,
108 query_timeout: Option<Duration>,
109 seam_overrides: SeamOverrides,
110}
111
112impl Default for EngineBuilder {
113 fn default() -> Self {
114 Self::new()
115 }
116}
117
118impl EngineBuilder {
119 #[must_use]
122 pub fn new() -> Self {
123 Self {
124 store: None,
125 visibility_store: None,
126 scheduler_threads: 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 }
152 }
153
154 #[must_use]
162 pub const fn query_timeout(mut self, timeout: Duration) -> Self {
163 self.query_timeout = Some(timeout);
164 self
165 }
166
167 #[must_use]
169 pub const fn configured_query_timeout(&self) -> Option<Duration> {
170 self.query_timeout
171 }
172
173 #[must_use]
183 pub const fn event_streaming(mut self, capacity: NonZeroUsize) -> Self {
184 self.event_streaming_capacity = Some(capacity);
185 self
186 }
187
188 #[must_use]
194 pub fn search_attribute_schema(mut self, schema: SearchAttributeSchema) -> Self {
195 self.search_attribute_schema = schema;
196 self
197 }
198
199 #[must_use]
201 pub fn store<S>(mut self, store: S) -> Self
202 where
203 S: EventStore,
204 {
205 self.store = Some(Arc::new(store));
206 self
207 }
208
209 #[must_use]
211 pub fn store_arc(mut self, store: Arc<dyn EventStore>) -> Self {
212 self.store = Some(store);
213 self
214 }
215
216 #[must_use]
218 pub fn visibility_store<S>(mut self, visibility_store: S) -> Self
219 where
220 S: VisibilityStore,
221 {
222 self.visibility_store = Some(Arc::new(visibility_store));
223 self
224 }
225
226 #[must_use]
228 pub fn visibility_store_arc(mut self, visibility_store: Arc<dyn VisibilityStore>) -> Self {
229 self.visibility_store = Some(visibility_store);
230 self
231 }
232
233 #[must_use]
239 pub fn in_memory_visibility(mut self) -> Self {
240 self.visibility_store = Some(Arc::new(InMemoryStore::default()));
241 self
242 }
243
244 #[must_use]
248 pub const fn scheduler_threads(mut self, threads: usize) -> Self {
249 self.scheduler_threads = Some(threads);
250 self
251 }
252
253 #[must_use]
257 pub const fn visibility_reconciliation_interval(mut self, interval: Duration) -> Self {
258 self.visibility_reconciliation_interval = Some(interval);
259 self
260 }
261
262 #[must_use]
264 pub const fn signal_delivery(mut self, signal_delivery: SignalDeliveryConfig) -> Self {
265 self.signal_delivery = signal_delivery;
266 self
267 }
268
269 #[must_use]
276 pub const fn completion_retry(
277 mut self,
278 completion_retry: crate::runtime::CompletionRetryConfig,
279 ) -> Self {
280 self.completion_retry = completion_retry;
281 self
282 }
283
284 #[must_use]
286 pub fn outbox_enabled(mut self, enabled: bool) -> Self {
287 self.outbox_enabled = enabled;
288 self
289 }
290
291 #[must_use]
299 pub const fn bootstrap_schedule_coordinator(mut self, enabled: bool) -> Self {
300 self.bootstrap_schedule_coordinator = enabled;
301 self
302 }
303
304 #[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]
371 pub fn signal_router(mut self, signal_router: Arc<dyn SignalRouter>) -> Self {
372 self.signal_router_factory = None;
373 self.delegated = DelegatedSeams::new(
374 signal_router,
375 self.delegated.query_service_arc(),
376 self.delegated.event_publisher_arc(),
377 );
378 self
379 }
380
381 #[must_use]
383 pub fn signal_router_factory<F>(mut self, factory: F) -> Self
384 where
385 F: Fn(Arc<RuntimeHandle>, Arc<SignalResumeHandoff>) -> Arc<dyn SignalRouter>
386 + Send
387 + Sync
388 + 'static,
389 {
390 self.signal_router_factory = Some(Arc::new(factory));
391 self
392 }
393
394 #[must_use]
399 pub fn query_service(mut self, query_service: Arc<dyn QueryService>) -> Self {
400 self.seam_overrides.query_service = true;
401 self.delegated = DelegatedSeams::new(
402 self.delegated.signal_router_arc(),
403 query_service,
404 self.delegated.event_publisher_arc(),
405 );
406 self
407 }
408
409 #[must_use]
414 pub fn event_publisher(mut self, event_publisher: Arc<dyn EventPublisher>) -> Self {
415 self.seam_overrides.event_publisher = true;
416 self.delegated = DelegatedSeams::new(
417 self.delegated.signal_router_arc(),
418 self.delegated.query_service_arc(),
419 event_publisher,
420 );
421 self
422 }
423
424 #[must_use]
430 pub fn activity_dispatcher(mut self, dispatcher: Arc<dyn ActivityDispatcher>) -> Self {
431 self.activity_dispatcher = Some(dispatcher);
432 self
433 }
434
435 #[must_use]
447 pub const fn in_process_activity_serving(mut self) -> Self {
448 self.activity_serving = ActivityServing::InProcess;
449 self
450 }
451
452 #[must_use]
457 pub fn active_registry(mut self, registry: Arc<Registry>) -> Self {
458 self.active_registry = Some(registry);
459 self
460 }
461
462 #[must_use]
464 pub const fn scheduler_thread_count(&self) -> Option<usize> {
465 self.scheduler_threads
466 }
467
468 #[must_use]
470 pub const fn configured_visibility_reconciliation_interval(&self) -> Option<Duration> {
471 self.visibility_reconciliation_interval
472 }
473
474 fn runtime_config(&self) -> RuntimeConfig {
477 RuntimeConfig::new(self.scheduler_threads)
478 .with_signal_delivery(self.signal_delivery)
479 .with_completion_retry(self.completion_retry)
480 .with_outbox_enabled(self.outbox_enabled)
481 }
482
483 fn start_runtime_with_nifs(
485 runtime_config: RuntimeConfig,
486 host_nifs: Vec<NifEntry>,
487 ) -> Result<Arc<RuntimeHandle>, EngineError> {
488 let runtime = Arc::new(RuntimeHandle::new(runtime_config)?);
489 let mut nifs = NifRegistration::new();
490 nifs.add_engine_nifs().add_host_nifs(host_nifs);
491 runtime.install_nifs(nifs)?;
492 Ok(runtime)
493 }
494
495 pub async fn build(self) -> Result<Engine, EngineError> {
503 let runtime_config = self.runtime_config();
504 let (store, streaming_publisher) = wrap_event_streaming(
505 self.store.ok_or(EngineError::MissingStore)?,
506 self.event_streaming_capacity,
507 self.seam_overrides.event_publisher,
508 )?;
509 let visibility_store = self
510 .visibility_store
511 .ok_or(EngineError::MissingVisibilityStore)?;
512 claim_owned_shards(store.as_ref(), self.owned_shards.as_deref())?;
513
514 let runtime = Self::start_runtime_with_nifs(runtime_config, self.host_nifs)?;
515
516 let catalog = assemble_startup_catalog(
520 runtime.as_ref(),
521 store.as_ref(),
522 self.workflow_sources,
523 self.activity_serving,
524 )
525 .await?;
526
527 let registry = self
528 .active_registry
529 .unwrap_or_else(|| Arc::new(Registry::default()));
530 let nif_state = Arc::clone(runtime.nif_state());
531 let query_mailbox_engine = install_engine_nif_seams(
532 &nif_state,
533 ®istry,
534 &store,
535 &runtime,
536 self.activity_dispatcher,
537 self.query_timeout,
538 );
539 let supervision = Arc::new(SupervisionTree::new());
540 let search_attribute_schema = Arc::new(self.search_attribute_schema);
541 let signal_handoff = Arc::new(SignalResumeHandoff::new());
542
543 let delegated = assemble_delegated_seams(SeamAssembly {
544 configured: self.delegated,
545 signal_router_factory: self.signal_router_factory,
546 runtime: Arc::clone(&runtime),
547 signal_handoff: Arc::clone(&signal_handoff),
548 streaming_publisher,
549 query_mailbox_engine,
550 query_timeout: self.query_timeout,
551 query_service_overridden: self.seam_overrides.query_service,
552 });
553
554 install_signal_nif_bridge(
555 &nif_state,
556 Arc::new(crate::runtime::SignalNifBridge::new(
557 Arc::clone(®istry),
558 Arc::clone(&runtime),
559 tokio::runtime::Handle::current(),
560 delegated.signal_router_arc(),
561 )),
562 );
563 install_configured_child_nif_bridge(&ChildBridgeAssembly {
564 nif_state: &nif_state,
565 store: &store,
566 visibility_store: &visibility_store,
567 runtime: &runtime,
568 catalog: &catalog,
569 registry: ®istry,
570 supervision: &supervision,
571 signal_handoff: &signal_handoff,
572 search_attribute_schema: &search_attribute_schema,
573 watch_backoff: self.signal_delivery,
574 })?;
575
576 recover_active_workflows_on_startup(StartupRecoveryContext {
583 store: Arc::clone(&store),
584 visibility_store: Arc::clone(&visibility_store),
585 runtime: Arc::clone(&runtime),
586 catalog: Arc::clone(&catalog),
587 registry: Arc::clone(®istry),
588 supervision: Arc::clone(&supervision),
589 recovery: self.recovery,
590 search_attribute_schema: Arc::clone(&search_attribute_schema),
591 bootstrap_schedule_coordinator: self.bootstrap_schedule_coordinator,
592 })
593 .await?;
594 recover_timers_on_startup(&nif_state, Arc::clone(&store)).await?;
595
596 let visibility_reconciliation_task = maybe_spawn_visibility_reconciliation(
597 self.visibility_reconciliation_interval,
598 &store,
599 &visibility_store,
600 );
601
602 let engine = Engine::new(EngineComponents {
603 store,
604 visibility_store,
605 runtime,
606 catalog,
607 registry,
608 supervision,
609 delegated,
610 signal_handoff,
611 search_attribute_schema,
612 visibility_reconciliation_task,
613 });
614 engine.catchup_schedule_coordinator().await?;
615 engine.recover_schedules_on_startup(Utc::now()).await?;
616 Ok(engine)
617 }
618}
619
620#[cfg(test)]
621mod tests {
622 use super::super::gleam_test_support;
638
639 use std::{num::NonZeroUsize, path::PathBuf, process::Command, sync::Arc, time::Duration};
640
641 use aion_core::{Event, EventEnvelope, Payload, WorkflowId, WorkflowStatus};
642 use aion_package::{
643 BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, ExtractionLimits, Manifest,
644 ManifestVersion, Package, PackageBuilder,
645 };
646 use aion_store::visibility::{ListWorkflowsFilter, VisibilityStore};
647 use aion_store::{InMemoryStore, ReadableEventStore, WritableEventStore, WriteToken};
648 use chrono::Utc;
649 use futures::StreamExt;
650 use serde_json::json;
651
652 use crate::engine::api_schedule::{
653 schedule_coordinator_run_id, schedule_coordinator_workflow_id,
654 schedule_coordinator_workflow_type,
655 };
656 use crate::runtime::{Determinism, Mfa, NifEntry};
657
658 use super::EngineBuilder;
659 use crate::EngineError;
660
661 fn payload() -> Result<Payload, aion_core::PayloadError> {
662 Payload::from_json(&json!({ "input": true }))
663 }
664
665 fn started(
666 workflow_id: &WorkflowId,
667 workflow_type: &str,
668 ) -> Result<Event, aion_core::PayloadError> {
669 Ok(Event::WorkflowStarted {
670 envelope: EventEnvelope {
671 seq: 1,
672 recorded_at: Utc::now(),
673 workflow_id: workflow_id.clone(),
674 },
675 workflow_type: workflow_type.to_owned(),
676 input: payload()?,
677 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
678 parent_run_id: None,
679 package_version: aion_core::PackageVersion::new("a".repeat(64)),
680 })
681 }
682
683 fn completed(workflow_id: &WorkflowId) -> Result<Event, aion_core::PayloadError> {
684 Ok(Event::WorkflowCompleted {
685 envelope: EventEnvelope {
686 seq: 2,
687 recorded_at: Utc::now(),
688 workflow_id: workflow_id.clone(),
689 },
690 result: payload()?,
691 })
692 }
693
694 fn package_manifest() -> Manifest {
695 Manifest {
696 entry_module: "counter".to_owned(),
697 entry_function: "version".to_owned(),
698 input_schema: json!({ "type": "object" }),
699 output_schema: json!({ "type": "integer" }),
700 timeout: Some(Duration::from_secs(30)),
701 activities: vec![DeclaredActivity {
702 activity_type: "activity/test".to_owned(),
703 }],
704 version: ManifestVersion::new("test"),
705 format_version: CURRENT_FORMAT_VERSION,
706 additional_workflows: Vec::new(),
707 }
708 }
709
710 fn compile_counter_beam() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
711 let temp_dir =
712 std::env::temp_dir().join(format!("aion-engine-builder-{}", uuid::Uuid::new_v4()));
713 std::fs::create_dir(&temp_dir)?;
714 let source_path = temp_dir.join("counter.erl");
715 let beam_path = temp_dir.join("counter.beam");
716 std::fs::write(
717 &source_path,
718 "-module(counter).\n-export([version/0]).\nversion() -> 1.\n",
719 )?;
720 let status = Command::new("erlc")
721 .arg("-o")
722 .arg(&temp_dir)
723 .arg(&source_path)
724 .status()?;
725 if !status.success() {
726 let cleanup_result = std::fs::remove_dir_all(&temp_dir);
727 drop(cleanup_result);
728 return Err(format!("erlc failed with status {status}").into());
729 }
730 let bytes = std::fs::read(beam_path)?;
731 std::fs::remove_dir_all(temp_dir)?;
732 Ok(bytes)
733 }
734
735 fn fixture_package() -> Result<Package, Box<dyn std::error::Error>> {
736 let beams = BeamSet::new(vec![BeamModule::new("counter", compile_counter_beam()?)])?;
737 let archive = PackageBuilder::new(package_manifest(), beams).write_to_bytes()?;
738 Ok(Package::load_from_bytes(
739 archive,
740 ExtractionLimits::unbounded(),
741 )?)
742 }
743
744 fn write_fixture_package(package: &Package) -> Result<PathBuf, Box<dyn std::error::Error>> {
745 let path =
746 std::env::temp_dir().join(format!("aion-engine-builder-{}.aion", uuid::Uuid::new_v4()));
747 PackageBuilder::new(package.manifest().clone(), package.beams().clone())
748 .write_to_path(&path)?;
749 Ok(path)
750 }
751
752 #[tokio::test]
753 async fn build_without_store_returns_missing_store() {
754 let error = EngineBuilder::new().build().await.err();
755
756 assert!(matches!(error, Some(EngineError::MissingStore)));
757 }
758
759 #[tokio::test]
760 async fn build_without_visibility_store_returns_missing_visibility_store() {
761 let error = EngineBuilder::new()
762 .store(InMemoryStore::default())
763 .build()
764 .await
765 .err();
766
767 assert!(matches!(error, Some(EngineError::MissingVisibilityStore)));
768 }
769
770 #[tokio::test]
771 async fn in_memory_visibility_allows_build_without_visibility_store() -> Result<(), EngineError>
772 {
773 let engine = EngineBuilder::new()
774 .store(InMemoryStore::default())
775 .in_memory_visibility()
776 .build()
777 .await?;
778
779 engine.shutdown()?;
780 Ok(())
781 }
782
783 #[tokio::test]
817 async fn dropping_an_engine_without_shutdown_closes_the_completion_retry_epoch()
818 -> Result<(), EngineError> {
819 let visibility = Arc::new(TickCountingVisibilityStore::default());
820 let engine = EngineBuilder::new()
821 .store(InMemoryStore::default())
822 .visibility_store_arc(Arc::clone(&visibility) as Arc<dyn VisibilityStore>)
823 .visibility_reconciliation_interval(Duration::from_millis(10))
824 .build()
825 .await?;
826 let tasks = engine.runtime().engine_tasks();
830 assert!(
831 tasks.is_epoch_open(),
832 "control: a live engine's epoch must be open, or the assertion below is trivially \
833 satisfied"
834 );
835
836 let ticking = tokio::time::timeout(Duration::from_secs(10), async {
840 while visibility.ticks() < 2 {
841 tokio::time::sleep(Duration::from_millis(5)).await;
842 }
843 })
844 .await;
845 assert!(
846 ticking.is_ok(),
847 "control: the periodic reconciliation loop must be running before the engine is \
848 dropped, or this test measures nothing; it reached {} ticks",
849 visibility.ticks()
850 );
851
852 drop(engine);
853
854 assert!(
855 !tasks.is_epoch_open(),
856 "an engine released without an explicit shutdown must still close the epoch, or a \
857 completion retry can append a terminal for a run this process no longer owns"
858 );
859
860 tokio::time::sleep(Duration::from_millis(200)).await;
864 let after_abort = visibility.ticks();
865 tokio::time::sleep(Duration::from_millis(200)).await;
866 assert_eq!(
867 visibility.ticks(),
868 after_abort,
869 "an engine released without an explicit shutdown must also stop the visibility \
870 reconciliation loop; it ticked again over twenty intervals after the drop, which \
871 means a detached task is still WRITING to a visibility store this process no longer \
872 owns"
873 );
874 Ok(())
875 }
876
877 const WHEEL_TIMER_ARMING_MARGIN: std::time::Duration = std::time::Duration::from_secs(2);
888
889 const WHEEL_TIMER_OBSERVATION_SLACK: std::time::Duration = std::time::Duration::from_secs(1);
893
894 async fn arm_resident_run_with_wheel_timer(
912 engine: &crate::Engine,
913 store: &Arc<InMemoryStore>,
914 ) -> Result<(WorkflowId, chrono::DateTime<Utc>), Box<dyn std::error::Error>> {
915 use aion_store::EventStore;
916
917 use crate::durability::{Recorder, WorkflowStartRecord};
918 use crate::registry::{
919 CompletionNotifier, HandleResidency, WorkflowHandle, WorkflowHandleParts,
920 };
921
922 let workflow_id = WorkflowId::new_v4();
926 let run_id = aion_core::RunId::new_v4();
927 let timer_id = aion_core::TimerId::named("sleep")?;
928 let mut recorder = Recorder::new(
929 workflow_id.clone(),
930 Arc::clone(store) as Arc<dyn EventStore>,
931 );
932 recorder
933 .record_workflow_started(
934 Utc::now(),
935 WorkflowStartRecord {
936 workflow_type: "checkout".to_owned(),
937 input: payload()?,
938 run_id: run_id.clone(),
939 parent_run_id: None,
940 package_version: aion_core::PackageVersion::new("a".repeat(64)),
941 },
942 )
943 .await?;
944 recorder
945 .record_timer_started(Utc::now(), timer_id.clone(), Utc::now())
946 .await?;
947 engine.registry().insert(
948 (workflow_id.clone(), run_id.clone()),
949 WorkflowHandle::new(WorkflowHandleParts {
950 workflow_id: workflow_id.clone(),
951 run_id,
952 pid: 1,
953 workflow_type: "checkout".to_owned(),
954 namespace: String::from("default"),
955 loaded_version: aion_package::ContentHash::from_bytes([9; 32]),
956 cached_status: WorkflowStatus::Running,
957 residency: HandleResidency::Resident,
958 recorder,
959 completion: CompletionNotifier::new(),
960 }),
961 )?;
962
963 let fire_at = Utc::now() + chrono::Duration::from_std(WHEEL_TIMER_ARMING_MARGIN)?;
964 crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
965 .map_err(|error| format!("the engine installed no timer service: {error}"))?
966 .schedule(workflow_id.clone(), timer_id, fire_at)
967 .await?;
968 Ok((workflow_id, fire_at))
969 }
970
971 #[tokio::test]
995 async fn dropping_an_engine_without_shutdown_disarms_the_live_timer_wheel()
996 -> Result<(), Box<dyn std::error::Error>> {
997 use aion_store::EventStore;
998
999 let store = Arc::new(InMemoryStore::default());
1000 let engine = EngineBuilder::new()
1001 .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1002 .visibility_store(InMemoryStore::default())
1003 .build()
1004 .await?;
1005 let nif_state = Arc::clone(engine.runtime().nif_state());
1007 let (workflow_id, fire_at) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1008 assert_eq!(
1009 nif_state.armed_wheel_timers(),
1010 1,
1011 "control: the fixture must have actually armed a wheel timer, or the assertions below \
1012 are satisfied by an engine that never had one — the exact way the sibling test above \
1013 is blind to this half of `Drop`"
1014 );
1015
1016 let before_drop = Utc::now();
1024 assert!(
1025 before_drop < fire_at,
1026 "fixture: the arming window closed before the release was reached ({before_drop} is \
1027 not before {fire_at}). This is a FIXTURE timing failure — most likely a loaded box — \
1028 and NOT evidence about cancellation. Widen `WHEEL_TIMER_ARMING_MARGIN`; do not read \
1029 this as `Drop` leaking a task."
1030 );
1031 let armed = store.read_history(&workflow_id).await?;
1032 assert!(
1033 !armed
1034 .iter()
1035 .any(|event| matches!(event, Event::TimerFired { .. })),
1036 "fixture: the timer already fired before the engine was released, so this run cannot \
1037 say anything about cancellation. Same remedy as above — widen the margin: {armed:#?}"
1038 );
1039
1040 drop(engine);
1041
1042 assert_eq!(
1043 nif_state.armed_wheel_timers(),
1044 0,
1045 "an engine released without an explicit shutdown must empty its live timer wheel"
1046 );
1047
1048 tokio::time::sleep(WHEEL_TIMER_ARMING_MARGIN + WHEEL_TIMER_OBSERVATION_SLACK).await;
1059 let history = store.read_history(&workflow_id).await?;
1060 assert!(
1061 !history
1062 .iter()
1063 .any(|event| matches!(event, Event::TimerFired { .. })),
1064 "an engine released without an explicit shutdown must CANCEL its wheel tasks, not \
1065 merely forget them: a surviving task records `TimerFired` for a run this process no \
1066 longer owns, which is a second writer for one workflow: {history:#?}"
1067 );
1068 Ok(())
1069 }
1070
1071 #[tokio::test]
1094 async fn arming_a_wheel_timer_after_the_engine_is_released_is_refused()
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());
1105 let (workflow_id, _fire_at) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1108 assert_eq!(
1109 nif_state.armed_wheel_timers(),
1110 1,
1111 "control: the fixture must have armed a wheel timer through the production path, or \
1112 the refusal below is satisfied by a fixture that could never arm one"
1113 );
1114
1115 drop(engine);
1116
1117 assert_eq!(nif_state.armed_wheel_timers(), 0);
1120
1121 let refused = crate::runtime::nif_timer_bridge::installed_timer_service(&nif_state)
1122 .map_err(|error| format!("the released engine's timer seam is gone: {error}"))?
1123 .schedule(
1124 workflow_id,
1125 aion_core::TimerId::named("sleep-after-release")?,
1126 Utc::now() + chrono::Duration::from_std(WHEEL_TIMER_ARMING_MARGIN)?,
1127 )
1128 .await;
1129 let Err(error) = refused else {
1130 return Err(
1131 "arming a wheel timer through a released engine must be REFUSED: the task \
1132 it spawns appends a durable `TimerFired` for a run this process no longer \
1133 owns, which is a second writer for one workflow"
1134 .into(),
1135 );
1136 };
1137 assert!(
1138 error.to_string().contains("torn down"),
1139 "the refusal must say WHY, so an operator reading it is not sent looking for a \
1140 missing workflow or a bad timer id: {error}"
1141 );
1142 assert_eq!(
1143 nif_state.armed_wheel_timers(),
1144 0,
1145 "the refused arm must leave nothing behind: a wheel entry inserted before the refusal \
1146 would be a durable writer with no owner to cancel it"
1147 );
1148 Ok(())
1149 }
1150
1151 #[tokio::test]
1160 async fn a_live_wheel_timer_fires_when_the_engine_is_not_released()
1161 -> Result<(), Box<dyn std::error::Error>> {
1162 use aion_store::EventStore;
1163
1164 let store = Arc::new(InMemoryStore::default());
1165 let engine = EngineBuilder::new()
1166 .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1167 .visibility_store(InMemoryStore::default())
1168 .build()
1169 .await?;
1170 let (workflow_id, _) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1171
1172 tokio::time::sleep(WHEEL_TIMER_ARMING_MARGIN + WHEEL_TIMER_OBSERVATION_SLACK).await;
1173
1174 let history = store.read_history(&workflow_id).await?;
1175 assert!(
1176 history
1177 .iter()
1178 .any(|event| matches!(event, Event::TimerFired { .. })),
1179 "the fixture's wheel timer must reach the store when the engine is still held, or \
1180 the sibling test's absence proves nothing: {history:#?}"
1181 );
1182 drop(engine);
1183 Ok(())
1184 }
1185
1186 #[derive(Default)]
1194 struct TickCountingVisibilityStore {
1195 inner: InMemoryStore,
1196 list_calls: std::sync::atomic::AtomicUsize,
1197 }
1198
1199 impl TickCountingVisibilityStore {
1200 fn ticks(&self) -> usize {
1201 self.list_calls.load(std::sync::atomic::Ordering::Acquire)
1202 }
1203 }
1204
1205 #[async_trait::async_trait]
1206 impl VisibilityStore for TickCountingVisibilityStore {
1207 async fn record_visibility(
1208 &self,
1209 record: aion_store::visibility::VisibilityRecord,
1210 ) -> Result<(), aion_store::StoreError> {
1211 self.inner.record_visibility(record).await
1212 }
1213
1214 async fn list_workflows(
1215 &self,
1216 filter: ListWorkflowsFilter,
1217 ) -> Result<Vec<aion_store::visibility::WorkflowSummary>, aion_store::StoreError> {
1218 self.list_calls
1219 .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
1220 self.inner.list_workflows(filter).await
1221 }
1222
1223 async fn count_workflows(
1224 &self,
1225 filter: ListWorkflowsFilter,
1226 ) -> Result<u64, aion_store::StoreError> {
1227 self.inner.count_workflows(filter).await
1228 }
1229 }
1230
1231 fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
1232 NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
1233 }
1234
1235 #[tokio::test]
1236 async fn event_streaming_delivers_recorder_appends_through_engine_subscribe()
1237 -> Result<(), Box<dyn std::error::Error>> {
1238 let engine = EngineBuilder::new()
1239 .store(InMemoryStore::default())
1240 .in_memory_visibility()
1241 .event_streaming(capacity(8)?)
1242 .build()
1243 .await?;
1244 let workflow_id = WorkflowId::new_v4();
1245 let mut subscription = engine.subscribe(crate::EventFilter {
1246 workflow_id: Some(workflow_id.clone()),
1247 run: None,
1248 family: None,
1249 });
1250
1251 let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1254 recorder
1255 .record_workflow_started(
1256 Utc::now(),
1257 crate::durability::WorkflowStartRecord {
1258 workflow_type: "checkout".to_owned(),
1259 input: payload()?,
1260 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(7)),
1261 parent_run_id: None,
1262 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1263 },
1264 )
1265 .await?;
1266
1267 let item = tokio::time::timeout(Duration::from_secs(2), subscription.next())
1268 .await?
1269 .ok_or("subscription ended without delivering the appended event")?;
1270 let event = item?;
1271 assert_eq!(event.workflow_id(), &workflow_id);
1272 assert_eq!(event.seq(), 1);
1273 assert!(matches!(event, Event::WorkflowStarted { .. }));
1274 engine.shutdown()?;
1275 Ok(())
1276 }
1277
1278 #[tokio::test]
1279 async fn without_event_streaming_subscriptions_stay_on_deferred_empty_stream()
1280 -> Result<(), Box<dyn std::error::Error>> {
1281 let engine = EngineBuilder::new()
1282 .store(InMemoryStore::default())
1283 .in_memory_visibility()
1284 .build()
1285 .await?;
1286
1287 let mut subscription = engine.subscribe(crate::EventFilter::default());
1288 let item = tokio::time::timeout(Duration::from_secs(2), subscription.next()).await?;
1289
1290 assert!(item.is_none(), "deferred publisher streams must be empty");
1291 engine.shutdown()?;
1292 Ok(())
1293 }
1294
1295 #[tokio::test]
1296 async fn event_streaming_conflicts_with_explicit_event_publisher()
1297 -> Result<(), Box<dyn std::error::Error>> {
1298 let error = EngineBuilder::new()
1299 .store(InMemoryStore::default())
1300 .in_memory_visibility()
1301 .event_publisher(Arc::new(crate::DeferredEventPublisher))
1302 .event_streaming(capacity(8)?)
1303 .build()
1304 .await
1305 .err();
1306
1307 assert!(matches!(
1308 error,
1309 Some(EngineError::ConflictingEventPublisher)
1310 ));
1311 Ok(())
1312 }
1313
1314 #[test]
1315 fn query_timeout_is_only_set_by_caller() {
1316 assert_eq!(EngineBuilder::new().configured_query_timeout(), None);
1317 assert_eq!(
1318 EngineBuilder::new()
1319 .query_timeout(Duration::from_secs(3))
1320 .configured_query_timeout(),
1321 Some(Duration::from_secs(3))
1322 );
1323 }
1324
1325 async fn insert_running_workflow(
1326 engine: &crate::Engine,
1327 ) -> Result<(WorkflowId, aion_core::RunId), Box<dyn std::error::Error>> {
1328 let workflow_id = WorkflowId::new_v4();
1329 let run_id = aion_core::RunId::new_v4();
1330 let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1331 recorder
1332 .record_workflow_started(
1333 Utc::now(),
1334 crate::durability::WorkflowStartRecord {
1335 workflow_type: "checkout".to_owned(),
1336 input: payload()?,
1337 run_id: run_id.clone(),
1338 parent_run_id: None,
1339 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1340 },
1341 )
1342 .await?;
1343 let handle = crate::registry::WorkflowHandle::new(crate::registry::WorkflowHandleParts {
1344 workflow_id: workflow_id.clone(),
1345 run_id: run_id.clone(),
1346 pid: engine.runtime().spawn_test_process_with_trap_exit(true)?,
1347 workflow_type: "checkout".to_owned(),
1348 namespace: String::from("default"),
1349 loaded_version: aion_package::ContentHash::from_bytes([2; 32]),
1350 cached_status: WorkflowStatus::Running,
1351 residency: crate::registry::HandleResidency::Resident,
1352 recorder,
1353 completion: crate::registry::CompletionNotifier::new(),
1354 });
1355 engine
1356 .registry()
1357 .insert((workflow_id.clone(), run_id.clone()), handle)?;
1358 Ok((workflow_id, run_id))
1359 }
1360
1361 #[tokio::test]
1362 async fn query_timeout_installs_the_concrete_query_seam()
1363 -> Result<(), Box<dyn std::error::Error>> {
1364 let engine = EngineBuilder::new()
1365 .store(InMemoryStore::default())
1366 .in_memory_visibility()
1367 .query_timeout(Duration::from_millis(250))
1368 .build()
1369 .await?;
1370 let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1371
1372 let result = engine
1376 .query(
1377 &workflow_id,
1378 &run_id,
1379 "state",
1380 aion_core::Payload::json_null(),
1381 )
1382 .await;
1383
1384 assert!(matches!(
1385 result,
1386 Err(crate::EngineError::Query(crate::QueryError::UnknownQuery(name))) if name == "state"
1387 ));
1388 engine.shutdown()?;
1389 Ok(())
1390 }
1391
1392 #[tokio::test]
1393 async fn without_query_timeout_the_query_seam_stays_deferred()
1394 -> Result<(), Box<dyn std::error::Error>> {
1395 let engine = EngineBuilder::new()
1396 .store(InMemoryStore::default())
1397 .in_memory_visibility()
1398 .build()
1399 .await?;
1400 let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1401
1402 let result = engine
1403 .query(
1404 &workflow_id,
1405 &run_id,
1406 "state",
1407 aion_core::Payload::json_null(),
1408 )
1409 .await;
1410
1411 assert!(matches!(
1412 result,
1413 Err(crate::EngineError::Runtime { reason }) if reason.contains("not configured")
1414 ));
1415 engine.shutdown()?;
1416 Ok(())
1417 }
1418
1419 #[test]
1420 fn owned_shards_are_only_set_by_caller() {
1421 assert_eq!(EngineBuilder::new().configured_owned_shards(), None);
1425 assert_eq!(
1426 EngineBuilder::new()
1427 .owned_shards([2, 0, 2, 1])
1428 .configured_owned_shards(),
1429 Some([2, 0, 2, 1].as_slice())
1430 );
1431 }
1432
1433 #[test]
1434 fn scheduler_threads_are_only_set_by_caller() {
1435 assert_eq!(EngineBuilder::new().scheduler_thread_count(), None);
1436 assert_eq!(
1437 EngineBuilder::new()
1438 .scheduler_threads(4)
1439 .scheduler_thread_count(),
1440 Some(4)
1441 );
1442 }
1443
1444 #[test]
1458 fn the_completion_retry_ladder_reaches_the_runtime_configuration()
1459 -> Result<(), Box<dyn std::error::Error>> {
1460 let chosen = crate::runtime::CompletionRetryConfig::try_new(
1461 Duration::from_millis(250),
1462 Duration::from_secs(7),
1463 )?;
1464 assert_ne!(
1465 chosen,
1466 crate::runtime::CompletionRetryConfig::default(),
1467 "a ladder equal to the default could not distinguish wiring from inheritance"
1468 );
1469
1470 assert_eq!(
1471 EngineBuilder::new()
1472 .completion_retry(chosen)
1473 .runtime_config()
1474 .completion_retry,
1475 chosen,
1476 "the runtime must be started with the operator's ladder, not the inherited default"
1477 );
1478 Ok(())
1479 }
1480
1481 #[test]
1482 fn visibility_reconciliation_interval_is_only_set_by_caller() {
1483 let interval = Duration::from_millis(250);
1484
1485 assert_eq!(
1486 EngineBuilder::new().configured_visibility_reconciliation_interval(),
1487 None
1488 );
1489 assert_eq!(
1490 EngineBuilder::new()
1491 .visibility_reconciliation_interval(interval)
1492 .configured_visibility_reconciliation_interval(),
1493 Some(interval)
1494 );
1495 }
1496
1497 #[tokio::test]
1498 async fn duplicate_host_nif_mfa_returns_typed_error() {
1499 let mfa = Mfa::new("host", "zero", 0);
1500 let error = EngineBuilder::new()
1501 .store(InMemoryStore::default())
1502 .in_memory_visibility()
1503 .register_nifs([
1504 NifEntry::new(
1505 mfa.clone(),
1506 crate::runtime::nif::test_native_zero,
1507 Determinism::Pure,
1508 ),
1509 NifEntry::dirty(
1510 mfa,
1511 crate::runtime::nif::test_native_zero,
1512 Determinism::Pure,
1513 ),
1514 ])
1515 .build()
1516 .await
1517 .err();
1518
1519 assert!(matches!(
1520 error,
1521 Some(EngineError::NifRegistration { reason }) if reason.contains("host:zero/0")
1522 ));
1523 }
1524
1525 #[tokio::test]
1526 async fn empty_store_builds_coordinator_history_without_registry_or_supervision()
1527 -> Result<(), EngineError> {
1528 let store = Arc::new(InMemoryStore::default());
1529 let engine = EngineBuilder::new()
1530 .store_arc(store.clone())
1531 .in_memory_visibility()
1532 .build()
1533 .await?;
1534
1535 assert!(engine.registry().list()?.is_empty());
1536 assert_eq!(engine.supervision().type_supervisor_count()?, 1);
1537 assert_eq!(engine.workflow_catalog().workflows()?.len(), 0);
1538
1539 let coordinator_id = schedule_coordinator_workflow_id();
1540 let active = store.list_active().await?;
1541 assert_eq!(active, vec![coordinator_id.clone()]);
1542 let history = store.read_history(&coordinator_id).await?;
1543 let [started] = history.as_slice() else {
1544 return Err(EngineError::Load {
1545 reason: format!(
1546 "expected exactly one coordinator event, found {}",
1547 history.len()
1548 ),
1549 });
1550 };
1551 match started {
1552 Event::WorkflowStarted {
1553 workflow_type,
1554 input,
1555 run_id,
1556 parent_run_id,
1557 ..
1558 } => {
1559 assert_eq!(workflow_type, schedule_coordinator_workflow_type());
1560 assert_eq!(
1561 input,
1562 &Payload::from_json(&json!({})).map_err(|error| {
1563 EngineError::Load {
1564 reason: format!("failed to build expected payload: {error}"),
1565 }
1566 })?
1567 );
1568 assert_eq!(run_id, &schedule_coordinator_run_id());
1569 assert!(parent_run_id.is_none());
1570 }
1571 other => {
1572 return Err(EngineError::Load {
1573 reason: format!("expected coordinator WorkflowStarted, found {other:?}"),
1574 });
1575 }
1576 }
1577
1578 engine.shutdown()?;
1579 let rebuilt = EngineBuilder::new()
1580 .store_arc(store.clone())
1581 .in_memory_visibility()
1582 .build()
1583 .await?;
1584 let rebuilt_history = store.read_history(&coordinator_id).await?;
1585 assert_eq!(rebuilt_history.len(), 1);
1586 rebuilt.shutdown()?;
1587
1588 Ok(())
1589 }
1590
1591 #[tokio::test]
1592 async fn build_loads_already_loaded_package() -> Result<(), Box<dyn std::error::Error>> {
1593 if gleam_test_support::skip_if_unavailable() {
1594 return Ok(());
1595 }
1596 let package = fixture_package()?;
1597 let version = package.content_hash().clone();
1598 let deployed_entry_module = package.deployed_entry_module();
1599
1600 let engine = EngineBuilder::new()
1601 .store(InMemoryStore::default())
1602 .in_memory_visibility()
1603 .load_workflows(package)
1604 .build()
1605 .await?;
1606
1607 let loaded = engine
1608 .workflow_catalog()
1609 .get("counter", &version)?
1610 .ok_or("loaded package record missing")?;
1611 assert_eq!(loaded.deployed_entry_module(), deployed_entry_module);
1612 assert!(
1613 engine
1614 .runtime()
1615 .has_registered_module(&deployed_entry_module)
1616 );
1617 Ok(())
1618 }
1619
1620 #[tokio::test]
1621 async fn startup_reconciliation_backfills_completed_visibility()
1622 -> Result<(), Box<dyn std::error::Error>> {
1623 let store = Arc::new(InMemoryStore::default());
1624 let completed_id = WorkflowId::new_v4();
1625
1626 store
1627 .append(
1628 WriteToken::recorder(),
1629 &completed_id,
1630 &[
1631 started(&completed_id, "billing")?,
1632 completed(&completed_id)?,
1633 ],
1634 0,
1635 )
1636 .await?;
1637
1638 let engine = EngineBuilder::new()
1639 .store_arc(store.clone())
1640 .visibility_store_arc(store.clone())
1641 .build()
1642 .await?;
1643
1644 let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1645 let completed_summary = summaries
1646 .iter()
1647 .find(|summary| summary.workflow_id == completed_id)
1648 .ok_or("completed workflow missing from visibility")?;
1649
1650 assert_eq!(completed_summary.status, WorkflowStatus::Completed);
1651 assert!(completed_summary.close_time.is_some());
1652 engine.shutdown()?;
1653 Ok(())
1654 }
1655
1656 #[tokio::test]
1657 async fn periodic_visibility_reconciliation_repairs_gap_after_startup()
1658 -> Result<(), Box<dyn std::error::Error>> {
1659 let store = Arc::new(InMemoryStore::default());
1660 let engine = EngineBuilder::new()
1661 .store_arc(store.clone())
1662 .visibility_store_arc(store.clone())
1663 .visibility_reconciliation_interval(Duration::from_millis(25))
1664 .build()
1665 .await?;
1666 let workflow_id = WorkflowId::new_v4();
1667
1668 store
1669 .append(
1670 WriteToken::recorder(),
1671 &workflow_id,
1672 &[started(&workflow_id, "checkout")?],
1673 0,
1674 )
1675 .await?;
1676
1677 tokio::time::timeout(Duration::from_secs(2), async {
1678 loop {
1679 let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1680 if summaries.iter().any(|summary| {
1681 summary.workflow_id == workflow_id && summary.status == WorkflowStatus::Running
1682 }) {
1683 return Ok::<(), aion_store::StoreError>(());
1684 }
1685 tokio::time::sleep(Duration::from_millis(10)).await;
1686 }
1687 })
1688 .await??;
1689
1690 engine.shutdown()?;
1691 Ok(())
1692 }
1693
1694 #[tokio::test]
1695 async fn build_loads_package_from_path() -> Result<(), Box<dyn std::error::Error>> {
1696 if gleam_test_support::skip_if_unavailable() {
1697 return Ok(());
1698 }
1699 let package = fixture_package()?;
1700 let version = package.content_hash().clone();
1701 let path = write_fixture_package(&package)?;
1702
1703 let engine = EngineBuilder::new()
1704 .store(InMemoryStore::default())
1705 .in_memory_visibility()
1706 .load_workflows(path.as_path())
1707 .build()
1708 .await?;
1709 std::fs::remove_file(path)?;
1710
1711 assert!(
1712 engine
1713 .workflow_catalog()
1714 .get("counter", &version)?
1715 .is_some()
1716 );
1717 Ok(())
1718 }
1719}