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 ActivityServing, EngineError, Registry, RuntimeConfig, RuntimeHandle, SignalDeliveryConfig,
14 SupervisionTree, 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 serving: ActivityServing,
110) -> Result<Arc<WorkflowCatalog>, EngineError> {
111 let catalog = Arc::new(WorkflowCatalog::new_with_serving(serving));
112 crate::loader::persistence::reload_persisted_packages(runtime, catalog.as_ref(), store).await?;
113 for source in sources {
114 let package = package_from_source(source)?;
115 let outcome = catalog.load_package(runtime, &package).await?;
116 tracing::info!(
117 workflow_type = outcome.record.workflow_type(),
118 content_hash = %outcome.record.version(),
119 freshly_loaded = outcome.freshly_loaded,
120 "loaded workflow package {}",
121 outcome.record.workflow_type()
122 );
123 }
124 Ok(catalog)
125}
126
127impl From<Package> for WorkflowPackageSource {
128 fn from(package: Package) -> Self {
129 Self::Package(Box::new(package))
130 }
131}
132
133fn spawn_visibility_reconciliation_task(
134 interval: Duration,
135 store: Arc<dyn EventStore>,
136 visibility_store: Arc<dyn VisibilityStore>,
137) -> tokio::task::JoinHandle<()> {
138 tokio::spawn(async move {
139 loop {
140 tokio::time::sleep(interval).await;
141 if let Err(error) = crate::lifecycle::visibility::reconcile_visibility(
142 Arc::clone(&store),
143 Arc::clone(&visibility_store),
144 )
145 .await
146 {
147 tracing::warn!(
148 error = %error,
149 "periodic visibility reconciliation failed; crash-consistency window may remain until a later reconciliation repairs visibility"
150 );
151 }
152 }
153 })
154}
155
156fn apply_owned_shards(store: &dyn EventStore, owned_shards: Option<&[usize]>) {
164 if let Some(shards) = owned_shards {
165 store.set_owned_shards(Some(shards));
166 }
167}
168
169fn acquire_owned_shards(
186 store: &dyn EventStore,
187 owned_shards: Option<&[usize]>,
188) -> Result<(), EngineError> {
189 if let Some(shards) = owned_shards {
190 store.acquire_owned_shards(shards)?;
191 }
192 Ok(())
193}
194
195fn maybe_spawn_visibility_reconciliation(
198 interval: Option<Duration>,
199 store: &Arc<dyn EventStore>,
200 visibility_store: &Arc<dyn VisibilityStore>,
201) -> Option<tokio::task::JoinHandle<()>> {
202 interval.map(|interval| {
203 spawn_visibility_reconciliation_task(
204 interval,
205 Arc::clone(store),
206 Arc::clone(visibility_store),
207 )
208 })
209}
210
211impl From<PathBuf> for WorkflowPackageSource {
212 fn from(path: PathBuf) -> Self {
213 Self::Path(path)
214 }
215}
216
217impl From<&std::path::Path> for WorkflowPackageSource {
218 fn from(path: &std::path::Path) -> Self {
219 Self::Path(path.to_path_buf())
220 }
221}
222
223impl From<&str> for WorkflowPackageSource {
224 fn from(path: &str) -> Self {
225 Self::Path(PathBuf::from(path))
226 }
227}
228
229impl From<String> for WorkflowPackageSource {
230 fn from(path: String) -> Self {
231 Self::Path(PathBuf::from(path))
232 }
233}
234
235#[derive(Default)]
240struct SeamOverrides {
241 event_publisher: bool,
243 query_service: bool,
245}
246
247pub struct EngineBuilder {
249 store: Option<Arc<dyn EventStore>>,
250 visibility_store: Option<Arc<dyn VisibilityStore>>,
251 scheduler_threads: Option<usize>,
252 signal_delivery: SignalDeliveryConfig,
253 outbox_enabled: bool,
254 bootstrap_schedule_coordinator: bool,
255 owned_shards: Option<Vec<usize>>,
256 workflow_sources: Vec<WorkflowPackageSource>,
257 host_nifs: Vec<NifEntry>,
258 recovery: Option<Arc<dyn ActiveWorkflowRecoverySeam>>,
259 delegated: DelegatedSeams,
260 signal_router_factory: Option<SignalRouterFactory>,
261 activity_dispatcher: Option<Arc<dyn ActivityDispatcher>>,
262 activity_serving: ActivityServing,
263 active_registry: Option<Arc<Registry>>,
264 visibility_reconciliation_interval: Option<Duration>,
265 search_attribute_schema: SearchAttributeSchema,
266 event_streaming_capacity: Option<NonZeroUsize>,
267 query_timeout: Option<Duration>,
268 seam_overrides: SeamOverrides,
269}
270
271impl Default for EngineBuilder {
272 fn default() -> Self {
273 Self::new()
274 }
275}
276
277impl EngineBuilder {
278 #[must_use]
281 pub fn new() -> Self {
282 Self {
283 store: None,
284 visibility_store: None,
285 scheduler_threads: None,
286 signal_delivery: SignalDeliveryConfig::default(),
287 outbox_enabled: false,
288 bootstrap_schedule_coordinator: true,
292 owned_shards: None,
296 workflow_sources: Vec::new(),
297 host_nifs: Vec::new(),
298 recovery: None,
299 delegated: DelegatedSeams::default(),
300 signal_router_factory: None,
301 activity_dispatcher: None,
302 activity_serving: ActivityServing::QueueRouted,
303 active_registry: None,
304 visibility_reconciliation_interval: None,
305 search_attribute_schema: SearchAttributeSchema::new(),
306 event_streaming_capacity: None,
307 query_timeout: None,
308 seam_overrides: SeamOverrides::default(),
309 }
310 }
311
312 #[must_use]
320 pub const fn query_timeout(mut self, timeout: Duration) -> Self {
321 self.query_timeout = Some(timeout);
322 self
323 }
324
325 #[must_use]
327 pub const fn configured_query_timeout(&self) -> Option<Duration> {
328 self.query_timeout
329 }
330
331 #[must_use]
341 pub const fn event_streaming(mut self, capacity: NonZeroUsize) -> Self {
342 self.event_streaming_capacity = Some(capacity);
343 self
344 }
345
346 #[must_use]
352 pub fn search_attribute_schema(mut self, schema: SearchAttributeSchema) -> Self {
353 self.search_attribute_schema = schema;
354 self
355 }
356
357 #[must_use]
359 pub fn store<S>(mut self, store: S) -> Self
360 where
361 S: EventStore,
362 {
363 self.store = Some(Arc::new(store));
364 self
365 }
366
367 #[must_use]
369 pub fn store_arc(mut self, store: Arc<dyn EventStore>) -> Self {
370 self.store = Some(store);
371 self
372 }
373
374 #[must_use]
376 pub fn visibility_store<S>(mut self, visibility_store: S) -> Self
377 where
378 S: VisibilityStore,
379 {
380 self.visibility_store = Some(Arc::new(visibility_store));
381 self
382 }
383
384 #[must_use]
386 pub fn visibility_store_arc(mut self, visibility_store: Arc<dyn VisibilityStore>) -> Self {
387 self.visibility_store = Some(visibility_store);
388 self
389 }
390
391 #[must_use]
397 pub fn in_memory_visibility(mut self) -> Self {
398 self.visibility_store = Some(Arc::new(InMemoryStore::default()));
399 self
400 }
401
402 #[must_use]
406 pub const fn scheduler_threads(mut self, threads: usize) -> Self {
407 self.scheduler_threads = Some(threads);
408 self
409 }
410
411 #[must_use]
415 pub const fn visibility_reconciliation_interval(mut self, interval: Duration) -> Self {
416 self.visibility_reconciliation_interval = Some(interval);
417 self
418 }
419
420 #[must_use]
422 pub const fn signal_delivery(mut self, signal_delivery: SignalDeliveryConfig) -> Self {
423 self.signal_delivery = signal_delivery;
424 self
425 }
426
427 #[must_use]
429 pub fn outbox_enabled(mut self, enabled: bool) -> Self {
430 self.outbox_enabled = enabled;
431 self
432 }
433
434 #[must_use]
442 pub const fn bootstrap_schedule_coordinator(mut self, enabled: bool) -> Self {
443 self.bootstrap_schedule_coordinator = enabled;
444 self
445 }
446
447 #[must_use]
461 pub fn owned_shards(mut self, shards: impl IntoIterator<Item = usize>) -> Self {
462 self.owned_shards = Some(shards.into_iter().collect());
463 self
464 }
465
466 #[must_use]
468 pub fn configured_owned_shards(&self) -> Option<&[usize]> {
469 self.owned_shards.as_deref()
470 }
471
472 #[must_use]
474 pub fn load_workflows(mut self, source: impl Into<WorkflowPackageSource>) -> Self {
475 self.workflow_sources.push(source.into());
476 self
477 }
478
479 #[must_use]
481 pub fn load_workflow_sources<I, S>(mut self, sources: I) -> Self
482 where
483 I: IntoIterator<Item = S>,
484 S: Into<WorkflowPackageSource>,
485 {
486 self.workflow_sources
487 .extend(sources.into_iter().map(Into::into));
488 self
489 }
490
491 #[must_use]
493 pub fn register_nifs(mut self, entries: impl IntoIterator<Item = NifEntry>) -> Self {
494 self.host_nifs.extend(entries);
495 self
496 }
497
498 #[must_use]
500 pub fn recovery_seam(mut self, recovery: Arc<dyn ActiveWorkflowRecoverySeam>) -> Self {
501 self.recovery = Some(recovery);
502 self
503 }
504
505 #[must_use]
507 pub fn production_recovery_seam(mut self) -> Self {
508 self.recovery = None;
509 self
510 }
511
512 #[must_use]
514 pub fn signal_router(mut self, signal_router: Arc<dyn SignalRouter>) -> Self {
515 self.signal_router_factory = None;
516 self.delegated = DelegatedSeams::new(
517 signal_router,
518 self.delegated.query_service_arc(),
519 self.delegated.event_publisher_arc(),
520 );
521 self
522 }
523
524 #[must_use]
526 pub fn signal_router_factory<F>(mut self, factory: F) -> Self
527 where
528 F: Fn(Arc<RuntimeHandle>, Arc<SignalResumeHandoff>) -> Arc<dyn SignalRouter>
529 + Send
530 + Sync
531 + 'static,
532 {
533 self.signal_router_factory = Some(Arc::new(factory));
534 self
535 }
536
537 #[must_use]
542 pub fn query_service(mut self, query_service: Arc<dyn QueryService>) -> Self {
543 self.seam_overrides.query_service = true;
544 self.delegated = DelegatedSeams::new(
545 self.delegated.signal_router_arc(),
546 query_service,
547 self.delegated.event_publisher_arc(),
548 );
549 self
550 }
551
552 #[must_use]
557 pub fn event_publisher(mut self, event_publisher: Arc<dyn EventPublisher>) -> Self {
558 self.seam_overrides.event_publisher = true;
559 self.delegated = DelegatedSeams::new(
560 self.delegated.signal_router_arc(),
561 self.delegated.query_service_arc(),
562 event_publisher,
563 );
564 self
565 }
566
567 #[must_use]
573 pub fn activity_dispatcher(mut self, dispatcher: Arc<dyn ActivityDispatcher>) -> Self {
574 self.activity_dispatcher = Some(dispatcher);
575 self
576 }
577
578 #[must_use]
590 pub const fn in_process_activity_serving(mut self) -> Self {
591 self.activity_serving = ActivityServing::InProcess;
592 self
593 }
594
595 #[must_use]
600 pub fn active_registry(mut self, registry: Arc<Registry>) -> Self {
601 self.active_registry = Some(registry);
602 self
603 }
604
605 #[must_use]
607 pub const fn scheduler_thread_count(&self) -> Option<usize> {
608 self.scheduler_threads
609 }
610
611 #[must_use]
613 pub const fn configured_visibility_reconciliation_interval(&self) -> Option<Duration> {
614 self.visibility_reconciliation_interval
615 }
616
617 fn runtime_config(&self) -> RuntimeConfig {
620 RuntimeConfig::new(self.scheduler_threads)
621 .with_signal_delivery(self.signal_delivery)
622 .with_outbox_enabled(self.outbox_enabled)
623 }
624
625 fn start_runtime_with_nifs(
627 runtime_config: RuntimeConfig,
628 host_nifs: Vec<NifEntry>,
629 ) -> Result<Arc<RuntimeHandle>, EngineError> {
630 let runtime = Arc::new(RuntimeHandle::new(runtime_config)?);
631 let mut nifs = NifRegistration::new();
632 nifs.add_engine_nifs().add_host_nifs(host_nifs);
633 runtime.install_nifs(nifs)?;
634 Ok(runtime)
635 }
636
637 pub async fn build(self) -> Result<Engine, EngineError> {
645 let runtime_config = self.runtime_config();
646 let (store, streaming_publisher) = wrap_event_streaming(
647 self.store.ok_or(EngineError::MissingStore)?,
648 self.event_streaming_capacity,
649 self.seam_overrides.event_publisher,
650 )?;
651 let visibility_store = self
652 .visibility_store
653 .ok_or(EngineError::MissingVisibilityStore)?;
654
655 apply_owned_shards(store.as_ref(), self.owned_shards.as_deref());
656 acquire_owned_shards(store.as_ref(), self.owned_shards.as_deref())?;
661
662 let runtime = Self::start_runtime_with_nifs(runtime_config, self.host_nifs)?;
663
664 let catalog = assemble_startup_catalog(
668 runtime.as_ref(),
669 store.as_ref(),
670 self.workflow_sources,
671 self.activity_serving,
672 )
673 .await?;
674
675 let registry = self
676 .active_registry
677 .unwrap_or_else(|| Arc::new(Registry::default()));
678 let nif_state = Arc::clone(runtime.nif_state());
679 let query_mailbox_engine = install_engine_nif_seams(
680 &nif_state,
681 ®istry,
682 &store,
683 &runtime,
684 self.activity_dispatcher,
685 self.query_timeout,
686 );
687 let supervision = Arc::new(SupervisionTree::new());
688 let search_attribute_schema = Arc::new(self.search_attribute_schema);
689 let signal_handoff = Arc::new(SignalResumeHandoff::new());
690
691 let delegated = assemble_delegated_seams(SeamAssembly {
692 configured: self.delegated,
693 signal_router_factory: self.signal_router_factory,
694 runtime: Arc::clone(&runtime),
695 signal_handoff: Arc::clone(&signal_handoff),
696 streaming_publisher,
697 query_mailbox_engine,
698 query_timeout: self.query_timeout,
699 query_service_overridden: self.seam_overrides.query_service,
700 });
701
702 install_signal_nif_bridge(
703 &nif_state,
704 Arc::new(crate::runtime::SignalNifBridge::new(
705 Arc::clone(®istry),
706 Arc::clone(&runtime),
707 tokio::runtime::Handle::current(),
708 delegated.signal_router_arc(),
709 )),
710 );
711 install_configured_child_nif_bridge(&ChildBridgeAssembly {
712 nif_state: &nif_state,
713 store: &store,
714 visibility_store: &visibility_store,
715 runtime: &runtime,
716 catalog: &catalog,
717 registry: ®istry,
718 supervision: &supervision,
719 signal_handoff: &signal_handoff,
720 search_attribute_schema: &search_attribute_schema,
721 watch_backoff: self.signal_delivery,
722 })?;
723
724 recover_active_workflows_on_startup(StartupRecoveryContext {
731 store: Arc::clone(&store),
732 visibility_store: Arc::clone(&visibility_store),
733 runtime: Arc::clone(&runtime),
734 catalog: Arc::clone(&catalog),
735 registry: Arc::clone(®istry),
736 supervision: Arc::clone(&supervision),
737 recovery: self.recovery,
738 search_attribute_schema: Arc::clone(&search_attribute_schema),
739 bootstrap_schedule_coordinator: self.bootstrap_schedule_coordinator,
740 })
741 .await?;
742 recover_timers_on_startup(&nif_state, Arc::clone(&store)).await?;
743
744 let visibility_reconciliation_task = maybe_spawn_visibility_reconciliation(
745 self.visibility_reconciliation_interval,
746 &store,
747 &visibility_store,
748 );
749
750 let engine = Engine::new(EngineComponents {
751 store,
752 visibility_store,
753 runtime,
754 catalog,
755 registry,
756 supervision,
757 delegated,
758 signal_handoff,
759 search_attribute_schema,
760 visibility_reconciliation_task,
761 });
762 engine.catchup_schedule_coordinator().await?;
763 engine.recover_schedules_on_startup(Utc::now()).await?;
764 Ok(engine)
765 }
766}
767
768struct ChildBridgeAssembly<'a> {
770 nif_state: &'a Arc<crate::runtime::EngineNifState>,
771 store: &'a Arc<dyn EventStore>,
772 visibility_store: &'a Arc<dyn VisibilityStore>,
773 runtime: &'a Arc<RuntimeHandle>,
774 catalog: &'a Arc<WorkflowCatalog>,
775 registry: &'a Arc<Registry>,
776 supervision: &'a Arc<SupervisionTree>,
777 signal_handoff: &'a Arc<SignalResumeHandoff>,
778 search_attribute_schema: &'a Arc<aion_core::SearchAttributeSchema>,
779 watch_backoff: SignalDeliveryConfig,
782}
783
784fn register_workflow_deadline_handler(
794 nif_state: &crate::runtime::EngineNifState,
795 runtime: &Arc<RuntimeHandle>,
796 store: &Arc<dyn EventStore>,
797 visibility_store: &Arc<dyn VisibilityStore>,
798 registry: &Arc<Registry>,
799) -> Result<(), EngineError> {
800 crate::runtime::nif_timer_bridge::register_deadline_handler(
801 nif_state,
802 Arc::new(crate::lifecycle::deadline::WorkflowDeadlineHandler::new(
803 Arc::downgrade(runtime),
804 Arc::clone(store),
805 Arc::clone(visibility_store),
806 Arc::clone(registry),
807 )),
808 )
809 .map_err(|error| EngineError::Runtime {
810 reason: format!("failed to register workflow deadline handler: {error}"),
811 })
812}
813
814fn install_configured_child_nif_bridge(
815 assembly: &ChildBridgeAssembly<'_>,
816) -> Result<(), EngineError> {
817 install_child_nif_bridge(
818 assembly.nif_state,
819 Arc::new(ChildNifBridge::new(ChildNifBridgeParts {
820 store: Arc::clone(assembly.store),
821 visibility_store: Arc::clone(assembly.visibility_store),
822 runtime: Arc::clone(assembly.runtime),
823 catalog: Arc::clone(assembly.catalog),
824 registry: Arc::clone(assembly.registry),
825 supervision: Arc::clone(assembly.supervision),
826 signal_handoff: Arc::clone(assembly.signal_handoff),
827 search_attribute_schema: Arc::clone(assembly.search_attribute_schema),
828 tokio_handle: tokio::runtime::Handle::current(),
829 watch_backoff: assembly.watch_backoff,
830 })?),
831 );
832 assembly
835 .nif_state
836 .set_workflow_catalog(Arc::clone(assembly.catalog));
837 register_workflow_deadline_handler(
842 assembly.nif_state,
843 assembly.runtime,
844 assembly.store,
845 assembly.visibility_store,
846 assembly.registry,
847 )
848}
849
850pub(super) fn package_from_source(source: WorkflowPackageSource) -> Result<Package, EngineError> {
851 match source {
852 WorkflowPackageSource::Path(path) => {
853 Package::load_from_path(&path, ExtractionLimits::unbounded()).map_err(|error| {
856 EngineError::Load {
857 reason: format!(
858 "failed to load workflow package `{}`: {error}",
859 path.display()
860 ),
861 }
862 })
863 }
864 WorkflowPackageSource::Package(package) => Ok(*package),
865 }
866}
867
868#[cfg(test)]
869mod tests {
870 use super::super::gleam_test_support;
886
887 use std::{num::NonZeroUsize, path::PathBuf, process::Command, sync::Arc, time::Duration};
888
889 use aion_core::{Event, EventEnvelope, Payload, WorkflowId, WorkflowStatus};
890 use aion_package::{
891 BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, ExtractionLimits, Manifest,
892 ManifestVersion, Package, PackageBuilder,
893 };
894 use aion_store::visibility::{ListWorkflowsFilter, VisibilityStore};
895 use aion_store::{InMemoryStore, ReadableEventStore, WritableEventStore, WriteToken};
896 use chrono::Utc;
897 use futures::StreamExt;
898 use serde_json::json;
899
900 use crate::engine::api_schedule::{
901 schedule_coordinator_run_id, schedule_coordinator_workflow_id,
902 schedule_coordinator_workflow_type,
903 };
904 use crate::runtime::{Determinism, Mfa, NifEntry};
905
906 use super::EngineBuilder;
907 use crate::EngineError;
908
909 fn payload() -> Result<Payload, aion_core::PayloadError> {
910 Payload::from_json(&json!({ "input": true }))
911 }
912
913 fn started(
914 workflow_id: &WorkflowId,
915 workflow_type: &str,
916 ) -> Result<Event, aion_core::PayloadError> {
917 Ok(Event::WorkflowStarted {
918 envelope: EventEnvelope {
919 seq: 1,
920 recorded_at: Utc::now(),
921 workflow_id: workflow_id.clone(),
922 },
923 workflow_type: workflow_type.to_owned(),
924 input: payload()?,
925 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
926 parent_run_id: None,
927 package_version: aion_core::PackageVersion::new("a".repeat(64)),
928 })
929 }
930
931 fn completed(workflow_id: &WorkflowId) -> Result<Event, aion_core::PayloadError> {
932 Ok(Event::WorkflowCompleted {
933 envelope: EventEnvelope {
934 seq: 2,
935 recorded_at: Utc::now(),
936 workflow_id: workflow_id.clone(),
937 },
938 result: payload()?,
939 })
940 }
941
942 fn package_manifest() -> Manifest {
943 Manifest {
944 entry_module: "counter".to_owned(),
945 entry_function: "version".to_owned(),
946 input_schema: json!({ "type": "object" }),
947 output_schema: json!({ "type": "integer" }),
948 timeout: Some(Duration::from_secs(30)),
949 activities: vec![DeclaredActivity {
950 activity_type: "activity/test".to_owned(),
951 }],
952 version: ManifestVersion::new("test"),
953 format_version: CURRENT_FORMAT_VERSION,
954 additional_workflows: Vec::new(),
955 }
956 }
957
958 fn compile_counter_beam() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
959 let temp_dir =
960 std::env::temp_dir().join(format!("aion-engine-builder-{}", uuid::Uuid::new_v4()));
961 std::fs::create_dir(&temp_dir)?;
962 let source_path = temp_dir.join("counter.erl");
963 let beam_path = temp_dir.join("counter.beam");
964 std::fs::write(
965 &source_path,
966 "-module(counter).\n-export([version/0]).\nversion() -> 1.\n",
967 )?;
968 let status = Command::new("erlc")
969 .arg("-o")
970 .arg(&temp_dir)
971 .arg(&source_path)
972 .status()?;
973 if !status.success() {
974 let cleanup_result = std::fs::remove_dir_all(&temp_dir);
975 drop(cleanup_result);
976 return Err(format!("erlc failed with status {status}").into());
977 }
978 let bytes = std::fs::read(beam_path)?;
979 std::fs::remove_dir_all(temp_dir)?;
980 Ok(bytes)
981 }
982
983 fn fixture_package() -> Result<Package, Box<dyn std::error::Error>> {
984 let beams = BeamSet::new(vec![BeamModule::new("counter", compile_counter_beam()?)])?;
985 let archive = PackageBuilder::new(package_manifest(), beams).write_to_bytes()?;
986 Ok(Package::load_from_bytes(
987 archive,
988 ExtractionLimits::unbounded(),
989 )?)
990 }
991
992 fn write_fixture_package(package: &Package) -> Result<PathBuf, Box<dyn std::error::Error>> {
993 let path =
994 std::env::temp_dir().join(format!("aion-engine-builder-{}.aion", uuid::Uuid::new_v4()));
995 PackageBuilder::new(package.manifest().clone(), package.beams().clone())
996 .write_to_path(&path)?;
997 Ok(path)
998 }
999
1000 #[tokio::test]
1001 async fn build_without_store_returns_missing_store() {
1002 let error = EngineBuilder::new().build().await.err();
1003
1004 assert!(matches!(error, Some(EngineError::MissingStore)));
1005 }
1006
1007 #[tokio::test]
1008 async fn build_without_visibility_store_returns_missing_visibility_store() {
1009 let error = EngineBuilder::new()
1010 .store(InMemoryStore::default())
1011 .build()
1012 .await
1013 .err();
1014
1015 assert!(matches!(error, Some(EngineError::MissingVisibilityStore)));
1016 }
1017
1018 #[tokio::test]
1019 async fn in_memory_visibility_allows_build_without_visibility_store() -> Result<(), EngineError>
1020 {
1021 let engine = EngineBuilder::new()
1022 .store(InMemoryStore::default())
1023 .in_memory_visibility()
1024 .build()
1025 .await?;
1026
1027 engine.shutdown()?;
1028 Ok(())
1029 }
1030
1031 fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
1032 NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
1033 }
1034
1035 #[tokio::test]
1036 async fn event_streaming_delivers_recorder_appends_through_engine_subscribe()
1037 -> Result<(), Box<dyn std::error::Error>> {
1038 let engine = EngineBuilder::new()
1039 .store(InMemoryStore::default())
1040 .in_memory_visibility()
1041 .event_streaming(capacity(8)?)
1042 .build()
1043 .await?;
1044 let workflow_id = WorkflowId::new_v4();
1045 let mut subscription = engine.subscribe(crate::EventFilter {
1046 workflow_id: Some(workflow_id.clone()),
1047 run: None,
1048 family: None,
1049 });
1050
1051 let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1054 recorder
1055 .record_workflow_started(
1056 Utc::now(),
1057 crate::durability::WorkflowStartRecord {
1058 workflow_type: "checkout".to_owned(),
1059 input: payload()?,
1060 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(7)),
1061 parent_run_id: None,
1062 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1063 },
1064 )
1065 .await?;
1066
1067 let item = tokio::time::timeout(Duration::from_secs(2), subscription.next())
1068 .await?
1069 .ok_or("subscription ended without delivering the appended event")?;
1070 let event = item?;
1071 assert_eq!(event.workflow_id(), &workflow_id);
1072 assert_eq!(event.seq(), 1);
1073 assert!(matches!(event, Event::WorkflowStarted { .. }));
1074 engine.shutdown()?;
1075 Ok(())
1076 }
1077
1078 #[tokio::test]
1079 async fn without_event_streaming_subscriptions_stay_on_deferred_empty_stream()
1080 -> Result<(), Box<dyn std::error::Error>> {
1081 let engine = EngineBuilder::new()
1082 .store(InMemoryStore::default())
1083 .in_memory_visibility()
1084 .build()
1085 .await?;
1086
1087 let mut subscription = engine.subscribe(crate::EventFilter::default());
1088 let item = tokio::time::timeout(Duration::from_secs(2), subscription.next()).await?;
1089
1090 assert!(item.is_none(), "deferred publisher streams must be empty");
1091 engine.shutdown()?;
1092 Ok(())
1093 }
1094
1095 #[tokio::test]
1096 async fn event_streaming_conflicts_with_explicit_event_publisher()
1097 -> Result<(), Box<dyn std::error::Error>> {
1098 let error = EngineBuilder::new()
1099 .store(InMemoryStore::default())
1100 .in_memory_visibility()
1101 .event_publisher(Arc::new(crate::DeferredEventPublisher))
1102 .event_streaming(capacity(8)?)
1103 .build()
1104 .await
1105 .err();
1106
1107 assert!(matches!(
1108 error,
1109 Some(EngineError::ConflictingEventPublisher)
1110 ));
1111 Ok(())
1112 }
1113
1114 #[test]
1115 fn query_timeout_is_only_set_by_caller() {
1116 assert_eq!(EngineBuilder::new().configured_query_timeout(), None);
1117 assert_eq!(
1118 EngineBuilder::new()
1119 .query_timeout(Duration::from_secs(3))
1120 .configured_query_timeout(),
1121 Some(Duration::from_secs(3))
1122 );
1123 }
1124
1125 async fn insert_running_workflow(
1126 engine: &crate::Engine,
1127 ) -> Result<(WorkflowId, aion_core::RunId), Box<dyn std::error::Error>> {
1128 let workflow_id = WorkflowId::new_v4();
1129 let run_id = aion_core::RunId::new_v4();
1130 let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1131 recorder
1132 .record_workflow_started(
1133 Utc::now(),
1134 crate::durability::WorkflowStartRecord {
1135 workflow_type: "checkout".to_owned(),
1136 input: payload()?,
1137 run_id: run_id.clone(),
1138 parent_run_id: None,
1139 package_version: aion_core::PackageVersion::new("a".repeat(64)),
1140 },
1141 )
1142 .await?;
1143 let handle = crate::registry::WorkflowHandle::new(crate::registry::WorkflowHandleParts {
1144 workflow_id: workflow_id.clone(),
1145 run_id: run_id.clone(),
1146 pid: engine.runtime().spawn_test_process_with_trap_exit(true)?,
1147 workflow_type: "checkout".to_owned(),
1148 namespace: String::from("default"),
1149 loaded_version: aion_package::ContentHash::from_bytes([2; 32]),
1150 cached_status: WorkflowStatus::Running,
1151 residency: crate::registry::HandleResidency::Resident,
1152 recorder,
1153 completion: crate::registry::CompletionNotifier::new(),
1154 });
1155 engine
1156 .registry()
1157 .insert((workflow_id.clone(), run_id.clone()), handle)?;
1158 Ok((workflow_id, run_id))
1159 }
1160
1161 #[tokio::test]
1162 async fn query_timeout_installs_the_concrete_query_seam()
1163 -> Result<(), Box<dyn std::error::Error>> {
1164 let engine = EngineBuilder::new()
1165 .store(InMemoryStore::default())
1166 .in_memory_visibility()
1167 .query_timeout(Duration::from_millis(250))
1168 .build()
1169 .await?;
1170 let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1171
1172 let result = engine.query(&workflow_id, &run_id, "state").await;
1176
1177 assert!(matches!(
1178 result,
1179 Err(crate::EngineError::Query(crate::QueryError::UnknownQuery(name))) if name == "state"
1180 ));
1181 engine.shutdown()?;
1182 Ok(())
1183 }
1184
1185 #[tokio::test]
1186 async fn without_query_timeout_the_query_seam_stays_deferred()
1187 -> Result<(), Box<dyn std::error::Error>> {
1188 let engine = EngineBuilder::new()
1189 .store(InMemoryStore::default())
1190 .in_memory_visibility()
1191 .build()
1192 .await?;
1193 let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1194
1195 let result = engine.query(&workflow_id, &run_id, "state").await;
1196
1197 assert!(matches!(
1198 result,
1199 Err(crate::EngineError::Runtime { reason }) if reason.contains("not configured")
1200 ));
1201 engine.shutdown()?;
1202 Ok(())
1203 }
1204
1205 #[test]
1206 fn owned_shards_are_only_set_by_caller() {
1207 assert_eq!(EngineBuilder::new().configured_owned_shards(), None);
1211 assert_eq!(
1212 EngineBuilder::new()
1213 .owned_shards([2, 0, 2, 1])
1214 .configured_owned_shards(),
1215 Some([2, 0, 2, 1].as_slice())
1216 );
1217 }
1218
1219 #[test]
1220 fn scheduler_threads_are_only_set_by_caller() {
1221 assert_eq!(EngineBuilder::new().scheduler_thread_count(), None);
1222 assert_eq!(
1223 EngineBuilder::new()
1224 .scheduler_threads(4)
1225 .scheduler_thread_count(),
1226 Some(4)
1227 );
1228 }
1229
1230 #[test]
1231 fn visibility_reconciliation_interval_is_only_set_by_caller() {
1232 let interval = Duration::from_millis(250);
1233
1234 assert_eq!(
1235 EngineBuilder::new().configured_visibility_reconciliation_interval(),
1236 None
1237 );
1238 assert_eq!(
1239 EngineBuilder::new()
1240 .visibility_reconciliation_interval(interval)
1241 .configured_visibility_reconciliation_interval(),
1242 Some(interval)
1243 );
1244 }
1245
1246 #[tokio::test]
1247 async fn duplicate_host_nif_mfa_returns_typed_error() {
1248 let mfa = Mfa::new("host", "zero", 0);
1249 let error = EngineBuilder::new()
1250 .store(InMemoryStore::default())
1251 .in_memory_visibility()
1252 .register_nifs([
1253 NifEntry::new(
1254 mfa.clone(),
1255 crate::runtime::nif::test_native_zero,
1256 Determinism::Pure,
1257 ),
1258 NifEntry::dirty(
1259 mfa,
1260 crate::runtime::nif::test_native_zero,
1261 Determinism::Pure,
1262 ),
1263 ])
1264 .build()
1265 .await
1266 .err();
1267
1268 assert!(matches!(
1269 error,
1270 Some(EngineError::NifRegistration { reason }) if reason.contains("host:zero/0")
1271 ));
1272 }
1273
1274 #[tokio::test]
1275 async fn empty_store_builds_coordinator_history_without_registry_or_supervision()
1276 -> Result<(), EngineError> {
1277 let store = Arc::new(InMemoryStore::default());
1278 let engine = EngineBuilder::new()
1279 .store_arc(store.clone())
1280 .in_memory_visibility()
1281 .build()
1282 .await?;
1283
1284 assert!(engine.registry().list()?.is_empty());
1285 assert_eq!(engine.supervision().type_supervisor_count()?, 1);
1286 assert_eq!(engine.workflow_catalog().workflows()?.len(), 0);
1287
1288 let coordinator_id = schedule_coordinator_workflow_id();
1289 let active = store.list_active().await?;
1290 assert_eq!(active, vec![coordinator_id.clone()]);
1291 let history = store.read_history(&coordinator_id).await?;
1292 let [started] = history.as_slice() else {
1293 return Err(EngineError::Load {
1294 reason: format!(
1295 "expected exactly one coordinator event, found {}",
1296 history.len()
1297 ),
1298 });
1299 };
1300 match started {
1301 Event::WorkflowStarted {
1302 workflow_type,
1303 input,
1304 run_id,
1305 parent_run_id,
1306 ..
1307 } => {
1308 assert_eq!(workflow_type, schedule_coordinator_workflow_type());
1309 assert_eq!(
1310 input,
1311 &Payload::from_json(&json!({})).map_err(|error| {
1312 EngineError::Load {
1313 reason: format!("failed to build expected payload: {error}"),
1314 }
1315 })?
1316 );
1317 assert_eq!(run_id, &schedule_coordinator_run_id());
1318 assert!(parent_run_id.is_none());
1319 }
1320 other => {
1321 return Err(EngineError::Load {
1322 reason: format!("expected coordinator WorkflowStarted, found {other:?}"),
1323 });
1324 }
1325 }
1326
1327 engine.shutdown()?;
1328 let rebuilt = EngineBuilder::new()
1329 .store_arc(store.clone())
1330 .in_memory_visibility()
1331 .build()
1332 .await?;
1333 let rebuilt_history = store.read_history(&coordinator_id).await?;
1334 assert_eq!(rebuilt_history.len(), 1);
1335 rebuilt.shutdown()?;
1336
1337 Ok(())
1338 }
1339
1340 #[tokio::test]
1341 async fn build_loads_already_loaded_package() -> Result<(), Box<dyn std::error::Error>> {
1342 if gleam_test_support::skip_if_unavailable() {
1343 return Ok(());
1344 }
1345 let package = fixture_package()?;
1346 let version = package.content_hash().clone();
1347 let deployed_entry_module = package.deployed_entry_module();
1348
1349 let engine = EngineBuilder::new()
1350 .store(InMemoryStore::default())
1351 .in_memory_visibility()
1352 .load_workflows(package)
1353 .build()
1354 .await?;
1355
1356 let loaded = engine
1357 .workflow_catalog()
1358 .get("counter", &version)?
1359 .ok_or("loaded package record missing")?;
1360 assert_eq!(loaded.deployed_entry_module(), deployed_entry_module);
1361 assert!(
1362 engine
1363 .runtime()
1364 .has_registered_module(&deployed_entry_module)
1365 );
1366 Ok(())
1367 }
1368
1369 #[tokio::test]
1370 async fn startup_reconciliation_backfills_completed_visibility()
1371 -> Result<(), Box<dyn std::error::Error>> {
1372 let store = Arc::new(InMemoryStore::default());
1373 let completed_id = WorkflowId::new_v4();
1374
1375 store
1376 .append(
1377 WriteToken::recorder(),
1378 &completed_id,
1379 &[
1380 started(&completed_id, "billing")?,
1381 completed(&completed_id)?,
1382 ],
1383 0,
1384 )
1385 .await?;
1386
1387 let engine = EngineBuilder::new()
1388 .store_arc(store.clone())
1389 .visibility_store_arc(store.clone())
1390 .build()
1391 .await?;
1392
1393 let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1394 let completed_summary = summaries
1395 .iter()
1396 .find(|summary| summary.workflow_id == completed_id)
1397 .ok_or("completed workflow missing from visibility")?;
1398
1399 assert_eq!(completed_summary.status, WorkflowStatus::Completed);
1400 assert!(completed_summary.close_time.is_some());
1401 engine.shutdown()?;
1402 Ok(())
1403 }
1404
1405 #[tokio::test]
1406 async fn periodic_visibility_reconciliation_repairs_gap_after_startup()
1407 -> Result<(), Box<dyn std::error::Error>> {
1408 let store = Arc::new(InMemoryStore::default());
1409 let engine = EngineBuilder::new()
1410 .store_arc(store.clone())
1411 .visibility_store_arc(store.clone())
1412 .visibility_reconciliation_interval(Duration::from_millis(25))
1413 .build()
1414 .await?;
1415 let workflow_id = WorkflowId::new_v4();
1416
1417 store
1418 .append(
1419 WriteToken::recorder(),
1420 &workflow_id,
1421 &[started(&workflow_id, "checkout")?],
1422 0,
1423 )
1424 .await?;
1425
1426 tokio::time::timeout(Duration::from_secs(2), async {
1427 loop {
1428 let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1429 if summaries.iter().any(|summary| {
1430 summary.workflow_id == workflow_id && summary.status == WorkflowStatus::Running
1431 }) {
1432 return Ok::<(), aion_store::StoreError>(());
1433 }
1434 tokio::time::sleep(Duration::from_millis(10)).await;
1435 }
1436 })
1437 .await??;
1438
1439 engine.shutdown()?;
1440 Ok(())
1441 }
1442
1443 #[tokio::test]
1444 async fn build_loads_package_from_path() -> Result<(), Box<dyn std::error::Error>> {
1445 if gleam_test_support::skip_if_unavailable() {
1446 return Ok(());
1447 }
1448 let package = fixture_package()?;
1449 let version = package.content_hash().clone();
1450 let path = write_fixture_package(&package)?;
1451
1452 let engine = EngineBuilder::new()
1453 .store(InMemoryStore::default())
1454 .in_memory_visibility()
1455 .load_workflows(path.as_path())
1456 .build()
1457 .await?;
1458 std::fs::remove_file(path)?;
1459
1460 assert!(
1461 engine
1462 .workflow_catalog()
1463 .get("counter", &version)?
1464 .is_some()
1465 );
1466 Ok(())
1467 }
1468}