Skip to main content

aion/engine/
api.rs

1//! `Engine` start, cancel, result, list, and shutdown support.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use aion_core::{
7    Event, Payload, RunId, SearchAttributeSchema, SearchAttributeValue, TimerCancelCause,
8    WorkflowError, WorkflowFilter, WorkflowId, WorkflowSummary,
9};
10use tokio::sync::Mutex as AsyncMutex;
11use tokio::task::JoinHandle;
12
13use crate::durability::Recorder;
14use crate::schedule::ScheduleEvaluator;
15use aion_store::EventStore;
16use aion_store::visibility::VisibilityStore;
17
18use crate::lifecycle::continue_as_new::{self, ContinueAsNewContext, ContinueAsNewRequest};
19use crate::lifecycle::reopen::{self, ReopenWorkflowContext};
20use crate::lifecycle::start::{self, StartWorkflowContext};
21use crate::lifecycle::terminate::{self, TerminateWorkflowContext};
22use crate::lifecycle::transition;
23use crate::registry::{TerminalOutcome, WorkflowHandle};
24use crate::{
25    EngineError, Registry, RuntimeHandle, SupervisionTree, WorkflowCatalog,
26    signal::SignalResumeHandoff,
27};
28
29use super::api_schedule::{
30    ScheduleRuntimeDeps, default_schedule_evaluator, schedule_coordinator_workflow_id,
31};
32use super::delegated::DelegatedSeams;
33use super::shutdown_gate::ShutdownGate;
34use crate::time::timer_service::live_timers_in_active_segment;
35
36/// Live embedded workflow engine assembled by [`crate::EngineBuilder`].
37pub struct Engine {
38    store: Arc<dyn EventStore>,
39    visibility_store: Arc<dyn VisibilityStore>,
40    pub(super) schedule_recorder: Arc<AsyncMutex<Recorder>>,
41    pub(super) schedule_evaluator: Arc<AsyncMutex<ScheduleEvaluator>>,
42    pub(super) schedule_coordinator_workflow_id: WorkflowId,
43    runtime: Arc<RuntimeHandle>,
44    catalog: Arc<WorkflowCatalog>,
45    registry: Arc<Registry>,
46    supervision: Arc<SupervisionTree>,
47    delegated: DelegatedSeams,
48    signal_handoff: Arc<SignalResumeHandoff>,
49    search_attribute_schema: Arc<SearchAttributeSchema>,
50    pub(super) shutdown_gate: ShutdownGate,
51    /// Serializes the deploy mutations (load / route / unload) end-to-end
52    /// across BOTH the catalog commit and its store persistence write, so
53    /// the persisted package set and route pointers can never disagree with
54    /// the catalog through interleaving (for example a concurrent re-deploy
55    /// re-persisting a version an unload just deleted). Workflow dispatch
56    /// never takes this lock.
57    pub(super) deploy_mutations: AsyncMutex<()>,
58    visibility_reconciliation_task: Option<JoinHandle<()>>,
59    /// Shared dispatch-hold set for durable pause (#204): the workflow ids whose
60    /// outbox rows are held `Pending` while paused. Mutated by pause/resume/cancel
61    /// and rebuilt from [`EventStore::list_paused`] at startup/adoption; read by
62    /// the outbox dispatcher at claim time.
63    paused_runs: crate::lifecycle::PausedRuns,
64}
65
66/// Components required to construct an [`Engine`].
67pub(crate) struct EngineComponents {
68    pub(crate) store: Arc<dyn EventStore>,
69    pub(crate) visibility_store: Arc<dyn VisibilityStore>,
70    pub(crate) runtime: Arc<RuntimeHandle>,
71    pub(crate) catalog: Arc<WorkflowCatalog>,
72    pub(crate) registry: Arc<Registry>,
73    pub(crate) supervision: Arc<SupervisionTree>,
74    pub(crate) delegated: DelegatedSeams,
75    pub(crate) signal_handoff: Arc<SignalResumeHandoff>,
76    pub(crate) search_attribute_schema: Arc<SearchAttributeSchema>,
77    pub(crate) visibility_reconciliation_task: Option<JoinHandle<()>>,
78}
79
80impl Engine {
81    /// Construct an engine from already-assembled components.
82    #[must_use]
83    pub(crate) fn new(components: EngineComponents) -> Self {
84        let EngineComponents {
85            store,
86            visibility_store,
87            runtime,
88            catalog,
89            registry,
90            supervision,
91            delegated,
92            signal_handoff,
93            search_attribute_schema,
94            visibility_reconciliation_task,
95        } = components;
96        let schedule_coordinator_workflow_id = schedule_coordinator_workflow_id();
97        let schedule_recorder = Arc::new(AsyncMutex::new(Recorder::new(
98            schedule_coordinator_workflow_id.clone(),
99            Arc::clone(&store),
100        )));
101        let runtime_arc = runtime;
102        let registry_arc = registry;
103        let supervision_arc = supervision;
104        let schedule_evaluator = Arc::new(AsyncMutex::new(default_schedule_evaluator(
105            schedule_coordinator_workflow_id.clone(),
106            Arc::clone(&schedule_recorder),
107            ScheduleRuntimeDeps {
108                store: Arc::clone(&store),
109                visibility_store: Arc::clone(&visibility_store),
110                runtime: Arc::clone(&runtime_arc),
111                catalog: Arc::clone(&catalog),
112                registry: Arc::clone(&registry_arc),
113                supervision: Arc::clone(&supervision_arc),
114                search_attribute_schema: Arc::clone(&search_attribute_schema),
115            },
116        )));
117        Self {
118            store,
119            visibility_store,
120            schedule_recorder,
121            schedule_evaluator,
122            schedule_coordinator_workflow_id,
123            runtime: runtime_arc,
124            catalog,
125            registry: registry_arc,
126            supervision: supervision_arc,
127            delegated,
128            signal_handoff,
129            search_attribute_schema,
130            shutdown_gate: ShutdownGate::default(),
131            deploy_mutations: AsyncMutex::new(()),
132            visibility_reconciliation_task,
133            paused_runs: crate::lifecycle::PausedRuns::default(),
134        }
135    }
136
137    /// Advance the schedule coordinator's recorder head to match persisted
138    /// events so that a rebuilt engine resumes appending at the correct
139    /// sequence rather than conflicting at head 0.
140    ///
141    /// # Errors
142    ///
143    /// Returns store read errors.
144    pub(crate) async fn catchup_schedule_coordinator(&self) -> Result<(), EngineError> {
145        let history = self
146            .store
147            .read_history(&self.schedule_coordinator_workflow_id)
148            .await?;
149        let head = u64::try_from(history.len()).unwrap_or(u64::MAX);
150        if head > 0 {
151            let mut recorder = self.schedule_recorder.lock().await;
152            *recorder = Recorder::resume_at(
153                self.schedule_coordinator_workflow_id.clone(),
154                Arc::clone(&self.store),
155                head,
156            );
157        }
158        Ok(())
159    }
160
161    /// Event store used by lifecycle and delegated AD/AT operations.
162    #[must_use]
163    pub fn store(&self) -> Arc<dyn EventStore> {
164        Arc::clone(&self.store)
165    }
166
167    /// Visibility store used for workflow summary projections.
168    #[must_use]
169    pub fn visibility_store(&self) -> Arc<dyn VisibilityStore> {
170        Arc::clone(&self.visibility_store)
171    }
172
173    /// Runtime boundary assembled for this engine.
174    #[must_use]
175    pub fn runtime(&self) -> &RuntimeHandle {
176        &self.runtime
177    }
178
179    /// Shared workflow package catalog: loaded versions and routing.
180    #[must_use]
181    pub fn workflow_catalog(&self) -> &Arc<WorkflowCatalog> {
182        &self.catalog
183    }
184
185    /// Active execution registry.
186    #[must_use]
187    pub fn registry(&self) -> &Registry {
188        &self.registry
189    }
190
191    /// Supervision tree snapshot/model.
192    #[must_use]
193    pub fn supervision(&self) -> &SupervisionTree {
194        &self.supervision
195    }
196
197    /// Delegated signal/query/subscribe seams installed for AT/AD integration.
198    #[must_use]
199    pub const fn delegated(&self) -> &DelegatedSeams {
200        &self.delegated
201    }
202
203    /// Shared in-memory handoff for already-recorded non-resident signals.
204    #[must_use]
205    pub fn signal_handoff(&self) -> Arc<SignalResumeHandoff> {
206        Arc::clone(&self.signal_handoff)
207    }
208
209    /// Start a loaded workflow type as a new BEAM process.
210    ///
211    /// `search_attributes` are validated against the engine's configured
212    /// [`SearchAttributeSchema`] and recorded atomically with the
213    /// `WorkflowStarted` event, so visibility metadata can never be lost to a
214    /// crash between start and a later attribute update.
215    ///
216    /// # Errors
217    ///
218    /// Returns [`EngineError::ShuttingDown`] after shutdown begins, and
219    /// [`EngineError::Durability`] when a search attribute is unregistered or
220    /// mistyped (nothing is appended and no process is spawned). Otherwise
221    /// delegates to the start lifecycle transition and returns its typed errors.
222    pub async fn start_workflow(
223        &self,
224        workflow_type: &str,
225        input: Payload,
226        search_attributes: HashMap<String, SearchAttributeValue>,
227        namespace: String,
228    ) -> Result<WorkflowHandle, EngineError> {
229        self.start_workflow_with_id(
230            workflow_type,
231            input,
232            search_attributes,
233            namespace,
234            None,
235            None,
236        )
237        .await
238    }
239
240    /// Start a loaded workflow type, optionally with a caller-chosen
241    /// `workflow_id` and/or R-4 steered-start `routing_key`.
242    ///
243    /// The request-routing edge supplies `workflow_id` to *place* a new start on
244    /// a shard this node owns: the R-1 unsteered-start remint (any locally-owned
245    /// shard) or, for a steered start, an id the edge derived on the
246    /// `routing_key`'s shard before deciding to run locally. So a `start` whose
247    /// id would otherwise hash to a non-owned shard never fences. When
248    /// `workflow_id` is `None` this is identical to [`Self::start_workflow`]: the
249    /// lifecycle mints a fresh `WorkflowId`, so the default single-node path is
250    /// unchanged.
251    ///
252    /// `routing_key` is the caller-chosen steered-start key recorded on the start
253    /// options. Shard derivation for the cluster path is performed at the edge
254    /// (which holds the concrete cluster store); here it is threaded through for
255    /// API completeness and direct callers.
256    ///
257    /// # Errors
258    ///
259    /// Identical to [`Self::start_workflow`]. A supplied `workflow_id` is treated
260    /// as a fresh execution; the caller is responsible for choosing an unused id.
261    pub async fn start_workflow_with_id(
262        &self,
263        workflow_type: &str,
264        input: Payload,
265        search_attributes: HashMap<String, SearchAttributeValue>,
266        namespace: String,
267        workflow_id: Option<WorkflowId>,
268        routing_key: Option<String>,
269    ) -> Result<WorkflowHandle, EngineError> {
270        let operation = self.shutdown_gate.begin_start()?;
271        let result = start::start_workflow_with_options(
272            StartWorkflowContext {
273                store: self.store(),
274                visibility_store: self.visibility_store(),
275                catalog: Arc::clone(&self.catalog),
276                runtime: Arc::clone(&self.runtime),
277                supervision: Arc::clone(&self.supervision),
278                registry: Arc::clone(&self.registry),
279                signal_handoff: Some(self.signal_handoff()),
280                search_attribute_schema: Arc::clone(&self.search_attribute_schema),
281                monitor_tokio_handle: tokio::runtime::Handle::current(),
282            },
283            workflow_type,
284            input,
285            start::StartWorkflowOptions {
286                namespace: Some(namespace),
287                search_attributes,
288                workflow_id,
289                routing_key,
290                ..start::StartWorkflowOptions::default()
291            },
292        )
293        .await;
294        drop(operation);
295        result
296    }
297
298    /// Absorb a dead peer's distribution shards into this LIVE engine and resume
299    /// their orphaned workflows — the SS-5 failover entry point.
300    ///
301    /// This is the production failover step a cluster supervisor invokes when it
302    /// observes a peer gone (membership loss). It is the post-boot counterpart to
303    /// the boot path's `EngineBuilder::owned_shards` election + recovery, run
304    /// against an already-running engine:
305    ///
306    /// 1. **Elect + union-merge.** `acquire_owned_shards` wins the per-shard
307    ///    election for each `shards` entry (fencing the dead owner) and
308    ///    `become_live` union-merges that shard's committed history locally, so
309    ///    every event the dead node had quorum-committed is now present on this
310    ///    node. The election is blocking and runs off the tokio runtime inside the
311    ///    store seam, honouring haematite's no-blocking-election-in-async
312    ///    constraint, so this `async` method may call it directly.
313    /// 2. **Widen the scope.** `extend_owned_shards` unions `shards` into this
314    ///    node's owned-enumeration set so the adopted workflows, timers, and
315    ///    outbox rows become visible to enumeration WITHOUT dropping this node's
316    ///    own shards.
317    /// 3. **Publish ownership.** `publish_shard_owner` records this node as each
318    ///    adopted shard's current owner in the cluster's quorum-replicated
319    ///    shard-owner directory (SS-3), so a request reaching a DIFFERENT survivor
320    ///    routes to this adopter rather than mis-resolving to the dead declared
321    ///    owner. The publish is fenced by the election just won, so only the true
322    ///    adopter writes it; a non-distributed store no-ops it.
323    /// 4. **Re-resident.** Re-run the idempotent active-workflow recovery and
324    ///    timer recovery, which re-spawn every adopted workflow from the
325    ///    union-merged history through the same production recovery seam the boot
326    ///    path uses, skipping the workflows this node already owns.
327    ///
328    /// Detection of the peer's death is the CALLER's responsibility (a cluster
329    /// supervisor / membership-loss trigger); this method performs the
330    /// re-acquisition and resume once that decision is made. It is idempotent:
331    /// adopting a shard this node already serves re-acquires (a no-op on the
332    /// fence it already holds) and recovers nothing new.
333    ///
334    /// # Errors
335    ///
336    /// Returns [`EngineError::ShuttingDown`] after shutdown begins, store errors
337    /// from the election / union-merge ([`EngineError::Durability`]), and any
338    /// typed recovery error from re-residenting an adopted workflow.
339    pub async fn adopt_shards(&self, shards: &[usize]) -> Result<(), EngineError> {
340        let operation = self.shutdown_gate.begin_start()?;
341        let result = self.adopt_shards_inner(shards).await;
342        drop(operation);
343        result
344    }
345
346    /// Body of [`Self::adopt_shards`]: acquire+publish each shard as a UNIT under
347    /// the double-adoption fence (ADR-021 clean-partial), then widen scope and
348    /// recover over EXACTLY the shards that survived BOTH steps.
349    ///
350    /// ## Ordering invariant (the fix)
351    ///
352    /// For each shard the publish-fence happens BEFORE the shard contributes to
353    /// `extend_owned_shards` AND before it is recovered. The pre-fix order
354    /// (extend → publish) let a survivor that won the election but was then
355    /// deposed at publish-time still widen its scope and recover the shard, so two
356    /// survivors could both execute its workflows. Here, a `NotOwner` from EITHER
357    /// `acquire_owned_shard` OR `publish_shard_owner` DROPS that shard: it never
358    /// reaches `extend_owned_shards`, is never recovered, and is NEVER a hard
359    /// `Durability` error. A deposed survivor therefore leaves ZERO widened
360    /// owned-shards scope and recovers nothing.
361    async fn adopt_shards_inner(&self, shards: &[usize]) -> Result<(), EngineError> {
362        // 1-3. Drive the double-adoption fence in the FIXED order (acquire →
363        //      publish per shard as a UNIT, then re-assert ownership and widen the
364        //      enumeration scope ONCE) and learn which shards survived it. A shard
365        //      deposed at acquire OR publish (or in the residual window) is dropped
366        //      cleanly — never extended, never recovered, never a hard error. The
367        //      planner GUARANTEES each survivor's publish-fence precedes both the
368        //      scope widening and (below) recovery. A single-node store no-ops
369        //      every step, so this path stays byte-identical there.
370        // The returned survivor set is already reflected in the store's widened
371        // owned-shard scope (the planner's single `extend`), which is what recovery
372        // enumerates over; the value is bound only to make that contract explicit.
373        let _recoverable = super::fence::plan_adopted_shards(
374            &super::fence::StoreFenceSeam {
375                store: &*self.store,
376            },
377            shards,
378        )?;
379        // 3b. Rebuild the pause dispatch-hold for the newly-adopted shards (#204).
380        //     The fence above widened the owned-shard scope, so `list_paused` now
381        //     sees the adopted shards' durably-`Paused` runs. `extend` (not replace)
382        //     preserves the holds for shards this node already owned. A run paused on
383        //     an adopted shard keeps its outbox rows held after failover; without this
384        //     the adopting node's dispatcher would claim and dispatch them. A store
385        //     error is logged, not fatal: the adoption itself is durable and the next
386        //     startup/rebuild repopulates the hold.
387        match self.store.list_paused().await {
388            Ok(paused) => self.paused_runs.extend(paused),
389            Err(error) => {
390                tracing::warn!(%error, "failed to rebuild paused-runs dispatch hold at shard adoption");
391            }
392        }
393        // 4. Re-resident the adopted workflows through the production recovery
394        //    seam (idempotent: this node's own workflows are skipped). Recovery
395        //    enumerates over the owned scope, which now contains only shards that
396        //    survived the fence.
397        super::startup::recover_adopted_shards(super::startup::StartupRecoveryContext {
398            store: Arc::clone(&self.store),
399            visibility_store: Arc::clone(&self.visibility_store),
400            runtime: Arc::clone(&self.runtime),
401            catalog: Arc::clone(&self.catalog),
402            registry: Arc::clone(&self.registry),
403            supervision: Arc::clone(&self.supervision),
404            recovery: None,
405            search_attribute_schema: Arc::clone(&self.search_attribute_schema),
406            bootstrap_schedule_coordinator: false,
407        })
408        .await?;
409        // 5. Re-arm durable timers for the adopted workflows — the SAME step the
410        //    boot path runs after `recover_active_workflows_on_startup` (see
411        //    `EngineBuilder::build`). This is LOAD-BEARING for a workflow PARKED on
412        //    a durable timer (#119): step 4 replays it and re-parks it, but the
413        //    replay of a not-yet-fired sleep does NOT re-arm the live wheel (only a
414        //    first, non-replay arrival does — see `nif_timer::sleep`'s `ResumeLive`
415        //    branch). Without this call the adopted workflow stays parked forever:
416        //    `recover_due` fires already-expired timers and
417        //    `rearm_future_from_active_histories` re-arms still-future ones onto the
418        //    now-resident process. Removing it reproduces the #119 symptom (a
419        //    survivor adopts the shard but the parked timer never reaches the
420        //    resumed workflow). Guarded by `tests/adoption_parked_timer_e2e.rs`
421        //    (single-process) and `tests/adoption_parked_timer_xnode_e2e.rs`
422        //    (real cross-node failover).
423        super::startup::recover_timers_on_startup(self.runtime.nif_state(), Arc::clone(&self.store))
424            .await
425    }
426
427    /// Resume a suspended workflow run and flush deferred signals through its mailbox.
428    ///
429    /// # Errors
430    ///
431    /// Returns [`EngineError::WorkflowNotFound`] when the `(workflow, run)` pair
432    /// is absent, or registry errors from the residency transition. Deferred
433    /// delivery failures are logged and dropped because signals are already durable.
434    pub fn resume_workflow(
435        &self,
436        id: &WorkflowId,
437        run: &RunId,
438    ) -> Result<WorkflowHandle, EngineError> {
439        let handle = transition::resume(self.registry(), id, run)?;
440        if let Err(error) = self.signal_handoff.deliver_deferred(self, id) {
441            tracing::warn!(
442                workflow_id = %id,
443                run_id = %run,
444                error = %error,
445                "failed to flush deferred signals after workflow resume"
446            );
447        }
448        Ok(handle)
449    }
450
451    /// Cancel a live workflow run by killing its runtime process.
452    ///
453    /// # Errors
454    ///
455    /// Returns [`EngineError::ShuttingDown`] after shutdown begins, and
456    /// [`EngineError::WorkflowNotFound`] when the `(workflow, run)` pair
457    /// is not live. Other typed errors come from the cancel transition.
458    pub async fn cancel(
459        &self,
460        id: &WorkflowId,
461        run: &RunId,
462        reason: impl Into<String>,
463    ) -> Result<(), EngineError> {
464        let operation = self.shutdown_gate.begin_operation()?;
465        // Tear down the run's in-flight durable timers BEFORE the cancel
466        // transition. Cancellation that leaves a live timer behind orphans it:
467        // recovery later tries to fire it against a workflow that no longer
468        // exists. See `cancel_inflight_timers` for the ordering constraints.
469        self.cancel_inflight_timers(id).await;
470        let result = terminate::cancel(
471            TerminateWorkflowContext {
472                runtime: &self.runtime,
473                store: self.store(),
474                visibility_store: self.visibility_store(),
475                registry: &self.registry,
476            },
477            id,
478            run,
479            reason,
480        )
481        .await;
482        // Cancel of a Paused run must release the dispatch hold so its held rows
483        // are not leaked forever (#204, GATE-4). Removal is unconditional and
484        // idempotent: a non-paused run is simply absent from the set.
485        if result.is_ok() {
486            self.paused_runs.remove(id);
487        }
488        drop(operation);
489        result
490    }
491
492    /// Cancel the workflow's in-flight durable timers, routed through the
493    /// production [`crate::time::TimerService`] so each records a
494    /// `TimerCancelled` (and disarms the resident wheel) under the service's
495    /// terminal-update guard. Once `TimerCancelled` is in history the timer is
496    /// dead everywhere — a later wheel or recovery fire no-ops on the liveness
497    /// check — so a cancelled workflow no longer leaves orphaned timers that
498    /// brick startup recovery.
499    ///
500    /// Ordering matters and is the reason this lives in `Engine::cancel` rather
501    /// than inside `terminate::cancel`:
502    /// * It runs **before** `terminate::cancel`, while the workflow is still in
503    ///   the registry (before `terminate::cancel`'s final `registry.remove`), so
504    ///   the timer bridge's registry lookup succeeds. `UnknownWorkflow` is raised
505    ///   only when the workflow is absent from the registry entirely — a
506    ///   suspended (non-resident-but-registered) workflow is fine: its wheel
507    ///   disarm is skipped but `TimerCancelled` is still recorded.
508    /// * It runs **outside** `terminate::cancel`'s recorder lock —
509    ///   `TimerService::cancel` re-acquires that same per-handle lock to record,
510    ///   and the tokio mutex is not reentrant.
511    ///
512    /// Best-effort by design: every failure path here is backstopped by
513    /// `recover_due`'s orphaned-timer skip (see [`crate::time`]'s recovery
514    /// module), so it is logged but never fails the cancel. The only residual
515    /// orphan window — a timer armed in the instant between enumeration and the
516    /// process kill — is absorbed by that same recovery skip.
517    async fn cancel_inflight_timers(&self, id: &WorkflowId) {
518        let timer_service = match crate::runtime::nif_timer_bridge::installed_timer_service(
519            self.runtime.nif_state(),
520        ) {
521            Ok(service) => service,
522            Err(error) => {
523                tracing::warn!(
524                    %error,
525                    workflow_id = %id,
526                    "timer service unavailable during cancel; any in-flight timers will be skipped by recovery"
527                );
528                return;
529            }
530        };
531        let history = match self.store.read_history(id).await {
532            Ok(history) => history,
533            Err(error) => {
534                tracing::warn!(
535                    %error,
536                    workflow_id = %id,
537                    "could not read history for timer cleanup during cancel; any in-flight timers will be skipped by recovery"
538                );
539                return;
540            }
541        };
542        for timer_id in live_timers_in_active_segment(&history) {
543            // A reserved workflow-deadline timer is retired PERMANENTLY
544            // (`WorkflowIntent`): reopen must never resurrect it (a
545            // `CancelTeardown` deadline would be re-armed at its original
546            // `fire_at` by `rearmable_timers`). Every other in-flight timer is
547            // ordinary cancel-teardown bookkeeping that reopen re-arms.
548            let cause = if crate::time::is_deadline_timer(&timer_id) {
549                TimerCancelCause::WorkflowIntent
550            } else {
551                TimerCancelCause::CancelTeardown
552            };
553            if let Err(error) = timer_service
554                .cancel(id.clone(), timer_id.clone(), cause)
555                .await
556            {
557                tracing::warn!(
558                    %error,
559                    workflow_id = %id,
560                    %timer_id,
561                    "failed to cancel in-flight timer during workflow cancel; recovery will skip it if orphaned"
562                );
563            }
564        }
565    }
566
567    /// Continue a live workflow run as a new run under the same workflow id.
568    ///
569    /// # Errors
570    ///
571    /// Returns [`EngineError::ShuttingDown`] after shutdown begins, and
572    /// [`EngineError::WorkflowNotFound`] when the `(workflow, run)` pair
573    /// is not live. Other typed errors come from the continue-as-new transition.
574    pub async fn continue_as_new(
575        &self,
576        id: &WorkflowId,
577        run: &RunId,
578        input: Payload,
579        workflow_type: Option<String>,
580    ) -> Result<WorkflowHandle, EngineError> {
581        let operation = self.shutdown_gate.begin_operation()?;
582        let result = continue_as_new::continue_as_new(
583            ContinueAsNewContext {
584                store: self.store(),
585                visibility_store: Arc::clone(&self.visibility_store),
586                catalog: Arc::clone(&self.catalog),
587                runtime: &self.runtime,
588                supervision: Arc::clone(&self.supervision),
589                registry: &self.registry,
590                search_attribute_schema: Arc::clone(&self.search_attribute_schema),
591            },
592            id,
593            run,
594            ContinueAsNewRequest {
595                input,
596                workflow_type,
597            },
598        )
599        .await;
600        drop(operation);
601        result
602    }
603
604    /// Reopen a terminal-`Failed` or terminal-`Cancelled` run and re-drive it.
605    ///
606    /// Appends a single `WorkflowReopened` that supersedes the run's terminal
607    /// event (returning it to Running), then respawns and re-drives the SAME run
608    /// through the existing recovery path so replay returns every recorded result
609    /// and only the reopened / in-flight step re-dispatches live, in the
610    /// workflow's own namespace. Takes only a workflow id and run; the reopened
611    /// steps and the namespace are derived from history.
612    ///
613    /// # Errors
614    ///
615    /// Returns [`EngineError::ShuttingDown`] after shutdown begins,
616    /// [`EngineError::WorkflowNotFound`] when no history exists for the pair, and
617    /// [`EngineError::InvalidState`] when the run is not a reopenable terminal
618    /// (not terminal, terminal for Completed/`TimedOut`, or already Running).
619    pub async fn reopen_workflow(
620        &self,
621        id: &WorkflowId,
622        run: &RunId,
623    ) -> Result<WorkflowHandle, EngineError> {
624        let operation = self.shutdown_gate.begin_operation()?;
625        let result = reopen::reopen(
626            ReopenWorkflowContext {
627                store: self.store(),
628                visibility_store: Arc::clone(&self.visibility_store),
629                catalog: Arc::clone(&self.catalog),
630                runtime: &self.runtime,
631                supervision: Arc::clone(&self.supervision),
632                registry: &self.registry,
633                search_attribute_schema: Arc::clone(&self.search_attribute_schema),
634            },
635            id,
636            run,
637        )
638        .await;
639        drop(operation);
640        result
641    }
642
643    /// The shared dispatch-hold set for durable pause (#204).
644    ///
645    /// Handed to the outbox dispatcher at wiring time so a held (paused) run's
646    /// rows are never claimed, and rebuilt from [`EventStore::list_paused`] at
647    /// startup/adoption.
648    #[must_use]
649    pub fn paused_runs(&self) -> crate::lifecycle::PausedRuns {
650        self.paused_runs.clone()
651    }
652
653    /// Rebuild the dispatch-hold set from durable state (startup / shard
654    /// adoption). A run projecting `Paused` is excluded from `list_active`
655    /// respawn for free; this repopulates the hold so its pre-pause outbox rows
656    /// stay unclaimed after a restart.
657    ///
658    /// # Errors
659    ///
660    /// Returns store errors from the `list_paused` scan.
661    pub async fn rebuild_paused_runs(&self) -> Result<(), EngineError> {
662        let paused = self.store.list_paused().await?;
663        self.paused_runs.replace_all(paused);
664        Ok(())
665    }
666
667    fn pause_context(&self) -> crate::lifecycle::PauseWorkflowContext<'_> {
668        crate::lifecycle::PauseWorkflowContext {
669            store: self.store(),
670            visibility_store: Arc::clone(&self.visibility_store),
671            catalog: Arc::clone(&self.catalog),
672            runtime: &self.runtime,
673            supervision: Arc::clone(&self.supervision),
674            registry: &self.registry,
675            search_attribute_schema: Arc::clone(&self.search_attribute_schema),
676            paused_runs: self.paused_runs.clone(),
677        }
678    }
679
680    /// Pause a live `Running` run, durably holding NEW activity dispatch (#204).
681    ///
682    /// Appends `WorkflowPaused` through the resident handle's own recorder and
683    /// inserts the run into the dispatch-hold set; the resident process stays
684    /// alive and keeps recording (timer fires, signals, drained completions).
685    ///
686    /// # Errors
687    ///
688    /// Returns [`EngineError::ShuttingDown`] after shutdown begins,
689    /// [`EngineError::WorkflowNotFound`] when the pair has no history / no
690    /// resident handle, and [`EngineError::InvalidState`] — naming the actual
691    /// status — when the run is not `Running`.
692    pub async fn pause_workflow(
693        &self,
694        id: &WorkflowId,
695        run: &RunId,
696        reason: Option<String>,
697        operator: Option<String>,
698    ) -> Result<WorkflowHandle, EngineError> {
699        let operation = self.shutdown_gate.begin_operation()?;
700        let result =
701            crate::lifecycle::pause::pause(&self.pause_context(), id, run, reason, operator).await;
702        drop(operation);
703        result
704    }
705
706    /// Resume a `Paused` run, releasing the dispatch hold (#204).
707    ///
708    /// Named `resume_paused_workflow` to avoid colliding with the existing
709    /// residency-flip [`Engine::resume_workflow`]. Appends `WorkflowResumed`,
710    /// removes the run from the dispatch-hold set, and — when the run crashed
711    /// while paused and is no longer resident — respawns it via the reopen
712    /// recovery path, re-arming unfired timers. The ordinary sweep then claims
713    /// the released rows.
714    ///
715    /// # Errors
716    ///
717    /// Returns [`EngineError::ShuttingDown`] after shutdown begins,
718    /// [`EngineError::WorkflowNotFound`] when the pair has no history, and
719    /// [`EngineError::InvalidState`] — naming the actual status — when the run is
720    /// not `Paused`.
721    pub async fn resume_paused_workflow(
722        &self,
723        id: &WorkflowId,
724        run: &RunId,
725        operator: Option<String>,
726    ) -> Result<WorkflowHandle, EngineError> {
727        let operation = self.shutdown_gate.begin_operation()?;
728        let result =
729            crate::lifecycle::pause::resume(&self.pause_context(), id, run, operator).await;
730        drop(operation);
731        result
732    }
733
734    /// Await a workflow run's terminal result.
735    ///
736    /// Already-terminal histories return immediately. Live workflows await their
737    /// completion notifier. Unknown workflow/run pairs return not found.
738    ///
739    /// # Errors
740    ///
741    /// Returns store, registry, or runtime channel errors as typed [`EngineError`]
742    /// variants, or [`EngineError::WorkflowNotFound`] when no live handle or
743    /// terminal history exists for the requested pair.
744    pub async fn result(
745        &self,
746        id: &WorkflowId,
747        run: &RunId,
748    ) -> Result<Result<Payload, WorkflowError>, EngineError> {
749        let history = self.store.read_history(id).await?;
750        if let Some(outcome) = terminal_outcome_from_history(&history) {
751            return Ok(outcome_to_result(outcome));
752        }
753
754        let handle = match self.registry.get(id, run)? {
755            Some(handle) => handle,
756            // Registration birth window: the run is durably started but its
757            // handle insert has not landed yet (see
758            // `Engine::handle_after_birth_window`).
759            None => self
760                .handle_after_birth_window(id, run, &history)
761                .await?
762                .ok_or_else(|| workflow_not_found(id, run))?,
763        };
764        let mut receiver = handle.completion().subscribe();
765        loop {
766            if let Some(outcome) = receiver.borrow().clone() {
767                return Ok(outcome_to_result(outcome));
768            }
769            if receiver.changed().await.is_err() {
770                if let Some(outcome) =
771                    terminal_outcome_from_history(&self.store.read_history(id).await?)
772                {
773                    return Ok(outcome_to_result(outcome));
774                }
775                return Err(EngineError::Runtime {
776                    reason: format!(
777                        "completion channel closed before workflow `{id}/{run}` finished"
778                    ),
779                });
780            }
781        }
782    }
783
784    /// List live and terminal workflow summaries matching `filter`.
785    ///
786    /// Store projections are authoritative; live registry entries are projected
787    /// from durable history before being merged and deduplicated.
788    ///
789    /// # Errors
790    ///
791    /// Returns typed store or registry errors when visibility data cannot be read.
792    pub async fn list_workflows(
793        &self,
794        filter: WorkflowFilter,
795    ) -> Result<Vec<WorkflowSummary>, EngineError> {
796        let mut summaries = self
797            .store
798            .query(&filter)
799            .await?
800            .into_iter()
801            .map(|summary| (summary.workflow_id.clone(), summary))
802            .collect::<HashMap<_, _>>();
803
804        for handle in self.registry.list()? {
805            let history = self.store.read_history(handle.workflow_id()).await?;
806            self.registry
807                .reconcile(handle.workflow_id(), handle.run_id(), &history)?;
808            if let Some(summary) = WorkflowSummary::from_history(&history) {
809                if filter.matches(&summary) {
810                    summaries.insert(summary.workflow_id.clone(), summary);
811                }
812            }
813        }
814
815        let mut summaries = summaries.into_values().collect::<Vec<_>>();
816        summaries.sort_by(|left, right| {
817            left.started_at.cmp(&right.started_at).then_with(|| {
818                left.workflow_id
819                    .to_string()
820                    .cmp(&right.workflow_id.to_string())
821            })
822        });
823        Ok(summaries)
824    }
825
826    /// Gracefully stop accepting new starts and shut down the embedded runtime.
827    ///
828    /// # Errors
829    ///
830    /// Returns registry poison or runtime shutdown failures as typed errors.
831    pub fn shutdown(&self) -> Result<(), EngineError> {
832        if let Some(task) = &self.visibility_reconciliation_task {
833            task.abort();
834        }
835        self.shutdown_gate.close_and_wait()?;
836        // Epoch close for engine-side child tasks (F4): the scheduler stops
837        // first (so no NIF can arm a new watcher mid-shutdown), then every
838        // watcher and spawn-recovery task is aborted AND awaited to
839        // quiescence — a task still mid-record after shutdown could
840        // double-write a parent history a successor engine over the same
841        // store also records into. Arming is additionally gated inside the
842        // task registry the moment shutdown begins.
843        self.runtime.shutdown()?;
844        self.runtime.nif_state().shutdown_child_tasks();
845        // Abort armed live-wheel timer tasks (#119): they run on the tokio
846        // runtime, not the beamr scheduler, so `runtime.shutdown()` does not
847        // reach them. A timer this engine armed must NOT fire after the engine
848        // has stopped owning the workflow — otherwise, across a failover, the
849        // dead owner's orphaned wheel task races the survivor's adoption-armed
850        // timer and can record the one durable `TimerFired` first, leaving the
851        // survivor's resident sleeper parked forever.
852        self.runtime.nif_state().shutdown_timer_wheel();
853        // Break the RuntimeHandle <-> EngineNifState reference cycle (see
854        // EngineNifState::clear_engine_seams). The engine-scoped NIF seams each
855        // hold an Arc back to the runtime and/or clones of the event store and
856        // registry; without releasing them here the runtime, its NIF state, and
857        // every store clone they reach would outlive the dropped Engine
858        // forever, keeping a durable backend's writer lock held past shutdown.
859        // Safe now: the scheduler has stopped and the child-task and timer-wheel
860        // epochs have closed, so no NIF or background task can still read a slot.
861        self.runtime.nif_state().clear_engine_seams();
862        Ok(())
863    }
864}
865
866pub(crate) fn terminal_outcome_from_history(events: &[Event]) -> Option<TerminalOutcome> {
867    // Reset-aware via the shared single-source predicate: the current lease's
868    // terminal event, where a reopen (WorkflowReopened) supersedes any earlier
869    // terminal.
870    match aion_core::current_lease_terminal(events)? {
871        Event::WorkflowCompleted { result, .. } => Some(TerminalOutcome::Completed(result.clone())),
872        Event::WorkflowFailed { error, .. } => Some(TerminalOutcome::Failed(error.clone())),
873        Event::WorkflowCancelled { reason, .. } => Some(TerminalOutcome::Cancelled(reason.clone())),
874        Event::WorkflowTimedOut { timeout, .. } => Some(TerminalOutcome::TimedOut(timeout.clone())),
875        Event::WorkflowContinuedAsNew {
876            input,
877            workflow_type,
878            parent_run_id,
879            ..
880        } => Some(TerminalOutcome::ContinuedAsNew {
881            input: input.clone(),
882            workflow_type: workflow_type.clone(),
883            parent_run_id: parent_run_id.clone(),
884        }),
885        _ => None,
886    }
887}
888
889fn outcome_to_result(outcome: TerminalOutcome) -> Result<Payload, WorkflowError> {
890    match outcome {
891        TerminalOutcome::Completed(payload) => Ok(payload),
892        TerminalOutcome::Failed(error) => Err(error),
893        TerminalOutcome::Cancelled(reason) => Err(WorkflowError {
894            message: format!("workflow cancelled: {reason}"),
895            details: None,
896        }),
897        TerminalOutcome::TimedOut(timeout) => Err(WorkflowError {
898            message: format!("workflow timed out: {timeout}"),
899            details: None,
900        }),
901        TerminalOutcome::ContinuedAsNew { parent_run_id, .. } => Err(WorkflowError {
902            message: format!("workflow continued as new from run {parent_run_id}"),
903            details: None,
904        }),
905    }
906}
907
908pub(crate) fn workflow_not_found(id: &WorkflowId, run: &RunId) -> EngineError {
909    EngineError::WorkflowNotFound {
910        workflow_type: format!("{id}/{run}"),
911    }
912}
913
914#[cfg(test)]
915mod tests {
916    use std::collections::HashMap;
917    use std::sync::Arc;
918    use std::time::Duration;
919
920    use aion_core::{
921        Event, EventEnvelope, PackageVersion, Payload, RunId, SearchAttributeSchema,
922        TimerCancelCause, TimerId, WorkflowFilter, WorkflowId, WorkflowStatus,
923    };
924    use aion_package::ContentHash;
925    use aion_store::visibility::VisibilityStore;
926    use aion_store::{EventStore, InMemoryStore, ReadableEventStore};
927    use serde_json::json;
928
929    use super::{DelegatedSeams, Engine, EngineComponents, live_timers_in_active_segment};
930    use crate::durability::Recorder;
931    use crate::lifecycle::terminate::{self, TerminateWorkflowContext};
932    use crate::registry::{CompletionNotifier, HandleResidency, WorkflowHandleParts};
933    use crate::time::TimerRecovery;
934    use crate::{
935        EngineError, Registry, RuntimeConfig, RuntimeHandle, SupervisionTree, WorkflowCatalog,
936        WorkflowHandle,
937    };
938
939    fn payload(label: &str) -> Result<Payload, aion_core::PayloadError> {
940        Payload::from_json(&json!({ "label": label }))
941    }
942
943    fn workflow_error(message: &str) -> aion_core::WorkflowError {
944        aion_core::WorkflowError {
945            message: message.to_owned(),
946            details: None,
947        }
948    }
949
950    fn workflow_catalog(workflow_type: &str, deployed_module: &str) -> Arc<WorkflowCatalog> {
951        let catalog = Arc::new(WorkflowCatalog::new());
952        catalog.note_loaded_workflow_for_test(
953            workflow_type,
954            deployed_module,
955            "run",
956            ContentHash::from_bytes([5; 32]),
957        );
958        catalog
959    }
960
961    fn engine_with_loaded_workflow(
962        store: Arc<dyn EventStore>,
963        workflow_type: &str,
964        deployed_module: &str,
965    ) -> Result<Engine, EngineError> {
966        let runtime = RuntimeHandle::new(RuntimeConfig::new(Some(1)))?;
967        runtime.register_waiting_test_module(deployed_module, "run");
968        let visibility_store: Arc<dyn VisibilityStore> = Arc::new(InMemoryStore::default());
969        Ok(Engine::new(EngineComponents {
970            store,
971            visibility_store,
972            runtime: Arc::new(runtime),
973            catalog: workflow_catalog(workflow_type, deployed_module),
974            registry: Arc::new(Registry::default()),
975            supervision: Arc::new(SupervisionTree::new()),
976            delegated: DelegatedSeams::default(),
977            signal_handoff: Arc::new(crate::signal::SignalResumeHandoff::new()),
978            search_attribute_schema: Arc::new(SearchAttributeSchema::new()),
979            visibility_reconciliation_task: None,
980        }))
981    }
982
983    fn termination_context(engine: &Engine) -> TerminateWorkflowContext<'_> {
984        TerminateWorkflowContext {
985            runtime: engine.runtime(),
986            store: engine.store(),
987            visibility_store: engine.visibility_store(),
988            registry: engine.registry(),
989        }
990    }
991
992    async fn insert_active_handle(
993        engine: &Engine,
994        store: Arc<dyn EventStore>,
995        workflow_type: &str,
996    ) -> Result<WorkflowHandle, Box<dyn std::error::Error>> {
997        let workflow_id = aion_core::WorkflowId::new_v4();
998        let run_id = aion_core::RunId::new_v4();
999        let mut recorder = Recorder::new(workflow_id.clone(), store);
1000        recorder
1001            .record_workflow_started(
1002                chrono::Utc::now(),
1003                crate::durability::WorkflowStartRecord {
1004                    workflow_type: workflow_type.to_owned(),
1005                    input: payload("input")?,
1006                    run_id: run_id.clone(),
1007                    parent_run_id: None,
1008                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
1009                },
1010            )
1011            .await?;
1012        let pid = engine.runtime().spawn_test_process_with_trap_exit(true)?;
1013        let handle = WorkflowHandle::new(WorkflowHandleParts {
1014            workflow_id: workflow_id.clone(),
1015            run_id: run_id.clone(),
1016            pid,
1017            workflow_type: workflow_type.to_owned(),
1018            namespace: String::from("default"),
1019            loaded_version: ContentHash::from_bytes([9; 32]),
1020            cached_status: WorkflowStatus::Running,
1021            residency: HandleResidency::Resident,
1022            recorder,
1023            completion: CompletionNotifier::new(),
1024        });
1025        engine
1026            .registry()
1027            .insert((workflow_id, run_id), handle.clone())?;
1028        Ok(handle)
1029    }
1030
1031    #[tokio::test]
1032    async fn start_then_cancel_records_started_then_cancelled()
1033    -> Result<(), Box<dyn std::error::Error>> {
1034        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1035        let engine =
1036            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1037        let handle = engine
1038            .start_workflow(
1039                "checkout",
1040                payload("input")?,
1041                HashMap::new(),
1042                String::from("default"),
1043            )
1044            .await?;
1045
1046        engine
1047            .cancel(
1048                handle.workflow_id(),
1049                handle.run_id(),
1050                "caller requested cancellation",
1051            )
1052            .await?;
1053
1054        let history = store.read_history(handle.workflow_id()).await?;
1055        match history.as_slice() {
1056            [
1057                Event::WorkflowStarted { .. },
1058                Event::WorkflowCancelled { reason, .. },
1059            ] => {
1060                assert_eq!(reason, "caller requested cancellation");
1061            }
1062            other => return Err(format!("expected started then cancelled, found {other:?}").into()),
1063        }
1064        engine.shutdown()?;
1065        Ok(())
1066    }
1067
1068    fn test_envelope(workflow_id: &WorkflowId, seq: u64) -> EventEnvelope {
1069        EventEnvelope {
1070            seq,
1071            recorded_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap_or_default(),
1072            workflow_id: workflow_id.clone(),
1073        }
1074    }
1075
1076    fn started_event(workflow_id: &WorkflowId, seq: u64) -> Event {
1077        Event::WorkflowStarted {
1078            envelope: test_envelope(workflow_id, seq),
1079            workflow_type: String::from("checkout"),
1080            input: Payload::new(aion_core::ContentType::Json, b"{}".to_vec()),
1081            run_id: RunId::new_v4(),
1082            parent_run_id: None,
1083            package_version: PackageVersion::new("a".repeat(64)),
1084        }
1085    }
1086
1087    fn timer_started_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
1088        Event::TimerStarted {
1089            envelope: test_envelope(workflow_id, seq),
1090            timer_id: timer_id.clone(),
1091            fire_at: chrono::DateTime::from_timestamp(1_700_000_500, 0).unwrap_or_default(),
1092        }
1093    }
1094
1095    fn timer_fired_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
1096        Event::TimerFired {
1097            envelope: test_envelope(workflow_id, seq),
1098            timer_id: timer_id.clone(),
1099        }
1100    }
1101
1102    fn timer_cancelled_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
1103        Event::TimerCancelled {
1104            envelope: test_envelope(workflow_id, seq),
1105            timer_id: timer_id.clone(),
1106            cause: TimerCancelCause::WorkflowIntent,
1107        }
1108    }
1109
1110    #[test]
1111    fn live_timers_lists_started_and_unterminated() {
1112        let workflow_id = WorkflowId::new_v4();
1113        let first = TimerId::anonymous(0);
1114        let second = TimerId::anonymous(1);
1115        let history = vec![
1116            started_event(&workflow_id, 0),
1117            timer_started_event(&workflow_id, 1, &first),
1118            timer_started_event(&workflow_id, 2, &second),
1119        ];
1120        assert_eq!(
1121            live_timers_in_active_segment(&history),
1122            vec![first, second],
1123            "both started, unterminated timers should be live, in start order"
1124        );
1125    }
1126
1127    #[test]
1128    fn live_timers_excludes_fired_and_cancelled() {
1129        let workflow_id = WorkflowId::new_v4();
1130        let fired = TimerId::anonymous(0);
1131        let cancelled = TimerId::anonymous(1);
1132        let live = TimerId::anonymous(2);
1133        let history = vec![
1134            started_event(&workflow_id, 0),
1135            timer_started_event(&workflow_id, 1, &fired),
1136            timer_started_event(&workflow_id, 2, &cancelled),
1137            timer_started_event(&workflow_id, 3, &live),
1138            timer_fired_event(&workflow_id, 4, &fired),
1139            timer_cancelled_event(&workflow_id, 5, &cancelled),
1140        ];
1141        assert_eq!(
1142            live_timers_in_active_segment(&history),
1143            vec![live],
1144            "only the timer with no terminal event remains live"
1145        );
1146    }
1147
1148    #[test]
1149    fn live_timers_dedups_repeated_start() {
1150        let workflow_id = WorkflowId::new_v4();
1151        let timer = TimerId::anonymous(0);
1152        let history = vec![
1153            started_event(&workflow_id, 0),
1154            timer_started_event(&workflow_id, 1, &timer),
1155            timer_started_event(&workflow_id, 2, &timer),
1156        ];
1157        assert_eq!(live_timers_in_active_segment(&history), vec![timer]);
1158    }
1159
1160    #[test]
1161    fn live_timers_scopes_to_active_run_segment() {
1162        // A timer started in a prior run (before a continue-as-new
1163        // `WorkflowStarted`) must not be surfaced for the replacement run.
1164        let workflow_id = WorkflowId::new_v4();
1165        let prior_run = TimerId::anonymous(0);
1166        let current_run = TimerId::anonymous(0);
1167        let history = vec![
1168            started_event(&workflow_id, 0),
1169            timer_started_event(&workflow_id, 1, &prior_run),
1170            started_event(&workflow_id, 2),
1171            timer_started_event(&workflow_id, 3, &current_run),
1172        ];
1173        assert_eq!(
1174            live_timers_in_active_segment(&history),
1175            vec![current_run],
1176            "only timers from the latest WorkflowStarted segment are live"
1177        );
1178    }
1179
1180    #[test]
1181    fn live_timers_empty_history_is_empty() {
1182        assert!(live_timers_in_active_segment(&[]).is_empty());
1183    }
1184
1185    /// Build an engine whose runtime has the production timer NIF bridge
1186    /// installed against the given store + registry, so `Engine::cancel`'s timer
1187    /// cleanup exercises the real `TimerService` path (not a fake). Must be
1188    /// called from within a tokio runtime (`Handle::current()`).
1189    fn engine_with_timer_bridge(
1190        store: Arc<dyn EventStore>,
1191        registry: Arc<Registry>,
1192    ) -> Result<Engine, EngineError> {
1193        let runtime = RuntimeHandle::new(RuntimeConfig::new(Some(1)))?;
1194        runtime.register_waiting_test_module("checkout_deployed", "run");
1195        crate::runtime::nif_timer_bridge::install_timer_nif_bridge(
1196            runtime.nif_state(),
1197            Arc::clone(&registry),
1198            Arc::clone(&store),
1199            tokio::runtime::Handle::current(),
1200            crate::runtime::SignalDeliveryConfig::default(),
1201        );
1202        let visibility_store: Arc<dyn VisibilityStore> = Arc::new(InMemoryStore::default());
1203        Ok(Engine::new(EngineComponents {
1204            store,
1205            visibility_store,
1206            runtime: Arc::new(runtime),
1207            catalog: workflow_catalog("checkout", "checkout_deployed"),
1208            registry,
1209            supervision: Arc::new(SupervisionTree::new()),
1210            delegated: DelegatedSeams::default(),
1211            signal_handoff: Arc::new(crate::signal::SignalResumeHandoff::new()),
1212            search_attribute_schema: Arc::new(SearchAttributeSchema::new()),
1213            visibility_reconciliation_task: None,
1214        }))
1215    }
1216
1217    /// Root-cause regression: cancelling a workflow with a live durable timer
1218    /// must record `TimerCancelled` (before the terminal `WorkflowCancelled`),
1219    /// so the timer is dead in history and recovery never fires it as an
1220    /// orphan. Drives the real `Engine::cancel` against a runtime with the
1221    /// production timer bridge installed.
1222    #[tokio::test(flavor = "multi_thread")]
1223    async fn cancel_records_timer_cancelled_before_workflow_cancelled()
1224    -> Result<(), Box<dyn std::error::Error>> {
1225        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1226        let registry = Arc::new(Registry::default());
1227        let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(&registry))?;
1228
1229        let handle = engine
1230            .start_workflow(
1231                "checkout",
1232                payload("input")?,
1233                HashMap::new(),
1234                String::from("default"),
1235            )
1236            .await?;
1237
1238        // Arm a live durable timer for the resident run and record its
1239        // `TimerStarted`, exactly as the resume-live handoff would in production.
1240        let timer_id = TimerId::anonymous(0);
1241        let fire_at = chrono::Utc::now() + chrono::Duration::hours(1);
1242        handle
1243            .recorder()
1244            .lock()
1245            .await
1246            .record_timer_started(chrono::Utc::now(), timer_id.clone(), fire_at)
1247            .await?;
1248        let timer_service =
1249            crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1250                .map_err(|error| format!("timer service unavailable: {error}"))?;
1251        timer_service
1252            .schedule(handle.workflow_id().clone(), timer_id.clone(), fire_at)
1253            .await?;
1254
1255        engine
1256            .cancel(
1257                handle.workflow_id(),
1258                handle.run_id(),
1259                "caller requested cancellation",
1260            )
1261            .await?;
1262
1263        let history = store.read_history(handle.workflow_id()).await?;
1264        match history.as_slice() {
1265            [
1266                Event::WorkflowStarted { .. },
1267                Event::TimerStarted {
1268                    timer_id: started, ..
1269                },
1270                Event::TimerCancelled {
1271                    timer_id: cancelled,
1272                    ..
1273                },
1274                Event::WorkflowCancelled { reason, .. },
1275            ] => {
1276                assert_eq!(started, &timer_id);
1277                assert_eq!(cancelled, &timer_id, "the live timer must be cancelled");
1278                assert_eq!(reason, "caller requested cancellation");
1279            }
1280            other => {
1281                return Err(format!(
1282                    "expected [started, timer-started, timer-cancelled, cancelled], found {other:?}"
1283                )
1284                .into());
1285            }
1286        }
1287        engine.shutdown()?;
1288        Ok(())
1289    }
1290
1291    /// All live timers (not just one) are cancelled, in start order, before the
1292    /// terminal `WorkflowCancelled`.
1293    #[tokio::test(flavor = "multi_thread")]
1294    async fn cancel_cancels_multiple_live_timers() -> Result<(), Box<dyn std::error::Error>> {
1295        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1296        let registry = Arc::new(Registry::default());
1297        let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(&registry))?;
1298        let handle = engine
1299            .start_workflow(
1300                "checkout",
1301                payload("input")?,
1302                HashMap::new(),
1303                String::from("default"),
1304            )
1305            .await?;
1306
1307        let first = TimerId::anonymous(0);
1308        let second = TimerId::anonymous(1);
1309        let fire_at = chrono::Utc::now() + chrono::Duration::hours(1);
1310        {
1311            let recorder = handle.recorder();
1312            let mut recorder = recorder.lock().await;
1313            recorder
1314                .record_timer_started(chrono::Utc::now(), first.clone(), fire_at)
1315                .await?;
1316            recorder
1317                .record_timer_started(chrono::Utc::now(), second.clone(), fire_at)
1318                .await?;
1319        }
1320
1321        engine
1322            .cancel(handle.workflow_id(), handle.run_id(), "stop")
1323            .await?;
1324
1325        let history = store.read_history(handle.workflow_id()).await?;
1326        match history.as_slice() {
1327            [
1328                Event::WorkflowStarted { .. },
1329                Event::TimerStarted {
1330                    timer_id: started_first,
1331                    ..
1332                },
1333                Event::TimerStarted {
1334                    timer_id: started_second,
1335                    ..
1336                },
1337                Event::TimerCancelled {
1338                    timer_id: cancelled_first,
1339                    ..
1340                },
1341                Event::TimerCancelled {
1342                    timer_id: cancelled_second,
1343                    ..
1344                },
1345                Event::WorkflowCancelled { .. },
1346            ] => {
1347                assert_eq!(started_first, &first);
1348                assert_eq!(started_second, &second);
1349                assert_eq!(cancelled_first, &first, "first live timer cancelled first");
1350                assert_eq!(
1351                    cancelled_second, &second,
1352                    "second live timer cancelled second"
1353                );
1354            }
1355            other => {
1356                return Err(format!(
1357                    "expected two timer-cancels before workflow-cancel, found {other:?}"
1358                )
1359                .into());
1360            }
1361        }
1362        engine.shutdown()?;
1363        Ok(())
1364    }
1365
1366    /// End-to-end source-of-bug proof: a cancelled workflow leaves no orphan for
1367    /// startup recovery. With a past-due durable timer row (the exact shape that
1368    /// bricked startup before the fix), recovery surfaces no `UnknownWorkflow`
1369    /// and fires nothing — because cancel recorded `TimerCancelled`, so the
1370    /// timer is dead in history. Complements the committed `recover_due` defense
1371    /// test by proving the orphan is gone *at the source*.
1372    #[tokio::test(flavor = "multi_thread")]
1373    async fn cancelled_workflow_leaves_no_orphan_for_recovery()
1374    -> Result<(), Box<dyn std::error::Error>> {
1375        let concrete: Arc<InMemoryStore> = Arc::new(InMemoryStore::default());
1376        let store: Arc<dyn EventStore> = concrete.clone();
1377        let registry = Arc::new(Registry::default());
1378        let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(&registry))?;
1379        let handle = engine
1380            .start_workflow(
1381                "checkout",
1382                payload("input")?,
1383                HashMap::new(),
1384                String::from("default"),
1385            )
1386            .await?;
1387        let workflow_id = handle.workflow_id().clone();
1388
1389        // A live timer whose durable row is already past-due, inserted directly
1390        // (no wheel arm, so nothing races the cancel).
1391        let timer_id = TimerId::anonymous(0);
1392        let fire_at = chrono::Utc::now() - chrono::Duration::hours(1);
1393        handle
1394            .recorder()
1395            .lock()
1396            .await
1397            .record_timer_started(chrono::Utc::now(), timer_id.clone(), fire_at)
1398            .await?;
1399        concrete
1400            .schedule_timer(&workflow_id, &timer_id, fire_at)
1401            .await?;
1402
1403        let timer_service =
1404            crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1405                .map_err(|error| format!("timer service unavailable: {error}"))?;
1406
1407        engine.cancel(&workflow_id, handle.run_id(), "stop").await?;
1408
1409        // Cancel removed the workflow from the registry and the durable row is
1410        // now past-due — exactly the orphan scenario. Recovery must handle it
1411        // cleanly: the recorded `TimerCancelled` makes `fire_timer` a no-op, so
1412        // no `TimerFired` and (critically) no `UnknownWorkflow`.
1413        let readable: Arc<dyn ReadableEventStore> = concrete.clone();
1414        TimerRecovery::new(readable, timer_service, Duration::ZERO)
1415            .recover_on_startup(chrono::Utc::now())
1416            .await?;
1417
1418        let history = concrete.read_history(&workflow_id).await?;
1419        assert!(
1420            !history
1421                .iter()
1422                .any(|event| matches!(event, Event::TimerFired { .. })),
1423            "no timer should fire for a cancelled workflow during recovery"
1424        );
1425        assert!(
1426            history
1427                .iter()
1428                .any(|event| matches!(event, Event::TimerCancelled { .. })),
1429            "cancel must have recorded TimerCancelled at the source"
1430        );
1431        engine.shutdown()?;
1432        Ok(())
1433    }
1434
1435    #[tokio::test]
1436    async fn result_returns_completed_payload() -> Result<(), Box<dyn std::error::Error>> {
1437        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1438        let engine =
1439            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1440        let handle = engine
1441            .start_workflow(
1442                "checkout",
1443                payload("input")?,
1444                HashMap::new(),
1445                String::from("default"),
1446            )
1447            .await?;
1448        let result_payload = payload("result")?;
1449
1450        terminate::complete(
1451            termination_context(&engine),
1452            handle.workflow_id(),
1453            handle.run_id(),
1454            result_payload.clone(),
1455        )
1456        .await?;
1457
1458        assert_eq!(
1459            engine.result(handle.workflow_id(), handle.run_id()).await?,
1460            Ok(result_payload)
1461        );
1462        engine.shutdown()?;
1463        Ok(())
1464    }
1465
1466    #[tokio::test]
1467    async fn result_returns_failed_workflow_error() -> Result<(), Box<dyn std::error::Error>> {
1468        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1469        let engine =
1470            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1471        let handle = engine
1472            .start_workflow(
1473                "checkout",
1474                payload("input")?,
1475                HashMap::new(),
1476                String::from("default"),
1477            )
1478            .await?;
1479        let error = workflow_error("workflow failed");
1480
1481        terminate::fail(
1482            termination_context(&engine),
1483            handle.workflow_id(),
1484            handle.run_id(),
1485            error.clone(),
1486        )
1487        .await?;
1488
1489        assert_eq!(
1490            engine.result(handle.workflow_id(), handle.run_id()).await?,
1491            Err(error)
1492        );
1493        engine.shutdown()?;
1494        Ok(())
1495    }
1496
1497    #[tokio::test]
1498    async fn result_unknown_workflow_returns_not_found() -> Result<(), Box<dyn std::error::Error>> {
1499        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1500        let engine = engine_with_loaded_workflow(store, "checkout", "checkout_deployed")?;
1501        let workflow_id = aion_core::WorkflowId::new_v4();
1502        let run_id = aion_core::RunId::new_v4();
1503
1504        let result = engine.result(&workflow_id, &run_id).await;
1505
1506        assert!(matches!(result, Err(EngineError::WorkflowNotFound { .. })));
1507        engine.shutdown()?;
1508        Ok(())
1509    }
1510
1511    #[tokio::test]
1512    async fn continue_as_new_unknown_workflow_returns_not_found()
1513    -> Result<(), Box<dyn std::error::Error>> {
1514        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1515        let engine = engine_with_loaded_workflow(store, "checkout", "checkout_deployed")?;
1516        let workflow_id = aion_core::WorkflowId::new_v4();
1517        let run_id = aion_core::RunId::new_v4();
1518
1519        let result = engine
1520            .continue_as_new(&workflow_id, &run_id, payload("next")?, None)
1521            .await;
1522
1523        assert!(matches!(result, Err(EngineError::WorkflowNotFound { .. })));
1524        engine.shutdown()?;
1525        Ok(())
1526    }
1527
1528    #[tokio::test]
1529    async fn list_workflows_merges_live_and_terminal_without_duplicates()
1530    -> Result<(), Box<dyn std::error::Error>> {
1531        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1532        let engine =
1533            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1534        let running = insert_active_handle(&engine, Arc::clone(&store), "checkout").await?;
1535        let completed = engine
1536            .start_workflow(
1537                "checkout",
1538                payload("input")?,
1539                HashMap::new(),
1540                String::from("default"),
1541            )
1542            .await?;
1543        terminate::complete(
1544            termination_context(&engine),
1545            completed.workflow_id(),
1546            completed.run_id(),
1547            payload("result")?,
1548        )
1549        .await?;
1550
1551        let summaries = engine.list_workflows(WorkflowFilter::default()).await?;
1552        assert_eq!(summaries.len(), 2);
1553        assert!(summaries.iter().any(|summary| {
1554            &summary.workflow_id == running.workflow_id()
1555                && summary.status == WorkflowStatus::Running
1556        }));
1557        assert!(summaries.iter().any(|summary| {
1558            &summary.workflow_id == completed.workflow_id()
1559                && summary.status == WorkflowStatus::Completed
1560        }));
1561
1562        let completed_only = engine
1563            .list_workflows(WorkflowFilter {
1564                status: Some(WorkflowStatus::Completed),
1565                ..WorkflowFilter::default()
1566            })
1567            .await?;
1568        assert_eq!(completed_only.len(), 1);
1569        assert_eq!(&completed_only[0].workflow_id, completed.workflow_id());
1570        engine.shutdown()?;
1571        Ok(())
1572    }
1573
1574    #[tokio::test]
1575    async fn shutdown_rejects_subsequent_starts() -> Result<(), Box<dyn std::error::Error>> {
1576        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1577        let engine =
1578            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1579        let handle = engine
1580            .start_workflow(
1581                "checkout",
1582                payload("input")?,
1583                HashMap::new(),
1584                String::from("default"),
1585            )
1586            .await?;
1587        terminate::complete(
1588            termination_context(&engine),
1589            handle.workflow_id(),
1590            handle.run_id(),
1591            payload("result")?,
1592        )
1593        .await?;
1594
1595        engine.shutdown()?;
1596        let result = engine
1597            .start_workflow(
1598                "checkout",
1599                payload("after-shutdown")?,
1600                HashMap::new(),
1601                String::from("default"),
1602            )
1603            .await;
1604
1605        assert!(matches!(result, Err(EngineError::ShuttingDown)));
1606        Ok(())
1607    }
1608
1609    #[tokio::test]
1610    async fn shutdown_is_idempotent() -> Result<(), Box<dyn std::error::Error>> {
1611        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1612        let engine =
1613            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1614        let handle = engine
1615            .start_workflow(
1616                "checkout",
1617                payload("input")?,
1618                HashMap::new(),
1619                String::from("default"),
1620            )
1621            .await?;
1622        terminate::complete(
1623            termination_context(&engine),
1624            handle.workflow_id(),
1625            handle.run_id(),
1626            payload("result")?,
1627        )
1628        .await?;
1629
1630        engine.shutdown()?;
1631        let second = engine.shutdown();
1632
1633        assert!(
1634            second.is_ok(),
1635            "double shutdown should succeed; got {second:?}"
1636        );
1637        Ok(())
1638    }
1639
1640    #[tokio::test]
1641    async fn shutdown_rejects_schedule_creation() -> Result<(), Box<dyn std::error::Error>> {
1642        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1643        let engine =
1644            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1645        let handle = engine
1646            .start_workflow(
1647                "checkout",
1648                payload("input")?,
1649                HashMap::new(),
1650                String::from("default"),
1651            )
1652            .await?;
1653        terminate::complete(
1654            termination_context(&engine),
1655            handle.workflow_id(),
1656            handle.run_id(),
1657            payload("result")?,
1658        )
1659        .await?;
1660        engine.shutdown()?;
1661
1662        let config = aion_core::ScheduleConfig {
1663            trigger: aion_core::TriggerSpec::Interval {
1664                period: Duration::from_secs(60),
1665            },
1666            overlap_policy: aion_core::OverlapPolicy::Skip,
1667            catch_up_policy: aion_core::CatchUpPolicy::Skip,
1668            workflow_type: String::from("checkout"),
1669            input: payload("scheduled")?,
1670            search_attributes: HashMap::new(),
1671        };
1672        let result = engine.create_schedule(config).await;
1673
1674        assert!(
1675            matches!(result, Err(EngineError::ShuttingDown)),
1676            "create_schedule after shutdown should return ShuttingDown; got {result:?}"
1677        );
1678        Ok(())
1679    }
1680}