aion/time/deadline.rs
1//! Reserved workflow-deadline timer identity and the deadline-handler seam.
2//!
3//! A declared workflow timeout arms a single reserved timer named
4//! `deadline:{run_id}`. The reserved prefix is engine-minted only — the author
5//! NIF choke point (`decode_timer_id_arg`) refuses any author-supplied name that
6//! starts with it — so a `deadline:` timer in history is always the engine's
7//! per-run deadline and never a workflow-authored timer.
8//!
9//! When such a timer elapses, [`TimerService`](crate::time::TimerService)
10//! demuxes it out of the generic `TimerFired` path and hands it to the
11//! registered [`DeadlineHandler`], which records `WorkflowTimedOut` and tears
12//! the run down engine-side. Routing is inversion of control precisely so the
13//! timer machinery never needs a strong handle back to the engine (the
14//! documented `RuntimeHandle`/`EngineNifState` cycle-avoidance): the engine
15//! registers a handler holding whatever weak references it needs at construction
16//! time.
17
18use aion_core::{Event, RunId, TimerCancelCause, TimerId};
19use chrono::Utc;
20use uuid::Uuid;
21
22use crate::durability::{DurabilityError, Recorder};
23
24/// Reserved name prefix for a workflow's declared-timeout deadline timer.
25///
26/// Engine-minted only: `decode_timer_id_arg` refuses author-supplied names under
27/// this prefix, so a `deadline:` timer is always the per-run workflow deadline.
28pub const DEADLINE_TIMER_PREFIX: &str = "deadline:";
29
30/// Closed-set descriptor token recorded on the `WorkflowTimedOut` terminal for a
31/// declared workflow timeout, consistent with the `deadline:` timer id family.
32///
33/// User-visible in `one_motion` output and replay terminals; the sole value a
34/// declared-workflow-timeout deadline records.
35pub const WORKFLOW_TIMEOUT_DESCRIPTOR: &str = "workflow";
36
37/// The reserved deadline timer id for `run_id`: `deadline:{run_id}`.
38///
39/// # Errors
40///
41/// Returns [`aion_core::IdError`] only if timer-name construction rejects the
42/// composed name; the name is always non-empty, so this never fails in practice
43/// and the `Result` exists solely to keep the helper total without an `unwrap`.
44pub fn deadline_timer_id(run_id: &RunId) -> Result<TimerId, aion_core::IdError> {
45 TimerId::named(format!("{DEADLINE_TIMER_PREFIX}{run_id}"))
46}
47
48/// Whether `timer_id` is a reserved workflow-deadline timer.
49#[must_use]
50pub fn is_deadline_timer(timer_id: &TimerId) -> bool {
51 timer_id
52 .name()
53 .is_some_and(|name| name.starts_with(DEADLINE_TIMER_PREFIX))
54}
55
56/// The run id encoded in a `deadline:{run_id}` timer, if `timer_id` is a
57/// well-formed reserved deadline timer.
58///
59/// Returns `None` for a non-deadline timer or a deadline-prefixed name whose
60/// suffix is not a valid run identifier.
61#[must_use]
62pub fn deadline_run_id(timer_id: &TimerId) -> Option<RunId> {
63 let suffix = timer_id.name()?.strip_prefix(DEADLINE_TIMER_PREFIX)?;
64 Uuid::parse_str(suffix).ok().map(RunId::new)
65}
66
67/// The still-live reserved deadline timer for `run_id`, derived purely from
68/// recorded history — or `None` when the run never armed a deadline (or its
69/// deadline was already fired/cancelled).
70///
71/// LAW 1: this constructs NO deadline id. It reads the id only from a
72/// `TimerStarted` event whose id is a reserved deadline timer for exactly
73/// `run_id` (matched by the run encoded in the id, not by a minted candidate),
74/// so a timeout-less run — which recorded no such event — yields `None` and
75/// touches no deadline object of any kind. Liveness is last-event-wins: a later
76/// `TimerFired`/`TimerCancelled` for the same id retires it.
77#[must_use]
78pub fn outstanding_deadline_timer(history: &[Event], run_id: &RunId) -> Option<TimerId> {
79 let mut live: Option<TimerId> = None;
80 for event in history {
81 let (timer_id, started) = match event {
82 Event::TimerStarted { timer_id, .. } => (timer_id, true),
83 Event::TimerFired { timer_id, .. } | Event::TimerCancelled { timer_id, .. } => {
84 (timer_id, false)
85 }
86 _ => continue,
87 };
88 if !is_deadline_timer(timer_id) || deadline_run_id(timer_id).as_ref() != Some(run_id) {
89 continue;
90 }
91 live = if started {
92 Some(timer_id.clone())
93 } else {
94 None
95 };
96 }
97 live
98}
99
100/// Cancels a run's still-live declared-timeout deadline as part of a terminal
101/// transition, under the caller's already-held recorder lock.
102///
103/// This is the ONE deadline-retirement primitive EVERY terminal writer shares —
104/// `complete`, `fail`, `cancel`, and continue-as-new on both the API and NIF
105/// paths — so D5 ("a recorded terminal permanently retires the run's deadline")
106/// is enforced uniformly instead of re-implemented, or forgotten, per writer.
107///
108/// It derives the deadline id purely from `history` (never minted): a
109/// timeout-less run recorded no deadline `TimerStarted`, so it retires nothing
110/// and touches no deadline object (LAW 1). It is IDEMPOTENT — a run whose
111/// deadline was already cancelled (or never armed) has no outstanding deadline,
112/// so a re-entry records nothing. That idempotence is what makes a terminal
113/// transition RESUMABLE: because the terminal append and this cancellation are
114/// two separate durable writes, a crash between them leaves the deadline
115/// outstanding, and the next terminal writer — or the process-exit monitor
116/// re-encountering the run's own terminal — completes the cancellation. Without
117/// it, whole-history `outstanding_future_timers` recovery would keep re-arming a
118/// predecessor deadline after failover.
119///
120/// `history` MUST be the history read under the same recorder lock. On the happy
121/// path it is read BEFORE the terminal append so the deadline still reads live
122/// and is retired; on a resume it is read after the terminal, where an
123/// already-cancelled deadline is a clean no-op and an uncancelled one is
124/// completed.
125///
126/// # Errors
127///
128/// Returns [`DurabilityError`] when the `TimerCancelled` append fails; the caller
129/// propagates it so the interrupted transition is retried rather than silently
130/// leaving the deadline live.
131pub async fn retire_run_deadline(
132 recorder: &mut Recorder,
133 history: &[Event],
134 run_id: &RunId,
135) -> Result<(), DurabilityError> {
136 if let Some(deadline_id) = outstanding_deadline_timer(history, run_id) {
137 recorder
138 .record_timer_cancelled(Utc::now(), deadline_id, TimerCancelCause::WorkflowIntent)
139 .await?;
140 }
141 Ok(())
142}
143
144/// Errors surfaced by a [`DeadlineHandler`] to the timer service.
145///
146/// Deliberately message-only: the handler lives engine-side and produces the
147/// engine's own typed errors, which the timer service (in a lower crate layer)
148/// only needs to observe and propagate as a fire failure.
149#[derive(thiserror::Error, Debug)]
150#[error("deadline handler failed: {0}")]
151pub struct DeadlineHandlerError(pub String);
152
153/// Engine-side handler invoked when a workflow's declared-timeout deadline
154/// elapses.
155///
156/// The timer service demuxes a reserved `deadline:{run_id}` fire to this seam
157/// instead of recording a generic `TimerFired`. The implementation records
158/// `WorkflowTimedOut` for the run under the per-handle recorder lock (losing
159/// cleanly to any concurrent terminal) and tears the run down.
160#[async_trait::async_trait]
161pub trait DeadlineHandler: Send + Sync {
162 /// Drive `run_id` of `workflow_id` to a `WorkflowTimedOut` terminal.
163 ///
164 /// # Errors
165 ///
166 /// Returns [`DeadlineHandlerError`] when the terminal cannot be recorded or
167 /// the run cannot be torn down; the timer service surfaces it as a fire
168 /// failure.
169 async fn on_deadline_elapsed(
170 &self,
171 workflow_id: aion_core::WorkflowId,
172 run_id: RunId,
173 ) -> Result<(), DeadlineHandlerError>;
174}
175
176#[cfg(test)]
177mod tests {
178 use aion_core::{Event, EventEnvelope, RunId, TimerCancelCause, TimerId, WorkflowId};
179 use uuid::Uuid;
180
181 use super::{
182 deadline_run_id, deadline_timer_id, is_deadline_timer, outstanding_deadline_timer,
183 };
184
185 type TestResult = Result<(), Box<dyn std::error::Error>>;
186
187 fn envelope(seq: u64, workflow_id: &WorkflowId) -> EventEnvelope {
188 EventEnvelope {
189 seq,
190 recorded_at: chrono::Utc::now(),
191 workflow_id: workflow_id.clone(),
192 }
193 }
194
195 #[test]
196 fn outstanding_deadline_timer_is_none_for_a_run_without_a_deadline() -> TestResult {
197 // LAW 1: a run whose history has no deadline `TimerStarted` of its own
198 // yields no deadline object of any kind — not even a constructed candidate
199 // id. Neither an ordinary author timer nor ANOTHER run's deadline matches.
200 let workflow_id = WorkflowId::new_v4();
201 let run_id = RunId::new_v4();
202 let other_run = RunId::new_v4();
203 let history = vec![
204 Event::TimerStarted {
205 envelope: envelope(1, &workflow_id),
206 timer_id: deadline_timer_id(&other_run)?,
207 fire_at: chrono::Utc::now(),
208 },
209 Event::TimerStarted {
210 envelope: envelope(2, &workflow_id),
211 timer_id: TimerId::anonymous(3),
212 fire_at: chrono::Utc::now(),
213 },
214 ];
215 assert_eq!(outstanding_deadline_timer(&history, &run_id), None);
216 Ok(())
217 }
218
219 #[test]
220 fn outstanding_deadline_timer_tracks_liveness_and_scopes_to_the_run() -> TestResult {
221 let workflow_id = WorkflowId::new_v4();
222 let this_run = RunId::new_v4();
223 let other_run = RunId::new_v4();
224 let this_deadline = deadline_timer_id(&this_run)?;
225 let other_deadline = deadline_timer_id(&other_run)?;
226
227 // Armed for this run, plus another run's deadline that must be ignored.
228 let armed = vec![
229 Event::TimerStarted {
230 envelope: envelope(1, &workflow_id),
231 timer_id: this_deadline.clone(),
232 fire_at: chrono::Utc::now(),
233 },
234 Event::TimerStarted {
235 envelope: envelope(2, &workflow_id),
236 timer_id: other_deadline,
237 fire_at: chrono::Utc::now(),
238 },
239 ];
240 assert_eq!(
241 outstanding_deadline_timer(&armed, &this_run),
242 Some(this_deadline.clone())
243 );
244
245 // A later cancel retires it (last-event-wins).
246 let mut cancelled = armed;
247 cancelled.push(Event::TimerCancelled {
248 envelope: envelope(3, &workflow_id),
249 timer_id: this_deadline,
250 cause: TimerCancelCause::WorkflowIntent,
251 });
252 assert_eq!(outstanding_deadline_timer(&cancelled, &this_run), None);
253 Ok(())
254 }
255
256 #[test]
257 fn deadline_timer_id_round_trips_to_its_run() -> TestResult {
258 let run_id = RunId::new(Uuid::from_u128(42));
259 let timer_id = deadline_timer_id(&run_id)?;
260 assert!(is_deadline_timer(&timer_id));
261 assert_eq!(deadline_run_id(&timer_id), Some(run_id));
262 Ok(())
263 }
264
265 #[test]
266 fn author_named_timer_is_not_a_deadline() -> TestResult {
267 let timer_id = TimerId::named("review-deadline")?;
268 assert!(!is_deadline_timer(&timer_id));
269 assert_eq!(deadline_run_id(&timer_id), None);
270 Ok(())
271 }
272
273 #[test]
274 fn anonymous_timer_is_not_a_deadline() {
275 let timer_id = TimerId::anonymous(7);
276 assert!(!is_deadline_timer(&timer_id));
277 assert_eq!(deadline_run_id(&timer_id), None);
278 }
279
280 #[test]
281 fn deadline_prefixed_but_malformed_suffix_has_no_run() -> TestResult {
282 // A `deadline:`-prefixed name whose suffix is not a UUID is detected as
283 // a deadline timer (prefix match) but yields no run id — the timer
284 // service turns this into a typed refusal, never a silent generic fire.
285 let timer_id = TimerId::named("deadline:not-a-uuid")?;
286 assert!(is_deadline_timer(&timer_id));
287 assert_eq!(deadline_run_id(&timer_id), None);
288 Ok(())
289 }
290}