Skip to main content

aion/time/
recovery.rs

1//! Expired timer polling at engine boot and shard adoption.
2//!
3//! THE BOOT/ADOPTION SWEEP IS THE ONLY SWEEP. [`TimerRecovery::recover_on_startup`]
4//! runs exactly twice per engine lifetime shape: once at engine startup and
5//! once per shard adoption (`Engine::adopt_shards`). There is deliberately NO
6//! periodic driver in this landing — a row this sweep leaves behind (a failed
7//! retirement, a wake lost after recording) heals at the NEXT BOOT OR
8//! ADOPTION SWEEP, not on any timer-driven cadence. The periodic driver is
9//! commissioned separately (interval from server config/builder, absence =
10//! boot-only, loudly documented) — see the periodic-driver brief in the
11//! collapse lane's follow-ups; this module must not pretend it exists.
12//!
13//! The sweep is GROUPED: due rows are bucketed by workflow and one
14//! history read per workflow answers every question the sweep has — is the
15//! workflow terminal (all its due rows are moot), which future timers still
16//! need re-arming, and each due row's disposition. Only rows
17//! that still owe fire-path work (a live arming whose row matches the
18//! recorded arming, or a recorded fire whose mailbox wake may still be owed —
19//! aion#145) enter
20//! [`TimerService::fire_timer`]; consumed rows retire in bulk. Before
21//! retirement existed, every consumed row was re-walked by every sweep
22//! forever — the boot-walk collapse this module's grouping completes.
23//!
24//! The honest cost model: the SWEEP'S OWN store reads scale with distinct
25//! workflows, never with rows. Rows that enter the fire or redelivery paths
26//! additionally pay those paths' own costs — a live fire's gate read and
27//! recorder append, and a redelivery's history read UNDER THE RECORDER LOCK
28//! inside [`EngineHandle::record_redelivered_timer_fire`] — so the first
29//! boot after a backlog accumulated still pays roughly one recorder-seam
30//! round trip per surviving row. What the collapse guarantees is that each
31//! such row pays it ONCE, EVER: retirement (or supersession) ends the row's
32//! life, and the steady-state sweep reads nothing but the expired index.
33//!
34//! [`EngineHandle::record_redelivered_timer_fire`]: crate::engine_seam::EngineHandle::record_redelivered_timer_fire
35
36use aion_core::{Event, TimerId, WorkflowId, status_from_events};
37use std::collections::HashMap;
38use std::sync::Arc;
39
40use aion_store::{ReadableEventStore, StoreError, TimerEntry};
41use chrono::{DateTime, Utc};
42
43use crate::engine_seam::EngineSeamError;
44use crate::time::timer_service::{
45    RetireAttempt, TimerDisposition, armed_fire_at_in_active_segment,
46    timer_disposition_in_active_segment,
47};
48use crate::time::{TimerService, TimerServiceError, is_deadline_timer};
49
50/// Recovery service for durable timers that elapsed outside the live wheel path.
51pub struct TimerRecovery {
52    store: Arc<dyn ReadableEventStore>,
53    timer_service: Arc<TimerService>,
54    /// Workflows already warned about as orphans (rows with no usable
55    /// history, or gone from the engine), so a permanent orphan produces ONE
56    /// warning, not one per row per sweep. The rows themselves are still
57    /// counted by every sweep — `skipped_orphans` in the summary line is the
58    /// standing gauge of the population this sweep cannot explain.
59    warned_orphans: std::sync::Mutex<std::collections::HashSet<WorkflowId>>,
60}
61
62/// Errors returned by [`TimerRecovery`].
63#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
64pub enum TimerRecoveryError {
65    /// Durable timer polling failed.
66    #[error("timer recovery store operation failed: {0}")]
67    Store(#[from] StoreError),
68
69    /// Recovered timer firing failed.
70    #[error("timer recovery fire operation failed: {0}")]
71    Timer(#[from] TimerServiceError),
72}
73
74impl TimerRecovery {
75    /// Creates the boot/adoption timer-recovery sweep service.
76    ///
77    /// There is deliberately no interval or clock parameter: the sweep runs
78    /// only when the engine boots or adopts a shard, and the sweep instant is
79    /// the caller's `now` argument to [`Self::recover_on_startup`].
80    #[must_use]
81    pub fn new(store: Arc<dyn ReadableEventStore>, timer_service: Arc<TimerService>) -> Self {
82        Self {
83            store,
84            timer_service,
85            warned_orphans: std::sync::Mutex::new(std::collections::HashSet::new()),
86        }
87    }
88
89    /// Runs the boot/adoption recovery sweep for timers due as of `now` — the
90    /// ONLY sweep this landing has (engine startup and `adopt_shards` both
91    /// drive it; nothing runs it periodically).
92    ///
93    /// One history read per workflow serves the WHOLE startup sweep: the same
94    /// read re-arms the workflow's outstanding future timers on the wheel and
95    /// disposes its due rows ([`Self::dispose_due_rows`]). Workflows outside
96    /// the active set that still hold due rows (terminal, paused, or unknown
97    /// to the store) are read once each after the active pass.
98    ///
99    /// Returns the sweep's [`SweepCounts`] — every due row lands in exactly
100    /// one of its counters, so their sum equals the due-row population.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`TimerRecoveryError`] when polling expired timers, reading a
105    /// history, re-arming a future timer, or firing a due timer fails.
106    pub async fn recover_on_startup(
107        &self,
108        now: DateTime<Utc>,
109    ) -> Result<SweepCounts, TimerRecoveryError> {
110        let sweep_started = std::time::Instant::now();
111        let due_by_workflow = group_by_workflow(self.store.expired_timers(now).await?);
112        let expired_rows: usize = due_by_workflow.values().map(Vec::len).sum();
113        tracing::info!(
114            expired_rows,
115            due_workflows = due_by_workflow.len(),
116            "timer recovery startup sweep started"
117        );
118        let mut counts = SweepCounts::default();
119        let mut rearmed = 0usize;
120        let result = self
121            .startup_passes(now, due_by_workflow, &mut counts, &mut rearmed)
122            .await;
123        // The summary is emitted on BOTH exits: on the boot where the sweep
124        // dies, the operator needs how far it got — a missing line cannot
125        // distinguish a half-completed sweep from one that never started.
126        let elapsed_ms = u64::try_from(sweep_started.elapsed().as_millis()).unwrap_or(u64::MAX);
127        match &result {
128            Ok(()) => tracing::info!(
129                fired = counts.fired,
130                redelivered = counts.redelivered,
131                retired = counts.retired,
132                superseded = counts.superseded,
133                retire_failures = counts.retire_failures,
134                skipped_orphans = counts.skipped_orphans,
135                rearmed,
136                elapsed_ms,
137                "timer recovery startup sweep finished"
138            ),
139            Err(error) => tracing::warn!(
140                fired = counts.fired,
141                redelivered = counts.redelivered,
142                retired = counts.retired,
143                superseded = counts.superseded,
144                retire_failures = counts.retire_failures,
145                skipped_orphans = counts.skipped_orphans,
146                rearmed,
147                elapsed_ms,
148                %error,
149                "timer recovery startup sweep ABORTED; counters cover the work \
150                 completed before the failure"
151            ),
152        }
153        result.map(|()| counts)
154    }
155
156    /// The startup sweep's two passes (active re-arm + disposition, then
157    /// leftover disposition), split out so the caller can emit the summary
158    /// line on the error exit too.
159    async fn startup_passes(
160        &self,
161        now: DateTime<Utc>,
162        mut due_by_workflow: HashMap<WorkflowId, Vec<TimerEntry>>,
163        counts: &mut SweepCounts,
164        rearmed: &mut usize,
165    ) -> Result<(), TimerRecoveryError> {
166        for workflow_id in self.store.list_active().await? {
167            let history = self.store.read_history(&workflow_id).await?;
168            for (timer_id, fire_at, armed_seq) in outstanding_future_timers(&history, now) {
169                self.timer_service
170                    .schedule(workflow_id.clone(), timer_id, fire_at, armed_seq)
171                    .await?;
172                *rearmed += 1;
173            }
174            if let Some(entries) = due_by_workflow.remove(&workflow_id) {
175                self.dispose_due_rows(&workflow_id, &history, entries, counts)
176                    .await?;
177            }
178        }
179        for (workflow_id, entries) in due_by_workflow {
180            let history = self.store.read_history(&workflow_id).await?;
181            self.dispose_due_rows(&workflow_id, &history, entries, counts)
182                .await?;
183        }
184        Ok(())
185    }
186
187    /// Warn about an orphaned workflow exactly once per process lifetime.
188    ///
189    /// Returns `true` when this call was the first sighting. A poisoned set
190    /// fails OPEN (warn again) — deduplication is a courtesy, never a reason
191    /// to suppress an operator signal.
192    fn first_orphan_sighting(&self, workflow_id: &WorkflowId) -> bool {
193        self.warned_orphans
194            .lock()
195            .map_or(true, |mut warned| warned.insert(workflow_id.clone()))
196    }
197
198    /// Dispose one workflow's due rows from a single already-read history.
199    ///
200    /// - No history at all: the store holds rows for a workflow it has no
201    ///   events for. Conservative orphan shape — skip and count, never retire
202    ///   on a history that answers nothing.
203    /// - Projected TERMINAL: every due row is moot (no fire can ever record),
204    ///   so the rows retire in bulk without entering the fire path. This is
205    ///   what empties the boot walk for completed workflows.
206    /// - Otherwise, per row: a live arming WHOSE ROW MATCHES the recorded
207    ///   arming's `fire_at` — or a recorded non-deadline fire whose mailbox
208    ///   wake may still be owed (aion#145) — goes through
209    ///   [`TimerService::fire_timer`], which owns terminal filtering, the
210    ///   fire guard, recording, delivery, and the consumed row's retirement.
211    ///   A live arming whose row DISAGREES with history is a stale row
212    ///   (round-2 F1) and retires without firing. Consumed rows (fired
213    ///   deadline, cancelled, absent from the active segment) retire
214    ///   directly.
215    async fn dispose_due_rows(
216        &self,
217        workflow_id: &WorkflowId,
218        history: &[Event],
219        entries: Vec<TimerEntry>,
220        counts: &mut SweepCounts,
221    ) -> Result<(), TimerRecoveryError> {
222        if history.is_empty() {
223            counts.skipped_orphans += entries.len();
224            if self.first_orphan_sighting(workflow_id) {
225                tracing::warn!(
226                    %workflow_id,
227                    rows = entries.len(),
228                    "skipping due timer rows for a workflow with no recorded history \
229                     (orphaned rows); nothing is fired and nothing is retired — \
230                     counted in every sweep's skipped_orphans, warned once"
231                );
232            }
233            return Ok(());
234        }
235        if status_from_events(history).is_terminal() {
236            for entry in entries {
237                tracing::debug!(
238                    %workflow_id,
239                    timer_id = %entry.timer_id,
240                    fire_at = %entry.fire_at,
241                    "retiring due timer row of a terminal workflow"
242                );
243                let attempt = self
244                    .timer_service
245                    .retire_consumed_row(
246                        workflow_id,
247                        &entry.timer_id,
248                        entry.fire_at,
249                        entry.armed_seq,
250                    )
251                    .await;
252                counts.record_row_retirement(attempt);
253            }
254            return Ok(());
255        }
256        for entry in entries {
257            match timer_disposition_in_active_segment(history, &entry.timer_id) {
258                // A live arming fires ONLY when the row IS that arming
259                // (round-2 F1): the row's `fire_at` must equal the recorded
260                // arming's. A mismatched row predates the current arming —
261                // the workflow recorded a new `TimerStarted` and died before
262                // the replacement row write — and firing it would mint a
263                // durable `TimerFired` for an instant the workflow never
264                // armed: a three-month sleep returning immediately,
265                // permanent in history. The stale row retires instead
266                // (fire_at-conditionally, so if the startup re-arm pass has
267                // already rewritten the row with the armed value, the
268                // replacement survives as Superseded) and the recorded
269                // arming's own row is restored by the re-arm pass when its
270                // fire_at is still in the future.
271                TimerDisposition::Live
272                    if armed_fire_at_in_active_segment(history, &entry.timer_id)
273                        == Some(entry.fire_at) =>
274                {
275                    self.fire_due(workflow_id, &entry, counts).await?;
276                }
277                TimerDisposition::Live => {
278                    tracing::warn!(
279                        %workflow_id,
280                        timer_id = %entry.timer_id,
281                        row_fire_at = %entry.fire_at,
282                        armed_fire_at = ?armed_fire_at_in_active_segment(history, &entry.timer_id),
283                        "due timer row disagrees with the recorded arming; retiring the \
284                         stale row instead of firing it"
285                    );
286                    let attempt = self
287                        .timer_service
288                        .retire_consumed_row(
289                            workflow_id,
290                            &entry.timer_id,
291                            entry.fire_at,
292                            entry.armed_seq,
293                        )
294                        .await;
295                    counts.record_row_retirement(attempt);
296                }
297                // A surviving row whose fire is already recorded IS the missing
298                // acknowledgement (aion#145): retirement follows delivery on
299                // the fire path, so a row that outlived its recorded fire says
300                // the wake may never have landed. Deadlines never record
301                // `TimerFired` through this path, so a fired deadline owes
302                // nothing.
303                TimerDisposition::Fired if !is_deadline_timer(&entry.timer_id) => {
304                    self.redeliver_surviving_fired_row(workflow_id, &entry, counts)
305                        .await?;
306                }
307                TimerDisposition::Fired
308                | TimerDisposition::Cancelled
309                | TimerDisposition::Absent => {
310                    tracing::debug!(
311                        %workflow_id,
312                        timer_id = %entry.timer_id,
313                        fire_at = %entry.fire_at,
314                        "retiring consumed timer row without entering the fire path"
315                    );
316                    let attempt = self
317                        .timer_service
318                        .retire_consumed_row(
319                            workflow_id,
320                            &entry.timer_id,
321                            entry.fire_at,
322                            entry.armed_seq,
323                        )
324                        .await;
325                    counts.record_row_retirement(attempt);
326                }
327            }
328        }
329        Ok(())
330    }
331
332    /// One surviving already-fired row's owed redelivery (aion#145), then its
333    /// retirement — served from the sweep's already-read history, never a
334    /// per-row read, so each such row costs this once, ever.
335    async fn redeliver_surviving_fired_row(
336        &self,
337        workflow_id: &WorkflowId,
338        entry: &TimerEntry,
339        counts: &mut SweepCounts,
340    ) -> Result<(), TimerRecoveryError> {
341        match self
342            .timer_service
343            .redeliver_owed_wake(
344                workflow_id.clone(),
345                entry.timer_id.clone(),
346                entry.fire_at,
347                entry.armed_seq,
348            )
349            .await
350        {
351            // A wake was delivered: the row counts as redelivered whatever
352            // its retirement answered (a failed retire is already warned at
353            // the retire site and heals at the next boot or adoption sweep —
354            // wake-idempotent by the seam's design).
355            Ok((true, _)) => counts.redelivered += 1,
356            Ok((false, attempt)) => counts.record_row_retirement(attempt),
357            // `UnknownWorkflow` from the redelivery seam after residency
358            // answered `Resident` means the run left the registry between the
359            // two lookups — it terminated or was torn down mid-redelivery.
360            // That is the NON-RESIDENT shape arriving late: no live wake is
361            // deliverable, replay on any future residency restore consumes
362            // the recorded fire, and the arming's row retires
363            // (fire_at-conditionally). Leaving the row instead would
364            // re-attempt it at every boot or adoption sweep forever.
365            Err(TimerServiceError::Engine(EngineSeamError::UnknownWorkflow {
366                workflow_id: gone,
367            })) => {
368                tracing::info!(
369                    workflow_id = %gone,
370                    timer_id = %entry.timer_id,
371                    "workflow left residency mid-redelivery; retiring the \
372                     consumed row without a wake"
373                );
374                let attempt = self
375                    .timer_service
376                    .retire_consumed_row(
377                        workflow_id,
378                        &entry.timer_id,
379                        entry.fire_at,
380                        entry.armed_seq,
381                    )
382                    .await;
383                counts.record_row_retirement(attempt);
384            }
385            Err(other) => return Err(other.into()),
386        }
387        Ok(())
388    }
389
390    /// One due row through the fire path, with the orphan shape preserved.
391    async fn fire_due(
392        &self,
393        workflow_id: &WorkflowId,
394        entry: &TimerEntry,
395        counts: &mut SweepCounts,
396    ) -> Result<(), TimerRecoveryError> {
397        match self
398            .timer_service
399            .fire_timer(workflow_id.clone(), entry.timer_id.clone(), entry.fire_at)
400            .await
401        {
402            Ok(()) => counts.fired += 1,
403            // An orphaned timer whose workflow no longer exists — e.g. the
404            // workflow was cancelled and purged from the engine's known set —
405            // must never abort recovery or block engine startup. The workflow
406            // is gone, so the timer is moot: log it and skip. (A terminal but
407            // still-known workflow's timer is already filtered inside
408            // `fire_timer`'s liveness check, which returns `Ok` without firing.)
409            Err(TimerServiceError::Engine(EngineSeamError::UnknownWorkflow { workflow_id })) => {
410                if self.first_orphan_sighting(&workflow_id) {
411                    tracing::warn!(
412                        %workflow_id,
413                        timer_id = %entry.timer_id,
414                        "skipping recovered timer for unknown workflow (orphaned timer); \
415                         the workflow no longer exists — counted in every sweep's \
416                         skipped_orphans, warned once"
417                    );
418                }
419                counts.skipped_orphans += 1;
420            }
421            Err(other) => return Err(other.into()),
422        }
423        Ok(())
424    }
425}
426
427/// What one sweep did, for the summary line and the specimens.
428///
429/// Every due row lands in EXACTLY ONE counter, so their sum equals the
430/// sweep's `expired_rows`: `fired` and `redelivered` claim their rows
431/// whatever the internal housekeeping answered, and every other row is
432/// counted by what actually happened to it — a deletion (`retired`), a
433/// surviving replacement arming (`superseded`), a store refusal
434/// (`retire_failures`), or an unexplainable owner (`skipped_orphans`).
435/// `retired` therefore measures deletions, never attempts.
436#[derive(Clone, Copy, Debug, Default)]
437pub struct SweepCounts {
438    /// Due rows that completed the fire path as live fires.
439    pub fired: usize,
440    /// Surviving already-recorded rows whose owed wake was redelivered
441    /// (aion#145) before the row retired.
442    pub redelivered: usize,
443    /// Rows whose retirement DELETED the row (or found it already gone).
444    pub retired: usize,
445    /// Rows whose key a replacement arming owns; the new row was left
446    /// standing and this sweep is done with the old arming forever.
447    pub superseded: usize,
448    /// Rows whose retirement the store refused (each already warned with its
449    /// cause at the retire site); the rows survive until the next boot or
450    /// adoption sweep.
451    pub retire_failures: usize,
452    /// Rows skipped because their workflow is unknown (to the store or to
453    /// the engine) — the conservative orphan shape.
454    pub skipped_orphans: usize,
455}
456
457impl SweepCounts {
458    /// Count one wake-less row by its retirement outcome.
459    fn record_row_retirement(&mut self, attempt: RetireAttempt) {
460        match attempt {
461            RetireAttempt::Retired => self.retired += 1,
462            RetireAttempt::Superseded => self.superseded += 1,
463            RetireAttempt::Failed => self.retire_failures += 1,
464        }
465    }
466}
467
468/// Bucket due rows by their owning workflow, so one history read serves all
469/// of a workflow's rows.
470fn group_by_workflow(entries: Vec<TimerEntry>) -> HashMap<WorkflowId, Vec<TimerEntry>> {
471    let mut by_workflow: HashMap<WorkflowId, Vec<TimerEntry>> = HashMap::new();
472    for entry in entries {
473        by_workflow
474            .entry(entry.workflow_id.clone())
475            .or_default()
476            .push(entry);
477    }
478    by_workflow
479}
480
481fn outstanding_future_timers(
482    history: &[Event],
483    now: DateTime<Utc>,
484) -> Vec<(TimerId, DateTime<Utc>, u64)> {
485    let mut outstanding: HashMap<TimerId, (DateTime<Utc>, u64)> = HashMap::new();
486    for event in history {
487        match event {
488            Event::TimerStarted {
489                envelope,
490                timer_id,
491                fire_at,
492            } => {
493                outstanding.insert(timer_id.clone(), (*fire_at, envelope.seq));
494            }
495            Event::TimerFired { timer_id, .. } | Event::TimerCancelled { timer_id, .. } => {
496                outstanding.remove(timer_id);
497            }
498            _ => {}
499        }
500    }
501    outstanding
502        .into_iter()
503        .filter(|(_, (fire_at, _))| *fire_at > now)
504        .map(|(timer_id, (fire_at, armed_seq))| (timer_id, fire_at, armed_seq))
505        .collect()
506}
507
508#[cfg(test)]
509mod tests {
510    use std::sync::Arc;
511
512    use aion_core::{Event, EventEnvelope, RunId, TimerCancelCause, TimerId, WorkflowId};
513    use aion_store::{
514        InMemoryStore, ReadableEventStore, StoreError, WritableEventStore, WriteToken,
515    };
516    use chrono::{DateTime, Utc};
517
518    use super::{TimerRecovery, TimerRecoveryError, outstanding_future_timers};
519    use crate::engine_seam::test_support::{DeliveredWorkflowMessage, FakeEngineHandle};
520    use crate::engine_seam::{
521        EngineHandle, EngineSeamError, WorkflowProcessHandle, WorkflowResidency,
522    };
523    use crate::time::TimerService;
524    use crate::time::deadline_timer_id;
525
526    #[derive(Debug, thiserror::Error)]
527    enum TestError {
528        #[error(transparent)]
529        Recovery(#[from] TimerRecoveryError),
530
531        #[error(transparent)]
532        Store(#[from] StoreError),
533
534        #[error(transparent)]
535        Engine(#[from] EngineSeamError),
536    }
537
538    fn instant(offset_seconds: i64) -> DateTime<Utc> {
539        DateTime::from_timestamp(1_700_000_000 + offset_seconds, 0).unwrap_or_default()
540    }
541
542    fn recorded_at() -> DateTime<Utc> {
543        instant(1)
544    }
545
546    fn workflow_id() -> WorkflowId {
547        WorkflowId::new_v4()
548    }
549
550    fn timer_id(sequence: u64) -> TimerId {
551        TimerId::anonymous(sequence)
552    }
553
554    fn recovery() -> (Arc<InMemoryStore>, Arc<FakeEngineHandle>, TimerRecovery) {
555        let concrete_store = Arc::new(InMemoryStore::default());
556        let writable: Arc<dyn WritableEventStore> = concrete_store.clone();
557        let readable: Arc<dyn ReadableEventStore> = concrete_store.clone();
558        let engine = Arc::new(FakeEngineHandle::recording_to(writable));
559        let timer_service = Arc::new(TimerService::with_recorded_at(
560            engine.clone(),
561            readable.clone(),
562            recorded_at,
563        ));
564        let recovery = TimerRecovery::new(readable, timer_service);
565        (concrete_store, engine, recovery)
566    }
567
568    async fn history(
569        store: &InMemoryStore,
570        workflow_id: &WorkflowId,
571    ) -> Result<Vec<Event>, StoreError> {
572        store.read_history(workflow_id).await
573    }
574
575    /// A `TimerStarted` arming event. `fire_at` is explicit so every fixture
576    /// keeps the recorded arming and the durable row in agreement — the
577    /// boot sweep fires a due row only when the two match (round-2 F1).
578    fn timer_started_event(
579        workflow_id: &WorkflowId,
580        timer_id: &TimerId,
581        seq: u64,
582        fire_at: DateTime<Utc>,
583    ) -> Event {
584        Event::TimerStarted {
585            envelope: EventEnvelope {
586                seq,
587                recorded_at: instant(0),
588                workflow_id: workflow_id.clone(),
589            },
590            timer_id: timer_id.clone(),
591            fire_at,
592        }
593    }
594
595    fn workflow_started_event(workflow_id: &WorkflowId, seq: u64) -> Event {
596        Event::WorkflowStarted {
597            envelope: EventEnvelope {
598                seq,
599                recorded_at: instant(0),
600                workflow_id: workflow_id.clone(),
601            },
602            workflow_type: "fixture".to_owned(),
603            input: aion_core::Payload::new(aion_core::ContentType::Json, b"null".to_vec()),
604            run_id: RunId::new_v4(),
605            parent_run_id: None,
606            parent_workflow_id: None,
607            package_version: aion_core::PackageVersion::new("a".repeat(64)),
608        }
609    }
610
611    fn count_timer_fired(events: &[Event], timer_id: &TimerId) -> usize {
612        events
613            .iter()
614            .filter(|event| {
615                matches!(event, Event::TimerFired { timer_id: recorded, .. } if recorded == timer_id)
616            })
617            .count()
618    }
619
620    #[tokio::test]
621    async fn startup_sweep_fires_past_timer_and_delivers() -> Result<(), TestError> {
622        let process = WorkflowProcessHandle::new(42);
623        let (store, engine, recovery) = recovery();
624        let workflow_id = workflow_id();
625        let timer_id = timer_id(1);
626        let fire_at = instant(10);
627        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
628        engine.record_workflow_event(
629            &workflow_id,
630            timer_started_event(&workflow_id, &timer_id, 1, fire_at),
631        )?;
632        store
633            .schedule_timer(&workflow_id, &timer_id, fire_at, 1)
634            .await?;
635
636        let recovered = recovery.recover_on_startup(instant(20)).await?;
637
638        assert_eq!(recovered.fired, 1);
639        assert_eq!(
640            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
641            1
642        );
643        assert_eq!(
644            engine.delivered_messages()?,
645            vec![(
646                process,
647                DeliveredWorkflowMessage::TimerFired {
648                    timer_id: timer_id.clone(),
649                    fire_at
650                }
651            )]
652        );
653        Ok(())
654    }
655
656    #[tokio::test]
657    async fn startup_sweep_does_not_fire_future_timer() -> Result<(), TestError> {
658        let process = WorkflowProcessHandle::new(42);
659        let (store, engine, recovery) = recovery();
660        let workflow_id = workflow_id();
661        let timer_id = timer_id(2);
662        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
663        store
664            .schedule_timer(&workflow_id, &timer_id, instant(30), 1)
665            .await?;
666
667        let recovered = recovery.recover_on_startup(instant(20)).await?;
668
669        assert_eq!(recovered.fired, 0);
670        assert_eq!(
671            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
672            0
673        );
674        assert!(engine.delivered_messages()?.is_empty());
675        Ok(())
676    }
677
678    /// Round-2 F1, the boot specimen from the ruling: a STALE past-due row
679    /// must never fire against a re-armed FUTURE arming.
680    ///
681    /// The crash shape: the workflow recorded `TimerStarted(T, Y three months
682    /// out)` and died before the replacement row write, so the store still
683    /// holds the PRIOR arming's row `(T, X past)`. Pre-fix, boot fired the
684    /// stale row — a durable `TimerFired` with the recorded arming still
685    /// three months out: the sleep returned immediately and the fabricated
686    /// event was permanent in history. Post-fix the sweep compares the row
687    /// against `armed_fire_at_in_active_segment` and retires the mismatch
688    /// (counted), while the startup re-arm pass restores the row to the
689    /// armed value — the keyspace converges to what history says.
690    ///
691    /// Mutation-witnessed: with the comparison broken (Live always fires),
692    /// the premature `TimerFired` appears and this test goes red.
693    #[tokio::test]
694    async fn a_stale_past_due_row_is_retired_not_fired_against_a_rearmed_future_arming()
695    -> Result<(), TestError> {
696        let process = WorkflowProcessHandle::new(42);
697        let (store, engine, recovery) = recovery();
698        let workflow_id = workflow_id();
699        let timer_id = timer_id(11);
700        let stale_row_fire_at = instant(5); // past at sweep time
701        let armed_fire_at = instant(300); // future at sweep time
702        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
703        // Active workflow (the startup re-arm pass walks `list_active`).
704        engine.record_workflow_event(&workflow_id, workflow_started_event(&workflow_id, 1))?;
705        // The recorded arming is the FUTURE one...
706        engine.record_workflow_event(
707            &workflow_id,
708            timer_started_event(&workflow_id, &timer_id, 2, armed_fire_at),
709        )?;
710        // ...but the durable row still carries the PRIOR arming (the crash
711        // landed between the `TimerStarted` append and the row write).
712        store
713            .schedule_timer(&workflow_id, &timer_id, stale_row_fire_at, 1)
714            .await?;
715
716        let recovered = recovery.recover_on_startup(instant(20)).await?;
717
718        assert_eq!(recovered.fired, 0, "a stale row must never fire");
719        assert_eq!(
720            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
721            0,
722            "no premature TimerFired may be fabricated for the future arming"
723        );
724        assert!(
725            engine.delivered_messages()?.is_empty(),
726            "no wake may be delivered for a stale row"
727        );
728        // The stale row was disposed as a retirement (the startup re-arm pass
729        // rewrote the row to the armed value FIRST, so the conditional retire
730        // of the stale arming answers Superseded — the replacement survives).
731        assert_eq!(
732            recovered.superseded, 1,
733            "the stale row lands in the superseded bin: the re-arm pass's \
734             replacement row won the key"
735        );
736        assert_eq!(
737            recovered.fired
738                + recovered.redelivered
739                + recovered.retired
740                + recovered.superseded
741                + recovered.retire_failures
742                + recovered.skipped_orphans,
743            1,
744            "the one due row lands in exactly one counter"
745        );
746        // The keyspace converged to the RECORDED arming: nothing is due any
747        // more, and the row now carries the armed future fire_at.
748        assert!(
749            store.expired_timers(instant(20)).await?.is_empty(),
750            "the stale past-due row is gone"
751        );
752        let rows = store.expired_timers(armed_fire_at).await?;
753        assert_eq!(rows.len(), 1, "the armed row survives: {rows:?}");
754        assert_eq!(
755            rows[0].fire_at, armed_fire_at,
756            "the row converged to the recorded arming's fire_at"
757        );
758        Ok(())
759    }
760
761    /// Round-2 F5: the `retire_failures` bin, exercised POSITIVELY. A store
762    /// that refuses a consumed row's retirement must land that row in
763    /// `retire_failures` (never silently in `retired`, never an aborted
764    /// sweep), the partition must still sum to the population, and the row
765    /// must SURVIVE the refusal so the next boot or adoption sweep heals it.
766    /// Mutation-sensitive: counting the refusal as `retired` breaks the bin
767    /// asserts; aborting the sweep on the refusal breaks the Ok return; and
768    /// deleting the row anyway breaks the healing sweep's `retired == 1`.
769    #[tokio::test]
770    async fn a_refused_retirement_lands_in_retire_failures_and_heals_at_the_next_sweep()
771    -> Result<(), TestError> {
772        use crate::store_faults::FlakyStore;
773
774        let flaky = Arc::new(FlakyStore::new());
775        let writable: Arc<dyn WritableEventStore> = flaky.clone();
776        let readable: Arc<dyn ReadableEventStore> = flaky.clone();
777        let engine = Arc::new(FakeEngineHandle::recording_to(writable));
778        let timer_service = Arc::new(TimerService::with_recorded_at(
779            engine.clone(),
780            readable.clone(),
781            recorded_at,
782        ));
783        let recovery = TimerRecovery::new(readable, timer_service);
784
785        // A consumed row: the timer was cancelled (its retirement is what the
786        // sweep owes), and the row survived the original cancel.
787        let workflow_id = workflow_id();
788        let timer_id = timer_id(12);
789        engine.record_workflow_event(
790            &workflow_id,
791            timer_started_event(&workflow_id, &timer_id, 1, instant(5)),
792        )?;
793        engine.record_workflow_event(
794            &workflow_id,
795            Event::TimerCancelled {
796                cause: TimerCancelCause::WorkflowIntent,
797                envelope: EventEnvelope {
798                    seq: 2,
799                    recorded_at: instant(6),
800                    workflow_id: workflow_id.clone(),
801                },
802                timer_id: timer_id.clone(),
803            },
804        )?;
805        flaky
806            .schedule_timer(&workflow_id, &timer_id, instant(5), 1)
807            .await?;
808
809        flaky.fail_next_retirements(1);
810        let refused = recovery.recover_on_startup(instant(20)).await?;
811
812        assert_eq!(
813            refused.retire_failures, 1,
814            "the refused retirement must be counted as a failure, not a deletion"
815        );
816        assert_eq!(refused.retired, 0);
817        assert_eq!(
818            refused.fired
819                + refused.redelivered
820                + refused.retired
821                + refused.superseded
822                + refused.retire_failures
823                + refused.skipped_orphans,
824            1,
825            "the refused row still lands in exactly one counter"
826        );
827        assert_eq!(
828            flaky.expired_timers(instant(20)).await?.len(),
829            1,
830            "the row survives the refusal — nothing is silently dropped"
831        );
832
833        // The healer: the next boot or adoption sweep retires the survivor.
834        let healed = recovery.recover_on_startup(instant(20)).await?;
835        assert_eq!(healed.retired, 1, "the next sweep retires the survivor");
836        assert_eq!(healed.retire_failures, 0);
837        assert!(
838            flaky.expired_timers(instant(20)).await?.is_empty(),
839            "the row is gone once the store accepts the retirement"
840        );
841        Ok(())
842    }
843
844    /// Re-pointed from the removed `tick()` surface (round-2 F3): the sweep's
845    /// per-sweep counters, driven through the ONLY entrypoint this landing
846    /// has. The first boot sweep fires the due arming; the second finds
847    /// nothing due, because retirement is the wake's acknowledgement — the
848    /// fire path retires the row AFTER delivering, so no re-walk and no
849    /// duplicate wake. (Before retirement existed, the row survived and every
850    /// sweep redelivered forever.) A wake genuinely lost after recording
851    /// leaves its row alive, and the surviving-row specimens below prove that
852    /// row is redelivered then retired.
853    #[tokio::test]
854    async fn a_second_boot_sweep_after_a_fired_row_retires_finds_nothing_due()
855    -> Result<(), TestError> {
856        let process = WorkflowProcessHandle::new(42);
857        let (store, engine, recovery) = recovery();
858        let workflow_id = workflow_id();
859        let timer_id = timer_id(3);
860        let fire_at = instant(25);
861        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
862        engine.record_workflow_event(
863            &workflow_id,
864            timer_started_event(&workflow_id, &timer_id, 1, fire_at),
865        )?;
866        store
867            .schedule_timer(&workflow_id, &timer_id, fire_at, 1)
868            .await?;
869
870        assert_eq!(recovery.recover_on_startup(instant(30)).await?.fired, 1);
871        assert_eq!(recovery.recover_on_startup(instant(30)).await?.fired, 0);
872
873        assert_eq!(
874            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
875            1,
876            "the durable TimerFired is recorded exactly once across repeated sweeps"
877        );
878        assert_eq!(engine.delivered_messages()?.len(), 1);
879        Ok(())
880    }
881
882    #[tokio::test]
883    async fn running_startup_sweep_twice_records_due_timer_once_total() -> Result<(), TestError> {
884        let process = WorkflowProcessHandle::new(42);
885        let (store, engine, recovery) = recovery();
886        let workflow_id = workflow_id();
887        let timer_id = timer_id(4);
888        let fire_at = instant(10);
889        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
890        engine.record_workflow_event(
891            &workflow_id,
892            timer_started_event(&workflow_id, &timer_id, 1, fire_at),
893        )?;
894        store
895            .schedule_timer(&workflow_id, &timer_id, fire_at, 1)
896            .await?;
897
898        recovery.recover_on_startup(instant(20)).await?;
899        recovery.recover_on_startup(instant(20)).await?;
900
901        assert_eq!(
902            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
903            1,
904            "the durable TimerFired is recorded exactly once across repeated sweeps"
905        );
906        // The first sweep's fire retired the row (retirement follows the
907        // delivered wake), so the second sweep finds nothing due and delivers
908        // nothing — see the surviving-row specimens for the lost-wake arm.
909        assert_eq!(engine.delivered_messages()?.len(), 1);
910        Ok(())
911    }
912
913    #[tokio::test]
914    async fn cancelled_timer_is_never_fired_by_recovery() -> Result<(), TestError> {
915        let process = WorkflowProcessHandle::new(42);
916        let (store, engine, recovery) = recovery();
917        let workflow_id = workflow_id();
918        let timer_id = timer_id(5);
919        let fire_at = instant(10);
920        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
921        store
922            .schedule_timer(&workflow_id, &timer_id, fire_at, 1)
923            .await?;
924        engine.record_workflow_event(
925            &workflow_id,
926            Event::TimerCancelled {
927                cause: aion_core::TimerCancelCause::WorkflowIntent,
928                envelope: EventEnvelope {
929                    seq: 1,
930                    recorded_at: instant(9),
931                    workflow_id: workflow_id.clone(),
932                },
933                timer_id: timer_id.clone(),
934            },
935        )?;
936
937        recovery.recover_on_startup(instant(20)).await?;
938
939        assert_eq!(
940            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
941            0
942        );
943        assert!(engine.delivered_messages()?.is_empty());
944        Ok(())
945    }
946
947    /// D5 resurrection hazard: `outstanding_future_timers` is whole-history
948    /// scoped, so a continue-as-new predecessor's still-outstanding
949    /// `deadline:{run}` WOULD be re-armed after failover — firing a timeout
950    /// against a run that already continued. The `WorkflowIntent` cancel recorded
951    /// at the CAN terminal closes exactly that hole. This proves both halves at
952    /// the precise mechanism the scout flagged.
953    #[test]
954    fn cancelled_predecessor_deadline_is_not_rearmed_after_continue_as_new() {
955        let workflow_id = workflow_id();
956        let predecessor_run = RunId::new_v4();
957        let deadline = deadline_timer_id(&predecessor_run).unwrap_or_else(|_| timer_id(0));
958        let now = instant(0);
959        let fire_at = instant(120); // future: eligible for re-arm
960
961        let started = |seq: u64, run: &RunId| Event::WorkflowStarted {
962            envelope: EventEnvelope {
963                seq,
964                recorded_at: instant(0),
965                workflow_id: workflow_id.clone(),
966            },
967            workflow_type: "sleeper".to_owned(),
968            input: aion_core::Payload::new(aion_core::ContentType::Json, b"null".to_vec()),
969            run_id: run.clone(),
970            parent_run_id: None,
971            parent_workflow_id: None,
972            package_version: aion_core::PackageVersion::new("a".repeat(64)),
973        };
974        let deadline_started = Event::TimerStarted {
975            envelope: EventEnvelope {
976                seq: 2,
977                recorded_at: instant(0),
978                workflow_id: workflow_id.clone(),
979            },
980            timer_id: deadline.clone(),
981            fire_at,
982        };
983        let continued = Event::WorkflowContinuedAsNew {
984            envelope: EventEnvelope {
985                seq: 3,
986                recorded_at: instant(1),
987                workflow_id: workflow_id.clone(),
988            },
989            input: aion_core::Payload::new(aion_core::ContentType::Json, b"null".to_vec()),
990            workflow_type: None,
991            parent_run_id: predecessor_run.clone(),
992        };
993
994        // Before the cancel: the hazard is real — the predecessor's future
995        // deadline is outstanding across the whole history even after CAN.
996        let uncancelled = vec![
997            started(1, &predecessor_run),
998            deadline_started.clone(),
999            continued.clone(),
1000        ];
1001        assert!(
1002            outstanding_future_timers(&uncancelled, now)
1003                .into_iter()
1004                .any(|(timer_id, _, _)| timer_id == deadline),
1005            "an uncancelled predecessor deadline WOULD be re-armed after failover"
1006        );
1007
1008        // With the WorkflowIntent cancel recorded at the CAN terminal: closed.
1009        let cancelled = vec![
1010            started(1, &predecessor_run),
1011            deadline_started,
1012            continued,
1013            Event::TimerCancelled {
1014                envelope: EventEnvelope {
1015                    seq: 4,
1016                    recorded_at: instant(1),
1017                    workflow_id: workflow_id.clone(),
1018                },
1019                timer_id: deadline.clone(),
1020                cause: TimerCancelCause::WorkflowIntent,
1021            },
1022        ];
1023        assert!(
1024            !outstanding_future_timers(&cancelled, now)
1025                .into_iter()
1026                .any(|(timer_id, _, _)| timer_id == deadline),
1027            "the WorkflowIntent cancel closes the whole-history re-arm hole"
1028        );
1029    }
1030
1031    #[tokio::test]
1032    async fn orphaned_timer_for_unknown_workflow_is_skipped_not_fatal() -> Result<(), TestError> {
1033        // Regression: a durable timer whose workflow was cancelled and purged
1034        // from the engine's known set must NOT abort startup recovery. Before the
1035        // fix, `fire_timer`'s `UnknownWorkflow` error propagated and bricked engine
1036        // startup — observed in production after a restart:
1037        //   "timer recovery fire operation failed: ... workflow <id> is unknown".
1038        let (store, engine, recovery) = recovery();
1039        let workflow_id = workflow_id();
1040        let timer_id = timer_id(6);
1041        let fire_at = instant(10);
1042
1043        // The timer is live in history (started, never fired/cancelled) and
1044        // its row matches the recorded arming, so the sweep routes it into
1045        // the fire path (the F1 stale-row gate must not divert it) ...
1046        store
1047            .schedule_timer(&workflow_id, &timer_id, fire_at, 1)
1048            .await?;
1049        engine.record_workflow_event(
1050            &workflow_id,
1051            timer_started_event(&workflow_id, &timer_id, 1, fire_at),
1052        )?;
1053        // ... but the workflow itself is gone: the engine rejects the recovered
1054        // fire with `UnknownWorkflow` (exactly what the real engine does when a
1055        // cancelled workflow's record has been purged).
1056        engine.push_record_response(Err(EngineSeamError::UnknownWorkflow {
1057            workflow_id: workflow_id.clone(),
1058        }))?;
1059
1060        // Recovery must SUCCEED by skipping the orphan, not error out.
1061        let recovered = recovery.recover_on_startup(instant(20)).await?;
1062
1063        assert_eq!(
1064            recovered.fired, 0,
1065            "the orphaned timer is skipped, not fired"
1066        );
1067        assert_eq!(
1068            recovered.skipped_orphans, 1,
1069            "the engine-side orphan is counted in skipped_orphans (round-3 N5)"
1070        );
1071        assert_eq!(
1072            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1073            0,
1074            "no TimerFired is recorded for an unknown workflow"
1075        );
1076        assert!(
1077            engine.delivered_messages()?.is_empty(),
1078            "nothing is delivered for an unknown workflow"
1079        );
1080        Ok(())
1081    }
1082
1083    /// A read-counting decorator: the R2 acceptance instrument. The grouped
1084    /// sweep's read complexity is pinned against the store's own surface —
1085    /// `read_history` calls counted at the seam — never inferred from timing.
1086    struct CountingStore {
1087        inner: Arc<InMemoryStore>,
1088        history_reads: std::sync::atomic::AtomicUsize,
1089    }
1090
1091    impl CountingStore {
1092        fn new(inner: Arc<InMemoryStore>) -> Self {
1093            Self {
1094                inner,
1095                history_reads: std::sync::atomic::AtomicUsize::new(0),
1096            }
1097        }
1098
1099        fn history_reads(&self) -> usize {
1100            self.history_reads.load(std::sync::atomic::Ordering::SeqCst)
1101        }
1102    }
1103
1104    #[async_trait::async_trait]
1105    impl ReadableEventStore for CountingStore {
1106        async fn read_history(&self, workflow_id: &WorkflowId) -> Result<Vec<Event>, StoreError> {
1107            self.history_reads
1108                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1109            self.inner.read_history(workflow_id).await
1110        }
1111
1112        async fn read_history_from(
1113            &self,
1114            workflow_id: &WorkflowId,
1115            from_seq: u64,
1116        ) -> Result<Vec<Event>, StoreError> {
1117            self.inner.read_history_from(workflow_id, from_seq).await
1118        }
1119
1120        async fn read_run_chain(
1121            &self,
1122            workflow_id: &WorkflowId,
1123        ) -> Result<Vec<aion_store::RunSummary>, StoreError> {
1124            self.inner.read_run_chain(workflow_id).await
1125        }
1126
1127        async fn list_workflow_ids(&self) -> Result<Vec<WorkflowId>, StoreError> {
1128            self.inner.list_workflow_ids().await
1129        }
1130
1131        async fn list_active(&self) -> Result<Vec<WorkflowId>, StoreError> {
1132            self.inner.list_active().await
1133        }
1134
1135        async fn list_paused(&self) -> Result<Vec<WorkflowId>, StoreError> {
1136            self.inner.list_paused().await
1137        }
1138        async fn stream_heads(
1139            &self,
1140        ) -> Result<Vec<aion_store::visibility::StreamHead>, StoreError> {
1141            self.inner.stream_heads().await
1142        }
1143
1144        async fn query(
1145            &self,
1146            filter: &aion_core::WorkflowFilter,
1147        ) -> Result<Vec<aion_core::WorkflowSummary>, StoreError> {
1148            self.inner.query(filter).await
1149        }
1150
1151        async fn schedule_timer(
1152            &self,
1153            workflow_id: &WorkflowId,
1154            timer_id: &TimerId,
1155            fire_at: DateTime<Utc>,
1156            armed_seq: u64,
1157        ) -> Result<(), StoreError> {
1158            self.inner
1159                .schedule_timer(workflow_id, timer_id, fire_at, armed_seq)
1160                .await
1161        }
1162
1163        async fn expired_timers(
1164            &self,
1165            as_of: DateTime<Utc>,
1166        ) -> Result<Vec<aion_store::TimerEntry>, StoreError> {
1167            self.inner.expired_timers(as_of).await
1168        }
1169
1170        async fn retire_timer(
1171            &self,
1172            workflow_id: &WorkflowId,
1173            timer_id: &TimerId,
1174            fire_at: DateTime<Utc>,
1175            armed_seq: u64,
1176        ) -> Result<aion_store::TimerRetirement, StoreError> {
1177            self.inner
1178                .retire_timer(workflow_id, timer_id, fire_at, armed_seq)
1179                .await
1180        }
1181    }
1182
1183    /// The counting fixture: the wrapper is BOTH the recovery's store and the
1184    /// timer service's, so every history read either sweep makes is counted.
1185    fn counting_recovery() -> (
1186        Arc<InMemoryStore>,
1187        Arc<CountingStore>,
1188        Arc<FakeEngineHandle>,
1189        TimerRecovery,
1190    ) {
1191        let inner = Arc::new(InMemoryStore::default());
1192        let counting = Arc::new(CountingStore::new(inner.clone()));
1193        let writable: Arc<dyn WritableEventStore> = inner.clone();
1194        let readable: Arc<dyn ReadableEventStore> = counting.clone();
1195        let engine = Arc::new(FakeEngineHandle::recording_to(writable));
1196        let timer_service = Arc::new(TimerService::with_recorded_at(
1197            engine.clone(),
1198            readable.clone(),
1199            recorded_at,
1200        ));
1201        let recovery = TimerRecovery::new(readable, timer_service);
1202        (inner, counting, engine, recovery)
1203    }
1204
1205    fn timer_fired_event(workflow_id: &WorkflowId, timer_id: &TimerId, seq: u64) -> Event {
1206        Event::TimerFired {
1207            envelope: EventEnvelope {
1208                seq,
1209                recorded_at: instant(0),
1210                workflow_id: workflow_id.clone(),
1211            },
1212            timer_id: timer_id.clone(),
1213        }
1214    }
1215
1216    /// Seed `count` CONSUMED armings (started at `instant(5)`, then fired,
1217    /// with the surviving durable row the incident left behind) for the
1218    /// estate-shaped specimen, returning the last history seq used.
1219    async fn seed_consumed_armings(
1220        engine: &FakeEngineHandle,
1221        store: &InMemoryStore,
1222        workflow_id: &WorkflowId,
1223        count: u64,
1224    ) -> Result<u64, TestError> {
1225        let mut seq = 0;
1226        for i in 0..count {
1227            let consumed = timer_id(i);
1228            seq += 1;
1229            let armed_seq = seq;
1230            engine.record_workflow_event(
1231                workflow_id,
1232                timer_started_event(workflow_id, &consumed, armed_seq, instant(5)),
1233            )?;
1234            seq += 1;
1235            engine.record_workflow_event(
1236                workflow_id,
1237                timer_fired_event(workflow_id, &consumed, seq),
1238            )?;
1239            store
1240                .schedule_timer(workflow_id, &consumed, instant(5), armed_seq)
1241                .await?;
1242        }
1243        Ok(seq)
1244    }
1245
1246    /// The estate-shaped R2 acceptance specimen: hundreds of CONSUMED rows and
1247    /// one live arming across two workflows — one running, one terminal.
1248    ///
1249    /// Pinned facts, in the order they killed the estate boot:
1250    /// - history reads scale with DISTINCT WORKFLOWS (plus the fire path's own
1251    ///   two for the single live fire), never with rows. The pre-collapse
1252    ///   sweep put every consumed row through the fire path — two reads per
1253    ///   row, thousands of reads per boot, the 90-minute startup;
1254    /// - exactly one fire records and delivers;
1255    /// - the sweep leaves the expired-timers index EMPTY: consumed rows are
1256    ///   retired in bulk, the terminal workflow's rows without ever computing
1257    ///   a per-row disposition, and the fired arming by the fire path itself.
1258    #[tokio::test]
1259    async fn a_grouped_sweep_reads_once_per_workflow_and_retires_the_backlog()
1260    -> Result<(), TestError> {
1261        const CONSUMED_PER_WORKFLOW: u64 = 300;
1262        let process = WorkflowProcessHandle::new(42);
1263        let (inner, counting, engine, recovery) = counting_recovery();
1264
1265        // Workflow A: running, resident, 300 consumed armings and ONE live
1266        // due arming.
1267        let running = workflow_id();
1268        engine.set_residency(running.clone(), WorkflowResidency::Resident(process))?;
1269        let mut seq =
1270            seed_consumed_armings(&engine, &inner, &running, CONSUMED_PER_WORKFLOW).await?;
1271        let live = timer_id(9_999);
1272        seq += 1;
1273        engine.record_workflow_event(
1274            &running,
1275            timer_started_event(&running, &live, seq, instant(5)),
1276        )?;
1277        inner
1278            .schedule_timer(&running, &live, instant(5), seq)
1279            .await?;
1280
1281        // Workflow B: 300 consumed armings, then a terminal event — its rows
1282        // are moot whatever their per-timer dispositions say.
1283        let finished = workflow_id();
1284        let seq = seed_consumed_armings(&engine, &inner, &finished, CONSUMED_PER_WORKFLOW).await?;
1285        engine.record_workflow_event(
1286            &finished,
1287            Event::WorkflowCompleted {
1288                envelope: EventEnvelope {
1289                    seq: seq + 1,
1290                    recorded_at: instant(6),
1291                    workflow_id: finished.clone(),
1292                },
1293                result: aion_core::Payload::new(aion_core::ContentType::Json, b"null".to_vec()),
1294            },
1295        )?;
1296
1297        let recovered = recovery.recover_on_startup(instant(20)).await?;
1298
1299        assert_eq!(recovered.fired, 1, "exactly the one live arming fires");
1300        // Every one of the 601 due rows lands in exactly one counter: the one
1301        // live fire, the resident workflow's 300 redelivered rows, and the
1302        // terminal workflow's 300 bulk retirements. Nothing superseded,
1303        // nothing failed, nothing orphaned — and the sum IS the population,
1304        // so a row silently double-counted or dropped breaks this line.
1305        assert_eq!(
1306            recovered.redelivered, 300,
1307            "one redelivery per surviving fired row"
1308        );
1309        assert_eq!(
1310            recovered.retired, 300,
1311            "the terminal workflow's rows bulk-retire"
1312        );
1313        assert_eq!(recovered.superseded, 0);
1314        assert_eq!(recovered.retire_failures, 0);
1315        assert_eq!(recovered.skipped_orphans, 0);
1316        assert_eq!(
1317            recovered.fired
1318                + recovered.redelivered
1319                + recovered.retired
1320                + recovered.superseded
1321                + recovered.retire_failures
1322                + recovered.skipped_orphans,
1323            601,
1324            "the counters partition the due-row population exactly"
1325        );
1326        assert_eq!(
1327            count_timer_fired(&history(&inner, &running).await?, &live),
1328            1,
1329            "the live arming records its fire exactly once"
1330        );
1331        // The RESIDENT workflow's 300 surviving already-fired rows each owe
1332        // one redelivered wake (aion#145: a row that outlived its recorded
1333        // fire says the wake may never have landed — retirement is the ack).
1334        // Each is duplicate-safe and paid ONCE ever: the rows retire behind
1335        // it. The terminal workflow's 300 rows owe nothing.
1336        assert_eq!(
1337            engine.delivered_messages()?.len(),
1338            301,
1339            "one live-fire wake plus one owed-wake redelivery per surviving \
1340             fired row of the resident workflow"
1341        );
1342        assert_eq!(
1343            count_timer_fired(&history(&inner, &running).await?, &timer_id(0)),
1344            1,
1345            "a redelivered wake appends nothing: the recorder seam answers \
1346             AlreadyRecorded and the original fire stays the only record"
1347        );
1348        // The counting store sees the SWEEP'S OWN reads: one grouped read per
1349        // distinct workflow, plus the fire path's own two (its service-layer
1350        // disposition gate and its envelope read) for the single fire. 601
1351        // rows, four reads — the pre-collapse shape was two reads per ROW.
1352        // Out of this instrument's frame, DELIBERATELY: the redelivery seam's
1353        // history read under the recorder lock. The fake engine stands in for
1354        // that seam, so production's first boot after a backlog still pays
1355        // roughly one recorder-seam round trip per surviving row — once,
1356        // ever, per row (the module doc's honest cost model). This assertion
1357        // discriminates the sweep's own read scaling, nothing more.
1358        assert_eq!(
1359            counting.history_reads(),
1360            4,
1361            "the sweep's own history reads must scale with workflows (2) plus \
1362             the fire path's own reads (2 for 1 fire), never with the 601 rows"
1363        );
1364        assert!(
1365            inner.expired_timers(instant(20)).await?.is_empty(),
1366            "the sweep must leave the expired index EMPTY: every consumed row \
1367             retired in bulk and the fired arming retired by the fire path"
1368        );
1369        Ok(())
1370    }
1371
1372    /// The conservative orphan shape at the STORE: rows whose workflow has no
1373    /// recorded history at all are skipped and SURVIVE — never fired, never
1374    /// retired on a history that answers nothing.
1375    #[tokio::test]
1376    async fn rows_with_no_history_are_skipped_and_survive() -> Result<(), TestError> {
1377        let (inner, _counting, engine, recovery) = counting_recovery();
1378        let orphaned = workflow_id();
1379        for i in 0..3 {
1380            inner
1381                .schedule_timer(&orphaned, &timer_id(i), instant(5), 1)
1382                .await?;
1383        }
1384
1385        let recovered = recovery.recover_on_startup(instant(20)).await?;
1386
1387        assert_eq!(
1388            recovered.fired, 0,
1389            "nothing fires for a workflow with no history"
1390        );
1391        assert_eq!(
1392            recovered.skipped_orphans, 3,
1393            "every orphaned row is COUNTED — the summary line's standing gauge \
1394             of the population the sweep cannot explain (round-3 N5)"
1395        );
1396        assert_eq!(
1397            inner.expired_timers(instant(20)).await?.len(),
1398            3,
1399            "orphaned rows survive the sweep — skip and count, never retire on \
1400             a history that answers nothing"
1401        );
1402        assert!(engine.delivered_messages()?.is_empty());
1403        Ok(())
1404    }
1405
1406    /// aion#145, post-retirement carrier: a row that OUTLIVED its recorded
1407    /// fire is the missing acknowledgement (the fire path retires only after
1408    /// delivering), so the sweep redelivers the owed wake once — from its
1409    /// grouped read, appending nothing — and the retirement behind it keeps
1410    /// every later sweep quiet.
1411    #[tokio::test]
1412    async fn a_surviving_recorded_fire_row_is_redelivered_once_then_retires()
1413    -> Result<(), TestError> {
1414        let process = WorkflowProcessHandle::new(42);
1415        let (store, engine, recovery) = recovery();
1416        let workflow_id = workflow_id();
1417        let timer_id = timer_id(7);
1418        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
1419        // The ack-loss shape: the fire recorded durably, but the wake (and the
1420        // retirement that follows it) never happened — the row survives.
1421        engine.record_workflow_event(
1422            &workflow_id,
1423            timer_started_event(&workflow_id, &timer_id, 1, instant(5)),
1424        )?;
1425        engine
1426            .record_workflow_event(&workflow_id, timer_fired_event(&workflow_id, &timer_id, 2))?;
1427        store
1428            .schedule_timer(&workflow_id, &timer_id, instant(5), 1)
1429            .await?;
1430
1431        assert_eq!(
1432            recovery.recover_on_startup(instant(20)).await?.fired,
1433            0,
1434            "a redelivery is not a fire"
1435        );
1436        assert_eq!(
1437            engine.delivered_messages()?.len(),
1438            1,
1439            "the owed wake is delivered exactly once"
1440        );
1441        assert_eq!(
1442            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1443            1,
1444            "redelivery appends nothing — the recorder seam answers AlreadyRecorded"
1445        );
1446        assert!(
1447            store.expired_timers(instant(20)).await?.is_empty(),
1448            "the redelivered row retires — the wake's acknowledgement is durable now"
1449        );
1450
1451        // Paid once, ever: the next sweep is quiet.
1452        assert_eq!(recovery.recover_on_startup(instant(20)).await?.fired, 0);
1453        assert_eq!(engine.delivered_messages()?.len(), 1);
1454        Ok(())
1455    }
1456
1457    /// The non-resident arm of the same shape: no live wake is owed (replay on
1458    /// residency restore consumes the recorded fire), so the row just retires.
1459    /// This is the arm the 2026-08-24 boot walked 1,434 times without ever
1460    /// emptying.
1461    #[tokio::test]
1462    async fn a_surviving_recorded_fire_row_of_a_nonresident_workflow_retires_silently()
1463    -> Result<(), TestError> {
1464        let (store, engine, recovery) = recovery();
1465        let workflow_id = workflow_id();
1466        let timer_id = timer_id(8);
1467        engine.record_workflow_event(
1468            &workflow_id,
1469            timer_started_event(&workflow_id, &timer_id, 1, instant(5)),
1470        )?;
1471        engine
1472            .record_workflow_event(&workflow_id, timer_fired_event(&workflow_id, &timer_id, 2))?;
1473        store
1474            .schedule_timer(&workflow_id, &timer_id, instant(5), 1)
1475            .await?;
1476
1477        assert_eq!(recovery.recover_on_startup(instant(20)).await?.fired, 0);
1478
1479        assert!(
1480            engine.delivered_messages()?.is_empty(),
1481            "no wake is owed to a non-resident workflow"
1482        );
1483        assert_eq!(
1484            count_timer_fired(&history(&store, &workflow_id).await?, &timer_id),
1485            1,
1486            "nothing is appended for a non-resident redelivery"
1487        );
1488        assert!(
1489            store.expired_timers(instant(20)).await?.is_empty(),
1490            "the consumed row retires instead of surviving every boot"
1491        );
1492        Ok(())
1493    }
1494
1495    /// The stale-oracle interleave: the sweep's history snapshot says `Fired`,
1496    /// but by redelivery time the workflow has RE-ARMED the same timer name —
1497    /// the engine's recorded events are ahead of the swept snapshot. The
1498    /// redelivery seam, answering under the recorder's authority, must say
1499    /// `NotOwed`: NO second `TimerFired` is minted for the new arming (the
1500    /// premature-fire defect this pin exists for), no wake is delivered, and
1501    /// the OLD arming's row retires while the new arming's claim is untouched.
1502    #[tokio::test]
1503    async fn a_redelivery_for_a_rearmed_timer_appends_nothing_and_wakes_nobody()
1504    -> Result<(), TestError> {
1505        let concrete_store = Arc::new(InMemoryStore::default());
1506        let readable: Arc<dyn ReadableEventStore> = concrete_store.clone();
1507        // The fake is deliberately NOT wired to the store: its recorded
1508        // events model the recorder's (ahead) view, the store models the
1509        // sweep's (stale) snapshot.
1510        let engine = Arc::new(FakeEngineHandle::new());
1511        let timer_service = Arc::new(TimerService::with_recorded_at(
1512            engine.clone(),
1513            readable.clone(),
1514            recorded_at,
1515        ));
1516        let recovery = TimerRecovery::new(readable, timer_service);
1517
1518        let workflow_id = workflow_id();
1519        let timer_id = timer_id(3);
1520        engine.set_residency(
1521            workflow_id.clone(),
1522            WorkflowResidency::Resident(WorkflowProcessHandle::new(7)),
1523        )?;
1524        // The recorder's view: fired, then RE-ARMED (`TimerStarted` again).
1525        engine.record_workflow_event(
1526            &workflow_id,
1527            timer_started_event(&workflow_id, &timer_id, 1, instant(5)),
1528        )?;
1529        engine
1530            .record_workflow_event(&workflow_id, timer_fired_event(&workflow_id, &timer_id, 2))?;
1531        engine.record_workflow_event(
1532            &workflow_id,
1533            timer_started_event(&workflow_id, &timer_id, 3, instant(5)),
1534        )?;
1535        // The sweep's view: history stops at the fire, and the old arming's
1536        // row still stands in the expired index.
1537        concrete_store
1538            .append(
1539                WriteToken::recorder(),
1540                &workflow_id,
1541                &[
1542                    timer_started_event(&workflow_id, &timer_id, 1, instant(5)),
1543                    timer_fired_event(&workflow_id, &timer_id, 2),
1544                ],
1545                0,
1546            )
1547            .await?;
1548        concrete_store
1549            .schedule_timer(&workflow_id, &timer_id, instant(5), 1)
1550            .await?;
1551
1552        let recovered = recovery.recover_on_startup(instant(20)).await?;
1553
1554        assert_eq!(recovered.fired, 0, "a stale row is never a live fire");
1555        assert!(
1556            engine.delivered_messages()?.is_empty(),
1557            "no wake may reach a workflow that already ran past the recorded fire"
1558        );
1559        let recorded: Vec<Event> = engine
1560            .recorded_events()?
1561            .into_iter()
1562            .map(|(_, event)| event)
1563            .collect();
1564        assert_eq!(
1565            count_timer_fired(&recorded, &timer_id),
1566            1,
1567            "the redelivery must NOT mint a premature `TimerFired` for the \
1568             re-armed timer — the original fire stays the only record"
1569        );
1570        assert!(
1571            concrete_store.expired_timers(instant(20)).await?.is_empty(),
1572            "the OLD arming's row retires; nothing re-walks it"
1573        );
1574        Ok(())
1575    }
1576}