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