Skip to main content

aion/engine/
builder.rs

1//! `EngineBuilder` and build wiring.
2
3use 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/// Source for a workflow package collected before `build()` performs fallible
35/// loading and runtime registration.
36#[derive(Clone, Debug)]
37pub enum WorkflowPackageSource {
38    /// Load a package from this `.aion` archive path during `build()`.
39    Path(PathBuf),
40    /// Use an already-loaded package value.
41    Package(Box<Package>),
42}
43
44/// Install the engine-scoped NIF seams that are available before delegated
45/// seams exist: runtime context, timer bridge, deterministic context source,
46/// query bridge, and the optional activity dispatcher.
47///
48/// Returns the query mailbox engine handle installed in the query bridge, so
49/// `build()` can wire the concrete query-dispatch seam over the same
50/// delivery path the NIF-side `dispatch_query` uses.
51fn 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
94/// Assemble the startup catalog: persisted runtime deploys reload first
95/// (with their persisted route pointers), then explicit operator-supplied
96/// sources load on top.
97///
98/// The order is the routing-intent precedence: a package named explicitly at
99/// THIS boot (`--workflow-package` / builder source) is the operator's newest
100/// instruction and wins the route for its type, while every persisted deploy
101/// still reloads so startup recovery — which runs after this and resolves
102/// each run's recorded pinned version — finds every version it needs.
103/// Operator-file sources are not persisted; only the runtime deploy seam
104/// writes package rows.
105async 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
179/// Builder for the embedded, transport-agnostic workflow engine.
180pub 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    /// Create a builder with no store, no scheduler-thread override, no loaded
208    /// workflows, and no host NIFs.
209    #[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    /// Record the caller-supplied workflow query reply timeout.
233    ///
234    /// Setting a timeout installs the concrete query-dispatch seam during
235    /// `build()` (unless [`Self::query_service`] overrides it) and enables
236    /// the in-engine `dispatch_query` NIF. There is no default: without this
237    /// call the query seam stays deferred and `Engine::query` fails typed
238    /// with its "not configured" error.
239    #[must_use]
240    pub const fn query_timeout(mut self, timeout: Duration) -> Self {
241        self.query_timeout = Some(timeout);
242        self
243    }
244
245    /// Inspect the configured workflow query reply timeout.
246    #[must_use]
247    pub const fn configured_query_timeout(&self) -> Option<Duration> {
248        self.query_timeout
249    }
250
251    /// Opt in to live event streaming with a caller-provided broadcast capacity.
252    ///
253    /// `build()` wraps the configured store in a
254    /// [`PublishingEventStore`](crate::publish::PublishingEventStore) before any
255    /// recorder, recovery, or NIF bridge captures the store — so every
256    /// successful append publishes — and installs the matching
257    /// [`BroadcastEventPublisher`](crate::publish::BroadcastEventPublisher) as
258    /// the event-publisher seam behind [`Engine::subscribe`]. Without this call
259    /// the deferred publisher remains installed and subscriptions are empty.
260    #[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    /// Supply the search attribute schema validating every recorded attribute.
267    ///
268    /// The default schema is empty, which rejects all search attributes: a
269    /// deployment must declare each attribute name and type before workflows
270    /// can record values for it.
271    #[must_use]
272    pub fn search_attribute_schema(mut self, schema: SearchAttributeSchema) -> Self {
273        self.search_attribute_schema = schema;
274        self
275    }
276
277    /// Supply the event store used by the engine.
278    #[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    /// Supply an already type-erased event store.
288    #[must_use]
289    pub fn store_arc(mut self, store: Arc<dyn EventStore>) -> Self {
290        self.store = Some(store);
291        self
292    }
293
294    /// Supply the visibility store used by the engine for workflow projections.
295    #[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    /// Supply an already type-erased visibility store.
305    #[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    /// Explicitly opt in to an ephemeral in-memory visibility store.
312    ///
313    /// This is intended for tests and local scenarios that do not need durable
314    /// visibility projections. Visibility data stored this way does not survive
315    /// process restarts.
316    #[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    /// Record the caller-supplied scheduler thread count.
323    ///
324    /// If this setter is never called, `None` is passed through to beamr.
325    #[must_use]
326    pub const fn scheduler_threads(mut self, threads: usize) -> Self {
327        self.scheduler_threads = Some(threads);
328        self
329    }
330
331    /// Record the caller-supplied periodic visibility reconciliation interval.
332    ///
333    /// If this setter is never called, no periodic background reconciliation task is spawned.
334    #[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    /// Record the caller-supplied signal delivery readiness and retry policy.
341    #[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    /// Add one workflow package source to load during `build()`.
348    #[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    /// Add many workflow package sources to load during `build()`.
355    #[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    /// Collect host-supplied NIF entries to install before workflow modules load.
367    #[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    /// Override the AD recovery seam used while repopulating active workflows.
374    #[must_use]
375    pub fn recovery_seam(mut self, recovery: Arc<dyn ActiveWorkflowRecoverySeam>) -> Self {
376        self.recovery = Some(recovery);
377        self
378    }
379
380    /// Use the production AD recovery seam created after runtime/package loading.
381    #[must_use]
382    pub fn production_recovery_seam(mut self) -> Self {
383        self.recovery = None;
384        self
385    }
386
387    /// Override the AT signal-routing seam.
388    #[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    /// Override the AT signal-routing seam after the runtime is assembled.
400    #[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    /// Override the AT query-dispatch seam.
413    ///
414    /// An explicit override wins over the concrete service that
415    /// [`Self::query_timeout`] would otherwise install during `build()`.
416    #[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    /// Override the AD/AT live event-publisher seam.
428    ///
429    /// Mutually exclusive with [`Self::event_streaming`], which installs the
430    /// broadcast publisher itself; configuring both fails `build()`.
431    #[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    /// Supply the activity dispatcher that backs activity dispatch NIFs.
443    ///
444    /// When set, the dispatcher is installed in the global bridge before
445    /// workflow modules are loaded. Without a dispatcher, `dispatch_activity`
446    /// returns an error to workflow code instead of crashing the process.
447    #[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    /// Supply the active workflow registry used by the built engine.
454    ///
455    /// Server-owned dispatchers that run behind raw NIFs use this to correlate a
456    /// calling BEAM pid to the same workflow handle the engine registers.
457    #[must_use]
458    pub fn active_registry(mut self, registry: Arc<Registry>) -> Self {
459        self.active_registry = Some(registry);
460        self
461    }
462
463    /// Inspect the configured scheduler thread count.
464    #[must_use]
465    pub const fn scheduler_thread_count(&self) -> Option<usize> {
466        self.scheduler_threads
467    }
468
469    /// Inspect the configured periodic visibility reconciliation interval.
470    #[must_use]
471    pub const fn configured_visibility_reconciliation_interval(&self) -> Option<Duration> {
472        self.visibility_reconciliation_interval
473    }
474
475    /// Construct the live engine.
476    ///
477    /// # Errors
478    ///
479    /// Returns typed [`EngineError`] variants for missing store, runtime startup,
480    /// NIF registration, package loading, store reads, registry/supervision lock
481    /// poison, or deferred AD recovery failures for active histories.
482    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        // Persisted runtime deploys must be resident before startup recovery
501        // below resolves any run's recorded pinned version — this is the
502        // restart half of the deploy durability promise.
503        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            &registry,
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(&registry),
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: &registry,
550            supervision: &supervision,
551            signal_handoff: &signal_handoff,
552            search_attribute_schema: &search_attribute_schema,
553            watch_backoff: self.signal_delivery,
554        })?;
555
556        // Startup recovery re-spawns active workflow processes, and those
557        // processes begin replaying on scheduler threads immediately. Replay
558        // re-executes workflow code through the engine NIFs, so every NIF
559        // bridge (signal, child) must be installed before the first recovered
560        // process can run, or an early replayed spawn_child/receive_signal
561        // call fails with a missing-bridge error.
562        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(&registry),
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
602/// Borrowed engine components assembled into the child NIF bridge.
603struct 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    /// The child-terminal watcher reuses the builder's delivery retry
614    /// policy for its registry-miss backoff windows.
615    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            // Operator-local startup packages from config/CLI are trusted
643            // input; only the network deploy path extracts bounded.
644            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        // The production append path: a Recorder over the engine's store,
823        // which `event_streaming` wrapped before any recorder existed.
824        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        // The concrete seam reached the query mailbox engine, which answers
943        // an unregistered name with a typed UnknownQuery — the deferred seam
944        // would have failed with its "not configured" runtime error instead.
945        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}