Skip to main content

aion/lifecycle/
deadline.rs

1//! Engine-side handler that drives an elapsed workflow deadline to a
2//! `WorkflowTimedOut` terminal.
3//!
4//! Registered on the timer bridge at engine construction, this is the seam the
5//! `TimerService` demuxes a reserved `deadline:{run_id}` fire to. It records the
6//! terminal under the per-handle recorder lock — with a terminal re-check so it
7//! loses cleanly to a concurrent completion — then tears the run down matching
8//! `terminate::cancel` discipline: kill the process, refresh visibility, notify
9//! result awaiters, and deregister.
10//!
11//! It holds a `Weak<RuntimeHandle>` (never a strong one) so the engine's
12//! `RuntimeHandle` → `EngineNifState` → timer bridge → handler chain does not
13//! cycle back into the runtime — the same cycle-avoidance the timer bridge's
14//! `Weak<EngineNifState>` observes.
15
16use std::sync::{Arc, Weak};
17
18use aion_core::{Event, RunId, TimerCancelCause, WorkflowId};
19use aion_store::EventStore;
20use aion_store::visibility::VisibilityStore;
21use chrono::Utc;
22
23use crate::durability::Recorder;
24use crate::registry::{Registry, TerminalOutcome, WorkflowHandle};
25use crate::runtime::RuntimeHandle;
26use crate::time::timer_service::live_timers_in_active_segment;
27use crate::time::{DeadlineHandler, DeadlineHandlerError, WORKFLOW_TIMEOUT_DESCRIPTOR};
28
29use super::completion::terminal_outcome_from_history;
30use super::visibility::upsert_workflow_visibility;
31
32/// Whether the elapsed deadline records a fresh terminal, resumes an interrupted
33/// teardown of its own prior terminal, or loses cleanly to a competing terminal.
34enum DeadlineDisposition {
35    /// This call appended `WorkflowTimedOut`; run the full teardown.
36    Appended,
37    /// Our own `WorkflowTimedOut` is already durable but teardown was
38    /// interrupted; resume the idempotent teardown without a second terminal.
39    ResumeTeardown,
40    /// A competing terminal already won (or the deadline is no longer live);
41    /// nothing to record and nothing to tear down.
42    LoseCleanly,
43}
44
45/// Records `WorkflowTimedOut` and tears down a run whose deadline elapsed.
46pub struct WorkflowDeadlineHandler {
47    /// Weak to avoid the `RuntimeHandle`↔`EngineNifState`↔bridge cycle; upgraded
48    /// only to kill the timed-out process.
49    runtime: Weak<RuntimeHandle>,
50    store: Arc<dyn EventStore>,
51    visibility_store: Arc<dyn VisibilityStore>,
52    registry: Arc<Registry>,
53}
54
55impl WorkflowDeadlineHandler {
56    /// Assembles a deadline handler from the engine's teardown dependencies.
57    ///
58    /// `runtime` is held weakly on purpose (see the module docs); the rest are
59    /// the same durable store, visibility index, and active registry the
60    /// `terminate::cancel` path uses.
61    #[must_use]
62    pub fn new(
63        runtime: Weak<RuntimeHandle>,
64        store: Arc<dyn EventStore>,
65        visibility_store: Arc<dyn VisibilityStore>,
66        registry: Arc<Registry>,
67    ) -> Self {
68        Self {
69            runtime,
70            store,
71            visibility_store,
72            registry,
73        }
74    }
75
76    /// Body of the timeout terminal + teardown, returning typed engine errors.
77    async fn drive_timed_out(
78        &self,
79        workflow_id: WorkflowId,
80        run_id: RunId,
81    ) -> Result<(), crate::EngineError> {
82        let Some(handle) = self.registry.get(&workflow_id, &run_id)? else {
83            // No registered handle. This is NOT automatically a no-op: a cold
84            // engine (or a shard adopter) never registers a terminal run, so a
85            // recovered deadline row whose durable history shows `WorkflowTimedOut`
86            // with teardown left unfinished reaches here with no handle. Complete
87            // that teardown registry-free — this is the ONLY actor that finishes
88            // it. A non-timeout terminal, or a fully-torn-down run, is a genuine
89            // no-op (its deadline is already retired or was never this run's).
90            return self
91                .finalize_timed_out_without_handle(&workflow_id, &run_id)
92                .await;
93        };
94
95        let disposition = self
96            .decide_disposition(&handle, &workflow_id, &run_id)
97            .await?;
98        match disposition {
99            DeadlineDisposition::LoseCleanly => Ok(()),
100            DeadlineDisposition::Appended | DeadlineDisposition::ResumeTeardown => {
101                self.tear_down(&handle, &workflow_id, &run_id).await
102            }
103        }
104    }
105
106    /// Decides — atomically under the recorder lock — whether to append a fresh
107    /// `WorkflowTimedOut`, resume an interrupted teardown of an already-recorded
108    /// one, or lose cleanly.
109    ///
110    /// The terminal re-check, the deadline-liveness re-check, and the terminal
111    /// append are one critical section: a concurrent complete/fail/cancel records
112    /// through the same recorder, so checking outside the lock could double-record
113    /// a terminal or let a cancelled deadline still time the run out.
114    async fn decide_disposition(
115        &self,
116        handle: &WorkflowHandle,
117        workflow_id: &WorkflowId,
118        run_id: &RunId,
119    ) -> Result<DeadlineDisposition, crate::EngineError> {
120        let recorder = handle.recorder();
121        let mut recorder = recorder.lock().await;
122        let history = self.store.read_history(workflow_id).await?;
123        match terminal_outcome_from_history(&history, run_id) {
124            Some(TerminalOutcome::TimedOut(_)) => {
125                // Our own terminal is durable but teardown did not finish (a
126                // dropped runtime, a failed visibility upsert, an interrupted
127                // fire). Resume the idempotent teardown — do NOT append again.
128                tracing::debug!(
129                    %workflow_id,
130                    %run_id,
131                    "workflow deadline re-fired after its WorkflowTimedOut was recorded; resuming teardown"
132                );
133                Ok(DeadlineDisposition::ResumeTeardown)
134            }
135            Some(_) => {
136                // A competing terminal (complete/fail/cancel/continue-as-new) won.
137                // The deadline loses — but if it is still outstanding, that
138                // terminal writer's own deadline cancellation did not commit (a
139                // two-write crash), so this fire REPAIRS it: retire the deadline
140                // here, under the recorder lock, rather than losing without
141                // cancelling and letting whole-history recovery keep re-arming it.
142                // This is the guaranteed re-drive for an interrupted non-timeout
143                // terminal transition — the live wheel or `recover_due`/`tick`
144                // re-arms the still-live deadline, and this fire completes D5.
145                tracing::debug!(
146                    %workflow_id,
147                    %run_id,
148                    "workflow deadline elapsed but another terminal was already recorded; retiring the deadline and losing"
149                );
150                crate::time::retire_run_deadline(&mut recorder, &history, run_id).await?;
151                Ok(DeadlineDisposition::LoseCleanly)
152            }
153            None => {
154                // Re-check THIS deadline is still live: a cancel that recorded
155                // `TimerCancelled { WorkflowIntent }` before its terminal must win,
156                // so a retired deadline loses cleanly rather than timing the run
157                // out after its cancellation.
158                if crate::time::outstanding_deadline_timer(&history, run_id).is_none() {
159                    tracing::debug!(
160                        %workflow_id,
161                        %run_id,
162                        "workflow deadline elapsed but its timer was already retired; deadline loses"
163                    );
164                    return Ok(DeadlineDisposition::LoseCleanly);
165                }
166                recorder
167                    .record_workflow_timed_out(Utc::now(), WORKFLOW_TIMEOUT_DESCRIPTOR.to_owned())
168                    .await?;
169                Ok(DeadlineDisposition::Appended)
170            }
171        }
172    }
173
174    /// Idempotent, resumable teardown after the `WorkflowTimedOut` terminal is
175    /// durable.
176    ///
177    /// Ordering is the invariant that makes resume reachable: the run's OWN
178    /// deadline timer stays live and its registry entry stays present until every
179    /// fallible teardown step has succeeded. So it retires the ordinary
180    /// (non-deadline) timers first, confirms process teardown and refreshes
181    /// visibility, notifies awaiters, and only THEN retires the deadline itself
182    /// and deregisters. A failure in any earlier step is PROPAGATED (not merely
183    /// logged): the handler returns it as a fire failure, the deadline remains
184    /// live, and recovery's `outstanding_future_timers` re-arms it so a later fire
185    /// re-enters here and resumes — rather than destroying both retry anchors
186    /// before the work that needs them.
187    ///
188    /// # Errors
189    ///
190    /// Returns the typed [`crate::EngineError`] from the first failing durable
191    /// step so recovery retries the interrupted teardown.
192    async fn tear_down(
193        &self,
194        handle: &WorkflowHandle,
195        workflow_id: &WorkflowId,
196        run_id: &RunId,
197    ) -> Result<(), crate::EngineError> {
198        // 1. Retire the run's ordinary (non-deadline) timers. The deadline is
199        //    deliberately NOT retired here — it is the resume anchor.
200        self.retire_ordinary_timers(handle, workflow_id, run_id)
201            .await?;
202
203        // 2. Stop the timed-out process. A cancel failure means it already
204        //    exited (benign); a dropped runtime is propagated so a re-fire under a
205        //    live runtime completes the kill.
206        match self.runtime.upgrade() {
207            Some(runtime) => {
208                if let Err(error) = runtime.cancel_pid(handle.pid()) {
209                    tracing::debug!(
210                        %workflow_id,
211                        %run_id,
212                        %error,
213                        "workflow process already exited during deadline teardown"
214                    );
215                }
216            }
217            None => {
218                return Err(crate::EngineError::Runtime {
219                    reason: format!(
220                        "runtime dropped during deadline teardown of {workflow_id}/{run_id}; a later re-fire resumes teardown"
221                    ),
222                });
223            }
224        }
225
226        // 3. Refresh visibility; a failure is propagated so it is retried.
227        upsert_workflow_visibility(
228            Arc::clone(&self.store),
229            Arc::clone(&self.visibility_store),
230            workflow_id,
231            run_id,
232        )
233        .await?;
234
235        // 4. Notify awaiters (a doorbell send; never a retry condition).
236        handle.completion().notify(TerminalOutcome::TimedOut(
237            WORKFLOW_TIMEOUT_DESCRIPTOR.to_owned(),
238        ));
239
240        // 5. Retire the deadline LAST, once teardown has otherwise succeeded, so
241        //    no earlier failure could have removed the resume anchor. Idempotent.
242        self.retire_deadline(handle, workflow_id, run_id).await?;
243
244        // 6. Deregister LAST.
245        self.registry.remove(workflow_id, run_id)?;
246        Ok(())
247    }
248
249    /// Retires the timed-out run's still-live ORDINARY timers (every live timer
250    /// except this run's own deadline) by recording `TimerCancelled { WorkflowIntent }`
251    /// for each, through the handle recorder under its lock. The deadline is
252    /// excluded so it stays live as the teardown resume anchor. Idempotent — a
253    /// re-run sees the same timers already retired and records nothing.
254    ///
255    /// # Errors
256    ///
257    /// Returns the typed [`crate::EngineError`] when history cannot be read or a
258    /// cancellation append fails, so the interrupted teardown is retried.
259    async fn retire_ordinary_timers(
260        &self,
261        handle: &WorkflowHandle,
262        workflow_id: &WorkflowId,
263        run_id: &RunId,
264    ) -> Result<(), crate::EngineError> {
265        let recorder = handle.recorder();
266        let mut recorder = recorder.lock().await;
267        let history = self.store.read_history(workflow_id).await?;
268        record_ordinary_timer_retirements(&mut recorder, &history, run_id).await?;
269        Ok(())
270    }
271
272    /// Registry-free completion of an interrupted timeout teardown.
273    ///
274    /// A cold engine and a shard adopter never register a terminal run, so a
275    /// recovered due deadline row reaches [`Self::drive_timed_out`] with no
276    /// handle. When durable history shows this run's own `WorkflowTimedOut` with
277    /// teardown left unfinished (an outstanding deadline or still-live ordinary
278    /// timers), this finishes the SAME durable steps the handle path runs —
279    /// ordinary timers first, visibility, then the deadline LAST — through an
280    /// independent recorder. It deliberately omits the handle-only side effects:
281    /// the process is already gone (the run is terminal), there are no local
282    /// awaiters this epoch, and nothing is registered to deregister. A non-timeout
283    /// or already-finished run is a clean no-op.
284    ///
285    /// # Errors
286    ///
287    /// Returns the typed [`crate::EngineError`] from the first failing durable
288    /// step so the caller (recovery) retries.
289    async fn finalize_timed_out_without_handle(
290        &self,
291        workflow_id: &WorkflowId,
292        run_id: &RunId,
293    ) -> Result<(), crate::EngineError> {
294        let history = self.store.read_history(workflow_id).await?;
295        if !matches!(
296            terminal_outcome_from_history(&history, run_id),
297            Some(TerminalOutcome::TimedOut(_))
298        ) {
299            tracing::debug!(
300                %workflow_id,
301                %run_id,
302                "unregistered deadline elapsed for a run that is not TimedOut; nothing to finalize"
303            );
304            return Ok(());
305        }
306        let head = history.iter().map(Event::seq).max().unwrap_or_default();
307        let mut recorder = Recorder::resume_at(workflow_id.clone(), Arc::clone(&self.store), head);
308        // Ordinary timers first (the deadline is retired LAST), then visibility.
309        record_ordinary_timer_retirements(&mut recorder, &history, run_id).await?;
310        upsert_workflow_visibility(
311            Arc::clone(&self.store),
312            Arc::clone(&self.visibility_store),
313            workflow_id,
314            run_id,
315        )
316        .await?;
317        crate::time::retire_run_deadline(&mut recorder, &history, run_id).await?;
318        Ok(())
319    }
320
321    /// Retires this run's own declared-timeout deadline as the final teardown
322    /// step, via the shared `retire_run_deadline` primitive. Idempotent — a
323    /// resumed teardown whose deadline is already retired records nothing.
324    ///
325    /// # Errors
326    ///
327    /// Returns the typed [`crate::EngineError`] when history cannot be read or the
328    /// cancellation append fails.
329    async fn retire_deadline(
330        &self,
331        handle: &WorkflowHandle,
332        workflow_id: &WorkflowId,
333        run_id: &RunId,
334    ) -> Result<(), crate::EngineError> {
335        let recorder = handle.recorder();
336        let mut recorder = recorder.lock().await;
337        let history = self.store.read_history(workflow_id).await?;
338        crate::time::retire_run_deadline(&mut recorder, &history, run_id).await?;
339        Ok(())
340    }
341}
342
343/// Records `TimerCancelled { WorkflowIntent }` for every still-live ORDINARY
344/// timer in the run's active segment — the deadline itself is excluded so it
345/// stays live as the teardown resume anchor. Shared by the handle-based teardown
346/// and the registry-free finalizer so both settle ordinary timers identically.
347/// Idempotent: a re-run sees the same timers already retired and records nothing.
348///
349/// # Errors
350///
351/// Returns the recorder's [`crate::durability::DurabilityError`] when a
352/// cancellation append fails.
353async fn record_ordinary_timer_retirements(
354    recorder: &mut Recorder,
355    history: &[Event],
356    run_id: &RunId,
357) -> Result<(), crate::durability::DurabilityError> {
358    let deadline = crate::time::outstanding_deadline_timer(history, run_id);
359    for timer_id in live_timers_in_active_segment(history) {
360        if deadline.as_ref() == Some(&timer_id) {
361            continue;
362        }
363        recorder
364            .record_timer_cancelled(Utc::now(), timer_id, TimerCancelCause::WorkflowIntent)
365            .await?;
366    }
367    Ok(())
368}
369
370#[async_trait::async_trait]
371impl DeadlineHandler for WorkflowDeadlineHandler {
372    async fn on_deadline_elapsed(
373        &self,
374        workflow_id: WorkflowId,
375        run_id: RunId,
376    ) -> Result<(), DeadlineHandlerError> {
377        self.drive_timed_out(workflow_id, run_id)
378            .await
379            .map_err(|error| DeadlineHandlerError(error.to_string()))
380    }
381}
382
383#[cfg(test)]
384#[path = "deadline_tests.rs"]
385mod tests;