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