1use 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
14pub struct TimerRecovery {
16 store: Arc<dyn ReadableEventStore>,
17 timer_service: Arc<TimerService>,
18 recovery_interval: Duration,
19 now: fn() -> DateTime<Utc>,
20}
21
22#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
24pub enum TimerRecoveryError {
25 #[error("timer recovery store operation failed: {0}")]
27 Store(#[from] StoreError),
28
29 #[error("timer recovery fire operation failed: {0}")]
31 Timer(#[from] TimerServiceError),
32}
33
34impl TimerRecovery {
35 #[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 #[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 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 pub async fn tick(&self) -> Result<usize, TimerRecoveryError> {
87 self.recover_due((self.now)()).await
88 }
89
90 #[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 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 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 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 engine.push_record_response(Err(EngineSeamError::UnknownWorkflow {
438 workflow_id: workflow_id.clone(),
439 }))?;
440
441 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}