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;