Skip to main content

aion/engine/
api.rs

1//! `Engine` start, cancel, result, list, and shutdown support.
2
3use std::sync::Arc;
4
5use aion_core::{Event, RunId, SearchAttributeSchema, WorkflowId};
6use tokio::sync::Mutex as AsyncMutex;
7use tokio::task::JoinHandle;
8
9use crate::durability::Recorder;
10use crate::schedule::ScheduleEvaluator;
11use aion_store::EventStore;
12use aion_store::visibility::VisibilityStore;
13
14use crate::registry::TerminalOutcome;
15use crate::{
16    EngineError, Registry, RuntimeHandle, SupervisionTree, WorkflowCatalog,
17    signal::SignalResumeHandoff,
18};
19
20use super::api_schedule::{
21    ScheduleRuntimeDeps, default_schedule_evaluator, schedule_coordinator_workflow_id,
22};
23use super::delegated::DelegatedSeams;
24use super::shutdown_gate::ShutdownGate;
25
26/// Live embedded workflow engine assembled by [`crate::EngineBuilder`].
27pub struct Engine {
28    pub(super) store: Arc<dyn EventStore>,
29    pub(super) visibility_store: Arc<dyn VisibilityStore>,
30    pub(super) schedule_recorder: Arc<AsyncMutex<Recorder>>,
31    pub(super) schedule_evaluator: Arc<AsyncMutex<ScheduleEvaluator>>,
32    pub(super) schedule_coordinator_workflow_id: WorkflowId,
33    pub(super) runtime: Arc<RuntimeHandle>,
34    pub(super) catalog: Arc<WorkflowCatalog>,
35    pub(super) registry: Arc<Registry>,
36    pub(super) supervision: Arc<SupervisionTree>,
37    delegated: DelegatedSeams,
38    pub(super) signal_handoff: Arc<SignalResumeHandoff>,
39    pub(super) search_attribute_schema: Arc<SearchAttributeSchema>,
40    pub(super) shutdown_gate: ShutdownGate,
41    /// Serializes the deploy mutations (load / route / unload) end-to-end
42    /// across BOTH the catalog commit and its store persistence write, so
43    /// the persisted package set and route pointers can never disagree with
44    /// the catalog through interleaving (for example a concurrent re-deploy
45    /// re-persisting a version an unload just deleted). Workflow dispatch
46    /// never takes this lock.
47    pub(super) deploy_mutations: AsyncMutex<()>,
48    visibility_reconciliation_task: Option<JoinHandle<()>>,
49    /// One-shot slot for deferred startup recovery (#266). `NotDeferred` on a
50    /// default build; `Pending` until [`Engine::run_startup_recovery`]
51    /// consumes it.
52    pub(super) deferred_startup_recovery:
53        std::sync::Mutex<super::startup_deferred::DeferredRecoverySlot>,
54    /// Shared dispatch-hold set for durable pause (#204): the workflow ids whose
55    /// outbox rows are held `Pending` while paused. Mutated by pause/resume/cancel
56    /// and rebuilt from [`EventStore::list_paused`] at startup/adoption; read by
57    /// the outbox dispatcher at claim time.
58    pub(super) paused_runs: crate::lifecycle::PausedRuns,
59}
60
61impl Drop for Engine {
62    /// Close the engine-task epoch when the engine is released, whether or not
63    /// [`Engine::shutdown`] was ever called or ever succeeded.
64    ///
65    /// Without this, an engine dropped without a successful shutdown left
66    /// completion retries armed and appending terminal events. They could not
67    /// be stopped by `EngineTaskRuntime::drop` either: an attempt in flight
68    /// upgrades its weak reference and holds the `RuntimeHandle` strongly for
69    /// the length of the attempt, so the refcount never reaches zero and that
70    /// backstop is unreachable for precisely the span of the append it exists
71    /// to stop. This drop runs before the engine's own fields are released, so
72    /// it does not depend on that refcount at all.
73    ///
74    /// Uses the non-blocking `EngineTaskRuntime::begin_close` rather than the
75    /// full shutdown:
76    /// a `Drop` may run inside a host async context, where a blocking join
77    /// panics. It therefore **gates and aborts, it does not await** — an
78    /// attempt already past the append boundary is cancelled at its next await
79    /// point. The gate is the load-bearing half: the append boundary reads it
80    /// through `is_epoch_open` and refuses.
81    ///
82    /// # The visibility reconciliation task is aborted here for the same reason
83    ///
84    /// It runs on the HOST runtime, not the engine-task executor, so the epoch
85    /// gate does not reach it — and dropping its `JoinHandle` detaches rather
86    /// than cancels. It is an unbounded loop holding the event store and the
87    /// visibility store, and `reconcile_visibility` WRITES. Left detached, an
88    /// engine released without `shutdown` went on upserting visibility rows for
89    /// the life of the process, against a store a successor engine may already
90    /// own. `Engine::shutdown` aborts it as its first act; this does the same,
91    /// so the two paths agree.
92    ///
93    /// # The live timer wheel is disarmed here for the third time, same reason
94    ///
95    /// 🔴 THIS WAS MISSING, AND IT LEFT A DURABLE WRITER ARMED. Live-wheel
96    /// timer tasks are `tokio::spawn`ed on the HOST runtime
97    /// (`runtime/nif_timer_bridge.rs`), so — exactly like the reconciliation
98    /// loop — the engine-task epoch gate does not reach them. Their body is
99    /// `fire_wheel_timer`, which records a durable `TimerFired`. They hold a
100    /// `Weak<EngineNifState>`, and this drop deliberately does NOT clear the
101    /// seams (see below), so that upgrade succeeds and the fire proceeds.
102    ///
103    /// An engine released without `shutdown` therefore kept a durable-append
104    /// path armed for the life of the process. `Engine::shutdown` names the
105    /// consequence precisely: across a failover, the dead owner's orphaned
106    /// wheel task races the survivor's adoption-armed timer and can record the
107    /// one durable `TimerFired` first, leaving the survivor's resident sleeper
108    /// parked forever. That is the single-writer invariant, and nothing about
109    /// it cares whether the engine was shut down or dropped.
110    ///
111    /// Safe in a `Drop`: `shutdown_timer_wheel` sets a flag and then performs a
112    /// `DashMap` drain plus `abort()` — non-blocking, structurally identical to
113    /// the `visibility_reconciliation_task.abort()` above. The async-context
114    /// objection that justifies `begin_close` over the full shutdown does not
115    /// apply to it.
116    ///
117    /// 🔴 AND IT IS A GATE, NOT ONLY A DRAIN — which it had to become for this
118    /// `Drop` to be worth anything. A drain closes the set of timers armed at
119    /// one instant; this `Drop` deliberately leaves the beamr scheduler and the
120    /// engine seams alive, so a workflow process still runnable could reach
121    /// `sleep` a moment later and arm a fresh durable `TimerFired` writer
122    /// through a wheel this drop believed it had emptied. `arm_timer` now
123    /// refuses once the flag is set (`nif_timer_bridge.rs`, `shut_down`), so
124    /// the guarantee below is a property of the wheel from here on rather than
125    /// of one instant.
126    ///
127    /// # 🔴 WHAT THIS DOES NOT DO, STATED SO NOBODY READS MORE INTO IT
128    ///
129    /// It does not clear the engine NIF seams. Those hold `Arc`s back to the
130    /// `RuntimeHandle`, so until `clear_engine_seams` runs the handle, its
131    /// beamr scheduler and every store clone they reach outlive this drop.
132    /// `Engine::shutdown` clears them only after the scheduler has stopped and
133    /// the child-task and timer-wheel epochs have closed; none of that has
134    /// happened here, and a NIF could still read a slot this drop cleared.
135    /// Trading a leak for a use-after-clear is the wrong direction, so the leak
136    /// stands and is named: **an engine released without `shutdown` still holds
137    /// its runtime.** What this drop guarantees is narrower and is the part
138    /// that matters for durability — **no durable writer this drop can reach
139    /// keeps writing, and no writer it cannot reach can end a run.** The first
140    /// clause covers FOUR BACKGROUND writers, stopped in two different ways:
141    ///
142    /// 1. anything armed on the **engine-task epoch** — `begin_close()` below;
143    /// 2. the **visibility reconciliation loop** — `abort()` below;
144    /// 3. the **live timer wheel** — `shutdown_timer_wheel()` below, which
145    ///    gates and drains, *and* refuses at the point of writing, because
146    ///    `abort` cannot stop a task already inside a poll. That refusal is in
147    ///    TWO places, not one, and the second is easy to miss: an ordinary timer
148    ///    is refused at the bridge's append boundary
149    ///    (`nif_timer_bridge.rs`, `record_workflow_event`), but a reserved
150    ///    `deadline:{run}` fire never reaches that boundary — `fire_timer_guarded`
151    ///    demuxes it to the deadline handler first — so it is refused inside
152    ///    [`crate::lifecycle::deadline::WorkflowDeadlineHandler`] instead, off
153    ///    the same latch;
154    /// 4. the **activity completion / retry task**
155    ///    ([`crate::runtime::nif_activity_retry_dispatch::spawn_completion_task`]),
156    ///    which this drop **cannot reach at all**: its `JoinHandle` is
157    ///    discarded, so it is detached on the host runtime and nothing here
158    ///    registers or aborts it. It is stopped instead at its append boundary,
159    ///    which reads `is_epoch_open()` under the recorder lock — so step 1's
160    ///    `begin_close()` is what silences it, one indirection away.
161    ///
162    /// # 🔴 AND THERE IS A FIFTH, WHICH IS NOT A BACKGROUND WRITER AT ALL
163    ///
164    /// The four above are things the engine spawned; this drop stops them
165    /// because it can reach them. The fifth is the **workflow process itself**,
166    /// and this drop deliberately does not stop it — it leaves the beamr
167    /// scheduler running and the NIF seams installed, which is exactly what the
168    /// section above says it is trading for. A still-runnable workflow process
169    /// therefore keeps calling NIFs after the `Engine` is gone, and **13 of the
170    /// 24 registered engine NIFs perform durable writes** — `dispatch_activity`,
171    /// `dispatch_activity_in_vm`, `await_activity_result`, `sleep`,
172    /// `start_timer`, `cancel_timer`, `with_timeout`, `continue_as_new`,
173    /// `send_signal`, `spawn_child`, `collect_all`, `collect_race`,
174    /// `collect_map`. The other 11 read or reply and record nothing. The
175    /// registration table is `runtime::engine_nifs::engine_nif_entries`, whose
176    /// own test asserts the total, so both halves of that split are checkable
177    /// against a closed set rather than taken on trust — which is the point,
178    /// since the first draft of this paragraph carried a transposed count.
179    /// None of the 13 consults the engine-task epoch, and
180    /// nothing in the append path does either: `NifContext::block_on_recorder`
181    /// takes the recorder lock and nothing else, and `Recorder::append_one`
182    /// goes straight to `store.append`.
183    ///
184    /// An earlier revision of this doc said "there are FOUR" full stop, and was
185    /// wrong in the way that matters most: it did not omit an obscure writer, it
186    /// omitted **the one that executes user code**.
187    ///
188    /// What has been closed is the part that can END A RUN.
189    /// `WorkflowContinuedAsNew` is a TERMINAL, it was the ONE terminal this
190    /// fifth writer could still record, and it is now refused off the same epoch
191    /// (`runtime::nif_continue_as_new::record_continuation`). The reason it had
192    /// to be, in one line: **the successor run that terminal obliges was already
193    /// refused** at `completion::start_continuation_replacement`, so the two
194    /// halves of one transition disagreed and the run was left terminal with no
195    /// continuation. Every other terminal reachable from workflow code was
196    /// already gated — process exit at the completion append boundary,
197    /// `WorkflowTimedOut` off the timer bridge's stand-down latch.
198    ///
199    /// **And the refusal ENDS THE PROCESS, which is the half that makes it a
200    /// gain rather than a trade.** Before the gate, the recorder call either
201    /// succeeded or aborted the NIF, and the success path always reached
202    /// `cancel_pid` — that instruction is where this fifth writer died. A
203    /// refusal that merely returned early would have removed it, leaving the
204    /// process runnable and free to make every ungated write listed below. So
205    /// `runtime::nif_continue_as_new` terminates on the epoch refusal too — and,
206    /// of the refusals, on that one ONLY. A pre-terminal store fault is an
207    /// ordinary error workflow code may handle, and killing a process for it
208    /// would turn a transient blip into a dead run; an already-terminal run is
209    /// spared for a different reason — its terminal was recorded by a seam
210    /// that owns its own teardown, and of those owners some end the pid (a
211    /// second `cancel_pid` from here would race them) while some only
212    /// deregister (a kill from here would usurp them). The predicate's doc
213    /// carries that split; the "Five ordinary terminal paths" paragraph in
214    /// `lifecycle/completion.rs` carries the one enumeration of the owners.
215    /// It ALSO terminates whenever the terminal actually landed, including
216    /// the half-completed case where the terminal is durable but the deadline
217    /// retirement that follows it failed — because the question that decides
218    /// this is "did the terminal land", not "was there an error". The
219    /// predicate is `outcome_must_end_the_process`, pinned by a test with both
220    /// negative controls.
221    ///
222    /// The cost, stated because it is not zero: the refusal returns before
223    /// `retire_run_deadline`, so the predecessor's deadline row stays armed. A
224    /// restart gap longer than the run's remaining budget times the run out
225    /// instead of continuing it. That is the same exposure every other in-flight
226    /// run already carries across an outage; the old path escaped it only by
227    /// recording a terminal for a transition that never completed.
228    ///
229    /// # 🔴 WHAT IS STILL OPEN, AND WHY IT IS NOT CLOSED HERE
230    ///
231    /// A workflow process refused by the EPOCH gate is now stopped, so the
232    /// writes below are not reachable from that path. Say "the epoch gate" and
233    /// not "was refused": the other refusals deliberately leave the process
234    /// alive, so a reader who takes this sentence at its widest reading would
235    /// believe an exposure is closed that is open by design.
236    ///
237    /// They remain fully open on every other path — a process that never calls
238    /// `continue_as_new` is untouched by any of this and keeps writing.
239    ///
240    /// The fifth writer's NON-terminal durable writes are ungated and remain so:
241    /// `TimerStarted` plus a durable timer row (`sleep`, `start_timer`,
242    /// `with_timeout` — `TimerService::schedule` writes the row and only then
243    /// arms, so the wheel's refusal lands after both), activity schedule/start
244    /// and completion records, `spawn_child`'s whole child-start chain, and
245    /// `send_signal`, which writes into a THIRD workflow's history.
246    ///
247    /// Two things bound that, and neither is what a reader might assume:
248    /// - `WriteToken` fences NOTHING. It is a zero-sized marker with a public
249    ///   `recorder()` constructor and no engine, epoch, lease or node identity;
250    ///   two engines over one store both mint valid ones. Its own doc says so —
251    ///   it exists to stop an `Arc<dyn EventStore>` alone being write authority.
252    /// - `SequenceConflict` catches only the LOSER of a head race, and a
253    ///   released engine is structurally positioned to be the winner: its
254    ///   Recorder is the one already at the current head, because it is the one
255    ///   that has been appending. If it writes first, its write succeeds and the
256    ///   SUCCESSOR takes the conflict.
257    ///
258    /// So the remaining exposure is real and is stated rather than denied. It is
259    /// not closed here because **no flag in this crate distinguishes "released"
260    /// from "shutting down"** — `begin_close` sets one bit and both `Engine::drop`
261    /// and `Engine::shutdown` set it. A gate on that bit at a workflow-process
262    /// write path would therefore also fire during an ORDINARY graceful
263    /// shutdown, for the whole unbounded span between `begin_close()` and
264    /// `runtime.shutdown()` further down this file, and there the failure is an
265    /// `{error, _}` returned INSIDE running workflow code — a failed `sleep`, a
266    /// failed `spawn_child` — on runs the shutdown was trying to leave intact.
267    /// The terminal was worth that trade because its successor was already
268    /// refused at `start_continuation_replacement`: recording it could only
269    /// produce a run that is terminal with no continuation.
270    ///
271    /// ⚠️ **Refusing it is not free, and an earlier revision of this sentence
272    /// said it was.** It read "refusing cost nothing that was not already lost",
273    /// which is the exact claim `runtime::nif_continue_as_new`'s own
274    /// documentation exists to retract — and which the "cost, stated because it
275    /// is not zero" paragraph above already contradicts. The price is stated
276    /// there and holds here: the refusal returns before `retire_run_deadline`,
277    /// so the predecessor's deadline stays armed and a long enough outage
278    /// times the run out instead of continuing it. What makes the trade worth
279    /// taking is not that it is free but that the alternative bought its
280    /// exemption with a false terminal.
281    ///
282    /// Refusing ordinary progress is a different bargain and needs a latch that
283    /// means what it says. Do not add one of these gates without adding that
284    /// latch.
285    ///
286    /// 🔴 THAT LIST IS A CLAIM ABOUT DURABLE WRITERS AND IT IS ONLY AS GOOD AS
287    /// ITS ENUMERATION — four times proven. An earlier revision named two and
288    /// was wrong: the timer wheel was the third, and it was armed. The revision
289    /// after that named three and was also wrong: the completion task was the
290    /// fourth, it had no epoch check of any kind, and it sleeps an
291    /// SDK-declared backoff with no ceiling between attempts. And the revision
292    /// after THAT — the one that added the wheel's append-boundary refusal —
293    /// wrote entry 3 as though that boundary covered the whole wheel, when the
294    /// deadline path is demuxed away before it and had no refusal at all: an
295    /// engine released without `shutdown` could still record a durable
296    /// `WorkflowTimedOut` and tear a run down. **The enumeration was right and
297    /// the mechanism named under it was not**, which is the harder failure to
298    /// see, because the list looked complete.
299    ///
300    /// And the FOURTH time is the section above: every revision so far had
301    /// enumerated only what this drop *reaches*, and then written a guarantee
302    /// over every writer that *exists*. The workflow process is not on any list
303    /// of things a `Recorder` grep or a `spawn` grep produces, because nobody
304    /// spawned it here and it holds no handle this file can see — it is reached
305    /// through an installed NIF seam by code the operator wrote. **A search
306    /// shaped like the mechanism you already know will not find the writer you
307    /// do not.** That is why the method below now starts from the NIF
308    /// registration table, which is a closed set that something asserts the size
309    /// of, rather than from a grep whose completeness nothing checks.
310    ///
311    /// The way to check this list is: take
312    /// `runtime::engine_nifs::engine_nif_entries` and account for every entry;
313    /// grep the crate for every construction of a `Recorder` handle and every
314    /// detached `spawn`; and then, for each writer either search yields, follow
315    /// the ACTUAL route from the wake to the append and confirm the named gate
316    /// sits on it. Not to re-read this sentence and find it plausible.
317    fn drop(&mut self) {
318        if let Some(task) = &self.visibility_reconciliation_task {
319            task.abort();
320        }
321        self.runtime.nif_state().shutdown_timer_wheel();
322        self.runtime.engine_tasks().begin_close();
323    }
324}
325
326/// Components required to construct an [`Engine`].
327pub(crate) struct EngineComponents {
328    pub(crate) store: Arc<dyn EventStore>,
329    pub(crate) visibility_store: Arc<dyn VisibilityStore>,
330    pub(crate) runtime: Arc<RuntimeHandle>,
331    pub(crate) catalog: Arc<WorkflowCatalog>,
332    pub(crate) registry: Arc<Registry>,
333    pub(crate) supervision: Arc<SupervisionTree>,
334    pub(crate) delegated: DelegatedSeams,
335    pub(crate) signal_handoff: Arc<SignalResumeHandoff>,
336    pub(crate) search_attribute_schema: Arc<SearchAttributeSchema>,
337    pub(crate) visibility_reconciliation_task: Option<JoinHandle<()>>,
338    /// `Some` when the builder deferred startup recovery (#266): the stowed
339    /// recovery inputs [`Engine::run_startup_recovery`] consumes. `None` when
340    /// `build()` ran recovery itself, as it does by default.
341    pub(crate) deferred_startup_recovery: Option<super::startup_deferred::DeferredStartupRecovery>,
342}
343
344impl Engine {
345    /// Construct an engine from already-assembled components.
346    #[must_use]
347    pub(crate) fn new(components: EngineComponents) -> Self {
348        let EngineComponents {
349            store,
350            visibility_store,
351            runtime,
352            catalog,
353            registry,
354            supervision,
355            delegated,
356            signal_handoff,
357            search_attribute_schema,
358            visibility_reconciliation_task,
359            deferred_startup_recovery,
360        } = components;
361        let schedule_coordinator_workflow_id = schedule_coordinator_workflow_id();
362        let schedule_recorder = Arc::new(AsyncMutex::new(Recorder::new(
363            schedule_coordinator_workflow_id.clone(),
364            Arc::clone(&store),
365        )));
366        let runtime_arc = runtime;
367        let registry_arc = registry;
368        let supervision_arc = supervision;
369        let schedule_evaluator = Arc::new(AsyncMutex::new(default_schedule_evaluator(
370            schedule_coordinator_workflow_id.clone(),
371            Arc::clone(&schedule_recorder),
372            ScheduleRuntimeDeps {
373                store: Arc::clone(&store),
374                visibility_store: Arc::clone(&visibility_store),
375                runtime: Arc::clone(&runtime_arc),
376                catalog: Arc::clone(&catalog),
377                registry: Arc::clone(&registry_arc),
378                supervision: Arc::clone(&supervision_arc),
379                search_attribute_schema: Arc::clone(&search_attribute_schema),
380            },
381        )));
382        Self {
383            store,
384            visibility_store,
385            schedule_recorder,
386            schedule_evaluator,
387            schedule_coordinator_workflow_id,
388            runtime: runtime_arc,
389            catalog,
390            registry: registry_arc,
391            supervision: supervision_arc,
392            delegated,
393            signal_handoff,
394            search_attribute_schema,
395            shutdown_gate: ShutdownGate::default(),
396            deploy_mutations: AsyncMutex::new(()),
397            visibility_reconciliation_task,
398            deferred_startup_recovery: super::startup_deferred::DeferredRecoverySlot::from_build(
399                deferred_startup_recovery,
400            ),
401            paused_runs: crate::lifecycle::PausedRuns::default(),
402        }
403    }
404
405    /// Advance the schedule coordinator's recorder head to match persisted
406    /// events so that a rebuilt engine resumes appending at the correct
407    /// sequence rather than conflicting at head 0.
408    ///
409    /// # Errors
410    ///
411    /// Returns store read errors.
412    pub(crate) async fn catchup_schedule_coordinator(&self) -> Result<(), EngineError> {
413        let history = self
414            .store
415            .read_history(&self.schedule_coordinator_workflow_id)
416            .await?;
417        let head = u64::try_from(history.len()).unwrap_or(u64::MAX);
418        if head > 0 {
419            let mut recorder = self.schedule_recorder.lock().await;
420            *recorder = Recorder::resume_at(
421                self.schedule_coordinator_workflow_id.clone(),
422                Arc::clone(&self.store),
423                head,
424            );
425        }
426        Ok(())
427    }
428
429    /// Event store used by lifecycle and delegated AD/AT operations.
430    #[must_use]
431    pub fn store(&self) -> Arc<dyn EventStore> {
432        Arc::clone(&self.store)
433    }
434
435    /// Visibility store used for workflow summary projections.
436    #[must_use]
437    pub fn visibility_store(&self) -> Arc<dyn VisibilityStore> {
438        Arc::clone(&self.visibility_store)
439    }
440
441    /// Runtime boundary assembled for this engine.
442    #[must_use]
443    pub fn runtime(&self) -> &RuntimeHandle {
444        &self.runtime
445    }
446
447    /// Shared workflow package catalog: loaded versions and routing.
448    #[must_use]
449    pub fn workflow_catalog(&self) -> &Arc<WorkflowCatalog> {
450        &self.catalog
451    }
452
453    /// Active execution registry.
454    #[must_use]
455    pub fn registry(&self) -> &Registry {
456        &self.registry
457    }
458
459    /// Supervision tree snapshot/model.
460    #[must_use]
461    pub fn supervision(&self) -> &SupervisionTree {
462        &self.supervision
463    }
464
465    /// Delegated signal/query/subscribe seams installed for AT/AD integration.
466    #[must_use]
467    pub const fn delegated(&self) -> &DelegatedSeams {
468        &self.delegated
469    }
470
471    /// Shared in-memory handoff for already-recorded non-resident signals.
472    #[must_use]
473    pub fn signal_handoff(&self) -> Arc<SignalResumeHandoff> {
474        Arc::clone(&self.signal_handoff)
475    }
476
477    /// Absorb a dead peer's distribution shards into this LIVE engine and resume
478    /// their orphaned workflows — the SS-5 failover entry point.
479    ///
480    /// This is the production failover step a cluster supervisor invokes when it
481    /// observes a peer gone (membership loss). It is the post-boot counterpart to
482    /// the boot path's `EngineBuilder::owned_shards` election + recovery, run
483    /// against an already-running engine:
484    ///
485    /// 1. **Elect + union-merge.** `acquire_owned_shards` wins the per-shard
486    ///    election for each `shards` entry (fencing the dead owner) and
487    ///    `become_live` union-merges that shard's committed history locally, so
488    ///    every event the dead node had quorum-committed is now present on this
489    ///    node. The election is blocking and runs off the tokio runtime inside the
490    ///    store seam, honouring haematite's no-blocking-election-in-async
491    ///    constraint, so this `async` method may call it directly.
492    /// 2. **Widen the scope.** `extend_owned_shards` unions `shards` into this
493    ///    node's owned-enumeration set so the adopted workflows, timers, and
494    ///    outbox rows become visible to enumeration WITHOUT dropping this node's
495    ///    own shards.
496    /// 3. **Publish ownership.** `publish_shard_owner` records this node as each
497    ///    adopted shard's current owner in the cluster's quorum-replicated
498    ///    shard-owner directory (SS-3), so a request reaching a DIFFERENT survivor
499    ///    routes to this adopter rather than mis-resolving to the dead declared
500    ///    owner. The publish is fenced by the election just won, so only the true
501    ///    adopter writes it; a non-distributed store no-ops it.
502    /// 4. **Re-resident.** Re-run the idempotent active-workflow recovery and
503    ///    timer recovery, which re-spawn every adopted workflow from the
504    ///    union-merged history through the same production recovery seam the boot
505    ///    path uses, skipping the workflows this node already owns.
506    ///
507    /// Detection of the peer's death is the CALLER's responsibility (a cluster
508    /// supervisor / membership-loss trigger); this method performs the
509    /// re-acquisition and resume once that decision is made. It is idempotent:
510    /// adopting a shard this node already serves re-acquires (a no-op on the
511    /// fence it already holds) and recovers nothing new.
512    ///
513    /// # Errors
514    ///
515    /// Returns [`EngineError::ShuttingDown`] after shutdown begins, store errors
516    /// from the election / union-merge ([`EngineError::Durability`]), and any
517    /// typed recovery error from re-residenting an adopted workflow.
518    pub async fn adopt_shards(&self, shards: &[usize]) -> Result<(), EngineError> {
519        let operation = self.shutdown_gate.begin_start()?;
520        let result = self.adopt_shards_inner(shards).await;
521        drop(operation);
522        result
523    }
524
525    /// Body of [`Self::adopt_shards`]: acquire+publish each shard as a UNIT under
526    /// the double-adoption fence (ADR-021 clean-partial), then widen scope and
527    /// recover over EXACTLY the shards that survived BOTH steps.
528    ///
529    /// ## Ordering invariant (the fix)
530    ///
531    /// For each shard the publish-fence happens BEFORE the shard contributes to
532    /// `extend_owned_shards` AND before it is recovered. The pre-fix order
533    /// (extend → publish) let a survivor that won the election but was then
534    /// deposed at publish-time still widen its scope and recover the shard, so two
535    /// survivors could both execute its workflows. Here, a `NotOwner` from EITHER
536    /// `acquire_owned_shard` OR `publish_shard_owner` DROPS that shard: it never
537    /// reaches `extend_owned_shards`, is never recovered, and is NEVER a hard
538    /// `Durability` error. A deposed survivor therefore leaves ZERO widened
539    /// owned-shards scope and recovers nothing.
540    async fn adopt_shards_inner(&self, shards: &[usize]) -> Result<(), EngineError> {
541        // 1-3. Drive the double-adoption fence in the FIXED order (acquire →
542        //      publish per shard as a UNIT, then re-assert ownership and widen the
543        //      enumeration scope ONCE) and learn which shards survived it. A shard
544        //      deposed at acquire OR publish (or in the residual window) is dropped
545        //      cleanly — never extended, never recovered, never a hard error. The
546        //      planner GUARANTEES each survivor's publish-fence precedes both the
547        //      scope widening and (below) recovery. A single-node store no-ops
548        //      every step, so this path stays byte-identical there.
549        // The returned survivor set is already reflected in the store's widened
550        // owned-shard scope (the planner's single `extend`), which is what recovery
551        // enumerates over; the value is bound only to make that contract explicit.
552        let _recoverable = super::fence::plan_adopted_shards(
553            &super::fence::StoreFenceSeam {
554                store: &*self.store,
555            },
556            shards,
557        )?;
558        // 3b. Rebuild the pause dispatch-hold for the newly-adopted shards (#204).
559        //     The fence above widened the owned-shard scope, so `list_paused` now
560        //     sees the adopted shards' durably-`Paused` runs. `extend` (not replace)
561        //     preserves the holds for shards this node already owned. A run paused on
562        //     an adopted shard keeps its outbox rows held after failover; without this
563        //     the adopting node's dispatcher would claim and dispatch them. A store
564        //     error is logged, not fatal: the adoption itself is durable and the next
565        //     startup/rebuild repopulates the hold.
566        match self.store.list_paused().await {
567            Ok(paused) => self.paused_runs.extend(paused),
568            Err(error) => {
569                tracing::warn!(%error, "failed to rebuild paused-runs dispatch hold at shard adoption");
570            }
571        }
572        // 4. Re-resident the adopted workflows through the production recovery
573        //    seam (idempotent: this node's own workflows are skipped). Recovery
574        //    enumerates over the owned scope, which now contains only shards that
575        //    survived the fence.
576        super::startup::recover_adopted_shards(super::startup::StartupRecoveryContext {
577            store: Arc::clone(&self.store),
578            visibility_store: Arc::clone(&self.visibility_store),
579            runtime: Arc::clone(&self.runtime),
580            catalog: Arc::clone(&self.catalog),
581            registry: Arc::clone(&self.registry),
582            supervision: Arc::clone(&self.supervision),
583            recovery: None,
584            search_attribute_schema: Arc::clone(&self.search_attribute_schema),
585            bootstrap_schedule_coordinator: false,
586        })
587        .await?;
588        // 5. Re-arm durable timers for the adopted workflows — the SAME step the
589        //    boot path runs after `recover_active_workflows_on_startup` (see
590        //    `EngineBuilder::build`). This is LOAD-BEARING for a workflow PARKED on
591        //    a durable timer (#119): step 4 replays it and re-parks it, but the
592        //    replay of a not-yet-fired sleep does NOT re-arm the live wheel (only a
593        //    first, non-replay arrival does — see `nif_timer::sleep`'s `ResumeLive`
594        //    branch). Without this call the adopted workflow stays parked forever:
595        //    `recover_due` fires already-expired timers and
596        //    `rearm_future_from_active_histories` re-arms still-future ones onto the
597        //    now-resident process. Removing it reproduces the #119 symptom (a
598        //    survivor adopts the shard but the parked timer never reaches the
599        //    resumed workflow). Guarded by `tests/adoption_parked_timer_e2e.rs`
600        //    (single-process) and `tests/adoption_parked_timer_xnode_e2e.rs`
601        //    (real cross-node failover).
602        super::startup::recover_timers_on_startup(self.runtime.nif_state(), Arc::clone(&self.store))
603            .await
604    }
605
606    /// Gracefully stop accepting new starts and shut down the embedded runtime.
607    ///
608    /// # Errors
609    ///
610    /// Returns registry poison or runtime shutdown failures as typed errors.
611    pub fn shutdown(&self) -> Result<(), EngineError> {
612        if let Some(task) = &self.visibility_reconciliation_task {
613            task.abort();
614        }
615        // 🔴 THE EPOCH CLOSES FIRST, BEFORE ANY WAIT.
616        //
617        // The first cut put the unconditional close in `RuntimeHandle::shutdown`
618        // — one level BELOW the call the shipped server actually makes — and
619        // left this function short-circuiting above it. Two ways that lost the
620        // property it was written to guarantee:
621        //
622        //   1. `close_and_wait` returns `Err` on registry poison, so `?` here
623        //      returned before the epoch was ever gated and completion retries
624        //      stayed armed.
625        //   2. `close_and_wait` is a condvar wait with NO timeout. A lifecycle
626        //      operation stuck on a degraded store — precisely the condition
627        //      that arms completion retries in the first place — blocks this
628        //      function indefinitely, and the operator reasonably concludes the
629        //      node is wedged and brings up a successor while this process is
630        //      still appending terminals.
631        //
632        // Gating costs nothing, cannot fail, and is idempotent. Doing it first
633        // means no path through this function leaves retries armed. Everything
634        // after is teardown that still needs to run.
635        //
636        // 🔴 WHAT THIS ORDERING COSTS, STATED WHERE THE ORDERING IS CHOSEN.
637        // Process-exit callbacks are still admitted for the whole span between
638        // this line and `process_exits.begin_shutdown()` below, and the
639        // completion path refuses every one of them because the epoch is
640        // already closed. A run exiting in that window records no terminal and
641        // stays `Running` in the store, with one `error!` line naming it. The
642        // span is UNBOUNDED — `close_and_wait` is a condvar wait with no
643        // timeout — and it is longest under exactly the degraded-store
644        // condition the completion retries exist for. That window is the price
645        // of the two properties above and is argued in full at the refusal site
646        // (`lifecycle::completion`, at `refuse_if_epoch_closed`); it is
647        // repeated here because a reader deciding to move this line would
648        // otherwise not know a cost had been accepted.
649        self.runtime.engine_tasks().begin_close();
650        // Every step below runs on every path, and the FIRST error is returned
651        // at the end. A `?` here would skip the timer-wheel shutdown and the
652        // seam clearing, whose consequences are spelled out at their own call
653        // sites — an orphaned wheel task racing a survivor's adoption timer, and
654        // a durable backend's writer lock held past shutdown. Neither is
655        // something to trade for reporting an earlier error sooner.
656        //
657        // 🔴 THE SEAM CLEARING IS THE HALF WITH NO BACKSTOP, AND THAT IS THE
658        // WHOLE REASON. An earlier revision said `Drop for Engine` "backstops
659        // neither", which stopped being true in this same file when `Drop`
660        // gained `shutdown_timer_wheel` (see it above) — so the wheel half IS
661        // backstopped, and a reader checking only that half would conclude the
662        // `?` costs nothing. It does: `clear_engine_seams` runs from
663        // `Engine::shutdown` and NOWHERE else, by design — it may only run once
664        // the scheduler has stopped and both epochs are closed, which `Drop`
665        // cannot establish. Skip it and the `RuntimeHandle` ↔ `EngineNifState`
666        // cycle is never broken, so every store clone reached through the seams
667        // outlives the process's interest in them and a durable backend's
668        // cross-process writer lock is held until exit.
669        //
670        // 🔴 THE THIRD COST, STATED BECAUSE EVERY OTHER ONE IN THIS FUNCTION IS.
671        // `ShutdownGate::close_and_wait` returns `Err` on exactly one condition
672        // — mutex poison — and continuing past it means the gate's DRAIN
673        // guarantee is skipped, not merely its error deferred: a lifecycle
674        // operation admitted before the poison may still be in flight when
675        // `runtime.shutdown()` stops the scheduler and `clear_engine_seams()`
676        // nulls the seam slots. That is not a memory hazard (the slots are
677        // `Option`-shaped and a NIF reading a cleared one gets a typed error),
678        // and no NEW operation can be admitted either, because `begin_start`
679        // and `begin_operation` share the same poisoned `state()`. What is lost
680        // is the promise that nothing was still running when teardown began.
681        // Accepted for the same reason as the rest: the alternative is skipping
682        // the seam clearing, which is unbacked-up and permanent.
683        let mut first_error: Option<EngineError> = None;
684        // Scoped so the closure's unique borrow of `first_error` visibly ends
685        // before the value is read. (`drop(closure)` would end it just as
686        // surely — this crate is edition 2024, and under NLL a borrow ends at
687        // its last use — so this is a readability choice, not a soundness one.
688        // An earlier revision of this comment argued the opposite and was
689        // describing pre-NLL lexical scoping.)
690        {
691            // 🔴 THE SECOND ERROR IS REPORTED, NOT DISCARDED. Only one
692            // `EngineError` can be returned, but accumulate-and-continue means
693            // more than one step can fail — and at HEAD that could not happen
694            // at all, because `?` meant a later step never ran. Keeping only
695            // the first and dropping the rest would trade a skipped teardown
696            // for a swallowed failure, which is the same defect wearing the
697            // other hat: an operator seeing `RegistryPoisoned` would have no
698            // signal that the runtime teardown ALSO failed. Each subsequent
699            // failure is emitted at `error` level with the position that made
700            // it subsequent, so the log carries what the return value cannot.
701            let mut failed_steps = 0_u32;
702            let mut keep = |step: &'static str, result: Result<(), EngineError>| {
703                if let Err(error) = result {
704                    failed_steps += 1;
705                    if first_error.is_none() {
706                        first_error = Some(error);
707                    } else {
708                        tracing::error!(
709                            step,
710                            failed_steps,
711                            error = %error,
712                            "a further engine-shutdown step failed after an earlier one; only \
713                             the first failure can be returned, so this one is reported here"
714                        );
715                    }
716                }
717            };
718            keep(
719                "shutdown_gate.close_and_wait",
720                self.shutdown_gate.close_and_wait(),
721            );
722            // Epoch close for engine background tasks (F4): every watcher,
723            // spawn-recovery task and process-exit completion retry is aborted AND
724            // awaited to quiescence — a task still mid-record after shutdown could
725            // double-write a history a successor engine over the same store also
726            // records into. Arming is additionally gated inside the task registry
727            // the moment shutdown begins.
728            //
729            // `runtime.shutdown()` performs that close itself, because the
730            // completion retry is a core lifecycle path and its epoch close must not
731            // depend on whether an optional bridge was installed. The bridge call
732            // that follows is idempotent and kept only so an installed bridge
733            // participates explicitly.
734            keep("runtime.shutdown", self.runtime.shutdown());
735        }
736        self.runtime.nif_state().shutdown_engine_tasks();
737        // Abort armed live-wheel timer tasks (#119): they run on the tokio
738        // runtime, not the beamr scheduler, so `runtime.shutdown()` does not
739        // reach them. A timer this engine armed must NOT fire after the engine
740        // has stopped owning the workflow — otherwise, across a failover, the
741        // dead owner's orphaned wheel task races the survivor's adoption-armed
742        // timer and can record the one durable `TimerFired` first, leaving the
743        // survivor's resident sleeper parked forever.
744        self.runtime.nif_state().shutdown_timer_wheel();
745        // Break the RuntimeHandle <-> EngineNifState reference cycle (see
746        // EngineNifState::clear_engine_seams). The engine-scoped NIF seams each
747        // hold an Arc back to the runtime and/or clones of the event store and
748        // registry; without releasing them here the runtime, its NIF state, and
749        // every store clone they reach would outlive the dropped Engine
750        // forever, keeping a durable backend's writer lock held past shutdown.
751        // Safe now: the scheduler has stopped and the child-task and timer-wheel
752        // epochs have closed, so no NIF or background task can still read a slot.
753        self.runtime.nif_state().clear_engine_seams();
754        match first_error {
755            Some(error) => Err(error),
756            None => Ok(()),
757        }
758    }
759}
760
761pub(crate) fn terminal_outcome_from_history(events: &[Event]) -> Option<TerminalOutcome> {
762    // Reset-aware via the shared single-source predicate: the current lease's
763    // terminal event, where a reopen (WorkflowReopened) supersedes any earlier
764    // terminal.
765    match aion_core::current_lease_terminal(events)? {
766        Event::WorkflowCompleted { result, .. } => Some(TerminalOutcome::Completed(result.clone())),
767        Event::WorkflowFailed { error, .. } => Some(TerminalOutcome::Failed(error.clone())),
768        Event::WorkflowCancelled { reason, .. } => Some(TerminalOutcome::Cancelled(reason.clone())),
769        Event::WorkflowTimedOut { timeout, .. } => Some(TerminalOutcome::TimedOut(timeout.clone())),
770        Event::WorkflowContinuedAsNew {
771            input,
772            workflow_type,
773            parent_run_id,
774            ..
775        } => Some(TerminalOutcome::ContinuedAsNew {
776            input: input.clone(),
777            workflow_type: workflow_type.clone(),
778            parent_run_id: parent_run_id.clone(),
779        }),
780        _ => None,
781    }
782}
783
784pub(crate) fn workflow_not_found(id: &WorkflowId, run: &RunId) -> EngineError {
785    EngineError::WorkflowNotFound {
786        workflow_type: format!("{id}/{run}"),
787    }
788}
789
790#[cfg(test)]
791mod tests {
792    use std::sync::Arc;
793    use std::time::Duration;
794
795    use aion_core::{
796        Event, EventEnvelope, PackageVersion, Payload, RunId, SearchAttributeSchema,
797        TimerCancelCause, TimerId, WorkflowFilter, WorkflowId, WorkflowStatus,
798    };
799    use aion_package::ContentHash;
800    use aion_store::visibility::VisibilityStore;
801    use aion_store::{EventStore, InMemoryStore, ReadableEventStore};
802    use serde_json::json;
803
804    use std::collections::HashMap;
805
806    use super::{DelegatedSeams, Engine, EngineComponents};
807    use crate::durability::Recorder;
808    use crate::lifecycle::terminate::{self, TerminateWorkflowContext};
809    use crate::registry::{CompletionNotifier, HandleResidency, WorkflowHandleParts};
810    use crate::time::TimerRecovery;
811    use crate::time::timer_service::live_timers_in_active_segment;
812    use crate::{
813        EngineError, Registry, RuntimeConfig, RuntimeHandle, SupervisionTree, WorkflowCatalog,
814        WorkflowHandle,
815    };
816
817    fn payload(label: &str) -> Result<Payload, aion_core::PayloadError> {
818        Payload::from_json(&json!({ "label": label }))
819    }
820
821    fn workflow_error(message: &str) -> aion_core::WorkflowError {
822        aion_core::WorkflowError {
823            message: message.to_owned(),
824            details: None,
825        }
826    }
827
828    fn workflow_catalog(workflow_type: &str, deployed_module: &str) -> Arc<WorkflowCatalog> {
829        let catalog = Arc::new(WorkflowCatalog::new());
830        catalog.note_loaded_workflow_for_test(
831            workflow_type,
832            deployed_module,
833            "run",
834            ContentHash::from_bytes([5; 32]),
835        );
836        catalog
837    }
838
839    fn engine_with_loaded_workflow(
840        store: Arc<dyn EventStore>,
841        workflow_type: &str,
842        deployed_module: &str,
843    ) -> Result<Engine, EngineError> {
844        let runtime = RuntimeHandle::new(RuntimeConfig::new(Some(1)))?;
845        runtime.register_waiting_test_module(deployed_module, "run");
846        let visibility_store: Arc<dyn VisibilityStore> = Arc::new(InMemoryStore::default());
847        Ok(Engine::new(EngineComponents {
848            store,
849            visibility_store,
850            runtime: Arc::new(runtime),
851            catalog: workflow_catalog(workflow_type, deployed_module),
852            registry: Arc::new(Registry::default()),
853            supervision: Arc::new(SupervisionTree::new()),
854            delegated: DelegatedSeams::default(),
855            signal_handoff: Arc::new(crate::signal::SignalResumeHandoff::new()),
856            search_attribute_schema: Arc::new(SearchAttributeSchema::new()),
857            visibility_reconciliation_task: None,
858            deferred_startup_recovery: None,
859        }))
860    }
861
862    fn termination_context(engine: &Engine) -> TerminateWorkflowContext<'_> {
863        TerminateWorkflowContext {
864            runtime: engine.runtime(),
865            store: engine.store(),
866            visibility_store: engine.visibility_store(),
867            registry: engine.registry(),
868            catalog: engine.workflow_catalog(),
869        }
870    }
871
872    async fn insert_active_handle(
873        engine: &Engine,
874        store: Arc<dyn EventStore>,
875        workflow_type: &str,
876    ) -> Result<WorkflowHandle, Box<dyn std::error::Error>> {
877        let workflow_id = aion_core::WorkflowId::new_v4();
878        let run_id = aion_core::RunId::new_v4();
879        let mut recorder = Recorder::new(workflow_id.clone(), store);
880        recorder
881            .record_workflow_started(
882                chrono::Utc::now(),
883                crate::durability::WorkflowStartRecord {
884                    workflow_type: workflow_type.to_owned(),
885                    input: payload("input")?,
886                    run_id: run_id.clone(),
887                    parent_run_id: None,
888                    parent_workflow_id: None,
889                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
890                },
891            )
892            .await?;
893        let pid = engine.runtime().spawn_test_process_with_trap_exit(true)?;
894        let handle = WorkflowHandle::new(WorkflowHandleParts {
895            workflow_id: workflow_id.clone(),
896            run_id: run_id.clone(),
897            pid,
898            workflow_type: workflow_type.to_owned(),
899            namespace: String::from("default"),
900            loaded_version: ContentHash::from_bytes([9; 32]),
901            cached_status: WorkflowStatus::Running,
902            residency: HandleResidency::Resident,
903            recorder,
904            completion: CompletionNotifier::new(),
905        });
906        engine
907            .registry()
908            .insert((workflow_id, run_id), handle.clone())?;
909        Ok(handle)
910    }
911
912    #[tokio::test]
913    async fn start_then_cancel_records_started_then_cancelled()
914    -> Result<(), Box<dyn std::error::Error>> {
915        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
916        let engine =
917            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
918        let handle = engine
919            .start_workflow(
920                "checkout",
921                payload("input")?,
922                HashMap::new(),
923                String::from("default"),
924            )
925            .await?;
926
927        engine
928            .cancel(
929                handle.workflow_id(),
930                handle.run_id(),
931                "caller requested cancellation",
932            )
933            .await?;
934
935        let history = store.read_history(handle.workflow_id()).await?;
936        match history.as_slice() {
937            [
938                Event::WorkflowStarted { .. },
939                Event::WorkflowCancelled { reason, .. },
940            ] => {
941                assert_eq!(reason, "caller requested cancellation");
942            }
943            other => return Err(format!("expected started then cancelled, found {other:?}").into()),
944        }
945        engine.shutdown()?;
946        Ok(())
947    }
948
949    fn test_envelope(workflow_id: &WorkflowId, seq: u64) -> EventEnvelope {
950        EventEnvelope {
951            seq,
952            recorded_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap_or_default(),
953            workflow_id: workflow_id.clone(),
954        }
955    }
956
957    fn started_event(workflow_id: &WorkflowId, seq: u64) -> Event {
958        Event::WorkflowStarted {
959            envelope: test_envelope(workflow_id, seq),
960            workflow_type: String::from("checkout"),
961            input: Payload::new(aion_core::ContentType::Json, b"{}".to_vec()),
962            run_id: RunId::new_v4(),
963            parent_run_id: None,
964            parent_workflow_id: None,
965            package_version: PackageVersion::new("a".repeat(64)),
966        }
967    }
968
969    fn timer_started_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
970        Event::TimerStarted {
971            envelope: test_envelope(workflow_id, seq),
972            timer_id: timer_id.clone(),
973            fire_at: chrono::DateTime::from_timestamp(1_700_000_500, 0).unwrap_or_default(),
974        }
975    }
976
977    fn timer_fired_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
978        Event::TimerFired {
979            envelope: test_envelope(workflow_id, seq),
980            timer_id: timer_id.clone(),
981        }
982    }
983
984    fn timer_cancelled_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
985        Event::TimerCancelled {
986            envelope: test_envelope(workflow_id, seq),
987            timer_id: timer_id.clone(),
988            cause: TimerCancelCause::WorkflowIntent,
989        }
990    }
991
992    #[test]
993    fn live_timers_lists_started_and_unterminated() {
994        let workflow_id = WorkflowId::new_v4();
995        let first = TimerId::anonymous(0);
996        let second = TimerId::anonymous(1);
997        let history = vec![
998            started_event(&workflow_id, 0),
999            timer_started_event(&workflow_id, 1, &first),
1000            timer_started_event(&workflow_id, 2, &second),
1001        ];
1002        assert_eq!(
1003            live_timers_in_active_segment(&history),
1004            vec![first, second],
1005            "both started, unterminated timers should be live, in start order"
1006        );
1007    }
1008
1009    #[test]
1010    fn live_timers_excludes_fired_and_cancelled() {
1011        let workflow_id = WorkflowId::new_v4();
1012        let fired = TimerId::anonymous(0);
1013        let cancelled = TimerId::anonymous(1);
1014        let live = TimerId::anonymous(2);
1015        let history = vec![
1016            started_event(&workflow_id, 0),
1017            timer_started_event(&workflow_id, 1, &fired),
1018            timer_started_event(&workflow_id, 2, &cancelled),
1019            timer_started_event(&workflow_id, 3, &live),
1020            timer_fired_event(&workflow_id, 4, &fired),
1021            timer_cancelled_event(&workflow_id, 5, &cancelled),
1022        ];
1023        assert_eq!(
1024            live_timers_in_active_segment(&history),
1025            vec![live],
1026            "only the timer with no terminal event remains live"
1027        );
1028    }
1029
1030    #[test]
1031    fn live_timers_dedups_repeated_start() {
1032        let workflow_id = WorkflowId::new_v4();
1033        let timer = TimerId::anonymous(0);
1034        let history = vec![
1035            started_event(&workflow_id, 0),
1036            timer_started_event(&workflow_id, 1, &timer),
1037            timer_started_event(&workflow_id, 2, &timer),
1038        ];
1039        assert_eq!(live_timers_in_active_segment(&history), vec![timer]);
1040    }
1041
1042    #[test]
1043    fn live_timers_scopes_to_active_run_segment() {
1044        // A timer started in a prior run (before a continue-as-new
1045        // `WorkflowStarted`) must not be surfaced for the replacement run.
1046        let workflow_id = WorkflowId::new_v4();
1047        let prior_run = TimerId::anonymous(0);
1048        let current_run = TimerId::anonymous(0);
1049        let history = vec![
1050            started_event(&workflow_id, 0),
1051            timer_started_event(&workflow_id, 1, &prior_run),
1052            started_event(&workflow_id, 2),
1053            timer_started_event(&workflow_id, 3, &current_run),
1054        ];
1055        assert_eq!(
1056            live_timers_in_active_segment(&history),
1057            vec![current_run],
1058            "only timers from the latest WorkflowStarted segment are live"
1059        );
1060    }
1061
1062    #[test]
1063    fn live_timers_empty_history_is_empty() {
1064        assert!(live_timers_in_active_segment(&[]).is_empty());
1065    }
1066
1067    /// Build an engine whose runtime has the production timer NIF bridge
1068    /// installed against the given store + registry, so `Engine::cancel`'s timer
1069    /// cleanup exercises the real `TimerService` path (not a fake). Must be
1070    /// called from within a tokio runtime (`Handle::current()`).
1071    fn engine_with_timer_bridge(
1072        store: Arc<dyn EventStore>,
1073        registry: Arc<Registry>,
1074    ) -> Result<Engine, EngineError> {
1075        let runtime = RuntimeHandle::new(RuntimeConfig::new(Some(1)))?;
1076        runtime.register_waiting_test_module("checkout_deployed", "run");
1077        crate::runtime::nif_timer_bridge::install_timer_nif_bridge(
1078            runtime.nif_state(),
1079            Arc::clone(&registry),
1080            Arc::clone(&store),
1081            tokio::runtime::Handle::current(),
1082            crate::runtime::SignalDeliveryConfig::default(),
1083        );
1084        let visibility_store: Arc<dyn VisibilityStore> = Arc::new(InMemoryStore::default());
1085        Ok(Engine::new(EngineComponents {
1086            store,
1087            visibility_store,
1088            runtime: Arc::new(runtime),
1089            catalog: workflow_catalog("checkout", "checkout_deployed"),
1090            registry,
1091            supervision: Arc::new(SupervisionTree::new()),
1092            delegated: DelegatedSeams::default(),
1093            signal_handoff: Arc::new(crate::signal::SignalResumeHandoff::new()),
1094            search_attribute_schema: Arc::new(SearchAttributeSchema::new()),
1095            visibility_reconciliation_task: None,
1096            deferred_startup_recovery: None,
1097        }))
1098    }
1099
1100    /// Root-cause regression: cancelling a workflow with a live durable timer
1101    /// must record `TimerCancelled` (before the terminal `WorkflowCancelled`),
1102    /// so the timer is dead in history and recovery never fires it as an
1103    /// orphan. Drives the real `Engine::cancel` against a runtime with the
1104    /// production timer bridge installed.
1105    #[tokio::test(flavor = "multi_thread")]
1106    async fn cancel_records_timer_cancelled_before_workflow_cancelled()
1107    -> Result<(), Box<dyn std::error::Error>> {
1108        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1109        let registry = Arc::new(Registry::default());
1110        let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(&registry))?;
1111
1112        let handle = engine
1113            .start_workflow(
1114                "checkout",
1115                payload("input")?,
1116                HashMap::new(),
1117                String::from("default"),
1118            )
1119            .await?;
1120
1121        // Arm a live durable timer for the resident run and record its
1122        // `TimerStarted`, exactly as the resume-live handoff would in production.
1123        let timer_id = TimerId::anonymous(0);
1124        let fire_at = chrono::Utc::now() + chrono::Duration::hours(1);
1125        handle
1126            .recorder()
1127            .lock()
1128            .await
1129            .record_timer_started(chrono::Utc::now(), timer_id.clone(), fire_at)
1130            .await?;
1131        let timer_service =
1132            crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1133                .map_err(|error| format!("timer service unavailable: {error}"))?;
1134        timer_service
1135            .schedule(handle.workflow_id().clone(), timer_id.clone(), fire_at)
1136            .await?;
1137
1138        engine
1139            .cancel(
1140                handle.workflow_id(),
1141                handle.run_id(),
1142                "caller requested cancellation",
1143            )
1144            .await?;
1145
1146        let history = store.read_history(handle.workflow_id()).await?;
1147        match history.as_slice() {
1148            [
1149                Event::WorkflowStarted { .. },
1150                Event::TimerStarted {
1151                    timer_id: started, ..
1152                },
1153                Event::TimerCancelled {
1154                    timer_id: cancelled,
1155                    ..
1156                },
1157                Event::WorkflowCancelled { reason, .. },
1158            ] => {
1159                assert_eq!(started, &timer_id);
1160                assert_eq!(cancelled, &timer_id, "the live timer must be cancelled");
1161                assert_eq!(reason, "caller requested cancellation");
1162            }
1163            other => {
1164                return Err(format!(
1165                    "expected [started, timer-started, timer-cancelled, cancelled], found {other:?}"
1166                )
1167                .into());
1168            }
1169        }
1170        engine.shutdown()?;
1171        Ok(())
1172    }
1173
1174    /// All live timers (not just one) are cancelled, in start order, before the
1175    /// terminal `WorkflowCancelled`.
1176    #[tokio::test(flavor = "multi_thread")]
1177    async fn cancel_cancels_multiple_live_timers() -> Result<(), Box<dyn std::error::Error>> {
1178        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1179        let registry = Arc::new(Registry::default());
1180        let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(&registry))?;
1181        let handle = engine
1182            .start_workflow(
1183                "checkout",
1184                payload("input")?,
1185                HashMap::new(),
1186                String::from("default"),
1187            )
1188            .await?;
1189
1190        let first = TimerId::anonymous(0);
1191        let second = TimerId::anonymous(1);
1192        let fire_at = chrono::Utc::now() + chrono::Duration::hours(1);
1193        {
1194            let recorder = handle.recorder();
1195            let mut recorder = recorder.lock().await;
1196            recorder
1197                .record_timer_started(chrono::Utc::now(), first.clone(), fire_at)
1198                .await?;
1199            recorder
1200                .record_timer_started(chrono::Utc::now(), second.clone(), fire_at)
1201                .await?;
1202        }
1203
1204        engine
1205            .cancel(handle.workflow_id(), handle.run_id(), "stop")
1206            .await?;
1207
1208        let history = store.read_history(handle.workflow_id()).await?;
1209        match history.as_slice() {
1210            [
1211                Event::WorkflowStarted { .. },
1212                Event::TimerStarted {
1213                    timer_id: started_first,
1214                    ..
1215                },
1216                Event::TimerStarted {
1217                    timer_id: started_second,
1218                    ..
1219                },
1220                Event::TimerCancelled {
1221                    timer_id: cancelled_first,
1222                    ..
1223                },
1224                Event::TimerCancelled {
1225                    timer_id: cancelled_second,
1226                    ..
1227                },
1228                Event::WorkflowCancelled { .. },
1229            ] => {
1230                assert_eq!(started_first, &first);
1231                assert_eq!(started_second, &second);
1232                assert_eq!(cancelled_first, &first, "first live timer cancelled first");
1233                assert_eq!(
1234                    cancelled_second, &second,
1235                    "second live timer cancelled second"
1236                );
1237            }
1238            other => {
1239                return Err(format!(
1240                    "expected two timer-cancels before workflow-cancel, found {other:?}"
1241                )
1242                .into());
1243            }
1244        }
1245        engine.shutdown()?;
1246        Ok(())
1247    }
1248
1249    /// End-to-end source-of-bug proof: a cancelled workflow leaves no orphan for
1250    /// startup recovery. With a past-due durable timer row (the exact shape that
1251    /// bricked startup before the fix), recovery surfaces no `UnknownWorkflow`
1252    /// and fires nothing — because cancel recorded `TimerCancelled`, so the
1253    /// timer is dead in history. Complements the committed `recover_due` defense
1254    /// test by proving the orphan is gone *at the source*.
1255    #[tokio::test(flavor = "multi_thread")]
1256    async fn cancelled_workflow_leaves_no_orphan_for_recovery()
1257    -> Result<(), Box<dyn std::error::Error>> {
1258        let concrete: Arc<InMemoryStore> = Arc::new(InMemoryStore::default());
1259        let store: Arc<dyn EventStore> = concrete.clone();
1260        let registry = Arc::new(Registry::default());
1261        let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(&registry))?;
1262        let handle = engine
1263            .start_workflow(
1264                "checkout",
1265                payload("input")?,
1266                HashMap::new(),
1267                String::from("default"),
1268            )
1269            .await?;
1270        let workflow_id = handle.workflow_id().clone();
1271
1272        // A live timer whose durable row is already past-due, inserted directly
1273        // (no wheel arm, so nothing races the cancel).
1274        let timer_id = TimerId::anonymous(0);
1275        let fire_at = chrono::Utc::now() - chrono::Duration::hours(1);
1276        handle
1277            .recorder()
1278            .lock()
1279            .await
1280            .record_timer_started(chrono::Utc::now(), timer_id.clone(), fire_at)
1281            .await?;
1282        concrete
1283            .schedule_timer(&workflow_id, &timer_id, fire_at)
1284            .await?;
1285
1286        let timer_service =
1287            crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1288                .map_err(|error| format!("timer service unavailable: {error}"))?;
1289
1290        engine.cancel(&workflow_id, handle.run_id(), "stop").await?;
1291
1292        // Cancel removed the workflow from the registry and the durable row is
1293        // now past-due — exactly the orphan scenario. Recovery must handle it
1294        // cleanly: the recorded `TimerCancelled` makes `fire_timer` a no-op, so
1295        // no `TimerFired` and (critically) no `UnknownWorkflow`.
1296        let readable: Arc<dyn ReadableEventStore> = concrete.clone();
1297        TimerRecovery::new(readable, timer_service, Duration::ZERO)
1298            .recover_on_startup(chrono::Utc::now())
1299            .await?;
1300
1301        let history = concrete.read_history(&workflow_id).await?;
1302        assert!(
1303            !history
1304                .iter()
1305                .any(|event| matches!(event, Event::TimerFired { .. })),
1306            "no timer should fire for a cancelled workflow during recovery"
1307        );
1308        assert!(
1309            history
1310                .iter()
1311                .any(|event| matches!(event, Event::TimerCancelled { .. })),
1312            "cancel must have recorded TimerCancelled at the source"
1313        );
1314        engine.shutdown()?;
1315        Ok(())
1316    }
1317
1318    #[tokio::test]
1319    async fn result_returns_completed_payload() -> Result<(), Box<dyn std::error::Error>> {
1320        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1321        let engine =
1322            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1323        let handle = engine
1324            .start_workflow(
1325                "checkout",
1326                payload("input")?,
1327                HashMap::new(),
1328                String::from("default"),
1329            )
1330            .await?;
1331        let result_payload = payload("result")?;
1332
1333        terminate::complete(
1334            termination_context(&engine),
1335            handle.workflow_id(),
1336            handle.run_id(),
1337            result_payload.clone(),
1338        )
1339        .await?;
1340
1341        assert_eq!(
1342            engine.result(handle.workflow_id(), handle.run_id()).await?,
1343            Ok(result_payload)
1344        );
1345        engine.shutdown()?;
1346        Ok(())
1347    }
1348
1349    #[tokio::test]
1350    async fn result_returns_failed_workflow_error() -> Result<(), Box<dyn std::error::Error>> {
1351        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1352        let engine =
1353            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1354        let handle = engine
1355            .start_workflow(
1356                "checkout",
1357                payload("input")?,
1358                HashMap::new(),
1359                String::from("default"),
1360            )
1361            .await?;
1362        let error = workflow_error("workflow failed");
1363
1364        terminate::fail(
1365            termination_context(&engine),
1366            handle.workflow_id(),
1367            handle.run_id(),
1368            error.clone(),
1369        )
1370        .await?;
1371
1372        assert_eq!(
1373            engine.result(handle.workflow_id(), handle.run_id()).await?,
1374            Err(error)
1375        );
1376        engine.shutdown()?;
1377        Ok(())
1378    }
1379
1380    #[tokio::test]
1381    async fn result_unknown_workflow_returns_not_found() -> Result<(), Box<dyn std::error::Error>> {
1382        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1383        let engine = engine_with_loaded_workflow(store, "checkout", "checkout_deployed")?;
1384        let workflow_id = aion_core::WorkflowId::new_v4();
1385        let run_id = aion_core::RunId::new_v4();
1386
1387        let result = engine.result(&workflow_id, &run_id).await;
1388
1389        assert!(matches!(result, Err(EngineError::WorkflowNotFound { .. })));
1390        engine.shutdown()?;
1391        Ok(())
1392    }
1393
1394    /// F5: `Engine::shutdown`'s SECOND failing step must be reported.
1395    ///
1396    /// Its two fallible steps both go through `keep`, which returns the first
1397    /// and emits every later one at `error` level. That `else` arm IS the fix
1398    /// for the swallowed-second-error defect, and nothing asserted on it: the
1399    /// sibling below arms only the drain, which fails the SECOND step, so
1400    /// `first_error` is still `None` when `keep` sees it and the `else` is
1401    /// never taken.
1402    ///
1403    /// Reaching it needs BOTH steps to fail, which is why `ShutdownGate` gained
1404    /// its own injection seam. The gate's only real failure is mutex poison, so
1405    /// the injected error is `RegistryPoisoned` — a fault wearing the label its
1406    /// injection point can actually issue.
1407    ///
1408    /// Killing mutation: replace the `else` body inside `keep` with `{}`. No
1409    /// `error!` is emitted and the capture assertion fails.
1410    #[tokio::test]
1411    async fn a_second_failing_engine_shutdown_step_is_reported_not_swallowed()
1412    -> Result<(), Box<dyn std::error::Error>> {
1413        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1414        let engine = engine_with_loaded_workflow(store, "checkout", "checkout_deployed")?;
1415
1416        // Both steps fail: the gate first (so it is the returned error), the
1417        // runtime drain second (so it lands in the `else`).
1418        engine.shutdown_gate.force_close_failure();
1419        engine.runtime().force_process_exit_drain_failure();
1420
1421        let (captured, subscriber) = crate::log_capture::LogCapture::new()?;
1422        let returned = {
1423            let _installed = tracing::subscriber::set_default(subscriber);
1424            engine.shutdown()
1425        };
1426
1427        let error = returned
1428            .err()
1429            .ok_or("both steps failed, so shutdown must not return Ok")?;
1430        assert!(
1431            matches!(error, EngineError::RegistryPoisoned),
1432            "control: the FIRST failure is the one returned, and it is the gate's: {error:?}"
1433        );
1434
1435        let reported: Vec<_> = captured
1436            .at_level("ERROR")?
1437            .into_iter()
1438            .filter(|event| event.mentions("a further engine-shutdown step failed"))
1439            .collect();
1440        assert!(
1441            !reported.is_empty(),
1442            "the second failing step must be reported — a teardown failure with no trace at all \
1443             is a swallowed Result, which this codebase forbids outright"
1444        );
1445        assert!(
1446            reported
1447                .iter()
1448                .any(|event| event.field("step") == Some("runtime.shutdown")),
1449            "the report must NAME the step, or the operator cannot tell which half failed: \
1450             {reported:?}"
1451        );
1452        Ok(())
1453    }
1454
1455    /// 🔴 A FAILING TEARDOWN STEP DOES NOT CANCEL THE STEPS AFTER IT.
1456    ///
1457    /// `shutdown` used to be a chain of `?`, so the first step that failed
1458    /// returned and every later step — the runtime drain, and the three
1459    /// `nif_state` teardowns that release the engine's installed seams — simply
1460    /// never ran. The process then exited with a catalog still installed and an
1461    /// engine reference still reachable from the NIF table: the exact leak the
1462    /// function exists to prevent, produced by the error path of the function
1463    /// itself.
1464    ///
1465    /// The reason this went unmeasured is that no drain failure in here can be
1466    /// produced on demand, so no test ever took the error path at all.
1467    ///
1468    /// 🔴 WHY THAT SET IS UNREACHABLE IS STATED IN EXACTLY ONE PLACE, AND IT IS
1469    /// NOT HERE. See [`crate::RuntimeHandle::shutdown`].
1470    ///
1471    /// This comment has now been wrong twice about it, in two different ways —
1472    /// first "every one is timeout-shaped", then "the shared PRECONDITION: each
1473    /// needs a worker thread or a beamr publisher in a state no test can
1474    /// arrange". The second is false for `ProcessExitOutcomeMissingAfterEvent`,
1475    /// which is a beamr contract breach surfaced through `registry.process_event`
1476    /// and needs neither. It also reasons from a shared property, which is the
1477    /// move `RuntimeHandle::shutdown` explicitly forbids — the set is OPEN, so
1478    /// no property shared by today's members is safe to state about it.
1479    ///
1480    /// Two wrong answers in two revisions is what a rule known in two places
1481    /// does, and the cure is subtraction rather than a third attempt: the
1482    /// characterisation lives at the one site that owns the drain, and this one
1483    /// points at it. All that is needed locally is that
1484    /// `force_process_exit_drain_failure` is the named `#[cfg(test)]` seam that
1485    /// makes the path reachable at all, and that it cannot reach a shipped
1486    /// binary.
1487    ///
1488    /// **The decisive observable is (c), not (a).** That the error still reaches
1489    /// the caller is true of the old chain too — it is what the old chain did
1490    /// *instead of* finishing. Only `installed_workflow_catalog() == None` can
1491    /// tell "the later steps ran" from "the function returned early", because
1492    /// `clear_engine_seams` is ordered after the failing step. A test asserting
1493    /// only the error would pass against the defect.
1494    #[tokio::test]
1495    async fn a_failing_teardown_step_does_not_skip_the_ones_after_it()
1496    -> Result<(), Box<dyn std::error::Error>> {
1497        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1498        let engine = engine_with_loaded_workflow(store, "checkout", "checkout_deployed")?;
1499
1500        // Installed explicitly, because `engine_with_loaded_workflow` calls
1501        // `Engine::new` directly and only `EngineBuilder::build` installs the NIF
1502        // seams. Without this the assertion below would hold on an engine that
1503        // never had a catalog to clear — a pass measuring nothing. The control
1504        // that follows is what caught exactly that on the first cut of this test.
1505        engine
1506            .runtime()
1507            .nif_state()
1508            .set_workflow_catalog(Arc::clone(engine.workflow_catalog()));
1509
1510        // Control: the seam under (c) is genuinely installed before the call, so
1511        // a `None` afterwards is the teardown's doing and not the absence of
1512        // anything to tear down.
1513        assert!(
1514            engine
1515                .runtime()
1516                .nif_state()
1517                .installed_workflow_catalog()
1518                .is_some(),
1519            "control: the catalog must be installed before shutdown, or asserting it is \
1520             cleared afterwards measures nothing"
1521        );
1522
1523        engine.runtime().force_process_exit_drain_failure();
1524        let error = engine
1525            .shutdown()
1526            .err()
1527            .ok_or("an injected drain failure must be reported, not swallowed")?;
1528
1529        assert!(
1530            matches!(error, EngineError::ProcessExitRegistryPoisoned),
1531            "(a) the failure must reach the caller as itself: {error:?}"
1532        );
1533        assert!(
1534            !engine.runtime().engine_tasks().is_epoch_open(),
1535            "(b) the epoch must be closed — it is closed FIRST, so a shutdown that failed \
1536             later must still leave it shut"
1537        );
1538        assert!(
1539            engine
1540                .runtime()
1541                .nif_state()
1542                .installed_workflow_catalog()
1543                .is_none(),
1544            "(c) THE DECISIVE ONE: `clear_engine_seams` is ordered AFTER the step that \
1545             failed, so a still-installed catalog means the failure returned early and \
1546             the engine leaked its seams"
1547        );
1548        Ok(())
1549    }
1550
1551    #[tokio::test]
1552    async fn continue_as_new_unknown_workflow_returns_not_found()
1553    -> Result<(), Box<dyn std::error::Error>> {
1554        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1555        let engine = engine_with_loaded_workflow(store, "checkout", "checkout_deployed")?;
1556        let workflow_id = aion_core::WorkflowId::new_v4();
1557        let run_id = aion_core::RunId::new_v4();
1558
1559        let result = engine
1560            .continue_as_new(&workflow_id, &run_id, payload("next")?, None)
1561            .await;
1562
1563        assert!(matches!(result, Err(EngineError::WorkflowNotFound { .. })));
1564        engine.shutdown()?;
1565        Ok(())
1566    }
1567
1568    #[tokio::test]
1569    async fn list_workflows_merges_live_and_terminal_without_duplicates()
1570    -> Result<(), Box<dyn std::error::Error>> {
1571        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1572        let engine =
1573            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1574        let running = insert_active_handle(&engine, Arc::clone(&store), "checkout").await?;
1575        let completed = engine
1576            .start_workflow(
1577                "checkout",
1578                payload("input")?,
1579                HashMap::new(),
1580                String::from("default"),
1581            )
1582            .await?;
1583        terminate::complete(
1584            termination_context(&engine),
1585            completed.workflow_id(),
1586            completed.run_id(),
1587            payload("result")?,
1588        )
1589        .await?;
1590
1591        let summaries = engine.list_workflows(WorkflowFilter::default()).await?;
1592        assert_eq!(summaries.len(), 2);
1593        assert!(summaries.iter().any(|summary| {
1594            &summary.workflow_id == running.workflow_id()
1595                && summary.status == WorkflowStatus::Running
1596        }));
1597        assert!(summaries.iter().any(|summary| {
1598            &summary.workflow_id == completed.workflow_id()
1599                && summary.status == WorkflowStatus::Completed
1600        }));
1601
1602        let completed_only = engine
1603            .list_workflows(WorkflowFilter {
1604                status: Some(WorkflowStatus::Completed),
1605                ..WorkflowFilter::default()
1606            })
1607            .await?;
1608        assert_eq!(completed_only.len(), 1);
1609        assert_eq!(&completed_only[0].workflow_id, completed.workflow_id());
1610        engine.shutdown()?;
1611        Ok(())
1612    }
1613
1614    #[tokio::test]
1615    async fn shutdown_rejects_subsequent_starts() -> Result<(), Box<dyn std::error::Error>> {
1616        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1617        let engine =
1618            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1619        let handle = engine
1620            .start_workflow(
1621                "checkout",
1622                payload("input")?,
1623                HashMap::new(),
1624                String::from("default"),
1625            )
1626            .await?;
1627        terminate::complete(
1628            termination_context(&engine),
1629            handle.workflow_id(),
1630            handle.run_id(),
1631            payload("result")?,
1632        )
1633        .await?;
1634
1635        engine.shutdown()?;
1636        let result = engine
1637            .start_workflow(
1638                "checkout",
1639                payload("after-shutdown")?,
1640                HashMap::new(),
1641                String::from("default"),
1642            )
1643            .await;
1644
1645        assert!(matches!(result, Err(EngineError::ShuttingDown)));
1646        Ok(())
1647    }
1648
1649    #[tokio::test]
1650    async fn shutdown_is_idempotent() -> Result<(), Box<dyn std::error::Error>> {
1651        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1652        let engine =
1653            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1654        let handle = engine
1655            .start_workflow(
1656                "checkout",
1657                payload("input")?,
1658                HashMap::new(),
1659                String::from("default"),
1660            )
1661            .await?;
1662        terminate::complete(
1663            termination_context(&engine),
1664            handle.workflow_id(),
1665            handle.run_id(),
1666            payload("result")?,
1667        )
1668        .await?;
1669
1670        engine.shutdown()?;
1671        let second = engine.shutdown();
1672
1673        assert!(
1674            second.is_ok(),
1675            "double shutdown should succeed; got {second:?}"
1676        );
1677        Ok(())
1678    }
1679
1680    #[tokio::test]
1681    async fn shutdown_rejects_schedule_creation() -> Result<(), Box<dyn std::error::Error>> {
1682        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1683        let engine =
1684            engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1685        let handle = engine
1686            .start_workflow(
1687                "checkout",
1688                payload("input")?,
1689                HashMap::new(),
1690                String::from("default"),
1691            )
1692            .await?;
1693        terminate::complete(
1694            termination_context(&engine),
1695            handle.workflow_id(),
1696            handle.run_id(),
1697            payload("result")?,
1698        )
1699        .await?;
1700        engine.shutdown()?;
1701
1702        let config = aion_core::ScheduleConfig {
1703            trigger: aion_core::TriggerSpec::Interval {
1704                period: Duration::from_secs(60),
1705            },
1706            overlap_policy: aion_core::OverlapPolicy::Skip,
1707            catch_up_policy: aion_core::CatchUpPolicy::Skip,
1708            workflow_type: String::from("checkout"),
1709            input: payload("scheduled")?,
1710            search_attributes: HashMap::new(),
1711        };
1712        let result = engine.create_schedule(config).await;
1713
1714        assert!(
1715            matches!(result, Err(EngineError::ShuttingDown)),
1716            "create_schedule after shutdown should return ShuttingDown; got {result:?}"
1717        );
1718        Ok(())
1719    }
1720}