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, status_from_events};
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            && let Err(delivery) = self.engine.deliver_workflow_message(
384                process,
385                WorkflowMailboxMessage::TimerFired {
386                    timer_id: timer_id.clone(),
387                    fire_at,
388                },
389            )
390        {
391            // The fire IS durably recorded; only the wake failed. WHY decides
392            // what is still owed (aion#215). The residency read above and the
393            // wake are two instants, and on a boot sweep the workflow's own
394            // replay runs between them: a re-armed timer whose deadline passed
395            // during the outage fires from the live wheel the moment replay
396            // re-arms it, the run completes, and its process exits — so this
397            // sweep's wake meets a pid that is no longer live. Re-read the run:
398            // TERMINAL now means the wake was never owed (a finished run owes
399            // nothing), the arming is consumed and its row retires. Anything
400            // else — an active run whose process is gone — is a runtime fault
401            // the sweep must not absorb, so the error stands and boot stays
402            // loud. Before this re-read, the terminal case failed the whole
403            // boot: `reopen_timer_rearm_e2e`, intermittently since 2026-08-31.
404            let after_wake = self.store.read_history(&workflow_id).await?;
405            if !status_from_events(&after_wake).is_terminal() {
406                return Err(delivery.into());
407            }
408            tracing::warn!(
409                %workflow_id,
410                timer_id = %timer_id,
411                pid = process.pid(),
412                error = %delivery,
413                "recovered timer fire recorded, but the wake found the run already \
414                 terminal and its process gone — nothing owed; the row retires (aion#215)"
415            );
416        }
417
418        // The fire is durably recorded (and any owed wake delivered, or shown
419        // to be owed to nobody): the arming is consumed and its row retires.
420        // Ordered after delivery so a delivery error leaves the row for the
421        // next boot or adoption sweep's redelivery.
422        self.retire_consumed_row(&workflow_id, &timer_id, fire_at, armed_seq)
423            .await;
424
425        Ok(())
426    }
427
428    /// Completes a fire whose durable `TimerFired` already exists but whose
429    /// delivery — and possibly the recorder's own sequence advance — was lost
430    /// (aion#145): the incident's ack-lost append, or a wake that failed after
431    /// a fully recorded fire. Reached from the live wheel's re-fire and from
432    /// the boot/adoption sweep's disposition of surviving `Fired` rows.
433    ///
434    /// Only a RESIDENT workflow owes a live wake, and only its still-held
435    /// Recorder can be carrying the stale-low sequence the ack loss leaves
436    /// behind: a non-resident workflow's replay on residency restore rebuilds
437    /// its recorder from the durable head and consumes the recorded fire, so
438    /// for it this is a clean retire-only, exactly as before the fix.
439    ///
440    /// For the resident case the decision is made by the recorder seam UNDER
441    /// THE RECORDER LOCK ([`EngineHandle::record_redelivered_timer_fire`]):
442    /// the wake is owed only while the timer's last event is still the
443    /// recorded fire, and that is also where the recorder's in-memory
444    /// sequence is reconciled forward to the durable head — without that
445    /// repair the woken workflow's next append would mint a stale sequence
446    /// and die on `SequenceConflict`, wedging the run one event later. The
447    /// seam NEVER appends: a timer re-armed or cancelled since this caller's
448    /// observation answers `NotOwed` instead of minting a premature
449    /// `TimerFired` for the new arming. That no-append contract is also why
450    /// this path takes no terminal-update slot — it cannot race a cancel for
451    /// terminal-event ordering, and the wake itself is a pure wake (the
452    /// suspended await re-resolves from history), so a duplicate or stale
453    /// delivery is harmless by design.
454    ///
455    /// Returns whether a live wake was delivered, paired with what happened
456    /// to the arming's durable row, so the sweep's counters measure real
457    /// deletions rather than attempts.
458    pub(crate) async fn redeliver_owed_wake(
459        &self,
460        workflow_id: WorkflowId,
461        timer_id: TimerId,
462        fire_at: DateTime<Utc>,
463        armed_seq: u64,
464    ) -> Result<(bool, RetireAttempt), TimerServiceError> {
465        let WorkflowResidency::Resident(process) = self.engine.resolve_workflow(&workflow_id)?
466        else {
467            // Non-resident: the durable fire already exists and no live wake
468            // is owed — the arming is consumed, its row retires. This is the
469            // arm the 2026-08-24 boot walked 1,434 times without ever
470            // emptying: the row survived every redelivery.
471            let row = self
472                .retire_consumed_row(&workflow_id, &timer_id, fire_at, armed_seq)
473                .await;
474            return Ok((false, row));
475        };
476
477        match self
478            .engine
479            .record_redelivered_timer_fire(&workflow_id, &timer_id)?
480        {
481            // The run reached a terminal, or the timer moved on (re-armed or
482            // cancelled) since the fire recorded: the recorded fire is inert
483            // history and no wake may follow. Either way this arming is
484            // consumed and its row retires — identity-conditionally, so a
485            // re-armed replacement row is never touched.
486            RedeliveredFire::RefusedTerminal | RedeliveredFire::NotOwed => {
487                let row = self
488                    .retire_consumed_row(&workflow_id, &timer_id, fire_at, armed_seq)
489                    .await;
490                Ok((false, row))
491            }
492            RedeliveredFire::WakeOwed => {
493                self.engine.deliver_workflow_message(
494                    process,
495                    WorkflowMailboxMessage::TimerFired {
496                        timer_id: timer_id.clone(),
497                        fire_at,
498                    },
499                )?;
500                tracing::info!(
501                    %workflow_id,
502                    %timer_id,
503                    "timer fire was already durably recorded; delivered the owed mailbox wake"
504                );
505                let row = self
506                    .retire_consumed_row(&workflow_id, &timer_id, fire_at, armed_seq)
507                    .await;
508                Ok((true, row))
509            }
510        }
511    }
512
513    /// Route a live reserved-deadline fire to the registered handler.
514    ///
515    /// Called only for a `deadline:{run_id}` timer that passed the liveness
516    /// guard. A missing handler or an unparseable run id is a typed
517    /// [`TimerServiceError::Deadline`] — never a silent generic fire — and the
518    /// handler's own failure is surfaced the same way. The handler re-checks the
519    /// run's terminal under the recorder lock, so it loses cleanly to a
520    /// concurrent completion.
521    async fn fire_deadline(
522        &self,
523        workflow_id: WorkflowId,
524        timer_id: TimerId,
525    ) -> Result<(), TimerServiceError> {
526        let handler = self.deadline_handler.as_ref().ok_or_else(|| {
527            TimerServiceError::Deadline(format!(
528                "no deadline handler registered for {timer_id} on workflow {workflow_id}"
529            ))
530        })?;
531        let run_id = deadline_run_id(&timer_id).ok_or_else(|| {
532            TimerServiceError::Deadline(format!(
533                "malformed deadline timer {timer_id} on workflow {workflow_id}"
534            ))
535        })?;
536        handler
537            .on_deadline_elapsed(workflow_id, run_id)
538            .await
539            .map_err(|error| TimerServiceError::Deadline(error.to_string()))
540    }
541
542    /// Retire the durable row for a CONSUMED arming, warning instead of
543    /// failing: the row's survival is the redelivery-safe pre-retirement
544    /// status quo (the next boot or adoption sweep walks it again,
545    /// wake-only), while failing a
546    /// fire or cancel that already durably recorded — or aborting startup
547    /// recovery — over row housekeeping would invert the severities. The
548    /// `(fire_at, armed_seq)` condition keeps a re-armed timer's replacement
549    /// row untouched — even a replacement re-armed to the identical instant.
550    ///
551    /// The outcome is REPORTED, not swallowed: the sweep's counters separate
552    /// rows actually retired from rows a replacement arming superseded and
553    /// from store refusals, so `retired=N` in the sweep's summary measures
554    /// deletions, never attempts. Live fire/cancel callers may ignore the
555    /// answer — for them the next boot or adoption sweep is the healer
556    /// either way.
557    ///
558    /// `pub(crate)`: the boot/adoption recovery sweep retires consumed rows in
559    /// bulk through this same seam, so warn-never-fail lives in one place.
560    pub(crate) async fn retire_consumed_row(
561        &self,
562        workflow_id: &WorkflowId,
563        timer_id: &TimerId,
564        fire_at: DateTime<Utc>,
565        armed_seq: u64,
566    ) -> RetireAttempt {
567        match self
568            .store
569            .retire_timer(workflow_id, timer_id, fire_at, armed_seq)
570            .await
571        {
572            Ok(TimerRetirement::Retired) => RetireAttempt::Retired,
573            Ok(TimerRetirement::Superseded) => RetireAttempt::Superseded,
574            Err(error) => {
575                tracing::warn!(
576                    %workflow_id,
577                    %timer_id,
578                    %fire_at,
579                    %error,
580                    "consumed timer row could not be retired; the row survives until a \
581                     later fire or boot/adoption sweep retires it"
582                );
583                RetireAttempt::Failed
584            }
585        }
586    }
587
588    async fn next_envelope(&self, workflow_id: &WorkflowId) -> Result<EventEnvelope, StoreError> {
589        let history = self.store.read_history(workflow_id).await?;
590        let seq = history.iter().map(Event::seq).max().unwrap_or_default() + 1;
591        Ok(EventEnvelope {
592            seq,
593            recorded_at: (self.recorded_at)(),
594            workflow_id: workflow_id.clone(),
595        })
596    }
597}
598
599/// The `fire_at` of the timer's current arming in the active run segment, by
600/// the same last-event-wins model as [`live_timers_in_active_segment`]: a
601/// `TimerStarted` (re)arms it with its `fire_at`, a `TimerFired`/`TimerCancelled`
602/// clears it. `None` when the timer is not currently armed — the caller uses
603/// this to retire the durable row for exactly the arming it consumed, never a
604/// replacement's.
605///
606/// `pub(crate)`: the boot/adoption sweep compares a due row's `fire_at`
607/// against this recorded arming before firing (round-2 F1) — a row that
608/// disagrees with history is stale and retires instead of firing.
609pub(crate) fn armed_fire_at_in_active_segment(
610    history: &[Event],
611    timer_id: &TimerId,
612) -> Option<DateTime<Utc>> {
613    let mut armed = None;
614    for event in active_segment(history) {
615        match event {
616            Event::TimerStarted {
617                timer_id: id,
618                fire_at,
619                ..
620            } if id == timer_id => {
621                armed = Some(*fire_at);
622            }
623            Event::TimerFired { timer_id: id, .. } | Event::TimerCancelled { timer_id: id, .. }
624                if id == timer_id =>
625            {
626                armed = None;
627            }
628            _ => {}
629        }
630    }
631    armed
632}
633
634/// The LAST recorded arming for `timer_id` anywhere in `history` — its
635/// `(fire_at, TimerStarted seq)` identity — or `None` when no arming was ever
636/// recorded.
637///
638/// This is the ROW-IDENTITY view, deliberately whole-history where the
639/// disposition helpers are active-segment: the durable timer row is keyed per
640/// timer id (not per run segment), so the last `TimerStarted` anywhere is the
641/// last writer of that row, whatever segment it lives in. Callers use it to
642/// retire exactly the row the consumed arming wrote — never a replacement's,
643/// even one re-armed to the identical instant (the seq differs).
644fn last_recorded_arming(history: &[Event], timer_id: &TimerId) -> Option<(DateTime<Utc>, u64)> {
645    history.iter().rev().find_map(|event| match event {
646        Event::TimerStarted {
647            envelope,
648            timer_id: id,
649            fire_at,
650        } if id == timer_id => Some((*fire_at, envelope.seq)),
651        _ => None,
652    })
653}
654
655/// The live timer ids in the workflow's active run segment, by last-event-wins.
656///
657/// Scans forward from the latest `WorkflowStarted` (the active run segment) and
658/// lets the *last* event for each timer id decide its liveness: a `TimerStarted`
659/// (re)arms it, a `TimerFired`/`TimerCancelled` retires it. This means a *named*
660/// timer that fired or was cancelled and then re-armed within the same segment
661/// (`TimerStarted(T), TimerFired(T), TimerStarted(T)`) is correctly reported live
662/// again, rather than judged terminal forever by the earlier terminal event.
663///
664/// Start order is preserved and a timer id started more than once is deduped, so
665/// the result is a stable, history-derived (and therefore replay-deterministic)
666/// view of which timers are outstanding. This is the single liveness model shared
667/// by the per-id views ([`timer_disposition_in_active_segment`] on the fire and
668/// cancel paths, [`armed_fire_at_in_active_segment`] for row retirement) and the
669/// cancel-path enumerator in `engine::api`, so they cannot diverge.
670pub(crate) fn live_timers_in_active_segment(history: &[Event]) -> Vec<TimerId> {
671    let mut live: Vec<TimerId> = Vec::new();
672    for event in active_segment(history) {
673        match event {
674            Event::TimerStarted { timer_id, .. } if !live.contains(timer_id) => {
675                live.push(timer_id.clone());
676            }
677            Event::TimerFired { timer_id, .. } | Event::TimerCancelled { timer_id, .. } => {
678                live.retain(|id| id != timer_id);
679            }
680            _ => {}
681        }
682    }
683    live
684}
685
686/// The workflow's active run segment: everything from the latest
687/// `WorkflowStarted` (the whole history when none is recorded — bare fixtures
688/// and coordinator histories).
689///
690/// The single segment anchor shared by [`live_timers_in_active_segment`] and
691/// [`timer_disposition_in_active_segment`], so the enumerating and the per-id
692/// view of the liveness model cannot disagree about where the active run
693/// begins.
694fn active_segment(history: &[Event]) -> &[Event] {
695    let segment_start = history
696        .iter()
697        .rposition(|event| matches!(event, Event::WorkflowStarted { .. }))
698        .unwrap_or(0);
699    &history[segment_start..]
700}
701
702/// The recorded fate of ONE timer in the workflow's active run segment, by the
703/// same last-event-wins rule as [`live_timers_in_active_segment`].
704///
705/// [`live_timers_in_active_segment`] can only answer "live or not"; the fire
706/// path needs to know WHY a timer is not live (aion#145): a timer whose last
707/// event is `TimerFired` already has its durable record — the fire's mailbox
708/// wake may still be owed — while a cancelled or absent timer owes nothing.
709/// This is the per-id view of the SAME model, not a fork of it: same segment
710/// anchor ([`active_segment`]), same last-event-wins traversal, so for every
711/// history and timer id, `Live` here if and only if the id appears in
712/// [`live_timers_in_active_segment`].
713#[derive(Clone, Copy, Debug, Eq, PartialEq)]
714pub(crate) enum TimerDisposition {
715    /// The timer's last event in the active segment is `TimerStarted`: live.
716    Live,
717    /// The timer's last event in the active segment is `TimerFired`: the
718    /// durable fire record exists (its mailbox wake may or may not have been
719    /// delivered — history cannot tell, which is why delivery is a pure,
720    /// duplicate-safe wake).
721    Fired,
722    /// The timer's last event in the active segment is `TimerCancelled`.
723    Cancelled,
724    /// The timer has no event in the active segment (never started there, or
725    /// started only in a prior, closed run segment).
726    Absent,
727}
728
729/// Computes [`TimerDisposition`] for `timer_id` over `history`.
730pub(crate) fn timer_disposition_in_active_segment(
731    history: &[Event],
732    timer_id: &TimerId,
733) -> TimerDisposition {
734    let mut disposition = TimerDisposition::Absent;
735    for event in active_segment(history) {
736        match event {
737            Event::TimerStarted { timer_id: id, .. } if id == timer_id => {
738                disposition = TimerDisposition::Live;
739            }
740            Event::TimerFired { timer_id: id, .. } if id == timer_id => {
741                disposition = TimerDisposition::Fired;
742            }
743            Event::TimerCancelled { timer_id: id, .. } if id == timer_id => {
744                disposition = TimerDisposition::Cancelled;
745            }
746            _ => {}
747        }
748    }
749    disposition
750}
751
752#[cfg(test)]
753mod tests {
754    use std::sync::Arc;
755
756    use aion_core::{Event, EventEnvelope, RunId, TimerCancelCause, TimerId, WorkflowId};
757    use aion_store::{InMemoryStore, ReadableEventStore, StoreError, WritableEventStore};
758    use chrono::{DateTime, Utc};
759
760    use super::{
761        TimerDisposition, TimerService, TimerServiceError, live_timers_in_active_segment,
762        timer_disposition_in_active_segment,
763    };
764    use crate::engine_seam::test_support::{
765        DeliveredWorkflowMessage, FakeEngineHandle, FakeEngineOperation,
766    };
767    use crate::engine_seam::{
768        EngineHandle, EngineSeamError, TimerWheelEntry, WorkflowProcessHandle, WorkflowResidency,
769    };
770    use crate::time::deadline::{DeadlineHandler, DeadlineHandlerError, deadline_timer_id};
771
772    fn instant(offset_seconds: i64) -> DateTime<Utc> {
773        DateTime::from_timestamp(1_700_000_000 + offset_seconds, 0).unwrap_or_default()
774    }
775
776    fn workflow_id() -> WorkflowId {
777        WorkflowId::new_v4()
778    }
779
780    fn timer_id() -> TimerId {
781        TimerId::anonymous(7)
782    }
783
784    fn service() -> (Arc<InMemoryStore>, Arc<FakeEngineHandle>, TimerService) {
785        let concrete_store = Arc::new(InMemoryStore::default());
786        let recorder_store: Arc<dyn WritableEventStore> = concrete_store.clone();
787        let readable_store: Arc<dyn ReadableEventStore> = concrete_store.clone();
788        let engine = Arc::new(FakeEngineHandle::recording_to(recorder_store));
789        let service = TimerService::with_recorded_at(engine.clone(), readable_store, recorded_at);
790        (concrete_store, engine, service)
791    }
792
793    fn recorded_at() -> DateTime<Utc> {
794        instant(1)
795    }
796
797    async fn history(
798        store: &InMemoryStore,
799        workflow_id: &WorkflowId,
800    ) -> Result<Vec<Event>, StoreError> {
801        store.read_history(workflow_id).await
802    }
803
804    fn count_timer_fired(events: &[Event], timer_id: &TimerId) -> usize {
805        events
806            .iter()
807            .filter(|event| {
808                matches!(event, Event::TimerFired { timer_id: recorded, .. } if recorded == timer_id)
809            })
810            .count()
811    }
812
813    fn timer_started_event(workflow_id: &WorkflowId, timer_id: &TimerId, seq: u64) -> Event {
814        Event::TimerStarted {
815            envelope: EventEnvelope {
816                seq,
817                recorded_at: instant(0),
818                workflow_id: workflow_id.clone(),
819            },
820            timer_id: timer_id.clone(),
821            fire_at: instant(5),
822        }
823    }
824
825    fn workflow_started_event(workflow_id: &WorkflowId, seq: u64) -> Event {
826        Event::WorkflowStarted {
827            envelope: EventEnvelope {
828                seq,
829                recorded_at: instant(0),
830                workflow_id: workflow_id.clone(),
831            },
832            workflow_type: "fixture".to_owned(),
833            input: aion_core::Payload::new(aion_core::ContentType::Json, b"null".to_vec()),
834            run_id: aion_core::RunId::new_v4(),
835            parent_run_id: None,
836            parent_workflow_id: None,
837            package_version: aion_core::PackageVersion::new("a".repeat(64)),
838        }
839    }
840
841    fn timer_fired_event(workflow_id: &WorkflowId, timer_id: &TimerId, seq: u64) -> Event {
842        Event::TimerFired {
843            envelope: EventEnvelope {
844                seq,
845                recorded_at: instant(0),
846                workflow_id: workflow_id.clone(),
847            },
848            timer_id: timer_id.clone(),
849        }
850    }
851
852    fn timer_cancelled_event(workflow_id: &WorkflowId, timer_id: &TimerId, seq: u64) -> Event {
853        Event::TimerCancelled {
854            cause: TimerCancelCause::WorkflowIntent,
855            envelope: EventEnvelope {
856                seq,
857                recorded_at: instant(0),
858                workflow_id: workflow_id.clone(),
859            },
860            timer_id: timer_id.clone(),
861        }
862    }
863
864    fn make_named(name: &str) -> TimerId {
865        // The name is a non-empty literal, so construction never fails; the
866        // anonymous fallback only exists to keep the helper total without an
867        // `unwrap`/`expect` (disallowed by clippy in this crate).
868        TimerId::named(name).unwrap_or_else(|_| TimerId::anonymous(0))
869    }
870
871    fn named_timer_id() -> TimerId {
872        make_named("review-deadline")
873    }
874
875    // --- `live_timers_in_active_segment` / timer-disposition semantics ---
876
877    #[test]
878    fn started_timer_is_live() {
879        let workflow_id = workflow_id();
880        let timer_id = named_timer_id();
881        let history = vec![
882            workflow_started_event(&workflow_id, 0),
883            timer_started_event(&workflow_id, &timer_id, 1),
884        ];
885        assert_eq!(live_timers_in_active_segment(&history), vec![timer_id]);
886    }
887
888    #[test]
889    fn started_then_fired_timer_is_dead() {
890        let workflow_id = workflow_id();
891        let timer_id = named_timer_id();
892        let history = vec![
893            workflow_started_event(&workflow_id, 0),
894            timer_started_event(&workflow_id, &timer_id, 1),
895            timer_fired_event(&workflow_id, &timer_id, 2),
896        ];
897        assert!(live_timers_in_active_segment(&history).is_empty());
898    }
899
900    #[test]
901    fn started_then_cancelled_timer_is_dead() {
902        let workflow_id = workflow_id();
903        let timer_id = named_timer_id();
904        let history = vec![
905            workflow_started_event(&workflow_id, 0),
906            timer_started_event(&workflow_id, &timer_id, 1),
907            timer_cancelled_event(&workflow_id, &timer_id, 2),
908        ];
909        assert!(live_timers_in_active_segment(&history).is_empty());
910    }
911
912    #[test]
913    fn restarted_named_timer_after_fire_is_live() {
914        // The bug fix: a named timer that fired then was re-armed in the same run
915        // segment must be live again (last-event-wins), not judged terminal forever
916        // by the earlier `TimerFired`.
917        let workflow_id = workflow_id();
918        let timer_id = named_timer_id();
919        let history = vec![
920            workflow_started_event(&workflow_id, 0),
921            timer_started_event(&workflow_id, &timer_id, 1),
922            timer_fired_event(&workflow_id, &timer_id, 2),
923            timer_started_event(&workflow_id, &timer_id, 3),
924        ];
925        assert_eq!(
926            live_timers_in_active_segment(&history),
927            vec![timer_id],
928            "a re-armed named timer is live again"
929        );
930    }
931
932    #[test]
933    fn restarted_named_timer_after_cancel_is_live() {
934        let workflow_id = workflow_id();
935        let timer_id = named_timer_id();
936        let history = vec![
937            workflow_started_event(&workflow_id, 0),
938            timer_started_event(&workflow_id, &timer_id, 1),
939            timer_cancelled_event(&workflow_id, &timer_id, 2),
940            timer_started_event(&workflow_id, &timer_id, 3),
941        ];
942        assert_eq!(live_timers_in_active_segment(&history), vec![timer_id]);
943    }
944
945    #[test]
946    fn prior_run_segment_timer_is_not_live() {
947        // A timer started in a run segment that a later `WorkflowStarted` closed
948        // (continue-as-new) is out of scope for the active segment.
949        let workflow_id = workflow_id();
950        let prior = named_timer_id();
951        let current = make_named("current-deadline");
952        let history = vec![
953            workflow_started_event(&workflow_id, 0),
954            timer_started_event(&workflow_id, &prior, 1),
955            // New run segment begins; the prior timer must not be surfaced.
956            workflow_started_event(&workflow_id, 2),
957            timer_started_event(&workflow_id, &current, 3),
958        ];
959        assert_eq!(live_timers_in_active_segment(&history), vec![current]);
960    }
961
962    /// The per-id disposition view (aion#145) must agree with the enumerating
963    /// liveness model on every shape: `Live` exactly when the id appears in
964    /// [`live_timers_in_active_segment`], with the not-live cases split by WHY.
965    /// Each case asserts both views so the two traversals cannot drift.
966    #[test]
967    fn disposition_tracks_the_last_event_for_the_id_and_agrees_with_liveness() {
968        let workflow_id = workflow_id();
969        let timer_id = named_timer_id();
970        let other = make_named("unrelated");
971        let assert_agrees = |history: &[Event], expected: TimerDisposition| {
972            assert_eq!(
973                timer_disposition_in_active_segment(history, &timer_id),
974                expected
975            );
976            assert_eq!(
977                live_timers_in_active_segment(history).contains(&timer_id),
978                expected == TimerDisposition::Live,
979                "the per-id disposition and the enumerating model disagree on liveness"
980            );
981        };
982
983        // Started → live.
984        let mut history = vec![
985            workflow_started_event(&workflow_id, 0),
986            timer_started_event(&workflow_id, &timer_id, 1),
987        ];
988        assert_agrees(&history, TimerDisposition::Live);
989
990        // Fired at head → the durable record exists (the aion#145 shape).
991        history.push(timer_fired_event(&workflow_id, &timer_id, 2));
992        assert_agrees(&history, TimerDisposition::Fired);
993
994        // A fire for an UNRELATED id must not disturb this timer's disposition.
995        history.push(timer_fired_event(&workflow_id, &other, 3));
996        assert_agrees(&history, TimerDisposition::Fired);
997
998        // Re-armed after the fire → live again (last-event-wins).
999        history.push(timer_started_event(&workflow_id, &timer_id, 4));
1000        assert_agrees(&history, TimerDisposition::Live);
1001
1002        // Cancelled at head → retired, owed nothing.
1003        history.push(timer_cancelled_event(&workflow_id, &timer_id, 5));
1004        assert_agrees(&history, TimerDisposition::Cancelled);
1005
1006        // A new run segment closes the book: the id is absent from the active
1007        // segment even though the prior segment fired and cancelled it.
1008        history.push(workflow_started_event(&workflow_id, 6));
1009        assert_agrees(&history, TimerDisposition::Absent);
1010
1011        // And with no events at all it was absent to begin with.
1012        assert_agrees(&[], TimerDisposition::Absent);
1013    }
1014
1015    #[tokio::test]
1016    async fn re_armed_named_timer_fires_again() -> Result<(), TimerServiceError> {
1017        // End-to-end firing-path guard: with last-event-wins, a re-armed named
1018        // timer is live, so `fire_timer` records a second `TimerFired` and
1019        // delivers it — rather than silently no-opping under the old
1020        // `any`-semantics.
1021        let process = WorkflowProcessHandle::new(42);
1022        let (store, engine, service) = service();
1023        let workflow_id = workflow_id();
1024        let timer_id = named_timer_id();
1025        let fire_at = instant(110);
1026        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1027        engine.record_workflow_event(
1028            &workflow_id,
1029            timer_started_event(&workflow_id, &timer_id, 1),
1030        )?;
1031        engine
1032            .record_workflow_event(&workflow_id, timer_fired_event(&workflow_id, &timer_id, 2))?;
1033        engine.record_workflow_event(
1034            &workflow_id,
1035            timer_started_event(&workflow_id, &timer_id, 3),
1036        )?;
1037
1038        service
1039            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1040            .await?;
1041
1042        assert_eq!(
1043            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1044            2,
1045            "the re-armed timer fires again, recording a second TimerFired"
1046        );
1047        assert_eq!(engine.delivered_messages()?.len(), 1);
1048        Ok(())
1049    }
1050
1051    fn workflow_completed_event(workflow_id: &WorkflowId, seq: u64) -> Event {
1052        Event::WorkflowCompleted {
1053            envelope: EventEnvelope {
1054                seq,
1055                recorded_at: instant(0),
1056                workflow_id: workflow_id.clone(),
1057            },
1058            result: aion_core::Payload::new(aion_core::ContentType::Json, b"null".to_vec()),
1059        }
1060    }
1061
1062    fn process_gone() -> EngineSeamError {
1063        EngineSeamError::Delivery {
1064            reason: "runtime error: process 42 is not live".to_owned(),
1065        }
1066    }
1067
1068    /// aion#215. On a boot sweep the workflow's own replay can fire a re-armed
1069    /// timer from the live wheel and finish the run between the sweep's
1070    /// residency read and its wake; the wake then meets a dead pid. That is
1071    /// nothing owed, not a failed boot: the fire is recorded, the row retires,
1072    /// the sweep completes.
1073    #[tokio::test]
1074    async fn a_wake_that_finds_the_process_gone_owes_nothing_when_the_run_is_terminal()
1075    -> Result<(), TimerServiceError> {
1076        let process = WorkflowProcessHandle::new(42);
1077        let (store, engine, service) = service();
1078        let workflow_id = workflow_id();
1079        let timer_id = named_timer_id();
1080        let fire_at = instant(110);
1081        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1082        engine.record_workflow_event(&workflow_id, workflow_started_event(&workflow_id, 1))?;
1083        engine.record_workflow_event(
1084            &workflow_id,
1085            timer_started_event(&workflow_id, &timer_id, 2),
1086        )?;
1087        // The run finished under the sweep's feet: terminal at head by the
1088        // time the wake is attempted, the timer never fired or cancelled in
1089        // its segment, so the sweep still sees it Live and records the fire.
1090        engine.record_workflow_event(&workflow_id, workflow_completed_event(&workflow_id, 3))?;
1091        service
1092            .schedule(workflow_id.clone(), timer_id.clone(), fire_at, 2)
1093            .await?;
1094        engine.push_delivery_response(Err(process_gone()))?;
1095
1096        service
1097            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1098            .await?;
1099
1100        assert_eq!(
1101            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1102            1,
1103            "the fire was recorded before the wake was attempted"
1104        );
1105        assert!(
1106            store.expired_timers(fire_at).await?.is_empty(),
1107            "a fire owed to a finished run consumes its arming: the row retires"
1108        );
1109        Ok(())
1110    }
1111
1112    /// The other half of aion#215: an ACTIVE run whose process is gone is a
1113    /// runtime fault. The sweep does not absorb it; the error stands and the
1114    /// row survives for the next sweep.
1115    #[tokio::test]
1116    async fn a_wake_that_finds_the_process_gone_stays_an_error_while_the_run_is_active()
1117    -> Result<(), TimerServiceError> {
1118        let process = WorkflowProcessHandle::new(42);
1119        let (store, engine, service) = service();
1120        let workflow_id = workflow_id();
1121        let timer_id = named_timer_id();
1122        let fire_at = instant(110);
1123        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1124        engine.record_workflow_event(&workflow_id, workflow_started_event(&workflow_id, 1))?;
1125        engine.record_workflow_event(
1126            &workflow_id,
1127            timer_started_event(&workflow_id, &timer_id, 2),
1128        )?;
1129        service
1130            .schedule(workflow_id.clone(), timer_id.clone(), fire_at, 2)
1131            .await?;
1132        engine.push_delivery_response(Err(process_gone()))?;
1133
1134        let outcome = service
1135            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1136            .await;
1137
1138        assert!(
1139            matches!(
1140                outcome,
1141                Err(TimerServiceError::Engine(EngineSeamError::Delivery { .. }))
1142            ),
1143            "an active run with a dead process is a fault the sweep must not absorb: {outcome:?}"
1144        );
1145        assert_eq!(
1146            store.expired_timers(fire_at).await?.len(),
1147            1,
1148            "the row survives for the next sweep's redelivery"
1149        );
1150        Ok(())
1151    }
1152
1153    #[tokio::test]
1154    async fn schedule_records_timer_row_without_timer_started_event()
1155    -> Result<(), TimerServiceError> {
1156        let (store, _engine, service) = service();
1157        let workflow_id = workflow_id();
1158        let timer_id = timer_id();
1159        let fire_at = instant(10);
1160
1161        service
1162            .schedule(workflow_id.clone(), timer_id.clone(), fire_at, 1)
1163            .await?;
1164
1165        let expired = store.expired_timers(fire_at).await?;
1166        assert_eq!(expired.len(), 1);
1167        assert_eq!(expired[0].workflow_id, workflow_id);
1168        assert_eq!(expired[0].timer_id, timer_id);
1169        assert_eq!(expired[0].fire_at, fire_at);
1170
1171        assert!(history(&store, &workflow_id).await?.is_empty());
1172        Ok(())
1173    }
1174
1175    #[tokio::test]
1176    async fn schedule_arms_wheel_for_resident_workflow() -> 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(20);
1182        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1183
1184        service
1185            .schedule(workflow_id, timer_id.clone(), fire_at, 1)
1186            .await?;
1187
1188        assert_eq!(
1189            engine.armed_timers()?,
1190            vec![TimerWheelEntry {
1191                process,
1192                timer_id,
1193                fire_at
1194            }]
1195        );
1196        Ok(())
1197    }
1198
1199    #[tokio::test]
1200    async fn schedule_for_nonresident_records_without_arming() -> Result<(), TimerServiceError> {
1201        let (store, engine, service) = service();
1202        let workflow_id = workflow_id();
1203        let timer_id = timer_id();
1204        let fire_at = instant(30);
1205        engine.set_residency(workflow_id.clone(), WorkflowResidency::NonResident)?;
1206
1207        service
1208            .schedule(workflow_id.clone(), timer_id, fire_at, 1)
1209            .await?;
1210
1211        assert!(engine.armed_timers()?.is_empty());
1212        assert!(history(&store, &workflow_id).await?.is_empty());
1213        Ok(())
1214    }
1215
1216    #[tokio::test]
1217    async fn fire_records_timer_fired_then_delivers_mailbox_message()
1218    -> Result<(), TimerServiceError> {
1219        let process = WorkflowProcessHandle::new(42);
1220        let (store, engine, service) = service();
1221        let workflow_id = workflow_id();
1222        let timer_id = timer_id();
1223        let fire_at = instant(40);
1224        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1225        engine.record_workflow_event(
1226            &workflow_id,
1227            timer_started_event(&workflow_id, &timer_id, 1),
1228        )?;
1229
1230        service
1231            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1232            .await?;
1233
1234        assert_eq!(
1235            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1236            1
1237        );
1238        assert_eq!(
1239            engine.delivered_messages()?,
1240            vec![(
1241                process,
1242                DeliveredWorkflowMessage::TimerFired {
1243                    timer_id: timer_id.clone(),
1244                    fire_at
1245                }
1246            )]
1247        );
1248        assert!(matches!(
1249            engine.operations()?.as_slice(),
1250            [
1251                FakeEngineOperation::EventRecorded {
1252                    event: Event::TimerStarted { .. },
1253                    ..
1254                },
1255                FakeEngineOperation::EventRecorded {
1256                    workflow_id: recorded_workflow_id,
1257                    event: Event::TimerFired { timer_id: recorded_timer_id, .. },
1258                },
1259                FakeEngineOperation::Delivered {
1260                    process: delivered_process,
1261                    message: DeliveredWorkflowMessage::TimerFired { timer_id: delivered_timer_id, .. },
1262                }
1263            ] if recorded_workflow_id == &workflow_id
1264                && recorded_timer_id == &timer_id
1265                && delivered_process == &process
1266                && delivered_timer_id == &timer_id
1267        ));
1268        Ok(())
1269    }
1270
1271    #[tokio::test]
1272    async fn fire_records_without_delivery_when_workflow_becomes_nonresident()
1273    -> Result<(), TimerServiceError> {
1274        let (store, engine, service) = service();
1275        let workflow_id = workflow_id();
1276        let timer_id = timer_id();
1277        let fire_at = instant(50);
1278        engine.set_residency(workflow_id.clone(), WorkflowResidency::NonResident)?;
1279        engine.record_workflow_event(
1280            &workflow_id,
1281            timer_started_event(&workflow_id, &timer_id, 1),
1282        )?;
1283
1284        service
1285            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1286            .await?;
1287
1288        assert_eq!(
1289            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1290            1
1291        );
1292        assert!(engine.delivered_messages()?.is_empty());
1293        Ok(())
1294    }
1295
1296    /// aion#145: a second fire of an already-fired timer records nothing new
1297    /// but RE-DELIVERS the owed wake to a resident workflow. Delivery is a pure
1298    /// wake (the suspended await re-resolves from history), so a duplicate is
1299    /// harmless — while the pre-fix silent no-op is exactly what wedged the
1300    /// incident's workflows: the durable `TimerFired` existed and the resident
1301    /// process waited forever on a wake that never came. Mutation-sensitive:
1302    /// reverting the `Fired`-disposition branch to a plain `Ok(())` leaves one
1303    /// delivery; a second append would raise the fired count to two.
1304    #[tokio::test]
1305    async fn firing_same_timer_twice_records_once_and_redelivers_the_wake()
1306    -> Result<(), TimerServiceError> {
1307        let process = WorkflowProcessHandle::new(42);
1308        let (store, engine, service) = service();
1309        let workflow_id = workflow_id();
1310        let timer_id = timer_id();
1311        let fire_at = instant(60);
1312        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1313        engine.record_workflow_event(
1314            &workflow_id,
1315            timer_started_event(&workflow_id, &timer_id, 1),
1316        )?;
1317
1318        service
1319            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1320            .await?;
1321        // The second fire re-enters the recorder seam, which answers
1322        // `AlreadyRecorded` without appending (the fake implements the seam
1323        // contract; the real bridge's under-lock decision — including the
1324        // recorder-sequence reconciliation — is pinned in
1325        // `nif_timer_bridge_tests`).
1326        service
1327            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1328            .await?;
1329
1330        assert_eq!(
1331            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1332            1,
1333            "the recorded fire must never be appended a second time"
1334        );
1335        assert_eq!(
1336            engine.delivered_messages()?.len(),
1337            2,
1338            "the second fire re-delivers the owed wake instead of silently no-opping"
1339        );
1340        Ok(())
1341    }
1342
1343    /// aion#145 Parts 2+3, service-seam mapping: a fire whose `TimerFired` is
1344    /// already the timer's last recorded event (the incident's ack-lost append)
1345    /// must NOT no-op for a resident workflow — it re-enters the recorder seam
1346    /// (which answers `AlreadyRecorded` without appending) and then delivers
1347    /// the owed wake. Mutation-sensitive both ways: reverting the
1348    /// `Fired`-disposition branch to `Ok(())` delivers nothing, and mapping
1349    /// `AlreadyRecorded` like `RefusedTerminal` delivers nothing.
1350    #[tokio::test]
1351    async fn already_recorded_fire_delivers_owed_wake_without_second_append()
1352    -> Result<(), TimerServiceError> {
1353        let process = WorkflowProcessHandle::new(42);
1354        let (store, engine, service) = service();
1355        let workflow_id = workflow_id();
1356        let timer_id = timer_id();
1357        let fire_at = instant(140);
1358        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1359        engine.record_workflow_event(
1360            &workflow_id,
1361            timer_started_event(&workflow_id, &timer_id, 1),
1362        )?;
1363        engine
1364            .record_workflow_event(&workflow_id, timer_fired_event(&workflow_id, &timer_id, 2))?;
1365
1366        service
1367            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1368            .await?;
1369
1370        assert_eq!(
1371            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1372            1,
1373            "the already-recorded fire must not be appended again"
1374        );
1375        assert_eq!(
1376            engine.delivered_messages()?,
1377            vec![(
1378                process,
1379                DeliveredWorkflowMessage::TimerFired { timer_id, fire_at }
1380            )],
1381            "the owed mailbox wake must be delivered"
1382        );
1383        Ok(())
1384    }
1385
1386    /// aion#145 test matrix row 3: fired-but-undelivered for a NON-resident
1387    /// workflow is a clean no-op — no wake is attempted (there is no live
1388    /// process to wake) and the recorder seam is not re-entered: replay on
1389    /// residency restore rebuilds the recorder from the durable head and
1390    /// consumes the recorded fire. Load-bearing assertions: exactly one
1391    /// durable `TimerFired`, and an empty delivery log.
1392    #[tokio::test]
1393    async fn already_recorded_fire_for_nonresident_workflow_wakes_nothing()
1394    -> Result<(), TimerServiceError> {
1395        let (store, engine, service) = service();
1396        let workflow_id = workflow_id();
1397        let timer_id = timer_id();
1398        engine.set_residency(workflow_id.clone(), WorkflowResidency::NonResident)?;
1399        engine.record_workflow_event(
1400            &workflow_id,
1401            timer_started_event(&workflow_id, &timer_id, 1),
1402        )?;
1403        engine
1404            .record_workflow_event(&workflow_id, timer_fired_event(&workflow_id, &timer_id, 2))?;
1405
1406        service
1407            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(150))
1408            .await?;
1409
1410        assert_eq!(
1411            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1412            1,
1413            "a non-resident redelivery must not re-enter the recorder seam"
1414        );
1415        assert!(
1416            engine.delivered_messages()?.is_empty(),
1417            "no wake is attempted for a non-resident workflow"
1418        );
1419        Ok(())
1420    }
1421
1422    /// aion#145: the redelivery path still honors the post-terminal refusal.
1423    /// A recorded fire whose run has since reached a terminal gets NO wake —
1424    /// the recorder seam answers `RefusedTerminal` and the recorded fire is
1425    /// inert history. Mutation-sensitive: delivering the wake regardless of the
1426    /// refusal would reschedule a terminated workflow.
1427    #[tokio::test]
1428    async fn already_recorded_fire_after_run_terminal_delivers_no_wake()
1429    -> Result<(), TimerServiceError> {
1430        let process = WorkflowProcessHandle::new(42);
1431        let (store, engine, service) = service();
1432        let workflow_id = workflow_id();
1433        let timer_id = timer_id();
1434        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1435        engine.record_workflow_event(
1436            &workflow_id,
1437            timer_started_event(&workflow_id, &timer_id, 1),
1438        )?;
1439        engine
1440            .record_workflow_event(&workflow_id, timer_fired_event(&workflow_id, &timer_id, 2))?;
1441        engine.refuse_next_record_as_terminal()?;
1442
1443        service
1444            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(160))
1445            .await?;
1446
1447        assert_eq!(
1448            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1449            1
1450        );
1451        assert!(
1452            engine.delivered_messages()?.is_empty(),
1453            "a post-terminal redelivery must not wake the terminated run"
1454        );
1455        Ok(())
1456    }
1457
1458    #[tokio::test]
1459    async fn firing_cancelled_timer_is_noop() -> Result<(), TimerServiceError> {
1460        let process = WorkflowProcessHandle::new(42);
1461        let (store, engine, service) = service();
1462        let workflow_id = workflow_id();
1463        let timer_id = timer_id();
1464        let fire_at = instant(70);
1465        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1466        engine.record_workflow_event(
1467            &workflow_id,
1468            timer_started_event(&workflow_id, &timer_id, 1),
1469        )?;
1470        let cancelled = Event::TimerCancelled {
1471            cause: TimerCancelCause::WorkflowIntent,
1472            envelope: EventEnvelope {
1473                seq: 2,
1474                recorded_at: instant(69),
1475                workflow_id: workflow_id.clone(),
1476            },
1477            timer_id: timer_id.clone(),
1478        };
1479        engine.record_workflow_event(&workflow_id, cancelled)?;
1480
1481        service
1482            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1483            .await?;
1484
1485        let history = history(&store, &workflow_id).await?;
1486        assert_eq!(count_timer_fired(&history, &timer_id), 0);
1487        assert!(engine.delivered_messages()?.is_empty());
1488        Ok(())
1489    }
1490
1491    #[tokio::test]
1492    async fn fire_resolves_residency_at_fire_time() -> Result<(), TimerServiceError> {
1493        let process = WorkflowProcessHandle::new(42);
1494        let (store, engine, service) = service();
1495        let workflow_id = workflow_id();
1496        let timer_id = timer_id();
1497        let fire_at = instant(80);
1498
1499        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1500        engine.set_residency(workflow_id.clone(), WorkflowResidency::NonResident)?;
1501        engine.record_workflow_event(
1502            &workflow_id,
1503            timer_started_event(&workflow_id, &timer_id, 1),
1504        )?;
1505        service
1506            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1507            .await?;
1508
1509        assert_eq!(
1510            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1511            1
1512        );
1513        assert!(engine.delivered_messages()?.is_empty());
1514        Ok(())
1515    }
1516
1517    #[tokio::test]
1518    async fn firing_unstarted_timer_records_nothing() -> Result<(), TimerServiceError> {
1519        let process = WorkflowProcessHandle::new(42);
1520        let (store, engine, service) = service();
1521        let workflow_id = workflow_id();
1522        let timer_id = timer_id();
1523        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1524
1525        service
1526            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(90))
1527            .await?;
1528
1529        assert!(history(&store, &workflow_id).await?.is_empty());
1530        assert!(engine.delivered_messages()?.is_empty());
1531        Ok(())
1532    }
1533
1534    /// A deadline handler that records each fire and can be told to fail.
1535    struct RecordingDeadlineHandler {
1536        calls: std::sync::Mutex<Vec<(WorkflowId, RunId)>>,
1537        fail: bool,
1538    }
1539
1540    impl RecordingDeadlineHandler {
1541        fn new(fail: bool) -> Self {
1542            Self {
1543                calls: std::sync::Mutex::new(Vec::new()),
1544                fail,
1545            }
1546        }
1547
1548        fn calls(&self) -> Result<Vec<(WorkflowId, RunId)>, TimerServiceError> {
1549            self.calls
1550                .lock()
1551                .map(|calls| calls.clone())
1552                .map_err(|error| TimerServiceError::Deadline(error.to_string()))
1553        }
1554    }
1555
1556    #[async_trait::async_trait]
1557    impl DeadlineHandler for RecordingDeadlineHandler {
1558        async fn on_deadline_elapsed(
1559            &self,
1560            workflow_id: WorkflowId,
1561            run_id: RunId,
1562        ) -> Result<(), DeadlineHandlerError> {
1563            self.calls
1564                .lock()
1565                .map_err(|error| DeadlineHandlerError(error.to_string()))?
1566                .push((workflow_id, run_id));
1567            if self.fail {
1568                Err(DeadlineHandlerError(
1569                    "deliberate handler failure".to_owned(),
1570                ))
1571            } else {
1572                Ok(())
1573            }
1574        }
1575    }
1576
1577    fn service_with_handler(
1578        handler: Arc<dyn DeadlineHandler>,
1579    ) -> (Arc<InMemoryStore>, Arc<FakeEngineHandle>, TimerService) {
1580        let concrete_store = Arc::new(InMemoryStore::default());
1581        let recorder_store: Arc<dyn WritableEventStore> = concrete_store.clone();
1582        let readable_store: Arc<dyn ReadableEventStore> = concrete_store.clone();
1583        let engine = Arc::new(FakeEngineHandle::recording_to(recorder_store));
1584        let service = TimerService::with_recorded_at(engine.clone(), readable_store, recorded_at)
1585            .with_deadline_handler(handler);
1586        (concrete_store, engine, service)
1587    }
1588
1589    /// A live reserved deadline fire is demuxed to the registered handler with
1590    /// the id-encoded run, and records NO `TimerFired` and delivers nothing.
1591    #[tokio::test]
1592    async fn deadline_fire_routes_to_handler_and_records_no_timer_fired()
1593    -> Result<(), TimerServiceError> {
1594        let run_id = RunId::new_v4();
1595        let deadline_id = deadline_timer_id(&run_id)
1596            .map_err(|error| TimerServiceError::Deadline(error.to_string()))?;
1597        let handler = Arc::new(RecordingDeadlineHandler::new(false));
1598        let (store, engine, service) = service_with_handler(handler.clone());
1599        let workflow_id = workflow_id();
1600        let fire_at = instant(120);
1601        engine.set_residency(
1602            workflow_id.clone(),
1603            WorkflowResidency::Resident(WorkflowProcessHandle::new(9)),
1604        )?;
1605        engine.record_workflow_event(
1606            &workflow_id,
1607            timer_started_event(&workflow_id, &deadline_id, 1),
1608        )?;
1609
1610        service
1611            .fire_timer(workflow_id.clone(), deadline_id.clone(), fire_at)
1612            .await?;
1613
1614        assert_eq!(handler.calls()?, vec![(workflow_id.clone(), run_id)]);
1615        assert_eq!(
1616            count_timer_fired(&history(&store, &workflow_id).await?, &deadline_id),
1617            0,
1618            "a deadline fire never records TimerFired"
1619        );
1620        assert!(engine.delivered_messages()?.is_empty());
1621        Ok(())
1622    }
1623
1624    /// A deadline fire with no handler registered is a typed error — never a
1625    /// silent generic fire.
1626    #[tokio::test]
1627    async fn deadline_fire_without_handler_is_typed_error() -> Result<(), TimerServiceError> {
1628        let run_id = RunId::new_v4();
1629        let deadline_id = deadline_timer_id(&run_id)
1630            .map_err(|error| TimerServiceError::Deadline(error.to_string()))?;
1631        let (store, engine, service) = service();
1632        let workflow_id = workflow_id();
1633        engine.record_workflow_event(
1634            &workflow_id,
1635            timer_started_event(&workflow_id, &deadline_id, 1),
1636        )?;
1637
1638        let result = service
1639            .fire_timer(workflow_id.clone(), deadline_id.clone(), instant(120))
1640            .await;
1641
1642        assert!(
1643            matches!(result, Err(TimerServiceError::Deadline(_))),
1644            "unhandled deadline fire must be a typed error, got {result:?}"
1645        );
1646        assert_eq!(
1647            count_timer_fired(&history(&store, &workflow_id).await?, &deadline_id),
1648            0
1649        );
1650        Ok(())
1651    }
1652
1653    /// A handler failure surfaces as a typed deadline error to the caller.
1654    #[tokio::test]
1655    async fn deadline_handler_failure_surfaces_as_typed_error() -> Result<(), TimerServiceError> {
1656        let run_id = RunId::new_v4();
1657        let deadline_id = deadline_timer_id(&run_id)
1658            .map_err(|error| TimerServiceError::Deadline(error.to_string()))?;
1659        let handler = Arc::new(RecordingDeadlineHandler::new(true));
1660        let (_store, engine, service) = service_with_handler(handler);
1661        let workflow_id = workflow_id();
1662        engine.record_workflow_event(
1663            &workflow_id,
1664            timer_started_event(&workflow_id, &deadline_id, 1),
1665        )?;
1666
1667        let result = service
1668            .fire_timer(workflow_id, deadline_id, instant(120))
1669            .await;
1670
1671        assert!(matches!(result, Err(TimerServiceError::Deadline(_))));
1672        Ok(())
1673    }
1674
1675    /// A fire the recorder refuses as a post-terminal late arrival records
1676    /// nothing AND delivers no wake. Mutation-sensitive: the timer is live so the
1677    /// pre-check passes and the fire reaches the recorder seam, which returns
1678    /// `RefusedTerminal`; delivering the mailbox wake regardless of that outcome
1679    /// would reschedule a terminated workflow and fail this test.
1680    #[tokio::test]
1681    async fn refused_terminal_fire_records_nothing_and_delivers_no_wake()
1682    -> Result<(), TimerServiceError> {
1683        let process = WorkflowProcessHandle::new(42);
1684        let (store, engine, service) = service();
1685        let workflow_id = workflow_id();
1686        let timer_id = timer_id();
1687        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1688        engine.record_workflow_event(
1689            &workflow_id,
1690            timer_started_event(&workflow_id, &timer_id, 1),
1691        )?;
1692        engine.refuse_next_record_as_terminal()?;
1693
1694        service
1695            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(130))
1696            .await?;
1697
1698        assert_eq!(
1699            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1700            0,
1701            "a refused fire records no TimerFired"
1702        );
1703        assert!(
1704            engine.delivered_messages()?.is_empty(),
1705            "a refused fire delivers no wake"
1706        );
1707        Ok(())
1708    }
1709
1710    /// Two services obtained separately but sharing ONE terminal-update
1711    /// coordinator (as the production bridge hands out) serialize a cancel and a
1712    /// fire of the same timer: exactly one terminal timer event is recorded, never
1713    /// both. A `Barrier` forces genuine overlap — both actors are released
1714    /// together after setup — and the loop runs each direction. Mutation-sensitive:
1715    /// a per-service coordinator would let both read the timer live and record a
1716    /// `TimerFired` AND a `TimerCancelled`.
1717    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1718    async fn shared_coordinator_serializes_cancel_and_fire_across_services()
1719    -> Result<(), TimerServiceError> {
1720        use dashmap::DashSet;
1721        use tokio::sync::Barrier;
1722
1723        for _ in 0..20 {
1724            let process = WorkflowProcessHandle::new(42);
1725            let concrete_store = Arc::new(InMemoryStore::default());
1726            let recorder_store: Arc<dyn WritableEventStore> = concrete_store.clone();
1727            let readable: Arc<dyn ReadableEventStore> = concrete_store.clone();
1728            let engine = Arc::new(FakeEngineHandle::recording_to(recorder_store));
1729            let coordinator = Arc::new(DashSet::new());
1730            let service_a =
1731                TimerService::with_recorded_at(engine.clone(), readable.clone(), recorded_at)
1732                    .with_terminal_updates(Arc::clone(&coordinator));
1733            let service_b =
1734                TimerService::with_recorded_at(engine.clone(), readable.clone(), recorded_at)
1735                    .with_terminal_updates(Arc::clone(&coordinator));
1736
1737            let workflow_id = workflow_id();
1738            let timer_id = timer_id();
1739            let fire_at = instant(200);
1740            engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1741            engine.record_workflow_event(
1742                &workflow_id,
1743                timer_started_event(&workflow_id, &timer_id, 1),
1744            )?;
1745
1746            let gate = Arc::new(Barrier::new(2));
1747            let (cancel_gate, fire_gate) = (Arc::clone(&gate), gate);
1748            let (cancel_wf, cancel_timer) = (workflow_id.clone(), timer_id.clone());
1749            let cancel = async move {
1750                cancel_gate.wait().await;
1751                service_a
1752                    .cancel(cancel_wf, cancel_timer, TimerCancelCause::WorkflowIntent)
1753                    .await
1754            };
1755            let (fire_wf, fire_timer) = (workflow_id.clone(), timer_id.clone());
1756            let fire = async move {
1757                fire_gate.wait().await;
1758                service_b.fire_timer(fire_wf, fire_timer, fire_at).await
1759            };
1760            let (cancel_result, fire_result) = tokio::join!(cancel, fire);
1761            cancel_result?;
1762            fire_result?;
1763
1764            let history = history(&concrete_store, &workflow_id).await?;
1765            let terminal_timer_events = history
1766                .iter()
1767                .filter(|event| {
1768                    matches!(
1769                        event,
1770                        Event::TimerFired { timer_id: recorded, .. }
1771                        | Event::TimerCancelled { timer_id: recorded, .. }
1772                            if recorded == &timer_id
1773                    )
1774                })
1775                .count();
1776            assert_eq!(
1777                terminal_timer_events, 1,
1778                "first-recorded wins across shared services: {history:#?}"
1779            );
1780        }
1781        Ok(())
1782    }
1783
1784    #[tokio::test]
1785    async fn firing_prior_run_timer_after_continue_as_new_is_noop() -> Result<(), TimerServiceError>
1786    {
1787        let process = WorkflowProcessHandle::new(42);
1788        let (store, engine, service) = service();
1789        let workflow_id = workflow_id();
1790        let timer_id = timer_id();
1791        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1792        // Run 1 started the timer; run 2's WorkflowStarted closes that segment.
1793        engine.record_workflow_event(
1794            &workflow_id,
1795            timer_started_event(&workflow_id, &timer_id, 1),
1796        )?;
1797        engine.record_workflow_event(&workflow_id, workflow_started_event(&workflow_id, 2))?;
1798
1799        service
1800            .fire_timer(workflow_id.clone(), timer_id.clone(), instant(100))
1801            .await?;
1802
1803        assert_eq!(
1804            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1805            0
1806        );
1807        assert!(engine.delivered_messages()?.is_empty());
1808        Ok(())
1809    }
1810
1811    // --- consumed-arming row retirement (the collapse fix's engine half) ---
1812
1813    /// The rows outstanding at `as_of`, for asserting what a boot sweep
1814    /// would still walk.
1815    async fn outstanding_rows(
1816        store: &InMemoryStore,
1817        as_of: DateTime<Utc>,
1818    ) -> Result<usize, StoreError> {
1819        Ok(store.expired_timers(as_of).await?.len())
1820    }
1821
1822    /// A recorded fire retires the consumed arming's durable row: the boot
1823    /// sweep that used to re-walk every consumed row (the estate's
1824    /// 1,434-line 2026-08-24 boot) finds nothing left for this timer.
1825    #[tokio::test]
1826    async fn a_recorded_fire_retires_the_consumed_row() -> Result<(), TimerServiceError> {
1827        let process = WorkflowProcessHandle::new(42);
1828        let (store, engine, service) = service();
1829        let workflow_id = workflow_id();
1830        let timer_id = timer_id();
1831        let fire_at = instant(40);
1832        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1833        engine.record_workflow_event(
1834            &workflow_id,
1835            timer_started_event(&workflow_id, &timer_id, 1),
1836        )?;
1837        service
1838            .schedule(workflow_id.clone(), timer_id.clone(), fire_at, 1)
1839            .await?;
1840        assert_eq!(
1841            outstanding_rows(&store, instant(1_000)).await?,
1842            1,
1843            "precondition: the arming's row is durable before the fire"
1844        );
1845
1846        service
1847            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1848            .await?;
1849
1850        assert_eq!(
1851            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1852            1,
1853            "the fire itself must still record"
1854        );
1855        assert_eq!(
1856            outstanding_rows(&store, instant(1_000)).await?,
1857            0,
1858            "a recorded fire must retire the consumed arming's row"
1859        );
1860        Ok(())
1861    }
1862
1863    /// A recorded cancel retires the arming's row just as a fire does: a
1864    /// cancelled timer owes no recovery fire, so its row must not outlive it.
1865    #[tokio::test]
1866    async fn a_recorded_cancel_retires_the_consumed_row() -> Result<(), TimerServiceError> {
1867        let process = WorkflowProcessHandle::new(42);
1868        let (store, engine, service) = service();
1869        let workflow_id = workflow_id();
1870        let timer_id = named_timer_id();
1871        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1872        // The history's arming carries instant(5) (the helper's fire_at); the
1873        // row must carry the same instant for the conditional retire to see
1874        // one consistent arming.
1875        engine.record_workflow_event(
1876            &workflow_id,
1877            timer_started_event(&workflow_id, &timer_id, 1),
1878        )?;
1879        service
1880            .schedule(workflow_id.clone(), timer_id.clone(), instant(5), 1)
1881            .await?;
1882        assert_eq!(outstanding_rows(&store, instant(1_000)).await?, 1);
1883
1884        service
1885            .cancel(
1886                workflow_id.clone(),
1887                timer_id.clone(),
1888                TimerCancelCause::WorkflowIntent,
1889            )
1890            .await?;
1891
1892        assert_eq!(
1893            outstanding_rows(&store, instant(1_000)).await?,
1894            0,
1895            "a recorded cancel must retire the cancelled arming's row"
1896        );
1897        Ok(())
1898    }
1899
1900    /// THE RE-ARM RACE the `fire_at` condition exists for: a stale fire
1901    /// (armed for the OLD `fire_at`) must not retire the row a re-armed timer
1902    /// wrote with a NEW `fire_at` — that row is the replacement arming's only durable
1903    /// claim to a recovery fire, and deleting it is a lost wake after
1904    /// restart.
1905    #[tokio::test]
1906    async fn a_stale_fire_leaves_a_re_armed_timers_row() -> Result<(), TimerServiceError> {
1907        let process = WorkflowProcessHandle::new(42);
1908        let (store, engine, service) = service();
1909        let workflow_id = workflow_id();
1910        let timer_id = named_timer_id();
1911        let old_fire_at = instant(5);
1912        let new_fire_at = instant(500);
1913        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1914        engine.record_workflow_event(
1915            &workflow_id,
1916            timer_started_event(&workflow_id, &timer_id, 1),
1917        )?;
1918        // The re-arm overwrites the timer's single row with the new fire_at.
1919        service
1920            .schedule(workflow_id.clone(), timer_id.clone(), new_fire_at, 2)
1921            .await?;
1922
1923        // The stale wheel callback for the OLD arming arrives late.
1924        service
1925            .fire_timer(workflow_id.clone(), timer_id.clone(), old_fire_at)
1926            .await?;
1927
1928        assert_eq!(
1929            outstanding_rows(&store, instant(1_000)).await?,
1930            1,
1931            "the re-armed row is the replacement arming's only durable claim \
1932             to a recovery fire; a stale retire must leave it standing"
1933        );
1934        Ok(())
1935    }
1936
1937    /// A fire refused because the run reached its terminal retires the row:
1938    /// the fire can never record, the arming is moot forever, and without
1939    /// retirement a terminal workflow's rows survive every boot.
1940    #[tokio::test]
1941    async fn a_terminal_refused_fire_retires_the_row() -> Result<(), TimerServiceError> {
1942        let process = WorkflowProcessHandle::new(42);
1943        let (store, engine, service) = service();
1944        let workflow_id = workflow_id();
1945        let timer_id = timer_id();
1946        let fire_at = instant(40);
1947        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1948        engine.record_workflow_event(
1949            &workflow_id,
1950            timer_started_event(&workflow_id, &timer_id, 1),
1951        )?;
1952        service
1953            .schedule(workflow_id.clone(), timer_id.clone(), fire_at, 1)
1954            .await?;
1955        engine.refuse_next_record_as_terminal()?;
1956
1957        service
1958            .fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
1959            .await?;
1960
1961        assert_eq!(
1962            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1963            0,
1964            "the refused fire must record nothing"
1965        );
1966        assert!(
1967            engine.delivered_messages()?.is_empty(),
1968            "a refused fire must wake nothing"
1969        );
1970        assert_eq!(
1971            outstanding_rows(&store, instant(1_000)).await?,
1972            0,
1973            "a terminal-refused fire's arming is moot forever; its row retires"
1974        );
1975        Ok(())
1976    }
1977}