Skip to main content

aion/time/
named.rs

1//! Named/cancellable timers and anonymous sleeps.
2
3use std::time::Duration;
4
5use aion_core::{TimerCancelCause, TimerId, WorkflowId};
6use chrono::{DateTime, Utc};
7
8use crate::time::{TimerService, TimerServiceError};
9
10/// Result returned when an anonymous sleep timer is scheduled.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct SleepTimer {
13    /// Engine-assigned anonymous timer id derived from the deterministic sequence position.
14    pub timer_id: TimerId,
15    /// Deterministic fire timestamp computed from the workflow's recorded timestamp.
16    pub fire_at: DateTime<Utc>,
17}
18
19/// Errors returned by anonymous sleep scheduling.
20#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
21pub enum SleepTimerError {
22    /// The supplied standard-library duration cannot be represented by chrono.
23    #[error("sleep duration cannot be represented as a chrono duration")]
24    DurationOutOfRange,
25
26    /// Adding the duration to the recorded workflow timestamp overflowed.
27    #[error("sleep fire_at timestamp overflowed recorded workflow time")]
28    FireAtOutOfRange,
29
30    /// Durable timer scheduling failed.
31    #[error("sleep timer scheduling failed: {0}")]
32    Timer(#[from] TimerServiceError),
33}
34
35/// Starts a named, cancellable timer with the author-assigned [`TimerId`].
36///
37/// The supplied `timer_id` is preserved verbatim in the durable timer row and `TimerStarted` event;
38/// this wrapper does not derive or rewrite named ids.
39///
40/// `armed_seq` MUST be the workflow-history sequence of the already-recorded `TimerStarted` for
41/// this arming (the value [`Recorder::record_timer_started`] returned): it is the durable row's
42/// identity, and a wrong value means retirement silently never matches — the consumed row answers
43/// `Superseded` forever and survives every boot sweep.
44///
45/// [`Recorder::record_timer_started`]: crate::durability::Recorder::record_timer_started
46///
47/// # Errors
48///
49/// Returns [`TimerServiceError`] when durable scheduling, recording, residency resolution, or live
50/// wheel arming fails.
51pub async fn start_timer(
52    service: &TimerService,
53    workflow_id: WorkflowId,
54    timer_id: TimerId,
55    fire_at: DateTime<Utc>,
56    armed_seq: u64,
57) -> Result<(), TimerServiceError> {
58    service
59        .schedule(workflow_id, timer_id, fire_at, armed_seq)
60        .await
61}
62
63/// Cancels a timer if it has not already fired or been cancelled.
64///
65/// Active resident timers are disarmed through the engine seam and then recorded as
66/// `TimerCancelled`. Already-fired or already-cancelled timers are idempotent no-ops. Authors
67/// only ever cancel named timers (a `TimerRef` is minted by `start_timer`); the engine also
68/// settles anonymous `with_timeout` scope deadlines through this path.
69///
70/// # Errors
71///
72/// Returns [`TimerServiceError`] when history inspection, residency resolution, disarming, or
73/// cancellation recording fails.
74pub async fn cancel_timer(
75    service: &TimerService,
76    workflow_id: WorkflowId,
77    timer_id: TimerId,
78) -> Result<(), TimerServiceError> {
79    service
80        .cancel(workflow_id, timer_id, TimerCancelCause::WorkflowIntent)
81        .await
82}
83
84/// Schedules an anonymous durable sleep timer using deterministic workflow inputs.
85///
86/// `recorded_now` must be the current timestamp supplied by AD's determinism context, not the wall
87/// clock. The anonymous [`TimerId`] is deterministically derived from `sequence_position` via
88/// [`TimerId::anonymous`], so replay can reconstruct the same id. `armed_seq` is the recorded
89/// `TimerStarted`'s workflow-history sequence (the arming's row identity). Anonymous sleep timers
90/// do not have a separate public cancel entrypoint; cancelling a sleep is modelled as cancelling
91/// the owning workflow.
92///
93/// # Errors
94///
95/// Returns [`SleepTimerError`] when duration conversion overflows, `fire_at` overflows, or durable
96/// timer scheduling fails.
97pub async fn sleep(
98    service: &TimerService,
99    workflow_id: WorkflowId,
100    duration: Duration,
101    recorded_now: DateTime<Utc>,
102    sequence_position: u64,
103    armed_seq: u64,
104) -> Result<SleepTimer, SleepTimerError> {
105    let chrono_duration =
106        chrono::Duration::from_std(duration).map_err(|_| SleepTimerError::DurationOutOfRange)?;
107    let fire_at = recorded_now
108        .checked_add_signed(chrono_duration)
109        .ok_or(SleepTimerError::FireAtOutOfRange)?;
110    let timer_id = TimerId::anonymous(sequence_position);
111
112    service
113        .schedule(workflow_id, timer_id.clone(), fire_at, armed_seq)
114        .await?;
115
116    Ok(SleepTimer { timer_id, fire_at })
117}
118
119#[cfg(test)]
120mod tests {
121    use std::sync::Arc;
122    use std::time::Duration;
123
124    use aion_core::{Event, EventEnvelope, IdError, TimerCancelCause, TimerId, WorkflowId};
125    use aion_store::{InMemoryStore, ReadableEventStore, StoreError, WritableEventStore};
126    use chrono::{DateTime, Utc};
127
128    use super::{SleepTimerError, cancel_timer, sleep, start_timer};
129    use crate::engine_seam::test_support::{FakeEngineHandle, FakeEngineOperation};
130    use crate::engine_seam::{
131        EngineHandle, TimerWheelEntry, WorkflowProcessHandle, WorkflowResidency,
132    };
133    use crate::time::{TimerService, TimerServiceError};
134
135    #[derive(thiserror::Error, Debug)]
136    enum TestError {
137        #[error(transparent)]
138        Timer(#[from] TimerServiceError),
139        #[error(transparent)]
140        Sleep(#[from] SleepTimerError),
141        #[error(transparent)]
142        Store(#[from] StoreError),
143        #[error(transparent)]
144        Engine(#[from] crate::engine_seam::EngineSeamError),
145        #[error(transparent)]
146        Id(#[from] IdError),
147    }
148
149    fn instant(offset_seconds: i64) -> DateTime<Utc> {
150        DateTime::from_timestamp(1_700_000_000 + offset_seconds, 0).unwrap_or_default()
151    }
152
153    fn recorded_at() -> DateTime<Utc> {
154        instant(1)
155    }
156
157    fn workflow_id() -> WorkflowId {
158        WorkflowId::new_v4()
159    }
160
161    fn service() -> (Arc<InMemoryStore>, Arc<FakeEngineHandle>, TimerService) {
162        let concrete_store = Arc::new(InMemoryStore::default());
163        let writable: Arc<dyn WritableEventStore> = concrete_store.clone();
164        let readable: Arc<dyn ReadableEventStore> = concrete_store.clone();
165        let engine = Arc::new(FakeEngineHandle::recording_to(writable));
166        let service = TimerService::with_recorded_at(engine.clone(), readable, recorded_at);
167        (concrete_store, engine, service)
168    }
169
170    async fn history(
171        store: &InMemoryStore,
172        workflow_id: &WorkflowId,
173    ) -> Result<Vec<Event>, StoreError> {
174        store.read_history(workflow_id).await
175    }
176
177    fn timer_started_event(workflow_id: &WorkflowId, timer_id: &TimerId, seq: u64) -> Event {
178        Event::TimerStarted {
179            envelope: EventEnvelope {
180                seq,
181                recorded_at: instant(0),
182                workflow_id: workflow_id.clone(),
183            },
184            timer_id: timer_id.clone(),
185            fire_at: instant(5),
186        }
187    }
188
189    fn count_cancelled(events: &[Event], timer_id: &TimerId) -> usize {
190        events
191            .iter()
192            .filter(|event| {
193                matches!(event, Event::TimerCancelled { timer_id: recorded, .. } if recorded == timer_id)
194            })
195            .count()
196    }
197
198    fn count_fired(events: &[Event], timer_id: &TimerId) -> usize {
199        events
200            .iter()
201            .filter(|event| {
202                matches!(event, Event::TimerFired { timer_id: recorded, .. } if recorded == timer_id)
203            })
204            .count()
205    }
206
207    #[tokio::test]
208    async fn start_timer_preserves_named_id_in_history_and_timer_row() -> Result<(), TestError> {
209        let (store, _engine, service) = service();
210        let workflow_id = workflow_id();
211        let timer_id = TimerId::named("deadline")?;
212        let fire_at = instant(10);
213
214        start_timer(&service, workflow_id.clone(), timer_id.clone(), fire_at, 1).await?;
215
216        let expired = store.expired_timers(fire_at).await?;
217        assert_eq!(expired.len(), 1);
218        assert_eq!(expired[0].workflow_id, workflow_id);
219        assert_eq!(expired[0].timer_id, timer_id);
220        assert_eq!(expired[0].fire_at, fire_at);
221
222        // TimerStarted is now recorded by AD's resume-live handoff, not by the timer service.
223        let history = history(&store, &workflow_id).await?;
224        assert!(history.is_empty());
225        Ok(())
226    }
227
228    #[tokio::test]
229    async fn cancel_timer_disarms_resident_wheel_and_records_cancelled() -> Result<(), TestError> {
230        let process = WorkflowProcessHandle::new(42);
231        let (store, engine, service) = service();
232        let workflow_id = workflow_id();
233        let timer_id = TimerId::named("deadline")?;
234        let fire_at = instant(20);
235        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
236        engine.record_workflow_event(
237            &workflow_id,
238            timer_started_event(&workflow_id, &timer_id, 1),
239        )?;
240
241        start_timer(&service, workflow_id.clone(), timer_id.clone(), fire_at, 1).await?;
242        cancel_timer(&service, workflow_id.clone(), timer_id.clone()).await?;
243
244        assert!(engine.armed_timers()?.is_empty());
245        let history = history(&store, &workflow_id).await?;
246        assert_eq!(count_cancelled(&history, &timer_id), 1);
247        // Cancel only acts on a timer whose TimerStarted is recorded in the
248        // active run segment, so history is the seeded start plus the cancel.
249        assert!(matches!(
250            history.as_slice(),
251            [
252                Event::TimerStarted { .. },
253                Event::TimerCancelled {
254                    envelope,
255                    timer_id: recorded,
256                    cause: TimerCancelCause::WorkflowIntent,
257                }
258            ] if envelope.seq == 2 && recorded == &timer_id
259        ));
260        assert!(engine.operations()?.iter().any(|operation| matches!(
261            operation,
262            FakeEngineOperation::TimerDisarmed { process: disarmed_process, timer_id: disarmed }
263                if disarmed_process == &process && disarmed == &timer_id
264        )));
265        Ok(())
266    }
267
268    #[tokio::test]
269    async fn cancel_timer_after_fire_is_noop() -> Result<(), TestError> {
270        let process = WorkflowProcessHandle::new(42);
271        let (store, engine, service) = service();
272        let workflow_id = workflow_id();
273        let timer_id = TimerId::named("deadline")?;
274        let fire_at = instant(30);
275        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
276        engine.record_workflow_event(
277            &workflow_id,
278            timer_started_event(&workflow_id, &timer_id, 1),
279        )?;
280
281        start_timer(&service, workflow_id.clone(), timer_id.clone(), fire_at, 1).await?;
282        service
283            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
284            .await?;
285        let operation_count = engine.operations()?.len();
286
287        cancel_timer(&service, workflow_id.clone(), timer_id.clone()).await?;
288
289        let history = history(&store, &workflow_id).await?;
290        assert_eq!(count_fired(&history, &timer_id), 1);
291        assert_eq!(count_cancelled(&history, &timer_id), 0);
292        assert_eq!(engine.operations()?.len(), operation_count);
293        Ok(())
294    }
295
296    #[tokio::test]
297    async fn cancel_timer_after_cancel_is_idempotent_noop() -> Result<(), TestError> {
298        let process = WorkflowProcessHandle::new(42);
299        let (store, engine, service) = service();
300        let workflow_id = workflow_id();
301        let timer_id = TimerId::named("deadline")?;
302        let fire_at = instant(40);
303        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
304        engine.record_workflow_event(
305            &workflow_id,
306            timer_started_event(&workflow_id, &timer_id, 1),
307        )?;
308
309        start_timer(&service, workflow_id.clone(), timer_id.clone(), fire_at, 1).await?;
310        cancel_timer(&service, workflow_id.clone(), timer_id.clone()).await?;
311        let operation_count = engine.operations()?.len();
312
313        cancel_timer(&service, workflow_id.clone(), timer_id.clone()).await?;
314
315        let history = history(&store, &workflow_id).await?;
316        assert_eq!(count_cancelled(&history, &timer_id), 1);
317        assert_eq!(engine.operations()?.len(), operation_count);
318        Ok(())
319    }
320
321    #[tokio::test]
322    async fn cancel_timer_settles_anonymous_scope_deadline() -> Result<(), TestError> {
323        // Authors can never address an anonymous timer (TimerRef is minted by
324        // start_timer, always named), but with_timeout settles its anonymous
325        // scope deadline through this exact path — the cancel must record the
326        // terminal event so the scope race reads signal-won deterministically.
327        let process = WorkflowProcessHandle::new(42);
328        let (store, engine, service) = service();
329        let workflow_id = workflow_id();
330        let timer_id = TimerId::anonymous(42);
331        let fire_at = instant(40);
332        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
333        engine.record_workflow_event(
334            &workflow_id,
335            timer_started_event(&workflow_id, &timer_id, 1),
336        )?;
337        service
338            .schedule(workflow_id.clone(), timer_id.clone(), fire_at, 1)
339            .await?;
340
341        cancel_timer(&service, workflow_id.clone(), timer_id.clone()).await?;
342
343        let history = history(&store, &workflow_id).await?;
344        assert_eq!(count_cancelled(&history, &timer_id), 1);
345        assert_eq!(count_fired(&history, &timer_id), 0);
346        Ok(())
347    }
348
349    #[tokio::test]
350    async fn sleep_derives_anonymous_id_and_fire_at_from_recorded_inputs() -> Result<(), TestError>
351    {
352        let (store, _engine, service) = service();
353        let workflow_id = workflow_id();
354        let recorded_now = instant(50);
355        let duration = Duration::from_secs(15);
356        let sequence_position = 9;
357        let expected_timer_id = TimerId::anonymous(sequence_position);
358        let expected_fire_at = instant(65);
359
360        let scheduled = sleep(
361            &service,
362            workflow_id.clone(),
363            duration,
364            recorded_now,
365            sequence_position,
366            1,
367        )
368        .await?;
369
370        assert_eq!(scheduled.timer_id, expected_timer_id);
371        assert_eq!(scheduled.fire_at, expected_fire_at);
372        // TimerStarted is recorded by AD's resume-live handoff, not by the timer service.
373        let history = history(&store, &workflow_id).await?;
374        assert!(history.is_empty());
375        Ok(())
376    }
377
378    #[tokio::test]
379    async fn start_timer_arms_named_timer_without_rewriting_id() -> Result<(), TestError> {
380        let process = WorkflowProcessHandle::new(42);
381        let (_store, engine, service) = service();
382        let workflow_id = workflow_id();
383        let timer_id = TimerId::named("deadline")?;
384        let fire_at = instant(70);
385        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
386
387        start_timer(&service, workflow_id, timer_id.clone(), fire_at, 1).await?;
388
389        assert_eq!(
390            engine.armed_timers()?,
391            vec![TimerWheelEntry {
392                process,
393                timer_id,
394                fire_at,
395            }]
396        );
397        Ok(())
398    }
399}