Skip to main content

aion/time/
timer_service.rs

1//! Durable timer service: schedule, wheel arm, and `TimerFired` delivery.
2
3use std::sync::Arc;
4
5use aion_core::{Event, EventEnvelope, TimerCancelCause, TimerId, WorkflowId};
6use aion_store::{ReadableEventStore, StoreError};
7use chrono::{DateTime, Utc};
8use dashmap::DashSet;
9
10use crate::engine_seam::{
11    EngineHandle, EngineSeamError, RecordOutcome, TimerWheelEntry, WorkflowMailboxMessage,
12    WorkflowResidency,
13};
14use crate::time::deadline::{DeadlineHandler, deadline_run_id, is_deadline_timer};
15
16/// Durable timer scheduling and wheel-fire handling.
17///
18/// The service owns the AT live path for timers. Workflow-issued `TimerStarted` events are recorded
19/// by AD's resume-live handoff before this service is called; this service persists only the durable
20/// timer row and later asynchronous arrival/cancellation history through the engine recorder seam.
21pub struct TimerService {
22    engine: Arc<dyn EngineHandle>,
23    store: Arc<dyn ReadableEventStore>,
24    recorded_at: fn() -> DateTime<Utc>,
25    /// Per-timer first-recorded-wins coordinator shared across EVERY service
26    /// instance the production bridge hands out. Cancel and fire obtain
27    /// SEPARATE service instances (the live wheel constructs one, `Engine::cancel`
28    /// another), so a per-instance set would not exclude them; a shared `Arc`
29    /// makes a cancel and a fire for the same timer mutually exclude — the
30    /// #cancel-vs-fire race the review flagged. Bare unit-test services get their
31    /// own set, which is correct for a single-instance test.
32    terminal_updates: Arc<DashSet<(WorkflowId, TimerId)>>,
33    /// Engine-registered handler for reserved `deadline:{run_id}` fires.
34    ///
35    /// `None` on a bare service (unit tests): a deadline fire is then a typed
36    /// error, never a silent generic `TimerFired`. The production bridge sets it
37    /// via [`Self::with_deadline_handler`] when constructing the service.
38    deadline_handler: Option<Arc<dyn DeadlineHandler>>,
39}
40
41struct TerminalUpdateSlot<'a> {
42    terminal_updates: &'a DashSet<(WorkflowId, TimerId)>,
43    key: (WorkflowId, TimerId),
44}
45
46impl Drop for TerminalUpdateSlot<'_> {
47    fn drop(&mut self) {
48        self.terminal_updates.remove(&self.key);
49    }
50}
51
52/// Errors returned by [`TimerService`].
53#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
54pub enum TimerServiceError {
55    /// Durable timer storage or history inspection failed.
56    #[error("timer store operation failed: {0}")]
57    Store(#[from] StoreError),
58
59    /// Engine seam operation failed.
60    #[error("timer engine operation failed: {0}")]
61    Engine(#[from] EngineSeamError),
62
63    /// A reserved `deadline:{run_id}` timer fired but could not be routed to a
64    /// registered deadline handler (or the handler failed).
65    ///
66    /// Never a silent generic fire: a deadline timer that reaches
67    /// [`TimerService::fire_timer`] without a handler — or whose handler errors —
68    /// surfaces here so the caller (live wheel or `recover_due`) observes the
69    /// failure rather than recording a spurious `TimerFired`.
70    #[error("deadline timer routing failed: {0}")]
71    Deadline(String),
72}
73
74impl TimerService {
75    /// Creates a durable timer service from the engine seam and timer store.
76    #[must_use]
77    pub fn new(engine: Arc<dyn EngineHandle>, store: Arc<dyn ReadableEventStore>) -> Self {
78        Self::with_recorded_at(engine, store, Utc::now)
79    }
80
81    /// Creates a durable timer service with an injected history timestamp source.
82    #[must_use]
83    pub fn with_recorded_at(
84        engine: Arc<dyn EngineHandle>,
85        store: Arc<dyn ReadableEventStore>,
86        recorded_at: fn() -> DateTime<Utc>,
87    ) -> Self {
88        Self {
89            engine,
90            store,
91            recorded_at,
92            terminal_updates: Arc::new(DashSet::new()),
93            deadline_handler: None,
94        }
95    }
96
97    /// Replaces this service's per-timer terminal-update coordinator with a
98    /// shared one, returning the service for chaining.
99    ///
100    /// The production timer bridge owns ONE coordinator and hands it to every
101    /// [`TimerService`] it constructs, so a cancel obtained from one service and
102    /// a fire obtained from another still serialize per timer (first-recorded
103    /// wins). Without this, each service would guard against itself only.
104    #[must_use]
105    pub fn with_terminal_updates(
106        mut self,
107        terminal_updates: Arc<DashSet<(WorkflowId, TimerId)>>,
108    ) -> Self {
109        self.terminal_updates = terminal_updates;
110        self
111    }
112
113    /// Registers the engine-side deadline handler for reserved `deadline:{run_id}`
114    /// fires, returning the service for chaining.
115    ///
116    /// The production timer bridge calls this so both the live wheel and
117    /// `recover_due` (which share [`Self::fire_timer`]) demux a deadline fire to
118    /// the handler instead of recording a generic `TimerFired`.
119    #[must_use]
120    pub fn with_deadline_handler(mut self, handler: Arc<dyn DeadlineHandler>) -> Self {
121        self.deadline_handler = Some(handler);
122        self
123    }
124
125    /// Schedules a durable timer and arms the live wheel when the workflow is resident.
126    ///
127    /// The operation persists the durable timer row and arms the wheel when needed. The
128    /// command-issued `TimerStarted` recorder event is appended by AD's resume-live handoff before
129    /// AE/AT reaches this service, so this method deliberately does not record it again.
130    ///
131    /// # Errors
132    ///
133    /// Returns [`TimerServiceError`] when durable storage, recording, residency resolution, or wheel
134    /// arming fails.
135    pub async fn schedule(
136        &self,
137        workflow_id: WorkflowId,
138        timer_id: TimerId,
139        fire_at: DateTime<Utc>,
140    ) -> Result<(), TimerServiceError> {
141        self.store
142            .schedule_timer(&workflow_id, &timer_id, fire_at)
143            .await?;
144
145        if let WorkflowResidency::Resident(process) = self.engine.resolve_workflow(&workflow_id)? {
146            self.engine.arm_timer(TimerWheelEntry {
147                process,
148                timer_id,
149                fire_at,
150            })?;
151        }
152
153        Ok(())
154    }
155
156    /// Cancels a durable timer that has not already reached a terminal timer state.
157    ///
158    /// Already-fired and already-cancelled timers are treated as idempotent no-ops. For active
159    /// resident timers the live wheel is disarmed through the engine seam before `TimerCancelled` is
160    /// recorded through the workflow recorder seam. Non-resident timers still record the cancellation
161    /// so recovery/replay can suppress a later fire.
162    ///
163    /// Anonymous timers are accepted: authors can never address one (the SDK's `cancel_timer`
164    /// takes a `TimerRef` minted by `start_timer`, which is always named), but the engine settles
165    /// `with_timeout` scope deadlines — anonymous by construction — through this first-recorded-wins
166    /// race against [`Self::fire_timer`].
167    ///
168    /// # Errors
169    ///
170    /// Returns [`TimerServiceError`] when history inspection, residency resolution, wheel disarming,
171    /// or event recording fails.
172    pub async fn cancel(
173        &self,
174        workflow_id: WorkflowId,
175        timer_id: TimerId,
176        cause: TimerCancelCause,
177    ) -> Result<(), TimerServiceError> {
178        let key = (workflow_id.clone(), timer_id.clone());
179        let terminal_update_slot = self.wait_for_terminal_update_slot(key).await;
180
181        let result = self.cancel_guarded(workflow_id, timer_id, cause).await;
182        drop(terminal_update_slot);
183        result
184    }
185
186    async fn cancel_guarded(
187        &self,
188        workflow_id: WorkflowId,
189        timer_id: TimerId,
190        cause: TimerCancelCause,
191    ) -> Result<(), TimerServiceError> {
192        if !self.timer_is_live(&workflow_id, &timer_id).await? {
193            return Ok(());
194        }
195
196        if let WorkflowResidency::Resident(process) = self.engine.resolve_workflow(&workflow_id)? {
197            self.engine.disarm_timer(process, &timer_id)?;
198        }
199
200        let event = Event::TimerCancelled {
201            envelope: self.next_envelope(&workflow_id).await?,
202            timer_id,
203            cause,
204        };
205        self.engine.record_workflow_event(&workflow_id, event)?;
206
207        Ok(())
208    }
209
210    /// Handles a live timer-wheel fire.
211    ///
212    /// `TimerFired` is recorded before any mailbox delivery. If the workflow is no longer resident,
213    /// the recorded event remains the durable observation that replay/recovery can surface later.
214    ///
215    /// # Errors
216    ///
217    /// Returns [`TimerServiceError`] when history inspection, recording, residency resolution, or
218    /// live mailbox delivery fails.
219    pub async fn fire_timer(
220        &self,
221        workflow_id: WorkflowId,
222        timer_id: TimerId,
223        fire_at: DateTime<Utc>,
224    ) -> Result<(), TimerServiceError> {
225        let key = (workflow_id.clone(), timer_id.clone());
226        let terminal_update_slot = self.wait_for_terminal_update_slot(key).await;
227
228        let result = self
229            .fire_timer_guarded(workflow_id, timer_id, fire_at)
230            .await;
231        drop(terminal_update_slot);
232        result
233    }
234
235    async fn wait_for_terminal_update_slot(
236        &self,
237        key: (WorkflowId, TimerId),
238    ) -> TerminalUpdateSlot<'_> {
239        loop {
240            if self.terminal_updates.insert(key.clone()) {
241                return TerminalUpdateSlot {
242                    terminal_updates: self.terminal_updates.as_ref(),
243                    key,
244                };
245            }
246            tokio::task::yield_now().await;
247        }
248    }
249
250    async fn fire_timer_guarded(
251        &self,
252        workflow_id: WorkflowId,
253        timer_id: TimerId,
254        fire_at: DateTime<Utc>,
255    ) -> Result<(), TimerServiceError> {
256        if !self.timer_is_live(&workflow_id, &timer_id).await? {
257            return Ok(());
258        }
259
260        // Demux a reserved workflow-deadline timer out of the generic
261        // record-then-deliver path (both the live wheel and `recover_due` reach
262        // here): it never records a `TimerFired` — the registered handler records
263        // `WorkflowTimedOut` and tears the run down instead.
264        if is_deadline_timer(&timer_id) {
265            return self.fire_deadline(workflow_id, timer_id).await;
266        }
267
268        let event = Event::TimerFired {
269            envelope: self.next_envelope(&workflow_id).await?,
270            timer_id: timer_id.clone(),
271        };
272        // Deliver the mailbox wake ONLY for a genuine append. The recorder seam
273        // refuses a late fire that lands after the run terminated
274        // (`RefusedTerminal`), recording nothing; waking the process then would
275        // reschedule a workflow that has already reached its terminal — the
276        // post-terminal wake this gate closes.
277        if self.engine.record_workflow_event(&workflow_id, event)? == RecordOutcome::RefusedTerminal
278        {
279            return Ok(());
280        }
281
282        if let WorkflowResidency::Resident(process) = self.engine.resolve_workflow(&workflow_id)? {
283            self.engine.deliver_workflow_message(
284                process,
285                WorkflowMailboxMessage::TimerFired { timer_id, fire_at },
286            )?;
287        }
288
289        Ok(())
290    }
291
292    /// Route a live reserved-deadline fire to the registered handler.
293    ///
294    /// Called only for a `deadline:{run_id}` timer that passed the liveness
295    /// guard. A missing handler or an unparseable run id is a typed
296    /// [`TimerServiceError::Deadline`] — never a silent generic fire — and the
297    /// handler's own failure is surfaced the same way. The handler re-checks the
298    /// run's terminal under the recorder lock, so it loses cleanly to a
299    /// concurrent completion.
300    async fn fire_deadline(
301        &self,
302        workflow_id: WorkflowId,
303        timer_id: TimerId,
304    ) -> Result<(), TimerServiceError> {
305        let handler = self.deadline_handler.as_ref().ok_or_else(|| {
306            TimerServiceError::Deadline(format!(
307                "no deadline handler registered for {timer_id} on workflow {workflow_id}"
308            ))
309        })?;
310        let run_id = deadline_run_id(&timer_id).ok_or_else(|| {
311            TimerServiceError::Deadline(format!(
312                "malformed deadline timer {timer_id} on workflow {workflow_id}"
313            ))
314        })?;
315        handler
316            .on_deadline_elapsed(workflow_id, run_id)
317            .await
318            .map_err(|error| TimerServiceError::Deadline(error.to_string()))
319    }
320
321    /// Whether the timer is currently live (started and not since retired) in
322    /// the workflow's active run segment, by last-event-wins.
323    ///
324    /// A timer belongs to the run that recorded its `TimerStarted`, and
325    /// anonymous timer identities are run-scoped ordinals that replacement
326    /// runs (continue-as-new) re-allocate from zero. Scoping the check to
327    /// the latest run segment keeps a stale fire from a finished run from
328    /// recording into — or suppressing — the replacement run's identically
329    /// named timer.
330    ///
331    /// Liveness is decided by the *last* timer event for the id (see
332    /// [`live_timers_in_active_segment`]): a re-armed named timer
333    /// (`TimerStarted(T), TimerFired(T), TimerStarted(T)`) is correctly live
334    /// again rather than judged terminal forever by the earlier `TimerFired`.
335    async fn timer_is_live(
336        &self,
337        workflow_id: &WorkflowId,
338        timer_id: &TimerId,
339    ) -> Result<bool, StoreError> {
340        let history = self.store.read_history(workflow_id).await?;
341        Ok(live_timers_in_active_segment(&history).contains(timer_id))
342    }
343
344    async fn next_envelope(&self, workflow_id: &WorkflowId) -> Result<EventEnvelope, StoreError> {
345        let history = self.store.read_history(workflow_id).await?;
346        let seq = history.iter().map(Event::seq).max().unwrap_or_default() + 1;
347        Ok(EventEnvelope {
348            seq,
349            recorded_at: (self.recorded_at)(),
350            workflow_id: workflow_id.clone(),
351        })
352    }
353}
354
355/// The live timer ids in the workflow's active run segment, by last-event-wins.
356///
357/// Scans forward from the latest `WorkflowStarted` (the active run segment) and
358/// lets the *last* event for each timer id decide its liveness: a `TimerStarted`
359/// (re)arms it, a `TimerFired`/`TimerCancelled` retires it. This means a *named*
360/// timer that fired or was cancelled and then re-armed within the same segment
361/// (`TimerStarted(T), TimerFired(T), TimerStarted(T)`) is correctly reported live
362/// again, rather than judged terminal forever by the earlier terminal event.
363///
364/// Start order is preserved and a timer id started more than once is deduped, so
365/// the result is a stable, history-derived (and therefore replay-deterministic)
366/// view of which timers are outstanding. This is the single liveness model shared
367/// by [`TimerService::timer_is_live`] (firing/cancel guard) and the cancel-path
368/// enumerator in `engine::api`, so the two cannot diverge.
369pub(crate) fn live_timers_in_active_segment(history: &[Event]) -> Vec<TimerId> {
370    let segment_start = history
371        .iter()
372        .rposition(|event| matches!(event, Event::WorkflowStarted { .. }))
373        .unwrap_or(0);
374    let mut live: Vec<TimerId> = Vec::new();
375    for event in &history[segment_start..] {
376        match event {
377            Event::TimerStarted { timer_id, .. } if !live.contains(timer_id) => {
378                live.push(timer_id.clone());
379            }
380            Event::TimerFired { timer_id, .. } | Event::TimerCancelled { timer_id, .. } => {
381                live.retain(|id| id != timer_id);
382            }
383            _ => {}
384        }
385    }
386    live
387}
388
389#[cfg(test)]
390mod tests {
391    use std::sync::Arc;
392
393    use aion_core::{Event, EventEnvelope, RunId, TimerCancelCause, TimerId, WorkflowId};
394    use aion_store::{InMemoryStore, ReadableEventStore, StoreError, WritableEventStore};
395    use chrono::{DateTime, Utc};
396
397    use super::{TimerService, TimerServiceError, live_timers_in_active_segment};
398    use crate::engine_seam::test_support::{
399        DeliveredWorkflowMessage, FakeEngineHandle, FakeEngineOperation,
400    };
401    use crate::engine_seam::{
402        EngineHandle, TimerWheelEntry, WorkflowProcessHandle, WorkflowResidency,
403    };
404    use crate::time::deadline::{DeadlineHandler, DeadlineHandlerError, deadline_timer_id};
405
406    fn instant(offset_seconds: i64) -> DateTime<Utc> {
407        DateTime::from_timestamp(1_700_000_000 + offset_seconds, 0).unwrap_or_default()
408    }
409
410    fn workflow_id() -> WorkflowId {
411        WorkflowId::new_v4()
412    }
413
414    fn timer_id() -> TimerId {
415        TimerId::anonymous(7)
416    }
417
418    fn service() -> (Arc<InMemoryStore>, Arc<FakeEngineHandle>, TimerService) {
419        let concrete_store = Arc::new(InMemoryStore::default());
420        let recorder_store: Arc<dyn WritableEventStore> = concrete_store.clone();
421        let readable_store: Arc<dyn ReadableEventStore> = concrete_store.clone();
422        let engine = Arc::new(FakeEngineHandle::recording_to(recorder_store));
423        let service = TimerService::with_recorded_at(engine.clone(), readable_store, recorded_at);
424        (concrete_store, engine, service)
425    }
426
427    fn recorded_at() -> DateTime<Utc> {
428        instant(1)
429    }
430
431    async fn history(
432        store: &InMemoryStore,
433        workflow_id: &WorkflowId,
434    ) -> Result<Vec<Event>, StoreError> {
435        store.read_history(workflow_id).await
436    }
437
438    fn count_timer_fired(events: &[Event], timer_id: &TimerId) -> usize {
439        events
440            .iter()
441            .filter(|event| {
442                matches!(event, Event::TimerFired { timer_id: recorded, .. } if recorded == timer_id)
443            })
444            .count()
445    }
446
447    fn timer_started_event(workflow_id: &WorkflowId, timer_id: &TimerId, seq: u64) -> Event {
448        Event::TimerStarted {
449            envelope: EventEnvelope {
450                seq,
451                recorded_at: instant(0),
452                workflow_id: workflow_id.clone(),
453            },
454            timer_id: timer_id.clone(),
455            fire_at: instant(5),
456        }
457    }
458
459    fn workflow_started_event(workflow_id: &WorkflowId, seq: u64) -> Event {
460        Event::WorkflowStarted {
461            envelope: EventEnvelope {
462                seq,
463                recorded_at: instant(0),
464                workflow_id: workflow_id.clone(),
465            },
466            workflow_type: "fixture".to_owned(),
467            input: aion_core::Payload::new(aion_core::ContentType::Json, b"null".to_vec()),
468            run_id: aion_core::RunId::new_v4(),
469            parent_run_id: None,
470            parent_workflow_id: None,
471            package_version: aion_core::PackageVersion::new("a".repeat(64)),
472        }
473    }
474
475    fn timer_fired_event(workflow_id: &WorkflowId, timer_id: &TimerId, seq: u64) -> Event {
476        Event::TimerFired {
477            envelope: EventEnvelope {
478                seq,
479                recorded_at: instant(0),
480                workflow_id: workflow_id.clone(),
481            },
482            timer_id: timer_id.clone(),
483        }
484    }
485
486    fn timer_cancelled_event(workflow_id: &WorkflowId, timer_id: &TimerId, seq: u64) -> Event {
487        Event::TimerCancelled {
488            cause: TimerCancelCause::WorkflowIntent,
489            envelope: EventEnvelope {
490                seq,
491                recorded_at: instant(0),
492                workflow_id: workflow_id.clone(),
493            },
494            timer_id: timer_id.clone(),
495        }
496    }
497
498    fn make_named(name: &str) -> TimerId {
499        // The name is a non-empty literal, so construction never fails; the
500        // anonymous fallback only exists to keep the helper total without an
501        // `unwrap`/`expect` (disallowed by clippy in this crate).
502        TimerId::named(name).unwrap_or_else(|_| TimerId::anonymous(0))
503    }
504
505    fn named_timer_id() -> TimerId {
506        make_named("review-deadline")
507    }
508
509    // --- `live_timers_in_active_segment` / `timer_is_live` semantics ---
510
511    #[test]
512    fn started_timer_is_live() {
513        let workflow_id = workflow_id();
514        let timer_id = named_timer_id();
515        let history = vec![
516            workflow_started_event(&workflow_id, 0),
517            timer_started_event(&workflow_id, &timer_id, 1),
518        ];
519        assert_eq!(live_timers_in_active_segment(&history), vec![timer_id]);
520    }
521
522    #[test]
523    fn started_then_fired_timer_is_dead() {
524        let workflow_id = workflow_id();
525        let timer_id = named_timer_id();
526        let history = vec![
527            workflow_started_event(&workflow_id, 0),
528            timer_started_event(&workflow_id, &timer_id, 1),
529            timer_fired_event(&workflow_id, &timer_id, 2),
530        ];
531        assert!(live_timers_in_active_segment(&history).is_empty());
532    }
533
534    #[test]
535    fn started_then_cancelled_timer_is_dead() {
536        let workflow_id = workflow_id();
537        let timer_id = named_timer_id();
538        let history = vec![
539            workflow_started_event(&workflow_id, 0),
540            timer_started_event(&workflow_id, &timer_id, 1),
541            timer_cancelled_event(&workflow_id, &timer_id, 2),
542        ];
543        assert!(live_timers_in_active_segment(&history).is_empty());
544    }
545
546    #[test]
547    fn restarted_named_timer_after_fire_is_live() {
548        // The bug fix: a named timer that fired then was re-armed in the same run
549        // segment must be live again (last-event-wins), not judged terminal forever
550        // by the earlier `TimerFired`.
551        let workflow_id = workflow_id();
552        let timer_id = named_timer_id();
553        let history = vec![
554            workflow_started_event(&workflow_id, 0),
555            timer_started_event(&workflow_id, &timer_id, 1),
556            timer_fired_event(&workflow_id, &timer_id, 2),
557            timer_started_event(&workflow_id, &timer_id, 3),
558        ];
559        assert_eq!(
560            live_timers_in_active_segment(&history),
561            vec![timer_id],
562            "a re-armed named timer is live again"
563        );
564    }
565
566    #[test]
567    fn restarted_named_timer_after_cancel_is_live() {
568        let workflow_id = workflow_id();
569        let timer_id = named_timer_id();
570        let history = vec![
571            workflow_started_event(&workflow_id, 0),
572            timer_started_event(&workflow_id, &timer_id, 1),
573            timer_cancelled_event(&workflow_id, &timer_id, 2),
574            timer_started_event(&workflow_id, &timer_id, 3),
575        ];
576        assert_eq!(live_timers_in_active_segment(&history), vec![timer_id]);
577    }
578
579    #[test]
580    fn prior_run_segment_timer_is_not_live() {
581        // A timer started in a run segment that a later `WorkflowStarted` closed
582        // (continue-as-new) is out of scope for the active segment.
583        let workflow_id = workflow_id();
584        let prior = named_timer_id();
585        let current = make_named("current-deadline");
586        let history = vec![
587            workflow_started_event(&workflow_id, 0),
588            timer_started_event(&workflow_id, &prior, 1),
589            // New run segment begins; the prior timer must not be surfaced.
590            workflow_started_event(&workflow_id, 2),
591            timer_started_event(&workflow_id, &current, 3),
592        ];
593        assert_eq!(live_timers_in_active_segment(&history), vec![current]);
594    }
595
596    #[tokio::test]
597    async fn re_armed_named_timer_fires_again() -> Result<(), TimerServiceError> {
598        // End-to-end firing-path guard: with last-event-wins, a re-armed named
599        // timer is live, so `fire_timer` records a second `TimerFired` and
600        // delivers it — rather than silently no-opping under the old
601        // `any`-semantics.
602        let process = WorkflowProcessHandle::new(42);
603        let (store, engine, service) = service();
604        let workflow_id = workflow_id();
605        let timer_id = named_timer_id();
606        let fire_at = instant(110);
607        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
608        engine.record_workflow_event(
609            &workflow_id,
610            timer_started_event(&workflow_id, &timer_id, 1),
611        )?;
612        engine
613            .record_workflow_event(&workflow_id, timer_fired_event(&workflow_id, &timer_id, 2))?;
614        engine.record_workflow_event(
615            &workflow_id,
616            timer_started_event(&workflow_id, &timer_id, 3),
617        )?;
618
619        service
620            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
621            .await?;
622
623        assert_eq!(
624            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
625            2,
626            "the re-armed timer fires again, recording a second TimerFired"
627        );
628        assert_eq!(engine.delivered_messages()?.len(), 1);
629        Ok(())
630    }
631
632    #[tokio::test]
633    async fn schedule_records_timer_row_without_timer_started_event()
634    -> Result<(), TimerServiceError> {
635        let (store, _engine, service) = service();
636        let workflow_id = workflow_id();
637        let timer_id = timer_id();
638        let fire_at = instant(10);
639
640        service
641            .schedule(workflow_id.clone(), timer_id.clone(), fire_at)
642            .await?;
643
644        let expired = store.expired_timers(fire_at).await?;
645        assert_eq!(expired.len(), 1);
646        assert_eq!(expired[0].workflow_id, workflow_id);
647        assert_eq!(expired[0].timer_id, timer_id);
648        assert_eq!(expired[0].fire_at, fire_at);
649
650        assert!(history(&store, &workflow_id).await?.is_empty());
651        Ok(())
652    }
653
654    #[tokio::test]
655    async fn schedule_arms_wheel_for_resident_workflow() -> Result<(), TimerServiceError> {
656        let process = WorkflowProcessHandle::new(42);
657        let (_store, engine, service) = service();
658        let workflow_id = workflow_id();
659        let timer_id = timer_id();
660        let fire_at = instant(20);
661        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
662
663        service
664            .schedule(workflow_id, timer_id.clone(), fire_at)
665            .await?;
666
667        assert_eq!(
668            engine.armed_timers()?,
669            vec![TimerWheelEntry {
670                process,
671                timer_id,
672                fire_at
673            }]
674        );
675        Ok(())
676    }
677
678    #[tokio::test]
679    async fn schedule_for_nonresident_records_without_arming() -> Result<(), TimerServiceError> {
680        let (store, engine, service) = service();
681        let workflow_id = workflow_id();
682        let timer_id = timer_id();
683        let fire_at = instant(30);
684        engine.set_residency(workflow_id.clone(), WorkflowResidency::NonResident)?;
685
686        service
687            .schedule(workflow_id.clone(), timer_id, fire_at)
688            .await?;
689
690        assert!(engine.armed_timers()?.is_empty());
691        assert!(history(&store, &workflow_id).await?.is_empty());
692        Ok(())
693    }
694
695    #[tokio::test]
696    async fn fire_records_timer_fired_then_delivers_mailbox_message()
697    -> Result<(), TimerServiceError> {
698        let process = WorkflowProcessHandle::new(42);
699        let (store, engine, service) = service();
700        let workflow_id = workflow_id();
701        let timer_id = timer_id();
702        let fire_at = instant(40);
703        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
704        engine.record_workflow_event(
705            &workflow_id,
706            timer_started_event(&workflow_id, &timer_id, 1),
707        )?;
708
709        service
710            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
711            .await?;
712
713        assert_eq!(
714            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
715            1
716        );
717        assert_eq!(
718            engine.delivered_messages()?,
719            vec![(
720                process,
721                DeliveredWorkflowMessage::TimerFired {
722                    timer_id: timer_id.clone(),
723                    fire_at
724                }
725            )]
726        );
727        assert!(matches!(
728            engine.operations()?.as_slice(),
729            [
730                FakeEngineOperation::EventRecorded {
731                    event: Event::TimerStarted { .. },
732                    ..
733                },
734                FakeEngineOperation::EventRecorded {
735                    workflow_id: recorded_workflow_id,
736                    event: Event::TimerFired { timer_id: recorded_timer_id, .. },
737                },
738                FakeEngineOperation::Delivered {
739                    process: delivered_process,
740                    message: DeliveredWorkflowMessage::TimerFired { timer_id: delivered_timer_id, .. },
741                }
742            ] if recorded_workflow_id == &workflow_id
743                && recorded_timer_id == &timer_id
744                && delivered_process == &process
745                && delivered_timer_id == &timer_id
746        ));
747        Ok(())
748    }
749
750    #[tokio::test]
751    async fn fire_records_without_delivery_when_workflow_becomes_nonresident()
752    -> Result<(), TimerServiceError> {
753        let (store, engine, service) = service();
754        let workflow_id = workflow_id();
755        let timer_id = timer_id();
756        let fire_at = instant(50);
757        engine.set_residency(workflow_id.clone(), WorkflowResidency::NonResident)?;
758        engine.record_workflow_event(
759            &workflow_id,
760            timer_started_event(&workflow_id, &timer_id, 1),
761        )?;
762
763        service
764            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
765            .await?;
766
767        assert_eq!(
768            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
769            1
770        );
771        assert!(engine.delivered_messages()?.is_empty());
772        Ok(())
773    }
774
775    #[tokio::test]
776    async fn firing_same_timer_twice_records_and_delivers_once() -> Result<(), TimerServiceError> {
777        let process = WorkflowProcessHandle::new(42);
778        let (store, engine, service) = service();
779        let workflow_id = workflow_id();
780        let timer_id = timer_id();
781        let fire_at = instant(60);
782        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
783        engine.record_workflow_event(
784            &workflow_id,
785            timer_started_event(&workflow_id, &timer_id, 1),
786        )?;
787
788        service
789            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
790            .await?;
791        service
792            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
793            .await?;
794
795        assert_eq!(
796            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
797            1
798        );
799        assert_eq!(engine.delivered_messages()?.len(), 1);
800        Ok(())
801    }
802
803    #[tokio::test]
804    async fn firing_cancelled_timer_is_noop() -> Result<(), TimerServiceError> {
805        let process = WorkflowProcessHandle::new(42);
806        let (store, engine, service) = service();
807        let workflow_id = workflow_id();
808        let timer_id = timer_id();
809        let fire_at = instant(70);
810        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
811        engine.record_workflow_event(
812            &workflow_id,
813            timer_started_event(&workflow_id, &timer_id, 1),
814        )?;
815        let cancelled = Event::TimerCancelled {
816            cause: TimerCancelCause::WorkflowIntent,
817            envelope: EventEnvelope {
818                seq: 2,
819                recorded_at: instant(69),
820                workflow_id: workflow_id.clone(),
821            },
822            timer_id: timer_id.clone(),
823        };
824        engine.record_workflow_event(&workflow_id, cancelled)?;
825
826        service
827            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
828            .await?;
829
830        let history = history(&store, &workflow_id).await?;
831        assert_eq!(count_timer_fired(&history, &timer_id), 0);
832        assert!(engine.delivered_messages()?.is_empty());
833        Ok(())
834    }
835
836    #[tokio::test]
837    async fn fire_resolves_residency_at_fire_time() -> Result<(), TimerServiceError> {
838        let process = WorkflowProcessHandle::new(42);
839        let (store, engine, service) = service();
840        let workflow_id = workflow_id();
841        let timer_id = timer_id();
842        let fire_at = instant(80);
843
844        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
845        engine.set_residency(workflow_id.clone(), WorkflowResidency::NonResident)?;
846        engine.record_workflow_event(
847            &workflow_id,
848            timer_started_event(&workflow_id, &timer_id, 1),
849        )?;
850        service
851            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
852            .await?;
853
854        assert_eq!(
855            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
856            1
857        );
858        assert!(engine.delivered_messages()?.is_empty());
859        Ok(())
860    }
861
862    #[tokio::test]
863    async fn firing_unstarted_timer_records_nothing() -> Result<(), TimerServiceError> {
864        let process = WorkflowProcessHandle::new(42);
865        let (store, engine, service) = service();
866        let workflow_id = workflow_id();
867        let timer_id = timer_id();
868        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
869
870        service
871            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(90))
872            .await?;
873
874        assert!(history(&store, &workflow_id).await?.is_empty());
875        assert!(engine.delivered_messages()?.is_empty());
876        Ok(())
877    }
878
879    /// A deadline handler that records each fire and can be told to fail.
880    struct RecordingDeadlineHandler {
881        calls: std::sync::Mutex<Vec<(WorkflowId, RunId)>>,
882        fail: bool,
883    }
884
885    impl RecordingDeadlineHandler {
886        fn new(fail: bool) -> Self {
887            Self {
888                calls: std::sync::Mutex::new(Vec::new()),
889                fail,
890            }
891        }
892
893        fn calls(&self) -> Result<Vec<(WorkflowId, RunId)>, TimerServiceError> {
894            self.calls
895                .lock()
896                .map(|calls| calls.clone())
897                .map_err(|error| TimerServiceError::Deadline(error.to_string()))
898        }
899    }
900
901    #[async_trait::async_trait]
902    impl DeadlineHandler for RecordingDeadlineHandler {
903        async fn on_deadline_elapsed(
904            &self,
905            workflow_id: WorkflowId,
906            run_id: RunId,
907        ) -> Result<(), DeadlineHandlerError> {
908            self.calls
909                .lock()
910                .map_err(|error| DeadlineHandlerError(error.to_string()))?
911                .push((workflow_id, run_id));
912            if self.fail {
913                Err(DeadlineHandlerError(
914                    "deliberate handler failure".to_owned(),
915                ))
916            } else {
917                Ok(())
918            }
919        }
920    }
921
922    fn service_with_handler(
923        handler: Arc<dyn DeadlineHandler>,
924    ) -> (Arc<InMemoryStore>, Arc<FakeEngineHandle>, TimerService) {
925        let concrete_store = Arc::new(InMemoryStore::default());
926        let recorder_store: Arc<dyn WritableEventStore> = concrete_store.clone();
927        let readable_store: Arc<dyn ReadableEventStore> = concrete_store.clone();
928        let engine = Arc::new(FakeEngineHandle::recording_to(recorder_store));
929        let service = TimerService::with_recorded_at(engine.clone(), readable_store, recorded_at)
930            .with_deadline_handler(handler);
931        (concrete_store, engine, service)
932    }
933
934    /// A live reserved deadline fire is demuxed to the registered handler with
935    /// the id-encoded run, and records NO `TimerFired` and delivers nothing.
936    #[tokio::test]
937    async fn deadline_fire_routes_to_handler_and_records_no_timer_fired()
938    -> Result<(), TimerServiceError> {
939        let run_id = RunId::new_v4();
940        let deadline_id = deadline_timer_id(&run_id)
941            .map_err(|error| TimerServiceError::Deadline(error.to_string()))?;
942        let handler = Arc::new(RecordingDeadlineHandler::new(false));
943        let (store, engine, service) = service_with_handler(handler.clone());
944        let workflow_id = workflow_id();
945        let fire_at = instant(120);
946        engine.set_residency(
947            workflow_id.clone(),
948            WorkflowResidency::Resident(WorkflowProcessHandle::new(9)),
949        )?;
950        engine.record_workflow_event(
951            &workflow_id,
952            timer_started_event(&workflow_id, &deadline_id, 1),
953        )?;
954
955        service
956            .fire_timer(workflow_id.clone(), deadline_id.clone(), fire_at)
957            .await?;
958
959        assert_eq!(handler.calls()?, vec![(workflow_id.clone(), run_id)]);
960        assert_eq!(
961            count_timer_fired(&history(&store, &workflow_id).await?, &deadline_id),
962            0,
963            "a deadline fire never records TimerFired"
964        );
965        assert!(engine.delivered_messages()?.is_empty());
966        Ok(())
967    }
968
969    /// A deadline fire with no handler registered is a typed error — never a
970    /// silent generic fire.
971    #[tokio::test]
972    async fn deadline_fire_without_handler_is_typed_error() -> Result<(), TimerServiceError> {
973        let run_id = RunId::new_v4();
974        let deadline_id = deadline_timer_id(&run_id)
975            .map_err(|error| TimerServiceError::Deadline(error.to_string()))?;
976        let (store, engine, service) = service();
977        let workflow_id = workflow_id();
978        engine.record_workflow_event(
979            &workflow_id,
980            timer_started_event(&workflow_id, &deadline_id, 1),
981        )?;
982
983        let result = service
984            .fire_timer(workflow_id.clone(), deadline_id.clone(), instant(120))
985            .await;
986
987        assert!(
988            matches!(result, Err(TimerServiceError::Deadline(_))),
989            "unhandled deadline fire must be a typed error, got {result:?}"
990        );
991        assert_eq!(
992            count_timer_fired(&history(&store, &workflow_id).await?, &deadline_id),
993            0
994        );
995        Ok(())
996    }
997
998    /// A handler failure surfaces as a typed deadline error to the caller.
999    #[tokio::test]
1000    async fn deadline_handler_failure_surfaces_as_typed_error() -> Result<(), TimerServiceError> {
1001        let run_id = RunId::new_v4();
1002        let deadline_id = deadline_timer_id(&run_id)
1003            .map_err(|error| TimerServiceError::Deadline(error.to_string()))?;
1004        let handler = Arc::new(RecordingDeadlineHandler::new(true));
1005        let (_store, engine, service) = service_with_handler(handler);
1006        let workflow_id = workflow_id();
1007        engine.record_workflow_event(
1008            &workflow_id,
1009            timer_started_event(&workflow_id, &deadline_id, 1),
1010        )?;
1011
1012        let result = service
1013            .fire_timer(workflow_id, deadline_id, instant(120))
1014            .await;
1015
1016        assert!(matches!(result, Err(TimerServiceError::Deadline(_))));
1017        Ok(())
1018    }
1019
1020    /// A fire the recorder refuses as a post-terminal late arrival records
1021    /// nothing AND delivers no wake. Mutation-sensitive: the timer is live so the
1022    /// pre-check passes and the fire reaches the recorder seam, which returns
1023    /// `RefusedTerminal`; delivering the mailbox wake regardless of that outcome
1024    /// would reschedule a terminated workflow and fail this test.
1025    #[tokio::test]
1026    async fn refused_terminal_fire_records_nothing_and_delivers_no_wake()
1027    -> Result<(), TimerServiceError> {
1028        let process = WorkflowProcessHandle::new(42);
1029        let (store, engine, service) = service();
1030        let workflow_id = workflow_id();
1031        let timer_id = timer_id();
1032        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1033        engine.record_workflow_event(
1034            &workflow_id,
1035            timer_started_event(&workflow_id, &timer_id, 1),
1036        )?;
1037        engine.refuse_next_record_as_terminal()?;
1038
1039        service
1040            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(130))
1041            .await?;
1042
1043        assert_eq!(
1044            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1045            0,
1046            "a refused fire records no TimerFired"
1047        );
1048        assert!(
1049            engine.delivered_messages()?.is_empty(),
1050            "a refused fire delivers no wake"
1051        );
1052        Ok(())
1053    }
1054
1055    /// Two services obtained separately but sharing ONE terminal-update
1056    /// coordinator (as the production bridge hands out) serialize a cancel and a
1057    /// fire of the same timer: exactly one terminal timer event is recorded, never
1058    /// both. A `Barrier` forces genuine overlap — both actors are released
1059    /// together after setup — and the loop runs each direction. Mutation-sensitive:
1060    /// a per-service coordinator would let both read the timer live and record a
1061    /// `TimerFired` AND a `TimerCancelled`.
1062    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1063    async fn shared_coordinator_serializes_cancel_and_fire_across_services()
1064    -> Result<(), TimerServiceError> {
1065        use dashmap::DashSet;
1066        use tokio::sync::Barrier;
1067
1068        for _ in 0..20 {
1069            let process = WorkflowProcessHandle::new(42);
1070            let concrete_store = Arc::new(InMemoryStore::default());
1071            let recorder_store: Arc<dyn WritableEventStore> = concrete_store.clone();
1072            let readable: Arc<dyn ReadableEventStore> = concrete_store.clone();
1073            let engine = Arc::new(FakeEngineHandle::recording_to(recorder_store));
1074            let coordinator = Arc::new(DashSet::new());
1075            let service_a =
1076                TimerService::with_recorded_at(engine.clone(), readable.clone(), recorded_at)
1077                    .with_terminal_updates(Arc::clone(&coordinator));
1078            let service_b =
1079                TimerService::with_recorded_at(engine.clone(), readable.clone(), recorded_at)
1080                    .with_terminal_updates(Arc::clone(&coordinator));
1081
1082            let workflow_id = workflow_id();
1083            let timer_id = timer_id();
1084            let fire_at = instant(200);
1085            engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1086            engine.record_workflow_event(
1087                &workflow_id,
1088                timer_started_event(&workflow_id, &timer_id, 1),
1089            )?;
1090
1091            let gate = Arc::new(Barrier::new(2));
1092            let (cancel_gate, fire_gate) = (Arc::clone(&gate), gate);
1093            let (cancel_wf, cancel_timer) = (workflow_id.clone(), timer_id.clone());
1094            let cancel = async move {
1095                cancel_gate.wait().await;
1096                service_a
1097                    .cancel(cancel_wf, cancel_timer, TimerCancelCause::WorkflowIntent)
1098                    .await
1099            };
1100            let (fire_wf, fire_timer) = (workflow_id.clone(), timer_id.clone());
1101            let fire = async move {
1102                fire_gate.wait().await;
1103                service_b.fire_timer(fire_wf, fire_timer, fire_at).await
1104            };
1105            let (cancel_result, fire_result) = tokio::join!(cancel, fire);
1106            cancel_result?;
1107            fire_result?;
1108
1109            let history = history(&concrete_store, &workflow_id).await?;
1110            let terminal_timer_events = history
1111                .iter()
1112                .filter(|event| {
1113                    matches!(
1114                        event,
1115                        Event::TimerFired { timer_id: recorded, .. }
1116                        | Event::TimerCancelled { timer_id: recorded, .. }
1117                            if recorded == &timer_id
1118                    )
1119                })
1120                .count();
1121            assert_eq!(
1122                terminal_timer_events, 1,
1123                "first-recorded wins across shared services: {history:#?}"
1124            );
1125        }
1126        Ok(())
1127    }
1128
1129    #[tokio::test]
1130    async fn firing_prior_run_timer_after_continue_as_new_is_noop() -> Result<(), TimerServiceError>
1131    {
1132        let process = WorkflowProcessHandle::new(42);
1133        let (store, engine, service) = service();
1134        let workflow_id = workflow_id();
1135        let timer_id = timer_id();
1136        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1137        // Run 1 started the timer; run 2's WorkflowStarted closes that segment.
1138        engine.record_workflow_event(
1139            &workflow_id,
1140            timer_started_event(&workflow_id, &timer_id, 1),
1141        )?;
1142        engine.record_workflow_event(&workflow_id, workflow_started_event(&workflow_id, 2))?;
1143
1144        service
1145            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(100))
1146            .await?;
1147
1148        assert_eq!(
1149            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1150            0
1151        );
1152        assert!(engine.delivered_messages()?.is_empty());
1153        Ok(())
1154    }
1155}