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    /// A fire whose `TimerFired` is ALREADY the timer's last recorded event (an earlier append
215    /// landed while its acknowledgement was lost — aion#145) is not a no-op: for a resident
216    /// workflow the fire re-enters the recorder seam, which reconciles the recorder's sequence
217    /// forward without appending, and the owed mailbox wake is delivered.
218    ///
219    /// # Errors
220    ///
221    /// Returns [`TimerServiceError`] when history inspection, recording, residency resolution, or
222    /// live mailbox delivery fails.
223    pub async fn fire_timer(
224        &self,
225        workflow_id: WorkflowId,
226        timer_id: TimerId,
227        fire_at: DateTime<Utc>,
228    ) -> Result<(), TimerServiceError> {
229        let key = (workflow_id.clone(), timer_id.clone());
230        let terminal_update_slot = self.wait_for_terminal_update_slot(key).await;
231
232        let result = self
233            .fire_timer_guarded(workflow_id, timer_id, fire_at)
234            .await;
235        drop(terminal_update_slot);
236        result
237    }
238
239    async fn wait_for_terminal_update_slot(
240        &self,
241        key: (WorkflowId, TimerId),
242    ) -> TerminalUpdateSlot<'_> {
243        loop {
244            if self.terminal_updates.insert(key.clone()) {
245                return TerminalUpdateSlot {
246                    terminal_updates: self.terminal_updates.as_ref(),
247                    key,
248                };
249            }
250            tokio::task::yield_now().await;
251        }
252    }
253
254    async fn fire_timer_guarded(
255        &self,
256        workflow_id: WorkflowId,
257        timer_id: TimerId,
258        fire_at: DateTime<Utc>,
259    ) -> Result<(), TimerServiceError> {
260        // WHY the timer is not live decides what a fire still owes (aion#145).
261        // A cancelled or absent timer owes nothing; a timer whose last event is
262        // already `TimerFired` is the ack-loss shape — the durable record
263        // landed while the recording call's acknowledgement was lost, so the
264        // mailbox wake (and the recorder's sequence repair) may still be owed.
265        // This service-layer read is the cheap gate that keeps genuine no-ops
266        // (cancelled/absent/retired) out of the recorder seam; the bridge
267        // re-checks the same fact under the recorder lock, and that check is
268        // the authoritative one.
269        let history = self.store.read_history(&workflow_id).await?;
270        match timer_disposition_in_active_segment(&history, &timer_id) {
271            TimerDisposition::Live => {}
272            TimerDisposition::Fired if !is_deadline_timer(&timer_id) => {
273                return self
274                    .redeliver_recorded_fire(workflow_id, timer_id, fire_at)
275                    .await;
276            }
277            // A retired deadline (which never records `TimerFired` through this
278            // path), a cancelled timer, or a timer with no event in the active
279            // segment: nothing is owed, exactly as before the #145 fix.
280            TimerDisposition::Fired | TimerDisposition::Cancelled | TimerDisposition::Absent => {
281                return Ok(());
282            }
283        }
284
285        // Demux a reserved workflow-deadline timer out of the generic
286        // record-then-deliver path (both the live wheel and `recover_due` reach
287        // here): it never records a `TimerFired` — the registered handler records
288        // `WorkflowTimedOut` and tears the run down instead.
289        if is_deadline_timer(&timer_id) {
290            return self.fire_deadline(workflow_id, timer_id).await;
291        }
292
293        let event = Event::TimerFired {
294            envelope: self.next_envelope(&workflow_id).await?,
295            timer_id: timer_id.clone(),
296        };
297        // Deliver the mailbox wake only when the durable record exists. The
298        // recorder seam refuses a late fire that lands after the run terminated
299        // (`RefusedTerminal`), recording nothing; waking the process then would
300        // reschedule a workflow that has already reached its terminal — the
301        // post-terminal wake this gate closes. `AlreadyRecorded` is the
302        // opposite case: the record exists (an earlier acknowledgement-lost
303        // append landed), so the wake is owed exactly as for `Recorded`.
304        match self.engine.record_workflow_event(&workflow_id, event)? {
305            RecordOutcome::RefusedTerminal => return Ok(()),
306            RecordOutcome::Recorded | RecordOutcome::AlreadyRecorded => {}
307        }
308
309        if let WorkflowResidency::Resident(process) = self.engine.resolve_workflow(&workflow_id)? {
310            self.engine.deliver_workflow_message(
311                process,
312                WorkflowMailboxMessage::TimerFired { timer_id, fire_at },
313            )?;
314        }
315
316        Ok(())
317    }
318
319    /// Completes a fire whose durable `TimerFired` already exists but whose
320    /// delivery — and possibly the recorder's own sequence advance — was lost
321    /// (aion#145): the incident's ack-lost append, or a wake that failed after
322    /// a fully recorded fire.
323    ///
324    /// Only a RESIDENT workflow owes a live wake, and only its still-held
325    /// Recorder can be carrying the stale-low sequence the ack loss leaves
326    /// behind: a non-resident workflow's replay on residency restore rebuilds
327    /// its recorder from the durable head and consumes the recorded fire, so
328    /// for it this is a clean no-op, exactly as before the fix.
329    ///
330    /// For the resident case the fire is put back through the recorder seam
331    /// rather than woken directly: the bridge's check under the recorder lock
332    /// is the authoritative "already recorded" decision, and it is where the
333    /// recorder's in-memory sequence is reconciled forward to the durable head
334    /// — without that repair the woken workflow's next append would mint a
335    /// stale sequence and die on `SequenceConflict`, wedging the run one event
336    /// later. The wake itself is a pure wake (the suspended await re-resolves
337    /// from history), so a duplicate delivery is harmless by design.
338    async fn redeliver_recorded_fire(
339        &self,
340        workflow_id: WorkflowId,
341        timer_id: TimerId,
342        fire_at: DateTime<Utc>,
343    ) -> Result<(), TimerServiceError> {
344        let WorkflowResidency::Resident(process) = self.engine.resolve_workflow(&workflow_id)?
345        else {
346            return Ok(());
347        };
348
349        let event = Event::TimerFired {
350            envelope: self.next_envelope(&workflow_id).await?,
351            timer_id: timer_id.clone(),
352        };
353        match self.engine.record_workflow_event(&workflow_id, event)? {
354            // The run reached a terminal after the fire landed: the recorded
355            // fire is inert history now, and no wake may reschedule the run.
356            RecordOutcome::RefusedTerminal => return Ok(()),
357            RecordOutcome::Recorded | RecordOutcome::AlreadyRecorded => {}
358        }
359
360        self.engine.deliver_workflow_message(
361            process,
362            WorkflowMailboxMessage::TimerFired {
363                timer_id: timer_id.clone(),
364                fire_at,
365            },
366        )?;
367        tracing::info!(
368            %workflow_id,
369            %timer_id,
370            "timer fire was already durably recorded; delivered the owed mailbox wake"
371        );
372        Ok(())
373    }
374
375    /// Route a live reserved-deadline fire to the registered handler.
376    ///
377    /// Called only for a `deadline:{run_id}` timer that passed the liveness
378    /// guard. A missing handler or an unparseable run id is a typed
379    /// [`TimerServiceError::Deadline`] — never a silent generic fire — and the
380    /// handler's own failure is surfaced the same way. The handler re-checks the
381    /// run's terminal under the recorder lock, so it loses cleanly to a
382    /// concurrent completion.
383    async fn fire_deadline(
384        &self,
385        workflow_id: WorkflowId,
386        timer_id: TimerId,
387    ) -> Result<(), TimerServiceError> {
388        let handler = self.deadline_handler.as_ref().ok_or_else(|| {
389            TimerServiceError::Deadline(format!(
390                "no deadline handler registered for {timer_id} on workflow {workflow_id}"
391            ))
392        })?;
393        let run_id = deadline_run_id(&timer_id).ok_or_else(|| {
394            TimerServiceError::Deadline(format!(
395                "malformed deadline timer {timer_id} on workflow {workflow_id}"
396            ))
397        })?;
398        handler
399            .on_deadline_elapsed(workflow_id, run_id)
400            .await
401            .map_err(|error| TimerServiceError::Deadline(error.to_string()))
402    }
403
404    /// Whether the timer is currently live (started and not since retired) in
405    /// the workflow's active run segment, by last-event-wins.
406    ///
407    /// A timer belongs to the run that recorded its `TimerStarted`, and
408    /// anonymous timer identities are run-scoped ordinals that replacement
409    /// runs (continue-as-new) re-allocate from zero. Scoping the check to
410    /// the latest run segment keeps a stale fire from a finished run from
411    /// recording into — or suppressing — the replacement run's identically
412    /// named timer.
413    ///
414    /// Liveness is decided by the *last* timer event for the id (see
415    /// [`timer_disposition_in_active_segment`], the per-id view of the model
416    /// [`live_timers_in_active_segment`] enumerates): a re-armed named timer
417    /// (`TimerStarted(T), TimerFired(T), TimerStarted(T)`) is correctly live
418    /// again rather than judged terminal forever by the earlier `TimerFired`.
419    async fn timer_is_live(
420        &self,
421        workflow_id: &WorkflowId,
422        timer_id: &TimerId,
423    ) -> Result<bool, StoreError> {
424        let history = self.store.read_history(workflow_id).await?;
425        Ok(matches!(
426            timer_disposition_in_active_segment(&history, timer_id),
427            TimerDisposition::Live
428        ))
429    }
430
431    async fn next_envelope(&self, workflow_id: &WorkflowId) -> Result<EventEnvelope, StoreError> {
432        let history = self.store.read_history(workflow_id).await?;
433        let seq = history.iter().map(Event::seq).max().unwrap_or_default() + 1;
434        Ok(EventEnvelope {
435            seq,
436            recorded_at: (self.recorded_at)(),
437            workflow_id: workflow_id.clone(),
438        })
439    }
440}
441
442/// The live timer ids in the workflow's active run segment, by last-event-wins.
443///
444/// Scans forward from the latest `WorkflowStarted` (the active run segment) and
445/// lets the *last* event for each timer id decide its liveness: a `TimerStarted`
446/// (re)arms it, a `TimerFired`/`TimerCancelled` retires it. This means a *named*
447/// timer that fired or was cancelled and then re-armed within the same segment
448/// (`TimerStarted(T), TimerFired(T), TimerStarted(T)`) is correctly reported live
449/// again, rather than judged terminal forever by the earlier terminal event.
450///
451/// Start order is preserved and a timer id started more than once is deduped, so
452/// the result is a stable, history-derived (and therefore replay-deterministic)
453/// view of which timers are outstanding. This is the single liveness model shared
454/// by [`TimerService::timer_is_live`] (firing/cancel guard) and the cancel-path
455/// enumerator in `engine::api`, so the two cannot diverge.
456pub(crate) fn live_timers_in_active_segment(history: &[Event]) -> Vec<TimerId> {
457    let mut live: Vec<TimerId> = Vec::new();
458    for event in active_segment(history) {
459        match event {
460            Event::TimerStarted { timer_id, .. } if !live.contains(timer_id) => {
461                live.push(timer_id.clone());
462            }
463            Event::TimerFired { timer_id, .. } | Event::TimerCancelled { timer_id, .. } => {
464                live.retain(|id| id != timer_id);
465            }
466            _ => {}
467        }
468    }
469    live
470}
471
472/// The workflow's active run segment: everything from the latest
473/// `WorkflowStarted` (the whole history when none is recorded — bare fixtures
474/// and coordinator histories).
475///
476/// The single segment anchor shared by [`live_timers_in_active_segment`] and
477/// [`timer_disposition_in_active_segment`], so the enumerating and the per-id
478/// view of the liveness model cannot disagree about where the active run
479/// begins.
480fn active_segment(history: &[Event]) -> &[Event] {
481    let segment_start = history
482        .iter()
483        .rposition(|event| matches!(event, Event::WorkflowStarted { .. }))
484        .unwrap_or(0);
485    &history[segment_start..]
486}
487
488/// The recorded fate of ONE timer in the workflow's active run segment, by the
489/// same last-event-wins rule as [`live_timers_in_active_segment`].
490///
491/// [`live_timers_in_active_segment`] can only answer "live or not"; the fire
492/// path needs to know WHY a timer is not live (aion#145): a timer whose last
493/// event is `TimerFired` already has its durable record — the fire's mailbox
494/// wake may still be owed — while a cancelled or absent timer owes nothing.
495/// This is the per-id view of the SAME model, not a fork of it: same segment
496/// anchor ([`active_segment`]), same last-event-wins traversal, so for every
497/// history and timer id, `Live` here if and only if the id appears in
498/// [`live_timers_in_active_segment`].
499#[derive(Clone, Copy, Debug, Eq, PartialEq)]
500pub(crate) enum TimerDisposition {
501    /// The timer's last event in the active segment is `TimerStarted`: live.
502    Live,
503    /// The timer's last event in the active segment is `TimerFired`: the
504    /// durable fire record exists (its mailbox wake may or may not have been
505    /// delivered — history cannot tell, which is why delivery is a pure,
506    /// duplicate-safe wake).
507    Fired,
508    /// The timer's last event in the active segment is `TimerCancelled`.
509    Cancelled,
510    /// The timer has no event in the active segment (never started there, or
511    /// started only in a prior, closed run segment).
512    Absent,
513}
514
515/// Computes [`TimerDisposition`] for `timer_id` over `history`.
516pub(crate) fn timer_disposition_in_active_segment(
517    history: &[Event],
518    timer_id: &TimerId,
519) -> TimerDisposition {
520    let mut disposition = TimerDisposition::Absent;
521    for event in active_segment(history) {
522        match event {
523            Event::TimerStarted { timer_id: id, .. } if id == timer_id => {
524                disposition = TimerDisposition::Live;
525            }
526            Event::TimerFired { timer_id: id, .. } if id == timer_id => {
527                disposition = TimerDisposition::Fired;
528            }
529            Event::TimerCancelled { timer_id: id, .. } if id == timer_id => {
530                disposition = TimerDisposition::Cancelled;
531            }
532            _ => {}
533        }
534    }
535    disposition
536}
537
538#[cfg(test)]
539mod tests {
540    use std::sync::Arc;
541
542    use aion_core::{Event, EventEnvelope, RunId, TimerCancelCause, TimerId, WorkflowId};
543    use aion_store::{InMemoryStore, ReadableEventStore, StoreError, WritableEventStore};
544    use chrono::{DateTime, Utc};
545
546    use super::{
547        TimerDisposition, TimerService, TimerServiceError, live_timers_in_active_segment,
548        timer_disposition_in_active_segment,
549    };
550    use crate::engine_seam::test_support::{
551        DeliveredWorkflowMessage, FakeEngineHandle, FakeEngineOperation,
552    };
553    use crate::engine_seam::{
554        EngineHandle, TimerWheelEntry, WorkflowProcessHandle, WorkflowResidency,
555    };
556    use crate::time::deadline::{DeadlineHandler, DeadlineHandlerError, deadline_timer_id};
557
558    fn instant(offset_seconds: i64) -> DateTime<Utc> {
559        DateTime::from_timestamp(1_700_000_000 + offset_seconds, 0).unwrap_or_default()
560    }
561
562    fn workflow_id() -> WorkflowId {
563        WorkflowId::new_v4()
564    }
565
566    fn timer_id() -> TimerId {
567        TimerId::anonymous(7)
568    }
569
570    fn service() -> (Arc<InMemoryStore>, Arc<FakeEngineHandle>, TimerService) {
571        let concrete_store = Arc::new(InMemoryStore::default());
572        let recorder_store: Arc<dyn WritableEventStore> = concrete_store.clone();
573        let readable_store: Arc<dyn ReadableEventStore> = concrete_store.clone();
574        let engine = Arc::new(FakeEngineHandle::recording_to(recorder_store));
575        let service = TimerService::with_recorded_at(engine.clone(), readable_store, recorded_at);
576        (concrete_store, engine, service)
577    }
578
579    fn recorded_at() -> DateTime<Utc> {
580        instant(1)
581    }
582
583    async fn history(
584        store: &InMemoryStore,
585        workflow_id: &WorkflowId,
586    ) -> Result<Vec<Event>, StoreError> {
587        store.read_history(workflow_id).await
588    }
589
590    fn count_timer_fired(events: &[Event], timer_id: &TimerId) -> usize {
591        events
592            .iter()
593            .filter(|event| {
594                matches!(event, Event::TimerFired { timer_id: recorded, .. } if recorded == timer_id)
595            })
596            .count()
597    }
598
599    fn timer_started_event(workflow_id: &WorkflowId, timer_id: &TimerId, seq: u64) -> Event {
600        Event::TimerStarted {
601            envelope: EventEnvelope {
602                seq,
603                recorded_at: instant(0),
604                workflow_id: workflow_id.clone(),
605            },
606            timer_id: timer_id.clone(),
607            fire_at: instant(5),
608        }
609    }
610
611    fn workflow_started_event(workflow_id: &WorkflowId, seq: u64) -> Event {
612        Event::WorkflowStarted {
613            envelope: EventEnvelope {
614                seq,
615                recorded_at: instant(0),
616                workflow_id: workflow_id.clone(),
617            },
618            workflow_type: "fixture".to_owned(),
619            input: aion_core::Payload::new(aion_core::ContentType::Json, b"null".to_vec()),
620            run_id: aion_core::RunId::new_v4(),
621            parent_run_id: None,
622            parent_workflow_id: None,
623            package_version: aion_core::PackageVersion::new("a".repeat(64)),
624        }
625    }
626
627    fn timer_fired_event(workflow_id: &WorkflowId, timer_id: &TimerId, seq: u64) -> Event {
628        Event::TimerFired {
629            envelope: EventEnvelope {
630                seq,
631                recorded_at: instant(0),
632                workflow_id: workflow_id.clone(),
633            },
634            timer_id: timer_id.clone(),
635        }
636    }
637
638    fn timer_cancelled_event(workflow_id: &WorkflowId, timer_id: &TimerId, seq: u64) -> Event {
639        Event::TimerCancelled {
640            cause: TimerCancelCause::WorkflowIntent,
641            envelope: EventEnvelope {
642                seq,
643                recorded_at: instant(0),
644                workflow_id: workflow_id.clone(),
645            },
646            timer_id: timer_id.clone(),
647        }
648    }
649
650    fn make_named(name: &str) -> TimerId {
651        // The name is a non-empty literal, so construction never fails; the
652        // anonymous fallback only exists to keep the helper total without an
653        // `unwrap`/`expect` (disallowed by clippy in this crate).
654        TimerId::named(name).unwrap_or_else(|_| TimerId::anonymous(0))
655    }
656
657    fn named_timer_id() -> TimerId {
658        make_named("review-deadline")
659    }
660
661    // --- `live_timers_in_active_segment` / `timer_is_live` semantics ---
662
663    #[test]
664    fn started_timer_is_live() {
665        let workflow_id = workflow_id();
666        let timer_id = named_timer_id();
667        let history = vec![
668            workflow_started_event(&workflow_id, 0),
669            timer_started_event(&workflow_id, &timer_id, 1),
670        ];
671        assert_eq!(live_timers_in_active_segment(&history), vec![timer_id]);
672    }
673
674    #[test]
675    fn started_then_fired_timer_is_dead() {
676        let workflow_id = workflow_id();
677        let timer_id = named_timer_id();
678        let history = vec![
679            workflow_started_event(&workflow_id, 0),
680            timer_started_event(&workflow_id, &timer_id, 1),
681            timer_fired_event(&workflow_id, &timer_id, 2),
682        ];
683        assert!(live_timers_in_active_segment(&history).is_empty());
684    }
685
686    #[test]
687    fn started_then_cancelled_timer_is_dead() {
688        let workflow_id = workflow_id();
689        let timer_id = named_timer_id();
690        let history = vec![
691            workflow_started_event(&workflow_id, 0),
692            timer_started_event(&workflow_id, &timer_id, 1),
693            timer_cancelled_event(&workflow_id, &timer_id, 2),
694        ];
695        assert!(live_timers_in_active_segment(&history).is_empty());
696    }
697
698    #[test]
699    fn restarted_named_timer_after_fire_is_live() {
700        // The bug fix: a named timer that fired then was re-armed in the same run
701        // segment must be live again (last-event-wins), not judged terminal forever
702        // by the earlier `TimerFired`.
703        let workflow_id = workflow_id();
704        let timer_id = named_timer_id();
705        let history = vec![
706            workflow_started_event(&workflow_id, 0),
707            timer_started_event(&workflow_id, &timer_id, 1),
708            timer_fired_event(&workflow_id, &timer_id, 2),
709            timer_started_event(&workflow_id, &timer_id, 3),
710        ];
711        assert_eq!(
712            live_timers_in_active_segment(&history),
713            vec![timer_id],
714            "a re-armed named timer is live again"
715        );
716    }
717
718    #[test]
719    fn restarted_named_timer_after_cancel_is_live() {
720        let workflow_id = workflow_id();
721        let timer_id = named_timer_id();
722        let history = vec![
723            workflow_started_event(&workflow_id, 0),
724            timer_started_event(&workflow_id, &timer_id, 1),
725            timer_cancelled_event(&workflow_id, &timer_id, 2),
726            timer_started_event(&workflow_id, &timer_id, 3),
727        ];
728        assert_eq!(live_timers_in_active_segment(&history), vec![timer_id]);
729    }
730
731    #[test]
732    fn prior_run_segment_timer_is_not_live() {
733        // A timer started in a run segment that a later `WorkflowStarted` closed
734        // (continue-as-new) is out of scope for the active segment.
735        let workflow_id = workflow_id();
736        let prior = named_timer_id();
737        let current = make_named("current-deadline");
738        let history = vec![
739            workflow_started_event(&workflow_id, 0),
740            timer_started_event(&workflow_id, &prior, 1),
741            // New run segment begins; the prior timer must not be surfaced.
742            workflow_started_event(&workflow_id, 2),
743            timer_started_event(&workflow_id, &current, 3),
744        ];
745        assert_eq!(live_timers_in_active_segment(&history), vec![current]);
746    }
747
748    /// The per-id disposition view (aion#145) must agree with the enumerating
749    /// liveness model on every shape: `Live` exactly when the id appears in
750    /// [`live_timers_in_active_segment`], with the not-live cases split by WHY.
751    /// Each case asserts both views so the two traversals cannot drift.
752    #[test]
753    fn disposition_tracks_the_last_event_for_the_id_and_agrees_with_liveness() {
754        let workflow_id = workflow_id();
755        let timer_id = named_timer_id();
756        let other = make_named("unrelated");
757        let assert_agrees = |history: &[Event], expected: TimerDisposition| {
758            assert_eq!(
759                timer_disposition_in_active_segment(history, &timer_id),
760                expected
761            );
762            assert_eq!(
763                live_timers_in_active_segment(history).contains(&timer_id),
764                expected == TimerDisposition::Live,
765                "the per-id disposition and the enumerating model disagree on liveness"
766            );
767        };
768
769        // Started → live.
770        let mut history = vec![
771            workflow_started_event(&workflow_id, 0),
772            timer_started_event(&workflow_id, &timer_id, 1),
773        ];
774        assert_agrees(&history, TimerDisposition::Live);
775
776        // Fired at head → the durable record exists (the aion#145 shape).
777        history.push(timer_fired_event(&workflow_id, &timer_id, 2));
778        assert_agrees(&history, TimerDisposition::Fired);
779
780        // A fire for an UNRELATED id must not disturb this timer's disposition.
781        history.push(timer_fired_event(&workflow_id, &other, 3));
782        assert_agrees(&history, TimerDisposition::Fired);
783
784        // Re-armed after the fire → live again (last-event-wins).
785        history.push(timer_started_event(&workflow_id, &timer_id, 4));
786        assert_agrees(&history, TimerDisposition::Live);
787
788        // Cancelled at head → retired, owed nothing.
789        history.push(timer_cancelled_event(&workflow_id, &timer_id, 5));
790        assert_agrees(&history, TimerDisposition::Cancelled);
791
792        // A new run segment closes the book: the id is absent from the active
793        // segment even though the prior segment fired and cancelled it.
794        history.push(workflow_started_event(&workflow_id, 6));
795        assert_agrees(&history, TimerDisposition::Absent);
796
797        // And with no events at all it was absent to begin with.
798        assert_agrees(&[], TimerDisposition::Absent);
799    }
800
801    #[tokio::test]
802    async fn re_armed_named_timer_fires_again() -> Result<(), TimerServiceError> {
803        // End-to-end firing-path guard: with last-event-wins, a re-armed named
804        // timer is live, so `fire_timer` records a second `TimerFired` and
805        // delivers it — rather than silently no-opping under the old
806        // `any`-semantics.
807        let process = WorkflowProcessHandle::new(42);
808        let (store, engine, service) = service();
809        let workflow_id = workflow_id();
810        let timer_id = named_timer_id();
811        let fire_at = instant(110);
812        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
813        engine.record_workflow_event(
814            &workflow_id,
815            timer_started_event(&workflow_id, &timer_id, 1),
816        )?;
817        engine
818            .record_workflow_event(&workflow_id, timer_fired_event(&workflow_id, &timer_id, 2))?;
819        engine.record_workflow_event(
820            &workflow_id,
821            timer_started_event(&workflow_id, &timer_id, 3),
822        )?;
823
824        service
825            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
826            .await?;
827
828        assert_eq!(
829            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
830            2,
831            "the re-armed timer fires again, recording a second TimerFired"
832        );
833        assert_eq!(engine.delivered_messages()?.len(), 1);
834        Ok(())
835    }
836
837    #[tokio::test]
838    async fn schedule_records_timer_row_without_timer_started_event()
839    -> Result<(), TimerServiceError> {
840        let (store, _engine, service) = service();
841        let workflow_id = workflow_id();
842        let timer_id = timer_id();
843        let fire_at = instant(10);
844
845        service
846            .schedule(workflow_id.clone(), timer_id.clone(), fire_at)
847            .await?;
848
849        let expired = store.expired_timers(fire_at).await?;
850        assert_eq!(expired.len(), 1);
851        assert_eq!(expired[0].workflow_id, workflow_id);
852        assert_eq!(expired[0].timer_id, timer_id);
853        assert_eq!(expired[0].fire_at, fire_at);
854
855        assert!(history(&store, &workflow_id).await?.is_empty());
856        Ok(())
857    }
858
859    #[tokio::test]
860    async fn schedule_arms_wheel_for_resident_workflow() -> Result<(), TimerServiceError> {
861        let process = WorkflowProcessHandle::new(42);
862        let (_store, engine, service) = service();
863        let workflow_id = workflow_id();
864        let timer_id = timer_id();
865        let fire_at = instant(20);
866        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
867
868        service
869            .schedule(workflow_id, timer_id.clone(), fire_at)
870            .await?;
871
872        assert_eq!(
873            engine.armed_timers()?,
874            vec![TimerWheelEntry {
875                process,
876                timer_id,
877                fire_at
878            }]
879        );
880        Ok(())
881    }
882
883    #[tokio::test]
884    async fn schedule_for_nonresident_records_without_arming() -> Result<(), TimerServiceError> {
885        let (store, engine, service) = service();
886        let workflow_id = workflow_id();
887        let timer_id = timer_id();
888        let fire_at = instant(30);
889        engine.set_residency(workflow_id.clone(), WorkflowResidency::NonResident)?;
890
891        service
892            .schedule(workflow_id.clone(), timer_id, fire_at)
893            .await?;
894
895        assert!(engine.armed_timers()?.is_empty());
896        assert!(history(&store, &workflow_id).await?.is_empty());
897        Ok(())
898    }
899
900    #[tokio::test]
901    async fn fire_records_timer_fired_then_delivers_mailbox_message()
902    -> Result<(), TimerServiceError> {
903        let process = WorkflowProcessHandle::new(42);
904        let (store, engine, service) = service();
905        let workflow_id = workflow_id();
906        let timer_id = timer_id();
907        let fire_at = instant(40);
908        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
909        engine.record_workflow_event(
910            &workflow_id,
911            timer_started_event(&workflow_id, &timer_id, 1),
912        )?;
913
914        service
915            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
916            .await?;
917
918        assert_eq!(
919            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
920            1
921        );
922        assert_eq!(
923            engine.delivered_messages()?,
924            vec![(
925                process,
926                DeliveredWorkflowMessage::TimerFired {
927                    timer_id: timer_id.clone(),
928                    fire_at
929                }
930            )]
931        );
932        assert!(matches!(
933            engine.operations()?.as_slice(),
934            [
935                FakeEngineOperation::EventRecorded {
936                    event: Event::TimerStarted { .. },
937                    ..
938                },
939                FakeEngineOperation::EventRecorded {
940                    workflow_id: recorded_workflow_id,
941                    event: Event::TimerFired { timer_id: recorded_timer_id, .. },
942                },
943                FakeEngineOperation::Delivered {
944                    process: delivered_process,
945                    message: DeliveredWorkflowMessage::TimerFired { timer_id: delivered_timer_id, .. },
946                }
947            ] if recorded_workflow_id == &workflow_id
948                && recorded_timer_id == &timer_id
949                && delivered_process == &process
950                && delivered_timer_id == &timer_id
951        ));
952        Ok(())
953    }
954
955    #[tokio::test]
956    async fn fire_records_without_delivery_when_workflow_becomes_nonresident()
957    -> Result<(), TimerServiceError> {
958        let (store, engine, service) = service();
959        let workflow_id = workflow_id();
960        let timer_id = timer_id();
961        let fire_at = instant(50);
962        engine.set_residency(workflow_id.clone(), WorkflowResidency::NonResident)?;
963        engine.record_workflow_event(
964            &workflow_id,
965            timer_started_event(&workflow_id, &timer_id, 1),
966        )?;
967
968        service
969            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
970            .await?;
971
972        assert_eq!(
973            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
974            1
975        );
976        assert!(engine.delivered_messages()?.is_empty());
977        Ok(())
978    }
979
980    /// aion#145: a second fire of an already-fired timer records nothing new
981    /// but RE-DELIVERS the owed wake to a resident workflow. Delivery is a pure
982    /// wake (the suspended await re-resolves from history), so a duplicate is
983    /// harmless — while the pre-fix silent no-op is exactly what wedged the
984    /// incident's workflows: the durable `TimerFired` existed and the resident
985    /// process waited forever on a wake that never came. Mutation-sensitive:
986    /// reverting the `Fired`-disposition branch to a plain `Ok(())` leaves one
987    /// delivery; a second append would raise the fired count to two.
988    #[tokio::test]
989    async fn firing_same_timer_twice_records_once_and_redelivers_the_wake()
990    -> Result<(), TimerServiceError> {
991        let process = WorkflowProcessHandle::new(42);
992        let (store, engine, service) = service();
993        let workflow_id = workflow_id();
994        let timer_id = timer_id();
995        let fire_at = instant(60);
996        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
997        engine.record_workflow_event(
998            &workflow_id,
999            timer_started_event(&workflow_id, &timer_id, 1),
1000        )?;
1001
1002        service
1003            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1004            .await?;
1005        // The second fire re-enters the recorder seam, which answers
1006        // `AlreadyRecorded` without appending (the fake implements the seam
1007        // contract; the real bridge's under-lock decision — including the
1008        // recorder-sequence reconciliation — is pinned in
1009        // `nif_timer_bridge_tests`).
1010        service
1011            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1012            .await?;
1013
1014        assert_eq!(
1015            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1016            1,
1017            "the recorded fire must never be appended a second time"
1018        );
1019        assert_eq!(
1020            engine.delivered_messages()?.len(),
1021            2,
1022            "the second fire re-delivers the owed wake instead of silently no-opping"
1023        );
1024        Ok(())
1025    }
1026
1027    /// aion#145 Parts 2+3, service-seam mapping: a fire whose `TimerFired` is
1028    /// already the timer's last recorded event (the incident's ack-lost append)
1029    /// must NOT no-op for a resident workflow — it re-enters the recorder seam
1030    /// (which answers `AlreadyRecorded` without appending) and then delivers
1031    /// the owed wake. Mutation-sensitive both ways: reverting the
1032    /// `Fired`-disposition branch to `Ok(())` delivers nothing, and mapping
1033    /// `AlreadyRecorded` like `RefusedTerminal` delivers nothing.
1034    #[tokio::test]
1035    async fn already_recorded_fire_delivers_owed_wake_without_second_append()
1036    -> Result<(), TimerServiceError> {
1037        let process = WorkflowProcessHandle::new(42);
1038        let (store, engine, service) = service();
1039        let workflow_id = workflow_id();
1040        let timer_id = timer_id();
1041        let fire_at = instant(140);
1042        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1043        engine.record_workflow_event(
1044            &workflow_id,
1045            timer_started_event(&workflow_id, &timer_id, 1),
1046        )?;
1047        engine
1048            .record_workflow_event(&workflow_id, timer_fired_event(&workflow_id, &timer_id, 2))?;
1049
1050        service
1051            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1052            .await?;
1053
1054        assert_eq!(
1055            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1056            1,
1057            "the already-recorded fire must not be appended again"
1058        );
1059        assert_eq!(
1060            engine.delivered_messages()?,
1061            vec![(
1062                process,
1063                DeliveredWorkflowMessage::TimerFired { timer_id, fire_at }
1064            )],
1065            "the owed mailbox wake must be delivered"
1066        );
1067        Ok(())
1068    }
1069
1070    /// aion#145 test matrix row 3: fired-but-undelivered for a NON-resident
1071    /// workflow is a clean no-op — no wake is attempted (there is no live
1072    /// process to wake) and the recorder seam is not re-entered: replay on
1073    /// residency restore rebuilds the recorder from the durable head and
1074    /// consumes the recorded fire. Load-bearing assertions: exactly one
1075    /// durable `TimerFired`, and an empty delivery log.
1076    #[tokio::test]
1077    async fn already_recorded_fire_for_nonresident_workflow_wakes_nothing()
1078    -> Result<(), TimerServiceError> {
1079        let (store, engine, service) = service();
1080        let workflow_id = workflow_id();
1081        let timer_id = timer_id();
1082        engine.set_residency(workflow_id.clone(), WorkflowResidency::NonResident)?;
1083        engine.record_workflow_event(
1084            &workflow_id,
1085            timer_started_event(&workflow_id, &timer_id, 1),
1086        )?;
1087        engine
1088            .record_workflow_event(&workflow_id, timer_fired_event(&workflow_id, &timer_id, 2))?;
1089
1090        service
1091            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(150))
1092            .await?;
1093
1094        assert_eq!(
1095            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1096            1,
1097            "a non-resident redelivery must not re-enter the recorder seam"
1098        );
1099        assert!(
1100            engine.delivered_messages()?.is_empty(),
1101            "no wake is attempted for a non-resident workflow"
1102        );
1103        Ok(())
1104    }
1105
1106    /// aion#145: the redelivery path still honors the post-terminal refusal.
1107    /// A recorded fire whose run has since reached a terminal gets NO wake —
1108    /// the recorder seam answers `RefusedTerminal` and the recorded fire is
1109    /// inert history. Mutation-sensitive: delivering the wake regardless of the
1110    /// refusal would reschedule a terminated workflow.
1111    #[tokio::test]
1112    async fn already_recorded_fire_after_run_terminal_delivers_no_wake()
1113    -> Result<(), TimerServiceError> {
1114        let process = WorkflowProcessHandle::new(42);
1115        let (store, engine, service) = service();
1116        let workflow_id = workflow_id();
1117        let timer_id = timer_id();
1118        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1119        engine.record_workflow_event(
1120            &workflow_id,
1121            timer_started_event(&workflow_id, &timer_id, 1),
1122        )?;
1123        engine
1124            .record_workflow_event(&workflow_id, timer_fired_event(&workflow_id, &timer_id, 2))?;
1125        engine.refuse_next_record_as_terminal()?;
1126
1127        service
1128            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(160))
1129            .await?;
1130
1131        assert_eq!(
1132            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1133            1
1134        );
1135        assert!(
1136            engine.delivered_messages()?.is_empty(),
1137            "a post-terminal redelivery must not wake the terminated run"
1138        );
1139        Ok(())
1140    }
1141
1142    #[tokio::test]
1143    async fn firing_cancelled_timer_is_noop() -> Result<(), TimerServiceError> {
1144        let process = WorkflowProcessHandle::new(42);
1145        let (store, engine, service) = service();
1146        let workflow_id = workflow_id();
1147        let timer_id = timer_id();
1148        let fire_at = instant(70);
1149        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1150        engine.record_workflow_event(
1151            &workflow_id,
1152            timer_started_event(&workflow_id, &timer_id, 1),
1153        )?;
1154        let cancelled = Event::TimerCancelled {
1155            cause: TimerCancelCause::WorkflowIntent,
1156            envelope: EventEnvelope {
1157                seq: 2,
1158                recorded_at: instant(69),
1159                workflow_id: workflow_id.clone(),
1160            },
1161            timer_id: timer_id.clone(),
1162        };
1163        engine.record_workflow_event(&workflow_id, cancelled)?;
1164
1165        service
1166            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1167            .await?;
1168
1169        let history = history(&store, &workflow_id).await?;
1170        assert_eq!(count_timer_fired(&history, &timer_id), 0);
1171        assert!(engine.delivered_messages()?.is_empty());
1172        Ok(())
1173    }
1174
1175    #[tokio::test]
1176    async fn fire_resolves_residency_at_fire_time() -> Result<(), TimerServiceError> {
1177        let process = WorkflowProcessHandle::new(42);
1178        let (store, engine, service) = service();
1179        let workflow_id = workflow_id();
1180        let timer_id = timer_id();
1181        let fire_at = instant(80);
1182
1183        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1184        engine.set_residency(workflow_id.clone(), WorkflowResidency::NonResident)?;
1185        engine.record_workflow_event(
1186            &workflow_id,
1187            timer_started_event(&workflow_id, &timer_id, 1),
1188        )?;
1189        service
1190            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1191            .await?;
1192
1193        assert_eq!(
1194            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1195            1
1196        );
1197        assert!(engine.delivered_messages()?.is_empty());
1198        Ok(())
1199    }
1200
1201    #[tokio::test]
1202    async fn firing_unstarted_timer_records_nothing() -> Result<(), TimerServiceError> {
1203        let process = WorkflowProcessHandle::new(42);
1204        let (store, engine, service) = service();
1205        let workflow_id = workflow_id();
1206        let timer_id = timer_id();
1207        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1208
1209        service
1210            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(90))
1211            .await?;
1212
1213        assert!(history(&store, &workflow_id).await?.is_empty());
1214        assert!(engine.delivered_messages()?.is_empty());
1215        Ok(())
1216    }
1217
1218    /// A deadline handler that records each fire and can be told to fail.
1219    struct RecordingDeadlineHandler {
1220        calls: std::sync::Mutex<Vec<(WorkflowId, RunId)>>,
1221        fail: bool,
1222    }
1223
1224    impl RecordingDeadlineHandler {
1225        fn new(fail: bool) -> Self {
1226            Self {
1227                calls: std::sync::Mutex::new(Vec::new()),
1228                fail,
1229            }
1230        }
1231
1232        fn calls(&self) -> Result<Vec<(WorkflowId, RunId)>, TimerServiceError> {
1233            self.calls
1234                .lock()
1235                .map(|calls| calls.clone())
1236                .map_err(|error| TimerServiceError::Deadline(error.to_string()))
1237        }
1238    }
1239
1240    #[async_trait::async_trait]
1241    impl DeadlineHandler for RecordingDeadlineHandler {
1242        async fn on_deadline_elapsed(
1243            &self,
1244            workflow_id: WorkflowId,
1245            run_id: RunId,
1246        ) -> Result<(), DeadlineHandlerError> {
1247            self.calls
1248                .lock()
1249                .map_err(|error| DeadlineHandlerError(error.to_string()))?
1250                .push((workflow_id, run_id));
1251            if self.fail {
1252                Err(DeadlineHandlerError(
1253                    "deliberate handler failure".to_owned(),
1254                ))
1255            } else {
1256                Ok(())
1257            }
1258        }
1259    }
1260
1261    fn service_with_handler(
1262        handler: Arc<dyn DeadlineHandler>,
1263    ) -> (Arc<InMemoryStore>, Arc<FakeEngineHandle>, TimerService) {
1264        let concrete_store = Arc::new(InMemoryStore::default());
1265        let recorder_store: Arc<dyn WritableEventStore> = concrete_store.clone();
1266        let readable_store: Arc<dyn ReadableEventStore> = concrete_store.clone();
1267        let engine = Arc::new(FakeEngineHandle::recording_to(recorder_store));
1268        let service = TimerService::with_recorded_at(engine.clone(), readable_store, recorded_at)
1269            .with_deadline_handler(handler);
1270        (concrete_store, engine, service)
1271    }
1272
1273    /// A live reserved deadline fire is demuxed to the registered handler with
1274    /// the id-encoded run, and records NO `TimerFired` and delivers nothing.
1275    #[tokio::test]
1276    async fn deadline_fire_routes_to_handler_and_records_no_timer_fired()
1277    -> Result<(), TimerServiceError> {
1278        let run_id = RunId::new_v4();
1279        let deadline_id = deadline_timer_id(&run_id)
1280            .map_err(|error| TimerServiceError::Deadline(error.to_string()))?;
1281        let handler = Arc::new(RecordingDeadlineHandler::new(false));
1282        let (store, engine, service) = service_with_handler(handler.clone());
1283        let workflow_id = workflow_id();
1284        let fire_at = instant(120);
1285        engine.set_residency(
1286            workflow_id.clone(),
1287            WorkflowResidency::Resident(WorkflowProcessHandle::new(9)),
1288        )?;
1289        engine.record_workflow_event(
1290            &workflow_id,
1291            timer_started_event(&workflow_id, &deadline_id, 1),
1292        )?;
1293
1294        service
1295            .fire_timer(workflow_id.clone(), deadline_id.clone(), fire_at)
1296            .await?;
1297
1298        assert_eq!(handler.calls()?, vec![(workflow_id.clone(), run_id)]);
1299        assert_eq!(
1300            count_timer_fired(&history(&store, &workflow_id).await?, &deadline_id),
1301            0,
1302            "a deadline fire never records TimerFired"
1303        );
1304        assert!(engine.delivered_messages()?.is_empty());
1305        Ok(())
1306    }
1307
1308    /// A deadline fire with no handler registered is a typed error — never a
1309    /// silent generic fire.
1310    #[tokio::test]
1311    async fn deadline_fire_without_handler_is_typed_error() -> Result<(), TimerServiceError> {
1312        let run_id = RunId::new_v4();
1313        let deadline_id = deadline_timer_id(&run_id)
1314            .map_err(|error| TimerServiceError::Deadline(error.to_string()))?;
1315        let (store, engine, service) = service();
1316        let workflow_id = workflow_id();
1317        engine.record_workflow_event(
1318            &workflow_id,
1319            timer_started_event(&workflow_id, &deadline_id, 1),
1320        )?;
1321
1322        let result = service
1323            .fire_timer(workflow_id.clone(), deadline_id.clone(), instant(120))
1324            .await;
1325
1326        assert!(
1327            matches!(result, Err(TimerServiceError::Deadline(_))),
1328            "unhandled deadline fire must be a typed error, got {result:?}"
1329        );
1330        assert_eq!(
1331            count_timer_fired(&history(&store, &workflow_id).await?, &deadline_id),
1332            0
1333        );
1334        Ok(())
1335    }
1336
1337    /// A handler failure surfaces as a typed deadline error to the caller.
1338    #[tokio::test]
1339    async fn deadline_handler_failure_surfaces_as_typed_error() -> Result<(), TimerServiceError> {
1340        let run_id = RunId::new_v4();
1341        let deadline_id = deadline_timer_id(&run_id)
1342            .map_err(|error| TimerServiceError::Deadline(error.to_string()))?;
1343        let handler = Arc::new(RecordingDeadlineHandler::new(true));
1344        let (_store, engine, service) = service_with_handler(handler);
1345        let workflow_id = workflow_id();
1346        engine.record_workflow_event(
1347            &workflow_id,
1348            timer_started_event(&workflow_id, &deadline_id, 1),
1349        )?;
1350
1351        let result = service
1352            .fire_timer(workflow_id, deadline_id, instant(120))
1353            .await;
1354
1355        assert!(matches!(result, Err(TimerServiceError::Deadline(_))));
1356        Ok(())
1357    }
1358
1359    /// A fire the recorder refuses as a post-terminal late arrival records
1360    /// nothing AND delivers no wake. Mutation-sensitive: the timer is live so the
1361    /// pre-check passes and the fire reaches the recorder seam, which returns
1362    /// `RefusedTerminal`; delivering the mailbox wake regardless of that outcome
1363    /// would reschedule a terminated workflow and fail this test.
1364    #[tokio::test]
1365    async fn refused_terminal_fire_records_nothing_and_delivers_no_wake()
1366    -> Result<(), TimerServiceError> {
1367        let process = WorkflowProcessHandle::new(42);
1368        let (store, engine, service) = service();
1369        let workflow_id = workflow_id();
1370        let timer_id = timer_id();
1371        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1372        engine.record_workflow_event(
1373            &workflow_id,
1374            timer_started_event(&workflow_id, &timer_id, 1),
1375        )?;
1376        engine.refuse_next_record_as_terminal()?;
1377
1378        service
1379            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(130))
1380            .await?;
1381
1382        assert_eq!(
1383            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1384            0,
1385            "a refused fire records no TimerFired"
1386        );
1387        assert!(
1388            engine.delivered_messages()?.is_empty(),
1389            "a refused fire delivers no wake"
1390        );
1391        Ok(())
1392    }
1393
1394    /// Two services obtained separately but sharing ONE terminal-update
1395    /// coordinator (as the production bridge hands out) serialize a cancel and a
1396    /// fire of the same timer: exactly one terminal timer event is recorded, never
1397    /// both. A `Barrier` forces genuine overlap — both actors are released
1398    /// together after setup — and the loop runs each direction. Mutation-sensitive:
1399    /// a per-service coordinator would let both read the timer live and record a
1400    /// `TimerFired` AND a `TimerCancelled`.
1401    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1402    async fn shared_coordinator_serializes_cancel_and_fire_across_services()
1403    -> Result<(), TimerServiceError> {
1404        use dashmap::DashSet;
1405        use tokio::sync::Barrier;
1406
1407        for _ in 0..20 {
1408            let process = WorkflowProcessHandle::new(42);
1409            let concrete_store = Arc::new(InMemoryStore::default());
1410            let recorder_store: Arc<dyn WritableEventStore> = concrete_store.clone();
1411            let readable: Arc<dyn ReadableEventStore> = concrete_store.clone();
1412            let engine = Arc::new(FakeEngineHandle::recording_to(recorder_store));
1413            let coordinator = Arc::new(DashSet::new());
1414            let service_a =
1415                TimerService::with_recorded_at(engine.clone(), readable.clone(), recorded_at)
1416                    .with_terminal_updates(Arc::clone(&coordinator));
1417            let service_b =
1418                TimerService::with_recorded_at(engine.clone(), readable.clone(), recorded_at)
1419                    .with_terminal_updates(Arc::clone(&coordinator));
1420
1421            let workflow_id = workflow_id();
1422            let timer_id = timer_id();
1423            let fire_at = instant(200);
1424            engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1425            engine.record_workflow_event(
1426                &workflow_id,
1427                timer_started_event(&workflow_id, &timer_id, 1),
1428            )?;
1429
1430            let gate = Arc::new(Barrier::new(2));
1431            let (cancel_gate, fire_gate) = (Arc::clone(&gate), gate);
1432            let (cancel_wf, cancel_timer) = (workflow_id.clone(), timer_id.clone());
1433            let cancel = async move {
1434                cancel_gate.wait().await;
1435                service_a
1436                    .cancel(cancel_wf, cancel_timer, TimerCancelCause::WorkflowIntent)
1437                    .await
1438            };
1439            let (fire_wf, fire_timer) = (workflow_id.clone(), timer_id.clone());
1440            let fire = async move {
1441                fire_gate.wait().await;
1442                service_b.fire_timer(fire_wf, fire_timer, fire_at).await
1443            };
1444            let (cancel_result, fire_result) = tokio::join!(cancel, fire);
1445            cancel_result?;
1446            fire_result?;
1447
1448            let history = history(&concrete_store, &workflow_id).await?;
1449            let terminal_timer_events = history
1450                .iter()
1451                .filter(|event| {
1452                    matches!(
1453                        event,
1454                        Event::TimerFired { timer_id: recorded, .. }
1455                        | Event::TimerCancelled { timer_id: recorded, .. }
1456                            if recorded == &timer_id
1457                    )
1458                })
1459                .count();
1460            assert_eq!(
1461                terminal_timer_events, 1,
1462                "first-recorded wins across shared services: {history:#?}"
1463            );
1464        }
1465        Ok(())
1466    }
1467
1468    #[tokio::test]
1469    async fn firing_prior_run_timer_after_continue_as_new_is_noop() -> Result<(), TimerServiceError>
1470    {
1471        let process = WorkflowProcessHandle::new(42);
1472        let (store, engine, service) = service();
1473        let workflow_id = workflow_id();
1474        let timer_id = timer_id();
1475        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1476        // Run 1 started the timer; run 2's WorkflowStarted closes that segment.
1477        engine.record_workflow_event(
1478            &workflow_id,
1479            timer_started_event(&workflow_id, &timer_id, 1),
1480        )?;
1481        engine.record_workflow_event(&workflow_id, workflow_started_event(&workflow_id, 2))?;
1482
1483        service
1484            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(100))
1485            .await?;
1486
1487        assert_eq!(
1488            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1489            0
1490        );
1491        assert!(engine.delivered_messages()?.is_empty());
1492        Ok(())
1493    }
1494}