Skip to main content

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;