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
155impl From<PathBuf> for WorkflowPackageSource {
156 fn from(path: PathBuf) -> Self {
157 Self::Path(path)
158 }
159}
160
161impl From<&std::path::Path> for WorkflowPackageSource {
162 fn from(path: &std::path::Path) -> Self {
163 Self::Path(path.to_path_buf())
164 }
165}
166
167impl From<&str> for WorkflowPackageSource {
168 fn from(path: &str) -> Self {
169 Self::Path(PathBuf::from(path))
170 }
171}
172
173impl From<String> for WorkflowPackageSource {
174 fn from(path: String) -> Self {
175 Self::Path(PathBuf::from(path))
176 }
177}
178
179pub struct EngineBuilder {
181 store: Option<Arc<dyn EventStore>>,
182 visibility_store: Option<Arc<dyn VisibilityStore>>,
183 scheduler_threads: Option<usize>,
184 signal_delivery: SignalDeliveryConfig,
185 workflow_sources: Vec<WorkflowPackageSource>,
186 host_nifs: Vec<NifEntry>,
187 recovery: Option<Arc<dyn ActiveWorkflowRecoverySeam>>,
188 delegated: DelegatedSeams,
189 signal_router_factory: Option<SignalRouterFactory>,
190 activity_dispatcher: Option<Arc<dyn ActivityDispatcher>>,
191 active_registry: Option<Arc<Registry>>,
192 visibility_reconciliation_interval: Option<Duration>,
193 search_attribute_schema: SearchAttributeSchema,
194 event_streaming_capacity: Option<NonZeroUsize>,
195 event_publisher_overridden: bool,
196 query_timeout: Option<Duration>,
197 query_service_overridden: bool,
198}
199
200impl Default for EngineBuilder {
201 fn default() -> Self {
202 Self::new()
203 }
204}
205
206impl EngineBuilder {
207 #[must_use]
210 pub fn new() -> Self {
211 Self {
212 store: None,
213 visibility_store: None,
214 scheduler_threads: None,
215 signal_delivery: SignalDeliveryConfig::default(),
216 workflow_sources: Vec::new(),
217 host_nifs: Vec::new(),
218 recovery: None,
219 delegated: DelegatedSeams::default(),
220 signal_router_factory: None,
221 activity_dispatcher: None,
222 active_registry: None,
223 visibility_reconciliation_interval: None,
224 search_attribute_schema: SearchAttributeSchema::new(),
225 event_streaming_capacity: None,
226 event_publisher_overridden: false,
227 query_timeout: None,
228 query_service_overridden: false,
229 }
230 }
231
232 #[must_use]
240 pub const fn query_timeout(mut self, timeout: Duration) -> Self {
241 self.query_timeout = Some(timeout);
242 self
243 }
244
245 #[must_use]
247 pub const fn configured_query_timeout(&self) -> Option<Duration> {
248 self.query_timeout
249 }
250
251 #[must_use]
261 pub const fn event_streaming(mut self, capacity: NonZeroUsize) -> Self {
262 self.event_streaming_capacity = Some(capacity);
263 self
264 }
265
266 #[must_use]
272 pub fn search_attribute_schema(mut self, schema: SearchAttributeSchema) -> Self {
273 self.search_attribute_schema = schema;
274 self
275 }
276
277 #[must_use]
279 pub fn store<S>(mut self, store: S) -> Self
280 where
281 S: EventStore,
282 {
283 self.store = Some(Arc::new(store));
284 self
285 }
286
287 #[must_use]
289 pub fn store_arc(mut self, store: Arc<dyn EventStore>) -> Self {
290 self.store = Some(store);
291 self
292 }
293
294 #[must_use]
296 pub fn visibility_store<S>(mut self, visibility_store: S) -> Self
297 where
298 S: VisibilityStore,
299 {
300 self.visibility_store = Some(Arc::new(visibility_store));
301 self
302 }
303
304 #[must_use]
306 pub fn visibility_store_arc(mut self, visibility_store: Arc<dyn VisibilityStore>) -> Self {
307 self.visibility_store = Some(visibility_store);
308 self
309 }
310
311 #[must_use]
317 pub fn in_memory_visibility(mut self) -> Self {
318 self.visibility_store = Some(Arc::new(InMemoryStore::default()));
319 self
320 }
321
322 #[must_use]
326 pub const fn scheduler_threads(mut self, threads: usize) -> Self {
327 self.scheduler_threads = Some(threads);
328 self
329 }
330
331 #[must_use]
335 pub const fn visibility_reconciliation_interval(mut self, interval: Duration) -> Self {
336 self.visibility_reconciliation_interval = Some(interval);
337 self
338 }
339
340 #[must_use]
342 pub const fn signal_delivery(mut self, signal_delivery: SignalDeliveryConfig) -> Self {
343 self.signal_delivery = signal_delivery;
344 self
345 }
346
347 #[must_use]
349 pub fn load_workflows(mut self, source: impl Into<WorkflowPackageSource>) -> Self {
350 self.workflow_sources.push(source.into());
351 self
352 }
353
354 #[must_use]
356 pub fn load_workflow_sources<I, S>(mut self, sources: I) -> Self
357 where
358 I: IntoIterator<Item = S>,
359 S: Into<WorkflowPackageSource>,
360 {
361 self.workflow_sources
362 .extend(sources.into_iter().map(Into::into));
363 self
364 }
365
366 #[must_use]
368 pub fn register_nifs(mut self, entries: impl IntoIterator<Item = NifEntry>) -> Self {
369 self.host_nifs.extend(entries);
370 self
371 }
372
373 #[must_use]
375 pub fn recovery_seam(mut self, recovery: Arc<dyn ActiveWorkflowRecoverySeam>) -> Self {
376 self.recovery = Some(recovery);
377 self
378 }
379
380 #[must_use]
382 pub fn production_recovery_seam(mut self) -> Self {
383 self.recovery = None;
384 self
385 }
386
387 #[must_use]
389 pub fn signal_router(mut self, signal_router: Arc<dyn SignalRouter>) -> Self {
390 self.signal_router_factory = None;
391 self.delegated = DelegatedSeams::new(
392 signal_router,
393 self.delegated.query_service_arc(),
394 self.delegated.event_publisher_arc(),
395 );
396 self
397 }
398
399 #[must_use]
401 pub fn signal_router_factory<F>(mut self, factory: F) -> Self
402 where
403 F: Fn(Arc<RuntimeHandle>, Arc<SignalResumeHandoff>) -> Arc<dyn SignalRouter>
404 + Send
405 + Sync
406 + 'static,
407 {
408 self.signal_router_factory = Some(Arc::new(factory));
409 self
410 }
411
412 #[must_use]
417 pub fn query_service(mut self, query_service: Arc<dyn QueryService>) -> Self {
418 self.query_service_overridden = true;
419 self.delegated = DelegatedSeams::new(
420 self.delegated.signal_router_arc(),
421 query_service,
422 self.delegated.event_publisher_arc(),
423 );
424 self
425 }
426
427 #[must_use]
432 pub fn event_publisher(mut self, event_publisher: Arc<dyn EventPublisher>) -> Self {
433 self.event_publisher_overridden = true;
434 self.delegated = DelegatedSeams::new(
435 self.delegated.signal_router_arc(),
436 self.delegated.query_service_arc(),
437 event_publisher,
438 );
439 self
440 }
441
442 #[must_use]
448 pub fn activity_dispatcher(mut self, dispatcher: Arc<dyn ActivityDispatcher>) -> Self {
449 self.activity_dispatcher = Some(dispatcher);
450 self
451 }
452
453 #[must_use]
458 pub fn active_registry(mut self, registry: Arc<Registry>) -> Self {
459 self.active_registry = Some(registry);
460 self
461 }
462
463 #[must_use]
465 pub const fn scheduler_thread_count(&self) -> Option<usize> {
466 self.scheduler_threads
467 }
468
469 #[must_use]
471 pub const fn configured_visibility_reconciliation_interval(&self) -> Option<Duration> {
472 self.visibility_reconciliation_interval
473 }
474
475 pub async fn build(self) -> Result<Engine, EngineError> {
483 let (store, streaming_publisher) = wrap_event_streaming(
484 self.store.ok_or(EngineError::MissingStore)?,
485 self.event_streaming_capacity,
486 self.event_publisher_overridden,
487 )?;
488 let visibility_store = self
489 .visibility_store
490 .ok_or(EngineError::MissingVisibilityStore)?;
491
492 let runtime = Arc::new(RuntimeHandle::new(
493 RuntimeConfig::new(self.scheduler_threads).with_signal_delivery(self.signal_delivery),
494 )?);
495
496 let mut nifs = NifRegistration::new();
497 nifs.add_engine_nifs().add_host_nifs(self.host_nifs);
498 runtime.install_nifs(nifs)?;
499
500 let catalog =
504 assemble_startup_catalog(runtime.as_ref(), store.as_ref(), self.workflow_sources)
505 .await?;
506
507 let registry = self
508 .active_registry
509 .unwrap_or_else(|| Arc::new(Registry::default()));
510 let nif_state = Arc::clone(runtime.nif_state());
511 let query_mailbox_engine = install_engine_nif_seams(
512 &nif_state,
513 ®istry,
514 &store,
515 &runtime,
516 self.activity_dispatcher,
517 self.query_timeout,
518 );
519 let supervision = Arc::new(SupervisionTree::new());
520 let search_attribute_schema = Arc::new(self.search_attribute_schema);
521 let signal_handoff = Arc::new(SignalResumeHandoff::new());
522
523 let delegated = assemble_delegated_seams(SeamAssembly {
524 configured: self.delegated,
525 signal_router_factory: self.signal_router_factory,
526 runtime: Arc::clone(&runtime),
527 signal_handoff: Arc::clone(&signal_handoff),
528 streaming_publisher,
529 query_mailbox_engine,
530 query_timeout: self.query_timeout,
531 query_service_overridden: self.query_service_overridden,
532 });
533
534 install_signal_nif_bridge(
535 &nif_state,
536 Arc::new(crate::runtime::SignalNifBridge::new(
537 Arc::clone(®istry),
538 Arc::clone(&runtime),
539 tokio::runtime::Handle::current(),
540 delegated.signal_router_arc(),
541 )),
542 );
543 install_configured_child_nif_bridge(&ChildBridgeAssembly {
544 nif_state: &nif_state,
545 store: &store,
546 visibility_store: &visibility_store,
547 runtime: &runtime,
548 catalog: &catalog,
549 registry: ®istry,
550 supervision: &supervision,
551 signal_handoff: &signal_handoff,
552 search_attribute_schema: &search_attribute_schema,
553 watch_backoff: self.signal_delivery,
554 })?;
555
556 recover_active_workflows_on_startup(StartupRecoveryContext {
563 store: Arc::clone(&store),
564 visibility_store: Arc::clone(&visibility_store),
565 runtime: Arc::clone(&runtime),
566 catalog: Arc::clone(&catalog),
567 registry: Arc::clone(®istry),
568 supervision: Arc::clone(&supervision),
569 recovery: self.recovery,
570 search_attribute_schema: Arc::clone(&search_attribute_schema),
571 })
572 .await?;
573 recover_timers_on_startup(&nif_state, Arc::clone(&store)).await?;
574
575 let visibility_reconciliation_task =
576 self.visibility_reconciliation_interval.map(|interval| {
577 spawn_visibility_reconciliation_task(
578 interval,
579 Arc::clone(&store),
580 Arc::clone(&visibility_store),
581 )
582 });
583
584 let engine = Engine::new(EngineComponents {
585 store,
586 visibility_store,
587 runtime,
588 catalog,
589 registry,
590 supervision,
591 delegated,
592 signal_handoff,
593 search_attribute_schema,
594 visibility_reconciliation_task,
595 });
596 engine.catchup_schedule_coordinator().await?;
597 engine.recover_schedules_on_startup(Utc::now()).await?;
598 Ok(engine)
599 }
600}
601
602struct ChildBridgeAssembly<'a> {
604 nif_state: &'a Arc<crate::runtime::EngineNifState>,
605 store: &'a Arc<dyn EventStore>,
606 visibility_store: &'a Arc<dyn VisibilityStore>,
607 runtime: &'a Arc<RuntimeHandle>,
608 catalog: &'a Arc<WorkflowCatalog>,
609 registry: &'a Arc<Registry>,
610 supervision: &'a Arc<SupervisionTree>,
611 signal_handoff: &'a Arc<SignalResumeHandoff>,
612 search_attribute_schema: &'a Arc<aion_core::SearchAttributeSchema>,
613 watch_backoff: SignalDeliveryConfig,
616}
617
618fn install_configured_child_nif_bridge(
619 assembly: &ChildBridgeAssembly<'_>,
620) -> Result<(), EngineError> {
621 install_child_nif_bridge(
622 assembly.nif_state,
623 Arc::new(ChildNifBridge::new(ChildNifBridgeParts {
624 store: Arc::clone(assembly.store),
625 visibility_store: Arc::clone(assembly.visibility_store),
626 runtime: Arc::clone(assembly.runtime),
627 catalog: Arc::clone(assembly.catalog),
628 registry: Arc::clone(assembly.registry),
629 supervision: Arc::clone(assembly.supervision),
630 signal_handoff: Arc::clone(assembly.signal_handoff),
631 search_attribute_schema: Arc::clone(assembly.search_attribute_schema),
632 tokio_handle: tokio::runtime::Handle::current(),
633 watch_backoff: assembly.watch_backoff,
634 })?),
635 );
636 Ok(())
637}
638
639pub(super) fn package_from_source(source: WorkflowPackageSource) -> Result<Package, EngineError> {
640 match source {
641 WorkflowPackageSource::Path(path) => {
642 Package::load_from_path(&path, ExtractionLimits::unbounded()).map_err(|error| {
645 EngineError::Load {
646 reason: format!(
647 "failed to load workflow package `{}`: {error}",
648 path.display()
649 ),
650 }
651 })
652 }
653 WorkflowPackageSource::Package(package) => Ok(*package),
654 }
655}
656
657#[cfg(test)]
658mod tests {
659 use std::{num::NonZeroUsize, path::PathBuf, process::Command, sync::Arc, time::Duration};
660
661 use aion_core::{Event, EventEnvelope, Payload, WorkflowId, WorkflowStatus};
662 use aion_package::{
663 BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, ExtractionLimits, Manifest,
664 ManifestVersion, Package, PackageBuilder,
665 };
666 use aion_store::visibility::{ListWorkflowsFilter, VisibilityStore};
667 use aion_store::{InMemoryStore, ReadableEventStore, WritableEventStore, WriteToken};
668 use chrono::Utc;
669 use futures::StreamExt;
670 use serde_json::json;
671
672 use crate::engine::api_schedule::{
673 schedule_coordinator_run_id, schedule_coordinator_workflow_id,
674 schedule_coordinator_workflow_type,
675 };
676 use crate::runtime::{Mfa, NifEntry};
677
678 use super::EngineBuilder;
679 use crate::EngineError;
680
681 fn payload() -> Result<Payload, aion_core::PayloadError> {
682 Payload::from_json(&json!({ "input": true }))
683 }
684
685 fn started(
686 workflow_id: &WorkflowId,
687 workflow_type: &str,
688 ) -> Result<Event, aion_core::PayloadError> {
689 Ok(Event::WorkflowStarted {
690 envelope: EventEnvelope {
691 seq: 1,
692 recorded_at: Utc::now(),
693 workflow_id: workflow_id.clone(),
694 },
695 workflow_type: workflow_type.to_owned(),
696 input: payload()?,
697 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
698 parent_run_id: None,
699 package_version: aion_core::PackageVersion::new("a".repeat(64)),
700 })
701 }
702
703 fn completed(workflow_id: &WorkflowId) -> Result<Event, aion_core::PayloadError> {
704 Ok(Event::WorkflowCompleted {
705 envelope: EventEnvelope {
706 seq: 2,
707 recorded_at: Utc::now(),
708 workflow_id: workflow_id.clone(),
709 },
710 result: payload()?,
711 })
712 }
713
714 fn package_manifest() -> Manifest {
715 Manifest {
716 entry_module: "counter".to_owned(),
717 entry_function: "version".to_owned(),
718 input_schema: json!({ "type": "object" }),
719 output_schema: json!({ "type": "integer" }),
720 timeout: Duration::from_secs(30),
721 activities: vec![DeclaredActivity {
722 activity_type: "activity/test".to_owned(),
723 }],
724 version: ManifestVersion::new("test"),
725 format_version: CURRENT_FORMAT_VERSION,
726 }
727 }
728
729 fn compile_counter_beam() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
730 let temp_dir =
731 std::env::temp_dir().join(format!("aion-engine-builder-{}", uuid::Uuid::new_v4()));
732 std::fs::create_dir(&temp_dir)?;
733 let source_path = temp_dir.join("counter.erl");
734 let beam_path = temp_dir.join("counter.beam");
735 std::fs::write(
736 &source_path,
737 "-module(counter).\n-export([version/0]).\nversion() -> 1.\n",
738 )?;
739 let status = Command::new("erlc")
740 .arg("-o")
741 .arg(&temp_dir)
742 .arg(&source_path)
743 .status()?;
744 if !status.success() {
745 let cleanup_result = std::fs::remove_dir_all(&temp_dir);
746 drop(cleanup_result);
747 return Err(format!("erlc failed with status {status}").into());
748 }
749 let bytes = std::fs::read(beam_path)?;
750 std::fs::remove_dir_all(temp_dir)?;
751 Ok(bytes)
752 }
753
754 fn fixture_package() -> Result<Package, Box<dyn std::error::Error>> {
755 let beams = BeamSet::new(vec![BeamModule::new("counter", compile_counter_beam()?)])?;
756 let archive = PackageBuilder::new(package_manifest(), beams).write_to_bytes()?;
757 Ok(Package::load_from_bytes(
758 archive,
759 ExtractionLimits::unbounded(),
760 )?)
761 }
762
763 fn write_fixture_package(package: &Package) -> Result<PathBuf, Box<dyn std::error::Error>> {
764 let path =
765 std::env::temp_dir().join(format!("aion-engine-builder-{}.aion", uuid::Uuid::new_v4()));
766 PackageBuilder::new(package.manifest().clone(), package.beams().clone())
767 .write_to_path(&path)?;
768 Ok(path)
769 }
770
771 #[tokio::test]
772 async fn build_without_store_returns_missing_store() {
773 let error = EngineBuilder::new().build().await.err();
774
775 assert!(matches!(error, Some(EngineError::MissingStore)));
776 }
777
778 #[tokio::test]
779 async fn build_without_visibility_store_returns_missing_visibility_store() {
780 let error = EngineBuilder::new()
781 .store(InMemoryStore::default())
782 .build()
783 .await
784 .err();
785
786 assert!(matches!(error, Some(EngineError::MissingVisibilityStore)));
787 }
788
789 #[tokio::test]
790 async fn in_memory_visibility_allows_build_without_visibility_store() -> Result<(), EngineError>
791 {
792 let engine = EngineBuilder::new()
793 .store(InMemoryStore::default())
794 .in_memory_visibility()
795 .build()
796 .await?;
797
798 engine.shutdown()?;
799 Ok(())
800 }
801
802 fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
803 NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
804 }
805
806 #[tokio::test]
807 async fn event_streaming_delivers_recorder_appends_through_engine_subscribe()
808 -> Result<(), Box<dyn std::error::Error>> {
809 let engine = EngineBuilder::new()
810 .store(InMemoryStore::default())
811 .in_memory_visibility()
812 .event_streaming(capacity(8)?)
813 .build()
814 .await?;
815 let workflow_id = WorkflowId::new_v4();
816 let mut subscription = engine.subscribe(crate::EventFilter {
817 workflow_id: Some(workflow_id.clone()),
818 run: None,
819 family: None,
820 });
821
822 let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
825 recorder
826 .record_workflow_started(
827 Utc::now(),
828 crate::durability::WorkflowStartRecord {
829 workflow_type: "checkout".to_owned(),
830 input: payload()?,
831 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(7)),
832 parent_run_id: None,
833 package_version: aion_core::PackageVersion::new("a".repeat(64)),
834 },
835 )
836 .await?;
837
838 let item = tokio::time::timeout(Duration::from_secs(2), subscription.next())
839 .await?
840 .ok_or("subscription ended without delivering the appended event")?;
841 let event = item?;
842 assert_eq!(event.workflow_id(), &workflow_id);
843 assert_eq!(event.seq(), 1);
844 assert!(matches!(event, Event::WorkflowStarted { .. }));
845 engine.shutdown()?;
846 Ok(())
847 }
848
849 #[tokio::test]
850 async fn without_event_streaming_subscriptions_stay_on_deferred_empty_stream()
851 -> Result<(), Box<dyn std::error::Error>> {
852 let engine = EngineBuilder::new()
853 .store(InMemoryStore::default())
854 .in_memory_visibility()
855 .build()
856 .await?;
857
858 let mut subscription = engine.subscribe(crate::EventFilter::default());
859 let item = tokio::time::timeout(Duration::from_secs(2), subscription.next()).await?;
860
861 assert!(item.is_none(), "deferred publisher streams must be empty");
862 engine.shutdown()?;
863 Ok(())
864 }
865
866 #[tokio::test]
867 async fn event_streaming_conflicts_with_explicit_event_publisher()
868 -> Result<(), Box<dyn std::error::Error>> {
869 let error = EngineBuilder::new()
870 .store(InMemoryStore::default())
871 .in_memory_visibility()
872 .event_publisher(Arc::new(crate::DeferredEventPublisher))
873 .event_streaming(capacity(8)?)
874 .build()
875 .await
876 .err();
877
878 assert!(matches!(
879 error,
880 Some(EngineError::ConflictingEventPublisher)
881 ));
882 Ok(())
883 }
884
885 #[test]
886 fn query_timeout_is_only_set_by_caller() {
887 assert_eq!(EngineBuilder::new().configured_query_timeout(), None);
888 assert_eq!(
889 EngineBuilder::new()
890 .query_timeout(Duration::from_secs(3))
891 .configured_query_timeout(),
892 Some(Duration::from_secs(3))
893 );
894 }
895
896 async fn insert_running_workflow(
897 engine: &crate::Engine,
898 ) -> Result<(WorkflowId, aion_core::RunId), Box<dyn std::error::Error>> {
899 let workflow_id = WorkflowId::new_v4();
900 let run_id = aion_core::RunId::new_v4();
901 let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
902 recorder
903 .record_workflow_started(
904 Utc::now(),
905 crate::durability::WorkflowStartRecord {
906 workflow_type: "checkout".to_owned(),
907 input: payload()?,
908 run_id: run_id.clone(),
909 parent_run_id: None,
910 package_version: aion_core::PackageVersion::new("a".repeat(64)),
911 },
912 )
913 .await?;
914 let handle = crate::registry::WorkflowHandle::new(crate::registry::WorkflowHandleParts {
915 workflow_id: workflow_id.clone(),
916 run_id: run_id.clone(),
917 pid: engine.runtime().spawn_test_process_with_trap_exit(true)?,
918 workflow_type: "checkout".to_owned(),
919 loaded_version: aion_package::ContentHash::from_bytes([2; 32]),
920 cached_status: WorkflowStatus::Running,
921 residency: crate::registry::HandleResidency::Resident,
922 recorder,
923 completion: crate::registry::CompletionNotifier::new(),
924 });
925 engine
926 .registry()
927 .insert((workflow_id.clone(), run_id.clone()), handle)?;
928 Ok((workflow_id, run_id))
929 }
930
931 #[tokio::test]
932 async fn query_timeout_installs_the_concrete_query_seam()
933 -> Result<(), Box<dyn std::error::Error>> {
934 let engine = EngineBuilder::new()
935 .store(InMemoryStore::default())
936 .in_memory_visibility()
937 .query_timeout(Duration::from_millis(250))
938 .build()
939 .await?;
940 let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
941
942 let result = engine.query(&workflow_id, &run_id, "state").await;
946
947 assert!(matches!(
948 result,
949 Err(crate::EngineError::Query(crate::QueryError::UnknownQuery(name))) if name == "state"
950 ));
951 engine.shutdown()?;
952 Ok(())
953 }
954
955 #[tokio::test]
956 async fn without_query_timeout_the_query_seam_stays_deferred()
957 -> Result<(), Box<dyn std::error::Error>> {
958 let engine = EngineBuilder::new()
959 .store(InMemoryStore::default())
960 .in_memory_visibility()
961 .build()
962 .await?;
963 let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
964
965 let result = engine.query(&workflow_id, &run_id, "state").await;
966
967 assert!(matches!(
968 result,
969 Err(crate::EngineError::Runtime { reason }) if reason.contains("not configured")
970 ));
971 engine.shutdown()?;
972 Ok(())
973 }
974
975 #[test]
976 fn scheduler_threads_are_only_set_by_caller() {
977 assert_eq!(EngineBuilder::new().scheduler_thread_count(), None);
978 assert_eq!(
979 EngineBuilder::new()
980 .scheduler_threads(4)
981 .scheduler_thread_count(),
982 Some(4)
983 );
984 }
985
986 #[test]
987 fn visibility_reconciliation_interval_is_only_set_by_caller() {
988 let interval = Duration::from_millis(250);
989
990 assert_eq!(
991 EngineBuilder::new().configured_visibility_reconciliation_interval(),
992 None
993 );
994 assert_eq!(
995 EngineBuilder::new()
996 .visibility_reconciliation_interval(interval)
997 .configured_visibility_reconciliation_interval(),
998 Some(interval)
999 );
1000 }
1001
1002 #[tokio::test]
1003 async fn duplicate_host_nif_mfa_returns_typed_error() {
1004 let mfa = Mfa::new("host", "zero", 0);
1005 let error = EngineBuilder::new()
1006 .store(InMemoryStore::default())
1007 .in_memory_visibility()
1008 .register_nifs([
1009 NifEntry::new(mfa.clone(), crate::runtime::nif::test_native_zero),
1010 NifEntry::dirty(mfa, crate::runtime::nif::test_native_zero),
1011 ])
1012 .build()
1013 .await
1014 .err();
1015
1016 assert!(matches!(
1017 error,
1018 Some(EngineError::NifRegistration { reason }) if reason.contains("host:zero/0")
1019 ));
1020 }
1021
1022 #[tokio::test]
1023 async fn empty_store_builds_coordinator_history_without_registry_or_supervision()
1024 -> Result<(), EngineError> {
1025 let store = Arc::new(InMemoryStore::default());
1026 let engine = EngineBuilder::new()
1027 .store_arc(store.clone())
1028 .in_memory_visibility()
1029 .build()
1030 .await?;
1031
1032 assert!(engine.registry().list()?.is_empty());
1033 assert_eq!(engine.supervision().type_supervisor_count()?, 1);
1034 assert_eq!(engine.workflow_catalog().workflows()?.len(), 0);
1035
1036 let coordinator_id = schedule_coordinator_workflow_id();
1037 let active = store.list_active().await?;
1038 assert_eq!(active, vec![coordinator_id.clone()]);
1039 let history = store.read_history(&coordinator_id).await?;
1040 let [started] = history.as_slice() else {
1041 return Err(EngineError::Load {
1042 reason: format!(
1043 "expected exactly one coordinator event, found {}",
1044 history.len()
1045 ),
1046 });
1047 };
1048 match started {
1049 Event::WorkflowStarted {
1050 workflow_type,
1051 input,
1052 run_id,
1053 parent_run_id,
1054 ..
1055 } => {
1056 assert_eq!(workflow_type, schedule_coordinator_workflow_type());
1057 assert_eq!(
1058 input,
1059 &Payload::from_json(&json!({})).map_err(|error| {
1060 EngineError::Load {
1061 reason: format!("failed to build expected payload: {error}"),
1062 }
1063 })?
1064 );
1065 assert_eq!(run_id, &schedule_coordinator_run_id());
1066 assert!(parent_run_id.is_none());
1067 }
1068 other => {
1069 return Err(EngineError::Load {
1070 reason: format!("expected coordinator WorkflowStarted, found {other:?}"),
1071 });
1072 }
1073 }
1074
1075 engine.shutdown()?;
1076 let rebuilt = EngineBuilder::new()
1077 .store_arc(store.clone())
1078 .in_memory_visibility()
1079 .build()
1080 .await?;
1081 let rebuilt_history = store.read_history(&coordinator_id).await?;
1082 assert_eq!(rebuilt_history.len(), 1);
1083 rebuilt.shutdown()?;
1084
1085 Ok(())
1086 }
1087
1088 #[tokio::test]
1089 async fn build_loads_already_loaded_package() -> Result<(), Box<dyn std::error::Error>> {
1090 let package = fixture_package()?;
1091 let version = package.content_hash().clone();
1092 let deployed_entry_module = package.deployed_entry_module();
1093
1094 let engine = EngineBuilder::new()
1095 .store(InMemoryStore::default())
1096 .in_memory_visibility()
1097 .load_workflows(package)
1098 .build()
1099 .await?;
1100
1101 let loaded = engine
1102 .workflow_catalog()
1103 .get("counter", &version)?
1104 .ok_or("loaded package record missing")?;
1105 assert_eq!(loaded.deployed_entry_module(), deployed_entry_module);
1106 assert!(
1107 engine
1108 .runtime()
1109 .has_registered_module(&deployed_entry_module)
1110 );
1111 Ok(())
1112 }
1113
1114 #[tokio::test]
1115 async fn startup_reconciliation_backfills_completed_visibility()
1116 -> Result<(), Box<dyn std::error::Error>> {
1117 let store = Arc::new(InMemoryStore::default());
1118 let completed_id = WorkflowId::new_v4();
1119
1120 store
1121 .append(
1122 WriteToken::recorder(),
1123 &completed_id,
1124 &[
1125 started(&completed_id, "billing")?,
1126 completed(&completed_id)?,
1127 ],
1128 0,
1129 )
1130 .await?;
1131
1132 let engine = EngineBuilder::new()
1133 .store_arc(store.clone())
1134 .visibility_store_arc(store.clone())
1135 .build()
1136 .await?;
1137
1138 let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1139 let completed_summary = summaries
1140 .iter()
1141 .find(|summary| summary.workflow_id == completed_id)
1142 .ok_or("completed workflow missing from visibility")?;
1143
1144 assert_eq!(completed_summary.status, WorkflowStatus::Completed);
1145 assert!(completed_summary.close_time.is_some());
1146 engine.shutdown()?;
1147 Ok(())
1148 }
1149
1150 #[tokio::test]
1151 async fn periodic_visibility_reconciliation_repairs_gap_after_startup()
1152 -> Result<(), Box<dyn std::error::Error>> {
1153 let store = Arc::new(InMemoryStore::default());
1154 let engine = EngineBuilder::new()
1155 .store_arc(store.clone())
1156 .visibility_store_arc(store.clone())
1157 .visibility_reconciliation_interval(Duration::from_millis(25))
1158 .build()
1159 .await?;
1160 let workflow_id = WorkflowId::new_v4();
1161
1162 store
1163 .append(
1164 WriteToken::recorder(),
1165 &workflow_id,
1166 &[started(&workflow_id, "checkout")?],
1167 0,
1168 )
1169 .await?;
1170
1171 tokio::time::timeout(Duration::from_secs(2), async {
1172 loop {
1173 let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1174 if summaries.iter().any(|summary| {
1175 summary.workflow_id == workflow_id && summary.status == WorkflowStatus::Running
1176 }) {
1177 return Ok::<(), aion_store::StoreError>(());
1178 }
1179 tokio::time::sleep(Duration::from_millis(10)).await;
1180 }
1181 })
1182 .await??;
1183
1184 engine.shutdown()?;
1185 Ok(())
1186 }
1187
1188 #[tokio::test]
1189 async fn build_loads_package_from_path() -> Result<(), Box<dyn std::error::Error>> {
1190 let package = fixture_package()?;
1191 let version = package.content_hash().clone();
1192 let path = write_fixture_package(&package)?;
1193
1194 let engine = EngineBuilder::new()
1195 .store(InMemoryStore::default())
1196 .in_memory_visibility()
1197 .load_workflows(path.as_path())
1198 .build()
1199 .await?;
1200 std::fs::remove_file(path)?;
1201
1202 assert!(
1203 engine
1204 .workflow_catalog()
1205 .get("counter", &version)?
1206 .is_some()
1207 );
1208 Ok(())
1209 }
1210}