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