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