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