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