aion/lifecycle/reopen.rs
1//! Reopen lifecycle operation: re-drive a terminal-Failed or terminal-Cancelled
2//! workflow from where it left off.
3//!
4//! Reopen turns a run whose current-lease terminal is [`WorkflowFailed`] or
5//! [`WorkflowCancelled`] back into a running one. It appends a single
6//! [`Event::WorkflowReopened`] that supersedes the terminal in the status
7//! projection (AD-011), then re-drives the SAME run through the existing recovery
8//! respawn-and-register path (AD-012) so replay returns every recorded result and
9//! only the reopened / in-flight step re-dispatches live — in the workflow's own
10//! namespace, re-derived from history.
11//!
12//! Invariant #3 (single writer): the recorder that appends `WorkflowReopened` is
13//! the very recorder handed to resident registration; no second writer is ever
14//! constructed for the reopened run.
15//!
16//! Durable timers (#222): a reopened run's clock must come back with it. The
17//! run segment is scanned last-event-wins per timer id — a timer whose last
18//! event is `TimerCancelled { cause: CancelTeardown }` was retired by the
19//! cancel teardown (engine bookkeeping, not workflow intent) and is re-armed at
20//! its ORIGINAL `fire_at`; a timer still outstanding (`TimerStarted` with no
21//! terminal — the failed-run case) is re-armed without touching history. A
22//! deadline already in the past fires immediately after respawn, so a run
23//! reopened after its deadline takes the timeout branch instead of parking
24//! forever. Workflow-intent cancellations are never resurrected.
25//!
26//! [`WorkflowFailed`]: aion_core::Event::WorkflowFailed
27//! [`WorkflowCancelled`]: aion_core::Event::WorkflowCancelled
28
29use std::sync::Arc;
30
31use aion_core::{
32 ActivityId, Event, RunId, SearchAttributeSchema, TimerCancelCause, TimerId, WorkflowId,
33 current_lease_terminal, run_segment, status_from_events,
34};
35use aion_store::EventStore;
36use aion_store::visibility::VisibilityStore;
37use chrono::{DateTime, Utc};
38
39use crate::EngineError;
40use crate::durability::{
41 ActiveWorkflowRecovery, ActiveWorkflowRecoverySeam, ActiveWorkflowRecoverySeamImpl, Recorder,
42};
43use crate::engine::startup::{
44 RecoveredResident, StartupRecoveryContext, recover_active_workflow, register_recovered_resident,
45};
46use crate::loader::WorkflowCatalog;
47use crate::registry::{Registry, WorkflowHandle};
48use crate::runtime::RuntimeHandle;
49use crate::supervision::SupervisionTree;
50
51/// Dependencies required to reopen a terminal workflow.
52pub struct ReopenWorkflowContext<'a> {
53 /// Durable event store used to read history and construct the recorder.
54 pub store: Arc<dyn EventStore>,
55 /// Visibility store the resumed recorder projects the reopened run into.
56 pub visibility_store: Arc<dyn VisibilityStore>,
57 /// Workflow catalog resolving the pinned package version to spawn.
58 pub catalog: Arc<WorkflowCatalog>,
59 /// Runtime boundary used to spawn the reopened workflow process.
60 pub runtime: &'a Arc<RuntimeHandle>,
61 /// Structural supervision tree recording the per-type supervisor placement.
62 pub supervision: Arc<SupervisionTree>,
63 /// Active execution registry keyed by workflow/run identifiers.
64 pub registry: &'a Arc<Registry>,
65 /// Schema shared with startup recovery's resident registration.
66 pub search_attribute_schema: Arc<SearchAttributeSchema>,
67}
68
69/// Reopens a terminal-`Failed` or terminal-`Cancelled` run and re-drives it.
70///
71/// Validates the precondition, computes the reopened activity set, appends
72/// `WorkflowReopened` through one continuous recorder, then respawns and
73/// registers the run as Resident via the existing recovery path.
74///
75/// # Errors
76///
77/// Returns [`EngineError::WorkflowNotFound`] when no history exists for
78/// `(id, run)`, and [`EngineError::InvalidState`] when the run is not currently
79/// terminal, terminal for a non-reopenable reason (Completed/`TimedOut`), or
80/// already Running. A double-write race surfaces as a hard durability error via
81/// the recorder's expected-sequence discipline. Recorder, runtime, supervision,
82/// and registry failures surface as their typed [`EngineError`] variants.
83pub async fn reopen(
84 context: ReopenWorkflowContext<'_>,
85 id: &WorkflowId,
86 run: &RunId,
87) -> Result<WorkflowHandle, EngineError> {
88 let history = context.store.read_history(id).await?;
89 if history.is_empty() {
90 return Err(crate::engine::api::workflow_not_found(id, run));
91 }
92
93 // Validate against HISTORY first so the rejection names the run's true
94 // state: a terminal run can still hold a lingering registry handle (a
95 // Completed run's handle lives until reconciliation), and the handle check
96 // alone used to misreport every such rejection as "already Running".
97 let segment = run_segment(&history, run);
98 let reopened = validate_and_compute_reopened(id, run, segment)?;
99
100 // History says reopenable-terminal, but a live handle can mean two things.
101 // A run that fails while resident keeps its suspended handle registered —
102 // `reconcile_terminal_registry` reconciles the cached status to the
103 // terminal and suspends residency without removing the entry — so a
104 // terminal-cached handle is that leftover, not a live run: clear it and
105 // proceed. Only a non-terminal cached status means a concurrent reopen
106 // already won the race and is driving the run. Two reopens racing past
107 // this check are still serialized by the recorder's expected-sequence
108 // discipline (the loser's `WorkflowReopened` append fails).
109 if let Some(existing) = context.registry.get(id, run)? {
110 if existing.cached_status().is_terminal() {
111 context.registry.remove(id, run)?;
112 } else {
113 return Err(EngineError::InvalidState {
114 reason: format!(
115 "workflow {id} run {run} was already reopened and is Running (concurrent reopen)"
116 ),
117 });
118 }
119 }
120
121 let rearm = rearmable_timers(segment);
122
123 // ONE continuous recorder from the WorkflowReopened append through the
124 // respawn (invariant #3): built at the history head, it appends the marker
125 // and is then handed — the same instance — to resident registration.
126 let history_head = history.last().map(Event::seq).unwrap_or_default();
127 let mut recorder = Recorder::resume_at(id.clone(), Arc::clone(&context.store), history_head)
128 .with_visibility(run.clone(), Arc::clone(&context.visibility_store));
129 recorder
130 .record_workflow_reopened(Utc::now(), run.clone(), reopened)
131 .await?;
132 // A teardown-cancelled timer's last event is TimerCancelled, so liveness
133 // (last-event-wins) reads it as dead: record a fresh TimerStarted — same
134 // id, ORIGINAL fire_at — through the same recorder so the fire/replay
135 // machinery sees it live again. Still-outstanding timers (failed-run case)
136 // stay untouched: re-recording one would put a second unconsumed
137 // TimerStarted resolution in front of replay.
138 for timer in rearm.iter().filter(|timer| timer.needs_restart_marker) {
139 recorder
140 .record_timer_started(Utc::now(), timer.timer_id.clone(), timer.fire_at)
141 .await?;
142 }
143
144 // Re-read so registration reconciles against the history that now INCLUDES
145 // the WorkflowReopened marker: the projection (and the recovered resident's
146 // reconciled cached status) is Running, not the superseded terminal.
147 let history = context.store.read_history(id).await?;
148 let handle = respawn_and_register(&context, id, run, &history, recorder).await?;
149 rearm_reopened_timers(&context, id, handle.pid(), &rearm).await?;
150 Ok(handle)
151}
152
153/// A durable timer a reopened or resumed run must get back.
154pub(crate) struct RearmTimer {
155 pub(crate) timer_id: TimerId,
156 /// The ORIGINAL deadline. Reopen never extends a business deadline; one
157 /// already in the past fires immediately after respawn.
158 pub(crate) fire_at: DateTime<Utc>,
159 /// True when the cancel teardown recorded `TimerCancelled` for this timer,
160 /// so a fresh `TimerStarted` must be appended to make it live again. False
161 /// for a timer still outstanding in history (failed-run case) — only the
162 /// wheel/durable row needs re-arming.
163 pub(crate) needs_restart_marker: bool,
164}
165
166/// The run segment's timers that reopen must re-arm, decided last-event-wins
167/// per timer id:
168///
169/// * last event `TimerStarted` — outstanding at the terminal (a failure tears
170/// nothing down): re-arm the wheel/row only.
171/// * last event `TimerCancelled { cause: CancelTeardown }` — retired by the
172/// cancel teardown: append a fresh `TimerStarted` and re-arm.
173/// * last event `TimerFired` or `TimerCancelled { cause: WorkflowIntent }` —
174/// a settled business fact: never resurrected.
175pub(crate) fn rearmable_timers(segment: &[Event]) -> Vec<RearmTimer> {
176 use std::collections::HashMap;
177
178 struct TimerTrace {
179 fire_at: DateTime<Utc>,
180 last_was_teardown_cancel: Option<bool>,
181 }
182
183 let mut traces: HashMap<TimerId, TimerTrace> = HashMap::new();
184 let mut order: Vec<TimerId> = Vec::new();
185 for event in segment {
186 match event {
187 Event::TimerStarted {
188 timer_id, fire_at, ..
189 } => {
190 if !traces.contains_key(timer_id) {
191 order.push(timer_id.clone());
192 }
193 traces.insert(
194 timer_id.clone(),
195 TimerTrace {
196 fire_at: *fire_at,
197 last_was_teardown_cancel: None,
198 },
199 );
200 }
201 Event::TimerFired { timer_id, .. } => {
202 traces.remove(timer_id);
203 }
204 Event::TimerCancelled {
205 timer_id, cause, ..
206 } => {
207 if let Some(trace) = traces.get_mut(timer_id) {
208 match cause {
209 TimerCancelCause::CancelTeardown => {
210 trace.last_was_teardown_cancel = Some(true);
211 }
212 TimerCancelCause::WorkflowIntent => {
213 traces.remove(timer_id);
214 }
215 }
216 }
217 }
218 _ => {}
219 }
220 }
221
222 order
223 .into_iter()
224 .filter_map(|timer_id| {
225 traces.remove(&timer_id).map(|trace| RearmTimer {
226 timer_id,
227 fire_at: trace.fire_at,
228 needs_restart_marker: trace.last_was_teardown_cancel == Some(true),
229 })
230 })
231 .collect()
232}
233
234/// Arms (or immediately fires) the reopened run's re-armed timers through the
235/// production [`crate::time::TimerService`], AFTER resident registration so
236/// residency resolution and mailbox delivery hit the live process.
237///
238/// A future deadline gets its durable row and wheel entry back via
239/// `TimerService::schedule`. A past deadline writes its durable row FIRST and
240/// then fires through `TimerService::fire_timer` — if the fire fails, the row
241/// survives so the next startup's `recover_due` sweep completes it instead of
242/// losing the deadline silently.
243///
244/// # Errors
245///
246/// Surfaces timer-service and store failures as [`EngineError::Runtime`]: the
247/// reopen itself is already durable, and the recorded `TimerStarted` plus
248/// durable row make the failure recoverable at the next startup sweep, but the
249/// operator must know the re-arm did not complete live.
250pub(crate) async fn rearm_reopened_timers(
251 context: &ReopenWorkflowContext<'_>,
252 id: &WorkflowId,
253 pid: crate::Pid,
254 rearm: &[RearmTimer],
255) -> Result<(), EngineError> {
256 if rearm.is_empty() {
257 return Ok(());
258 }
259 // A record-before-deliver fire can otherwise overtake the recovered
260 // process's first await: the process reads `TimerFired`, completes, and
261 // disappears before the redundant wake is enqueued. A pending-await entry
262 // proves the process committed its park decision; beamr then admits the
263 // wake either to that parked slot or its in-flight store-back.
264 context.runtime.wait_for_pending_await(pid).await?;
265 let timer_service =
266 crate::runtime::nif_timer_bridge::installed_timer_service(context.runtime.nif_state())
267 .map_err(|error| EngineError::Runtime {
268 reason: format!("timer service unavailable while reopening {id}: {error}"),
269 })?;
270 let now = Utc::now();
271 for timer in rearm {
272 if timer.fire_at > now {
273 timer_service
274 .schedule(id.clone(), timer.timer_id.clone(), timer.fire_at)
275 .await
276 .map_err(|error| EngineError::Runtime {
277 reason: format!(
278 "failed to re-arm timer {} for reopened workflow {id}: {error}",
279 timer.timer_id
280 ),
281 })?;
282 } else {
283 context
284 .store
285 .schedule_timer(id, &timer.timer_id, timer.fire_at)
286 .await?;
287 timer_service
288 .fire_timer(id.clone(), timer.timer_id.clone(), timer.fire_at)
289 .await
290 .map_err(|error| EngineError::Runtime {
291 reason: format!(
292 "failed to fire past-due timer {} for reopened workflow {id}: {error}",
293 timer.timer_id
294 ),
295 })?;
296 }
297 }
298 Ok(())
299}
300
301/// Validates the reopen precondition and computes the reopened activity set.
302///
303/// Accepts a current-lease terminal of `WorkflowFailed` (AD-012) or
304/// `WorkflowCancelled` (AD-013); rejects every other state with a typed
305/// [`EngineError::InvalidState`] naming the actual status.
306fn validate_and_compute_reopened(
307 id: &WorkflowId,
308 run: &RunId,
309 segment: &[Event],
310) -> Result<Vec<ActivityId>, EngineError> {
311 match current_lease_terminal(segment) {
312 Some(Event::WorkflowFailed { .. }) => Ok(reopened_failed_activities(segment)),
313 // A cancel records no terminal activity failure and re-drives nothing
314 // already recorded: the reopened set is empty, and the in-flight-at-cancel
315 // step re-dispatches via the same ResumeLive path crash recovery uses.
316 Some(Event::WorkflowCancelled { .. }) => Ok(Vec::new()),
317 Some(other) => Err(EngineError::InvalidState {
318 reason: format!(
319 "workflow {id} run {run} is terminal for a non-reopenable reason ({}); only Failed and Cancelled are reopenable",
320 terminal_status_name(other)
321 ),
322 }),
323 None => Err(EngineError::InvalidState {
324 reason: format!(
325 "workflow {id} run {run} is {:?}, not a reopenable terminal (Failed or Cancelled)",
326 status_from_events(segment)
327 ),
328 }),
329 }
330}
331
332/// The activities that ended in a terminal failure in the CURRENT lease with no
333/// later successful attempt — exactly the steps the cursor reset rule (AD-011)
334/// must re-dispatch. A merely in-flight (scheduled, no terminal) activity is
335/// never listed: it already re-dispatches through the existing recovery path.
336///
337/// Scoped to the current lease (events after the last run start or reopen) so a
338/// failure superseded by an earlier `WorkflowReopened` — already re-driven in a
339/// prior lease — is never re-listed.
340fn reopened_failed_activities(segment: &[Event]) -> Vec<ActivityId> {
341 use std::collections::HashSet;
342
343 let lease_start = segment
344 .iter()
345 .rposition(|event| {
346 matches!(
347 event,
348 Event::WorkflowStarted { .. } | Event::WorkflowReopened { .. }
349 )
350 })
351 .map_or(0, |index| index + 1);
352 let lease = &segment[lease_start..];
353
354 let mut failed: HashSet<ActivityId> = HashSet::new();
355 let mut succeeded: HashSet<ActivityId> = HashSet::new();
356 for event in lease {
357 match event {
358 Event::ActivityFailed { activity_id, .. } => {
359 failed.insert(activity_id.clone());
360 }
361 Event::ActivityCompleted { activity_id, .. }
362 | Event::ActivityCancelled { activity_id, .. } => {
363 succeeded.insert(activity_id.clone());
364 }
365 _ => {}
366 }
367 }
368 let mut reopened: Vec<ActivityId> = failed
369 .into_iter()
370 .filter(|activity_id| !succeeded.contains(activity_id))
371 .collect();
372 // Deterministic order for a stable recorded event.
373 reopened.sort_by_key(ActivityId::sequence_position);
374 reopened
375}
376
377fn terminal_status_name(event: &Event) -> &'static str {
378 match event {
379 Event::WorkflowCompleted { .. } => "Completed",
380 Event::WorkflowTimedOut { .. } => "TimedOut",
381 Event::WorkflowContinuedAsNew { .. } => "ContinuedAsNew",
382 Event::WorkflowFailed { .. } => "Failed",
383 Event::WorkflowCancelled { .. } => "Cancelled",
384 _ => "non-terminal",
385 }
386}
387
388/// Respawns the reopened run through the recovery seam and registers it Resident,
389/// reusing `recorder` (the one that appended `WorkflowReopened`) so no second
390/// writer is ever constructed.
391pub(crate) async fn respawn_and_register(
392 context: &ReopenWorkflowContext<'_>,
393 id: &WorkflowId,
394 run: &RunId,
395 history: &[Event],
396 recorder: Recorder,
397) -> Result<WorkflowHandle, EngineError> {
398 let workflow_type = started_workflow_type(id, history)?;
399 context
400 .supervision
401 .ensure_type_supervisor(workflow_type.clone())?;
402
403 let seam: Arc<dyn ActiveWorkflowRecoverySeam> = Arc::new(ActiveWorkflowRecoverySeamImpl::new(
404 Arc::clone(context.runtime),
405 ));
406 let recovered =
407 recover_active_workflow(seam.as_ref(), id, &workflow_type, history, &context.catalog)?;
408 let (run_id, loaded_version, pid) = match recovered {
409 ActiveWorkflowRecovery::Resident {
410 run_id,
411 loaded_version,
412 pid,
413 } => (run_id, loaded_version, pid),
414 ActiveWorkflowRecovery::ScheduleCoordinator { .. } => {
415 return Err(EngineError::InvalidState {
416 reason: format!(
417 "workflow {id} run {run} is the schedule coordinator, not reopenable"
418 ),
419 });
420 }
421 };
422
423 let startup_context = StartupRecoveryContext {
424 store: Arc::clone(&context.store),
425 visibility_store: Arc::clone(&context.visibility_store),
426 runtime: Arc::clone(context.runtime),
427 catalog: Arc::clone(&context.catalog),
428 registry: Arc::clone(context.registry),
429 supervision: Arc::clone(&context.supervision),
430 recovery: Some(seam),
431 search_attribute_schema: Arc::clone(&context.search_attribute_schema),
432 bootstrap_schedule_coordinator: false,
433 };
434 let history_head = history.last().map(Event::seq).unwrap_or_default();
435 register_recovered_resident(
436 &startup_context,
437 RecoveredResident {
438 workflow_id: id,
439 workflow_type: &workflow_type,
440 history,
441 history_head,
442 projected_status: aion_core::WorkflowStatus::Running,
443 run_id: run_id.clone(),
444 loaded_version,
445 pid,
446 recorder: Some(recorder),
447 },
448 )
449 .await?;
450
451 context
452 .registry
453 .get(id, &run_id)?
454 .ok_or_else(|| EngineError::Runtime {
455 reason: format!("reopened workflow {id} run {run_id} was not registered"),
456 })
457}
458
459fn started_workflow_type(id: &WorkflowId, history: &[Event]) -> Result<String, EngineError> {
460 history
461 .iter()
462 .find_map(|event| match event {
463 Event::WorkflowStarted { workflow_type, .. } => Some(workflow_type.clone()),
464 _ => None,
465 })
466 .ok_or_else(|| EngineError::Load {
467 reason: format!("workflow {id} has no WorkflowStarted event to reopen from"),
468 })
469}
470
471#[cfg(test)]
472#[path = "reopen_tests.rs"]
473mod tests;