Skip to main content

aion/time/
timer_service.rs

1//! Durable timer service: schedule, wheel arm, and `TimerFired` delivery.
2
3use std::sync::Arc;
4
5use aion_core::{Event, EventEnvelope, TimerId, WorkflowId};
6use aion_store::{ReadableEventStore, StoreError};
7use chrono::{DateTime, Utc};
8use dashmap::DashSet;
9
10use crate::engine_seam::{
11    EngineHandle, EngineSeamError, TimerWheelEntry, WorkflowMailboxMessage, WorkflowResidency,
12};
13
14/// Durable timer scheduling and wheel-fire handling.
15///
16/// The service owns the AT live path for timers. Workflow-issued `TimerStarted` events are recorded
17/// by AD's resume-live handoff before this service is called; this service persists only the durable
18/// timer row and later asynchronous arrival/cancellation history through the engine recorder seam.
19pub struct TimerService {
20    engine: Arc<dyn EngineHandle>,
21    store: Arc<dyn ReadableEventStore>,
22    recorded_at: fn() -> DateTime<Utc>,
23    terminal_updates: DashSet<(WorkflowId, TimerId)>,
24}
25
26struct TerminalUpdateSlot<'a> {
27    terminal_updates: &'a DashSet<(WorkflowId, TimerId)>,
28    key: (WorkflowId, TimerId),
29}
30
31impl Drop for TerminalUpdateSlot<'_> {
32    fn drop(&mut self) {
33        self.terminal_updates.remove(&self.key);
34    }
35}
36
37/// Errors returned by [`TimerService`].
38#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
39pub enum TimerServiceError {
40    /// Durable timer storage or history inspection failed.
41    #[error("timer store operation failed: {0}")]
42    Store(#[from] StoreError),
43
44    /// Engine seam operation failed.
45    #[error("timer engine operation failed: {0}")]
46    Engine(#[from] EngineSeamError),
47}
48
49impl TimerService {
50    /// Creates a durable timer service from the engine seam and timer store.
51    #[must_use]
52    pub fn new(engine: Arc<dyn EngineHandle>, store: Arc<dyn ReadableEventStore>) -> Self {
53        Self::with_recorded_at(engine, store, Utc::now)
54    }
55
56    /// Creates a durable timer service with an injected history timestamp source.
57    #[must_use]
58    pub fn with_recorded_at(
59        engine: Arc<dyn EngineHandle>,
60        store: Arc<dyn ReadableEventStore>,
61        recorded_at: fn() -> DateTime<Utc>,
62    ) -> Self {
63        Self {
64            engine,
65            store,
66            recorded_at,
67            terminal_updates: DashSet::new(),
68        }
69    }
70
71    /// Schedules a durable timer and arms the live wheel when the workflow is resident.
72    ///
73    /// The operation persists the durable timer row and arms the wheel when needed. The
74    /// command-issued `TimerStarted` recorder event is appended by AD's resume-live handoff before
75    /// AE/AT reaches this service, so this method deliberately does not record it again.
76    ///
77    /// # Errors
78    ///
79    /// Returns [`TimerServiceError`] when durable storage, recording, residency resolution, or wheel
80    /// arming fails.
81    pub async fn schedule(
82        &self,
83        workflow_id: WorkflowId,
84        timer_id: TimerId,
85        fire_at: DateTime<Utc>,
86    ) -> Result<(), TimerServiceError> {
87        self.store
88            .schedule_timer(&workflow_id, &timer_id, fire_at)
89            .await?;
90
91        if let WorkflowResidency::Resident(process) = self.engine.resolve_workflow(&workflow_id)? {
92            self.engine.arm_timer(TimerWheelEntry {
93                process,
94                timer_id,
95                fire_at,
96            })?;
97        }
98
99        Ok(())
100    }
101
102    /// Cancels a durable timer that has not already reached a terminal timer state.
103    ///
104    /// Already-fired and already-cancelled timers are treated as idempotent no-ops. For active
105    /// resident timers the live wheel is disarmed through the engine seam before `TimerCancelled` is
106    /// recorded through the workflow recorder seam. Non-resident timers still record the cancellation
107    /// so recovery/replay can suppress a later fire.
108    ///
109    /// Anonymous timers are accepted: authors can never address one (the SDK's `cancel_timer`
110    /// takes a `TimerRef` minted by `start_timer`, which is always named), but the engine settles
111    /// `with_timeout` scope deadlines — anonymous by construction — through this first-recorded-wins
112    /// race against [`Self::fire_timer`].
113    ///
114    /// # Errors
115    ///
116    /// Returns [`TimerServiceError`] when history inspection, residency resolution, wheel disarming,
117    /// or event recording fails.
118    pub async fn cancel(
119        &self,
120        workflow_id: WorkflowId,
121        timer_id: TimerId,
122    ) -> Result<(), TimerServiceError> {
123        let key = (workflow_id.clone(), timer_id.clone());
124        let terminal_update_slot = self.wait_for_terminal_update_slot(key).await;
125
126        let result = self.cancel_guarded(workflow_id, timer_id).await;
127        drop(terminal_update_slot);
128        result
129    }
130
131    async fn cancel_guarded(
132        &self,
133        workflow_id: WorkflowId,
134        timer_id: TimerId,
135    ) -> Result<(), TimerServiceError> {
136        if !self.timer_is_live(&workflow_id, &timer_id).await? {
137            return Ok(());
138        }
139
140        if let WorkflowResidency::Resident(process) = self.engine.resolve_workflow(&workflow_id)? {
141            self.engine.disarm_timer(process, &timer_id)?;
142        }
143
144        let event = Event::TimerCancelled {
145            envelope: self.next_envelope(&workflow_id).await?,
146            timer_id,
147        };
148        self.engine.record_workflow_event(&workflow_id, event)?;
149
150        Ok(())
151    }
152
153    /// Handles a live timer-wheel fire.
154    ///
155    /// `TimerFired` is recorded before any mailbox delivery. If the workflow is no longer resident,
156    /// the recorded event remains the durable observation that replay/recovery can surface later.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`TimerServiceError`] when history inspection, recording, residency resolution, or
161    /// live mailbox delivery fails.
162    pub async fn fire_timer(
163        &self,
164        workflow_id: WorkflowId,
165        timer_id: TimerId,
166        fire_at: DateTime<Utc>,
167    ) -> Result<(), TimerServiceError> {
168        let key = (workflow_id.clone(), timer_id.clone());
169        let terminal_update_slot = self.wait_for_terminal_update_slot(key).await;
170
171        let result = self
172            .fire_timer_guarded(workflow_id, timer_id, fire_at)
173            .await;
174        drop(terminal_update_slot);
175        result
176    }
177
178    async fn wait_for_terminal_update_slot(
179        &self,
180        key: (WorkflowId, TimerId),
181    ) -> TerminalUpdateSlot<'_> {
182        loop {
183            if self.terminal_updates.insert(key.clone()) {
184                return TerminalUpdateSlot {
185                    terminal_updates: &self.terminal_updates,
186                    key,
187                };
188            }
189            tokio::task::yield_now().await;
190        }
191    }
192
193    async fn fire_timer_guarded(
194        &self,
195        workflow_id: WorkflowId,
196        timer_id: TimerId,
197        fire_at: DateTime<Utc>,
198    ) -> Result<(), TimerServiceError> {
199        if !self.timer_is_live(&workflow_id, &timer_id).await? {
200            return Ok(());
201        }
202
203        let event = Event::TimerFired {
204            envelope: self.next_envelope(&workflow_id).await?,
205            timer_id: timer_id.clone(),
206        };
207        self.engine.record_workflow_event(&workflow_id, event)?;
208
209        if let WorkflowResidency::Resident(process) = self.engine.resolve_workflow(&workflow_id)? {
210            self.engine.deliver_workflow_message(
211                process,
212                WorkflowMailboxMessage::TimerFired { timer_id, fire_at },
213            )?;
214        }
215
216        Ok(())
217    }
218
219    /// Whether the timer is started and not yet terminal in the workflow's
220    /// active run segment.
221    ///
222    /// A timer belongs to the run that recorded its `TimerStarted`, and
223    /// anonymous timer identities are run-scoped ordinals that replacement
224    /// runs (continue-as-new) re-allocate from zero. Scoping the check to
225    /// the latest run segment keeps a stale fire from a finished run from
226    /// recording into — or suppressing — the replacement run's identically
227    /// named timer.
228    async fn timer_is_live(
229        &self,
230        workflow_id: &WorkflowId,
231        timer_id: &TimerId,
232    ) -> Result<bool, StoreError> {
233        let history = self.store.read_history(workflow_id).await?;
234        let segment_start = history
235            .iter()
236            .rposition(|event| matches!(event, Event::WorkflowStarted { .. }))
237            .unwrap_or(0);
238        let segment = &history[segment_start..];
239        let started = segment.iter().any(|event| {
240            matches!(
241                event,
242                Event::TimerStarted { timer_id: recorded, .. } if recorded == timer_id
243            )
244        });
245        let terminal = segment.iter().any(|event| match event {
246            Event::TimerFired {
247                timer_id: recorded, ..
248            }
249            | Event::TimerCancelled {
250                timer_id: recorded, ..
251            } => recorded == timer_id,
252            _ => false,
253        });
254        Ok(started && !terminal)
255    }
256
257    async fn next_envelope(&self, workflow_id: &WorkflowId) -> Result<EventEnvelope, StoreError> {
258        let history = self.store.read_history(workflow_id).await?;
259        let seq = history.iter().map(Event::seq).max().unwrap_or_default() + 1;
260        Ok(EventEnvelope {
261            seq,
262            recorded_at: (self.recorded_at)(),
263            workflow_id: workflow_id.clone(),
264        })
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use std::sync::Arc;
271
272    use aion_core::{Event, EventEnvelope, TimerId, WorkflowId};
273    use aion_store::{InMemoryStore, ReadableEventStore, StoreError, WritableEventStore};
274    use chrono::{DateTime, Utc};
275
276    use super::{TimerService, TimerServiceError};
277    use crate::engine_seam::test_support::{
278        DeliveredWorkflowMessage, FakeEngineHandle, FakeEngineOperation,
279    };
280    use crate::engine_seam::{
281        EngineHandle, TimerWheelEntry, WorkflowProcessHandle, WorkflowResidency,
282    };
283
284    fn instant(offset_seconds: i64) -> DateTime<Utc> {
285        DateTime::from_timestamp(1_700_000_000 + offset_seconds, 0).unwrap_or_default()
286    }
287
288    fn workflow_id() -> WorkflowId {
289        WorkflowId::new_v4()
290    }
291
292    fn timer_id() -> TimerId {
293        TimerId::anonymous(7)
294    }
295
296    fn service() -> (Arc<InMemoryStore>, Arc<FakeEngineHandle>, TimerService) {
297        let concrete_store = Arc::new(InMemoryStore::default());
298        let recorder_store: Arc<dyn WritableEventStore> = concrete_store.clone();
299        let readable_store: Arc<dyn ReadableEventStore> = concrete_store.clone();
300        let engine = Arc::new(FakeEngineHandle::recording_to(recorder_store));
301        let service = TimerService::with_recorded_at(engine.clone(), readable_store, recorded_at);
302        (concrete_store, engine, service)
303    }
304
305    fn recorded_at() -> DateTime<Utc> {
306        instant(1)
307    }
308
309    async fn history(
310        store: &InMemoryStore,
311        workflow_id: &WorkflowId,
312    ) -> Result<Vec<Event>, StoreError> {
313        store.read_history(workflow_id).await
314    }
315
316    fn count_timer_fired(events: &[Event], timer_id: &TimerId) -> usize {
317        events
318            .iter()
319            .filter(|event| {
320                matches!(event, Event::TimerFired { timer_id: recorded, .. } if recorded == timer_id)
321            })
322            .count()
323    }
324
325    fn timer_started_event(workflow_id: &WorkflowId, timer_id: &TimerId, seq: u64) -> Event {
326        Event::TimerStarted {
327            envelope: EventEnvelope {
328                seq,
329                recorded_at: instant(0),
330                workflow_id: workflow_id.clone(),
331            },
332            timer_id: timer_id.clone(),
333            fire_at: instant(5),
334        }
335    }
336
337    fn workflow_started_event(workflow_id: &WorkflowId, seq: u64) -> Event {
338        Event::WorkflowStarted {
339            envelope: EventEnvelope {
340                seq,
341                recorded_at: instant(0),
342                workflow_id: workflow_id.clone(),
343            },
344            workflow_type: "fixture".to_owned(),
345            input: aion_core::Payload::new(aion_core::ContentType::Json, b"null".to_vec()),
346            run_id: aion_core::RunId::new_v4(),
347            parent_run_id: None,
348            package_version: aion_core::PackageVersion::new("a".repeat(64)),
349        }
350    }
351
352    #[tokio::test]
353    async fn schedule_records_timer_row_without_timer_started_event()
354    -> Result<(), TimerServiceError> {
355        let (store, _engine, service) = service();
356        let workflow_id = workflow_id();
357        let timer_id = timer_id();
358        let fire_at = instant(10);
359
360        service
361            .schedule(workflow_id.clone(), timer_id.clone(), fire_at)
362            .await?;
363
364        let expired = store.expired_timers(fire_at).await?;
365        assert_eq!(expired.len(), 1);
366        assert_eq!(expired[0].workflow_id, workflow_id);
367        assert_eq!(expired[0].timer_id, timer_id);
368        assert_eq!(expired[0].fire_at, fire_at);
369
370        assert!(history(&store, &workflow_id).await?.is_empty());
371        Ok(())
372    }
373
374    #[tokio::test]
375    async fn schedule_arms_wheel_for_resident_workflow() -> Result<(), TimerServiceError> {
376        let process = WorkflowProcessHandle::new(42);
377        let (_store, engine, service) = service();
378        let workflow_id = workflow_id();
379        let timer_id = timer_id();
380        let fire_at = instant(20);
381        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
382
383        service
384            .schedule(workflow_id, timer_id.clone(), fire_at)
385            .await?;
386
387        assert_eq!(
388            engine.armed_timers()?,
389            vec![TimerWheelEntry {
390                process,
391                timer_id,
392                fire_at
393            }]
394        );
395        Ok(())
396    }
397
398    #[tokio::test]
399    async fn schedule_for_nonresident_records_without_arming() -> Result<(), TimerServiceError> {
400        let (store, engine, service) = service();
401        let workflow_id = workflow_id();
402        let timer_id = timer_id();
403        let fire_at = instant(30);
404        engine.set_residency(workflow_id.clone(), WorkflowResidency::NonResident)?;
405
406        service
407            .schedule(workflow_id.clone(), timer_id, fire_at)
408            .await?;
409
410        assert!(engine.armed_timers()?.is_empty());
411        assert!(history(&store, &workflow_id).await?.is_empty());
412        Ok(())
413    }
414
415    #[tokio::test]
416    async fn fire_records_timer_fired_then_delivers_mailbox_message()
417    -> Result<(), TimerServiceError> {
418        let process = WorkflowProcessHandle::new(42);
419        let (store, engine, service) = service();
420        let workflow_id = workflow_id();
421        let timer_id = timer_id();
422        let fire_at = instant(40);
423        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
424        engine.record_workflow_event(
425            &workflow_id,
426            timer_started_event(&workflow_id, &timer_id, 1),
427        )?;
428
429        service
430            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
431            .await?;
432
433        assert_eq!(
434            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
435            1
436        );
437        assert_eq!(
438            engine.delivered_messages()?,
439            vec![(
440                process,
441                DeliveredWorkflowMessage::TimerFired {
442                    timer_id: timer_id.clone(),
443                    fire_at
444                }
445            )]
446        );
447        assert!(matches!(
448            engine.operations()?.as_slice(),
449            [
450                FakeEngineOperation::EventRecorded {
451                    event: Event::TimerStarted { .. },
452                    ..
453                },
454                FakeEngineOperation::EventRecorded {
455                    workflow_id: recorded_workflow_id,
456                    event: Event::TimerFired { timer_id: recorded_timer_id, .. },
457                },
458                FakeEngineOperation::Delivered {
459                    process: delivered_process,
460                    message: DeliveredWorkflowMessage::TimerFired { timer_id: delivered_timer_id, .. },
461                }
462            ] if recorded_workflow_id == &workflow_id
463                && recorded_timer_id == &timer_id
464                && delivered_process == &process
465                && delivered_timer_id == &timer_id
466        ));
467        Ok(())
468    }
469
470    #[tokio::test]
471    async fn fire_records_without_delivery_when_workflow_becomes_nonresident()
472    -> Result<(), TimerServiceError> {
473        let (store, engine, service) = service();
474        let workflow_id = workflow_id();
475        let timer_id = timer_id();
476        let fire_at = instant(50);
477        engine.set_residency(workflow_id.clone(), WorkflowResidency::NonResident)?;
478        engine.record_workflow_event(
479            &workflow_id,
480            timer_started_event(&workflow_id, &timer_id, 1),
481        )?;
482
483        service
484            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
485            .await?;
486
487        assert_eq!(
488            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
489            1
490        );
491        assert!(engine.delivered_messages()?.is_empty());
492        Ok(())
493    }
494
495    #[tokio::test]
496    async fn firing_same_timer_twice_records_and_delivers_once() -> Result<(), TimerServiceError> {
497        let process = WorkflowProcessHandle::new(42);
498        let (store, engine, service) = service();
499        let workflow_id = workflow_id();
500        let timer_id = timer_id();
501        let fire_at = instant(60);
502        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
503        engine.record_workflow_event(
504            &workflow_id,
505            timer_started_event(&workflow_id, &timer_id, 1),
506        )?;
507
508        service
509            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
510            .await?;
511        service
512            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
513            .await?;
514
515        assert_eq!(
516            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
517            1
518        );
519        assert_eq!(engine.delivered_messages()?.len(), 1);
520        Ok(())
521    }
522
523    #[tokio::test]
524    async fn firing_cancelled_timer_is_noop() -> Result<(), TimerServiceError> {
525        let process = WorkflowProcessHandle::new(42);
526        let (store, engine, service) = service();
527        let workflow_id = workflow_id();
528        let timer_id = timer_id();
529        let fire_at = instant(70);
530        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
531        engine.record_workflow_event(
532            &workflow_id,
533            timer_started_event(&workflow_id, &timer_id, 1),
534        )?;
535        let cancelled = Event::TimerCancelled {
536            envelope: EventEnvelope {
537                seq: 2,
538                recorded_at: instant(69),
539                workflow_id: workflow_id.clone(),
540            },
541            timer_id: timer_id.clone(),
542        };
543        engine.record_workflow_event(&workflow_id, cancelled)?;
544
545        service
546            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
547            .await?;
548
549        let history = history(&store, &workflow_id).await?;
550        assert_eq!(count_timer_fired(&history, &timer_id), 0);
551        assert!(engine.delivered_messages()?.is_empty());
552        Ok(())
553    }
554
555    #[tokio::test]
556    async fn fire_resolves_residency_at_fire_time() -> Result<(), TimerServiceError> {
557        let process = WorkflowProcessHandle::new(42);
558        let (store, engine, service) = service();
559        let workflow_id = workflow_id();
560        let timer_id = timer_id();
561        let fire_at = instant(80);
562
563        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
564        engine.set_residency(workflow_id.clone(), WorkflowResidency::NonResident)?;
565        engine.record_workflow_event(
566            &workflow_id,
567            timer_started_event(&workflow_id, &timer_id, 1),
568        )?;
569        service
570            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
571            .await?;
572
573        assert_eq!(
574            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
575            1
576        );
577        assert!(engine.delivered_messages()?.is_empty());
578        Ok(())
579    }
580
581    #[tokio::test]
582    async fn firing_unstarted_timer_records_nothing() -> Result<(), TimerServiceError> {
583        let process = WorkflowProcessHandle::new(42);
584        let (store, engine, service) = service();
585        let workflow_id = workflow_id();
586        let timer_id = timer_id();
587        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
588
589        service
590            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(90))
591            .await?;
592
593        assert!(history(&store, &workflow_id).await?.is_empty());
594        assert!(engine.delivered_messages()?.is_empty());
595        Ok(())
596    }
597
598    #[tokio::test]
599    async fn firing_prior_run_timer_after_continue_as_new_is_noop() -> Result<(), TimerServiceError>
600    {
601        let process = WorkflowProcessHandle::new(42);
602        let (store, engine, service) = service();
603        let workflow_id = workflow_id();
604        let timer_id = timer_id();
605        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
606        // Run 1 started the timer; run 2's WorkflowStarted closes that segment.
607        engine.record_workflow_event(
608            &workflow_id,
609            timer_started_event(&workflow_id, &timer_id, 1),
610        )?;
611        engine.record_workflow_event(&workflow_id, workflow_started_event(&workflow_id, 2))?;
612
613        service
614            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(100))
615            .await?;
616
617        assert_eq!(
618            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
619            0
620        );
621        assert!(engine.delivered_messages()?.is_empty());
622        Ok(())
623    }
624}