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