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_records_once_and_redelivers_the_wake() -> Result<(), TestError>
328    {
329        let process = WorkflowProcessHandle::new(42);
330        let (store, engine, recovery) = recovery();
331        let workflow_id = workflow_id();
332        let timer_id = timer_id(3);
333        let fire_at = instant(25);
334        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
335        engine.record_workflow_event(
336            &workflow_id,
337            timer_started_event(&workflow_id, &timer_id, 1),
338        )?;
339        store
340            .schedule_timer(&workflow_id, &timer_id, fire_at)
341            .await?;
342
343        assert_eq!(recovery.recovery_interval(), RECOVERY_INTERVAL);
344        assert_eq!(recovery.tick().await?, 1);
345        assert_eq!(recovery.tick().await?, 1);
346
347        assert_eq!(
348            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
349            1,
350            "the durable TimerFired is recorded exactly once across repeated ticks"
351        );
352        // aion#145: the second tick finds the fire already recorded and
353        // RE-DELIVERS the wake to the resident workflow instead of silently
354        // no-opping — history cannot tell a delivered wake from a lost one, and
355        // delivery is a pure, duplicate-safe wake, so redelivering is the
356        // direction that heals a fired-but-undelivered wedge.
357        assert_eq!(engine.delivered_messages()?.len(), 2);
358        Ok(())
359    }
360
361    #[tokio::test]
362    async fn running_startup_sweep_twice_records_due_timer_once_total() -> Result<(), TestError> {
363        let process = WorkflowProcessHandle::new(42);
364        let (store, engine, recovery) = recovery();
365        let workflow_id = workflow_id();
366        let timer_id = timer_id(4);
367        let fire_at = instant(10);
368        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
369        engine.record_workflow_event(
370            &workflow_id,
371            timer_started_event(&workflow_id, &timer_id, 1),
372        )?;
373        store
374            .schedule_timer(&workflow_id, &timer_id, fire_at)
375            .await?;
376
377        recovery.recover_on_startup(instant(20)).await?;
378        recovery.recover_on_startup(instant(20)).await?;
379
380        assert_eq!(
381            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
382            1,
383            "the durable TimerFired is recorded exactly once across repeated sweeps"
384        );
385        // aion#145: the second sweep redelivers the recorded fire's wake — see
386        // `tick_uses_injected_clock_records_once_and_redelivers_the_wake`.
387        assert_eq!(engine.delivered_messages()?.len(), 2);
388        Ok(())
389    }
390
391    #[tokio::test]
392    async fn cancelled_timer_is_never_fired_by_recovery() -> Result<(), TestError> {
393        let process = WorkflowProcessHandle::new(42);
394        let (store, engine, recovery) = recovery();
395        let workflow_id = workflow_id();
396        let timer_id = timer_id(5);
397        let fire_at = instant(10);
398        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
399        store
400            .schedule_timer(&workflow_id, &timer_id, fire_at)
401            .await?;
402        engine.record_workflow_event(
403            &workflow_id,
404            Event::TimerCancelled {
405                cause: aion_core::TimerCancelCause::WorkflowIntent,
406                envelope: EventEnvelope {
407                    seq: 1,
408                    recorded_at: instant(9),
409                    workflow_id: workflow_id.clone(),
410                },
411                timer_id: timer_id.clone(),
412            },
413        )?;
414
415        recovery.recover_on_startup(instant(20)).await?;
416
417        assert_eq!(
418            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
419            0
420        );
421        assert!(engine.delivered_messages()?.is_empty());
422        Ok(())
423    }
424
425    /// D5 resurrection hazard: `outstanding_future_timers` is whole-history
426    /// scoped, so a continue-as-new predecessor's still-outstanding
427    /// `deadline:{run}` WOULD be re-armed after failover — firing a timeout
428    /// against a run that already continued. The `WorkflowIntent` cancel recorded
429    /// at the CAN terminal closes exactly that hole. This proves both halves at
430    /// the precise mechanism the scout flagged.
431    #[test]
432    fn cancelled_predecessor_deadline_is_not_rearmed_after_continue_as_new() {
433        let workflow_id = workflow_id();
434        let predecessor_run = RunId::new_v4();
435        let deadline = deadline_timer_id(&predecessor_run).unwrap_or_else(|_| timer_id(0));
436        let now = instant(0);
437        let fire_at = instant(120); // future: eligible for re-arm
438
439        let started = |seq: u64, run: &RunId| Event::WorkflowStarted {
440            envelope: EventEnvelope {
441                seq,
442                recorded_at: instant(0),
443                workflow_id: workflow_id.clone(),
444            },
445            workflow_type: "sleeper".to_owned(),
446            input: aion_core::Payload::new(aion_core::ContentType::Json, b"null".to_vec()),
447            run_id: run.clone(),
448            parent_run_id: None,
449            parent_workflow_id: None,
450            package_version: aion_core::PackageVersion::new("a".repeat(64)),
451        };
452        let deadline_started = Event::TimerStarted {
453            envelope: EventEnvelope {
454                seq: 2,
455                recorded_at: instant(0),
456                workflow_id: workflow_id.clone(),
457            },
458            timer_id: deadline.clone(),
459            fire_at,
460        };
461        let continued = Event::WorkflowContinuedAsNew {
462            envelope: EventEnvelope {
463                seq: 3,
464                recorded_at: instant(1),
465                workflow_id: workflow_id.clone(),
466            },
467            input: aion_core::Payload::new(aion_core::ContentType::Json, b"null".to_vec()),
468            workflow_type: None,
469            parent_run_id: predecessor_run.clone(),
470        };
471
472        // Before the cancel: the hazard is real — the predecessor's future
473        // deadline is outstanding across the whole history even after CAN.
474        let uncancelled = vec![
475            started(1, &predecessor_run),
476            deadline_started.clone(),
477            continued.clone(),
478        ];
479        assert!(
480            outstanding_future_timers(&uncancelled, now)
481                .into_iter()
482                .any(|(timer_id, _)| timer_id == deadline),
483            "an uncancelled predecessor deadline WOULD be re-armed after failover"
484        );
485
486        // With the WorkflowIntent cancel recorded at the CAN terminal: closed.
487        let cancelled = vec![
488            started(1, &predecessor_run),
489            deadline_started,
490            continued,
491            Event::TimerCancelled {
492                envelope: EventEnvelope {
493                    seq: 4,
494                    recorded_at: instant(1),
495                    workflow_id: workflow_id.clone(),
496                },
497                timer_id: deadline.clone(),
498                cause: TimerCancelCause::WorkflowIntent,
499            },
500        ];
501        assert!(
502            !outstanding_future_timers(&cancelled, now)
503                .into_iter()
504                .any(|(timer_id, _)| timer_id == deadline),
505            "the WorkflowIntent cancel closes the whole-history re-arm hole"
506        );
507    }
508
509    #[tokio::test]
510    async fn orphaned_timer_for_unknown_workflow_is_skipped_not_fatal() -> Result<(), TestError> {
511        // Regression: a durable timer whose workflow was cancelled and purged
512        // from the engine's known set must NOT abort startup recovery. Before the
513        // fix, `fire_timer`'s `UnknownWorkflow` error propagated and bricked engine
514        // startup — observed in production after a restart:
515        //   "timer recovery fire operation failed: ... workflow <id> is unknown".
516        let (store, engine, recovery) = recovery();
517        let workflow_id = workflow_id();
518        let timer_id = timer_id(6);
519        let fire_at = instant(10);
520
521        // The timer is live in history (started, never fired/cancelled) ...
522        store
523            .schedule_timer(&workflow_id, &timer_id, fire_at)
524            .await?;
525        engine.record_workflow_event(
526            &workflow_id,
527            timer_started_event(&workflow_id, &timer_id, 1),
528        )?;
529        // ... but the workflow itself is gone: the engine rejects the recovered
530        // fire with `UnknownWorkflow` (exactly what the real engine does when a
531        // cancelled workflow's record has been purged).
532        engine.push_record_response(Err(EngineSeamError::UnknownWorkflow {
533            workflow_id: workflow_id.clone(),
534        }))?;
535
536        // Recovery must SUCCEED by skipping the orphan, not error out.
537        let recovered = recovery.recover_on_startup(instant(20)).await?;
538
539        assert_eq!(recovered, 0, "the orphaned timer is skipped, not fired");
540        assert_eq!(
541            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
542            0,
543            "no TimerFired is recorded for an unknown workflow"
544        );
545        assert!(
546            engine.delivered_messages()?.is_empty(),
547            "nothing is delivered for an unknown workflow"
548        );
549        Ok(())
550    }
551}