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, 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 #[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); 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 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 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 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 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 engine.push_record_response(Err(EngineSeamError::UnknownWorkflow {
522 workflow_id: workflow_id.clone(),
523 }))?;
524
525 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}