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 install_configured_child_nif_bridge(
753 assembly: &ChildBridgeAssembly<'_>,
754) -> Result<(), EngineError> {
755 install_child_nif_bridge(
756 assembly.nif_state,
757 Arc::new(ChildNifBridge::new(ChildNifBridgeParts {
758 store: Arc::clone(assembly.store),
759 visibility_store: Arc::clone(assembly.visibility_store),
760 runtime: Arc::clone(assembly.runtime),
761 catalog: Arc::clone(assembly.catalog),
762 registry: Arc::clone(assembly.registry),
763 supervision: Arc::clone(assembly.supervision),
764 signal_handoff: Arc::clone(assembly.signal_handoff),
765 search_attribute_schema: Arc::clone(assembly.search_attribute_schema),
766 tokio_handle: tokio::runtime::Handle::current(),
767 watch_backoff: assembly.watch_backoff,
768 })?),
769 );
770 Ok(())
771}
772
773pub(super) fn package_from_source(source: WorkflowPackageSource) -> Result<Package, EngineError> {
774 match source {
775 WorkflowPackageSource::Path(path) => {
776 Package::load_from_path(&path, ExtractionLimits::unbounded()).map_err(|error| {
779 EngineError::Load {
780 reason: format!(
781 "failed to load workflow package `{}`: {error}",
782 path.display()
783 ),
784 }
785 })
786 }
787 WorkflowPackageSource::Package(package) => Ok(*package),
788 }
789}
790
791#[cfg(test)]
792mod tests {
793 use std::{num::NonZeroUsize, path::PathBuf, process::Command, sync::Arc, time::Duration};
794
795 use aion_core::{Event, EventEnvelope, Payload, WorkflowId, WorkflowStatus};
796 use aion_package::{
797 BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, ExtractionLimits, Manifest,
798 ManifestVersion, Package, PackageBuilder,
799 };
800 use aion_store::visibility::{ListWorkflowsFilter, VisibilityStore};
801 use aion_store::{InMemoryStore, ReadableEventStore, WritableEventStore, WriteToken};
802 use chrono::Utc;
803 use futures::StreamExt;
804 use serde_json::json;
805
806 use crate::engine::api_schedule::{
807 schedule_coordinator_run_id, schedule_coordinator_workflow_id,
808 schedule_coordinator_workflow_type,
809 };
810 use crate::runtime::{Mfa, NifEntry};
811
812 use super::EngineBuilder;
813 use crate::EngineError;
814
815 fn payload() -> Result<Payload, aion_core::PayloadError> {
816 Payload::from_json(&json!({ "input": true }))
817 }
818
819 fn started(
820 workflow_id: &WorkflowId,
821 workflow_type: &str,
822 ) -> Result<Event, aion_core::PayloadError> {
823 Ok(Event::WorkflowStarted {
824 envelope: EventEnvelope {
825 seq: 1,
826 recorded_at: Utc::now(),
827 workflow_id: workflow_id.clone(),
828 },
829 workflow_type: workflow_type.to_owned(),
830 input: payload()?,
831 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
832 parent_run_id: None,
833 package_version: aion_core::PackageVersion::new("a".repeat(64)),
834 })
835 }
836
837 fn completed(workflow_id: &WorkflowId) -> Result<Event, aion_core::PayloadError> {
838 Ok(Event::WorkflowCompleted {
839 envelope: EventEnvelope {
840 seq: 2,
841 recorded_at: Utc::now(),
842 workflow_id: workflow_id.clone(),
843 },
844 result: payload()?,
845 })
846 }
847
848 fn package_manifest() -> Manifest {
849 Manifest {
850 entry_module: "counter".to_owned(),
851 entry_function: "version".to_owned(),
852 input_schema: json!({ "type": "object" }),
853 output_schema: json!({ "type": "integer" }),
854 timeout: Duration::from_secs(30),
855 activities: vec![DeclaredActivity {
856 activity_type: "activity/test".to_owned(),
857 }],
858 version: ManifestVersion::new("test"),
859 format_version: CURRENT_FORMAT_VERSION,
860 }
861 }
862
863 fn compile_counter_beam() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
864 let temp_dir =
865 std::env::temp_dir().join(format!("aion-engine-builder-{}", uuid::Uuid::new_v4()));
866 std::fs::create_dir(&temp_dir)?;
867 let source_path = temp_dir.join("counter.erl");
868 let beam_path = temp_dir.join("counter.beam");
869 std::fs::write(
870 &source_path,
871 "-module(counter).\n-export([version/0]).\nversion() -> 1.\n",
872 )?;
873 let status = Command::new("erlc")
874 .arg("-o")
875 .arg(&temp_dir)
876 .arg(&source_path)
877 .status()?;
878 if !status.success() {
879 let cleanup_result = std::fs::remove_dir_all(&temp_dir);
880 drop(cleanup_result);
881 return Err(format!("erlc failed with status {status}").into());
882 }
883 let bytes = std::fs::read(beam_path)?;
884 std::fs::remove_dir_all(temp_dir)?;
885 Ok(bytes)
886 }
887
888 fn fixture_package() -> Result<Package, Box<dyn std::error::Error>> {
889 let beams = BeamSet::new(vec![BeamModule::new("counter", compile_counter_beam()?)])?;
890 let archive = PackageBuilder::new(package_manifest(), beams).write_to_bytes()?;
891 Ok(Package::load_from_bytes(
892 archive,
893 ExtractionLimits::unbounded(),
894 )?)
895 }
896
897 fn write_fixture_package(package: &Package) -> Result<PathBuf, Box<dyn std::error::Error>> {
898 let path =
899 std::env::temp_dir().join(format!("aion-engine-builder-{}.aion", uuid::Uuid::new_v4()));
900 PackageBuilder::new(package.manifest().clone(), package.beams().clone())
901 .write_to_path(&path)?;
902 Ok(path)
903 }
904
905 #[tokio::test]
906 async fn build_without_store_returns_missing_store() {
907 let error = EngineBuilder::new().build().await.err();
908
909 assert!(matches!(error, Some(EngineError::MissingStore)));
910 }
911
912 #[tokio::test]
913 async fn build_without_visibility_store_returns_missing_visibility_store() {
914 let error = EngineBuilder::new()
915 .store(InMemoryStore::default())
916 .build()
917 .await
918 .err();
919
920 assert!(matches!(error, Some(EngineError::MissingVisibilityStore)));
921 }
922
923 #[tokio::test]
924 async fn in_memory_visibility_allows_build_without_visibility_store() -> Result<(), EngineError>
925 {
926 let engine = EngineBuilder::new()
927 .store(InMemoryStore::default())
928 .in_memory_visibility()
929 .build()
930 .await?;
931
932 engine.shutdown()?;
933 Ok(())
934 }
935
936 fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
937 NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
938 }
939
940 #[tokio::test]
941 async fn event_streaming_delivers_recorder_appends_through_engine_subscribe()
942 -> Result<(), Box<dyn std::error::Error>> {
943 let engine = EngineBuilder::new()
944 .store(InMemoryStore::default())
945 .in_memory_visibility()
946 .event_streaming(capacity(8)?)
947 .build()
948 .await?;
949 let workflow_id = WorkflowId::new_v4();
950 let mut subscription = engine.subscribe(crate::EventFilter {
951 workflow_id: Some(workflow_id.clone()),
952 run: None,
953 family: None,
954 });
955
956 let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
959 recorder
960 .record_workflow_started(
961 Utc::now(),
962 crate::durability::WorkflowStartRecord {
963 workflow_type: "checkout".to_owned(),
964 input: payload()?,
965 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(7)),
966 parent_run_id: None,
967 package_version: aion_core::PackageVersion::new("a".repeat(64)),
968 },
969 )
970 .await?;
971
972 let item = tokio::time::timeout(Duration::from_secs(2), subscription.next())
973 .await?
974 .ok_or("subscription ended without delivering the appended event")?;
975 let event = item?;
976 assert_eq!(event.workflow_id(), &workflow_id);
977 assert_eq!(event.seq(), 1);
978 assert!(matches!(event, Event::WorkflowStarted { .. }));
979 engine.shutdown()?;
980 Ok(())
981 }
982
983 #[tokio::test]
984 async fn without_event_streaming_subscriptions_stay_on_deferred_empty_stream()
985 -> Result<(), Box<dyn std::error::Error>> {
986 let engine = EngineBuilder::new()
987 .store(InMemoryStore::default())
988 .in_memory_visibility()
989 .build()
990 .await?;
991
992 let mut subscription = engine.subscribe(crate::EventFilter::default());
993 let item = tokio::time::timeout(Duration::from_secs(2), subscription.next()).await?;
994
995 assert!(item.is_none(), "deferred publisher streams must be empty");
996 engine.shutdown()?;
997 Ok(())
998 }
999
1000 #[tokio::test]
1001 async fn event_streaming_conflicts_with_explicit_event_publisher()
1002 -> Result<(), Box<dyn std::error::Error>> {
1003 let error = EngineBuilder::new()
1004 .store(InMemoryStore::default())
1005 .in_memory_visibility()
1006 .event_publisher(Arc::new(crate::DeferredEventPublisher))
1007 .event_streaming(capacity(8)?)
1008 .build()
1009 .await
1010 .err();
1011
1012 assert!(matches!(
1013 error,
1014 Some(EngineError::ConflictingEventPublisher)
1015 ));
1016 Ok(())
1017 }
1018
1019 #[test]
1020 fn query_timeout_is_only_set_by_caller() {
1021 assert_eq!(EngineBuilder::new().configured_query_timeout(), None);
1022 assert_eq!(
1023 EngineBuilder::new()
1024 .query_timeout(Duration::from_secs(3))
1025 .configured_query_timeout(),
1026 Some(Duration::from_secs(3))
1027 );
1028 }
1029
1030 async fn insert_running_workflow(
1031 engine: &crate::Engine,
1032 ) -> Result<(WorkflowId, aion_core::RunId), Box<dyn std::error::Error>> {
1033 let workflow_id = WorkflowId::new_v4();
1034 let run_id = aion_core::RunId::new_v4();
1035 let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1036 recorder
1037 .record_workflow_started(
1038 Utc::now(),
1039 crate::durability::WorkflowStartRecord {
1040 workflow_type: "checkout".to_owned(),
1041 input: payload()?,
1042 run_id: run_id.clone(),
1043 parent_run_id: None,
1044 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1045 },
1046 )
1047 .await?;
1048 let handle = crate::registry::WorkflowHandle::new(crate::registry::WorkflowHandleParts {
1049 workflow_id: workflow_id.clone(),
1050 run_id: run_id.clone(),
1051 pid: engine.runtime().spawn_test_process_with_trap_exit(true)?,
1052 workflow_type: "checkout".to_owned(),
1053 namespace: String::from("default"),
1054 loaded_version: aion_package::ContentHash::from_bytes([2; 32]),
1055 cached_status: WorkflowStatus::Running,
1056 residency: crate::registry::HandleResidency::Resident,
1057 recorder,
1058 completion: crate::registry::CompletionNotifier::new(),
1059 });
1060 engine
1061 .registry()
1062 .insert((workflow_id.clone(), run_id.clone()), handle)?;
1063 Ok((workflow_id, run_id))
1064 }
1065
1066 #[tokio::test]
1067 async fn query_timeout_installs_the_concrete_query_seam()
1068 -> Result<(), Box<dyn std::error::Error>> {
1069 let engine = EngineBuilder::new()
1070 .store(InMemoryStore::default())
1071 .in_memory_visibility()
1072 .query_timeout(Duration::from_millis(250))
1073 .build()
1074 .await?;
1075 let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1076
1077 let result = engine.query(&workflow_id, &run_id, "state").await;
1081
1082 assert!(matches!(
1083 result,
1084 Err(crate::EngineError::Query(crate::QueryError::UnknownQuery(name))) if name == "state"
1085 ));
1086 engine.shutdown()?;
1087 Ok(())
1088 }
1089
1090 #[tokio::test]
1091 async fn without_query_timeout_the_query_seam_stays_deferred()
1092 -> Result<(), Box<dyn std::error::Error>> {
1093 let engine = EngineBuilder::new()
1094 .store(InMemoryStore::default())
1095 .in_memory_visibility()
1096 .build()
1097 .await?;
1098 let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1099
1100 let result = engine.query(&workflow_id, &run_id, "state").await;
1101
1102 assert!(matches!(
1103 result,
1104 Err(crate::EngineError::Runtime { reason }) if reason.contains("not configured")
1105 ));
1106 engine.shutdown()?;
1107 Ok(())
1108 }
1109
1110 #[test]
1111 fn owned_shards_are_only_set_by_caller() {
1112 assert_eq!(EngineBuilder::new().configured_owned_shards(), None);
1116 assert_eq!(
1117 EngineBuilder::new()
1118 .owned_shards([2, 0, 2, 1])
1119 .configured_owned_shards(),
1120 Some([2, 0, 2, 1].as_slice())
1121 );
1122 }
1123
1124 #[test]
1125 fn scheduler_threads_are_only_set_by_caller() {
1126 assert_eq!(EngineBuilder::new().scheduler_thread_count(), None);
1127 assert_eq!(
1128 EngineBuilder::new()
1129 .scheduler_threads(4)
1130 .scheduler_thread_count(),
1131 Some(4)
1132 );
1133 }
1134
1135 #[test]
1136 fn visibility_reconciliation_interval_is_only_set_by_caller() {
1137 let interval = Duration::from_millis(250);
1138
1139 assert_eq!(
1140 EngineBuilder::new().configured_visibility_reconciliation_interval(),
1141 None
1142 );
1143 assert_eq!(
1144 EngineBuilder::new()
1145 .visibility_reconciliation_interval(interval)
1146 .configured_visibility_reconciliation_interval(),
1147 Some(interval)
1148 );
1149 }
1150
1151 #[tokio::test]
1152 async fn duplicate_host_nif_mfa_returns_typed_error() {
1153 let mfa = Mfa::new("host", "zero", 0);
1154 let error = EngineBuilder::new()
1155 .store(InMemoryStore::default())
1156 .in_memory_visibility()
1157 .register_nifs([
1158 NifEntry::new(mfa.clone(), crate::runtime::nif::test_native_zero),
1159 NifEntry::dirty(mfa, crate::runtime::nif::test_native_zero),
1160 ])
1161 .build()
1162 .await
1163 .err();
1164
1165 assert!(matches!(
1166 error,
1167 Some(EngineError::NifRegistration { reason }) if reason.contains("host:zero/0")
1168 ));
1169 }
1170
1171 #[tokio::test]
1172 async fn empty_store_builds_coordinator_history_without_registry_or_supervision()
1173 -> Result<(), EngineError> {
1174 let store = Arc::new(InMemoryStore::default());
1175 let engine = EngineBuilder::new()
1176 .store_arc(store.clone())
1177 .in_memory_visibility()
1178 .build()
1179 .await?;
1180
1181 assert!(engine.registry().list()?.is_empty());
1182 assert_eq!(engine.supervision().type_supervisor_count()?, 1);
1183 assert_eq!(engine.workflow_catalog().workflows()?.len(), 0);
1184
1185 let coordinator_id = schedule_coordinator_workflow_id();
1186 let active = store.list_active().await?;
1187 assert_eq!(active, vec![coordinator_id.clone()]);
1188 let history = store.read_history(&coordinator_id).await?;
1189 let [started] = history.as_slice() else {
1190 return Err(EngineError::Load {
1191 reason: format!(
1192 "expected exactly one coordinator event, found {}",
1193 history.len()
1194 ),
1195 });
1196 };
1197 match started {
1198 Event::WorkflowStarted {
1199 workflow_type,
1200 input,
1201 run_id,
1202 parent_run_id,
1203 ..
1204 } => {
1205 assert_eq!(workflow_type, schedule_coordinator_workflow_type());
1206 assert_eq!(
1207 input,
1208 &Payload::from_json(&json!({})).map_err(|error| {
1209 EngineError::Load {
1210 reason: format!("failed to build expected payload: {error}"),
1211 }
1212 })?
1213 );
1214 assert_eq!(run_id, &schedule_coordinator_run_id());
1215 assert!(parent_run_id.is_none());
1216 }
1217 other => {
1218 return Err(EngineError::Load {
1219 reason: format!("expected coordinator WorkflowStarted, found {other:?}"),
1220 });
1221 }
1222 }
1223
1224 engine.shutdown()?;
1225 let rebuilt = EngineBuilder::new()
1226 .store_arc(store.clone())
1227 .in_memory_visibility()
1228 .build()
1229 .await?;
1230 let rebuilt_history = store.read_history(&coordinator_id).await?;
1231 assert_eq!(rebuilt_history.len(), 1);
1232 rebuilt.shutdown()?;
1233
1234 Ok(())
1235 }
1236
1237 #[tokio::test]
1238 async fn build_loads_already_loaded_package() -> Result<(), Box<dyn std::error::Error>> {
1239 let package = fixture_package()?;
1240 let version = package.content_hash().clone();
1241 let deployed_entry_module = package.deployed_entry_module();
1242
1243 let engine = EngineBuilder::new()
1244 .store(InMemoryStore::default())
1245 .in_memory_visibility()
1246 .load_workflows(package)
1247 .build()
1248 .await?;
1249
1250 let loaded = engine
1251 .workflow_catalog()
1252 .get("counter", &version)?
1253 .ok_or("loaded package record missing")?;
1254 assert_eq!(loaded.deployed_entry_module(), deployed_entry_module);
1255 assert!(
1256 engine
1257 .runtime()
1258 .has_registered_module(&deployed_entry_module)
1259 );
1260 Ok(())
1261 }
1262
1263 #[tokio::test]
1264 async fn startup_reconciliation_backfills_completed_visibility()
1265 -> Result<(), Box<dyn std::error::Error>> {
1266 let store = Arc::new(InMemoryStore::default());
1267 let completed_id = WorkflowId::new_v4();
1268
1269 store
1270 .append(
1271 WriteToken::recorder(),
1272 &completed_id,
1273 &[
1274 started(&completed_id, "billing")?,
1275 completed(&completed_id)?,
1276 ],
1277 0,
1278 )
1279 .await?;
1280
1281 let engine = EngineBuilder::new()
1282 .store_arc(store.clone())
1283 .visibility_store_arc(store.clone())
1284 .build()
1285 .await?;
1286
1287 let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1288 let completed_summary = summaries
1289 .iter()
1290 .find(|summary| summary.workflow_id == completed_id)
1291 .ok_or("completed workflow missing from visibility")?;
1292
1293 assert_eq!(completed_summary.status, WorkflowStatus::Completed);
1294 assert!(completed_summary.close_time.is_some());
1295 engine.shutdown()?;
1296 Ok(())
1297 }
1298
1299 #[tokio::test]
1300 async fn periodic_visibility_reconciliation_repairs_gap_after_startup()
1301 -> Result<(), Box<dyn std::error::Error>> {
1302 let store = Arc::new(InMemoryStore::default());
1303 let engine = EngineBuilder::new()
1304 .store_arc(store.clone())
1305 .visibility_store_arc(store.clone())
1306 .visibility_reconciliation_interval(Duration::from_millis(25))
1307 .build()
1308 .await?;
1309 let workflow_id = WorkflowId::new_v4();
1310
1311 store
1312 .append(
1313 WriteToken::recorder(),
1314 &workflow_id,
1315 &[started(&workflow_id, "checkout")?],
1316 0,
1317 )
1318 .await?;
1319
1320 tokio::time::timeout(Duration::from_secs(2), async {
1321 loop {
1322 let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1323 if summaries.iter().any(|summary| {
1324 summary.workflow_id == workflow_id && summary.status == WorkflowStatus::Running
1325 }) {
1326 return Ok::<(), aion_store::StoreError>(());
1327 }
1328 tokio::time::sleep(Duration::from_millis(10)).await;
1329 }
1330 })
1331 .await??;
1332
1333 engine.shutdown()?;
1334 Ok(())
1335 }
1336
1337 #[tokio::test]
1338 async fn build_loads_package_from_path() -> Result<(), Box<dyn std::error::Error>> {
1339 let package = fixture_package()?;
1340 let version = package.content_hash().clone();
1341 let path = write_fixture_package(&package)?;
1342
1343 let engine = EngineBuilder::new()
1344 .store(InMemoryStore::default())
1345 .in_memory_visibility()
1346 .load_workflows(path.as_path())
1347 .build()
1348 .await?;
1349 std::fs::remove_file(path)?;
1350
1351 assert!(
1352 engine
1353 .workflow_catalog()
1354 .get("counter", &version)?
1355 .is_some()
1356 );
1357 Ok(())
1358 }
1359}