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(®istry_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 package_version: aion_core::PackageVersion::new("a".repeat(64)),
889 },
890 )
891 .await?;
892 let pid = engine.runtime().spawn_test_process_with_trap_exit(true)?;
893 let handle = WorkflowHandle::new(WorkflowHandleParts {
894 workflow_id: workflow_id.clone(),
895 run_id: run_id.clone(),
896 pid,
897 workflow_type: workflow_type.to_owned(),
898 namespace: String::from("default"),
899 loaded_version: ContentHash::from_bytes([9; 32]),
900 cached_status: WorkflowStatus::Running,
901 residency: HandleResidency::Resident,
902 recorder,
903 completion: CompletionNotifier::new(),
904 });
905 engine
906 .registry()
907 .insert((workflow_id, run_id), handle.clone())?;
908 Ok(handle)
909 }
910
911 #[tokio::test]
912 async fn start_then_cancel_records_started_then_cancelled()
913 -> Result<(), Box<dyn std::error::Error>> {
914 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
915 let engine =
916 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
917 let handle = engine
918 .start_workflow(
919 "checkout",
920 payload("input")?,
921 HashMap::new(),
922 String::from("default"),
923 )
924 .await?;
925
926 engine
927 .cancel(
928 handle.workflow_id(),
929 handle.run_id(),
930 "caller requested cancellation",
931 )
932 .await?;
933
934 let history = store.read_history(handle.workflow_id()).await?;
935 match history.as_slice() {
936 [
937 Event::WorkflowStarted { .. },
938 Event::WorkflowCancelled { reason, .. },
939 ] => {
940 assert_eq!(reason, "caller requested cancellation");
941 }
942 other => return Err(format!("expected started then cancelled, found {other:?}").into()),
943 }
944 engine.shutdown()?;
945 Ok(())
946 }
947
948 fn test_envelope(workflow_id: &WorkflowId, seq: u64) -> EventEnvelope {
949 EventEnvelope {
950 seq,
951 recorded_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap_or_default(),
952 workflow_id: workflow_id.clone(),
953 }
954 }
955
956 fn started_event(workflow_id: &WorkflowId, seq: u64) -> Event {
957 Event::WorkflowStarted {
958 envelope: test_envelope(workflow_id, seq),
959 workflow_type: String::from("checkout"),
960 input: Payload::new(aion_core::ContentType::Json, b"{}".to_vec()),
961 run_id: RunId::new_v4(),
962 parent_run_id: None,
963 package_version: PackageVersion::new("a".repeat(64)),
964 }
965 }
966
967 fn timer_started_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
968 Event::TimerStarted {
969 envelope: test_envelope(workflow_id, seq),
970 timer_id: timer_id.clone(),
971 fire_at: chrono::DateTime::from_timestamp(1_700_000_500, 0).unwrap_or_default(),
972 }
973 }
974
975 fn timer_fired_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
976 Event::TimerFired {
977 envelope: test_envelope(workflow_id, seq),
978 timer_id: timer_id.clone(),
979 }
980 }
981
982 fn timer_cancelled_event(workflow_id: &WorkflowId, seq: u64, timer_id: &TimerId) -> Event {
983 Event::TimerCancelled {
984 envelope: test_envelope(workflow_id, seq),
985 timer_id: timer_id.clone(),
986 cause: TimerCancelCause::WorkflowIntent,
987 }
988 }
989
990 #[test]
991 fn live_timers_lists_started_and_unterminated() {
992 let workflow_id = WorkflowId::new_v4();
993 let first = TimerId::anonymous(0);
994 let second = TimerId::anonymous(1);
995 let history = vec![
996 started_event(&workflow_id, 0),
997 timer_started_event(&workflow_id, 1, &first),
998 timer_started_event(&workflow_id, 2, &second),
999 ];
1000 assert_eq!(
1001 live_timers_in_active_segment(&history),
1002 vec![first, second],
1003 "both started, unterminated timers should be live, in start order"
1004 );
1005 }
1006
1007 #[test]
1008 fn live_timers_excludes_fired_and_cancelled() {
1009 let workflow_id = WorkflowId::new_v4();
1010 let fired = TimerId::anonymous(0);
1011 let cancelled = TimerId::anonymous(1);
1012 let live = TimerId::anonymous(2);
1013 let history = vec![
1014 started_event(&workflow_id, 0),
1015 timer_started_event(&workflow_id, 1, &fired),
1016 timer_started_event(&workflow_id, 2, &cancelled),
1017 timer_started_event(&workflow_id, 3, &live),
1018 timer_fired_event(&workflow_id, 4, &fired),
1019 timer_cancelled_event(&workflow_id, 5, &cancelled),
1020 ];
1021 assert_eq!(
1022 live_timers_in_active_segment(&history),
1023 vec![live],
1024 "only the timer with no terminal event remains live"
1025 );
1026 }
1027
1028 #[test]
1029 fn live_timers_dedups_repeated_start() {
1030 let workflow_id = WorkflowId::new_v4();
1031 let timer = TimerId::anonymous(0);
1032 let history = vec![
1033 started_event(&workflow_id, 0),
1034 timer_started_event(&workflow_id, 1, &timer),
1035 timer_started_event(&workflow_id, 2, &timer),
1036 ];
1037 assert_eq!(live_timers_in_active_segment(&history), vec![timer]);
1038 }
1039
1040 #[test]
1041 fn live_timers_scopes_to_active_run_segment() {
1042 // A timer started in a prior run (before a continue-as-new
1043 // `WorkflowStarted`) must not be surfaced for the replacement run.
1044 let workflow_id = WorkflowId::new_v4();
1045 let prior_run = TimerId::anonymous(0);
1046 let current_run = TimerId::anonymous(0);
1047 let history = vec![
1048 started_event(&workflow_id, 0),
1049 timer_started_event(&workflow_id, 1, &prior_run),
1050 started_event(&workflow_id, 2),
1051 timer_started_event(&workflow_id, 3, ¤t_run),
1052 ];
1053 assert_eq!(
1054 live_timers_in_active_segment(&history),
1055 vec![current_run],
1056 "only timers from the latest WorkflowStarted segment are live"
1057 );
1058 }
1059
1060 #[test]
1061 fn live_timers_empty_history_is_empty() {
1062 assert!(live_timers_in_active_segment(&[]).is_empty());
1063 }
1064
1065 /// Build an engine whose runtime has the production timer NIF bridge
1066 /// installed against the given store + registry, so `Engine::cancel`'s timer
1067 /// cleanup exercises the real `TimerService` path (not a fake). Must be
1068 /// called from within a tokio runtime (`Handle::current()`).
1069 fn engine_with_timer_bridge(
1070 store: Arc<dyn EventStore>,
1071 registry: Arc<Registry>,
1072 ) -> Result<Engine, EngineError> {
1073 let runtime = RuntimeHandle::new(RuntimeConfig::new(Some(1)))?;
1074 runtime.register_waiting_test_module("checkout_deployed", "run");
1075 crate::runtime::nif_timer_bridge::install_timer_nif_bridge(
1076 runtime.nif_state(),
1077 Arc::clone(®istry),
1078 Arc::clone(&store),
1079 tokio::runtime::Handle::current(),
1080 crate::runtime::SignalDeliveryConfig::default(),
1081 );
1082 let visibility_store: Arc<dyn VisibilityStore> = Arc::new(InMemoryStore::default());
1083 Ok(Engine::new(EngineComponents {
1084 store,
1085 visibility_store,
1086 runtime: Arc::new(runtime),
1087 catalog: workflow_catalog("checkout", "checkout_deployed"),
1088 registry,
1089 supervision: Arc::new(SupervisionTree::new()),
1090 delegated: DelegatedSeams::default(),
1091 signal_handoff: Arc::new(crate::signal::SignalResumeHandoff::new()),
1092 search_attribute_schema: Arc::new(SearchAttributeSchema::new()),
1093 visibility_reconciliation_task: None,
1094 deferred_startup_recovery: None,
1095 }))
1096 }
1097
1098 /// Root-cause regression: cancelling a workflow with a live durable timer
1099 /// must record `TimerCancelled` (before the terminal `WorkflowCancelled`),
1100 /// so the timer is dead in history and recovery never fires it as an
1101 /// orphan. Drives the real `Engine::cancel` against a runtime with the
1102 /// production timer bridge installed.
1103 #[tokio::test(flavor = "multi_thread")]
1104 async fn cancel_records_timer_cancelled_before_workflow_cancelled()
1105 -> Result<(), Box<dyn std::error::Error>> {
1106 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1107 let registry = Arc::new(Registry::default());
1108 let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(®istry))?;
1109
1110 let handle = engine
1111 .start_workflow(
1112 "checkout",
1113 payload("input")?,
1114 HashMap::new(),
1115 String::from("default"),
1116 )
1117 .await?;
1118
1119 // Arm a live durable timer for the resident run and record its
1120 // `TimerStarted`, exactly as the resume-live handoff would in production.
1121 let timer_id = TimerId::anonymous(0);
1122 let fire_at = chrono::Utc::now() + chrono::Duration::hours(1);
1123 handle
1124 .recorder()
1125 .lock()
1126 .await
1127 .record_timer_started(chrono::Utc::now(), timer_id.clone(), fire_at)
1128 .await?;
1129 let timer_service =
1130 crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1131 .map_err(|error| format!("timer service unavailable: {error}"))?;
1132 timer_service
1133 .schedule(handle.workflow_id().clone(), timer_id.clone(), fire_at)
1134 .await?;
1135
1136 engine
1137 .cancel(
1138 handle.workflow_id(),
1139 handle.run_id(),
1140 "caller requested cancellation",
1141 )
1142 .await?;
1143
1144 let history = store.read_history(handle.workflow_id()).await?;
1145 match history.as_slice() {
1146 [
1147 Event::WorkflowStarted { .. },
1148 Event::TimerStarted {
1149 timer_id: started, ..
1150 },
1151 Event::TimerCancelled {
1152 timer_id: cancelled,
1153 ..
1154 },
1155 Event::WorkflowCancelled { reason, .. },
1156 ] => {
1157 assert_eq!(started, &timer_id);
1158 assert_eq!(cancelled, &timer_id, "the live timer must be cancelled");
1159 assert_eq!(reason, "caller requested cancellation");
1160 }
1161 other => {
1162 return Err(format!(
1163 "expected [started, timer-started, timer-cancelled, cancelled], found {other:?}"
1164 )
1165 .into());
1166 }
1167 }
1168 engine.shutdown()?;
1169 Ok(())
1170 }
1171
1172 /// All live timers (not just one) are cancelled, in start order, before the
1173 /// terminal `WorkflowCancelled`.
1174 #[tokio::test(flavor = "multi_thread")]
1175 async fn cancel_cancels_multiple_live_timers() -> Result<(), Box<dyn std::error::Error>> {
1176 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1177 let registry = Arc::new(Registry::default());
1178 let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(®istry))?;
1179 let handle = engine
1180 .start_workflow(
1181 "checkout",
1182 payload("input")?,
1183 HashMap::new(),
1184 String::from("default"),
1185 )
1186 .await?;
1187
1188 let first = TimerId::anonymous(0);
1189 let second = TimerId::anonymous(1);
1190 let fire_at = chrono::Utc::now() + chrono::Duration::hours(1);
1191 {
1192 let recorder = handle.recorder();
1193 let mut recorder = recorder.lock().await;
1194 recorder
1195 .record_timer_started(chrono::Utc::now(), first.clone(), fire_at)
1196 .await?;
1197 recorder
1198 .record_timer_started(chrono::Utc::now(), second.clone(), fire_at)
1199 .await?;
1200 }
1201
1202 engine
1203 .cancel(handle.workflow_id(), handle.run_id(), "stop")
1204 .await?;
1205
1206 let history = store.read_history(handle.workflow_id()).await?;
1207 match history.as_slice() {
1208 [
1209 Event::WorkflowStarted { .. },
1210 Event::TimerStarted {
1211 timer_id: started_first,
1212 ..
1213 },
1214 Event::TimerStarted {
1215 timer_id: started_second,
1216 ..
1217 },
1218 Event::TimerCancelled {
1219 timer_id: cancelled_first,
1220 ..
1221 },
1222 Event::TimerCancelled {
1223 timer_id: cancelled_second,
1224 ..
1225 },
1226 Event::WorkflowCancelled { .. },
1227 ] => {
1228 assert_eq!(started_first, &first);
1229 assert_eq!(started_second, &second);
1230 assert_eq!(cancelled_first, &first, "first live timer cancelled first");
1231 assert_eq!(
1232 cancelled_second, &second,
1233 "second live timer cancelled second"
1234 );
1235 }
1236 other => {
1237 return Err(format!(
1238 "expected two timer-cancels before workflow-cancel, found {other:?}"
1239 )
1240 .into());
1241 }
1242 }
1243 engine.shutdown()?;
1244 Ok(())
1245 }
1246
1247 /// End-to-end source-of-bug proof: a cancelled workflow leaves no orphan for
1248 /// startup recovery. With a past-due durable timer row (the exact shape that
1249 /// bricked startup before the fix), recovery surfaces no `UnknownWorkflow`
1250 /// and fires nothing — because cancel recorded `TimerCancelled`, so the
1251 /// timer is dead in history. Complements the committed `recover_due` defense
1252 /// test by proving the orphan is gone *at the source*.
1253 #[tokio::test(flavor = "multi_thread")]
1254 async fn cancelled_workflow_leaves_no_orphan_for_recovery()
1255 -> Result<(), Box<dyn std::error::Error>> {
1256 let concrete: Arc<InMemoryStore> = Arc::new(InMemoryStore::default());
1257 let store: Arc<dyn EventStore> = concrete.clone();
1258 let registry = Arc::new(Registry::default());
1259 let engine = engine_with_timer_bridge(Arc::clone(&store), Arc::clone(®istry))?;
1260 let handle = engine
1261 .start_workflow(
1262 "checkout",
1263 payload("input")?,
1264 HashMap::new(),
1265 String::from("default"),
1266 )
1267 .await?;
1268 let workflow_id = handle.workflow_id().clone();
1269
1270 // A live timer whose durable row is already past-due, inserted directly
1271 // (no wheel arm, so nothing races the cancel).
1272 let timer_id = TimerId::anonymous(0);
1273 let fire_at = chrono::Utc::now() - chrono::Duration::hours(1);
1274 handle
1275 .recorder()
1276 .lock()
1277 .await
1278 .record_timer_started(chrono::Utc::now(), timer_id.clone(), fire_at)
1279 .await?;
1280 concrete
1281 .schedule_timer(&workflow_id, &timer_id, fire_at)
1282 .await?;
1283
1284 let timer_service =
1285 crate::runtime::nif_timer_bridge::installed_timer_service(engine.runtime().nif_state())
1286 .map_err(|error| format!("timer service unavailable: {error}"))?;
1287
1288 engine.cancel(&workflow_id, handle.run_id(), "stop").await?;
1289
1290 // Cancel removed the workflow from the registry and the durable row is
1291 // now past-due — exactly the orphan scenario. Recovery must handle it
1292 // cleanly: the recorded `TimerCancelled` makes `fire_timer` a no-op, so
1293 // no `TimerFired` and (critically) no `UnknownWorkflow`.
1294 let readable: Arc<dyn ReadableEventStore> = concrete.clone();
1295 TimerRecovery::new(readable, timer_service, Duration::ZERO)
1296 .recover_on_startup(chrono::Utc::now())
1297 .await?;
1298
1299 let history = concrete.read_history(&workflow_id).await?;
1300 assert!(
1301 !history
1302 .iter()
1303 .any(|event| matches!(event, Event::TimerFired { .. })),
1304 "no timer should fire for a cancelled workflow during recovery"
1305 );
1306 assert!(
1307 history
1308 .iter()
1309 .any(|event| matches!(event, Event::TimerCancelled { .. })),
1310 "cancel must have recorded TimerCancelled at the source"
1311 );
1312 engine.shutdown()?;
1313 Ok(())
1314 }
1315
1316 #[tokio::test]
1317 async fn result_returns_completed_payload() -> Result<(), Box<dyn std::error::Error>> {
1318 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1319 let engine =
1320 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1321 let handle = engine
1322 .start_workflow(
1323 "checkout",
1324 payload("input")?,
1325 HashMap::new(),
1326 String::from("default"),
1327 )
1328 .await?;
1329 let result_payload = payload("result")?;
1330
1331 terminate::complete(
1332 termination_context(&engine),
1333 handle.workflow_id(),
1334 handle.run_id(),
1335 result_payload.clone(),
1336 )
1337 .await?;
1338
1339 assert_eq!(
1340 engine.result(handle.workflow_id(), handle.run_id()).await?,
1341 Ok(result_payload)
1342 );
1343 engine.shutdown()?;
1344 Ok(())
1345 }
1346
1347 #[tokio::test]
1348 async fn result_returns_failed_workflow_error() -> Result<(), Box<dyn std::error::Error>> {
1349 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1350 let engine =
1351 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1352 let handle = engine
1353 .start_workflow(
1354 "checkout",
1355 payload("input")?,
1356 HashMap::new(),
1357 String::from("default"),
1358 )
1359 .await?;
1360 let error = workflow_error("workflow failed");
1361
1362 terminate::fail(
1363 termination_context(&engine),
1364 handle.workflow_id(),
1365 handle.run_id(),
1366 error.clone(),
1367 )
1368 .await?;
1369
1370 assert_eq!(
1371 engine.result(handle.workflow_id(), handle.run_id()).await?,
1372 Err(error)
1373 );
1374 engine.shutdown()?;
1375 Ok(())
1376 }
1377
1378 #[tokio::test]
1379 async fn result_unknown_workflow_returns_not_found() -> Result<(), Box<dyn std::error::Error>> {
1380 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1381 let engine = engine_with_loaded_workflow(store, "checkout", "checkout_deployed")?;
1382 let workflow_id = aion_core::WorkflowId::new_v4();
1383 let run_id = aion_core::RunId::new_v4();
1384
1385 let result = engine.result(&workflow_id, &run_id).await;
1386
1387 assert!(matches!(result, Err(EngineError::WorkflowNotFound { .. })));
1388 engine.shutdown()?;
1389 Ok(())
1390 }
1391
1392 /// F5: `Engine::shutdown`'s SECOND failing step must be reported.
1393 ///
1394 /// Its two fallible steps both go through `keep`, which returns the first
1395 /// and emits every later one at `error` level. That `else` arm IS the fix
1396 /// for the swallowed-second-error defect, and nothing asserted on it: the
1397 /// sibling below arms only the drain, which fails the SECOND step, so
1398 /// `first_error` is still `None` when `keep` sees it and the `else` is
1399 /// never taken.
1400 ///
1401 /// Reaching it needs BOTH steps to fail, which is why `ShutdownGate` gained
1402 /// its own injection seam. The gate's only real failure is mutex poison, so
1403 /// the injected error is `RegistryPoisoned` — a fault wearing the label its
1404 /// injection point can actually issue.
1405 ///
1406 /// Killing mutation: replace the `else` body inside `keep` with `{}`. No
1407 /// `error!` is emitted and the capture assertion fails.
1408 #[tokio::test]
1409 async fn a_second_failing_engine_shutdown_step_is_reported_not_swallowed()
1410 -> Result<(), Box<dyn std::error::Error>> {
1411 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1412 let engine = engine_with_loaded_workflow(store, "checkout", "checkout_deployed")?;
1413
1414 // Both steps fail: the gate first (so it is the returned error), the
1415 // runtime drain second (so it lands in the `else`).
1416 engine.shutdown_gate.force_close_failure();
1417 engine.runtime().force_process_exit_drain_failure();
1418
1419 let (captured, subscriber) = crate::log_capture::LogCapture::new()?;
1420 let returned = {
1421 let _installed = tracing::subscriber::set_default(subscriber);
1422 engine.shutdown()
1423 };
1424
1425 let error = returned
1426 .err()
1427 .ok_or("both steps failed, so shutdown must not return Ok")?;
1428 assert!(
1429 matches!(error, EngineError::RegistryPoisoned),
1430 "control: the FIRST failure is the one returned, and it is the gate's: {error:?}"
1431 );
1432
1433 let reported: Vec<_> = captured
1434 .at_level("ERROR")?
1435 .into_iter()
1436 .filter(|event| event.mentions("a further engine-shutdown step failed"))
1437 .collect();
1438 assert!(
1439 !reported.is_empty(),
1440 "the second failing step must be reported — a teardown failure with no trace at all \
1441 is a swallowed Result, which this codebase forbids outright"
1442 );
1443 assert!(
1444 reported
1445 .iter()
1446 .any(|event| event.field("step") == Some("runtime.shutdown")),
1447 "the report must NAME the step, or the operator cannot tell which half failed: \
1448 {reported:?}"
1449 );
1450 Ok(())
1451 }
1452
1453 /// 🔴 A FAILING TEARDOWN STEP DOES NOT CANCEL THE STEPS AFTER IT.
1454 ///
1455 /// `shutdown` used to be a chain of `?`, so the first step that failed
1456 /// returned and every later step — the runtime drain, and the three
1457 /// `nif_state` teardowns that release the engine's installed seams — simply
1458 /// never ran. The process then exited with a catalog still installed and an
1459 /// engine reference still reachable from the NIF table: the exact leak the
1460 /// function exists to prevent, produced by the error path of the function
1461 /// itself.
1462 ///
1463 /// The reason this went unmeasured is that no drain failure in here can be
1464 /// produced on demand, so no test ever took the error path at all.
1465 ///
1466 /// 🔴 WHY THAT SET IS UNREACHABLE IS STATED IN EXACTLY ONE PLACE, AND IT IS
1467 /// NOT HERE. See [`crate::RuntimeHandle::shutdown`].
1468 ///
1469 /// This comment has now been wrong twice about it, in two different ways —
1470 /// first "every one is timeout-shaped", then "the shared PRECONDITION: each
1471 /// needs a worker thread or a beamr publisher in a state no test can
1472 /// arrange". The second is false for `ProcessExitOutcomeMissingAfterEvent`,
1473 /// which is a beamr contract breach surfaced through `registry.process_event`
1474 /// and needs neither. It also reasons from a shared property, which is the
1475 /// move `RuntimeHandle::shutdown` explicitly forbids — the set is OPEN, so
1476 /// no property shared by today's members is safe to state about it.
1477 ///
1478 /// Two wrong answers in two revisions is what a rule known in two places
1479 /// does, and the cure is subtraction rather than a third attempt: the
1480 /// characterisation lives at the one site that owns the drain, and this one
1481 /// points at it. All that is needed locally is that
1482 /// `force_process_exit_drain_failure` is the named `#[cfg(test)]` seam that
1483 /// makes the path reachable at all, and that it cannot reach a shipped
1484 /// binary.
1485 ///
1486 /// **The decisive observable is (c), not (a).** That the error still reaches
1487 /// the caller is true of the old chain too — it is what the old chain did
1488 /// *instead of* finishing. Only `installed_workflow_catalog() == None` can
1489 /// tell "the later steps ran" from "the function returned early", because
1490 /// `clear_engine_seams` is ordered after the failing step. A test asserting
1491 /// only the error would pass against the defect.
1492 #[tokio::test]
1493 async fn a_failing_teardown_step_does_not_skip_the_ones_after_it()
1494 -> Result<(), Box<dyn std::error::Error>> {
1495 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1496 let engine = engine_with_loaded_workflow(store, "checkout", "checkout_deployed")?;
1497
1498 // Installed explicitly, because `engine_with_loaded_workflow` calls
1499 // `Engine::new` directly and only `EngineBuilder::build` installs the NIF
1500 // seams. Without this the assertion below would hold on an engine that
1501 // never had a catalog to clear — a pass measuring nothing. The control
1502 // that follows is what caught exactly that on the first cut of this test.
1503 engine
1504 .runtime()
1505 .nif_state()
1506 .set_workflow_catalog(Arc::clone(engine.workflow_catalog()));
1507
1508 // Control: the seam under (c) is genuinely installed before the call, so
1509 // a `None` afterwards is the teardown's doing and not the absence of
1510 // anything to tear down.
1511 assert!(
1512 engine
1513 .runtime()
1514 .nif_state()
1515 .installed_workflow_catalog()
1516 .is_some(),
1517 "control: the catalog must be installed before shutdown, or asserting it is \
1518 cleared afterwards measures nothing"
1519 );
1520
1521 engine.runtime().force_process_exit_drain_failure();
1522 let error = engine
1523 .shutdown()
1524 .err()
1525 .ok_or("an injected drain failure must be reported, not swallowed")?;
1526
1527 assert!(
1528 matches!(error, EngineError::ProcessExitRegistryPoisoned),
1529 "(a) the failure must reach the caller as itself: {error:?}"
1530 );
1531 assert!(
1532 !engine.runtime().engine_tasks().is_epoch_open(),
1533 "(b) the epoch must be closed — it is closed FIRST, so a shutdown that failed \
1534 later must still leave it shut"
1535 );
1536 assert!(
1537 engine
1538 .runtime()
1539 .nif_state()
1540 .installed_workflow_catalog()
1541 .is_none(),
1542 "(c) THE DECISIVE ONE: `clear_engine_seams` is ordered AFTER the step that \
1543 failed, so a still-installed catalog means the failure returned early and \
1544 the engine leaked its seams"
1545 );
1546 Ok(())
1547 }
1548
1549 #[tokio::test]
1550 async fn continue_as_new_unknown_workflow_returns_not_found()
1551 -> Result<(), Box<dyn std::error::Error>> {
1552 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1553 let engine = engine_with_loaded_workflow(store, "checkout", "checkout_deployed")?;
1554 let workflow_id = aion_core::WorkflowId::new_v4();
1555 let run_id = aion_core::RunId::new_v4();
1556
1557 let result = engine
1558 .continue_as_new(&workflow_id, &run_id, payload("next")?, None)
1559 .await;
1560
1561 assert!(matches!(result, Err(EngineError::WorkflowNotFound { .. })));
1562 engine.shutdown()?;
1563 Ok(())
1564 }
1565
1566 #[tokio::test]
1567 async fn list_workflows_merges_live_and_terminal_without_duplicates()
1568 -> Result<(), Box<dyn std::error::Error>> {
1569 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1570 let engine =
1571 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1572 let running = insert_active_handle(&engine, Arc::clone(&store), "checkout").await?;
1573 let completed = engine
1574 .start_workflow(
1575 "checkout",
1576 payload("input")?,
1577 HashMap::new(),
1578 String::from("default"),
1579 )
1580 .await?;
1581 terminate::complete(
1582 termination_context(&engine),
1583 completed.workflow_id(),
1584 completed.run_id(),
1585 payload("result")?,
1586 )
1587 .await?;
1588
1589 let summaries = engine.list_workflows(WorkflowFilter::default()).await?;
1590 assert_eq!(summaries.len(), 2);
1591 assert!(summaries.iter().any(|summary| {
1592 &summary.workflow_id == running.workflow_id()
1593 && summary.status == WorkflowStatus::Running
1594 }));
1595 assert!(summaries.iter().any(|summary| {
1596 &summary.workflow_id == completed.workflow_id()
1597 && summary.status == WorkflowStatus::Completed
1598 }));
1599
1600 let completed_only = engine
1601 .list_workflows(WorkflowFilter {
1602 status: Some(WorkflowStatus::Completed),
1603 ..WorkflowFilter::default()
1604 })
1605 .await?;
1606 assert_eq!(completed_only.len(), 1);
1607 assert_eq!(&completed_only[0].workflow_id, completed.workflow_id());
1608 engine.shutdown()?;
1609 Ok(())
1610 }
1611
1612 #[tokio::test]
1613 async fn shutdown_rejects_subsequent_starts() -> Result<(), Box<dyn std::error::Error>> {
1614 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1615 let engine =
1616 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1617 let handle = engine
1618 .start_workflow(
1619 "checkout",
1620 payload("input")?,
1621 HashMap::new(),
1622 String::from("default"),
1623 )
1624 .await?;
1625 terminate::complete(
1626 termination_context(&engine),
1627 handle.workflow_id(),
1628 handle.run_id(),
1629 payload("result")?,
1630 )
1631 .await?;
1632
1633 engine.shutdown()?;
1634 let result = engine
1635 .start_workflow(
1636 "checkout",
1637 payload("after-shutdown")?,
1638 HashMap::new(),
1639 String::from("default"),
1640 )
1641 .await;
1642
1643 assert!(matches!(result, Err(EngineError::ShuttingDown)));
1644 Ok(())
1645 }
1646
1647 #[tokio::test]
1648 async fn shutdown_is_idempotent() -> Result<(), Box<dyn std::error::Error>> {
1649 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1650 let engine =
1651 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1652 let handle = engine
1653 .start_workflow(
1654 "checkout",
1655 payload("input")?,
1656 HashMap::new(),
1657 String::from("default"),
1658 )
1659 .await?;
1660 terminate::complete(
1661 termination_context(&engine),
1662 handle.workflow_id(),
1663 handle.run_id(),
1664 payload("result")?,
1665 )
1666 .await?;
1667
1668 engine.shutdown()?;
1669 let second = engine.shutdown();
1670
1671 assert!(
1672 second.is_ok(),
1673 "double shutdown should succeed; got {second:?}"
1674 );
1675 Ok(())
1676 }
1677
1678 #[tokio::test]
1679 async fn shutdown_rejects_schedule_creation() -> Result<(), Box<dyn std::error::Error>> {
1680 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
1681 let engine =
1682 engine_with_loaded_workflow(Arc::clone(&store), "checkout", "checkout_deployed")?;
1683 let handle = engine
1684 .start_workflow(
1685 "checkout",
1686 payload("input")?,
1687 HashMap::new(),
1688 String::from("default"),
1689 )
1690 .await?;
1691 terminate::complete(
1692 termination_context(&engine),
1693 handle.workflow_id(),
1694 handle.run_id(),
1695 payload("result")?,
1696 )
1697 .await?;
1698 engine.shutdown()?;
1699
1700 let config = aion_core::ScheduleConfig {
1701 trigger: aion_core::TriggerSpec::Interval {
1702 period: Duration::from_secs(60),
1703 },
1704 overlap_policy: aion_core::OverlapPolicy::Skip,
1705 catch_up_policy: aion_core::CatchUpPolicy::Skip,
1706 workflow_type: String::from("checkout"),
1707 input: payload("scheduled")?,
1708 search_attributes: HashMap::new(),
1709 };
1710 let result = engine.create_schedule(config).await;
1711
1712 assert!(
1713 matches!(result, Err(EngineError::ShuttingDown)),
1714 "create_schedule after shutdown should return ShuttingDown; got {result:?}"
1715 );
1716 Ok(())
1717 }
1718}