Skip to main content

ic_timers/snapshot/
model.rs

1//! Closed policy, state, outcome, and epoch values.
2
3use crate::{
4    callback::{AfterCompletionDecision, OnceDecision},
5    schedule::{OrdinaryDirective, ScheduleError, TimerCadence, duration_ns},
6};
7
8/// Configured recurrence policy for one logical timer.
9#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10pub enum TimerPolicy {
11    /// Run at most once unless an explicit directive or request schedules it.
12    Once,
13    /// Permit recurrence at a configured cadence after callback completion.
14    AfterCompletion {
15        /// Validated configured cadence.
16        cadence: TimerCadence,
17    },
18    /// Commit a successor before dispatching synchronous fallible work.
19    Watchdog {
20        /// Validated configured cadence.
21        cadence: TimerCadence,
22    },
23}
24
25impl TimerPolicy {
26    /// Return the stable configured-policy label.
27    #[must_use]
28    pub const fn label(self) -> &'static str {
29        match self {
30            Self::Once => "once",
31            Self::AfterCompletion { .. } => "after_completion",
32            Self::Watchdog { .. } => "watchdog",
33        }
34    }
35
36    /// Return a configured cadence when the policy recurs.
37    #[must_use]
38    pub const fn cadence(self) -> Option<TimerCadence> {
39        match self {
40            Self::Once => None,
41            Self::AfterCompletion { cadence } | Self::Watchdog { cadence } => Some(cadence),
42        }
43    }
44}
45
46/// Whether a stopped declaration remains in the bounded registry.
47///
48/// This is an observation of declaration lifetime. Direct registration uses
49/// [`crate::TimerStart`] to couple retention with a valid initial schedule.
50#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
51pub enum DeclarationLifetime {
52    /// Keep callback authority available for a later ensure request.
53    Retained,
54    /// Remove the declaration after terminal completion or cancellation.
55    RemoveWhenStopped,
56}
57
58/// Latest effective scheduling reason for a declaration.
59///
60/// The value remains observable while inactive and is not itself evidence of
61/// an armed callback.
62#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
63pub enum TimerSchedulingMode {
64    /// An explicit relative-delay schedule, including initial one-shot work.
65    Once,
66    /// Recurrence delayed from the previous completion.
67    AfterCompletion,
68    /// An explicit absolute deadline.
69    Deadline,
70    /// A delayed retry following an expected failure.
71    Retry,
72    /// Immediate continuation of bounded work.
73    Continuation,
74    /// A successor committed before fallible watchdog work.
75    Watchdog,
76}
77
78impl TimerSchedulingMode {
79    /// Return a stable adapter-friendly label.
80    #[must_use]
81    pub const fn label(self) -> &'static str {
82        match self {
83            Self::Once => "once",
84            Self::AfterCompletion => "after_completion",
85            Self::Deadline => "deadline",
86            Self::Retry => "retry",
87            Self::Continuation => "continuation",
88            Self::Watchdog => "watchdog",
89        }
90    }
91}
92
93/// Portable representation of the effective ordinary scheduling directive
94/// after authoritative completion commands have been applied.
95#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
96pub enum TimerDirectiveSnapshot {
97    /// Stop after the completed invocation.
98    Stop,
99    /// Continue in the next available timer message.
100    ContinueImmediately,
101    /// Retry after a relative delay.
102    RetryAfter {
103        /// Requested delay in nanoseconds.
104        delay_ns: u64,
105    },
106    /// Schedule at one absolute IC timestamp.
107    ScheduleAt {
108        /// Absolute IC timestamp in nanoseconds.
109        deadline_ns: u64,
110    },
111    /// Recur using the registration's configured cadence.
112    RecurAfterCompletion,
113}
114
115impl TryFrom<OrdinaryDirective> for TimerDirectiveSnapshot {
116    type Error = ScheduleError;
117
118    fn try_from(value: OrdinaryDirective) -> Result<Self, Self::Error> {
119        Ok(match value {
120            OrdinaryDirective::Stop => Self::Stop,
121            OrdinaryDirective::ContinueImmediately => Self::ContinueImmediately,
122            OrdinaryDirective::RetryAfter(delay) => Self::RetryAfter {
123                delay_ns: duration_ns(delay)?,
124            },
125            OrdinaryDirective::ScheduleAt(deadline_ns) => Self::ScheduleAt { deadline_ns },
126            OrdinaryDirective::RecurAfterCompletion => Self::RecurAfterCompletion,
127        })
128    }
129}
130
131impl TryFrom<OnceDecision> for TimerDirectiveSnapshot {
132    type Error = ScheduleError;
133
134    fn try_from(value: OnceDecision) -> Result<Self, Self::Error> {
135        Self::try_from(OrdinaryDirective::from(value))
136    }
137}
138
139impl TryFrom<AfterCompletionDecision> for TimerDirectiveSnapshot {
140    type Error = ScheduleError;
141
142    fn try_from(value: AfterCompletionDecision) -> Result<Self, Self::Error> {
143        Self::try_from(OrdinaryDirective::from(value))
144    }
145}
146
147/// Typed terminal failure in pure timer control.
148#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
149pub enum TimerControlFailure {
150    /// A new callback would exceed its generation counter's maximum.
151    /// Cancellation does not allocate a generation and cannot cause this failure.
152    GenerationExhausted,
153    /// Checked successor deadline arithmetic overflowed.
154    DeadlineOverflow,
155    /// A requested relative delay cannot be encoded as `u64` nanoseconds.
156    DelayOutOfRange,
157    /// An internal erased proposal is inconsistent with its configured policy.
158    /// Public callback result types cannot express a policy-invalid decision.
159    DirectiveNotAllowed,
160    /// A checked registry effect could not establish canonical provider ownership.
161    ProviderBindingFailed,
162}
163
164impl TimerControlFailure {
165    /// Return a stable adapter-friendly label.
166    #[must_use]
167    pub const fn label(self) -> &'static str {
168        match self {
169            Self::GenerationExhausted => "generation_exhausted",
170            Self::DeadlineOverflow => "deadline_overflow",
171            Self::DelayOutOfRange => "delay_out_of_range",
172            Self::DirectiveNotAllowed => "directive_not_allowed",
173            Self::ProviderBindingFailed => "provider_binding_failed",
174        }
175    }
176}
177
178/// Why a declaration currently has no scheduled or running callback generation.
179#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
180pub enum InactiveReason {
181    /// The declaration has not yet been scheduled.
182    NeverScheduled,
183    /// Work returned a terminal stop decision.
184    Stopped,
185    /// Explicit cancellation won request arbitration.
186    Cancelled,
187    /// A confirmed ordinary delivery was dropped without normal completion.
188    /// This records abandonment, not the cause or recovery of application work.
189    Abandoned,
190    /// Consumer work reported an invariant or terminal failure.
191    InvariantFailure,
192    /// Checked pure control reached a terminal failure.
193    ControlFailure(TimerControlFailure),
194}
195
196/// Coherent ordinary timer state.
197#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
198pub enum OrdinaryRuntimeStateSnapshot {
199    /// One callback generation is scheduled.
200    Scheduled {
201        /// Generation the callback must present.
202        generation: u64,
203        /// Authoritative absolute deadline.
204        deadline_ns: u64,
205    },
206    /// One callback generation owns logical execution.
207    Running {
208        /// Generation owned by the running callback.
209        generation: u64,
210    },
211}
212
213/// Status of the watchdog attempt paired with a committed successor.
214#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
215pub enum WatchdogAttemptStatus {
216    /// The scheduler committed the work callback, which has not committed a start.
217    Dispatched,
218    /// The accepted work callback is currently executing synchronously.
219    Running,
220}
221
222/// Coherent watchdog timer state.
223#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
224pub enum WatchdogRuntimeStateSnapshot {
225    /// One scheduler generation is authoritative and no work is outstanding.
226    Scheduled {
227        /// Generation the scheduler callback must present.
228        scheduler_generation: u64,
229        /// Authoritative absolute successor deadline.
230        deadline_ns: u64,
231    },
232    /// A successor is authoritative while one work attempt is outstanding.
233    AwaitingWork {
234        /// Dispatch generation the successor scheduler must present, shared
235        /// with the paired work attempt.
236        successor_generation: u64,
237        /// Absolute successor deadline.
238        successor_deadline_ns: u64,
239        /// Whether the paired work is dispatched or running.
240        attempt_status: WatchdogAttemptStatus,
241    },
242}
243
244/// Closed policy-specific runtime state.
245#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
246pub enum TimerRuntimeStateSnapshot {
247    /// The declaration has no scheduled or running callback generation.
248    Inactive {
249        /// Reason scheduling is inactive.
250        reason: InactiveReason,
251    },
252    /// State legal only for `Once` and `AfterCompletion`.
253    Ordinary(OrdinaryRuntimeStateSnapshot),
254    /// State legal only for `Watchdog`.
255    Watchdog(WatchdogRuntimeStateSnapshot),
256}
257
258impl TimerRuntimeStateSnapshot {
259    pub(crate) const fn next_deadline_ns(self) -> Option<u64> {
260        match self {
261            Self::Inactive { .. }
262            | Self::Ordinary(OrdinaryRuntimeStateSnapshot::Running { .. }) => None,
263            Self::Ordinary(OrdinaryRuntimeStateSnapshot::Scheduled { deadline_ns, .. })
264            | Self::Watchdog(WatchdogRuntimeStateSnapshot::Scheduled { deadline_ns, .. }) => {
265                Some(deadline_ns)
266            }
267            Self::Watchdog(WatchdogRuntimeStateSnapshot::AwaitingWork {
268                successor_deadline_ns,
269                ..
270            }) => Some(successor_deadline_ns),
271        }
272    }
273}
274
275/// Portable projection of provider callback-generation state.
276///
277/// This is independent of declaration lifetime: a retained declaration may
278/// report `Unregistered` while it keeps callback authority for a later ensure.
279#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
280pub enum TimerRegistrationStatus {
281    /// No callback generation is scheduled or running.
282    Unregistered,
283    /// A wake-up generation is authoritative and consumer work is not running.
284    Scheduled,
285    /// Consumer work currently owns logical execution.
286    Running,
287}
288
289impl TimerRegistrationStatus {
290    /// Return a stable adapter-friendly label.
291    #[must_use]
292    pub const fn label(self) -> &'static str {
293        match self {
294            Self::Unregistered => "unregistered",
295            Self::Scheduled => "scheduled",
296            Self::Running => "running",
297        }
298    }
299}
300
301impl From<TimerRuntimeStateSnapshot> for TimerRegistrationStatus {
302    fn from(value: TimerRuntimeStateSnapshot) -> Self {
303        match value {
304            TimerRuntimeStateSnapshot::Inactive { .. } => Self::Unregistered,
305            TimerRuntimeStateSnapshot::Ordinary(state) => match state {
306                OrdinaryRuntimeStateSnapshot::Scheduled { .. } => Self::Scheduled,
307                OrdinaryRuntimeStateSnapshot::Running { .. } => Self::Running,
308            },
309            TimerRuntimeStateSnapshot::Watchdog(state) => match state {
310                WatchdogRuntimeStateSnapshot::Scheduled { .. }
311                | WatchdogRuntimeStateSnapshot::AwaitingWork {
312                    attempt_status: WatchdogAttemptStatus::Dispatched,
313                    ..
314                } => Self::Scheduled,
315                WatchdogRuntimeStateSnapshot::AwaitingWork {
316                    attempt_status: WatchdogAttemptStatus::Running,
317                    ..
318                } => Self::Running,
319            },
320        }
321    }
322}
323
324/// Outcome of one callback that returned to the runtime.
325#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
326pub enum TimerCompletionOutcome {
327    /// Callback completed useful work successfully.
328    Success,
329    /// Callback completed normally but found no work.
330    NoWork,
331    /// Expected failure permits retry policy.
332    RetryableFailure,
333    /// Unexpected invariant or terminal failure.
334    InvariantFailure,
335}
336
337impl TimerCompletionOutcome {
338    /// Return a stable adapter-friendly label.
339    #[must_use]
340    pub const fn label(self) -> &'static str {
341        match self {
342            Self::Success => "success",
343            Self::NoWork => "no_work",
344            Self::RetryableFailure => "retryable_failure",
345            Self::InvariantFailure => "invariant_failure",
346        }
347    }
348}
349
350/// Latest observed terminal event for one timer invocation.
351#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
352pub enum TimerLastOutcome {
353    /// One callback returned with a classified completion.
354    Completed(TimerCompletionOutcome),
355    /// A confirmed ordinary delivery was dropped, or a committed Watchdog
356    /// dispatch was retired by its successor, without a completion.
357    Unacknowledged,
358}
359
360/// Classified result of one returned consumer invocation.
361#[derive(Clone, Copy, Debug, Eq, PartialEq)]
362pub struct TimerCompletion {
363    outcome: TimerCompletionOutcome,
364    work_count: u64,
365}
366
367impl TimerCompletion {
368    /// Construct successful completed work.
369    #[must_use]
370    pub const fn success(work_count: u64) -> Self {
371        Self {
372            outcome: TimerCompletionOutcome::Success,
373            work_count,
374        }
375    }
376
377    /// Construct a valid no-work completion.
378    #[must_use]
379    pub const fn no_work() -> Self {
380        Self {
381            outcome: TimerCompletionOutcome::NoWork,
382            work_count: 0,
383        }
384    }
385
386    /// Construct an expected failure, retaining completed partial work.
387    #[must_use]
388    pub const fn retryable_failure(work_count: u64) -> Self {
389        Self {
390            outcome: TimerCompletionOutcome::RetryableFailure,
391            work_count,
392        }
393    }
394
395    /// Construct an invariant failure, retaining completed partial work.
396    #[must_use]
397    pub const fn invariant_failure(work_count: u64) -> Self {
398        Self {
399            outcome: TimerCompletionOutcome::InvariantFailure,
400            work_count,
401        }
402    }
403
404    /// Return the completion class.
405    #[must_use]
406    pub const fn outcome(self) -> TimerCompletionOutcome {
407        self.outcome
408    }
409
410    /// Return application work units reported by the consumer.
411    #[must_use]
412    pub const fn work_count(self) -> u64 {
413        self.work_count
414    }
415}
416
417/// Watchdog decision after one synchronous bounded work attempt.
418#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
419pub enum WatchdogDecision {
420    /// Retain the successor committed by the scheduler message.
421    Continue,
422    /// Move the committed successor to the current IC time.
423    ///
424    /// The replacement remains a later scheduler message; this never invokes
425    /// consumer work recursively in the current message.
426    ContinueImmediately,
427    /// Replace the committed successor with an exact absolute IC deadline.
428    ///
429    /// A past deadline schedules a later message with zero delay. If work
430    /// traps, this proposal rolls back and the cadence successor remains.
431    ScheduleAt(u64),
432    /// Terminate and clear the committed successor.
433    Stop,
434}
435
436/// Synchronous watchdog work result.
437#[derive(Clone, Copy, Debug, Eq, PartialEq)]
438pub struct WatchdogRunResult {
439    completion: TimerCompletion,
440    decision: WatchdogDecision,
441}
442
443impl WatchdogRunResult {
444    /// Report a completed batch and its next consumer-selected deadline.
445    ///
446    /// Zero work reports no work; nonzero work reports success. Work units are
447    /// defined by the caller and need not mean confirmed external effects.
448    /// `None` stops the watchdog; a deadline schedules its next attempt.
449    /// Use [`Self::new`] to report failures or retain the cadence successor.
450    #[must_use]
451    pub const fn completed_batch(work_count: u64, next_due_ns: Option<u64>) -> Self {
452        let completion = if work_count == 0 {
453            TimerCompletion::no_work()
454        } else {
455            TimerCompletion::success(work_count)
456        };
457        let decision = match next_due_ns {
458            Some(at_ns) => WatchdogDecision::ScheduleAt(at_ns),
459            None => WatchdogDecision::Stop,
460        };
461        Self::new(completion, decision)
462    }
463
464    /// Construct a result, forcing invariant failures to stop.
465    #[must_use]
466    pub const fn new(completion: TimerCompletion, decision: WatchdogDecision) -> Self {
467        Self {
468            decision: if matches!(completion.outcome, TimerCompletionOutcome::InvariantFailure) {
469                WatchdogDecision::Stop
470            } else {
471                decision
472            },
473            completion,
474        }
475    }
476
477    /// Return the completion classification and work count.
478    #[must_use]
479    pub const fn completion(self) -> TimerCompletion {
480        self.completion
481    }
482
483    /// Return how the committed successor should be retained or cleared.
484    #[must_use]
485    pub const fn decision(self) -> WatchdogDecision {
486        self.decision
487    }
488}
489
490// One event owns both its completion classification and reported work count.
491#[derive(Clone, Copy, Debug, Eq, PartialEq)]
492enum RecordedOutcome {
493    Completed(TimerCompletion),
494    Unacknowledged,
495}
496
497/// Latest outcome and functional failure state for one timer.
498#[derive(Clone, Copy, Debug, Eq, PartialEq)]
499pub struct TimerOutcomeSnapshot {
500    last_event: Option<RecordedOutcome>,
501    last_success_at_ns: Option<u64>,
502    last_failure_at_ns: Option<u64>,
503    last_unacknowledged_at_ns: Option<u64>,
504    consecutive_expected_failures: u64,
505}
506
507impl TimerOutcomeSnapshot {
508    pub(crate) const EMPTY: Self = Self {
509        last_event: None,
510        last_success_at_ns: None,
511        last_failure_at_ns: None,
512        last_unacknowledged_at_ns: None,
513        consecutive_expected_failures: 0,
514    };
515
516    pub(crate) const fn record_completion(
517        &mut self,
518        completion: TimerCompletion,
519        completed_at_ns: u64,
520    ) {
521        self.last_event = Some(RecordedOutcome::Completed(completion));
522        match completion.outcome {
523            TimerCompletionOutcome::Success | TimerCompletionOutcome::NoWork => {
524                self.last_success_at_ns = Some(completed_at_ns);
525                self.consecutive_expected_failures = 0;
526            }
527            TimerCompletionOutcome::RetryableFailure => {
528                self.last_failure_at_ns = Some(completed_at_ns);
529                self.consecutive_expected_failures =
530                    self.consecutive_expected_failures.saturating_add(1);
531            }
532            TimerCompletionOutcome::InvariantFailure => {
533                self.last_failure_at_ns = Some(completed_at_ns);
534                self.consecutive_expected_failures = 0;
535            }
536        }
537    }
538
539    pub(crate) const fn record_unacknowledged(&mut self, observed_at_ns: u64) {
540        self.last_event = Some(RecordedOutcome::Unacknowledged);
541        self.last_unacknowledged_at_ns = Some(observed_at_ns);
542    }
543
544    /// Return the latest terminal event.
545    #[must_use]
546    pub const fn last_outcome(self) -> Option<TimerLastOutcome> {
547        match self.last_event {
548            Some(RecordedOutcome::Completed(completion)) => {
549                Some(TimerLastOutcome::Completed(completion.outcome()))
550            }
551            Some(RecordedOutcome::Unacknowledged) => Some(TimerLastOutcome::Unacknowledged),
552            None => None,
553        }
554    }
555
556    /// Return work reported by the latest completion.
557    #[must_use]
558    pub const fn last_work_count(self) -> Option<u64> {
559        match self.last_event {
560            Some(RecordedOutcome::Completed(completion)) => Some(completion.work_count()),
561            Some(RecordedOutcome::Unacknowledged) | None => None,
562        }
563    }
564
565    /// Return the latest successful or valid no-work completion time.
566    #[must_use]
567    pub const fn last_success_at_ns(self) -> Option<u64> {
568        self.last_success_at_ns
569    }
570
571    /// Return the latest expected or invariant failure completion time.
572    #[must_use]
573    pub const fn last_failure_at_ns(self) -> Option<u64> {
574        self.last_failure_at_ns
575    }
576
577    /// Return when an ordinary delivery or dispatched Watchdog attempt was
578    /// most recently retired without completion.
579    #[must_use]
580    pub const fn last_unacknowledged_at_ns(self) -> Option<u64> {
581        self.last_unacknowledged_at_ns
582    }
583
584    /// Return consecutive retryable failures since the latest reset outcome.
585    #[must_use]
586    pub const fn consecutive_expected_failures(self) -> u64 {
587        self.consecutive_expected_failures
588    }
589}
590
591/// Identity and start time of one runtime-local observation epoch.
592#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
593pub struct TimerEpoch {
594    canister_version: u64,
595    started_at_ns: u64,
596}
597
598impl TimerEpoch {
599    pub(crate) const fn new(canister_version: u64, started_at_ns: u64) -> Self {
600        Self {
601            canister_version,
602            started_at_ns,
603        }
604    }
605
606    /// Return the IC canister version that owns this volatile epoch.
607    #[must_use]
608    pub const fn canister_version(self) -> u64 {
609        self.canister_version
610    }
611
612    /// Return the IC timestamp at which the epoch began.
613    #[must_use]
614    pub const fn started_at_ns(self) -> u64 {
615        self.started_at_ns
616    }
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    #[test]
624    fn completed_batch_keeps_work_classification_and_deadline_independent() {
625        for work_count in [0, 1, u64::MAX] {
626            for next_due_ns in [None, Some(0), Some(u64::MAX)] {
627                let result = WatchdogRunResult::completed_batch(work_count, next_due_ns);
628                assert_eq!(result.completion().work_count(), work_count);
629                assert_eq!(
630                    result.completion().outcome(),
631                    if work_count == 0 {
632                        TimerCompletionOutcome::NoWork
633                    } else {
634                        TimerCompletionOutcome::Success
635                    }
636                );
637                assert_eq!(
638                    result.decision(),
639                    next_due_ns.map_or(WatchdogDecision::Stop, WatchdogDecision::ScheduleAt)
640                );
641            }
642        }
643    }
644
645    #[test]
646    fn latest_outcome_keeps_work_count_and_independent_history_coherent() {
647        let mut outcomes = TimerOutcomeSnapshot::EMPTY;
648        assert_eq!(outcomes.last_outcome(), None);
649        assert_eq!(outcomes.last_work_count(), None);
650
651        for (completion, completed_at_ns) in [
652            (TimerCompletion::success(0), 10),
653            (TimerCompletion::no_work(), 11),
654            (TimerCompletion::retryable_failure(7), 12),
655        ] {
656            outcomes.record_completion(completion, completed_at_ns);
657            assert_eq!(
658                outcomes.last_outcome(),
659                Some(TimerLastOutcome::Completed(completion.outcome()))
660            );
661            assert_eq!(outcomes.last_work_count(), Some(completion.work_count()));
662        }
663        outcomes.record_unacknowledged(20);
664        assert_eq!(
665            outcomes.last_outcome(),
666            Some(TimerLastOutcome::Unacknowledged)
667        );
668        assert_eq!(outcomes.last_work_count(), None);
669        assert_eq!(outcomes.last_success_at_ns(), Some(11));
670        assert_eq!(outcomes.last_failure_at_ns(), Some(12));
671        assert_eq!(outcomes.last_unacknowledged_at_ns(), Some(20));
672        assert_eq!(outcomes.consecutive_expected_failures(), 1);
673
674        outcomes.record_completion(TimerCompletion::invariant_failure(2), 30);
675        assert_eq!(
676            outcomes.last_outcome(),
677            Some(TimerLastOutcome::Completed(
678                TimerCompletionOutcome::InvariantFailure
679            ))
680        );
681        assert_eq!(outcomes.last_work_count(), Some(2));
682        assert_eq!(outcomes.last_success_at_ns(), Some(11));
683        assert_eq!(outcomes.last_failure_at_ns(), Some(30));
684        assert_eq!(outcomes.last_unacknowledged_at_ns(), Some(20));
685        assert_eq!(outcomes.consecutive_expected_failures(), 0);
686
687        outcomes.record_completion(TimerCompletion::success(4), 40);
688        assert_eq!(
689            outcomes.last_outcome(),
690            Some(TimerLastOutcome::Completed(TimerCompletionOutcome::Success))
691        );
692        assert_eq!(outcomes.last_work_count(), Some(4));
693        assert_eq!(outcomes.last_success_at_ns(), Some(40));
694        assert_eq!(outcomes.last_failure_at_ns(), Some(30));
695        assert_eq!(outcomes.last_unacknowledged_at_ns(), Some(20));
696    }
697
698    #[test]
699    fn expected_failure_streak_saturates() {
700        let mut outcomes = TimerOutcomeSnapshot {
701            consecutive_expected_failures: u64::MAX,
702            ..TimerOutcomeSnapshot::EMPTY
703        };
704
705        outcomes.record_completion(TimerCompletion::retryable_failure(0), 10);
706
707        assert_eq!(outcomes.consecutive_expected_failures(), u64::MAX);
708    }
709
710    #[test]
711    fn invariant_results_are_forced_to_stop() {
712        let watchdog = WatchdogRunResult::new(
713            TimerCompletion::invariant_failure(3),
714            WatchdogDecision::ContinueImmediately,
715        );
716        assert_eq!(watchdog.decision(), WatchdogDecision::Stop);
717    }
718}