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::Package;
9use aion_store::visibility::VisibilityStore;
10use aion_store::{EventStore, InMemoryStore};
11
12use crate::{
13    ActivityServing, EngineError, Registry, RuntimeConfig, RuntimeHandle, SignalDeliveryConfig,
14    SupervisionTree,
15    activity::bridge::ActivityDispatcher,
16    durability::ActiveWorkflowRecoverySeam,
17    runtime::{NifEntry, NifRegistration},
18    signal::SignalResumeHandoff,
19};
20
21use super::api::{Engine, EngineComponents};
22use super::builder_assembly::{
23    ChildBridgeAssembly, assemble_startup_catalog, claim_owned_shards, install_engine_nif_seams,
24    install_workflow_nif_bridges, maybe_spawn_visibility_reconciliation,
25};
26use super::delegated::{DelegatedSeams, EventPublisher, QueryService, SignalRouter};
27use super::seams::{
28    SeamAssembly, SignalRouterFactory, assemble_delegated_seams, wrap_event_streaming,
29};
30use super::startup::{StartupRecoveryContext, resolve_startup_recovery};
31
32/// Source for a workflow package collected before `build()` performs fallible
33/// loading and runtime registration.
34#[derive(Clone, Debug)]
35pub enum WorkflowPackageSource {
36    /// Load a package from this `.aion` archive path during `build()`.
37    Path(PathBuf),
38    /// Use an already-loaded package value.
39    Package(Box<Package>),
40}
41
42impl From<Package> for WorkflowPackageSource {
43    fn from(package: Package) -> Self {
44        Self::Package(Box::new(package))
45    }
46}
47
48impl From<PathBuf> for WorkflowPackageSource {
49    fn from(path: PathBuf) -> Self {
50        Self::Path(path)
51    }
52}
53
54impl From<&std::path::Path> for WorkflowPackageSource {
55    fn from(path: &std::path::Path) -> Self {
56        Self::Path(path.to_path_buf())
57    }
58}
59
60impl From<&str> for WorkflowPackageSource {
61    fn from(path: &str) -> Self {
62        Self::Path(PathBuf::from(path))
63    }
64}
65
66impl From<String> for WorkflowPackageSource {
67    fn from(path: String) -> Self {
68        Self::Path(PathBuf::from(path))
69    }
70}
71
72/// Tracks which optional engine seams the caller explicitly overrode, so
73/// `build()` can detect mutually-exclusive configuration (e.g. an event
74/// publisher set both directly and via event streaming). Grouped so the builder
75/// keeps its boolean configuration flags few and named.
76#[derive(Default)]
77struct SeamOverrides {
78    /// The caller installed an explicit event-publisher seam.
79    event_publisher: bool,
80    /// The caller installed an explicit query-service seam.
81    query_service: bool,
82}
83
84/// Builder for the embedded, transport-agnostic workflow engine.
85pub struct EngineBuilder {
86    store: Option<Arc<dyn EventStore>>,
87    visibility_store: Option<Arc<dyn VisibilityStore>>,
88    scheduler_threads: Option<usize>,
89    scheduler_jit_threshold: Option<u32>,
90    signal_delivery: SignalDeliveryConfig,
91    completion_retry: crate::runtime::CompletionRetryConfig,
92    outbox_enabled: bool,
93    bootstrap_schedule_coordinator: bool,
94    owned_shards: Option<Vec<usize>>,
95    workflow_sources: Vec<WorkflowPackageSource>,
96    host_nifs: Vec<NifEntry>,
97    recovery: Option<Arc<dyn ActiveWorkflowRecoverySeam>>,
98    delegated: DelegatedSeams,
99    signal_router_factory: Option<SignalRouterFactory>,
100    activity_dispatcher: Option<Arc<dyn ActivityDispatcher>>,
101    activity_serving: ActivityServing,
102    active_registry: Option<Arc<Registry>>,
103    visibility_reconciliation_interval: Option<Duration>,
104    search_attribute_schema: SearchAttributeSchema,
105    event_streaming_capacity: Option<NonZeroUsize>,
106    query_timeout: Option<Duration>,
107    seam_overrides: SeamOverrides,
108    defer_startup_recovery: bool,
109}
110
111impl Default for EngineBuilder {
112    fn default() -> Self {
113        Self::new()
114    }
115}
116
117impl EngineBuilder {
118    /// Create a builder with no store, no scheduler-thread override, no loaded
119    /// workflows, and no host NIFs.
120    #[must_use]
121    pub fn new() -> Self {
122        Self {
123            store: None,
124            visibility_store: None,
125            scheduler_threads: None,
126            scheduler_jit_threshold: None,
127            signal_delivery: SignalDeliveryConfig::default(),
128            completion_retry: crate::runtime::CompletionRetryConfig::default(),
129            outbox_enabled: false,
130            // The only field that defaults true: single-node engines seed the
131            // schedule coordinator. A multi-node deployment disables it on nodes
132            // that do not own the coordinator's shard (see the builder method).
133            bootstrap_schedule_coordinator: true,
134            // No shard restriction by default: the store owns ALL shards, which
135            // is byte-identical to single-node behaviour. `build()` only ever
136            // touches owned-shard scoping when a deployment sets this.
137            owned_shards: None,
138            workflow_sources: Vec::new(),
139            host_nifs: Vec::new(),
140            recovery: None,
141            delegated: DelegatedSeams::default(),
142            signal_router_factory: None,
143            activity_dispatcher: None,
144            activity_serving: ActivityServing::QueueRouted,
145            active_registry: None,
146            visibility_reconciliation_interval: None,
147            search_attribute_schema: SearchAttributeSchema::new(),
148            event_streaming_capacity: None,
149            query_timeout: None,
150            seam_overrides: SeamOverrides::default(),
151            defer_startup_recovery: false,
152        }
153    }
154
155    /// Record the caller-supplied workflow query reply timeout.
156    ///
157    /// Setting a timeout installs the concrete query-dispatch seam during
158    /// `build()` (unless [`Self::query_service`] overrides it) and enables
159    /// the in-engine `dispatch_query` NIF. There is no default: without this
160    /// call the query seam stays deferred and `Engine::query` fails typed
161    /// with its "not configured" error.
162    #[must_use]
163    pub const fn query_timeout(mut self, timeout: Duration) -> Self {
164        self.query_timeout = Some(timeout);
165        self
166    }
167
168    /// Inspect the configured workflow query reply timeout.
169    #[must_use]
170    pub const fn configured_query_timeout(&self) -> Option<Duration> {
171        self.query_timeout
172    }
173
174    /// Opt in to live event streaming with a caller-provided broadcast capacity.
175    ///
176    /// `build()` wraps the configured store in a
177    /// [`PublishingEventStore`](crate::publish::PublishingEventStore) before any
178    /// recorder, recovery, or NIF bridge captures the store — so every
179    /// successful append publishes — and installs the matching
180    /// [`BroadcastEventPublisher`](crate::publish::BroadcastEventPublisher) as
181    /// the event-publisher seam behind [`Engine::subscribe`]. Without this call
182    /// the deferred publisher remains installed and subscriptions are empty.
183    #[must_use]
184    pub const fn event_streaming(mut self, capacity: NonZeroUsize) -> Self {
185        self.event_streaming_capacity = Some(capacity);
186        self
187    }
188
189    /// Supply the search attribute schema validating every recorded attribute.
190    ///
191    /// The default schema is empty, which rejects all search attributes: a
192    /// deployment must declare each attribute name and type before workflows
193    /// can record values for it.
194    #[must_use]
195    pub fn search_attribute_schema(mut self, schema: SearchAttributeSchema) -> Self {
196        self.search_attribute_schema = schema;
197        self
198    }
199
200    /// Supply the event store used by the engine.
201    #[must_use]
202    pub fn store<S>(mut self, store: S) -> Self
203    where
204        S: EventStore,
205    {
206        self.store = Some(Arc::new(store));
207        self
208    }
209
210    /// Supply an already type-erased event store.
211    #[must_use]
212    pub fn store_arc(mut self, store: Arc<dyn EventStore>) -> Self {
213        self.store = Some(store);
214        self
215    }
216
217    /// Supply the visibility store used by the engine for workflow projections.
218    #[must_use]
219    pub fn visibility_store<S>(mut self, visibility_store: S) -> Self
220    where
221        S: VisibilityStore,
222    {
223        self.visibility_store = Some(Arc::new(visibility_store));
224        self
225    }
226
227    /// Supply an already type-erased visibility store.
228    #[must_use]
229    pub fn visibility_store_arc(mut self, visibility_store: Arc<dyn VisibilityStore>) -> Self {
230        self.visibility_store = Some(visibility_store);
231        self
232    }
233
234    /// Explicitly opt in to an ephemeral in-memory visibility store.
235    ///
236    /// This is intended for tests and local scenarios that do not need durable
237    /// visibility projections. Visibility data stored this way does not survive
238    /// process restarts.
239    #[must_use]
240    pub fn in_memory_visibility(mut self) -> Self {
241        self.visibility_store = Some(Arc::new(InMemoryStore::default()));
242        self
243    }
244
245    /// Record the caller-supplied scheduler thread count.
246    ///
247    /// If this setter is never called, `None` is passed through to beamr.
248    #[must_use]
249    pub const fn scheduler_threads(mut self, threads: usize) -> Self {
250        self.scheduler_threads = Some(threads);
251        self
252    }
253
254    /// Record the caller-supplied JIT compilation threshold.
255    ///
256    /// If this setter is never called, `None` is passed through to beamr, which
257    /// applies its own default. Read [`crate::RuntimeConfig::jit_threshold`]
258    /// before choosing a value — a large one defers compilation past any
259    /// realistic workload but is **not** a JIT off-switch.
260    #[must_use]
261    pub const fn scheduler_jit_threshold(mut self, threshold: u32) -> Self {
262        self.scheduler_jit_threshold = Some(threshold);
263        self
264    }
265
266    /// Record the caller-supplied periodic visibility reconciliation interval.
267    ///
268    /// If this setter is never called, no periodic background reconciliation task is spawned.
269    #[must_use]
270    pub const fn visibility_reconciliation_interval(mut self, interval: Duration) -> Self {
271        self.visibility_reconciliation_interval = Some(interval);
272        self
273    }
274
275    /// Record the caller-supplied signal delivery readiness and retry policy.
276    #[must_use]
277    pub const fn signal_delivery(mut self, signal_delivery: SignalDeliveryConfig) -> Self {
278        self.signal_delivery = signal_delivery;
279        self
280    }
281
282    /// Record the caller-supplied durable completion-retry backoff ladder.
283    ///
284    /// Separate from [`Self::signal_delivery`] because the completion retry is
285    /// unbounded by attempts and sleeps between durable store round-trips; see
286    /// [`crate::runtime::CompletionRetryConfig`] for why sharing one ladder
287    /// between the two was a defect.
288    #[must_use]
289    pub const fn completion_retry(
290        mut self,
291        completion_retry: crate::runtime::CompletionRetryConfig,
292    ) -> Self {
293        self.completion_retry = completion_retry;
294        self
295    }
296
297    /// Record whether the durable-outbox fan-out dispatch path is enabled.
298    #[must_use]
299    pub fn outbox_enabled(mut self, enabled: bool) -> Self {
300        self.outbox_enabled = enabled;
301        self
302    }
303
304    /// Control whether `build()` seeds the schedule-coordinator history.
305    ///
306    /// Default `true` (single-node). Under multi-shard active-active the
307    /// coordinator stream is owned by exactly ONE shard; a deployment sets this
308    /// `false` on every node that does NOT own that shard, so only the owner
309    /// seeds (and serves) it — a non-owner would otherwise try to write the
310    /// coordinator stream and race or fence the real owner.
311    #[must_use]
312    pub const fn bootstrap_schedule_coordinator(mut self, enabled: bool) -> Self {
313        self.bootstrap_schedule_coordinator = enabled;
314        self
315    }
316
317    /// Restrict this engine's store to the distribution shards this node owns.
318    ///
319    /// Under multi-shard active-active a node serves only a SUBSET of the
320    /// cluster's shards. `build()` calls
321    /// [`ReadableEventStore::set_owned_shards`](aion_store::ReadableEventStore::set_owned_shards)
322    /// with this set BEFORE startup recovery, so the node recovers and
323    /// enumerates only the workflows / timers / outbox rows that live on its
324    /// shards. The set is deduplicated and ordered by the store.
325    ///
326    /// Not calling this leaves the store owning ALL shards — the single-node
327    /// default, which is byte-identical to today's behaviour (`build()` never
328    /// touches the scoping hook). The single-shard in-memory backend ignores the
329    /// call regardless, since it owns everything unconditionally; haematite
330    /// honours it.
331    #[must_use]
332    pub fn owned_shards(mut self, shards: impl IntoIterator<Item = usize>) -> Self {
333        self.owned_shards = Some(shards.into_iter().collect());
334        self
335    }
336
337    /// Inspect the configured owned-shard set (`None` = own all shards).
338    #[must_use]
339    pub fn configured_owned_shards(&self) -> Option<&[usize]> {
340        self.owned_shards.as_deref()
341    }
342
343    /// Add one workflow package source to load during `build()`.
344    #[must_use]
345    pub fn load_workflows(mut self, source: impl Into<WorkflowPackageSource>) -> Self {
346        self.workflow_sources.push(source.into());
347        self
348    }
349
350    /// Add many workflow package sources to load during `build()`.
351    #[must_use]
352    pub fn load_workflow_sources<I, S>(mut self, sources: I) -> Self
353    where
354        I: IntoIterator<Item = S>,
355        S: Into<WorkflowPackageSource>,
356    {
357        self.workflow_sources
358            .extend(sources.into_iter().map(Into::into));
359        self
360    }
361
362    /// Collect host-supplied NIF entries to install before workflow modules load.
363    #[must_use]
364    pub fn register_nifs(mut self, entries: impl IntoIterator<Item = NifEntry>) -> Self {
365        self.host_nifs.extend(entries);
366        self
367    }
368
369    /// Override the AD recovery seam used while repopulating active workflows.
370    #[must_use]
371    pub fn recovery_seam(mut self, recovery: Arc<dyn ActiveWorkflowRecoverySeam>) -> Self {
372        self.recovery = Some(recovery);
373        self
374    }
375
376    /// Use the production AD recovery seam created after runtime/package loading.
377    #[must_use]
378    pub fn production_recovery_seam(mut self) -> Self {
379        self.recovery = None;
380        self
381    }
382
383    /// Defer the four startup-recovery steps out of `build()` (#266).
384    ///
385    /// Recovery replay re-dispatches every in-flight activity through the
386    /// configured activity dispatcher the moment it runs. A host that
387    /// decorates that dispatcher with collaborators it can only install once
388    /// the engine exists (the server's declared-body source) must therefore
389    /// build first, install its seams, and only then run recovery — by
390    /// calling [`Engine::run_startup_recovery`] exactly once. Without this
391    /// call, `build()` runs recovery itself, byte-identically to before this
392    /// seam existed, and `run_startup_recovery` refuses typed.
393    #[must_use]
394    pub fn defer_startup_recovery(mut self) -> Self {
395        self.defer_startup_recovery = true;
396        self
397    }
398
399    /// Override the AT signal-routing seam.
400    #[must_use]
401    pub fn signal_router(mut self, signal_router: Arc<dyn SignalRouter>) -> Self {
402        self.signal_router_factory = None;
403        self.delegated = DelegatedSeams::new(
404            signal_router,
405            self.delegated.query_service_arc(),
406            self.delegated.event_publisher_arc(),
407        );
408        self
409    }
410
411    /// Override the AT signal-routing seam after the runtime is assembled.
412    #[must_use]
413    pub fn signal_router_factory<F>(mut self, factory: F) -> Self
414    where
415        F: Fn(Arc<RuntimeHandle>, Arc<SignalResumeHandoff>) -> Arc<dyn SignalRouter>
416            + Send
417            + Sync
418            + 'static,
419    {
420        self.signal_router_factory = Some(Arc::new(factory));
421        self
422    }
423
424    /// Override the AT query-dispatch seam.
425    ///
426    /// An explicit override wins over the concrete service that
427    /// [`Self::query_timeout`] would otherwise install during `build()`.
428    #[must_use]
429    pub fn query_service(mut self, query_service: Arc<dyn QueryService>) -> Self {
430        self.seam_overrides.query_service = true;
431        self.delegated = DelegatedSeams::new(
432            self.delegated.signal_router_arc(),
433            query_service,
434            self.delegated.event_publisher_arc(),
435        );
436        self
437    }
438
439    /// Override the AD/AT live event-publisher seam.
440    ///
441    /// Mutually exclusive with [`Self::event_streaming`], which installs the
442    /// broadcast publisher itself; configuring both fails `build()`.
443    #[must_use]
444    pub fn event_publisher(mut self, event_publisher: Arc<dyn EventPublisher>) -> Self {
445        self.seam_overrides.event_publisher = true;
446        self.delegated = DelegatedSeams::new(
447            self.delegated.signal_router_arc(),
448            self.delegated.query_service_arc(),
449            event_publisher,
450        );
451        self
452    }
453
454    /// Supply the activity dispatcher that backs activity dispatch NIFs.
455    ///
456    /// When set, the dispatcher is installed in the global bridge before
457    /// workflow modules are loaded. Without a dispatcher, `dispatch_activity`
458    /// returns an error to workflow code instead of crashing the process.
459    #[must_use]
460    pub fn activity_dispatcher(mut self, dispatcher: Arc<dyn ActivityDispatcher>) -> Self {
461        self.activity_dispatcher = Some(dispatcher);
462        self
463    }
464
465    /// Declare that the configured in-process [`ActivityDispatcher`] fulfils
466    /// every activity this engine dispatches — the embedded posture.
467    ///
468    /// This gates ONLY the structural queue-service admission check: with
469    /// in-process serving declared, a package whose contract retains
470    /// unscoped (legacy-manifest) activities is admitted at start, because
471    /// no queue exists to be unserved — dispatch runs on the configured
472    /// dispatcher immediately, and a missing dispatcher fails loudly at
473    /// dispatch. The `.v4` identity floor is unconditional either way. The
474    /// server never declares this: its dispatcher routes to task queues, so
475    /// it keeps the fail-closed queue-routed default.
476    #[must_use]
477    pub const fn in_process_activity_serving(mut self) -> Self {
478        self.activity_serving = ActivityServing::InProcess;
479        self
480    }
481
482    /// Supply the active workflow registry used by the built engine.
483    ///
484    /// Server-owned dispatchers that run behind raw NIFs use this to correlate a
485    /// calling BEAM pid to the same workflow handle the engine registers.
486    #[must_use]
487    pub fn active_registry(mut self, registry: Arc<Registry>) -> Self {
488        self.active_registry = Some(registry);
489        self
490    }
491
492    /// Inspect the configured scheduler thread count.
493    #[must_use]
494    pub const fn scheduler_thread_count(&self) -> Option<usize> {
495        self.scheduler_threads
496    }
497
498    /// Inspect the configured JIT compilation threshold.
499    #[must_use]
500    pub const fn configured_jit_threshold(&self) -> Option<u32> {
501        self.scheduler_jit_threshold
502    }
503
504    /// Inspect the configured periodic visibility reconciliation interval.
505    #[must_use]
506    pub const fn configured_visibility_reconciliation_interval(&self) -> Option<Duration> {
507        self.visibility_reconciliation_interval
508    }
509
510    /// Assemble the runtime configuration from the builder-supplied scheduler,
511    /// signal delivery, completion-retry, and outbox knobs.
512    fn runtime_config(&self) -> RuntimeConfig {
513        RuntimeConfig::new(self.scheduler_threads)
514            .with_jit_threshold(self.scheduler_jit_threshold)
515            .with_signal_delivery(self.signal_delivery)
516            .with_completion_retry(self.completion_retry)
517            .with_outbox_enabled(self.outbox_enabled)
518    }
519
520    /// Start the runtime and install the engine plus host NIFs.
521    fn start_runtime_with_nifs(
522        runtime_config: RuntimeConfig,
523        host_nifs: Vec<NifEntry>,
524    ) -> Result<Arc<RuntimeHandle>, EngineError> {
525        let runtime = Arc::new(RuntimeHandle::new(runtime_config)?);
526        let mut nifs = NifRegistration::new();
527        nifs.add_engine_nifs().add_host_nifs(host_nifs);
528        runtime.install_nifs(nifs)?;
529        Ok(runtime)
530    }
531
532    /// Construct the live engine.
533    ///
534    /// # Errors
535    ///
536    /// Returns typed [`EngineError`] variants for missing store, runtime startup,
537    /// NIF registration, package loading, store reads, registry/supervision lock
538    /// poison, or deferred AD recovery failures for active histories.
539    pub async fn build(self) -> Result<Engine, EngineError> {
540        let runtime_config = self.runtime_config();
541        let (store, streaming_publisher) = wrap_event_streaming(
542            self.store.ok_or(EngineError::MissingStore)?,
543            self.event_streaming_capacity,
544            self.seam_overrides.event_publisher,
545        )?;
546        let visibility_store = self
547            .visibility_store
548            .ok_or(EngineError::MissingVisibilityStore)?;
549        claim_owned_shards(store.as_ref(), self.owned_shards.as_deref())?;
550
551        let runtime = Self::start_runtime_with_nifs(runtime_config, self.host_nifs)?;
552
553        // Persisted runtime deploys must be resident before startup recovery
554        // below resolves any run's recorded pinned version — this is the
555        // restart half of the deploy durability promise.
556        let catalog = assemble_startup_catalog(
557            runtime.as_ref(),
558            store.as_ref(),
559            self.workflow_sources,
560            self.activity_serving,
561        )
562        .await?;
563
564        let registry = self
565            .active_registry
566            .unwrap_or_else(|| Arc::new(Registry::default()));
567        let nif_state = Arc::clone(runtime.nif_state());
568        let query_mailbox_engine = install_engine_nif_seams(
569            &nif_state,
570            &registry,
571            &store,
572            &runtime,
573            self.activity_dispatcher,
574            self.query_timeout,
575        );
576        let supervision = Arc::new(SupervisionTree::new());
577        let search_attribute_schema = Arc::new(self.search_attribute_schema);
578        let signal_handoff = Arc::new(SignalResumeHandoff::new());
579
580        let delegated = assemble_delegated_seams(SeamAssembly {
581            configured: self.delegated,
582            signal_router_factory: self.signal_router_factory,
583            runtime: Arc::clone(&runtime),
584            signal_handoff: Arc::clone(&signal_handoff),
585            streaming_publisher,
586            query_mailbox_engine,
587            query_timeout: self.query_timeout,
588            query_service_overridden: self.seam_overrides.query_service,
589        });
590
591        // Startup recovery re-spawns active workflow processes, and those
592        // processes begin replaying on scheduler threads immediately. Replay
593        // re-executes workflow code through the engine NIFs, so every NIF
594        // bridge (signal, child) must be installed before the first recovered
595        // process can run, or an early replayed spawn_child/receive_signal
596        // call fails with a missing-bridge error.
597        install_workflow_nif_bridges(
598            &ChildBridgeAssembly {
599                nif_state: &nif_state,
600                store: &store,
601                visibility_store: &visibility_store,
602                runtime: &runtime,
603                catalog: &catalog,
604                registry: &registry,
605                supervision: &supervision,
606                signal_handoff: &signal_handoff,
607                search_attribute_schema: &search_attribute_schema,
608                watch_backoff: self.signal_delivery,
609            },
610            &delegated,
611        )?;
612
613        // A deferred build (#266) skips the recovery steps and stows what the
614        // host owes [`Engine::run_startup_recovery`]; see
615        // [`resolve_startup_recovery`].
616        let deferred_startup_recovery = resolve_startup_recovery(
617            self.defer_startup_recovery,
618            &nif_state,
619            StartupRecoveryContext {
620                store: Arc::clone(&store),
621                visibility_store: Arc::clone(&visibility_store),
622                runtime: Arc::clone(&runtime),
623                catalog: Arc::clone(&catalog),
624                registry: Arc::clone(&registry),
625                supervision: Arc::clone(&supervision),
626                recovery: self.recovery,
627                search_attribute_schema: Arc::clone(&search_attribute_schema),
628                bootstrap_schedule_coordinator: self.bootstrap_schedule_coordinator,
629            },
630        )
631        .await?;
632
633        let visibility_reconciliation_task = maybe_spawn_visibility_reconciliation(
634            self.visibility_reconciliation_interval,
635            &store,
636            &visibility_store,
637        );
638
639        let deferred = deferred_startup_recovery.is_some();
640        let engine = Engine::new(EngineComponents {
641            store,
642            visibility_store,
643            runtime,
644            catalog,
645            registry,
646            supervision,
647            delegated,
648            signal_handoff,
649            search_attribute_schema,
650            visibility_reconciliation_task,
651            deferred_startup_recovery,
652        });
653        if !deferred {
654            engine.catchup_schedule_coordinator().await?;
655            engine.recover_schedules_on_startup(Utc::now()).await?;
656        }
657        Ok(engine)
658    }
659}
660
661#[cfg(test)]
662mod tests {
663    // 🔴 #125. This was a hand-rolled twelve-line gate announcing its skip with
664    // `println!` — the exact pattern task #74 replaced, because the harness
665    // captures `println!` on a PASSING test and a gated skip always passes, so
666    // the announcement was invisible in precisely the case it existed for.
667    //
668    // #74's cure landed in `tests/test_support/gleam.rs` and is pinned across
669    // four byte-identical copies. This file is `src/`, so a sweep over `tests/`
670    // could never see it — and the correct copy was already sitting in THIS
671    // crate's own `tests/` directory. A fix's search space is part of the fix.
672    //
673    // Reusing the canonical file by path rather than copying it, per the
674    // precedent in `aion-package/src/structure/mod.rs`: a third copy would look
675    // right in isolation and drift silently. It is DECLARED in `engine/mod.rs`
676    // because `#[path]` resolves against the declaring module's directory and
677    // `src/engine/builder/` does not exist.
678    use super::super::gleam_test_support;
679
680    use std::{num::NonZeroUsize, path::PathBuf, process::Command, sync::Arc, time::Duration};
681
682    use aion_core::{Event, EventEnvelope, Payload, WorkflowId, WorkflowStatus};
683    use aion_package::{
684        BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, ExtractionLimits, Manifest,
685        ManifestVersion, Package, PackageBuilder,
686    };
687    use aion_store::visibility::{ListWorkflowsFilter, VisibilityStore};
688    use aion_store::{InMemoryStore, ReadableEventStore, WritableEventStore, WriteToken};
689    use chrono::Utc;
690    use futures::StreamExt;
691    use serde_json::json;
692
693    use crate::engine::api_schedule::{
694        schedule_coordinator_run_id, schedule_coordinator_workflow_id,
695        schedule_coordinator_workflow_type,
696    };
697    use crate::runtime::{Determinism, Mfa, NifEntry};
698
699    use super::EngineBuilder;
700    use crate::EngineError;
701
702    fn payload() -> Result<Payload, aion_core::PayloadError> {
703        Payload::from_json(&json!({ "input": true }))
704    }
705
706    fn started(
707        workflow_id: &WorkflowId,
708        workflow_type: &str,
709    ) -> Result<Event, aion_core::PayloadError> {
710        Ok(Event::WorkflowStarted {
711            envelope: EventEnvelope {
712                seq: 1,
713                recorded_at: Utc::now(),
714                workflow_id: workflow_id.clone(),
715            },
716            workflow_type: workflow_type.to_owned(),
717            input: payload()?,
718            run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
719            parent_run_id: None,
720            parent_workflow_id: None,
721            package_version: aion_core::PackageVersion::new("a".repeat(64)),
722        })
723    }
724
725    fn completed(workflow_id: &WorkflowId) -> Result<Event, aion_core::PayloadError> {
726        Ok(Event::WorkflowCompleted {
727            envelope: EventEnvelope {
728                seq: 2,
729                recorded_at: Utc::now(),
730                workflow_id: workflow_id.clone(),
731            },
732            result: payload()?,
733        })
734    }
735
736    fn package_manifest() -> Manifest {
737        Manifest {
738            entry_module: "counter".to_owned(),
739            entry_function: "version".to_owned(),
740            input_schema: json!({ "type": "object" }),
741            output_schema: json!({ "type": "integer" }),
742            timeout: Some(Duration::from_secs(30)),
743            activities: vec![DeclaredActivity {
744                activity_type: "activity/test".to_owned(),
745            }],
746            version: ManifestVersion::new("test"),
747            format_version: CURRENT_FORMAT_VERSION,
748            additional_workflows: Vec::new(),
749        }
750    }
751
752    fn compile_counter_beam() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
753        let temp_dir =
754            std::env::temp_dir().join(format!("aion-engine-builder-{}", uuid::Uuid::new_v4()));
755        std::fs::create_dir(&temp_dir)?;
756        let source_path = temp_dir.join("counter.erl");
757        let beam_path = temp_dir.join("counter.beam");
758        std::fs::write(
759            &source_path,
760            "-module(counter).\n-export([version/0]).\nversion() -> 1.\n",
761        )?;
762        let status = Command::new("erlc")
763            .arg("-o")
764            .arg(&temp_dir)
765            .arg(&source_path)
766            .status()?;
767        if !status.success() {
768            let cleanup_result = std::fs::remove_dir_all(&temp_dir);
769            drop(cleanup_result);
770            return Err(format!("erlc failed with status {status}").into());
771        }
772        let bytes = std::fs::read(beam_path)?;
773        std::fs::remove_dir_all(temp_dir)?;
774        Ok(bytes)
775    }
776
777    fn fixture_package() -> Result<Package, Box<dyn std::error::Error>> {
778        let beams = BeamSet::new(vec![BeamModule::new("counter", compile_counter_beam()?)])?;
779        let archive = PackageBuilder::new(package_manifest(), beams).write_to_bytes()?;
780        Ok(Package::load_from_bytes(
781            archive,
782            ExtractionLimits::unbounded(),
783        )?)
784    }
785
786    fn write_fixture_package(package: &Package) -> Result<PathBuf, Box<dyn std::error::Error>> {
787        let path =
788            std::env::temp_dir().join(format!("aion-engine-builder-{}.aion", uuid::Uuid::new_v4()));
789        PackageBuilder::new(package.manifest().clone(), package.beams().clone())
790            .write_to_path(&path)?;
791        Ok(path)
792    }
793
794    #[tokio::test]
795    async fn build_without_store_returns_missing_store() {
796        let error = EngineBuilder::new().build().await.err();
797
798        assert!(matches!(error, Some(EngineError::MissingStore)));
799    }
800
801    #[tokio::test]
802    async fn build_without_visibility_store_returns_missing_visibility_store() {
803        let error = EngineBuilder::new()
804            .store(InMemoryStore::default())
805            .build()
806            .await
807            .err();
808
809        assert!(matches!(error, Some(EngineError::MissingVisibilityStore)));
810    }
811
812    #[tokio::test]
813    async fn in_memory_visibility_allows_build_without_visibility_store() -> Result<(), EngineError>
814    {
815        let engine = EngineBuilder::new()
816            .store(InMemoryStore::default())
817            .in_memory_visibility()
818            .build()
819            .await?;
820
821        engine.shutdown()?;
822        Ok(())
823    }
824
825    /// 🔴 M-1. Releasing an engine WITHOUT shutting it down closes the epoch.
826    ///
827    /// `RuntimeHandle::shutdown` covers the explicit path. This covers the other
828    /// one, and it is not hypothetical: a completion retry appends terminal
829    /// events, so an engine dropped on an error path with a retry still armed
830    /// would keep writing to histories nobody believes this process still owns.
831    ///
832    /// The refcount backstop cannot serve here, which is the whole reason this
833    /// `Drop` exists. `EngineTaskRuntime::drop` fires only when the last strong
834    /// reference goes, and an in-flight attempt holds one for the entire attempt
835    /// — so at the instant the hazard is real, the backstop is pinned shut. This
836    /// gate is set by a `Drop` that runs regardless of who else holds a handle.
837    ///
838    /// The first assertion is the control: an engine that never opened the epoch
839    /// would satisfy the second trivially.
840    ///
841    /// 🔴 THE ENGINE IS BUILT WITH A RECONCILIATION INTERVAL, AND THAT IS NOT
842    /// INCIDENTAL. Its first form used a bare fixture with no interval, so no
843    /// reconciliation task was ever spawned — and a fixture that never starts
844    /// the thing under test cannot see whether releasing the engine stops it.
845    /// It could not: `Drop` did not abort `visibility_reconciliation_task`, and
846    /// dropping a `JoinHandle` DETACHES rather than cancels, so a released
847    /// engine left an unbounded loop holding both stores and calling
848    /// `reconcile_visibility`, which WRITES. Two properties are pinned here
849    /// because one `Drop` owns both: the epoch closes, and the writer stops.
850    ///
851    /// The writer half is measured by counting the loop's own ticks —
852    /// `reconcile_visibility` reads `list_workflows` unconditionally on every
853    /// pass — through a wrapper that delegates everything to a real store, so
854    /// what is observed is the production loop and not a stand-in for it. The
855    /// wait for two ticks before the drop is the positive control: it fails the
856    /// test if the loop was never running, which is the exact way the first
857    /// form of this test was vacuous.
858    #[tokio::test]
859    async fn dropping_an_engine_without_shutdown_closes_the_completion_retry_epoch()
860    -> Result<(), EngineError> {
861        let visibility = Arc::new(TickCountingVisibilityStore::default());
862        let engine = EngineBuilder::new()
863            .store(InMemoryStore::default())
864            .visibility_store_arc(Arc::clone(&visibility) as Arc<dyn VisibilityStore>)
865            .visibility_reconciliation_interval(Duration::from_millis(10))
866            .build()
867            .await?;
868        // Taken before the drop and held across it: reading through the engine
869        // afterwards is impossible, and reading a fresh handle would observe a
870        // different runtime.
871        let tasks = engine.runtime().engine_tasks();
872        assert!(
873            tasks.is_epoch_open(),
874            "control: a live engine's epoch must be open, or the assertion below is trivially \
875             satisfied"
876        );
877
878        // Positive control for the writer half: the loop must be observably
879        // running BEFORE the drop, or "it stopped" is a statement about a task
880        // that never started. Bounded so a stalled loop fails rather than hangs.
881        let ticking = tokio::time::timeout(Duration::from_secs(10), async {
882            while visibility.ticks() < 2 {
883                tokio::time::sleep(Duration::from_millis(5)).await;
884            }
885        })
886        .await;
887        assert!(
888            ticking.is_ok(),
889            "control: the periodic reconciliation loop must be running before the engine is \
890             dropped, or this test measures nothing; it reached {} ticks",
891            visibility.ticks()
892        );
893
894        drop(engine);
895
896        assert!(
897            !tasks.is_epoch_open(),
898            "an engine released without an explicit shutdown must still close the epoch, or a \
899             completion retry can append a terminal for a run this process no longer owns"
900        );
901
902        // `abort` is asynchronous: it schedules cancellation, so give the
903        // runtime a window several intervals wide to deliver it before reading
904        // the baseline. Anything after that baseline is a task still looping.
905        tokio::time::sleep(Duration::from_millis(200)).await;
906        let after_abort = visibility.ticks();
907        tokio::time::sleep(Duration::from_millis(200)).await;
908        assert_eq!(
909            visibility.ticks(),
910            after_abort,
911            "an engine released without an explicit shutdown must also stop the visibility \
912             reconciliation loop; it ticked again over twenty intervals after the drop, which \
913             means a detached task is still WRITING to a visibility store this process no longer \
914             owns"
915        );
916        Ok(())
917    }
918
919    /// How far ahead of "now" the shared wheel-timer fixture arms its deadline.
920    ///
921    /// 🔴 THIS IS A RACE MARGIN, NOT A TASTE. The release test has to reach
922    /// `drop(engine)` while the timer is still pending; if the deadline passes
923    /// first, the timer fires LEGITIMATELY and the test's own message blames
924    /// `Drop` for a surviving task that never survived — a red that reassigns
925    /// blame, which is worse than no test. The margin buys a window that the
926    /// two atomic reads and one drop between arming and release cannot
927    /// plausibly exhaust, and the release test asserts it was still inside that
928    /// window rather than assuming it.
929    const WHEEL_TIMER_ARMING_MARGIN: std::time::Duration = std::time::Duration::from_secs(2);
930
931    /// How far past the deadline both wheel-timer tests observe before reading
932    /// history — the fire path is asynchronous, so the deadline arriving is not
933    /// the same as the append having landed.
934    const WHEEL_TIMER_OBSERVATION_SLACK: std::time::Duration = std::time::Duration::from_secs(1);
935
936    /// A resident run with one live wheel timer, armed through the production
937    /// path, shared by the release test and its positive control. Returns the
938    /// workflow and the deadline it armed, because the release test has to
939    /// check it is still inside the arming window before it can attribute
940    /// anything to the release.
941    ///
942    /// 🔴 ONE FIXTURE, DELIBERATELY. The control's whole job is to show that
943    /// THIS arrangement's timer reaches the store; a second copy that drifted —
944    /// a different residency, a different timer name, a different deadline —
945    /// would control nothing while still reading as a control.
946    ///
947    /// The deadline has to arrive INSIDE the test. The hazard a released engine
948    /// leaves behind is a parked task that later wakes and records, so a
949    /// deadline beyond the test's own lifetime makes "nothing fired" true for a
950    /// reason that has nothing to do with the release. That is what
951    /// [`WHEEL_TIMER_ARMING_MARGIN`] trades against: near enough to observe,
952    /// far enough not to race the drop.
953    async fn arm_resident_run_with_wheel_timer(
954        engine: &crate::Engine,
955        store: &Arc<InMemoryStore>,
956    ) -> Result<(WorkflowId, chrono::DateTime<Utc>), Box<dyn std::error::Error>> {
957        use aion_store::EventStore;
958
959        use crate::durability::{Recorder, WorkflowStartRecord};
960        use crate::registry::{
961            CompletionNotifier, HandleResidency, WorkflowHandle, WorkflowHandleParts,
962        };
963
964        // The wheel is armed only for a workflow the registry resolves as
965        // `Resident`, and `TimerService::schedule` is the production path that
966        // arms it.
967        let workflow_id = WorkflowId::new_v4();
968        let run_id = aion_core::RunId::new_v4();
969        let timer_id = aion_core::TimerId::named("sleep")?;
970        let mut recorder = Recorder::new(
971            workflow_id.clone(),
972            Arc::clone(store) as Arc<dyn EventStore>,
973        );
974        recorder
975            .record_workflow_started(
976                Utc::now(),
977                WorkflowStartRecord {
978                    workflow_type: "checkout".to_owned(),
979                    input: payload()?,
980                    run_id: run_id.clone(),
981                    parent_run_id: None,
982                    parent_workflow_id: None,
983                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
984                },
985            )
986            .await?;
987        recorder
988            .record_timer_started(Utc::now(), timer_id.clone(), Utc::now())
989            .await?;
990        engine.registry().insert(
991            (workflow_id.clone(), run_id.clone()),
992            WorkflowHandle::new(WorkflowHandleParts {
993                workflow_id: workflow_id.clone(),
994                run_id,
995                pid: 1,
996                workflow_type: "checkout".to_owned(),
997                namespace: String::from("default"),
998                loaded_version: aion_package::ContentHash::from_bytes([9; 32]),
999                cached_status: WorkflowStatus::Running,
1000                residency: HandleResidency::Resident,
1001                recorder,
1002                completion: CompletionNotifier::new(),
1003            }),
1004        )?;
1005
1006        let fire_at = Utc::now() + chrono::Duration::from_std(WHEEL_TIMER_ARMING_MARGIN)?;
1007        crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1008            .map_err(|error| format!("the engine installed no timer service: {error}"))?
1009            .schedule(workflow_id.clone(), timer_id, fire_at)
1010            .await?;
1011        Ok((workflow_id, fire_at))
1012    }
1013
1014    /// 🔴 M-1's THIRD WRITER, AND
1015    /// `dropping_an_engine_without_shutdown_closes_the_completion_retry_epoch`
1016    /// IS BLIND TO IT.
1017    ///
1018    /// `Drop for Engine` disarms the live timer wheel as well as closing the
1019    /// engine-task epoch and aborting the reconciliation loop. That sibling's
1020    /// fixture never arms a wheel timer, so deleting `shutdown_timer_wheel` from
1021    /// `Drop` leaves every one of its assertions green — a fixture that never
1022    /// starts the thing under test cannot see whether releasing the engine stops
1023    /// it, which is the same defect that test's own comment records having had.
1024    ///
1025    /// The wheel is a DURABLE writer: a fired wheel timer records `TimerFired`
1026    /// through the run's recorder. An engine released with one still armed
1027    /// therefore appends to a history this process no longer owns — the #119
1028    /// failover race reached through a different door, because
1029    /// `RuntimeHandle::shutdown` stops the beamr scheduler while the armed tasks
1030    /// live on the tokio runtime and are not reached by it. On the release path
1031    /// there is no shutdown at all, so `Drop` is the only thing that can.
1032    ///
1033    /// The count is read through an `Arc<EngineNifState>` held ACROSS the drop,
1034    /// so the release is the only variable between the two readings. The
1035    /// pre-drop assertion is the control and it is not decoration: it is the
1036    /// exact vacuity this test exists to close.
1037    #[tokio::test]
1038    async fn dropping_an_engine_without_shutdown_disarms_the_live_timer_wheel()
1039    -> Result<(), Box<dyn std::error::Error>> {
1040        use aion_store::EventStore;
1041
1042        let store = Arc::new(InMemoryStore::default());
1043        let engine = EngineBuilder::new()
1044            .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1045            .visibility_store(InMemoryStore::default())
1046            .build()
1047            .await?;
1048        // Held across the drop; see `EngineNifState::armed_wheel_timers`.
1049        let nif_state = Arc::clone(engine.runtime().nif_state());
1050        let (workflow_id, fire_at) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1051        assert_eq!(
1052            nif_state.armed_wheel_timers(),
1053            1,
1054            "control: the fixture must have actually armed a wheel timer, or the assertions below \
1055             are satisfied by an engine that never had one — the exact way the sibling test above \
1056             is blind to this half of `Drop`"
1057        );
1058
1059        // 🔴 FIXTURE GUARD, NOT A CLAIM ABOUT `Drop`. Everything below reads a
1060        // timer that has NOT yet fired; if the deadline has already passed, a
1061        // `TimerFired` in history is the timer doing its job, and the decisive
1062        // assertion would report it as a task that survived the release. That is
1063        // a red which reassigns blame — the failure mode this guard exists to
1064        // make impossible to mistake. Both readings are taken before the drop,
1065        // so neither can be explained by it.
1066        let before_drop = Utc::now();
1067        assert!(
1068            before_drop < fire_at,
1069            "fixture: the arming window closed before the release was reached ({before_drop} is \
1070             not before {fire_at}). This is a FIXTURE timing failure — most likely a loaded box — \
1071             and NOT evidence about cancellation. Widen `WHEEL_TIMER_ARMING_MARGIN`; do not read \
1072             this as `Drop` leaking a task."
1073        );
1074        let armed = store.read_history(&workflow_id).await?;
1075        assert!(
1076            !armed
1077                .iter()
1078                .any(|event| matches!(event, Event::TimerFired { .. })),
1079            "fixture: the timer already fired before the engine was released, so this run cannot \
1080             say anything about cancellation. Same remedy as above — widen the margin: {armed:#?}"
1081        );
1082
1083        drop(engine);
1084
1085        assert_eq!(
1086            nif_state.armed_wheel_timers(),
1087            0,
1088            "an engine released without an explicit shutdown must empty its live timer wheel"
1089        );
1090
1091        // 🔴 AND THE TASK ITSELF IS GONE, WHICH THE COUNT ABOVE CANNOT SEE.
1092        // `shutdown_timer_wheel` both removes the entry and aborts the handle;
1093        // the count observes only the removal, so deleting the abort leaves it
1094        // green while an armed task lives on. This waits past the deadline and
1095        // reads the durable record, which is where a surviving task would show
1096        // up — and the sibling test
1097        // `a_live_wheel_timer_fires_when_the_engine_is_not_released` is its
1098        // positive control: it proves this same fixture's timer DOES reach the
1099        // store when nothing disarms it, so an empty reading here is a
1100        // cancellation rather than a timer that was never going to arrive.
1101        tokio::time::sleep(WHEEL_TIMER_ARMING_MARGIN + WHEEL_TIMER_OBSERVATION_SLACK).await;
1102        let history = store.read_history(&workflow_id).await?;
1103        assert!(
1104            !history
1105                .iter()
1106                .any(|event| matches!(event, Event::TimerFired { .. })),
1107            "an engine released without an explicit shutdown must CANCEL its wheel tasks, not \
1108             merely forget them: a surviving task records `TimerFired` for a run this process no \
1109             longer owns, which is a second writer for one workflow: {history:#?}"
1110        );
1111        Ok(())
1112    }
1113
1114    /// 🔴 AND A DRAIN IS NOT A GATE. The test above proves the wheel is EMPTIED
1115    /// by the release; this one proves it stays empty.
1116    ///
1117    /// `Drop for Engine` deliberately leaves the beamr scheduler running and the
1118    /// engine seams installed, so a workflow process still runnable can reach
1119    /// `sleep` a moment AFTER the drain and arm a fresh task — whose body is
1120    /// `fire_wheel_timer`, a durable `TimerFired` append against a run a
1121    /// successor engine may already own. That is the same second-writer breach
1122    /// the drain exists to prevent, reached one instant later, and until
1123    /// 2026-08-07 `arm_timer` had no check of any kind: it spawned
1124    /// unconditionally.
1125    ///
1126    /// The scheduling call is the PRODUCTION path, not a direct poke at the
1127    /// bridge, and it is made through an `Arc<EngineNifState>` held across the
1128    /// drop — the same instrument the sibling above uses, and available for the
1129    /// same reason (the drop does not clear the seams).
1130    ///
1131    /// Two controls, because refusal has two boring explanations. The first
1132    /// schedule succeeds BEFORE the drop, so the fixture demonstrably reaches
1133    /// the arming path at all; and the count is asserted `1` then `0` around the
1134    /// release, so the post-drop `Err` cannot be a fixture that was never able
1135    /// to arm anything.
1136    #[tokio::test]
1137    async fn arming_a_wheel_timer_after_the_engine_is_released_is_refused()
1138    -> Result<(), Box<dyn std::error::Error>> {
1139        use aion_store::EventStore;
1140
1141        let store = Arc::new(InMemoryStore::default());
1142        let engine = EngineBuilder::new()
1143            .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1144            .visibility_store(InMemoryStore::default())
1145            .build()
1146            .await?;
1147        let nif_state = Arc::clone(engine.runtime().nif_state());
1148        // CONTROL 1: this fixture can arm. The call below is the same
1149        // `TimerService::schedule` the post-drop attempt makes, and it succeeds.
1150        let (workflow_id, _fire_at) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1151        assert_eq!(
1152            nif_state.armed_wheel_timers(),
1153            1,
1154            "control: the fixture must have armed a wheel timer through the production path, or \
1155             the refusal below is satisfied by a fixture that could never arm one"
1156        );
1157
1158        drop(engine);
1159
1160        // CONTROL 2: the release emptied the wheel, so what follows is measured
1161        // against a torn-down wheel rather than a busy one.
1162        assert_eq!(nif_state.armed_wheel_timers(), 0);
1163
1164        let refused = crate::runtime::nif_timer_bridge::installed_timer_service(&nif_state)
1165            .map_err(|error| format!("the released engine's timer seam is gone: {error}"))?
1166            .schedule(
1167                workflow_id,
1168                aion_core::TimerId::named("sleep-after-release")?,
1169                Utc::now() + chrono::Duration::from_std(WHEEL_TIMER_ARMING_MARGIN)?,
1170            )
1171            .await;
1172        let Err(error) = refused else {
1173            return Err(
1174                "arming a wheel timer through a released engine must be REFUSED: the task \
1175                        it spawns appends a durable `TimerFired` for a run this process no longer \
1176                        owns, which is a second writer for one workflow"
1177                    .into(),
1178            );
1179        };
1180        assert!(
1181            error.to_string().contains("torn down"),
1182            "the refusal must say WHY, so an operator reading it is not sent looking for a \
1183             missing workflow or a bad timer id: {error}"
1184        );
1185        assert_eq!(
1186            nif_state.armed_wheel_timers(),
1187            0,
1188            "the refused arm must leave nothing behind: a wheel entry inserted before the refusal \
1189             would be a durable writer with no owner to cancel it"
1190        );
1191        Ok(())
1192    }
1193
1194    /// The positive control for the assertion above: this fixture's wheel timer
1195    /// really does reach the durable record when nothing disarms it.
1196    ///
1197    /// Without this, "no `TimerFired` after the drop" is equally well explained
1198    /// by a fixture whose timer could never fire at all — a registry entry the
1199    /// fire path rejects, a seam that was never installed, a deadline that never
1200    /// arrives. An absence is only evidence when its presence has been shown
1201    /// reachable by the same means.
1202    #[tokio::test]
1203    async fn a_live_wheel_timer_fires_when_the_engine_is_not_released()
1204    -> Result<(), Box<dyn std::error::Error>> {
1205        use aion_store::EventStore;
1206
1207        let store = Arc::new(InMemoryStore::default());
1208        let engine = EngineBuilder::new()
1209            .store_arc(Arc::clone(&store) as Arc<dyn EventStore>)
1210            .visibility_store(InMemoryStore::default())
1211            .build()
1212            .await?;
1213        let (workflow_id, _) = arm_resident_run_with_wheel_timer(&engine, &store).await?;
1214
1215        tokio::time::sleep(WHEEL_TIMER_ARMING_MARGIN + WHEEL_TIMER_OBSERVATION_SLACK).await;
1216
1217        let history = store.read_history(&workflow_id).await?;
1218        assert!(
1219            history
1220                .iter()
1221                .any(|event| matches!(event, Event::TimerFired { .. })),
1222            "the fixture's wheel timer must reach the store when the engine is still held, or \
1223             the sibling test's absence proves nothing: {history:#?}"
1224        );
1225        drop(engine);
1226        Ok(())
1227    }
1228
1229    /// A visibility store that counts the periodic reconciliation loop's passes.
1230    ///
1231    /// Delegates every method to a real [`InMemoryStore`] so the loop under
1232    /// observation does the same work it does in production; the counter is the
1233    /// only addition. `list_workflows` is the tick site because
1234    /// `reconcile_visibility` calls it unconditionally at the top of every pass,
1235    /// so the count cannot be lowered by the store happening to be consistent.
1236    #[derive(Default)]
1237    struct TickCountingVisibilityStore {
1238        inner: InMemoryStore,
1239        list_calls: std::sync::atomic::AtomicUsize,
1240    }
1241
1242    impl TickCountingVisibilityStore {
1243        fn ticks(&self) -> usize {
1244            self.list_calls.load(std::sync::atomic::Ordering::Acquire)
1245        }
1246    }
1247
1248    #[async_trait::async_trait]
1249    impl VisibilityStore for TickCountingVisibilityStore {
1250        async fn record_visibility(
1251            &self,
1252            record: aion_store::visibility::VisibilityRecord,
1253        ) -> Result<(), aion_store::StoreError> {
1254            self.inner.record_visibility(record).await
1255        }
1256
1257        async fn list_workflows(
1258            &self,
1259            filter: ListWorkflowsFilter,
1260        ) -> Result<Vec<aion_store::visibility::WorkflowSummary>, aion_store::StoreError> {
1261            self.list_calls
1262                .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
1263            self.inner.list_workflows(filter).await
1264        }
1265
1266        async fn count_workflows(
1267            &self,
1268            filter: ListWorkflowsFilter,
1269        ) -> Result<u64, aion_store::StoreError> {
1270            self.inner.count_workflows(filter).await
1271        }
1272    }
1273
1274    fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
1275        NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
1276    }
1277
1278    #[tokio::test]
1279    async fn event_streaming_delivers_recorder_appends_through_engine_subscribe()
1280    -> Result<(), Box<dyn std::error::Error>> {
1281        let engine = EngineBuilder::new()
1282            .store(InMemoryStore::default())
1283            .in_memory_visibility()
1284            .event_streaming(capacity(8)?)
1285            .build()
1286            .await?;
1287        let workflow_id = WorkflowId::new_v4();
1288        let mut subscription = engine.subscribe(crate::EventFilter {
1289            workflow_id: Some(workflow_id.clone()),
1290            run: None,
1291            family: None,
1292        });
1293
1294        // The production append path: a Recorder over the engine's store,
1295        // which `event_streaming` wrapped before any recorder existed.
1296        let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1297        recorder
1298            .record_workflow_started(
1299                Utc::now(),
1300                crate::durability::WorkflowStartRecord {
1301                    workflow_type: "checkout".to_owned(),
1302                    input: payload()?,
1303                    run_id: aion_core::RunId::new(uuid::Uuid::from_u128(7)),
1304                    parent_run_id: None,
1305                    parent_workflow_id: None,
1306                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
1307                },
1308            )
1309            .await?;
1310
1311        let item = tokio::time::timeout(Duration::from_secs(2), subscription.next())
1312            .await?
1313            .ok_or("subscription ended without delivering the appended event")?;
1314        let event = item?;
1315        assert_eq!(event.workflow_id(), &workflow_id);
1316        assert_eq!(event.seq(), 1);
1317        assert!(matches!(event, Event::WorkflowStarted { .. }));
1318        engine.shutdown()?;
1319        Ok(())
1320    }
1321
1322    #[tokio::test]
1323    async fn without_event_streaming_subscriptions_stay_on_deferred_empty_stream()
1324    -> Result<(), Box<dyn std::error::Error>> {
1325        let engine = EngineBuilder::new()
1326            .store(InMemoryStore::default())
1327            .in_memory_visibility()
1328            .build()
1329            .await?;
1330
1331        let mut subscription = engine.subscribe(crate::EventFilter::default());
1332        let item = tokio::time::timeout(Duration::from_secs(2), subscription.next()).await?;
1333
1334        assert!(item.is_none(), "deferred publisher streams must be empty");
1335        engine.shutdown()?;
1336        Ok(())
1337    }
1338
1339    #[tokio::test]
1340    async fn event_streaming_conflicts_with_explicit_event_publisher()
1341    -> Result<(), Box<dyn std::error::Error>> {
1342        let error = EngineBuilder::new()
1343            .store(InMemoryStore::default())
1344            .in_memory_visibility()
1345            .event_publisher(Arc::new(crate::DeferredEventPublisher))
1346            .event_streaming(capacity(8)?)
1347            .build()
1348            .await
1349            .err();
1350
1351        assert!(matches!(
1352            error,
1353            Some(EngineError::ConflictingEventPublisher)
1354        ));
1355        Ok(())
1356    }
1357
1358    #[test]
1359    fn query_timeout_is_only_set_by_caller() {
1360        assert_eq!(EngineBuilder::new().configured_query_timeout(), None);
1361        assert_eq!(
1362            EngineBuilder::new()
1363                .query_timeout(Duration::from_secs(3))
1364                .configured_query_timeout(),
1365            Some(Duration::from_secs(3))
1366        );
1367    }
1368
1369    async fn insert_running_workflow(
1370        engine: &crate::Engine,
1371    ) -> Result<(WorkflowId, aion_core::RunId), Box<dyn std::error::Error>> {
1372        let workflow_id = WorkflowId::new_v4();
1373        let run_id = aion_core::RunId::new_v4();
1374        let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
1375        recorder
1376            .record_workflow_started(
1377                Utc::now(),
1378                crate::durability::WorkflowStartRecord {
1379                    workflow_type: "checkout".to_owned(),
1380                    input: payload()?,
1381                    run_id: run_id.clone(),
1382                    parent_run_id: None,
1383                    parent_workflow_id: None,
1384                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
1385                },
1386            )
1387            .await?;
1388        let handle = crate::registry::WorkflowHandle::new(crate::registry::WorkflowHandleParts {
1389            workflow_id: workflow_id.clone(),
1390            run_id: run_id.clone(),
1391            pid: engine.runtime().spawn_test_process_with_trap_exit(true)?,
1392            workflow_type: "checkout".to_owned(),
1393            namespace: String::from("default"),
1394            loaded_version: aion_package::ContentHash::from_bytes([2; 32]),
1395            cached_status: WorkflowStatus::Running,
1396            residency: crate::registry::HandleResidency::Resident,
1397            recorder,
1398            completion: crate::registry::CompletionNotifier::new(),
1399        });
1400        engine
1401            .registry()
1402            .insert((workflow_id.clone(), run_id.clone()), handle)?;
1403        Ok((workflow_id, run_id))
1404    }
1405
1406    #[tokio::test]
1407    async fn query_timeout_installs_the_concrete_query_seam()
1408    -> Result<(), Box<dyn std::error::Error>> {
1409        let engine = EngineBuilder::new()
1410            .store(InMemoryStore::default())
1411            .in_memory_visibility()
1412            .query_timeout(Duration::from_millis(250))
1413            .build()
1414            .await?;
1415        let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1416
1417        // The concrete seam reached the query mailbox engine, which answers
1418        // an unregistered name with a typed UnknownQuery — the deferred seam
1419        // would have failed with its "not configured" runtime error instead.
1420        let result = engine
1421            .query(
1422                &workflow_id,
1423                &run_id,
1424                "state",
1425                aion_core::Payload::json_null(),
1426            )
1427            .await;
1428
1429        assert!(matches!(
1430            result,
1431            Err(crate::EngineError::Query(crate::QueryError::UnknownQuery(name))) if name == "state"
1432        ));
1433        engine.shutdown()?;
1434        Ok(())
1435    }
1436
1437    #[tokio::test]
1438    async fn without_query_timeout_the_query_seam_stays_deferred()
1439    -> Result<(), Box<dyn std::error::Error>> {
1440        let engine = EngineBuilder::new()
1441            .store(InMemoryStore::default())
1442            .in_memory_visibility()
1443            .build()
1444            .await?;
1445        let (workflow_id, run_id) = insert_running_workflow(&engine).await?;
1446
1447        let result = engine
1448            .query(
1449                &workflow_id,
1450                &run_id,
1451                "state",
1452                aion_core::Payload::json_null(),
1453            )
1454            .await;
1455
1456        assert!(matches!(
1457            result,
1458            Err(crate::EngineError::Runtime { reason }) if reason.contains("not configured")
1459        ));
1460        engine.shutdown()?;
1461        Ok(())
1462    }
1463
1464    #[test]
1465    fn owned_shards_are_only_set_by_caller() {
1466        // The default builder configures NO shard restriction: `build()` never
1467        // touches the store's scoping hook, so single-node boot owns ALL shards
1468        // and is byte-identical to today.
1469        assert_eq!(EngineBuilder::new().configured_owned_shards(), None);
1470        assert_eq!(
1471            EngineBuilder::new()
1472                .owned_shards([2, 0, 2, 1])
1473                .configured_owned_shards(),
1474            Some([2, 0, 2, 1].as_slice())
1475        );
1476    }
1477
1478    #[test]
1479    fn scheduler_threads_are_only_set_by_caller() {
1480        assert_eq!(EngineBuilder::new().scheduler_thread_count(), None);
1481        assert_eq!(
1482            EngineBuilder::new()
1483                .scheduler_threads(4)
1484                .scheduler_thread_count(),
1485            Some(4)
1486        );
1487    }
1488
1489    /// The completion-retry ladder the caller set is the ladder the runtime is
1490    /// started with.
1491    ///
1492    /// Asserting on `runtime_config()` rather than on a builder accessor is the
1493    /// whole point: an accessor test would pass with the
1494    /// `.with_completion_retry(self.completion_retry)` line deleted, because the
1495    /// field would still hold what the setter put there while the runtime
1496    /// silently ran on the inherited default. This is the only place that link
1497    /// is held.
1498    ///
1499    /// The first assertion is the test's own control. It fixes that the chosen
1500    /// ladder differs from the default, so the equality below cannot be
1501    /// satisfied by a runtime configuration that ignored the builder entirely.
1502    #[test]
1503    fn the_completion_retry_ladder_reaches_the_runtime_configuration()
1504    -> Result<(), Box<dyn std::error::Error>> {
1505        let chosen = crate::runtime::CompletionRetryConfig::try_new(
1506            Duration::from_millis(250),
1507            Duration::from_secs(7),
1508        )?;
1509        assert_ne!(
1510            chosen,
1511            crate::runtime::CompletionRetryConfig::default(),
1512            "a ladder equal to the default could not distinguish wiring from inheritance"
1513        );
1514
1515        assert_eq!(
1516            EngineBuilder::new()
1517                .completion_retry(chosen)
1518                .runtime_config()
1519                .completion_retry,
1520            chosen,
1521            "the runtime must be started with the operator's ladder, not the inherited default"
1522        );
1523        Ok(())
1524    }
1525
1526    /// The JIT threshold the caller set is the threshold the runtime is started
1527    /// with.
1528    ///
1529    /// Asserting on `runtime_config()` rather than on
1530    /// `configured_jit_threshold()` is the whole point, for the reason given on
1531    /// the completion-retry test above: an accessor test stays green with the
1532    /// `.with_jit_threshold(self.scheduler_jit_threshold)` line deleted, because
1533    /// the builder field would still hold what the setter put there while the
1534    /// scheduler silently ran on beamr's inherited default. This is the only
1535    /// place that link is held.
1536    ///
1537    /// That distinction is not academic here. The threshold's whole purpose is
1538    /// to *move the moment of compilation* so a compile-correlated fault can be
1539    /// shown to track it. A silently-ignored threshold would not fail loudly —
1540    /// it would produce a run that looks like a measurement and measured the
1541    /// default, which is the shape of every instrument that reports a
1542    /// comfortable number without touching what it claims to touch.
1543    ///
1544    /// The first assertion is the control: it fixes that the unset builder does
1545    /// **not** name a threshold, so the equality below cannot be satisfied by a
1546    /// configuration that ignored the builder and happened to match.
1547    #[test]
1548    fn the_jit_threshold_reaches_the_runtime_configuration() {
1549        assert_eq!(
1550            EngineBuilder::new().runtime_config().jit_threshold,
1551            None,
1552            "an unset builder must pass None through, so beamr applies its own default rather \
1553             than a value aion invented"
1554        );
1555
1556        assert_eq!(
1557            EngineBuilder::new()
1558                .scheduler_jit_threshold(100)
1559                .runtime_config()
1560                .jit_threshold,
1561            Some(100),
1562            "the scheduler must be started with the operator's threshold, not beamr's default — \
1563             otherwise a proportionality run reports on the default while claiming to vary it"
1564        );
1565    }
1566
1567    #[test]
1568    fn visibility_reconciliation_interval_is_only_set_by_caller() {
1569        let interval = Duration::from_millis(250);
1570
1571        assert_eq!(
1572            EngineBuilder::new().configured_visibility_reconciliation_interval(),
1573            None
1574        );
1575        assert_eq!(
1576            EngineBuilder::new()
1577                .visibility_reconciliation_interval(interval)
1578                .configured_visibility_reconciliation_interval(),
1579            Some(interval)
1580        );
1581    }
1582
1583    #[tokio::test]
1584    async fn duplicate_host_nif_mfa_returns_typed_error() {
1585        let mfa = Mfa::new("host", "zero", 0);
1586        let error = EngineBuilder::new()
1587            .store(InMemoryStore::default())
1588            .in_memory_visibility()
1589            .register_nifs([
1590                NifEntry::new(
1591                    mfa.clone(),
1592                    crate::runtime::nif::test_native_zero,
1593                    Determinism::Pure,
1594                ),
1595                NifEntry::dirty(
1596                    mfa,
1597                    crate::runtime::nif::test_native_zero,
1598                    Determinism::Pure,
1599                ),
1600            ])
1601            .build()
1602            .await
1603            .err();
1604
1605        assert!(matches!(
1606            error,
1607            Some(EngineError::NifRegistration { reason }) if reason.contains("host:zero/0")
1608        ));
1609    }
1610
1611    #[tokio::test]
1612    async fn empty_store_builds_coordinator_history_without_registry_or_supervision()
1613    -> Result<(), EngineError> {
1614        let store = Arc::new(InMemoryStore::default());
1615        let engine = EngineBuilder::new()
1616            .store_arc(store.clone())
1617            .in_memory_visibility()
1618            .build()
1619            .await?;
1620
1621        assert!(engine.registry().list()?.is_empty());
1622        assert_eq!(engine.supervision().type_supervisor_count()?, 1);
1623        assert_eq!(engine.workflow_catalog().workflows()?.len(), 0);
1624
1625        let coordinator_id = schedule_coordinator_workflow_id();
1626        let active = store.list_active().await?;
1627        assert_eq!(active, vec![coordinator_id.clone()]);
1628        let history = store.read_history(&coordinator_id).await?;
1629        let [started] = history.as_slice() else {
1630            return Err(EngineError::Load {
1631                reason: format!(
1632                    "expected exactly one coordinator event, found {}",
1633                    history.len()
1634                ),
1635            });
1636        };
1637        match started {
1638            Event::WorkflowStarted {
1639                workflow_type,
1640                input,
1641                run_id,
1642                parent_run_id,
1643                ..
1644            } => {
1645                assert_eq!(workflow_type, schedule_coordinator_workflow_type());
1646                assert_eq!(
1647                    input,
1648                    &Payload::from_json(&json!({})).map_err(|error| {
1649                        EngineError::Load {
1650                            reason: format!("failed to build expected payload: {error}"),
1651                        }
1652                    })?
1653                );
1654                assert_eq!(run_id, &schedule_coordinator_run_id());
1655                assert!(parent_run_id.is_none());
1656            }
1657            other => {
1658                return Err(EngineError::Load {
1659                    reason: format!("expected coordinator WorkflowStarted, found {other:?}"),
1660                });
1661            }
1662        }
1663
1664        engine.shutdown()?;
1665        let rebuilt = EngineBuilder::new()
1666            .store_arc(store.clone())
1667            .in_memory_visibility()
1668            .build()
1669            .await?;
1670        let rebuilt_history = store.read_history(&coordinator_id).await?;
1671        assert_eq!(rebuilt_history.len(), 1);
1672        rebuilt.shutdown()?;
1673
1674        Ok(())
1675    }
1676
1677    #[tokio::test]
1678    async fn build_loads_already_loaded_package() -> Result<(), Box<dyn std::error::Error>> {
1679        if gleam_test_support::skip_if_unavailable() {
1680            return Ok(());
1681        }
1682        let package = fixture_package()?;
1683        let version = package.content_hash().clone();
1684        let deployed_entry_module = package.deployed_entry_module();
1685
1686        let engine = EngineBuilder::new()
1687            .store(InMemoryStore::default())
1688            .in_memory_visibility()
1689            .load_workflows(package)
1690            .build()
1691            .await?;
1692
1693        let loaded = engine
1694            .workflow_catalog()
1695            .get("counter", &version)?
1696            .ok_or("loaded package record missing")?;
1697        assert_eq!(loaded.deployed_entry_module(), deployed_entry_module);
1698        assert!(
1699            engine
1700                .runtime()
1701                .has_registered_module(&deployed_entry_module)
1702        );
1703        Ok(())
1704    }
1705
1706    #[tokio::test]
1707    async fn startup_reconciliation_backfills_completed_visibility()
1708    -> Result<(), Box<dyn std::error::Error>> {
1709        let store = Arc::new(InMemoryStore::default());
1710        let completed_id = WorkflowId::new_v4();
1711
1712        store
1713            .append(
1714                WriteToken::recorder(),
1715                &completed_id,
1716                &[
1717                    started(&completed_id, "billing")?,
1718                    completed(&completed_id)?,
1719                ],
1720                0,
1721            )
1722            .await?;
1723
1724        let engine = EngineBuilder::new()
1725            .store_arc(store.clone())
1726            .visibility_store_arc(store.clone())
1727            .build()
1728            .await?;
1729
1730        let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1731        let completed_summary = summaries
1732            .iter()
1733            .find(|summary| summary.workflow_id == completed_id)
1734            .ok_or("completed workflow missing from visibility")?;
1735
1736        assert_eq!(completed_summary.status, WorkflowStatus::Completed);
1737        assert!(completed_summary.close_time.is_some());
1738        engine.shutdown()?;
1739        Ok(())
1740    }
1741
1742    #[tokio::test]
1743    async fn periodic_visibility_reconciliation_repairs_gap_after_startup()
1744    -> Result<(), Box<dyn std::error::Error>> {
1745        let store = Arc::new(InMemoryStore::default());
1746        let engine = EngineBuilder::new()
1747            .store_arc(store.clone())
1748            .visibility_store_arc(store.clone())
1749            .visibility_reconciliation_interval(Duration::from_millis(25))
1750            .build()
1751            .await?;
1752        let workflow_id = WorkflowId::new_v4();
1753
1754        store
1755            .append(
1756                WriteToken::recorder(),
1757                &workflow_id,
1758                &[started(&workflow_id, "checkout")?],
1759                0,
1760            )
1761            .await?;
1762
1763        tokio::time::timeout(Duration::from_secs(2), async {
1764            loop {
1765                let summaries = store.list_workflows(ListWorkflowsFilter::default()).await?;
1766                if summaries.iter().any(|summary| {
1767                    summary.workflow_id == workflow_id && summary.status == WorkflowStatus::Running
1768                }) {
1769                    return Ok::<(), aion_store::StoreError>(());
1770                }
1771                tokio::time::sleep(Duration::from_millis(10)).await;
1772            }
1773        })
1774        .await??;
1775
1776        engine.shutdown()?;
1777        Ok(())
1778    }
1779
1780    #[tokio::test]
1781    async fn build_loads_package_from_path() -> Result<(), Box<dyn std::error::Error>> {
1782        if gleam_test_support::skip_if_unavailable() {
1783            return Ok(());
1784        }
1785        let package = fixture_package()?;
1786        let version = package.content_hash().clone();
1787        let path = write_fixture_package(&package)?;
1788
1789        let engine = EngineBuilder::new()
1790            .store(InMemoryStore::default())
1791            .in_memory_visibility()
1792            .load_workflows(path.as_path())
1793            .build()
1794            .await?;
1795        std::fs::remove_file(path)?;
1796
1797        assert!(
1798            engine
1799                .workflow_catalog()
1800                .get("counter", &version)?
1801                .is_some()
1802        );
1803        Ok(())
1804    }
1805}