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    /// The workloop cadence machinery (service + sweep task + store), present
60    /// only when the builder configured it. The sweep task is a DURABLE
61    /// WRITER on the host runtime — its sink appends `CadenceFired` and
62    /// `InvariantUnconfirmed` through loop Recorders — so it is stopped in
63    /// BOTH [`Engine::shutdown`] and `Drop`, exactly like the visibility
64    /// reconciliation loop and for the same reason: the engine-task epoch
65    /// gate cannot reach a host-runtime task.
66    pub(super) workloop: Option<super::api_workloop::WorkloopEngineRuntime>,
67}
68
69impl Drop for Engine {
70    /// Close the engine-task epoch when the engine is released, whether or not
71    /// [`Engine::shutdown`] was ever called or ever succeeded.
72    ///
73    /// Without this, an engine dropped without a successful shutdown left
74    /// completion retries armed and appending terminal events. They could not
75    /// be stopped by `EngineTaskRuntime::drop` either: an attempt in flight
76    /// upgrades its weak reference and holds the `RuntimeHandle` strongly for
77    /// the length of the attempt, so the refcount never reaches zero and that
78    /// backstop is unreachable for precisely the span of the append it exists
79    /// to stop. This drop runs before the engine's own fields are released, so
80    /// it does not depend on that refcount at all.
81    ///
82    /// Closes the engine-task epoch with `EngineTaskRuntime::shutdown`, whose
83    /// runtime drop is isolated on a plain joiner thread. That makes joined
84    /// cleanup safe even when this `Drop` runs inside a host async context: the
85    /// epoch is gated, every task is aborted, and the executor's I/O driver is
86    /// released before `Drop` returns. The gate remains load-bearing for an
87    /// attempt already past an await boundary: its append boundary reads
88    /// `is_epoch_open` and refuses.
89    ///
90    /// # The visibility reconciliation task is aborted here for the same reason
91    ///
92    /// It runs on the HOST runtime, not the engine-task executor, so the epoch
93    /// gate does not reach it — and dropping its `JoinHandle` detaches rather
94    /// than cancels. It is an unbounded loop holding the event store and the
95    /// visibility store, and `reconcile_visibility` WRITES. Left detached, an
96    /// engine released without `shutdown` went on upserting visibility rows for
97    /// the life of the process, against a store a successor engine may already
98    /// own. `Engine::shutdown` aborts it as its first act; this does the same,
99    /// so the two paths agree.
100    ///
101    /// # The live timer wheel is disarmed here for the third time, same reason
102    ///
103    /// 🔴 THIS WAS MISSING, AND IT LEFT A DURABLE WRITER ARMED. Live-wheel
104    /// timer tasks are `tokio::spawn`ed on the HOST runtime
105    /// (`runtime/nif_timer_bridge.rs`), so — exactly like the reconciliation
106    /// loop — the engine-task epoch gate does not reach them. Their body is
107    /// `fire_wheel_timer`, which records a durable `TimerFired`. They hold a
108    /// `Weak<EngineNifState>`, and this drop deliberately does NOT clear the
109    /// seams (see below), so that upgrade succeeds and the fire proceeds.
110    ///
111    /// An engine released without `shutdown` therefore kept a durable-append
112    /// path armed for the life of the process. `Engine::shutdown` names the
113    /// consequence precisely: across a failover, the dead owner's orphaned
114    /// wheel task races the survivor's adoption-armed timer and can record the
115    /// one durable `TimerFired` first, leaving the survivor's resident sleeper
116    /// parked forever. That is the single-writer invariant, and nothing about
117    /// it cares whether the engine was shut down or dropped.
118    ///
119    /// Safe in a `Drop`: `shutdown_timer_wheel` sets a flag and then performs a
120    /// `DashMap` drain plus `abort()` — non-blocking, structurally identical to
121    /// the `visibility_reconciliation_task.abort()` above. It therefore remains
122    /// safe before the joined engine-task shutdown below.
123    ///
124    /// 🔴 AND IT IS A GATE, NOT ONLY A DRAIN — which it had to become for this
125    /// `Drop` to be worth anything. A drain closes the set of timers armed at
126    /// one instant; this `Drop` deliberately leaves the beamr scheduler and the
127    /// engine seams alive, so a workflow process still runnable could reach
128    /// `sleep` a moment later and arm a fresh durable `TimerFired` writer
129    /// through a wheel this drop believed it had emptied. `arm_timer` now
130    /// refuses once the flag is set (`nif_timer_bridge.rs`, `shut_down`), so
131    /// the guarantee below is a property of the wheel from here on rather than
132    /// of one instant.
133    ///
134    /// # 🔴 WHAT THIS DOES NOT DO, STATED SO NOBODY READS MORE INTO IT
135    ///
136    /// It does not clear the engine NIF seams. Those hold `Arc`s back to the
137    /// `RuntimeHandle`, so until `clear_engine_seams` runs the handle, its
138    /// beamr scheduler and every store clone they reach outlive this drop.
139    /// `Engine::shutdown` clears them only after the scheduler has stopped and
140    /// the child-task and timer-wheel epochs have closed; none of that has
141    /// happened here, and a NIF could still read a slot this drop cleared.
142    /// Trading a scheduler leak for a use-after-clear is the wrong direction,
143    /// so that leak stands and is named: **an engine released without explicit
144    /// `shutdown` still holds its scheduler and installed seams.** The dedicated
145    /// engine-task executor is different: it is joined below so its I/O driver
146    /// cannot accumulate process descriptors. What this drop guarantees for
147    /// durability is still narrower — **no durable writer this drop can reach
148    /// keeps writing, and no writer it cannot reach can end a run.** The first
149    /// clause covers FOUR BACKGROUND writers, stopped in two different ways:
150    ///
151    /// 1. anything armed on the **engine-task epoch** — `shutdown()` below;
152    /// 2. the **visibility reconciliation loop** — `abort()` below;
153    /// 3. the **live timer wheel** — `shutdown_timer_wheel()` below, which
154    ///    gates and drains, *and* refuses at the point of writing, because
155    ///    `abort` cannot stop a task already inside a poll. That refusal is in
156    ///    TWO places, not one, and the second is easy to miss: an ordinary timer
157    ///    is refused at the bridge's append boundary
158    ///    (`nif_timer_bridge.rs`, `record_workflow_event`), but a reserved
159    ///    `deadline:{run}` fire never reaches that boundary — `fire_timer_guarded`
160    ///    demuxes it to the deadline handler first — so it is refused inside
161    ///    [`crate::lifecycle::deadline::WorkflowDeadlineHandler`] instead, off
162    ///    the same latch;
163    /// 4. the **activity completion / retry task**
164    ///    ([`crate::runtime::nif_activity_retry_dispatch::spawn_completion_task`]),
165    ///    which this drop **cannot reach at all**: its `JoinHandle` is
166    ///    discarded, so it is detached on the host runtime and nothing here
167    ///    registers or aborts it. It is stopped instead at its append boundary,
168    ///    which reads `is_epoch_open()` under the recorder lock — so step 1's
169    ///    the engine-task epoch closure is what silences it, one indirection away.
170    ///
171    /// # 🔴 AND THERE IS A FIFTH, WHICH IS NOT A BACKGROUND WRITER AT ALL
172    ///
173    /// The four above are things the engine spawned; this drop stops them
174    /// because it can reach them. The fifth is the **workflow process itself**,
175    /// and this drop deliberately does not stop it — it leaves the beamr
176    /// scheduler running and the NIF seams installed, which is exactly what the
177    /// section above says it is trading for. A still-runnable workflow process
178    /// therefore keeps calling NIFs after the `Engine` is gone, and **13 of the
179    /// 24 registered engine NIFs perform durable writes** — `dispatch_activity`,
180    /// `dispatch_activity_in_vm`, `await_activity_result`, `sleep`,
181    /// `start_timer`, `cancel_timer`, `with_timeout`, `continue_as_new`,
182    /// `send_signal`, `spawn_child`, `collect_all`, `collect_race`,
183    /// `collect_map`. The other 11 read or reply and record nothing. The
184    /// registration table is `runtime::engine_nifs::engine_nif_entries`, whose
185    /// own test asserts the total, so both halves of that split are checkable
186    /// against a closed set rather than taken on trust — which is the point,
187    /// since the first draft of this paragraph carried a transposed count.
188    /// None of the 13 consults the engine-task epoch, and
189    /// nothing in the append path does either: `NifContext::block_on_recorder`
190    /// takes the recorder lock and nothing else, and `Recorder::append_one`
191    /// goes straight to `store.append`.
192    ///
193    /// An earlier revision of this doc said "there are FOUR" full stop, and was
194    /// wrong in the way that matters most: it did not omit an obscure writer, it
195    /// omitted **the one that executes user code**.
196    ///
197    /// What has been closed is the part that can END A RUN.
198    /// `WorkflowContinuedAsNew` is a TERMINAL, it was the ONE terminal this
199    /// fifth writer could still record, and it is now refused off the same epoch
200    /// (`runtime::nif_continue_as_new::record_continuation`). The reason it had
201    /// to be, in one line: **the successor run that terminal obliges was already
202    /// refused** at `completion::start_continuation_replacement`, so the two
203    /// halves of one transition disagreed and the run was left terminal with no
204    /// continuation. Every other terminal reachable from workflow code was
205    /// already gated — process exit at the completion append boundary,
206    /// `WorkflowTimedOut` off the timer bridge's stand-down latch.
207    ///
208    /// **And the refusal ENDS THE PROCESS, which is the half that makes it a
209    /// gain rather than a trade.** Before the gate, the recorder call either
210    /// succeeded or aborted the NIF, and the success path always reached
211    /// `cancel_pid` — that instruction is where this fifth writer died. A
212    /// refusal that merely returned early would have removed it, leaving the
213    /// process runnable and free to make every ungated write listed below. So
214    /// `runtime::nif_continue_as_new` terminates on the epoch refusal too — and,
215    /// of the refusals, on that one ONLY. A pre-terminal store fault is an
216    /// ordinary error workflow code may handle, and killing a process for it
217    /// would turn a transient blip into a dead run; an already-terminal run is
218    /// spared for a different reason — its terminal was recorded by a seam
219    /// that owns its own teardown, and of those owners some end the pid (a
220    /// second `cancel_pid` from here would race them) while some only
221    /// deregister (a kill from here would usurp them). The predicate's doc
222    /// carries that split; the "Five ordinary terminal paths" paragraph in
223    /// `lifecycle/completion.rs` carries the one enumeration of the owners.
224    /// It ALSO terminates whenever the terminal actually landed, including
225    /// the half-completed case where the terminal is durable but the deadline
226    /// retirement that follows it failed — because the question that decides
227    /// this is "did the terminal land", not "was there an error". The
228    /// predicate is `outcome_must_end_the_process`, pinned by a test with both
229    /// negative controls.
230    ///
231    /// The cost, stated because it is not zero: the refusal returns before
232    /// `retire_run_deadline`, so the predecessor's deadline row stays armed. A
233    /// restart gap longer than the run's remaining budget times the run out
234    /// instead of continuing it. That is the same exposure every other in-flight
235    /// run already carries across an outage; the old path escaped it only by
236    /// recording a terminal for a transition that never completed.
237    ///
238    /// # 🔴 WHAT IS STILL OPEN, AND WHY IT IS NOT CLOSED HERE
239    ///
240    /// A workflow process refused by the EPOCH gate is now stopped, so the
241    /// writes below are not reachable from that path. Say "the epoch gate" and
242    /// not "was refused": the other refusals deliberately leave the process
243    /// alive, so a reader who takes this sentence at its widest reading would
244    /// believe an exposure is closed that is open by design.
245    ///
246    /// They remain fully open on every other path — a process that never calls
247    /// `continue_as_new` is untouched by any of this and keeps writing.
248    ///
249    /// The fifth writer's NON-terminal durable writes are ungated and remain so:
250    /// `TimerStarted` plus a durable timer row (`sleep`, `start_timer`,
251    /// `with_timeout` — `TimerService::schedule` writes the row and only then
252    /// arms, so the wheel's refusal lands after both), activity schedule/start
253    /// and completion records, `spawn_child`'s whole child-start chain, and
254    /// `send_signal`, which writes into a THIRD workflow's history.
255    ///
256    /// Two things bound that, and neither is what a reader might assume:
257    /// - `WriteToken` fences NOTHING. It is a zero-sized marker with a public
258    ///   `recorder()` constructor and no engine, epoch, lease or node identity;
259    ///   two engines over one store both mint valid ones. Its own doc says so —
260    ///   it exists to stop an `Arc<dyn EventStore>` alone being write authority.
261    /// - `SequenceConflict` catches only the LOSER of a head race, and a
262    ///   released engine is structurally positioned to be the winner: its
263    ///   Recorder is the one already at the current head, because it is the one
264    ///   that has been appending. If it writes first, its write succeeds and the
265    ///   SUCCESSOR takes the conflict.
266    ///
267    /// So the remaining exposure is real and is stated rather than denied. It is
268    /// not closed here because **no flag in this crate distinguishes "released"
269    /// from "shutting down"** — `begin_close` sets one bit and both `Engine::drop`
270    /// and `Engine::shutdown` set it. A gate on that bit at a workflow-process
271    /// write path would therefore also fire during an ORDINARY graceful
272    /// shutdown, for the whole unbounded span between `begin_close()` and
273    /// `runtime.shutdown()` further down this file, and there the failure is an
274    /// `{error, _}` returned INSIDE running workflow code — a failed `sleep`, a
275    /// failed `spawn_child` — on runs the shutdown was trying to leave intact.
276    /// The terminal was worth that trade because its successor was already
277    /// refused at `start_continuation_replacement`: recording it could only
278    /// produce a run that is terminal with no continuation.
279    ///
280    /// ⚠️ **Refusing it is not free, and an earlier revision of this sentence
281    /// said it was.** It read "refusing cost nothing that was not already lost",
282    /// which is the exact claim `runtime::nif_continue_as_new`'s own
283    /// documentation exists to retract — and which the "cost, stated because it
284    /// is not zero" paragraph above already contradicts. The price is stated
285    /// there and holds here: the refusal returns before `retire_run_deadline`,
286    /// so the predecessor's deadline stays armed and a long enough outage
287    /// times the run out instead of continuing it. What makes the trade worth
288    /// taking is not that it is free but that the alternative bought its
289    /// exemption with a false terminal.
290    ///
291    /// Refusing ordinary progress is a different bargain and needs a latch that
292    /// means what it says. Do not add one of these gates without adding that
293    /// latch.
294    ///
295    /// 🔴 THAT LIST IS A CLAIM ABOUT DURABLE WRITERS AND IT IS ONLY AS GOOD AS
296    /// ITS ENUMERATION — four times proven. An earlier revision named two and
297    /// was wrong: the timer wheel was the third, and it was armed. The revision
298    /// after that named three and was also wrong: the completion task was the
299    /// fourth, it had no epoch check of any kind, and it sleeps an
300    /// SDK-declared backoff with no ceiling between attempts. And the revision
301    /// after THAT — the one that added the wheel's append-boundary refusal —
302    /// wrote entry 3 as though that boundary covered the whole wheel, when the
303    /// deadline path is demuxed away before it and had no refusal at all: an
304    /// engine released without `shutdown` could still record a durable
305    /// `WorkflowTimedOut` and tear a run down. **The enumeration was right and
306    /// the mechanism named under it was not**, which is the harder failure to
307    /// see, because the list looked complete.
308    ///
309    /// And the FOURTH time is the section above: every revision so far had
310    /// enumerated only what this drop *reaches*, and then written a guarantee
311    /// over every writer that *exists*. The workflow process is not on any list
312    /// of things a `Recorder` grep or a `spawn` grep produces, because nobody
313    /// spawned it here and it holds no handle this file can see — it is reached
314    /// through an installed NIF seam by code the operator wrote. **A search
315    /// shaped like the mechanism you already know will not find the writer you
316    /// do not.** That is why the method below now starts from the NIF
317    /// registration table, which is a closed set that something asserts the size
318    /// of, rather than from a grep whose completeness nothing checks.
319    ///
320    /// The way to check this list is: take
321    /// `runtime::engine_nifs::engine_nif_entries` and account for every entry;
322    /// grep the crate for every construction of a `Recorder` handle and every
323    /// detached `spawn`; and then, for each writer either search yields, follow
324    /// the ACTUAL route from the wake to the append and confirm the named gate
325    /// sits on it. Not to re-read this sentence and find it plausible.
326    fn drop(&mut self) {
327        if let Some(task) = &self.visibility_reconciliation_task {
328            task.abort();
329        }
330        // The workloop sweep task is a SIXTH durable writer on the host
331        // runtime (its sink appends CadenceFired/InvariantUnconfirmed through
332        // loop Recorders), added to the enumeration above the way that doc
333        // demands: it is stopped here and in `Engine::shutdown`, exactly like
334        // the visibility reconciliation loop, because the engine-task epoch
335        // gate cannot reach it.
336        if let Some(workloop) = &self.workloop {
337            workloop.stop();
338        }
339        self.runtime.nif_state().shutdown_timer_wheel();
340        self.runtime.engine_tasks().shutdown();
341    }
342}
343
344/// Components required to construct an [`Engine`].
345pub(crate) struct EngineComponents {
346    pub(crate) store: Arc<dyn EventStore>,
347    pub(crate) visibility_store: Arc<dyn VisibilityStore>,
348    pub(crate) runtime: Arc<RuntimeHandle>,
349    pub(crate) catalog: Arc<WorkflowCatalog>,
350    pub(crate) registry: Arc<Registry>,
351    pub(crate) supervision: Arc<SupervisionTree>,
352    pub(crate) delegated: DelegatedSeams,
353    pub(crate) signal_handoff: Arc<SignalResumeHandoff>,
354    pub(crate) search_attribute_schema: Arc<SearchAttributeSchema>,
355    pub(crate) visibility_reconciliation_task: Option<JoinHandle<()>>,
356    /// `Some` when the builder deferred startup recovery (#266): the stowed
357    /// recovery inputs [`Engine::run_startup_recovery`] consumes. `None` when
358    /// `build()` ran recovery itself, as it does by default.
359    pub(crate) deferred_startup_recovery: Option<super::startup_deferred::DeferredStartupRecovery>,
360    /// The assembled workloop machinery, when the builder configured it.
361    pub(crate) workloop: Option<super::api_workloop::WorkloopEngineRuntime>,
362}
363
364impl Engine {
365    /// Construct an engine from already-assembled components.
366    #[must_use]
367    pub(crate) fn new(components: EngineComponents) -> Self {
368        let EngineComponents {
369            store,
370            visibility_store,
371            runtime,
372            catalog,
373            registry,
374            supervision,
375            delegated,
376            signal_handoff,
377            search_attribute_schema,
378            visibility_reconciliation_task,
379            deferred_startup_recovery,
380            workloop,
381        } = components;
382        let schedule_coordinator_workflow_id = schedule_coordinator_workflow_id();
383        let schedule_recorder = Arc::new(AsyncMutex::new(Recorder::new(
384            schedule_coordinator_workflow_id.clone(),
385            Arc::clone(&store),
386        )));
387        let runtime_arc = runtime;
388        let registry_arc = registry;
389        let supervision_arc = supervision;
390        let schedule_evaluator = Arc::new(AsyncMutex::new(default_schedule_evaluator(
391            schedule_coordinator_workflow_id.clone(),
392            Arc::clone(&schedule_recorder),
393            ScheduleRuntimeDeps {
394                store: Arc::clone(&store),
395                visibility_store: Arc::clone(&visibility_store),
396                runtime: Arc::clone(&runtime_arc),
397                catalog: Arc::clone(&catalog),
398                registry: Arc::clone(&registry_arc),
399                supervision: Arc::clone(&supervision_arc),
400                search_attribute_schema: Arc::clone(&search_attribute_schema),
401            },
402        )));
403        Self {
404            store,
405            visibility_store,
406            schedule_recorder,
407            schedule_evaluator,
408            schedule_coordinator_workflow_id,
409            runtime: runtime_arc,
410            catalog,
411            registry: registry_arc,
412            supervision: supervision_arc,
413            delegated,
414            signal_handoff,
415            search_attribute_schema,
416            shutdown_gate: ShutdownGate::default(),
417            deploy_mutations: AsyncMutex::new(()),
418            visibility_reconciliation_task,
419            deferred_startup_recovery: super::startup_deferred::DeferredRecoverySlot::from_build(
420                deferred_startup_recovery,
421            ),
422            paused_runs: crate::lifecycle::PausedRuns::default(),
423            workloop,
424        }
425    }
426
427    /// Advance the schedule coordinator's recorder head to match persisted
428    /// events so that a rebuilt engine resumes appending at the correct
429    /// sequence rather than conflicting at head 0.
430    ///
431    /// # Errors
432    ///
433    /// Returns store read errors.
434    pub(crate) async fn catchup_schedule_coordinator(&self) -> Result<(), EngineError> {
435        let history = self
436            .store
437            .read_history(&self.schedule_coordinator_workflow_id)
438            .await?;
439        let head = u64::try_from(history.len()).unwrap_or(u64::MAX);
440        if head > 0 {
441            let mut recorder = self.schedule_recorder.lock().await;
442            *recorder = Recorder::resume_at(
443                self.schedule_coordinator_workflow_id.clone(),
444                Arc::clone(&self.store),
445                head,
446            );
447        }
448        Ok(())
449    }
450
451    /// Event store used by lifecycle and delegated AD/AT operations.
452    #[must_use]
453    pub fn store(&self) -> Arc<dyn EventStore> {
454        Arc::clone(&self.store)
455    }
456
457    /// Visibility store used for workflow summary projections.
458    #[must_use]
459    pub fn visibility_store(&self) -> Arc<dyn VisibilityStore> {
460        Arc::clone(&self.visibility_store)
461    }
462
463    /// Runtime boundary assembled for this engine.
464    #[must_use]
465    pub fn runtime(&self) -> &RuntimeHandle {
466        &self.runtime
467    }
468
469    /// Shared workflow package catalog: loaded versions and routing.
470    #[must_use]
471    pub fn workflow_catalog(&self) -> &Arc<WorkflowCatalog> {
472        &self.catalog
473    }
474
475    /// Active execution registry.
476    #[must_use]
477    pub fn registry(&self) -> &Registry {
478        &self.registry
479    }
480
481    /// Supervision tree snapshot/model.
482    #[must_use]
483    pub fn supervision(&self) -> &SupervisionTree {
484        &self.supervision
485    }
486
487    /// Delegated signal/query/subscribe seams installed for AT/AD integration.
488    #[must_use]
489    pub const fn delegated(&self) -> &DelegatedSeams {
490        &self.delegated
491    }
492
493    /// Shared in-memory handoff for already-recorded non-resident signals.
494    #[must_use]
495    pub fn signal_handoff(&self) -> Arc<SignalResumeHandoff> {
496        Arc::clone(&self.signal_handoff)
497    }
498
499    /// Absorb a dead peer's distribution shards into this LIVE engine and resume
500    /// their orphaned workflows — the SS-5 failover entry point.
501    ///
502    /// This is the production failover step a cluster supervisor invokes when it
503    /// observes a peer gone (membership loss). It is the post-boot counterpart to
504    /// the boot path's `EngineBuilder::owned_shards` election + recovery, run
505    /// against an already-running engine:
506    ///
507    /// 1. **Elect + union-merge.** `acquire_owned_shards` wins the per-shard
508    ///    election for each `shards` entry (fencing the dead owner) and
509    ///    `become_live` union-merges that shard's committed history locally, so
510    ///    every event the dead node had quorum-committed is now present on this
511    ///    node. The election is blocking and runs off the tokio runtime inside the
512    ///    store seam, honouring haematite's no-blocking-election-in-async
513    ///    constraint, so this `async` method may call it directly.
514    /// 2. **Widen the scope.** `extend_owned_shards` unions `shards` into this
515    ///    node's owned-enumeration set so the adopted workflows, timers, and
516    ///    outbox rows become visible to enumeration WITHOUT dropping this node's
517    ///    own shards.
518    /// 3. **Publish ownership.** `publish_shard_owner` records this node as each
519    ///    adopted shard's current owner in the cluster's quorum-replicated
520    ///    shard-owner directory (SS-3), so a request reaching a DIFFERENT survivor
521    ///    routes to this adopter rather than mis-resolving to the dead declared
522    ///    owner. The publish is fenced by the election just won, so only the true
523    ///    adopter writes it; a non-distributed store no-ops it.
524    /// 4. **Re-resident.** Re-run the idempotent active-workflow recovery and
525    ///    timer recovery, which re-spawn every adopted workflow from the
526    ///    union-merged history through the same production recovery seam the boot
527    ///    path uses, skipping the workflows this node already owns.
528    ///
529    /// Detection of the peer's death is the CALLER's responsibility (a cluster
530    /// supervisor / membership-loss trigger); this method performs the
531    /// re-acquisition and resume once that decision is made. It is idempotent:
532    /// adopting a shard this node already serves re-acquires (a no-op on the
533    /// fence it already holds) and recovers nothing new.
534    ///
535    /// # Errors
536    ///
537    /// Returns [`EngineError::ShuttingDown`] after shutdown begins, store errors
538    /// from the election / union-merge ([`EngineError::Durability`]), and any
539    /// typed recovery error from re-residenting an adopted workflow.
540    pub async fn adopt_shards(&self, shards: &[usize]) -> Result<(), EngineError> {
541        let operation = self.shutdown_gate.begin_start()?;
542        let result = self.adopt_shards_inner(shards).await;
543        drop(operation);
544        result
545    }
546
547    /// Body of [`Self::adopt_shards`]: acquire+publish each shard as a UNIT under
548    /// the double-adoption fence (ADR-021 clean-partial), then widen scope and
549    /// recover over EXACTLY the shards that survived BOTH steps.
550    ///
551    /// ## Ordering invariant (the fix)
552    ///
553    /// For each shard the publish-fence happens BEFORE the shard contributes to
554    /// `extend_owned_shards` AND before it is recovered. The pre-fix order
555    /// (extend → publish) let a survivor that won the election but was then
556    /// deposed at publish-time still widen its scope and recover the shard, so two
557    /// survivors could both execute its workflows. Here, a `NotOwner` from EITHER
558    /// `acquire_owned_shard` OR `publish_shard_owner` DROPS that shard: it never
559    /// reaches `extend_owned_shards`, is never recovered, and is NEVER a hard
560    /// `Durability` error. A deposed survivor therefore leaves ZERO widened
561    /// owned-shards scope and recovers nothing.
562    async fn adopt_shards_inner(&self, shards: &[usize]) -> Result<(), EngineError> {
563        // 1-3. Drive the double-adoption fence in the FIXED order (acquire →
564        //      publish per shard as a UNIT, then re-assert ownership and widen the
565        //      enumeration scope ONCE) and learn which shards survived it. A shard
566        //      deposed at acquire OR publish (or in the residual window) is dropped
567        //      cleanly — never extended, never recovered, never a hard error. The
568        //      planner GUARANTEES each survivor's publish-fence precedes both the
569        //      scope widening and (below) recovery. A single-node store no-ops
570        //      every step, so this path stays byte-identical there.
571        // The returned survivor set is already reflected in the store's widened
572        // owned-shard scope (the planner's single `extend`), which is what recovery
573        // enumerates over; the value is bound only to make that contract explicit.
574        let _recoverable = super::fence::plan_adopted_shards(
575            &super::fence::StoreFenceSeam {
576                store: &*self.store,
577            },
578            shards,
579        )?;
580        // 3b. Rebuild the pause dispatch-hold for the newly-adopted shards (#204).
581        //     The fence above widened the owned-shard scope, so `list_paused` now
582        //     sees the adopted shards' durably-`Paused` runs. `extend` (not replace)
583        //     preserves the holds for shards this node already owned. A run paused on
584        //     an adopted shard keeps its outbox rows held after failover; without this
585        //     the adopting node's dispatcher would claim and dispatch them. A store
586        //     error is logged, not fatal: the adoption itself is durable and the next
587        //     startup/rebuild repopulates the hold.
588        match self.store.list_paused().await {
589            Ok(paused) => self.paused_runs.extend(paused),
590            Err(error) => {
591                tracing::warn!(%error, "failed to rebuild paused-runs dispatch hold at shard adoption");
592            }
593        }
594        // 4. Re-resident the adopted workflows through the production recovery
595        //    seam (idempotent: this node's own workflows are skipped). Recovery
596        //    enumerates over the owned scope, which now contains only shards that
597        //    survived the fence.
598        super::startup::recover_adopted_shards(super::startup::StartupRecoveryContext {
599            store: Arc::clone(&self.store),
600            visibility_store: Arc::clone(&self.visibility_store),
601            runtime: Arc::clone(&self.runtime),
602            catalog: Arc::clone(&self.catalog),
603            registry: Arc::clone(&self.registry),
604            supervision: Arc::clone(&self.supervision),
605            recovery: None,
606            search_attribute_schema: Arc::clone(&self.search_attribute_schema),
607            bootstrap_schedule_coordinator: false,
608        })
609        .await?;
610        // 5. Re-arm durable timers for the adopted workflows — the SAME step the
611        //    boot path runs after `recover_active_workflows_on_startup` (see
612        //    `EngineBuilder::build`). This is LOAD-BEARING for a workflow PARKED on
613        //    a durable timer (#119): step 4 replays it and re-parks it, but the
614        //    replay of a not-yet-fired sleep does NOT re-arm the live wheel (only a
615        //    first, non-replay arrival does — see `nif_timer::sleep`'s `ResumeLive`
616        //    branch). Without this call the adopted workflow stays parked forever:
617        //    the recovery sweep fires already-expired timers and the startup
618        //    sweep's re-arm pass (`TimerRecovery::recover_on_startup`) re-arms
619        //    still-future ones onto the now-resident process. Removing it reproduces the #119 symptom (a
620        //    survivor adopts the shard but the parked timer never reaches the
621        //    resumed workflow). Guarded by `tests/adoption_parked_timer_e2e.rs`
622        //    (single-process) and `tests/adoption_parked_timer_xnode_e2e.rs`
623        //    (real cross-node failover).
624        super::startup::recover_timers_on_startup(self.runtime.nif_state(), Arc::clone(&self.store))
625            .await
626    }
627
628    /// Gracefully stop accepting new starts and shut down the embedded runtime.
629    ///
630    /// # Errors
631    ///
632    /// Returns registry poison or runtime shutdown failures as typed errors.
633    pub fn shutdown(&self) -> Result<(), EngineError> {
634        if let Some(task) = &self.visibility_reconciliation_task {
635            task.abort();
636        }
637        // The workloop sweep task is a durable writer on the host runtime,
638        // outside the engine-task epoch's reach: stop it here, first, for the
639        // same reason the reconciliation loop is aborted first.
640        if let Some(workloop) = &self.workloop {
641            workloop.stop();
642        }
643        // 🔴 THE EPOCH CLOSES FIRST, BEFORE ANY WAIT.
644        //
645        // The first cut put the unconditional close in `RuntimeHandle::shutdown`
646        // — one level BELOW the call the shipped server actually makes — and
647        // left this function short-circuiting above it. Two ways that lost the
648        // property it was written to guarantee:
649        //
650        //   1. `close_and_wait` returns `Err` on registry poison, so `?` here
651        //      returned before the epoch was ever gated and completion retries
652        //      stayed armed.
653        //   2. `close_and_wait` is a condvar wait with NO timeout. A lifecycle
654        //      operation stuck on a degraded store — precisely the condition
655        //      that arms completion retries in the first place — blocks this
656        //      function indefinitely, and the operator reasonably concludes the
657        //      node is wedged and brings up a successor while this process is
658        //      still appending terminals.
659        //
660        // Gating costs nothing, cannot fail, and is idempotent. Doing it first
661        // means no path through this function leaves retries armed. Everything
662        // after is teardown that still needs to run.
663        //
664        // 🔴 WHAT THIS ORDERING COSTS, STATED WHERE THE ORDERING IS CHOSEN.
665        // Process-exit callbacks are still admitted for the whole span between
666        // this line and `process_exits.begin_shutdown()` below, and the
667        // completion path refuses every one of them because the epoch is
668        // already closed. A run exiting in that window records no terminal and
669        // stays `Running` in the store, with one `error!` line naming it. The
670        // span is UNBOUNDED — `close_and_wait` is a condvar wait with no
671        // timeout — and it is longest under exactly the degraded-store
672        // condition the completion retries exist for. That window is the price
673        // of the two properties above and is argued in full at the refusal site
674        // (`lifecycle::completion`, at `refuse_if_epoch_closed`); it is
675        // repeated here because a reader deciding to move this line would
676        // otherwise not know a cost had been accepted.
677        self.runtime.engine_tasks().begin_close();
678        // Every step below runs on every path, and the FIRST error is returned
679        // at the end. A `?` here would skip the timer-wheel shutdown and the
680        // seam clearing, whose consequences are spelled out at their own call
681        // sites — an orphaned wheel task racing a survivor's adoption timer, and
682        // a durable backend's writer lock held past shutdown. Neither is
683        // something to trade for reporting an earlier error sooner.
684        //
685        // 🔴 THE SEAM CLEARING IS THE HALF WITH NO BACKSTOP, AND THAT IS THE
686        // WHOLE REASON. An earlier revision said `Drop for Engine` "backstops
687        // neither", which stopped being true in this same file when `Drop`
688        // gained `shutdown_timer_wheel` (see it above) — so the wheel half IS
689        // backstopped, and a reader checking only that half would conclude the
690        // `?` costs nothing. It does: `clear_engine_seams` runs from
691        // `Engine::shutdown` and NOWHERE else, by design — it may only run once
692        // the scheduler has stopped and both epochs are closed, which `Drop`
693        // cannot establish. Skip it and the `RuntimeHandle` ↔ `EngineNifState`
694        // cycle is never broken, so every store clone reached through the seams
695        // outlives the process's interest in them and a durable backend's
696        // cross-process writer lock is held until exit.
697        //
698        // 🔴 THE THIRD COST, STATED BECAUSE EVERY OTHER ONE IN THIS FUNCTION IS.
699        // `ShutdownGate::close_and_wait` returns `Err` on exactly one condition
700        // — mutex poison — and continuing past it means the gate's DRAIN
701        // guarantee is skipped, not merely its error deferred: a lifecycle
702        // operation admitted before the poison may still be in flight when
703        // `runtime.shutdown()` stops the scheduler and `clear_engine_seams()`
704        // nulls the seam slots. That is not a memory hazard (the slots are
705        // `Option`-shaped and a NIF reading a cleared one gets a typed error),
706        // and no NEW operation can be admitted either, because `begin_start`
707        // and `begin_operation` share the same poisoned `state()`. What is lost
708        // is the promise that nothing was still running when teardown began.
709        // Accepted for the same reason as the rest: the alternative is skipping
710        // the seam clearing, which is unbacked-up and permanent.
711        let mut first_error: Option<EngineError> = None;
712        // Scoped so the closure's unique borrow of `first_error` visibly ends
713        // before the value is read. (`drop(closure)` would end it just as
714        // surely — this crate is edition 2024, and under NLL a borrow ends at
715        // its last use — so this is a readability choice, not a soundness one.
716        // An earlier revision of this comment argued the opposite and was
717        // describing pre-NLL lexical scoping.)
718        {
719            // 🔴 THE SECOND ERROR IS REPORTED, NOT DISCARDED. Only one
720            // `EngineError` can be returned, but accumulate-and-continue means
721            // more than one step can fail — and at HEAD that could not happen
722            // at all, because `?` meant a later step never ran. Keeping only
723            // the first and dropping the rest would trade a skipped teardown
724            // for a swallowed failure, which is the same defect wearing the
725            // other hat: an operator seeing `RegistryPoisoned` would have no
726            // signal that the runtime teardown ALSO failed. Each subsequent
727            // failure is emitted at `error` level with the position that made
728            // it subsequent, so the log carries what the return value cannot.
729            let mut failed_steps = 0_u32;
730            let mut keep = |step: &'static str, result: Result<(), EngineError>| {
731                if let Err(error) = result {
732                    failed_steps += 1;
733                    if first_error.is_none() {
734                        first_error = Some(error);
735                    } else {
736                        tracing::error!(
737                            step,
738                            failed_steps,
739                            error = %error,
740                            "a further engine-shutdown step failed after an earlier one; only \
741                             the first failure can be returned, so this one is reported here"
742                        );
743                    }
744                }
745            };
746            keep(
747                "shutdown_gate.close_and_wait",
748                self.shutdown_gate.close_and_wait(),
749            );
750            // Epoch close for engine background tasks (F4): every watcher,
751            // spawn-recovery task and process-exit completion retry is aborted AND
752            // awaited to quiescence — a task still mid-record after shutdown could
753            // double-write a history a successor engine over the same store also
754            // records into. Arming is additionally gated inside the task registry
755            // the moment shutdown begins.
756            //
757            // `runtime.shutdown()` performs that close itself, because the
758            // completion retry is a core lifecycle path and its epoch close must not
759            // depend on whether an optional bridge was installed. The bridge call
760            // that follows is idempotent and kept only so an installed bridge
761            // participates explicitly.
762            keep("runtime.shutdown", self.runtime.shutdown());
763        }
764        self.runtime.nif_state().shutdown_engine_tasks();
765        // Abort armed live-wheel timer tasks (#119): they run on the tokio
766        // runtime, not the beamr scheduler, so `runtime.shutdown()` does not
767        // reach them. A timer this engine armed must NOT fire after the engine
768        // has stopped owning the workflow — otherwise, across a failover, the
769        // dead owner's orphaned wheel task races the survivor's adoption-armed
770        // timer and can record the one durable `TimerFired` first, leaving the
771        // survivor's resident sleeper parked forever.
772        self.runtime.nif_state().shutdown_timer_wheel();
773        // Break the RuntimeHandle <-> EngineNifState reference cycle (see
774        // EngineNifState::clear_engine_seams). The engine-scoped NIF seams each
775        // hold an Arc back to the runtime and/or clones of the event store and
776        // registry; without releasing them here the runtime, its NIF state, and
777        // every store clone they reach would outlive the dropped Engine
778        // forever, keeping a durable backend's writer lock held past shutdown.
779        // Safe now: the scheduler has stopped and the child-task and timer-wheel
780        // epochs have closed, so no NIF or background task can still read a slot.
781        self.runtime.nif_state().clear_engine_seams();
782        match first_error {
783            Some(error) => Err(error),
784            None => Ok(()),
785        }
786    }
787}
788
789pub(crate) fn terminal_outcome_from_history(events: &[Event]) -> Option<TerminalOutcome> {
790    // Reset-aware via the shared single-source predicate: the current lease's
791    // terminal event, where a reopen (WorkflowReopened) supersedes any earlier
792    // terminal.
793    match aion_core::current_lease_terminal(events)? {
794        Event::WorkflowCompleted { result, .. } => Some(TerminalOutcome::Completed(result.clone())),
795        Event::WorkflowFailed { error, .. } => Some(TerminalOutcome::Failed(error.clone())),
796        Event::WorkflowCancelled { reason, .. } => Some(TerminalOutcome::Cancelled(reason.clone())),
797        Event::WorkflowTimedOut { timeout, .. } => Some(TerminalOutcome::TimedOut(timeout.clone())),
798        Event::WorkflowContinuedAsNew {
799            input,
800            workflow_type,
801            parent_run_id,
802            ..
803        } => Some(TerminalOutcome::ContinuedAsNew {
804            input: input.clone(),
805            workflow_type: workflow_type.clone(),
806            parent_run_id: parent_run_id.clone(),
807        }),
808        _ => None,
809    }
810}
811
812pub(crate) fn workflow_not_found(id: &WorkflowId, run: &RunId) -> EngineError {
813    EngineError::WorkflowNotFound {
814        workflow_type: format!("{id}/{run}"),
815    }
816}
817
818#[cfg(test)]
819mod api_tests;