1use std::{num::NonZeroUsize, path::PathBuf, sync::Arc, time::Duration};
4
5use chrono::Utc;
6
7use aion_core::SearchAttributeSchema;
8use aion_package::{ExtractionLimits, Package};
9use aion_store::visibility::VisibilityStore;
10use aion_store::{EventStore, InMemoryStore};
11
12use crate::{
13 EngineError, Registry, RuntimeConfig, RuntimeHandle, SignalDeliveryConfig, SupervisionTree,
14 WorkflowCatalog,
15 activity::bridge::ActivityDispatcher,
16 durability::ActiveWorkflowRecoverySeam,
17 runtime::{
18 ChildNifBridge, ChildNifBridgeParts, NifEntry, NifRegistration, install_child_nif_bridge,
19 install_nif_runtime_context, install_query_bridge, install_signal_nif_bridge,
20 nif_determinism::{NifContextSource, install_nif_context_source},
21 },
22 signal::SignalResumeHandoff,
23};
24
25use super::api::{Engine, EngineComponents};
26use super::delegated::{DelegatedSeams, EventPublisher, QueryService, SignalRouter};
27use super::seams::{
28 SeamAssembly, SignalRouterFactory, assemble_delegated_seams, wrap_event_streaming,
29};
30use super::startup::{
31 StartupRecoveryContext, recover_active_workflows_on_startup, recover_timers_on_startup,
32};
33
34#[derive(Clone, Debug)]
37pub enum WorkflowPackageSource {
38 Path(PathBuf),
40 Package(Box<Package>),
42}
43
44fn install_engine_nif_seams(
52 nif_state: &Arc<crate::runtime::EngineNifState>,
53 registry: &Arc<Registry>,
54 store: &Arc<dyn EventStore>,
55 runtime: &Arc<RuntimeHandle>,
56 activity_dispatcher: Option<Arc<dyn ActivityDispatcher>>,
57 query_timeout: Option<Duration>,
58) -> Arc<dyn crate::engine_seam::EngineHandle> {
59 install_nif_runtime_context(
60 nif_state,
61 Arc::clone(registry),
62 Arc::clone(runtime),
63 tokio::runtime::Handle::current(),
64 );
65 crate::runtime::nif_timer_bridge::install_timer_nif_bridge(
66 nif_state,
67 Arc::clone(registry),
68 Arc::clone(store),
69 tokio::runtime::Handle::current(),
70 runtime.signal_delivery(),
71 );
72 install_nif_context_source(
73 nif_state,
74 Arc::new(NifContextSource::new(
75 Arc::clone(registry),
76 tokio::runtime::Handle::current(),
77 Arc::clone(store),
78 runtime.signal_delivery(),
79 )),
80 );
81 let query_mailbox_engine = install_query_bridge(
82 nif_state,
83 Arc::clone(registry),
84 runtime,
85 tokio::runtime::Handle::current(),
86 query_timeout,
87 );
88 if let Some(dispatcher) = activity_dispatcher {
89 nif_state.set_activity_dispatcher(dispatcher);
90 }
91 query_mailbox_engine
92}
93
94async fn assemble_startup_catalog(
106 runtime: &RuntimeHandle,
107 store: &dyn EventStore,
108 sources: Vec<WorkflowPackageSource>,
109) -> Result<Arc<WorkflowCatalog>, EngineError> {
110 let catalog = Arc::new(WorkflowCatalog::new());
111 crate::loader::persistence::reload_persisted_packages(runtime, catalog.as_ref(), store).await?;
112 for source in sources {
113 let package = package_from_source(source)?;
114 let outcome = catalog.load_package(runtime, &package).await?;
115 tracing::info!(
116 workflow_type = outcome.record.workflow_type(),
117 content_hash = %outcome.record.version(),
118 freshly_loaded = outcome.freshly_loaded,
119 "loaded workflow package {}",
120 outcome.record.workflow_type()
121 );
122 }
123 Ok(catalog)
124}
125
126impl From<Package> for WorkflowPackageSource {
127 fn from(package: Package) -> Self {
128 Self::Package(Box::new(package))
129 }
130}
131
132fn spawn_visibility_reconciliation_task(
133 interval: Duration,
134 store: Arc<dyn EventStore>,
135 visibility_store: Arc<dyn VisibilityStore>,
136) -> tokio::task::JoinHandle<()> {
137 tokio::spawn(async move {
138 loop {
139 tokio::time::sleep(interval).await;
140 if let Err(error) = crate::lifecycle::visibility::reconcile_visibility(
141 Arc::clone(&store),
142 Arc::clone(&visibility_store),
143 )
144 .await
145 {
146 tracing::warn!(
147 error = %error,
148 "periodic visibility reconciliation failed; crash-consistency window may remain until a later reconciliation repairs visibility"
149 );
150 }
151 }
152 })
153}
154
155fn apply_owned_shards(store: &dyn EventStore, owned_shards: Option<&[usize]>) {
163 if let Some(shards) = owned_shards {
164 store.set_owned_shards(Some(shards));
165 }
166}
167
168fn acquire_owned_shards(
185 store: &dyn EventStore,
186 owned_shards: Option<&[usize]>,
187) -> Result<(), EngineError> {
188 if let Some(shards) = owned_shards {
189 store.acquire_owned_shards(shards)?;
190 }
191 Ok(())
192}
193
194fn maybe_spawn_visibility_reconciliation(
197 interval: Option<Duration>,
198 store: &Arc<dyn EventStore>,
199 visibility_store: &Arc<dyn VisibilityStore>,
200) -> Option<tokio::task::JoinHandle<()>> {
201 interval.map(|interval| {
202 spawn_visibility_reconciliation_task(
203 interval,
204 Arc::clone(store),
205 Arc::clone(visibility_store),
206 )
207 })
208}
209
210impl From<PathBuf> for WorkflowPackageSource {
211 fn from(path: PathBuf) -> Self {
212 Self::Path(path)
213 }
214}
215
216impl From<&std::path::Path> for WorkflowPackageSource {
217 fn from(path: &std::path::Path) -> Self {
218 Self::Path(path.to_path_buf())
219 }
220}
221
222impl From<&str> for WorkflowPackageSource {
223 fn from(path: &str) -> Self {
224 Self::Path(PathBuf::from(path))
225 }
226}
227
228impl From<String> for WorkflowPackageSource {
229 fn from(path: String) -> Self {
230 Self::Path(PathBuf::from(path))
231 }
232}
233
234#[derive(Default)]
239struct SeamOverrides {
240 event_publisher: bool,
242 query_service: bool,
244}
245
246pub struct EngineBuilder {
248 store: Option<Arc<dyn EventStore>>,
249 visibility_store: Option<Arc<dyn VisibilityStore>>,
250 scheduler_threads: Option<usize>,
251 signal_delivery: SignalDeliveryConfig,
252 outbox_enabled: bool,
253 bootstrap_schedule_coordinator: bool,
254 owned_shards: Option<Vec<usize>>,
255 workflow_sources: Vec<WorkflowPackageSource>,
256 host_nifs: Vec<NifEntry>,
257 recovery: Option<Arc<dyn ActiveWorkflowRecoverySeam>>,
258 delegated: DelegatedSeams,
259 signal_router_factory: Option<SignalRouterFactory>,
260 activity_dispatcher: Option<Arc<dyn ActivityDispatcher>>,
261 active_registry: Option<Arc<Registry>>,
262 visibility_reconciliation_interval: Option<Duration>,
263 search_attribute_schema: SearchAttributeSchema,
264 event_streaming_capacity: Option<NonZeroUsize>,
265 query_timeout: Option<Duration>,
266 seam_overrides: SeamOverrides,
267}
268
269impl Default for EngineBuilder {
270 fn default() -> Self {
271 Self::new()
272 }
273}
274
275impl EngineBuilder {
276 #[must_use]
279 pub fn new() -> Self {
280 Self {
281 store: None,
282 visibility_store: None,
283 scheduler_threads: None,
284 signal_delivery: SignalDeliveryConfig::default(),
285 outbox_enabled: false,
286 bootstrap_schedule_coordinator: true,
290 owned_shards: None,
294 workflow_sources: Vec::new(),
295 host_nifs: Vec::new(),
296 recovery: None,
297 delegated: DelegatedSeams::default(),
298 signal_router_factory: None,
299 activity_dispatcher: None,
300 active_registry: None,
301 visibility_reconciliation_interval: None,
302 search_attribute_schema: SearchAttributeSchema::new(),
303 event_streaming_capacity: None,
304 query_timeout: None,
305 seam_overrides: SeamOverrides::default(),
306 }
307 }
308
309 #[must_use]
317 pub const fn query_timeout(mut self, timeout: Duration) -> Self {
318 self.query_timeout = Some(timeout);
319 self
320 }
321
322 #[must_use]
324 pub const fn configured_query_timeout(&self) -> Option<Duration> {
325 self.query_timeout
326 }
327
328 #[must_use]
338 pub const fn event_streaming(mut self, capacity: NonZeroUsize) -> Self {
339 self.event_streaming_capacity = Some(capacity);
340 self
341 }
342
343 #[must_use]
349 pub fn search_attribute_schema(mut self, schema: SearchAttributeSchema) -> Self {
350 self.search_attribute_schema = schema;
351 self
352 }
353
354 #[must_use]
356 pub fn store<S>(mut self, store: S) -> Self
357 where
358 S: EventStore,
359 {
360 self.store = Some(Arc::new(store));
361 self
362 }
363
364 #[must_use]
366 pub fn store_arc(mut self, store: Arc<dyn EventStore>) -> Self {
367 self.store = Some(store);
368 self
369 }
370
371 #[must_use]
373 pub fn visibility_store<S>(mut self, visibility_store: S) -> Self
374 where
375 S: VisibilityStore,
376 {
377 self.visibility_store = Some(Arc::new(visibility_store));
378 self
379 }
380
381 #[must_use]
383 pub fn visibility_store_arc(mut self, visibility_store: Arc<dyn VisibilityStore>) -> Self {
384 self.visibility_store = Some(visibility_store);
385 self
386 }
387
388 #[must_use]
394 pub fn in_memory_visibility(mut self) -> Self {
395 self.visibility_store = Some(Arc::new(InMemoryStore::default()));
396 self
397 }
398
399 #[must_use]
403 pub const fn scheduler_threads(mut self, threads: usize) -> Self {
404 self.scheduler_threads = Some(threads);
405 self
406 }
407
408 #[must_use]
412 pub const fn visibility_reconciliation_interval(mut self, interval: Duration) -> Self {
413 self.visibility_reconciliation_interval = Some(interval);
414 self
415 }
416
417 #[must_use]
419 pub const fn signal_delivery(mut self, signal_delivery: SignalDeliveryConfig) -> Self {
420 self.signal_delivery = signal_delivery;
421 self
422 }
423
424 #[must_use]
426 pub fn outbox_enabled(mut self, enabled: bool) -> Self {
427 self.outbox_enabled = enabled;
428 self
429 }
430
431 #[must_use]
439 pub const fn bootstrap_schedule_coordinator(mut self, enabled: bool) -> Self {
440 self.bootstrap_schedule_coordinator = enabled;
441 self
442 }
443
444 #[must_use]
458 pub fn owned_shards(mut self, shards: impl IntoIterator<Item = usize>) -> Self {
459 self.owned_shards = Some(shards.into_iter().collect());
460 self
461 }
462
463 #[must_use]
465 pub fn configured_owned_shards(&self) -> Option<&[usize]> {
466 self.owned_shards.as_deref()
467 }
468
469 #[must_use]
471 pub fn load_workflows(mut self, source: impl Into<WorkflowPackageSource>) -> Self {
472 self.workflow_sources.push(source.into());
473 self
474 }
475
476 #[must_use]
478 pub fn load_workflow_sources<I, S>(mut self, sources: I) -> Self
479 where
480 I: IntoIterator<Item = S>,
481 S: Into<WorkflowPackageSource>,
482 {
483 self.workflow_sources
484 .extend(sources.into_iter().map(Into::into));
485 self
486 }
487
488 #[must_use]
490 pub fn register_nifs(mut self, entries: impl IntoIterator<Item = NifEntry>) -> Self {
491 self.host_nifs.extend(entries);
492 self
493 }
494
495 #[must_use]
497 pub fn recovery_seam(mut self, recovery: Arc<dyn ActiveWorkflowRecoverySeam>) -> Self {
498 self.recovery = Some(recovery);
499 self
500 }
501
502 #[must_use]
504 pub fn production_recovery_seam(mut self) -> Self {
505 self.recovery = None;
506 self
507 }
508
509 #[must_use]
511 pub fn signal_router(mut self, signal_router: Arc<dyn SignalRouter>) -> Self {
512 self.signal_router_factory = None;
513 self.delegated = DelegatedSeams::new(
514 signal_router,
515 self.delegated.query_service_arc(),
516 self.delegated.event_publisher_arc(),
517 );
518 self
519 }
520
521 #[must_use]
523 pub fn signal_router_factory<F>(mut self, factory: F) -> Self
524 where
525 F: Fn(Arc<RuntimeHandle>, Arc<SignalResumeHandoff>) -> Arc<dyn SignalRouter>
526 + Send
527 + Sync
528 + 'static,
529 {
530 self.signal_router_factory = Some(Arc::new(factory));
531 self
532 }
533
534 #[must_use]
539 pub fn query_service(mut self, query_service: Arc<dyn QueryService>) -> Self {
540 self.seam_overrides.query_service = true;
541 self.delegated = DelegatedSeams::new(
542 self.delegated.signal_router_arc(),
543 query_service,
544 self.delegated.event_publisher_arc(),
545 );
546 self
547 }
548
549 #[must_use]
554 pub fn event_publisher(mut self, event_publisher: Arc<dyn EventPublisher>) -> Self {
555 self.seam_overrides.event_publisher = true;
556 self.delegated = DelegatedSeams::new(
557 self.delegated.signal_router_arc(),
558 self.delegated.query_service_arc(),
559 event_publisher,
560 );
561 self
562 }
563
564 #[must_use]
570 pub fn activity_dispatcher(mut self, dispatcher: Arc<dyn ActivityDispatcher>) -> Self {
571 self.activity_dispatcher = Some(dispatcher);
572 self
573 }
574
575 #[must_use]
580 pub fn active_registry(mut self, registry: Arc<Registry>) -> Self {
581 self.active_registry = Some(registry);
582 self
583 }
584
585 #[must_use]
587 pub const fn scheduler_thread_count(&self) -> Option<usize> {
588 self.scheduler_threads
589 }
590
591 #[must_use]
593 pub const fn configured_visibility_reconciliation_interval(&self) -> Option<Duration> {
594 self.visibility_reconciliation_interval
595 }
596
597 fn runtime_config(&self) -> RuntimeConfig {
600 RuntimeConfig::new(self.scheduler_threads)
601 .with_signal_delivery(self.signal_delivery)
602 .with_outbox_enabled(self.outbox_enabled)
603 }
604
605 pub async fn build(self) -> Result<Engine, EngineError> {
613 let runtime_config = self.runtime_config();
614 let (store, streaming_publisher) = wrap_event_streaming(
615 self.store.ok_or(EngineError::MissingStore)?,
616 self.event_streaming_capacity,
617 self.seam_overrides.event_publisher,
618 )?;
619 let visibility_store = self
620 .visibility_store
621 .ok_or(EngineError::MissingVisibilityStore)?;
622
623 apply_owned_shards(store.as_ref(), self.owned_shards.as_deref());
624 acquire_owned_shards(store.as_ref(), self.owned_shards.as_deref())?;
629
630 let runtime = Arc::new(RuntimeHandle::new(runtime_config)?);
631
632 let mut nifs = NifRegistration::new();
633 nifs.add_engine_nifs().add_host_nifs(self.host_nifs);
634 runtime.install_nifs(nifs)?;
635
636 let catalog =
640 assemble_startup_catalog(runtime.as_ref(), store.as_ref(), self.workflow_sources)
641 .await?;
642
643 let registry = self
644 .active_registry
645 .unwrap_or_else(|| Arc::new(Registry::default()));
646 let nif_state = Arc::clone(runtime.nif_state());
647 let query_mailbox_engine = install_engine_nif_seams(
648 &nif_state,
649 ®istry,
650 &store,
651 &runtime,
652 self.activity_dispatcher,
653 self.query_timeout,
654 );
655 let supervision = Arc::new(SupervisionTree::new());
656 let search_attribute_schema = Arc::new(self.search_attribute_schema);
657 let signal_handoff = Arc::new(SignalResumeHandoff::new());
658
659 let delegated = assemble_delegated_seams(SeamAssembly {
660 configured: self.delegated,
661 signal_router_factory: self.signal_router_factory,
662 runtime: Arc::clone(&runtime),
663 signal_handoff: Arc::clone(&signal_handoff),
664 streaming_publisher,
665 query_mailbox_engine,
666 query_timeout: self.query_timeout,
667 query_service_overridden: self.seam_overrides.query_service,
668 });
669
670 install_signal_nif_bridge(
671 &nif_state,
672 Arc::new(crate::runtime::SignalNifBridge::new(
673 Arc::clone(®istry),
674 Arc::clone(&runtime),
675 tokio::runtime::Handle::current(),
676 delegated.signal_router_arc(),
677 )),
678 );
679 install_configured_child_nif_bridge(&ChildBridgeAssembly {
680 nif_state: &nif_state,
681 store: &store,
682 visibility_store: &visibility_store,
683 runtime: &runtime,
684 catalog: &catalog,
685 registry: ®istry,
686 supervision: &supervision,
687 signal_handoff: &signal_handoff,
688 search_attribute_schema: &search_attribute_schema,
689 watch_backoff: self.signal_delivery,
690 })?;
691
692 recover_active_workflows_on_startup(StartupRecoveryContext {
699 store: Arc::clone(&store),
700 visibility_store: Arc::clone(&visibility_store),
701 runtime: Arc::clone(&runtime),
702 catalog: Arc::clone(&catalog),
703 registry: Arc::clone(®istry),
704 supervision: Arc::clone(&supervision),
705 recovery: self.recovery,
706 search_attribute_schema: Arc::clone(&search_attribute_schema),
707 bootstrap_schedule_coordinator: self.bootstrap_schedule_coordinator,
708 })
709 .await?;
710 recover_timers_on_startup(&nif_state, Arc::clone(&store)).await?;
711
712 let visibility_reconciliation_task = maybe_spawn_visibility_reconciliation(
713 self.visibility_reconciliation_interval,
714 &store,
715 &visibility_store,
716 );
717
718 let engine = Engine::new(EngineComponents {
719 store,
720 visibility_store,
721 runtime,
722 catalog,
723 registry,
724 supervision,
725 delegated,
726 signal_handoff,
727 search_attribute_schema,
728 visibility_reconciliation_task,
729 });
730 engine.catchup_schedule_coordinator().await?;
731 engine.recover_schedules_on_startup(Utc::now()).await?;
732 Ok(engine)
733 }
734}
735
736struct ChildBridgeAssembly<'a> {
738 nif_state: &'a Arc<crate::runtime::EngineNifState>,
739 store: &'a Arc<dyn EventStore>,
740 visibility_store: &'a Arc<dyn VisibilityStore>,
741 runtime: &'a Arc<RuntimeHandle>,
742 catalog: &'a Arc<WorkflowCatalog>,
743 registry: &'a Arc<Registry>,
744 supervision: &'a Arc<SupervisionTree>,
745 signal_handoff: &'a Arc<SignalResumeHandoff>,
746 search_attribute_schema: &'a Arc<aion_core::SearchAttributeSchema>,
747 watch_backoff: SignalDeliveryConfig,
750}
751
752fn register_workflow_deadline_handler(
762 nif_state: &crate::runtime::EngineNifState,
763 runtime: &Arc<RuntimeHandle>,
764 store: &Arc<dyn EventStore>,
765 visibility_store: &Arc<dyn VisibilityStore>,
766 registry: &Arc<Registry>,
767) -> Result<(), EngineError> {
768 crate::runtime::nif_timer_bridge::register_deadline_handler(
769 nif_state,
770 Arc::new(crate::lifecycle::deadline::WorkflowDeadlineHandler::new(
771 Arc::downgrade(runtime),
772 Arc::clone(store),
773 Arc::clone(visibility_store),
774 Arc::clone(registry),
775 )),
776 )
777 .map_err(|error| EngineError::Runtime {
778 reason: format!("failed to register workflow deadline handler: {error}"),
779 })
780}
781
782fn install_configured_child_nif_bridge(
783 assembly: &ChildBridgeAssembly<'_>,
784) -> Result<(), EngineError> {
785 install_child_nif_bridge(
786 assembly.nif_state,
787 Arc::new(ChildNifBridge::new(ChildNifBridgeParts {
788 store: Arc::clone(assembly.store),
789 visibility_store: Arc::clone(assembly.visibility_store),
790 runtime: Arc::clone(assembly.runtime),
791 catalog: Arc::clone(assembly.catalog),
792 registry: Arc::clone(assembly.registry),
793 supervision: Arc::clone(assembly.supervision),
794 signal_handoff: Arc::clone(assembly.signal_handoff),
795 search_attribute_schema: Arc::clone(assembly.search_attribute_schema),
796 tokio_handle: tokio::runtime::Handle::current(),
797 watch_backoff: assembly.watch_backoff,
798 })?),
799 );
800 register_workflow_deadline_handler(
805 assembly.nif_state,
806 assembly.runtime,
807 assembly.store,
808 assembly.visibility_store,
809 assembly.registry,
810 )
811}
812
813pub(super) fn package_from_source(source: WorkflowPackageSource) -> Result<Package, EngineError> {
814 match source {
815 WorkflowPackageSource::Path(path) => {
816 Package::load_from_path(&path, ExtractionLimits::unbounded()).map_err(|error| {
819 EngineError::Load {
820 reason: format!(
821 "failed to load workflow package `{}`: {error}",
822 path.display()
823 ),
824 }
825 })
826 }
827 WorkflowPackageSource::Package(package) => Ok(*package),
828 }
829}
830
831#[cfg(test)]
832mod tests {
833 use std::{num::NonZeroUsize, path::PathBuf, process::Command, sync::Arc, time::Duration};
834
835 use aion_core::{Event, EventEnvelope, Payload, WorkflowId, WorkflowStatus};
836 use aion_package::{
837 BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, ExtractionLimits, Manifest,
838 ManifestVersion, Package, PackageBuilder,
839 };
840 use aion_store::visibility::{ListWorkflowsFilter, VisibilityStore};
841 use aion_store::{InMemoryStore, ReadableEventStore, WritableEventStore, WriteToken};
842 use chrono::Utc;
843 use futures::StreamExt;
844 use serde_json::json;
845
846 use crate::engine::api_schedule::{
847 schedule_coordinator_run_id, schedule_coordinator_workflow_id,
848 schedule_coordinator_workflow_type,
849 };
850 use crate::runtime::{Mfa, NifEntry};
851
852 use super::EngineBuilder;
853 use crate::EngineError;
854
855 fn payload() -> Result<Payload, aion_core::PayloadError> {
856 Payload::from_json(&json!({ "input": true }))
857 }
858
859 fn started(
860 workflow_id: &WorkflowId,
861 workflow_type: &str,
862 ) -> Result<Event, aion_core::PayloadError> {
863 Ok(Event::WorkflowStarted {
864 envelope: EventEnvelope {
865 seq: 1,
866 recorded_at: Utc::now(),
867 workflow_id: workflow_id.clone(),
868 },
869 workflow_type: workflow_type.to_owned(),
870 input: payload()?,
871 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
872 parent_run_id: None,
873 package_version: aion_core::PackageVersion::new("a".repeat(64)),
874 })
875 }
876
877 fn completed(workflow_id: &WorkflowId) -> Result<Event, aion_core::PayloadError> {
878 Ok(Event::WorkflowCompleted {
879 envelope: EventEnvelope {
880 seq: 2,
881 recorded_at: Utc::now(),
882 workflow_id: workflow_id.clone(),
883 },
884 result: payload()?,
885 })
886 }
887
888 fn package_manifest() -> Manifest {
889 Manifest {
890 entry_module: "counter".to_owned(),
891 entry_function: "version".to_owned(),
892 input_schema: json!({ "type": "object" }),
893 output_schema: json!({ "type": "integer" }),
894 timeout: Some(Duration::from_secs(30)),
895 activities: vec![DeclaredActivity {
896 activity_type: "activity/test".to_owned(),
897 }],
898 version: ManifestVersion::new("test"),
899 format_version: CURRENT_FORMAT_VERSION,
900 additional_workflows: Vec::new(),
901 }
902 }
903
904 fn compile_counter_beam() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
905 let temp_dir =
906 std::env::temp_dir().join(format!("aion-engine-builder-{}", uuid::Uuid::new_v4()));
907 std::fs::create_dir(&temp_dir)?;
908 let source_path = temp_dir.join("counter.erl");
909 let beam_path = temp_dir.join("counter.beam");
910 std::fs::write(
911 &source_path,
912 "-module(counter).\n-export([version/0]).\nversion() -> 1.\n",
913 )?;
914 let status = Command::new("erlc")
915 .arg("-o")
916 .arg(&temp_dir)
917 .arg(&source_path)
918 .status()?;
919 if !status.success() {
920 let cleanup_result = std::fs::remove_dir_all(&temp_dir);
921 drop(cleanup_result);
922 return Err(format!("erlc failed with status {status}").into());
923 }
924 let bytes = std::fs::read(beam_path)?;
925 std::fs::remove_dir_all(temp_dir)?;
926 Ok(bytes)
927 }
928
929 fn fixture_package() -> Result<Package, Box<dyn std::error::Error>> {
930 let beams = BeamSet::new(vec![BeamModule::new("counter", compile_counter_beam()?)])?;
931 let archive = PackageBuilder::new(package_manifest(), beams).write_to_bytes()?;
932 Ok(Package::load_from_bytes(
933 archive,
934 ExtractionLimits::unbounded(),
935 )?)
936 }
937
938 fn write_fixture_package(package: &Package) -> Result<PathBuf, Box<dyn std::error::Error>> {
939 let path =
940 std::env::temp_dir().join(format!("aion-engine-builder-{}.aion", uuid::Uuid::new_v4()));
941 PackageBuilder::new(package.manifest().clone(), package.beams().clone())
942 .write_to_path(&path)?;
943 Ok(path)
944 }
945
946 #[tokio::test]
947 async fn build_without_store_returns_missing_store() {
948 let error = EngineBuilder::new().build().await.err();
949
950 assert!(matches!(error, Some(EngineError::MissingStore)));
951 }
952
953 #[tokio::test]
954 async fn build_without_visibility_store_returns_missing_visibility_store() {
955 let error = EngineBuilder::new()
956 .store(InMemoryStore::default())
957 .build()
958 .await
959 .err();
960
961 assert!(matches!(error, Some(EngineError::MissingVisibilityStore)));
962 }
963
964 #[tokio::test]
965 async fn in_memory_visibility_allows_build_without_visibility_store() -> Result<(), EngineError>
966 {
967 let engine = EngineBuilder::new()
968 .store(InMemoryStore::default())
969 .in_memory_visibility()
970 .build()
971 .await?;
972
973 engine.shutdown()?;
974 Ok(())
975 }
976
977 fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
978 NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
979 }
980
981 #[tokio::test]
982 async fn event_streaming_delivers_recorder_appends_through_engine_subscribe()
983 -> Result<(), Box<dyn std::error::Error>> {
984 let engine = EngineBuilder::new()
985 .store(InMemoryStore::default())
986 .in_memory_visibility()
987 .event_streaming(capacity(8)?)
988 .build()
989 .await?;
990 let workflow_id = WorkflowId::new_v4();
991 let mut subscription = engine.subscribe(crate::EventFilter {
992 workflow_id: Some(workflow_id.clone()),
993 run: None,
994 family: None,
995 });
996
997 let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1000 recorder
1001 .record_workflow_started(
1002 Utc::now(),
1003 crate::durability::WorkflowStartRecord {
1004 workflow_type: "checkout".to_owned(),
1005 input: payload()?,
1006 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(7)),
1007 parent_run_id: None,
1008 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1009 },
1010 )
1011 .await?;
1012
1013 let item = tokio::time::timeout(Duration::from_secs(2), subscription.next())
1014 .await?
1015 .ok_or("subscription ended without delivering the appended event")?;
1016 let event = item?;
1017 assert_eq!(event.workflow_id(), &workflow_id);
1018 assert_eq!(event.seq(), 1);
1019 assert!(matches!(event, Event::WorkflowStarted { .. }));
1020 engine.shutdown()?;
1021 Ok(())
1022 }
1023
1024 #[tokio::test]
1025 async fn without_event_streaming_subscriptions_stay_on_deferred_empty_stream()
1026 -> Result<(), Box<dyn std::error::Error>> {
1027 let engine = EngineBuilder::new()
1028 .store(InMemoryStore::default())
1029 .in_memory_visibility()
1030 .build()
1031 .await?;
1032
1033 let mut subscription = engine.subscribe(crate::EventFilter::default());
1034 let item = tokio::time::timeout(Duration::from_secs(2), subscription.next()).await?;
1035
1036 assert!(item.is_none(), "deferred publisher streams must be empty");
1037 engine.shutdown()?;
1038 Ok(())
1039 }
1040
1041 #[tokio::test]
1042 async fn event_streaming_conflicts_with_explicit_event_publisher()
1043 -> Result<(), Box<dyn std::error::Error>> {
1044 let error = EngineBuilder::new()
1045 .store(InMemoryStore::default())
1046 .in_memory_visibility()
1047 .event_publisher(Arc::new(crate::DeferredEventPublisher))
1048 .event_streaming(capacity(8)?)
1049 .build()
1050 .await
1051 .err();
1052
1053 assert!(matches!(
1054 error,
1055 Some(EngineError::ConflictingEventPublisher)
1056 ));
1057 Ok(())
1058 }
1059
1060 #[test]
1061 fn query_timeout_is_only_set_by_caller() {
1062 assert_eq!(EngineBuilder::new().configured_query_timeout(), None);
1063 assert_eq!(
1064 EngineBuilder::new()
1065 .query_timeout(Duration::from_secs(3))
1066 .configured_query_timeout(),
1067 Some(Duration::from_secs(3))
1068 );
1069 }
1070
1071 async fn insert_running_workflow(
1072 engine: &crate::Engine,
1073 ) -> Result<(WorkflowId, aion_core::RunId), Box<dyn std::error::Error>> {
1074 let workflow_id = WorkflowId::new_v4();
1075 let run_id = aion_core::RunId::new_v4();
1076 let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1077 recorder
1078 .record_workflow_started(
1079 Utc::now(),
1080 crate::durability::WorkflowStartRecord {
1081 workflow_type: "checkout".to_owned(),
1082 input: payload()?,
1083 run_id: run_id.clone(),
1084 parent_run_id: None,
1085 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1086 },
1087 )
1088 .await?;
1089 let handle = crate::registry::WorkflowHandle::new(crate::registry::WorkflowHandleParts {
1090 workflow_id: workflow_id.clone(),
1091 run_id: run_id.clone(),
1092 pid: engine.runtime().spawn_test_process_with_trap_exit(true)?,
1093 workflow_type: "checkout".to_owned(),
1094 namespace: String::from("default"),
1095 loaded_version: aion_package::ContentHash::from_bytes([2; 32]),
1096 cached_status: WorkflowStatus::Running,
1097 residency: crate::registry::HandleResidency::Resident,
1098 recorder,
1099 completion: crate::registry::CompletionNotifier::new(),
1100 });
1101 engine
1102 .registry()
1103 .insert((workflow_id.clone(), run_id.clone()), handle)?;
1104 Ok((workflow_id, run_id))
1105 }
1106
1107 #[tokio::test]
1108 async fn query_timeout_installs_the_concrete_query_seam()
1109 -> Result<(), Box<dyn std::error::Error>> {
1110 let engine = EngineBuilder::new()
1111 .store(InMemoryStore::default())
1112 .in_memory_visibility()
1113 .query_timeout(Duration::from_millis(250))
1114 .build()
1115 .await?;
1116 let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1117
1118 let result = engine.query(&workflow_id, &run_id, "state").await;
1122
1123 assert!(matches!(
1124 result,
1125 Err(crate::EngineError::Query(crate::QueryError::UnknownQuery(name))) if name == "state"
1126 ));
1127 engine.shutdown()?;
1128 Ok(())
1129 }
1130
1131 #[tokio::test]
1132 async fn without_query_timeout_the_query_seam_stays_deferred()
1133 -> Result<(), Box<dyn std::error::Error>> {
1134 let engine = EngineBuilder::new()
1135 .store(InMemoryStore::default())
1136 .in_memory_visibility()
1137 .build()
1138 .await?;
1139 let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1140
1141 let result = engine.query(&workflow_id, &run_id, "state").await;
1142
1143 assert!(matches!(
1144 result,
1145 Err(crate::EngineError::Runtime { reason }) if reason.contains("not configured")
1146 ));
1147 engine.shutdown()?;
1148 Ok(())
1149 }
1150
1151 #[test]
1152 fn owned_shards_are_only_set_by_caller() {
1153 assert_eq!(EngineBuilder::new().configured_owned_shards(), None);
1157 assert_eq!(
1158 EngineBuilder::new()
1159 .owned_shards([2, 0, 2, 1])
1160 .configured_owned_shards(),
1161 Some([2, 0, 2, 1].as_slice())
1162 );
1163 }
1164
1165 #[test]
1166 fn scheduler_threads_are_only_set_by_caller() {
1167 assert_eq!(EngineBuilder::new().scheduler_thread_count(), None);
1168 assert_eq!(
1169 EngineBuilder::new()
1170 .scheduler_threads(4)
1171 .scheduler_thread_count(),
1172 Some(4)
1173 );
1174 }
1175
1176 #[test]
1177 fn visibility_reconciliation_interval_is_only_set_by_caller() {
1178 let interval = Duration::from_millis(250);
1179
1180 assert_eq!(
1181 EngineBuilder::new().configured_visibility_reconciliation_interval(),
1182 None
1183 );
1184 assert_eq!(
1185 EngineBuilder::new()
1186 .visibility_reconciliation_interval(interval)
1187 .configured_visibility_reconciliation_interval(),
1188 Some(interval)
1189 );
1190 }
1191
1192 #[tokio::test]
1193 async fn duplicate_host_nif_mfa_returns_typed_error() {
1194 let mfa = Mfa::new("host", "zero", 0);
1195 let error = EngineBuilder::new()
1196 .store(InMemoryStore::default())
1197 .in_memory_visibility()
1198 .register_nifs([
1199 NifEntry::new(mfa.clone(), crate::runtime::nif::test_native_zero),
1200 NifEntry::dirty(mfa, crate::runtime::nif::test_native_zero),
1201 ])
1202 .build()
1203 .await
1204 .err();
1205
1206 assert!(matches!(
1207 error,
1208 Some(EngineError::NifRegistration { reason }) if reason.contains("host:zero/0")
1209 ));
1210 }
1211
1212 #[tokio::test]
1213 async fn empty_store_builds_coordinator_history_without_registry_or_supervision()
1214 -> Result<(), EngineError> {
1215 let store = Arc::new(InMemoryStore::default());
1216 let engine = EngineBuilder::new()
1217 .store_arc(store.clone())
1218 .in_memory_visibility()
1219 .build()
1220 .await?;
1221
1222 assert!(engine.registry().list()?.is_empty());
1223 assert_eq!(engine.supervision().type_supervisor_count()?, 1);
1224 assert_eq!(engine.workflow_catalog().workflows()?.len(), 0);
1225
1226 let coordinator_id = schedule_coordinator_workflow_id();
1227 let active = store.list_active().await?;
1228 assert_eq!(active, vec![coordinator_id.clone()]);
1229 let history = store.read_history(&coordinator_id).await?;
1230 let [started] = history.as_slice() else {
1231 return Err(EngineError::Load {
1232 reason: format!(
1233 "expected exactly one coordinator event, found {}",
1234 history.len()
1235 ),
1236 });
1237 };
1238 match started {
1239 Event::WorkflowStarted {
1240 workflow_type,
1241 input,
1242 run_id,
1243 parent_run_id,
1244 ..
1245 } => {
1246 assert_eq!(workflow_type, schedule_coordinator_workflow_type());
1247 assert_eq!(
1248 input,
1249 &Payload::from_json(&json!({})).map_err(|error| {
1250 EngineError::Load {
1251 reason: format!("failed to build expected payload: {error}"),
1252 }
1253 })?
1254 );
1255 assert_eq!(run_id, &schedule_coordinator_run_id());
1256 assert!(parent_run_id.is_none());
1257 }
1258 other => {
1259 return Err(EngineError::Load {
1260 reason: format!("expected coordinator WorkflowStarted, found {other:?}"),
1261 });
1262 }
1263 }
1264
1265 engine.shutdown()?;
1266 let rebuilt = EngineBuilder::new()
1267 .store_arc(store.clone())
1268 .in_memory_visibility()
1269 .build()
1270 .await?;
1271 let rebuilt_history = store.read_history(&coordinator_id).await?;
1272 assert_eq!(rebuilt_history.len(), 1);
1273 rebuilt.shutdown()?;
1274
1275 Ok(())
1276 }
1277
1278 #[tokio::test]
1279 async fn build_loads_already_loaded_package() -> Result<(), Box<dyn std::error::Error>> {
1280 let package = fixture_package()?;
1281 let version = package.content_hash().clone();
1282 let deployed_entry_module = package.deployed_entry_module();
1283
1284 let engine = EngineBuilder::new()
1285 .store(InMemoryStore::default())
1286 .in_memory_visibility()
1287 .load_workflows(package)
1288 .build()
1289 .await?;
1290
1291 let loaded = engine
1292 .workflow_catalog()
1293 .get("counter", &version)?
1294 .ok_or("loaded package record missing")?;
1295 assert_eq!(loaded.deployed_entry_module(), deployed_entry_module);
1296 assert!(
1297 engine
1298 .runtime()
1299 .has_registered_module(&deployed_entry_module)
1300 );
1301 Ok(())
1302 }
1303
1304 #[tokio::test]
1305 async fn startup_reconciliation_backfills_completed_visibility()
1306 -> Result<(), Box<dyn std::error::Error>> {
1307 let store = Arc::new(InMemoryStore::default());
1308 let completed_id = WorkflowId::new_v4();
1309
1310 store
1311 .append(
1312 WriteToken::recorder(),
1313 &completed_id,
1314 &[
1315 started(&completed_id, "billing")?,
1316 completed(&completed_id)?,
1317 ],
1318 0,
1319 )
1320 .await?;
1321
1322 let engine = EngineBuilder::new()
1323 .store_arc(store.clone())
1324 .visibility_store_arc(store.clone())
1325 .build()
1326 .await?;
1327
1328 let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1329 let completed_summary = summaries
1330 .iter()
1331 .find(|summary| summary.workflow_id == completed_id)
1332 .ok_or("completed workflow missing from visibility")?;
1333
1334 assert_eq!(completed_summary.status, WorkflowStatus::Completed);
1335 assert!(completed_summary.close_time.is_some());
1336 engine.shutdown()?;
1337 Ok(())
1338 }
1339
1340 #[tokio::test]
1341 async fn periodic_visibility_reconciliation_repairs_gap_after_startup()
1342 -> Result<(), Box<dyn std::error::Error>> {
1343 let store = Arc::new(InMemoryStore::default());
1344 let engine = EngineBuilder::new()
1345 .store_arc(store.clone())
1346 .visibility_store_arc(store.clone())
1347 .visibility_reconciliation_interval(Duration::from_millis(25))
1348 .build()
1349 .await?;
1350 let workflow_id = WorkflowId::new_v4();
1351
1352 store
1353 .append(
1354 WriteToken::recorder(),
1355 &workflow_id,
1356 &[started(&workflow_id, "checkout")?],
1357 0,
1358 )
1359 .await?;
1360
1361 tokio::time::timeout(Duration::from_secs(2), async {
1362 loop {
1363 let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1364 if summaries.iter().any(|summary| {
1365 summary.workflow_id == workflow_id && summary.status == WorkflowStatus::Running
1366 }) {
1367 return Ok::<(), aion_store::StoreError>(());
1368 }
1369 tokio::time::sleep(Duration::from_millis(10)).await;
1370 }
1371 })
1372 .await??;
1373
1374 engine.shutdown()?;
1375 Ok(())
1376 }
1377
1378 #[tokio::test]
1379 async fn build_loads_package_from_path() -> Result<(), Box<dyn std::error::Error>> {
1380 let package = fixture_package()?;
1381 let version = package.content_hash().clone();
1382 let path = write_fixture_package(&package)?;
1383
1384 let engine = EngineBuilder::new()
1385 .store(InMemoryStore::default())
1386 .in_memory_visibility()
1387 .load_workflows(path.as_path())
1388 .build()
1389 .await?;
1390 std::fs::remove_file(path)?;
1391
1392 assert!(
1393 engine
1394 .workflow_catalog()
1395 .get("counter", &version)?
1396 .is_some()
1397 );
1398 Ok(())
1399 }
1400}