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, RunId, TimerCancelCause, TimerId, WorkflowId};
180    use aion_store::{InMemoryStore, ReadableEventStore, StoreError, WritableEventStore};
181    use chrono::{DateTime, Utc};
182
183    use super::{TimerRecovery, TimerRecoveryError, outstanding_future_timers};
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    use crate::time::deadline_timer_id;
190
191    const RECOVERY_INTERVAL: Duration = Duration::from_millis(10);
192
193    #[derive(Debug, thiserror::Error)]
194    enum TestError {
195        #[error(transparent)]
196        Recovery(#[from] TimerRecoveryError),
197
198        #[error(transparent)]
199        Store(#[from] StoreError),
200
201        #[error(transparent)]
202        Engine(#[from] EngineSeamError),
203    }
204
205    fn instant(offset_seconds: i64) -> DateTime<Utc> {
206        DateTime::from_timestamp(1_700_000_000 + offset_seconds, 0).unwrap_or_default()
207    }
208
209    fn recorded_at() -> DateTime<Utc> {
210        instant(1)
211    }
212
213    fn tick_now() -> DateTime<Utc> {
214        instant(30)
215    }
216
217    fn workflow_id() -> WorkflowId {
218        WorkflowId::new_v4()
219    }
220
221    fn timer_id(sequence: u64) -> TimerId {
222        TimerId::anonymous(sequence)
223    }
224
225    fn recovery() -> (Arc<InMemoryStore>, Arc<FakeEngineHandle>, TimerRecovery) {
226        let concrete_store = Arc::new(InMemoryStore::default());
227        let writable: Arc<dyn WritableEventStore> = concrete_store.clone();
228        let readable: Arc<dyn ReadableEventStore> = concrete_store.clone();
229        let engine = Arc::new(FakeEngineHandle::recording_to(writable));
230        let timer_service = Arc::new(TimerService::with_recorded_at(
231            engine.clone(),
232            readable.clone(),
233            recorded_at,
234        ));
235        let recovery =
236            TimerRecovery::with_clock(readable, timer_service, RECOVERY_INTERVAL, tick_now);
237        (concrete_store, engine, recovery)
238    }
239
240    async fn history(
241        store: &InMemoryStore,
242        workflow_id: &WorkflowId,
243    ) -> Result<Vec<Event>, StoreError> {
244        store.read_history(workflow_id).await
245    }
246
247    fn timer_started_event(workflow_id: &WorkflowId, timer_id: &TimerId, seq: u64) -> Event {
248        Event::TimerStarted {
249            envelope: EventEnvelope {
250                seq,
251                recorded_at: instant(0),
252                workflow_id: workflow_id.clone(),
253            },
254            timer_id: timer_id.clone(),
255            fire_at: instant(5),
256        }
257    }
258
259    fn count_timer_fired(events: &[Event], timer_id: &TimerId) -> usize {
260        events
261            .iter()
262            .filter(|event| {
263                matches!(event, Event::TimerFired { timer_id: recorded, .. } if recorded == timer_id)
264            })
265            .count()
266    }
267
268    #[tokio::test]
269    async fn startup_sweep_fires_past_timer_and_delivers() -> Result<(), TestError> {
270        let process = WorkflowProcessHandle::new(42);
271        let (store, engine, recovery) = recovery();
272        let workflow_id = workflow_id();
273        let timer_id = timer_id(1);
274        let fire_at = instant(10);
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        store
281            .schedule_timer(&workflow_id, &timer_id, fire_at)
282            .await?;
283
284        let recovered = recovery.recover_on_startup(instant(20)).await?;
285
286        assert_eq!(recovered, 1);
287        assert_eq!(
288            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
289            1
290        );
291        assert_eq!(
292            engine.delivered_messages()?,
293            vec![(
294                process,
295                DeliveredWorkflowMessage::TimerFired {
296                    timer_id: timer_id.clone(),
297                    fire_at
298                }
299            )]
300        );
301        Ok(())
302    }
303
304    #[tokio::test]
305    async fn startup_sweep_does_not_fire_future_timer() -> Result<(), TestError> {
306        let process = WorkflowProcessHandle::new(42);
307        let (store, engine, recovery) = recovery();
308        let workflow_id = workflow_id();
309        let timer_id = timer_id(2);
310        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
311        store
312            .schedule_timer(&workflow_id, &timer_id, instant(30))
313            .await?;
314
315        let recovered = recovery.recover_on_startup(instant(20)).await?;
316
317        assert_eq!(recovered, 0);
318        assert_eq!(
319            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
320            0
321        );
322        assert!(engine.delivered_messages()?.is_empty());
323        Ok(())
324    }
325
326    #[tokio::test]
327    async fn tick_uses_injected_clock_and_fires_newly_due_timer_once() -> Result<(), TestError> {
328        let process = WorkflowProcessHandle::new(42);
329        let (store, engine, recovery) = recovery();
330        let workflow_id = workflow_id();
331        let timer_id = timer_id(3);
332        let fire_at = instant(25);
333        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
334        engine.record_workflow_event(
335            &workflow_id,
336            timer_started_event(&workflow_id, &timer_id, 1),
337        )?;
338        store
339            .schedule_timer(&workflow_id, &timer_id, fire_at)
340            .await?;
341
342        assert_eq!(recovery.recovery_interval(), RECOVERY_INTERVAL);
343        assert_eq!(recovery.tick().await?, 1);
344        assert_eq!(recovery.tick().await?, 1);
345
346        assert_eq!(
347            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
348            1
349        );
350        assert_eq!(engine.delivered_messages()?.len(), 1);
351        Ok(())
352    }
353
354    #[tokio::test]
355    async fn running_startup_sweep_twice_fires_due_timer_once_total() -> Result<(), TestError> {
356        let process = WorkflowProcessHandle::new(42);
357        let (store, engine, recovery) = recovery();
358        let workflow_id = workflow_id();
359        let timer_id = timer_id(4);
360        let fire_at = instant(10);
361        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
362        engine.record_workflow_event(
363            &workflow_id,
364            timer_started_event(&workflow_id, &timer_id, 1),
365        )?;
366        store
367            .schedule_timer(&workflow_id, &timer_id, fire_at)
368            .await?;
369
370        recovery.recover_on_startup(instant(20)).await?;
371        recovery.recover_on_startup(instant(20)).await?;
372
373        assert_eq!(
374            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
375            1
376        );
377        assert_eq!(engine.delivered_messages()?.len(), 1);
378        Ok(())
379    }
380
381    #[tokio::test]
382    async fn cancelled_timer_is_never_fired_by_recovery() -> Result<(), TestError> {
383        let process = WorkflowProcessHandle::new(42);
384        let (store, engine, recovery) = recovery();
385        let workflow_id = workflow_id();
386        let timer_id = timer_id(5);
387        let fire_at = instant(10);
388        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
389        store
390            .schedule_timer(&workflow_id, &timer_id, fire_at)
391            .await?;
392        engine.record_workflow_event(
393            &workflow_id,
394            Event::TimerCancelled {
395                cause: aion_core::TimerCancelCause::WorkflowIntent,
396                envelope: EventEnvelope {
397                    seq: 1,
398                    recorded_at: instant(9),
399                    workflow_id: workflow_id.clone(),
400                },
401                timer_id: timer_id.clone(),
402            },
403        )?;
404
405        recovery.recover_on_startup(instant(20)).await?;
406
407        assert_eq!(
408            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
409            0
410        );
411        assert!(engine.delivered_messages()?.is_empty());
412        Ok(())
413    }
414
415    /// D5 resurrection hazard: `outstanding_future_timers` is whole-history
416    /// scoped, so a continue-as-new predecessor's still-outstanding
417    /// `deadline:{run}` WOULD be re-armed after failover — firing a timeout
418    /// against a run that already continued. The `WorkflowIntent` cancel recorded
419    /// at the CAN terminal closes exactly that hole. This proves both halves at
420    /// the precise mechanism the scout flagged.
421    #[test]
422    fn cancelled_predecessor_deadline_is_not_rearmed_after_continue_as_new() {
423        let workflow_id = workflow_id();
424        let predecessor_run = RunId::new_v4();
425        let deadline = deadline_timer_id(&predecessor_run).unwrap_or_else(|_| timer_id(0));
426        let now = instant(0);
427        let fire_at = instant(120); // future: eligible for re-arm
428
429        let started = |seq: u64, run: &RunId| Event::WorkflowStarted {
430            envelope: EventEnvelope {
431                seq,
432                recorded_at: instant(0),
433                workflow_id: workflow_id.clone(),
434            },
435            workflow_type: "sleeper".to_owned(),
436            input: aion_core::Payload::new(aion_core::ContentType::Json, b"null".to_vec()),
437            run_id: run.clone(),
438            parent_run_id: None,
439            package_version: aion_core::PackageVersion::new("a".repeat(64)),
440        };
441        let deadline_started = Event::TimerStarted {
442            envelope: EventEnvelope {
443                seq: 2,
444                recorded_at: instant(0),
445                workflow_id: workflow_id.clone(),
446            },
447            timer_id: deadline.clone(),
448            fire_at,
449        };
450        let continued = Event::WorkflowContinuedAsNew {
451            envelope: EventEnvelope {
452                seq: 3,
453                recorded_at: instant(1),
454                workflow_id: workflow_id.clone(),
455            },
456            input: aion_core::Payload::new(aion_core::ContentType::Json, b"null".to_vec()),
457            workflow_type: None,
458            parent_run_id: predecessor_run.clone(),
459        };
460
461        // Before the cancel: the hazard is real — the predecessor's future
462        // deadline is outstanding across the whole history even after CAN.
463        let uncancelled = vec![
464            started(1, &predecessor_run),
465            deadline_started.clone(),
466            continued.clone(),
467        ];
468        assert!(
469            outstanding_future_timers(&uncancelled, now)
470                .into_iter()
471                .any(|(timer_id, _)| timer_id == deadline),
472            "an uncancelled predecessor deadline WOULD be re-armed after failover"
473        );
474
475        // With the WorkflowIntent cancel recorded at the CAN terminal: closed.
476        let cancelled = vec![
477            started(1, &predecessor_run),
478            deadline_started,
479            continued,
480            Event::TimerCancelled {
481                envelope: EventEnvelope {
482                    seq: 4,
483                    recorded_at: instant(1),
484                    workflow_id: workflow_id.clone(),
485                },
486                timer_id: deadline.clone(),
487                cause: TimerCancelCause::WorkflowIntent,
488            },
489        ];
490        assert!(
491            !outstanding_future_timers(&cancelled, now)
492                .into_iter()
493                .any(|(timer_id, _)| timer_id == deadline),
494            "the WorkflowIntent cancel closes the whole-history re-arm hole"
495        );
496    }
497
498    #[tokio::test]
499    async fn orphaned_timer_for_unknown_workflow_is_skipped_not_fatal() -> Result<(), TestError> {
500        // Regression: a durable timer whose workflow was cancelled and purged
501        // from the engine's known set must NOT abort startup recovery. Before the
502        // fix, `fire_timer`'s `UnknownWorkflow` error propagated and bricked engine
503        // startup — observed in production after a restart:
504        //   "timer recovery fire operation failed: ... workflow <id> is unknown".
505        let (store, engine, recovery) = recovery();
506        let workflow_id = workflow_id();
507        let timer_id = timer_id(6);
508        let fire_at = instant(10);
509
510        // The timer is live in history (started, never fired/cancelled) ...
511        store
512            .schedule_timer(&workflow_id, &timer_id, fire_at)
513            .await?;
514        engine.record_workflow_event(
515            &workflow_id,
516            timer_started_event(&workflow_id, &timer_id, 1),
517        )?;
518        // ... but the workflow itself is gone: the engine rejects the recovered
519        // fire with `UnknownWorkflow` (exactly what the real engine does when a
520        // cancelled workflow's record has been purged).
521        engine.push_record_response(Err(EngineSeamError::UnknownWorkflow {
522            workflow_id: workflow_id.clone(),
523        }))?;
524
525        // Recovery must SUCCEED by skipping the orphan, not error out.
526        let recovered = recovery.recover_on_startup(instant(20)).await?;
527
528        assert_eq!(recovered, 0, "the orphaned timer is skipped, not fired");
529        assert_eq!(
530            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
531            0,
532            "no TimerFired is recorded for an unknown workflow"
533        );
534        assert!(
535            engine.delivered_messages()?.is_empty(),
536            "nothing is delivered for an unknown workflow"
537        );
538        Ok(())
539    }
540}