Skip to main content

aion/time/
named.rs

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