Skip to main content

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}