Skip to main content

aion/time/
recovery.rs

1//! Expired timer polling on startup and periodic recovery tick.
2
3use aion_core::{Event, TimerId};
4use std::collections::HashMap;
5use std::sync::Arc;
6use std::time::Duration;
7
8use aion_store::{ReadableEventStore, StoreError};
9use chrono::{DateTime, Utc};
10
11use crate::engine_seam::EngineSeamError;
12use crate::time::{TimerService, TimerServiceError};
13
14/// Recovery service for durable timers that elapsed outside the live wheel path.
15pub struct TimerRecovery {
16    store: Arc<dyn ReadableEventStore>,
17    timer_service: Arc<TimerService>,
18    recovery_interval: Duration,
19    now: fn() -> DateTime<Utc>,
20}
21
22/// Errors returned by [`TimerRecovery`].
23#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
24pub enum TimerRecoveryError {
25    /// Durable timer polling failed.
26    #[error("timer recovery store operation failed: {0}")]
27    Store(#[from] StoreError),
28
29    /// Recovered timer firing failed.
30    #[error("timer recovery fire operation failed: {0}")]
31    Timer(#[from] TimerServiceError),
32}
33
34impl TimerRecovery {
35    /// Creates a timer recovery service with an engine-configured recovery cadence.
36    #[must_use]
37    pub fn new(
38        store: Arc<dyn ReadableEventStore>,
39        timer_service: Arc<TimerService>,
40        recovery_interval: Duration,
41    ) -> Self {
42        Self::with_clock(store, timer_service, recovery_interval, Utc::now)
43    }
44
45    /// Creates a timer recovery service with an injected clock for deterministic ticking.
46    #[must_use]
47    pub fn with_clock(
48        store: Arc<dyn ReadableEventStore>,
49        timer_service: Arc<TimerService>,
50        recovery_interval: Duration,
51        now: fn() -> DateTime<Utc>,
52    ) -> Self {
53        Self {
54            store,
55            timer_service,
56            recovery_interval,
57            now,
58        }
59    }
60
61    /// Runs the engine-startup recovery sweep for timers due as of `now`.
62    ///
63    /// Each due timer is delegated to [`TimerService::fire_timer`], which owns terminal filtering,
64    /// the in-flight fire guard, recording `TimerFired`, and mailbox delivery.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`TimerRecoveryError`] when polling expired timers or firing a due timer fails.
69    pub async fn recover_on_startup(
70        &self,
71        now: DateTime<Utc>,
72    ) -> Result<usize, TimerRecoveryError> {
73        let fired = self.recover_due(now).await?;
74        self.rearm_future_from_active_histories(now).await?;
75        Ok(fired)
76    }
77
78    /// Runs one recovery tick using the injected clock.
79    ///
80    /// AE owns driving this method at [`Self::recovery_interval`]; this service intentionally does
81    /// not spawn or own the production runtime task.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`TimerRecoveryError`] when polling expired timers or firing a due timer fails.
86    pub async fn tick(&self) -> Result<usize, TimerRecoveryError> {
87        self.recover_due((self.now)()).await
88    }
89
90    /// Returns the engine-configured recovery cadence.
91    #[must_use]
92    pub const fn recovery_interval(&self) -> Duration {
93        self.recovery_interval
94    }
95
96    async fn recover_due(&self, now: DateTime<Utc>) -> Result<usize, TimerRecoveryError> {
97        let due_timers = self.store.expired_timers(now).await?;
98        let mut fired = 0;
99        for entry in due_timers {
100            match self
101                .timer_service
102                .fire_timer(
103                    entry.workflow_id.clone(),
104                    entry.timer_id.clone(),
105                    entry.fire_at,
106                )
107                .await
108            {
109                Ok(()) => fired += 1,
110                // An orphaned timer whose workflow no longer exists — e.g. the
111                // workflow was cancelled and purged from the engine's known set —
112                // must never abort recovery or block engine startup. The workflow
113                // is gone, so the timer is moot: log it and skip. (A terminal but
114                // still-known workflow's timer is already filtered inside
115                // `fire_timer`'s liveness check, which returns `Ok` without firing.)
116                Err(TimerServiceError::Engine(EngineSeamError::UnknownWorkflow {
117                    workflow_id,
118                })) => {
119                    tracing::warn!(
120                        %workflow_id,
121                        timer_id = %entry.timer_id,
122                        "skipping recovered timer for unknown workflow (orphaned timer); \
123                         the workflow no longer exists"
124                    );
125                }
126                Err(other) => return Err(other.into()),
127            }
128        }
129        Ok(fired)
130    }
131
132    async fn rearm_future_from_active_histories(
133        &self,
134        now: DateTime<Utc>,
135    ) -> Result<usize, TimerRecoveryError> {
136        let mut rearmed = 0;
137        for workflow_id in self.store.list_active().await? {
138            let history = self.store.read_history(&workflow_id).await?;
139            for (timer_id, fire_at) in outstanding_future_timers(&history, now) {
140                self.timer_service
141                    .schedule(workflow_id.clone(), timer_id, fire_at)
142                    .await?;
143                rearmed += 1;
144            }
145        }
146        Ok(rearmed)
147    }
148}
149
150fn outstanding_future_timers(
151    history: &[Event],
152    now: DateTime<Utc>,
153) -> Vec<(TimerId, DateTime<Utc>)> {
154    let mut outstanding: HashMap<TimerId, DateTime<Utc>> = HashMap::new();
155    for event in history {
156        match event {
157            Event::TimerStarted {
158                timer_id, fire_at, ..
159            } => {
160                outstanding.insert(timer_id.clone(), *fire_at);
161            }
162            Event::TimerFired { timer_id, .. } | Event::TimerCancelled { timer_id, .. } => {
163                outstanding.remove(timer_id);
164            }
165            _ => {}
166        }
167    }
168    outstanding
169        .into_iter()
170        .filter(|(_, fire_at)| *fire_at > now)
171        .collect()
172}
173
174#[cfg(test)]
175mod tests {
176    use std::sync::Arc;
177    use std::time::Duration;
178
179    use aion_core::{Event, EventEnvelope, TimerId, WorkflowId};
180    use aion_store::{InMemoryStore, ReadableEventStore, StoreError, WritableEventStore};
181    use chrono::{DateTime, Utc};
182
183    use super::{TimerRecovery, TimerRecoveryError};
184    use crate::engine_seam::test_support::{DeliveredWorkflowMessage, FakeEngineHandle};
185    use crate::engine_seam::{
186        EngineHandle, EngineSeamError, WorkflowProcessHandle, WorkflowResidency,
187    };
188    use crate::time::TimerService;
189
190    const RECOVERY_INTERVAL: Duration = Duration::from_millis(10);
191
192    #[derive(Debug, thiserror::Error)]
193    enum TestError {
194        #[error(transparent)]
195        Recovery(#[from] TimerRecoveryError),
196
197        #[error(transparent)]
198        Store(#[from] StoreError),
199
200        #[error(transparent)]
201        Engine(#[from] EngineSeamError),
202    }
203
204    fn instant(offset_seconds: i64) -> DateTime<Utc> {
205        DateTime::from_timestamp(1_700_000_000 + offset_seconds, 0).unwrap_or_default()
206    }
207
208    fn recorded_at() -> DateTime<Utc> {
209        instant(1)
210    }
211
212    fn tick_now() -> DateTime<Utc> {
213        instant(30)
214    }
215
216    fn workflow_id() -> WorkflowId {
217        WorkflowId::new_v4()
218    }
219
220    fn timer_id(sequence: u64) -> TimerId {
221        TimerId::anonymous(sequence)
222    }
223
224    fn recovery() -> (Arc<InMemoryStore>, Arc<FakeEngineHandle>, TimerRecovery) {
225        let concrete_store = Arc::new(InMemoryStore::default());
226        let writable: Arc<dyn WritableEventStore> = concrete_store.clone();
227        let readable: Arc<dyn ReadableEventStore> = concrete_store.clone();
228        let engine = Arc::new(FakeEngineHandle::recording_to(writable));
229        let timer_service = Arc::new(TimerService::with_recorded_at(
230            engine.clone(),
231            readable.clone(),
232            recorded_at,
233        ));
234        let recovery =
235            TimerRecovery::with_clock(readable, timer_service, RECOVERY_INTERVAL, tick_now);
236        (concrete_store, engine, recovery)
237    }
238
239    async fn history(
240        store: &InMemoryStore,
241        workflow_id: &WorkflowId,
242    ) -> Result<Vec<Event>, StoreError> {
243        store.read_history(workflow_id).await
244    }
245
246    fn timer_started_event(workflow_id: &WorkflowId, timer_id: &TimerId, seq: u64) -> Event {
247        Event::TimerStarted {
248            envelope: EventEnvelope {
249                seq,
250                recorded_at: instant(0),
251                workflow_id: workflow_id.clone(),
252            },
253            timer_id: timer_id.clone(),
254            fire_at: instant(5),
255        }
256    }
257
258    fn count_timer_fired(events: &[Event], timer_id: &TimerId) -> usize {
259        events
260            .iter()
261            .filter(|event| {
262                matches!(event, Event::TimerFired { timer_id: recorded, .. } if recorded == timer_id)
263            })
264            .count()
265    }
266
267    #[tokio::test]
268    async fn startup_sweep_fires_past_timer_and_delivers() -> Result<(), TestError> {
269        let process = WorkflowProcessHandle::new(42);
270        let (store, engine, recovery) = recovery();
271        let workflow_id = workflow_id();
272        let timer_id = timer_id(1);
273        let fire_at = instant(10);
274        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
275        engine.record_workflow_event(
276            &workflow_id,
277            timer_started_event(&workflow_id, &timer_id, 1),
278        )?;
279        store
280            .schedule_timer(&workflow_id, &timer_id, fire_at)
281            .await?;
282
283        let recovered = recovery.recover_on_startup(instant(20)).await?;
284
285        assert_eq!(recovered, 1);
286        assert_eq!(
287            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
288            1
289        );
290        assert_eq!(
291            engine.delivered_messages()?,
292            vec![(
293                process,
294                DeliveredWorkflowMessage::TimerFired {
295                    timer_id: timer_id.clone(),
296                    fire_at
297                }
298            )]
299        );
300        Ok(())
301    }
302
303    #[tokio::test]
304    async fn startup_sweep_does_not_fire_future_timer() -> Result<(), TestError> {
305        let process = WorkflowProcessHandle::new(42);
306        let (store, engine, recovery) = recovery();
307        let workflow_id = workflow_id();
308        let timer_id = timer_id(2);
309        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
310        store
311            .schedule_timer(&workflow_id, &timer_id, instant(30))
312            .await?;
313
314        let recovered = recovery.recover_on_startup(instant(20)).await?;
315
316        assert_eq!(recovered, 0);
317        assert_eq!(
318            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
319            0
320        );
321        assert!(engine.delivered_messages()?.is_empty());
322        Ok(())
323    }
324
325    #[tokio::test]
326    async fn tick_uses_injected_clock_and_fires_newly_due_timer_once() -> Result<(), TestError> {
327        let process = WorkflowProcessHandle::new(42);
328        let (store, engine, recovery) = recovery();
329        let workflow_id = workflow_id();
330        let timer_id = timer_id(3);
331        let fire_at = instant(25);
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        store
338            .schedule_timer(&workflow_id, &timer_id, fire_at)
339            .await?;
340
341        assert_eq!(recovery.recovery_interval(), RECOVERY_INTERVAL);
342        assert_eq!(recovery.tick().await?, 1);
343        assert_eq!(recovery.tick().await?, 1);
344
345        assert_eq!(
346            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
347            1
348        );
349        assert_eq!(engine.delivered_messages()?.len(), 1);
350        Ok(())
351    }
352
353    #[tokio::test]
354    async fn running_startup_sweep_twice_fires_due_timer_once_total() -> Result<(), TestError> {
355        let process = WorkflowProcessHandle::new(42);
356        let (store, engine, recovery) = recovery();
357        let workflow_id = workflow_id();
358        let timer_id = timer_id(4);
359        let fire_at = instant(10);
360        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
361        engine.record_workflow_event(
362            &workflow_id,
363            timer_started_event(&workflow_id, &timer_id, 1),
364        )?;
365        store
366            .schedule_timer(&workflow_id, &timer_id, fire_at)
367            .await?;
368
369        recovery.recover_on_startup(instant(20)).await?;
370        recovery.recover_on_startup(instant(20)).await?;
371
372        assert_eq!(
373            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
374            1
375        );
376        assert_eq!(engine.delivered_messages()?.len(), 1);
377        Ok(())
378    }
379
380    #[tokio::test]
381    async fn cancelled_timer_is_never_fired_by_recovery() -> Result<(), TestError> {
382        let process = WorkflowProcessHandle::new(42);
383        let (store, engine, recovery) = recovery();
384        let workflow_id = workflow_id();
385        let timer_id = timer_id(5);
386        let fire_at = instant(10);
387        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
388        store
389            .schedule_timer(&workflow_id, &timer_id, fire_at)
390            .await?;
391        engine.record_workflow_event(
392            &workflow_id,
393            Event::TimerCancelled {
394                cause: aion_core::TimerCancelCause::WorkflowIntent,
395                envelope: EventEnvelope {
396                    seq: 1,
397                    recorded_at: instant(9),
398                    workflow_id: workflow_id.clone(),
399                },
400                timer_id: timer_id.clone(),
401            },
402        )?;
403
404        recovery.recover_on_startup(instant(20)).await?;
405
406        assert_eq!(
407            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
408            0
409        );
410        assert!(engine.delivered_messages()?.is_empty());
411        Ok(())
412    }
413
414    #[tokio::test]
415    async fn orphaned_timer_for_unknown_workflow_is_skipped_not_fatal() -> Result<(), TestError> {
416        // Regression: a durable timer whose workflow was cancelled and purged
417        // from the engine's known set must NOT abort startup recovery. Before the
418        // fix, `fire_timer`'s `UnknownWorkflow` error propagated and bricked engine
419        // startup — observed in production after a restart:
420        //   "timer recovery fire operation failed: ... workflow <id> is unknown".
421        let (store, engine, recovery) = recovery();
422        let workflow_id = workflow_id();
423        let timer_id = timer_id(6);
424        let fire_at = instant(10);
425
426        // The timer is live in history (started, never fired/cancelled) ...
427        store
428            .schedule_timer(&workflow_id, &timer_id, fire_at)
429            .await?;
430        engine.record_workflow_event(
431            &workflow_id,
432            timer_started_event(&workflow_id, &timer_id, 1),
433        )?;
434        // ... but the workflow itself is gone: the engine rejects the recovered
435        // fire with `UnknownWorkflow` (exactly what the real engine does when a
436        // cancelled workflow's record has been purged).
437        engine.push_record_response(Err(EngineSeamError::UnknownWorkflow {
438            workflow_id: workflow_id.clone(),
439        }))?;
440
441        // Recovery must SUCCEED by skipping the orphan, not error out.
442        let recovered = recovery.recover_on_startup(instant(20)).await?;
443
444        assert_eq!(recovered, 0, "the orphaned timer is skipped, not fired");
445        assert_eq!(
446            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
447            0,
448            "no TimerFired is recorded for an unknown workflow"
449        );
450        assert!(
451            engine.delivered_messages()?.is_empty(),
452            "nothing is delivered for an unknown workflow"
453        );
454        Ok(())
455    }
456}