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, TimerRetirement};
7use chrono::{DateTime, Utc};
8use dashmap::DashSet;
9
10use crate::engine_seam::{
11    EngineHandle, EngineSeamError, RecordOutcome, RedeliveredFire, TimerWheelEntry,
12    WorkflowMailboxMessage, WorkflowResidency,
13};
14use crate::time::deadline::{DeadlineHandler, deadline_run_id, is_deadline_timer};
15
16/// The countable outcome of one consumed-row retirement attempt
17/// ([`TimerService::retire_consumed_row`]).
18///
19/// Distinguishing these is what lets the boot sweep's summary line measure
20/// what actually happened to the timer keyspace instead of counting calls:
21/// `retired` counts [`Self::Retired`] only.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub(crate) enum RetireAttempt {
24    /// The arming's row is durably gone (deleted now, or already absent).
25    Retired,
26    /// A replacement arming owns the key; its row was left standing.
27    Superseded,
28    /// The store refused the retirement; the row survives for a later fire
29    /// or sweep. Already logged with its cause at the warn site.
30    Failed,
31}
32
33/// Durable timer scheduling and wheel-fire handling.
34///
35/// The service owns the AT live path for timers. Workflow-issued `TimerStarted` events are recorded
36/// by AD's resume-live handoff before this service is called; this service persists only the durable
37/// timer row and later asynchronous arrival/cancellation history through the engine recorder seam.
38pub struct TimerService {
39    engine: Arc<dyn EngineHandle>,
40    store: Arc<dyn ReadableEventStore>,
41    recorded_at: fn() -> DateTime<Utc>,
42    /// Per-timer first-recorded-wins coordinator shared across EVERY service
43    /// instance the production bridge hands out. Cancel and fire obtain
44    /// SEPARATE service instances (the live wheel constructs one, `Engine::cancel`
45    /// another), so a per-instance set would not exclude them; a shared `Arc`
46    /// makes a cancel and a fire for the same timer mutually exclude — the
47    /// #cancel-vs-fire race the review flagged. Bare unit-test services get their
48    /// own set, which is correct for a single-instance test.
49    terminal_updates: Arc<DashSet<(WorkflowId, TimerId)>>,
50    /// Engine-registered handler for reserved `deadline:{run_id}` fires.
51    ///
52    /// `None` on a bare service (unit tests): a deadline fire is then a typed
53    /// error, never a silent generic `TimerFired`. The production bridge sets it
54    /// via [`Self::with_deadline_handler`] when constructing the service.
55    deadline_handler: Option<Arc<dyn DeadlineHandler>>,
56}
57
58struct TerminalUpdateSlot<'a> {
59    terminal_updates: &'a DashSet<(WorkflowId, TimerId)>,
60    key: (WorkflowId, TimerId),
61}
62
63impl Drop for TerminalUpdateSlot<'_> {
64    fn drop(&mut self) {
65        self.terminal_updates.remove(&self.key);
66    }
67}
68
69/// Errors returned by [`TimerService`].
70#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
71pub enum TimerServiceError {
72    /// Durable timer storage or history inspection failed.
73    #[error("timer store operation failed: {0}")]
74    Store(#[from] StoreError),
75
76    /// Engine seam operation failed.
77    #[error("timer engine operation failed: {0}")]
78    Engine(#[from] EngineSeamError),
79
80    /// A reserved `deadline:{run_id}` timer fired but could not be routed to a
81    /// registered deadline handler (or the handler failed).
82    ///
83    /// Never a silent generic fire: a deadline timer that reaches
84    /// [`TimerService::fire_timer`] without a handler — or whose handler errors —
85    /// surfaces here so the caller (live wheel or boot/adoption sweep) observes the
86    /// failure rather than recording a spurious `TimerFired`.
87    #[error("deadline timer routing failed: {0}")]
88    Deadline(String),
89}
90
91impl TimerService {
92    /// Creates a durable timer service from the engine seam and timer store.
93    #[must_use]
94    pub fn new(engine: Arc<dyn EngineHandle>, store: Arc<dyn ReadableEventStore>) -> Self {
95        Self::with_recorded_at(engine, store, Utc::now)
96    }
97
98    /// Creates a durable timer service with an injected history timestamp source.
99    #[must_use]
100    pub fn with_recorded_at(
101        engine: Arc<dyn EngineHandle>,
102        store: Arc<dyn ReadableEventStore>,
103        recorded_at: fn() -> DateTime<Utc>,
104    ) -> Self {
105        Self {
106            engine,
107            store,
108            recorded_at,
109            terminal_updates: Arc::new(DashSet::new()),
110            deadline_handler: None,
111        }
112    }
113
114    /// Replaces this service's per-timer terminal-update coordinator with a
115    /// shared one, returning the service for chaining.
116    ///
117    /// The production timer bridge owns ONE coordinator and hands it to every
118    /// [`TimerService`] it constructs, so a cancel obtained from one service and
119    /// a fire obtained from another still serialize per timer (first-recorded
120    /// wins). Without this, each service would guard against itself only.
121    #[must_use]
122    pub fn with_terminal_updates(
123        mut self,
124        terminal_updates: Arc<DashSet<(WorkflowId, TimerId)>>,
125    ) -> Self {
126        self.terminal_updates = terminal_updates;
127        self
128    }
129
130    /// Registers the engine-side deadline handler for reserved `deadline:{run_id}`
131    /// fires, returning the service for chaining.
132    ///
133    /// The production timer bridge calls this so both the live wheel and
134    /// the boot/adoption sweep (which share [`Self::fire_timer`]) demux a deadline fire to
135    /// the handler instead of recording a generic `TimerFired`.
136    #[must_use]
137    pub fn with_deadline_handler(mut self, handler: Arc<dyn DeadlineHandler>) -> Self {
138        self.deadline_handler = Some(handler);
139        self
140    }
141
142    /// Schedules a durable timer and arms the live wheel when the workflow is resident.
143    ///
144    /// The operation persists the durable timer row and arms the wheel when needed. The
145    /// command-issued `TimerStarted` recorder event is appended by AD's resume-live handoff before
146    /// AE/AT reaches this service, so this method deliberately does not record it again;
147    /// `armed_seq` is that recorded event's workflow-history sequence and becomes the row's
148    /// identity component ([`aion_store::TimerEntry::armed_seq`]), so retirement can tell this
149    /// arming from a re-arm to the identical instant.
150    ///
151    /// # Errors
152    ///
153    /// Returns [`TimerServiceError`] when durable storage, recording, residency resolution, or wheel
154    /// arming fails.
155    pub async fn schedule(
156        &self,
157        workflow_id: WorkflowId,
158        timer_id: TimerId,
159        fire_at: DateTime<Utc>,
160        armed_seq: u64,
161    ) -> Result<(), TimerServiceError> {
162        self.store
163            .schedule_timer(&workflow_id, &timer_id, fire_at, armed_seq)
164            .await?;
165
166        if let WorkflowResidency::Resident(process) = self.engine.resolve_workflow(&workflow_id)? {
167            self.engine.arm_timer(TimerWheelEntry {
168                process,
169                timer_id,
170                fire_at,
171            })?;
172        }
173
174        Ok(())
175    }
176
177    /// Cancels a durable timer that has not already reached a terminal timer state.
178    ///
179    /// Already-fired and already-cancelled timers are treated as idempotent no-ops. For active
180    /// resident timers the live wheel is disarmed through the engine seam before `TimerCancelled` is
181    /// recorded through the workflow recorder seam. Non-resident timers still record the cancellation
182    /// so recovery/replay can suppress a later fire.
183    ///
184    /// Anonymous timers are accepted: authors can never address one (the SDK's `cancel_timer`
185    /// takes a `TimerRef` minted by `start_timer`, which is always named), but the engine settles
186    /// `with_timeout` scope deadlines — anonymous by construction — through this first-recorded-wins
187    /// race against [`Self::fire_timer`].
188    ///
189    /// # Errors
190    ///
191    /// Returns [`TimerServiceError`] when history inspection, residency resolution, wheel disarming,
192    /// or event recording fails.
193    pub async fn cancel(
194        &self,
195        workflow_id: WorkflowId,
196        timer_id: TimerId,
197        cause: TimerCancelCause,
198    ) -> Result<(), TimerServiceError> {
199        let key = (workflow_id.clone(), timer_id.clone());
200        let terminal_update_slot = self.wait_for_terminal_update_slot(key).await;
201
202        let result = self.cancel_guarded(workflow_id, timer_id, cause).await;
203        drop(terminal_update_slot);
204        result
205    }
206
207    async fn cancel_guarded(
208        &self,
209        workflow_id: WorkflowId,
210        timer_id: TimerId,
211        cause: TimerCancelCause,
212    ) -> Result<(), TimerServiceError> {
213        // One history read answers both questions this path has: is the timer
214        // live (last-event-wins in the active segment), and which arming —
215        // which `(fire_at, armed_seq)` identity — is being cancelled, so the
216        // durable row for exactly that arming can be retired once the cancel
217        // records.
218        let history = self.store.read_history(&workflow_id).await?;
219        if !matches!(
220            timer_disposition_in_active_segment(&history, &timer_id),
221            TimerDisposition::Live
222        ) {
223            return Ok(());
224        }
225        let arming = last_recorded_arming(&history, &timer_id);
226
227        if let WorkflowResidency::Resident(process) = self.engine.resolve_workflow(&workflow_id)? {
228            self.engine.disarm_timer(process, &timer_id)?;
229        }
230
231        let event = Event::TimerCancelled {
232            envelope: self.next_envelope(&workflow_id).await?,
233            timer_id: timer_id.clone(),
234            cause,
235        };
236        self.engine.record_workflow_event(&workflow_id, event)?;
237
238        // The cancel is durably recorded: the arming is consumed and its row
239        // retires. (`Live` implies a `TimerStarted` was seen, so the arming's
240        // identity is present; the guard stands in for an unwrap.)
241        if let Some((fire_at, armed_seq)) = arming {
242            self.retire_consumed_row(&workflow_id, &timer_id, fire_at, armed_seq)
243                .await;
244        }
245
246        Ok(())
247    }
248
249    /// Handles a live timer-wheel fire.
250    ///
251    /// `TimerFired` is recorded before any mailbox delivery. If the workflow is no longer resident,
252    /// the recorded event remains the durable observation that replay/recovery can surface later.
253    /// A fire whose `TimerFired` is ALREADY the timer's last recorded event (an earlier append
254    /// landed while its acknowledgement was lost — aion#145) is not a no-op: for a resident
255    /// workflow the fire re-enters the recorder seam, which reconciles the recorder's sequence
256    /// forward without appending, and the owed mailbox wake is delivered.
257    ///
258    /// # Errors
259    ///
260    /// Returns [`TimerServiceError`] when history inspection, recording, residency resolution, or
261    /// live mailbox delivery fails.
262    pub async fn fire_timer(
263        &self,
264        workflow_id: WorkflowId,
265        timer_id: TimerId,
266        fire_at: DateTime<Utc>,
267    ) -> Result<(), TimerServiceError> {
268        let key = (workflow_id.clone(), timer_id.clone());
269        let terminal_update_slot = self.wait_for_terminal_update_slot(key).await;
270
271        let result = self
272            .fire_timer_guarded(workflow_id, timer_id, fire_at)
273            .await;
274        drop(terminal_update_slot);
275        result
276    }
277
278    async fn wait_for_terminal_update_slot(
279        &self,
280        key: (WorkflowId, TimerId),
281    ) -> TerminalUpdateSlot<'_> {
282        loop {
283            if self.terminal_updates.insert(key.clone()) {
284                return TerminalUpdateSlot {
285                    terminal_updates: self.terminal_updates.as_ref(),
286                    key,
287                };
288            }
289            tokio::task::yield_now().await;
290        }
291    }
292
293    async fn fire_timer_guarded(
294        &self,
295        workflow_id: WorkflowId,
296        timer_id: TimerId,
297        fire_at: DateTime<Utc>,
298    ) -> Result<(), TimerServiceError> {
299        // WHY the timer is not live decides what a fire still owes (aion#145).
300        // A cancelled or absent timer owes nothing; a timer whose last event is
301        // already `TimerFired` is the ack-loss shape — the durable record
302        // landed while the recording call's acknowledgement was lost, so the
303        // mailbox wake (and the recorder's sequence repair) may still be owed.
304        // This service-layer read is the cheap gate that keeps genuine no-ops
305        // (cancelled/absent/retired) out of the recorder seam; the bridge
306        // re-checks the same fact under the recorder lock, and that check is
307        // the authoritative one.
308        let history = self.store.read_history(&workflow_id).await?;
309        // The arming's identity for this fire's row retirement: the LAST
310        // recorded `TimerStarted` for the id anywhere in history is the last
311        // writer of the timer's single durable row (rows are keyed per timer
312        // id, not per segment). `0` when no arming was ever recorded — the
313        // identity an arming without a `TimerStarted` writes its row with.
314        let armed_seq = last_recorded_arming(&history, &timer_id).map_or(0, |(_, seq)| seq);
315        match timer_disposition_in_active_segment(&history, &timer_id) {
316            TimerDisposition::Live => {}
317            TimerDisposition::Fired if !is_deadline_timer(&timer_id) => {
318                return self
319                    .redeliver_owed_wake(workflow_id, timer_id, fire_at, armed_seq)
320                    .await
321                    .map(|_| ());
322            }
323            // A retired deadline (which never records `TimerFired` through this
324            // path), a cancelled timer, or a timer with no event in the active
325            // segment: nothing is owed, exactly as before the #145 fix — and
326            // the arming this fire was armed for is consumed, so its durable
327            // row retires (identity-conditional: a re-armed row survives).
328            TimerDisposition::Fired | TimerDisposition::Cancelled | TimerDisposition::Absent => {
329                self.retire_consumed_row(&workflow_id, &timer_id, fire_at, armed_seq)
330                    .await;
331                return Ok(());
332            }
333        }
334
335        // Demux a reserved workflow-deadline timer out of the generic
336        // record-then-deliver path (both the live wheel and the boot/adoption sweep reach
337        // here): it never records a `TimerFired` — the registered handler records
338        // `WorkflowTimedOut` and tears the run down instead.
339        if is_deadline_timer(&timer_id) {
340            self.fire_deadline(workflow_id.clone(), timer_id.clone())
341                .await?;
342            // The handler settled the deadline (recorded `WorkflowTimedOut`,
343            // or lost cleanly to a concurrent terminal under the recorder
344            // lock): either way this arming is consumed and its row retires.
345            // A handler error above leaves the row for the next boot or
346            // adoption sweep.
347            self.retire_consumed_row(&workflow_id, &timer_id, fire_at, armed_seq)
348                .await;
349            return Ok(());
350        }
351
352        let event = Event::TimerFired {
353            envelope: self.next_envelope(&workflow_id).await?,
354            timer_id: timer_id.clone(),
355        };
356        // Deliver the mailbox wake only when the durable record exists. The
357        // recorder seam refuses a late fire that lands after the run terminated
358        // (`RefusedTerminal`), recording nothing; waking the process then would
359        // reschedule a workflow that has already reached its terminal — the
360        // post-terminal wake this gate closes. `AlreadyRecorded` is the
361        // opposite case: the record exists (an earlier acknowledgement-lost
362        // append landed), so the wake is owed exactly as for `Recorded`.
363        match self.engine.record_workflow_event(&workflow_id, event)? {
364            // Both refusals mean the same thing to a timer: the run holds a
365            // terminal, nothing was recorded, and no wake may reschedule it.
366            // They are separate variants because the CADENCE sweep responds to
367            // them differently (a death alarms, a retirement does not); a
368            // timer has no such distinction to draw.
369            RecordOutcome::RefusedTerminal | RecordOutcome::RefusedRetired => {
370                // The run reached its terminal: this fire can never record,
371                // so the arming is moot forever and its row retires — without
372                // this, a terminal workflow's rows survive every boot. A
373                // RETIRED loop is terminal for this purpose too, so it retires
374                // its row by the same argument rather than leaking one.
375                self.retire_consumed_row(&workflow_id, &timer_id, fire_at, armed_seq)
376                    .await;
377                return Ok(());
378            }
379            RecordOutcome::Recorded | RecordOutcome::AlreadyRecorded => {}
380        }
381
382        if let WorkflowResidency::Resident(process) = self.engine.resolve_workflow(&workflow_id)? {
383            self.engine.deliver_workflow_message(
384                process,
385                WorkflowMailboxMessage::TimerFired {
386                    timer_id: timer_id.clone(),
387                    fire_at,
388                },
389            )?;
390        }
391
392        // The fire is durably recorded (and any owed wake delivered): the
393        // arming is consumed and its row retires. Ordered after delivery so a
394        // delivery error leaves the row for the next boot or adoption
395        // sweep's redelivery.
396        self.retire_consumed_row(&workflow_id, &timer_id, fire_at, armed_seq)
397            .await;
398
399        Ok(())
400    }
401
402    /// Completes a fire whose durable `TimerFired` already exists but whose
403    /// delivery — and possibly the recorder's own sequence advance — was lost
404    /// (aion#145): the incident's ack-lost append, or a wake that failed after
405    /// a fully recorded fire. Reached from the live wheel's re-fire and from
406    /// the boot/adoption sweep's disposition of surviving `Fired` rows.
407    ///
408    /// Only a RESIDENT workflow owes a live wake, and only its still-held
409    /// Recorder can be carrying the stale-low sequence the ack loss leaves
410    /// behind: a non-resident workflow's replay on residency restore rebuilds
411    /// its recorder from the durable head and consumes the recorded fire, so
412    /// for it this is a clean retire-only, exactly as before the fix.
413    ///
414    /// For the resident case the decision is made by the recorder seam UNDER
415    /// THE RECORDER LOCK ([`EngineHandle::record_redelivered_timer_fire`]):
416    /// the wake is owed only while the timer's last event is still the
417    /// recorded fire, and that is also where the recorder's in-memory
418    /// sequence is reconciled forward to the durable head — without that
419    /// repair the woken workflow's next append would mint a stale sequence
420    /// and die on `SequenceConflict`, wedging the run one event later. The
421    /// seam NEVER appends: a timer re-armed or cancelled since this caller's
422    /// observation answers `NotOwed` instead of minting a premature
423    /// `TimerFired` for the new arming. That no-append contract is also why
424    /// this path takes no terminal-update slot — it cannot race a cancel for
425    /// terminal-event ordering, and the wake itself is a pure wake (the
426    /// suspended await re-resolves from history), so a duplicate or stale
427    /// delivery is harmless by design.
428    ///
429    /// Returns whether a live wake was delivered, paired with what happened
430    /// to the arming's durable row, so the sweep's counters measure real
431    /// deletions rather than attempts.
432    pub(crate) async fn redeliver_owed_wake(
433        &self,
434        workflow_id: WorkflowId,
435        timer_id: TimerId,
436        fire_at: DateTime<Utc>,
437        armed_seq: u64,
438    ) -> Result<(bool, RetireAttempt), TimerServiceError> {
439        let WorkflowResidency::Resident(process) = self.engine.resolve_workflow(&workflow_id)?
440        else {
441            // Non-resident: the durable fire already exists and no live wake
442            // is owed — the arming is consumed, its row retires. This is the
443            // arm the 2026-08-24 boot walked 1,434 times without ever
444            // emptying: the row survived every redelivery.
445            let row = self
446                .retire_consumed_row(&workflow_id, &timer_id, fire_at, armed_seq)
447                .await;
448            return Ok((false, row));
449        };
450
451        match self
452            .engine
453            .record_redelivered_timer_fire(&workflow_id, &timer_id)?
454        {
455            // The run reached a terminal, or the timer moved on (re-armed or
456            // cancelled) since the fire recorded: the recorded fire is inert
457            // history and no wake may follow. Either way this arming is
458            // consumed and its row retires — identity-conditionally, so a
459            // re-armed replacement row is never touched.
460            RedeliveredFire::RefusedTerminal | RedeliveredFire::NotOwed => {
461                let row = self
462                    .retire_consumed_row(&workflow_id, &timer_id, fire_at, armed_seq)
463                    .await;
464                Ok((false, row))
465            }
466            RedeliveredFire::WakeOwed => {
467                self.engine.deliver_workflow_message(
468                    process,
469                    WorkflowMailboxMessage::TimerFired {
470                        timer_id: timer_id.clone(),
471                        fire_at,
472                    },
473                )?;
474                tracing::info!(
475                    %workflow_id,
476                    %timer_id,
477                    "timer fire was already durably recorded; delivered the owed mailbox wake"
478                );
479                let row = self
480                    .retire_consumed_row(&workflow_id, &timer_id, fire_at, armed_seq)
481                    .await;
482                Ok((true, row))
483            }
484        }
485    }
486
487    /// Route a live reserved-deadline fire to the registered handler.
488    ///
489    /// Called only for a `deadline:{run_id}` timer that passed the liveness
490    /// guard. A missing handler or an unparseable run id is a typed
491    /// [`TimerServiceError::Deadline`] — never a silent generic fire — and the
492    /// handler's own failure is surfaced the same way. The handler re-checks the
493    /// run's terminal under the recorder lock, so it loses cleanly to a
494    /// concurrent completion.
495    async fn fire_deadline(
496        &self,
497        workflow_id: WorkflowId,
498        timer_id: TimerId,
499    ) -> Result<(), TimerServiceError> {
500        let handler = self.deadline_handler.as_ref().ok_or_else(|| {
501            TimerServiceError::Deadline(format!(
502                "no deadline handler registered for {timer_id} on workflow {workflow_id}"
503            ))
504        })?;
505        let run_id = deadline_run_id(&timer_id).ok_or_else(|| {
506            TimerServiceError::Deadline(format!(
507                "malformed deadline timer {timer_id} on workflow {workflow_id}"
508            ))
509        })?;
510        handler
511            .on_deadline_elapsed(workflow_id, run_id)
512            .await
513            .map_err(|error| TimerServiceError::Deadline(error.to_string()))
514    }
515
516    /// Retire the durable row for a CONSUMED arming, warning instead of
517    /// failing: the row's survival is the redelivery-safe pre-retirement
518    /// status quo (the next boot or adoption sweep walks it again,
519    /// wake-only), while failing a
520    /// fire or cancel that already durably recorded — or aborting startup
521    /// recovery — over row housekeeping would invert the severities. The
522    /// `(fire_at, armed_seq)` condition keeps a re-armed timer's replacement
523    /// row untouched — even a replacement re-armed to the identical instant.
524    ///
525    /// The outcome is REPORTED, not swallowed: the sweep's counters separate
526    /// rows actually retired from rows a replacement arming superseded and
527    /// from store refusals, so `retired=N` in the sweep's summary measures
528    /// deletions, never attempts. Live fire/cancel callers may ignore the
529    /// answer — for them the next boot or adoption sweep is the healer
530    /// either way.
531    ///
532    /// `pub(crate)`: the boot/adoption recovery sweep retires consumed rows in
533    /// bulk through this same seam, so warn-never-fail lives in one place.
534    pub(crate) async fn retire_consumed_row(
535        &self,
536        workflow_id: &WorkflowId,
537        timer_id: &TimerId,
538        fire_at: DateTime<Utc>,
539        armed_seq: u64,
540    ) -> RetireAttempt {
541        match self
542            .store
543            .retire_timer(workflow_id, timer_id, fire_at, armed_seq)
544            .await
545        {
546            Ok(TimerRetirement::Retired) => RetireAttempt::Retired,
547            Ok(TimerRetirement::Superseded) => RetireAttempt::Superseded,
548            Err(error) => {
549                tracing::warn!(
550                    %workflow_id,
551                    %timer_id,
552                    %fire_at,
553                    %error,
554                    "consumed timer row could not be retired; the row survives until a \
555                     later fire or boot/adoption sweep retires it"
556                );
557                RetireAttempt::Failed
558            }
559        }
560    }
561
562    async fn next_envelope(&self, workflow_id: &WorkflowId) -> Result<EventEnvelope, StoreError> {
563        let history = self.store.read_history(workflow_id).await?;
564        let seq = history.iter().map(Event::seq).max().unwrap_or_default() + 1;
565        Ok(EventEnvelope {
566            seq,
567            recorded_at: (self.recorded_at)(),
568            workflow_id: workflow_id.clone(),
569        })
570    }
571}
572
573/// The `fire_at` of the timer's current arming in the active run segment, by
574/// the same last-event-wins model as [`live_timers_in_active_segment`]: a
575/// `TimerStarted` (re)arms it with its `fire_at`, a `TimerFired`/`TimerCancelled`
576/// clears it. `None` when the timer is not currently armed — the caller uses
577/// this to retire the durable row for exactly the arming it consumed, never a
578/// replacement's.
579///
580/// `pub(crate)`: the boot/adoption sweep compares a due row's `fire_at`
581/// against this recorded arming before firing (round-2 F1) — a row that
582/// disagrees with history is stale and retires instead of firing.
583pub(crate) fn armed_fire_at_in_active_segment(
584    history: &[Event],
585    timer_id: &TimerId,
586) -> Option<DateTime<Utc>> {
587    let mut armed = None;
588    for event in active_segment(history) {
589        match event {
590            Event::TimerStarted {
591                timer_id: id,
592                fire_at,
593                ..
594            } if id == timer_id => {
595                armed = Some(*fire_at);
596            }
597            Event::TimerFired { timer_id: id, .. } | Event::TimerCancelled { timer_id: id, .. }
598                if id == timer_id =>
599            {
600                armed = None;
601            }
602            _ => {}
603        }
604    }
605    armed
606}
607
608/// The LAST recorded arming for `timer_id` anywhere in `history` — its
609/// `(fire_at, TimerStarted seq)` identity — or `None` when no arming was ever
610/// recorded.
611///
612/// This is the ROW-IDENTITY view, deliberately whole-history where the
613/// disposition helpers are active-segment: the durable timer row is keyed per
614/// timer id (not per run segment), so the last `TimerStarted` anywhere is the
615/// last writer of that row, whatever segment it lives in. Callers use it to
616/// retire exactly the row the consumed arming wrote — never a replacement's,
617/// even one re-armed to the identical instant (the seq differs).
618fn last_recorded_arming(history: &[Event], timer_id: &TimerId) -> Option<(DateTime<Utc>, u64)> {
619    history.iter().rev().find_map(|event| match event {
620        Event::TimerStarted {
621            envelope,
622            timer_id: id,
623            fire_at,
624        } if id == timer_id => Some((*fire_at, envelope.seq)),
625        _ => None,
626    })
627}
628
629/// The live timer ids in the workflow's active run segment, by last-event-wins.
630///
631/// Scans forward from the latest `WorkflowStarted` (the active run segment) and
632/// lets the *last* event for each timer id decide its liveness: a `TimerStarted`
633/// (re)arms it, a `TimerFired`/`TimerCancelled` retires it. This means a *named*
634/// timer that fired or was cancelled and then re-armed within the same segment
635/// (`TimerStarted(T), TimerFired(T), TimerStarted(T)`) is correctly reported live
636/// again, rather than judged terminal forever by the earlier terminal event.
637///
638/// Start order is preserved and a timer id started more than once is deduped, so
639/// the result is a stable, history-derived (and therefore replay-deterministic)
640/// view of which timers are outstanding. This is the single liveness model shared
641/// by the per-id views ([`timer_disposition_in_active_segment`] on the fire and
642/// cancel paths, [`armed_fire_at_in_active_segment`] for row retirement) and the
643/// cancel-path enumerator in `engine::api`, so they cannot diverge.
644pub(crate) fn live_timers_in_active_segment(history: &[Event]) -> Vec<TimerId> {
645    let mut live: Vec<TimerId> = Vec::new();
646    for event in active_segment(history) {
647        match event {
648            Event::TimerStarted { timer_id, .. } if !live.contains(timer_id) => {
649                live.push(timer_id.clone());
650            }
651            Event::TimerFired { timer_id, .. } | Event::TimerCancelled { timer_id, .. } => {
652                live.retain(|id| id != timer_id);
653            }
654            _ => {}
655        }
656    }
657    live
658}
659
660/// The workflow's active run segment: everything from the latest
661/// `WorkflowStarted` (the whole history when none is recorded — bare fixtures
662/// and coordinator histories).
663///
664/// The single segment anchor shared by [`live_timers_in_active_segment`] and
665/// [`timer_disposition_in_active_segment`], so the enumerating and the per-id
666/// view of the liveness model cannot disagree about where the active run
667/// begins.
668fn active_segment(history: &[Event]) -> &[Event] {
669    let segment_start = history
670        .iter()
671        .rposition(|event| matches!(event, Event::WorkflowStarted { .. }))
672        .unwrap_or(0);
673    &history[segment_start..]
674}
675
676/// The recorded fate of ONE timer in the workflow's active run segment, by the
677/// same last-event-wins rule as [`live_timers_in_active_segment`].
678///
679/// [`live_timers_in_active_segment`] can only answer "live or not"; the fire
680/// path needs to know WHY a timer is not live (aion#145): a timer whose last
681/// event is `TimerFired` already has its durable record — the fire's mailbox
682/// wake may still be owed — while a cancelled or absent timer owes nothing.
683/// This is the per-id view of the SAME model, not a fork of it: same segment
684/// anchor ([`active_segment`]), same last-event-wins traversal, so for every
685/// history and timer id, `Live` here if and only if the id appears in
686/// [`live_timers_in_active_segment`].
687#[derive(Clone, Copy, Debug, Eq, PartialEq)]
688pub(crate) enum TimerDisposition {
689    /// The timer's last event in the active segment is `TimerStarted`: live.
690    Live,
691    /// The timer's last event in the active segment is `TimerFired`: the
692    /// durable fire record exists (its mailbox wake may or may not have been
693    /// delivered — history cannot tell, which is why delivery is a pure,
694    /// duplicate-safe wake).
695    Fired,
696    /// The timer's last event in the active segment is `TimerCancelled`.
697    Cancelled,
698    /// The timer has no event in the active segment (never started there, or
699    /// started only in a prior, closed run segment).
700    Absent,
701}
702
703/// Computes [`TimerDisposition`] for `timer_id` over `history`.
704pub(crate) fn timer_disposition_in_active_segment(
705    history: &[Event],
706    timer_id: &TimerId,
707) -> TimerDisposition {
708    let mut disposition = TimerDisposition::Absent;
709    for event in active_segment(history) {
710        match event {
711            Event::TimerStarted { timer_id: id, .. } if id == timer_id => {
712                disposition = TimerDisposition::Live;
713            }
714            Event::TimerFired { timer_id: id, .. } if id == timer_id => {
715                disposition = TimerDisposition::Fired;
716            }
717            Event::TimerCancelled { timer_id: id, .. } if id == timer_id => {
718                disposition = TimerDisposition::Cancelled;
719            }
720            _ => {}
721        }
722    }
723    disposition
724}
725
726#[cfg(test)]
727mod tests {
728    use std::sync::Arc;
729
730    use aion_core::{Event, EventEnvelope, RunId, TimerCancelCause, TimerId, WorkflowId};
731    use aion_store::{InMemoryStore, ReadableEventStore, StoreError, WritableEventStore};
732    use chrono::{DateTime, Utc};
733
734    use super::{
735        TimerDisposition, TimerService, TimerServiceError, live_timers_in_active_segment,
736        timer_disposition_in_active_segment,
737    };
738    use crate::engine_seam::test_support::{
739        DeliveredWorkflowMessage, FakeEngineHandle, FakeEngineOperation,
740    };
741    use crate::engine_seam::{
742        EngineHandle, TimerWheelEntry, WorkflowProcessHandle, WorkflowResidency,
743    };
744    use crate::time::deadline::{DeadlineHandler, DeadlineHandlerError, deadline_timer_id};
745
746    fn instant(offset_seconds: i64) -> DateTime<Utc> {
747        DateTime::from_timestamp(1_700_000_000 + offset_seconds, 0).unwrap_or_default()
748    }
749
750    fn workflow_id() -> WorkflowId {
751        WorkflowId::new_v4()
752    }
753
754    fn timer_id() -> TimerId {
755        TimerId::anonymous(7)
756    }
757
758    fn service() -> (Arc<InMemoryStore>, Arc<FakeEngineHandle>, TimerService) {
759        let concrete_store = Arc::new(InMemoryStore::default());
760        let recorder_store: Arc<dyn WritableEventStore> = concrete_store.clone();
761        let readable_store: Arc<dyn ReadableEventStore> = concrete_store.clone();
762        let engine = Arc::new(FakeEngineHandle::recording_to(recorder_store));
763        let service = TimerService::with_recorded_at(engine.clone(), readable_store, recorded_at);
764        (concrete_store, engine, service)
765    }
766
767    fn recorded_at() -> DateTime<Utc> {
768        instant(1)
769    }
770
771    async fn history(
772        store: &InMemoryStore,
773        workflow_id: &WorkflowId,
774    ) -> Result<Vec<Event>, StoreError> {
775        store.read_history(workflow_id).await
776    }
777
778    fn count_timer_fired(events: &[Event], timer_id: &TimerId) -> usize {
779        events
780            .iter()
781            .filter(|event| {
782                matches!(event, Event::TimerFired { timer_id: recorded, .. } if recorded == timer_id)
783            })
784            .count()
785    }
786
787    fn timer_started_event(workflow_id: &WorkflowId, timer_id: &TimerId, seq: u64) -> Event {
788        Event::TimerStarted {
789            envelope: EventEnvelope {
790                seq,
791                recorded_at: instant(0),
792                workflow_id: workflow_id.clone(),
793            },
794            timer_id: timer_id.clone(),
795            fire_at: instant(5),
796        }
797    }
798
799    fn workflow_started_event(workflow_id: &WorkflowId, seq: u64) -> Event {
800        Event::WorkflowStarted {
801            envelope: EventEnvelope {
802                seq,
803                recorded_at: instant(0),
804                workflow_id: workflow_id.clone(),
805            },
806            workflow_type: "fixture".to_owned(),
807            input: aion_core::Payload::new(aion_core::ContentType::Json, b"null".to_vec()),
808            run_id: aion_core::RunId::new_v4(),
809            parent_run_id: None,
810            parent_workflow_id: None,
811            package_version: aion_core::PackageVersion::new("a".repeat(64)),
812        }
813    }
814
815    fn timer_fired_event(workflow_id: &WorkflowId, timer_id: &TimerId, seq: u64) -> Event {
816        Event::TimerFired {
817            envelope: EventEnvelope {
818                seq,
819                recorded_at: instant(0),
820                workflow_id: workflow_id.clone(),
821            },
822            timer_id: timer_id.clone(),
823        }
824    }
825
826    fn timer_cancelled_event(workflow_id: &WorkflowId, timer_id: &TimerId, seq: u64) -> Event {
827        Event::TimerCancelled {
828            cause: TimerCancelCause::WorkflowIntent,
829            envelope: EventEnvelope {
830                seq,
831                recorded_at: instant(0),
832                workflow_id: workflow_id.clone(),
833            },
834            timer_id: timer_id.clone(),
835        }
836    }
837
838    fn make_named(name: &str) -> TimerId {
839        // The name is a non-empty literal, so construction never fails; the
840        // anonymous fallback only exists to keep the helper total without an
841        // `unwrap`/`expect` (disallowed by clippy in this crate).
842        TimerId::named(name).unwrap_or_else(|_| TimerId::anonymous(0))
843    }
844
845    fn named_timer_id() -> TimerId {
846        make_named("review-deadline")
847    }
848
849    // --- `live_timers_in_active_segment` / timer-disposition semantics ---
850
851    #[test]
852    fn started_timer_is_live() {
853        let workflow_id = workflow_id();
854        let timer_id = named_timer_id();
855        let history = vec![
856            workflow_started_event(&workflow_id, 0),
857            timer_started_event(&workflow_id, &timer_id, 1),
858        ];
859        assert_eq!(live_timers_in_active_segment(&history), vec![timer_id]);
860    }
861
862    #[test]
863    fn started_then_fired_timer_is_dead() {
864        let workflow_id = workflow_id();
865        let timer_id = named_timer_id();
866        let history = vec![
867            workflow_started_event(&workflow_id, 0),
868            timer_started_event(&workflow_id, &timer_id, 1),
869            timer_fired_event(&workflow_id, &timer_id, 2),
870        ];
871        assert!(live_timers_in_active_segment(&history).is_empty());
872    }
873
874    #[test]
875    fn started_then_cancelled_timer_is_dead() {
876        let workflow_id = workflow_id();
877        let timer_id = named_timer_id();
878        let history = vec![
879            workflow_started_event(&workflow_id, 0),
880            timer_started_event(&workflow_id, &timer_id, 1),
881            timer_cancelled_event(&workflow_id, &timer_id, 2),
882        ];
883        assert!(live_timers_in_active_segment(&history).is_empty());
884    }
885
886    #[test]
887    fn restarted_named_timer_after_fire_is_live() {
888        // The bug fix: a named timer that fired then was re-armed in the same run
889        // segment must be live again (last-event-wins), not judged terminal forever
890        // by the earlier `TimerFired`.
891        let workflow_id = workflow_id();
892        let timer_id = named_timer_id();
893        let history = vec![
894            workflow_started_event(&workflow_id, 0),
895            timer_started_event(&workflow_id, &timer_id, 1),
896            timer_fired_event(&workflow_id, &timer_id, 2),
897            timer_started_event(&workflow_id, &timer_id, 3),
898        ];
899        assert_eq!(
900            live_timers_in_active_segment(&history),
901            vec![timer_id],
902            "a re-armed named timer is live again"
903        );
904    }
905
906    #[test]
907    fn restarted_named_timer_after_cancel_is_live() {
908        let workflow_id = workflow_id();
909        let timer_id = named_timer_id();
910        let history = vec![
911            workflow_started_event(&workflow_id, 0),
912            timer_started_event(&workflow_id, &timer_id, 1),
913            timer_cancelled_event(&workflow_id, &timer_id, 2),
914            timer_started_event(&workflow_id, &timer_id, 3),
915        ];
916        assert_eq!(live_timers_in_active_segment(&history), vec![timer_id]);
917    }
918
919    #[test]
920    fn prior_run_segment_timer_is_not_live() {
921        // A timer started in a run segment that a later `WorkflowStarted` closed
922        // (continue-as-new) is out of scope for the active segment.
923        let workflow_id = workflow_id();
924        let prior = named_timer_id();
925        let current = make_named("current-deadline");
926        let history = vec![
927            workflow_started_event(&workflow_id, 0),
928            timer_started_event(&workflow_id, &prior, 1),
929            // New run segment begins; the prior timer must not be surfaced.
930            workflow_started_event(&workflow_id, 2),
931            timer_started_event(&workflow_id, &current, 3),
932        ];
933        assert_eq!(live_timers_in_active_segment(&history), vec![current]);
934    }
935
936    /// The per-id disposition view (aion#145) must agree with the enumerating
937    /// liveness model on every shape: `Live` exactly when the id appears in
938    /// [`live_timers_in_active_segment`], with the not-live cases split by WHY.
939    /// Each case asserts both views so the two traversals cannot drift.
940    #[test]
941    fn disposition_tracks_the_last_event_for_the_id_and_agrees_with_liveness() {
942        let workflow_id = workflow_id();
943        let timer_id = named_timer_id();
944        let other = make_named("unrelated");
945        let assert_agrees = |history: &[Event], expected: TimerDisposition| {
946            assert_eq!(
947                timer_disposition_in_active_segment(history, &timer_id),
948                expected
949            );
950            assert_eq!(
951                live_timers_in_active_segment(history).contains(&timer_id),
952                expected == TimerDisposition::Live,
953                "the per-id disposition and the enumerating model disagree on liveness"
954            );
955        };
956
957        // Started → live.
958        let mut history = vec![
959            workflow_started_event(&workflow_id, 0),
960            timer_started_event(&workflow_id, &timer_id, 1),
961        ];
962        assert_agrees(&history, TimerDisposition::Live);
963
964        // Fired at head → the durable record exists (the aion#145 shape).
965        history.push(timer_fired_event(&workflow_id, &timer_id, 2));
966        assert_agrees(&history, TimerDisposition::Fired);
967
968        // A fire for an UNRELATED id must not disturb this timer's disposition.
969        history.push(timer_fired_event(&workflow_id, &other, 3));
970        assert_agrees(&history, TimerDisposition::Fired);
971
972        // Re-armed after the fire → live again (last-event-wins).
973        history.push(timer_started_event(&workflow_id, &timer_id, 4));
974        assert_agrees(&history, TimerDisposition::Live);
975
976        // Cancelled at head → retired, owed nothing.
977        history.push(timer_cancelled_event(&workflow_id, &timer_id, 5));
978        assert_agrees(&history, TimerDisposition::Cancelled);
979
980        // A new run segment closes the book: the id is absent from the active
981        // segment even though the prior segment fired and cancelled it.
982        history.push(workflow_started_event(&workflow_id, 6));
983        assert_agrees(&history, TimerDisposition::Absent);
984
985        // And with no events at all it was absent to begin with.
986        assert_agrees(&[], TimerDisposition::Absent);
987    }
988
989    #[tokio::test]
990    async fn re_armed_named_timer_fires_again() -> Result<(), TimerServiceError> {
991        // End-to-end firing-path guard: with last-event-wins, a re-armed named
992        // timer is live, so `fire_timer` records a second `TimerFired` and
993        // delivers it — rather than silently no-opping under the old
994        // `any`-semantics.
995        let process = WorkflowProcessHandle::new(42);
996        let (store, engine, service) = service();
997        let workflow_id = workflow_id();
998        let timer_id = named_timer_id();
999        let fire_at = instant(110);
1000        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1001        engine.record_workflow_event(
1002            &workflow_id,
1003            timer_started_event(&workflow_id, &timer_id, 1),
1004        )?;
1005        engine
1006            .record_workflow_event(&workflow_id, timer_fired_event(&workflow_id, &timer_id, 2))?;
1007        engine.record_workflow_event(
1008            &workflow_id,
1009            timer_started_event(&workflow_id, &timer_id, 3),
1010        )?;
1011
1012        service
1013            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1014            .await?;
1015
1016        assert_eq!(
1017            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1018            2,
1019            "the re-armed timer fires again, recording a second TimerFired"
1020        );
1021        assert_eq!(engine.delivered_messages()?.len(), 1);
1022        Ok(())
1023    }
1024
1025    #[tokio::test]
1026    async fn schedule_records_timer_row_without_timer_started_event()
1027    -> Result<(), TimerServiceError> {
1028        let (store, _engine, service) = service();
1029        let workflow_id = workflow_id();
1030        let timer_id = timer_id();
1031        let fire_at = instant(10);
1032
1033        service
1034            .schedule(workflow_id.clone(), timer_id.clone(), fire_at, 1)
1035            .await?;
1036
1037        let expired = store.expired_timers(fire_at).await?;
1038        assert_eq!(expired.len(), 1);
1039        assert_eq!(expired[0].workflow_id, workflow_id);
1040        assert_eq!(expired[0].timer_id, timer_id);
1041        assert_eq!(expired[0].fire_at, fire_at);
1042
1043        assert!(history(&store, &workflow_id).await?.is_empty());
1044        Ok(())
1045    }
1046
1047    #[tokio::test]
1048    async fn schedule_arms_wheel_for_resident_workflow() -> Result<(), TimerServiceError> {
1049        let process = WorkflowProcessHandle::new(42);
1050        let (_store, engine, service) = service();
1051        let workflow_id = workflow_id();
1052        let timer_id = timer_id();
1053        let fire_at = instant(20);
1054        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1055
1056        service
1057            .schedule(workflow_id, timer_id.clone(), fire_at, 1)
1058            .await?;
1059
1060        assert_eq!(
1061            engine.armed_timers()?,
1062            vec![TimerWheelEntry {
1063                process,
1064                timer_id,
1065                fire_at
1066            }]
1067        );
1068        Ok(())
1069    }
1070
1071    #[tokio::test]
1072    async fn schedule_for_nonresident_records_without_arming() -> Result<(), TimerServiceError> {
1073        let (store, engine, service) = service();
1074        let workflow_id = workflow_id();
1075        let timer_id = timer_id();
1076        let fire_at = instant(30);
1077        engine.set_residency(workflow_id.clone(), WorkflowResidency::NonResident)?;
1078
1079        service
1080            .schedule(workflow_id.clone(), timer_id, fire_at, 1)
1081            .await?;
1082
1083        assert!(engine.armed_timers()?.is_empty());
1084        assert!(history(&store, &workflow_id).await?.is_empty());
1085        Ok(())
1086    }
1087
1088    #[tokio::test]
1089    async fn fire_records_timer_fired_then_delivers_mailbox_message()
1090    -> Result<(), TimerServiceError> {
1091        let process = WorkflowProcessHandle::new(42);
1092        let (store, engine, service) = service();
1093        let workflow_id = workflow_id();
1094        let timer_id = timer_id();
1095        let fire_at = instant(40);
1096        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1097        engine.record_workflow_event(
1098            &workflow_id,
1099            timer_started_event(&workflow_id, &timer_id, 1),
1100        )?;
1101
1102        service
1103            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1104            .await?;
1105
1106        assert_eq!(
1107            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1108            1
1109        );
1110        assert_eq!(
1111            engine.delivered_messages()?,
1112            vec![(
1113                process,
1114                DeliveredWorkflowMessage::TimerFired {
1115                    timer_id: timer_id.clone(),
1116                    fire_at
1117                }
1118            )]
1119        );
1120        assert!(matches!(
1121            engine.operations()?.as_slice(),
1122            [
1123                FakeEngineOperation::EventRecorded {
1124                    event: Event::TimerStarted { .. },
1125                    ..
1126                },
1127                FakeEngineOperation::EventRecorded {
1128                    workflow_id: recorded_workflow_id,
1129                    event: Event::TimerFired { timer_id: recorded_timer_id, .. },
1130                },
1131                FakeEngineOperation::Delivered {
1132                    process: delivered_process,
1133                    message: DeliveredWorkflowMessage::TimerFired { timer_id: delivered_timer_id, .. },
1134                }
1135            ] if recorded_workflow_id == &workflow_id
1136                && recorded_timer_id == &timer_id
1137                && delivered_process == &process
1138                && delivered_timer_id == &timer_id
1139        ));
1140        Ok(())
1141    }
1142
1143    #[tokio::test]
1144    async fn fire_records_without_delivery_when_workflow_becomes_nonresident()
1145    -> Result<(), TimerServiceError> {
1146        let (store, engine, service) = service();
1147        let workflow_id = workflow_id();
1148        let timer_id = timer_id();
1149        let fire_at = instant(50);
1150        engine.set_residency(workflow_id.clone(), WorkflowResidency::NonResident)?;
1151        engine.record_workflow_event(
1152            &workflow_id,
1153            timer_started_event(&workflow_id, &timer_id, 1),
1154        )?;
1155
1156        service
1157            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1158            .await?;
1159
1160        assert_eq!(
1161            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1162            1
1163        );
1164        assert!(engine.delivered_messages()?.is_empty());
1165        Ok(())
1166    }
1167
1168    /// aion#145: a second fire of an already-fired timer records nothing new
1169    /// but RE-DELIVERS the owed wake to a resident workflow. Delivery is a pure
1170    /// wake (the suspended await re-resolves from history), so a duplicate is
1171    /// harmless — while the pre-fix silent no-op is exactly what wedged the
1172    /// incident's workflows: the durable `TimerFired` existed and the resident
1173    /// process waited forever on a wake that never came. Mutation-sensitive:
1174    /// reverting the `Fired`-disposition branch to a plain `Ok(())` leaves one
1175    /// delivery; a second append would raise the fired count to two.
1176    #[tokio::test]
1177    async fn firing_same_timer_twice_records_once_and_redelivers_the_wake()
1178    -> Result<(), TimerServiceError> {
1179        let process = WorkflowProcessHandle::new(42);
1180        let (store, engine, service) = service();
1181        let workflow_id = workflow_id();
1182        let timer_id = timer_id();
1183        let fire_at = instant(60);
1184        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1185        engine.record_workflow_event(
1186            &workflow_id,
1187            timer_started_event(&workflow_id, &timer_id, 1),
1188        )?;
1189
1190        service
1191            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1192            .await?;
1193        // The second fire re-enters the recorder seam, which answers
1194        // `AlreadyRecorded` without appending (the fake implements the seam
1195        // contract; the real bridge's under-lock decision — including the
1196        // recorder-sequence reconciliation — is pinned in
1197        // `nif_timer_bridge_tests`).
1198        service
1199            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1200            .await?;
1201
1202        assert_eq!(
1203            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1204            1,
1205            "the recorded fire must never be appended a second time"
1206        );
1207        assert_eq!(
1208            engine.delivered_messages()?.len(),
1209            2,
1210            "the second fire re-delivers the owed wake instead of silently no-opping"
1211        );
1212        Ok(())
1213    }
1214
1215    /// aion#145 Parts 2+3, service-seam mapping: a fire whose `TimerFired` is
1216    /// already the timer's last recorded event (the incident's ack-lost append)
1217    /// must NOT no-op for a resident workflow — it re-enters the recorder seam
1218    /// (which answers `AlreadyRecorded` without appending) and then delivers
1219    /// the owed wake. Mutation-sensitive both ways: reverting the
1220    /// `Fired`-disposition branch to `Ok(())` delivers nothing, and mapping
1221    /// `AlreadyRecorded` like `RefusedTerminal` delivers nothing.
1222    #[tokio::test]
1223    async fn already_recorded_fire_delivers_owed_wake_without_second_append()
1224    -> Result<(), TimerServiceError> {
1225        let process = WorkflowProcessHandle::new(42);
1226        let (store, engine, service) = service();
1227        let workflow_id = workflow_id();
1228        let timer_id = timer_id();
1229        let fire_at = instant(140);
1230        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1231        engine.record_workflow_event(
1232            &workflow_id,
1233            timer_started_event(&workflow_id, &timer_id, 1),
1234        )?;
1235        engine
1236            .record_workflow_event(&workflow_id, timer_fired_event(&workflow_id, &timer_id, 2))?;
1237
1238        service
1239            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1240            .await?;
1241
1242        assert_eq!(
1243            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1244            1,
1245            "the already-recorded fire must not be appended again"
1246        );
1247        assert_eq!(
1248            engine.delivered_messages()?,
1249            vec![(
1250                process,
1251                DeliveredWorkflowMessage::TimerFired { timer_id, fire_at }
1252            )],
1253            "the owed mailbox wake must be delivered"
1254        );
1255        Ok(())
1256    }
1257
1258    /// aion#145 test matrix row 3: fired-but-undelivered for a NON-resident
1259    /// workflow is a clean no-op — no wake is attempted (there is no live
1260    /// process to wake) and the recorder seam is not re-entered: replay on
1261    /// residency restore rebuilds the recorder from the durable head and
1262    /// consumes the recorded fire. Load-bearing assertions: exactly one
1263    /// durable `TimerFired`, and an empty delivery log.
1264    #[tokio::test]
1265    async fn already_recorded_fire_for_nonresident_workflow_wakes_nothing()
1266    -> Result<(), TimerServiceError> {
1267        let (store, engine, service) = service();
1268        let workflow_id = workflow_id();
1269        let timer_id = timer_id();
1270        engine.set_residency(workflow_id.clone(), WorkflowResidency::NonResident)?;
1271        engine.record_workflow_event(
1272            &workflow_id,
1273            timer_started_event(&workflow_id, &timer_id, 1),
1274        )?;
1275        engine
1276            .record_workflow_event(&workflow_id, timer_fired_event(&workflow_id, &timer_id, 2))?;
1277
1278        service
1279            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(150))
1280            .await?;
1281
1282        assert_eq!(
1283            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1284            1,
1285            "a non-resident redelivery must not re-enter the recorder seam"
1286        );
1287        assert!(
1288            engine.delivered_messages()?.is_empty(),
1289            "no wake is attempted for a non-resident workflow"
1290        );
1291        Ok(())
1292    }
1293
1294    /// aion#145: the redelivery path still honors the post-terminal refusal.
1295    /// A recorded fire whose run has since reached a terminal gets NO wake —
1296    /// the recorder seam answers `RefusedTerminal` and the recorded fire is
1297    /// inert history. Mutation-sensitive: delivering the wake regardless of the
1298    /// refusal would reschedule a terminated workflow.
1299    #[tokio::test]
1300    async fn already_recorded_fire_after_run_terminal_delivers_no_wake()
1301    -> Result<(), TimerServiceError> {
1302        let process = WorkflowProcessHandle::new(42);
1303        let (store, engine, service) = service();
1304        let workflow_id = workflow_id();
1305        let timer_id = timer_id();
1306        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1307        engine.record_workflow_event(
1308            &workflow_id,
1309            timer_started_event(&workflow_id, &timer_id, 1),
1310        )?;
1311        engine
1312            .record_workflow_event(&workflow_id, timer_fired_event(&workflow_id, &timer_id, 2))?;
1313        engine.refuse_next_record_as_terminal()?;
1314
1315        service
1316            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(160))
1317            .await?;
1318
1319        assert_eq!(
1320            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1321            1
1322        );
1323        assert!(
1324            engine.delivered_messages()?.is_empty(),
1325            "a post-terminal redelivery must not wake the terminated run"
1326        );
1327        Ok(())
1328    }
1329
1330    #[tokio::test]
1331    async fn firing_cancelled_timer_is_noop() -> Result<(), TimerServiceError> {
1332        let process = WorkflowProcessHandle::new(42);
1333        let (store, engine, service) = service();
1334        let workflow_id = workflow_id();
1335        let timer_id = timer_id();
1336        let fire_at = instant(70);
1337        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1338        engine.record_workflow_event(
1339            &workflow_id,
1340            timer_started_event(&workflow_id, &timer_id, 1),
1341        )?;
1342        let cancelled = Event::TimerCancelled {
1343            cause: TimerCancelCause::WorkflowIntent,
1344            envelope: EventEnvelope {
1345                seq: 2,
1346                recorded_at: instant(69),
1347                workflow_id: workflow_id.clone(),
1348            },
1349            timer_id: timer_id.clone(),
1350        };
1351        engine.record_workflow_event(&workflow_id, cancelled)?;
1352
1353        service
1354            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1355            .await?;
1356
1357        let history = history(&store, &workflow_id).await?;
1358        assert_eq!(count_timer_fired(&history, &timer_id), 0);
1359        assert!(engine.delivered_messages()?.is_empty());
1360        Ok(())
1361    }
1362
1363    #[tokio::test]
1364    async fn fire_resolves_residency_at_fire_time() -> Result<(), TimerServiceError> {
1365        let process = WorkflowProcessHandle::new(42);
1366        let (store, engine, service) = service();
1367        let workflow_id = workflow_id();
1368        let timer_id = timer_id();
1369        let fire_at = instant(80);
1370
1371        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1372        engine.set_residency(workflow_id.clone(), WorkflowResidency::NonResident)?;
1373        engine.record_workflow_event(
1374            &workflow_id,
1375            timer_started_event(&workflow_id, &timer_id, 1),
1376        )?;
1377        service
1378            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1379            .await?;
1380
1381        assert_eq!(
1382            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1383            1
1384        );
1385        assert!(engine.delivered_messages()?.is_empty());
1386        Ok(())
1387    }
1388
1389    #[tokio::test]
1390    async fn firing_unstarted_timer_records_nothing() -> Result<(), TimerServiceError> {
1391        let process = WorkflowProcessHandle::new(42);
1392        let (store, engine, service) = service();
1393        let workflow_id = workflow_id();
1394        let timer_id = timer_id();
1395        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1396
1397        service
1398            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(90))
1399            .await?;
1400
1401        assert!(history(&store, &workflow_id).await?.is_empty());
1402        assert!(engine.delivered_messages()?.is_empty());
1403        Ok(())
1404    }
1405
1406    /// A deadline handler that records each fire and can be told to fail.
1407    struct RecordingDeadlineHandler {
1408        calls: std::sync::Mutex<Vec<(WorkflowId, RunId)>>,
1409        fail: bool,
1410    }
1411
1412    impl RecordingDeadlineHandler {
1413        fn new(fail: bool) -> Self {
1414            Self {
1415                calls: std::sync::Mutex::new(Vec::new()),
1416                fail,
1417            }
1418        }
1419
1420        fn calls(&self) -> Result<Vec<(WorkflowId, RunId)>, TimerServiceError> {
1421            self.calls
1422                .lock()
1423                .map(|calls| calls.clone())
1424                .map_err(|error| TimerServiceError::Deadline(error.to_string()))
1425        }
1426    }
1427
1428    #[async_trait::async_trait]
1429    impl DeadlineHandler for RecordingDeadlineHandler {
1430        async fn on_deadline_elapsed(
1431            &self,
1432            workflow_id: WorkflowId,
1433            run_id: RunId,
1434        ) -> Result<(), DeadlineHandlerError> {
1435            self.calls
1436                .lock()
1437                .map_err(|error| DeadlineHandlerError(error.to_string()))?
1438                .push((workflow_id, run_id));
1439            if self.fail {
1440                Err(DeadlineHandlerError(
1441                    "deliberate handler failure".to_owned(),
1442                ))
1443            } else {
1444                Ok(())
1445            }
1446        }
1447    }
1448
1449    fn service_with_handler(
1450        handler: Arc<dyn DeadlineHandler>,
1451    ) -> (Arc<InMemoryStore>, Arc<FakeEngineHandle>, TimerService) {
1452        let concrete_store = Arc::new(InMemoryStore::default());
1453        let recorder_store: Arc<dyn WritableEventStore> = concrete_store.clone();
1454        let readable_store: Arc<dyn ReadableEventStore> = concrete_store.clone();
1455        let engine = Arc::new(FakeEngineHandle::recording_to(recorder_store));
1456        let service = TimerService::with_recorded_at(engine.clone(), readable_store, recorded_at)
1457            .with_deadline_handler(handler);
1458        (concrete_store, engine, service)
1459    }
1460
1461    /// A live reserved deadline fire is demuxed to the registered handler with
1462    /// the id-encoded run, and records NO `TimerFired` and delivers nothing.
1463    #[tokio::test]
1464    async fn deadline_fire_routes_to_handler_and_records_no_timer_fired()
1465    -> Result<(), TimerServiceError> {
1466        let run_id = RunId::new_v4();
1467        let deadline_id = deadline_timer_id(&run_id)
1468            .map_err(|error| TimerServiceError::Deadline(error.to_string()))?;
1469        let handler = Arc::new(RecordingDeadlineHandler::new(false));
1470        let (store, engine, service) = service_with_handler(handler.clone());
1471        let workflow_id = workflow_id();
1472        let fire_at = instant(120);
1473        engine.set_residency(
1474            workflow_id.clone(),
1475            WorkflowResidency::Resident(WorkflowProcessHandle::new(9)),
1476        )?;
1477        engine.record_workflow_event(
1478            &workflow_id,
1479            timer_started_event(&workflow_id, &deadline_id, 1),
1480        )?;
1481
1482        service
1483            .fire_timer(workflow_id.clone(), deadline_id.clone(), fire_at)
1484            .await?;
1485
1486        assert_eq!(handler.calls()?, vec![(workflow_id.clone(), run_id)]);
1487        assert_eq!(
1488            count_timer_fired(&history(&store, &workflow_id).await?, &deadline_id),
1489            0,
1490            "a deadline fire never records TimerFired"
1491        );
1492        assert!(engine.delivered_messages()?.is_empty());
1493        Ok(())
1494    }
1495
1496    /// A deadline fire with no handler registered is a typed error — never a
1497    /// silent generic fire.
1498    #[tokio::test]
1499    async fn deadline_fire_without_handler_is_typed_error() -> Result<(), TimerServiceError> {
1500        let run_id = RunId::new_v4();
1501        let deadline_id = deadline_timer_id(&run_id)
1502            .map_err(|error| TimerServiceError::Deadline(error.to_string()))?;
1503        let (store, engine, service) = service();
1504        let workflow_id = workflow_id();
1505        engine.record_workflow_event(
1506            &workflow_id,
1507            timer_started_event(&workflow_id, &deadline_id, 1),
1508        )?;
1509
1510        let result = service
1511            .fire_timer(workflow_id.clone(), deadline_id.clone(), instant(120))
1512            .await;
1513
1514        assert!(
1515            matches!(result, Err(TimerServiceError::Deadline(_))),
1516            "unhandled deadline fire must be a typed error, got {result:?}"
1517        );
1518        assert_eq!(
1519            count_timer_fired(&history(&store, &workflow_id).await?, &deadline_id),
1520            0
1521        );
1522        Ok(())
1523    }
1524
1525    /// A handler failure surfaces as a typed deadline error to the caller.
1526    #[tokio::test]
1527    async fn deadline_handler_failure_surfaces_as_typed_error() -> Result<(), TimerServiceError> {
1528        let run_id = RunId::new_v4();
1529        let deadline_id = deadline_timer_id(&run_id)
1530            .map_err(|error| TimerServiceError::Deadline(error.to_string()))?;
1531        let handler = Arc::new(RecordingDeadlineHandler::new(true));
1532        let (_store, engine, service) = service_with_handler(handler);
1533        let workflow_id = workflow_id();
1534        engine.record_workflow_event(
1535            &workflow_id,
1536            timer_started_event(&workflow_id, &deadline_id, 1),
1537        )?;
1538
1539        let result = service
1540            .fire_timer(workflow_id, deadline_id, instant(120))
1541            .await;
1542
1543        assert!(matches!(result, Err(TimerServiceError::Deadline(_))));
1544        Ok(())
1545    }
1546
1547    /// A fire the recorder refuses as a post-terminal late arrival records
1548    /// nothing AND delivers no wake. Mutation-sensitive: the timer is live so the
1549    /// pre-check passes and the fire reaches the recorder seam, which returns
1550    /// `RefusedTerminal`; delivering the mailbox wake regardless of that outcome
1551    /// would reschedule a terminated workflow and fail this test.
1552    #[tokio::test]
1553    async fn refused_terminal_fire_records_nothing_and_delivers_no_wake()
1554    -> Result<(), TimerServiceError> {
1555        let process = WorkflowProcessHandle::new(42);
1556        let (store, engine, service) = service();
1557        let workflow_id = workflow_id();
1558        let timer_id = timer_id();
1559        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1560        engine.record_workflow_event(
1561            &workflow_id,
1562            timer_started_event(&workflow_id, &timer_id, 1),
1563        )?;
1564        engine.refuse_next_record_as_terminal()?;
1565
1566        service
1567            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(130))
1568            .await?;
1569
1570        assert_eq!(
1571            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1572            0,
1573            "a refused fire records no TimerFired"
1574        );
1575        assert!(
1576            engine.delivered_messages()?.is_empty(),
1577            "a refused fire delivers no wake"
1578        );
1579        Ok(())
1580    }
1581
1582    /// Two services obtained separately but sharing ONE terminal-update
1583    /// coordinator (as the production bridge hands out) serialize a cancel and a
1584    /// fire of the same timer: exactly one terminal timer event is recorded, never
1585    /// both. A `Barrier` forces genuine overlap — both actors are released
1586    /// together after setup — and the loop runs each direction. Mutation-sensitive:
1587    /// a per-service coordinator would let both read the timer live and record a
1588    /// `TimerFired` AND a `TimerCancelled`.
1589    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1590    async fn shared_coordinator_serializes_cancel_and_fire_across_services()
1591    -> Result<(), TimerServiceError> {
1592        use dashmap::DashSet;
1593        use tokio::sync::Barrier;
1594
1595        for _ in 0..20 {
1596            let process = WorkflowProcessHandle::new(42);
1597            let concrete_store = Arc::new(InMemoryStore::default());
1598            let recorder_store: Arc<dyn WritableEventStore> = concrete_store.clone();
1599            let readable: Arc<dyn ReadableEventStore> = concrete_store.clone();
1600            let engine = Arc::new(FakeEngineHandle::recording_to(recorder_store));
1601            let coordinator = Arc::new(DashSet::new());
1602            let service_a =
1603                TimerService::with_recorded_at(engine.clone(), readable.clone(), recorded_at)
1604                    .with_terminal_updates(Arc::clone(&coordinator));
1605            let service_b =
1606                TimerService::with_recorded_at(engine.clone(), readable.clone(), recorded_at)
1607                    .with_terminal_updates(Arc::clone(&coordinator));
1608
1609            let workflow_id = workflow_id();
1610            let timer_id = timer_id();
1611            let fire_at = instant(200);
1612            engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1613            engine.record_workflow_event(
1614                &workflow_id,
1615                timer_started_event(&workflow_id, &timer_id, 1),
1616            )?;
1617
1618            let gate = Arc::new(Barrier::new(2));
1619            let (cancel_gate, fire_gate) = (Arc::clone(&gate), gate);
1620            let (cancel_wf, cancel_timer) = (workflow_id.clone(), timer_id.clone());
1621            let cancel = async move {
1622                cancel_gate.wait().await;
1623                service_a
1624                    .cancel(cancel_wf, cancel_timer, TimerCancelCause::WorkflowIntent)
1625                    .await
1626            };
1627            let (fire_wf, fire_timer) = (workflow_id.clone(), timer_id.clone());
1628            let fire = async move {
1629                fire_gate.wait().await;
1630                service_b.fire_timer(fire_wf, fire_timer, fire_at).await
1631            };
1632            let (cancel_result, fire_result) = tokio::join!(cancel, fire);
1633            cancel_result?;
1634            fire_result?;
1635
1636            let history = history(&concrete_store, &workflow_id).await?;
1637            let terminal_timer_events = history
1638                .iter()
1639                .filter(|event| {
1640                    matches!(
1641                        event,
1642                        Event::TimerFired { timer_id: recorded, .. }
1643                        | Event::TimerCancelled { timer_id: recorded, .. }
1644                            if recorded == &timer_id
1645                    )
1646                })
1647                .count();
1648            assert_eq!(
1649                terminal_timer_events, 1,
1650                "first-recorded wins across shared services: {history:#?}"
1651            );
1652        }
1653        Ok(())
1654    }
1655
1656    #[tokio::test]
1657    async fn firing_prior_run_timer_after_continue_as_new_is_noop() -> Result<(), TimerServiceError>
1658    {
1659        let process = WorkflowProcessHandle::new(42);
1660        let (store, engine, service) = service();
1661        let workflow_id = workflow_id();
1662        let timer_id = timer_id();
1663        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1664        // Run 1 started the timer; run 2's WorkflowStarted closes that segment.
1665        engine.record_workflow_event(
1666            &workflow_id,
1667            timer_started_event(&workflow_id, &timer_id, 1),
1668        )?;
1669        engine.record_workflow_event(&workflow_id, workflow_started_event(&workflow_id, 2))?;
1670
1671        service
1672            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(100))
1673            .await?;
1674
1675        assert_eq!(
1676            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1677            0
1678        );
1679        assert!(engine.delivered_messages()?.is_empty());
1680        Ok(())
1681    }
1682
1683    // --- consumed-arming row retirement (the collapse fix's engine half) ---
1684
1685    /// The rows outstanding at `as_of`, for asserting what a boot sweep
1686    /// would still walk.
1687    async fn outstanding_rows(
1688        store: &InMemoryStore,
1689        as_of: DateTime<Utc>,
1690    ) -> Result<usize, StoreError> {
1691        Ok(store.expired_timers(as_of).await?.len())
1692    }
1693
1694    /// A recorded fire retires the consumed arming's durable row: the boot
1695    /// sweep that used to re-walk every consumed row (the estate's
1696    /// 1,434-line 2026-08-24 boot) finds nothing left for this timer.
1697    #[tokio::test]
1698    async fn a_recorded_fire_retires_the_consumed_row() -> Result<(), TimerServiceError> {
1699        let process = WorkflowProcessHandle::new(42);
1700        let (store, engine, service) = service();
1701        let workflow_id = workflow_id();
1702        let timer_id = timer_id();
1703        let fire_at = instant(40);
1704        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1705        engine.record_workflow_event(
1706            &workflow_id,
1707            timer_started_event(&workflow_id, &timer_id, 1),
1708        )?;
1709        service
1710            .schedule(workflow_id.clone(), timer_id.clone(), fire_at, 1)
1711            .await?;
1712        assert_eq!(
1713            outstanding_rows(&store, instant(1_000)).await?,
1714            1,
1715            "precondition: the arming's row is durable before the fire"
1716        );
1717
1718        service
1719            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1720            .await?;
1721
1722        assert_eq!(
1723            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1724            1,
1725            "the fire itself must still record"
1726        );
1727        assert_eq!(
1728            outstanding_rows(&store, instant(1_000)).await?,
1729            0,
1730            "a recorded fire must retire the consumed arming's row"
1731        );
1732        Ok(())
1733    }
1734
1735    /// A recorded cancel retires the arming's row just as a fire does: a
1736    /// cancelled timer owes no recovery fire, so its row must not outlive it.
1737    #[tokio::test]
1738    async fn a_recorded_cancel_retires_the_consumed_row() -> Result<(), TimerServiceError> {
1739        let process = WorkflowProcessHandle::new(42);
1740        let (store, engine, service) = service();
1741        let workflow_id = workflow_id();
1742        let timer_id = named_timer_id();
1743        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1744        // The history's arming carries instant(5) (the helper's fire_at); the
1745        // row must carry the same instant for the conditional retire to see
1746        // one consistent arming.
1747        engine.record_workflow_event(
1748            &workflow_id,
1749            timer_started_event(&workflow_id, &timer_id, 1),
1750        )?;
1751        service
1752            .schedule(workflow_id.clone(), timer_id.clone(), instant(5), 1)
1753            .await?;
1754        assert_eq!(outstanding_rows(&store, instant(1_000)).await?, 1);
1755
1756        service
1757            .cancel(
1758                workflow_id.clone(),
1759                timer_id.clone(),
1760                TimerCancelCause::WorkflowIntent,
1761            )
1762            .await?;
1763
1764        assert_eq!(
1765            outstanding_rows(&store, instant(1_000)).await?,
1766            0,
1767            "a recorded cancel must retire the cancelled arming's row"
1768        );
1769        Ok(())
1770    }
1771
1772    /// THE RE-ARM RACE the `fire_at` condition exists for: a stale fire
1773    /// (armed for the OLD `fire_at`) must not retire the row a re-armed timer
1774    /// wrote with a NEW `fire_at` — that row is the replacement arming's only durable
1775    /// claim to a recovery fire, and deleting it is a lost wake after
1776    /// restart.
1777    #[tokio::test]
1778    async fn a_stale_fire_leaves_a_re_armed_timers_row() -> Result<(), TimerServiceError> {
1779        let process = WorkflowProcessHandle::new(42);
1780        let (store, engine, service) = service();
1781        let workflow_id = workflow_id();
1782        let timer_id = named_timer_id();
1783        let old_fire_at = instant(5);
1784        let new_fire_at = instant(500);
1785        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1786        engine.record_workflow_event(
1787            &workflow_id,
1788            timer_started_event(&workflow_id, &timer_id, 1),
1789        )?;
1790        // The re-arm overwrites the timer's single row with the new fire_at.
1791        service
1792            .schedule(workflow_id.clone(), timer_id.clone(), new_fire_at, 2)
1793            .await?;
1794
1795        // The stale wheel callback for the OLD arming arrives late.
1796        service
1797            .fire_timer(workflow_id.clone(), timer_id.clone(), old_fire_at)
1798            .await?;
1799
1800        assert_eq!(
1801            outstanding_rows(&store, instant(1_000)).await?,
1802            1,
1803            "the re-armed row is the replacement arming's only durable claim \
1804             to a recovery fire; a stale retire must leave it standing"
1805        );
1806        Ok(())
1807    }
1808
1809    /// A fire refused because the run reached its terminal retires the row:
1810    /// the fire can never record, the arming is moot forever, and without
1811    /// retirement a terminal workflow's rows survive every boot.
1812    #[tokio::test]
1813    async fn a_terminal_refused_fire_retires_the_row() -> Result<(), TimerServiceError> {
1814        let process = WorkflowProcessHandle::new(42);
1815        let (store, engine, service) = service();
1816        let workflow_id = workflow_id();
1817        let timer_id = timer_id();
1818        let fire_at = instant(40);
1819        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1820        engine.record_workflow_event(
1821            &workflow_id,
1822            timer_started_event(&workflow_id, &timer_id, 1),
1823        )?;
1824        service
1825            .schedule(workflow_id.clone(), timer_id.clone(), fire_at, 1)
1826            .await?;
1827        engine.refuse_next_record_as_terminal()?;
1828
1829        service
1830            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1831            .await?;
1832
1833        assert_eq!(
1834            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1835            0,
1836            "the refused fire must record nothing"
1837        );
1838        assert!(
1839            engine.delivered_messages()?.is_empty(),
1840            "a refused fire must wake nothing"
1841        );
1842        assert_eq!(
1843            outstanding_rows(&store, instant(1_000)).await?,
1844            0,
1845            "a terminal-refused fire's arming is moot forever; its row retires"
1846        );
1847        Ok(())
1848    }
1849}