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_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 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 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 #[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); 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 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 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 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 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 engine.push_record_response(Err(EngineSeamError::UnknownWorkflow {
533 workflow_id: workflow_id.clone(),
534 }))?;
535
536 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}