Skip to main content

faucet_cli/schedule/
state.rs

1//! The scheduler's decision state machine. Pure — no clock, no tasks, no IO —
2//! so every overlap / failure / cap transition is unit-tested deterministically.
3
4use crate::schedule::compiled::CompiledSchedule;
5use crate::schedule::spec::{OverlapPolicy, ScheduleOnFailure};
6
7/// What the loop should do when a scheduled tick fires.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum TickAction {
10    /// Start a run now.
11    Dispatch,
12    /// Drop this tick (overlap, policy = skip).
13    Skip,
14    /// Remember this tick; run it when the current run finishes (policy = queue).
15    Queue,
16    /// Fatal overlap (policy = forbid) — exit non-zero.
17    ForbidAbort,
18}
19
20/// Outcome of a finished run, as seen by the state machine.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum RunOutcome {
23    Success,
24    Failure,
25}
26
27/// What the loop should do after a run finishes.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum AfterRun {
30    /// Keep scheduling. `dispatch_pending` = a queued tick should run now.
31    Continue { dispatch_pending: bool },
32    /// Stop cleanly (`max_runs` reached).
33    ExitOk,
34    /// Stop non-zero; carries the consecutive-failure count for the error.
35    ExitFailure { consecutive: u64 },
36}
37
38/// Mutable scheduler counters + policy.
39pub struct SchedulerState {
40    overlap: OverlapPolicy,
41    on_failure: ScheduleOnFailure,
42    max_runs: Option<u64>,
43    max_consecutive_failures: Option<u64>,
44    successful_runs: u64,
45    consecutive_failures: u64,
46    pending: bool,
47}
48
49impl SchedulerState {
50    pub fn new(c: &CompiledSchedule) -> Self {
51        Self {
52            overlap: c.overlap_policy,
53            on_failure: c.on_failure,
54            max_runs: c.max_runs,
55            max_consecutive_failures: c.max_consecutive_failures,
56            successful_runs: 0,
57            consecutive_failures: 0,
58            pending: false,
59        }
60    }
61
62    pub fn consecutive_failures(&self) -> u64 {
63        self.consecutive_failures
64    }
65
66    /// A scheduled tick fired. `running` = a run is currently in flight.
67    pub fn on_tick(&mut self, running: bool) -> TickAction {
68        if !running {
69            return TickAction::Dispatch;
70        }
71        match self.overlap {
72            OverlapPolicy::Skip => TickAction::Skip,
73            OverlapPolicy::Queue => {
74                self.pending = true;
75                TickAction::Queue
76            }
77            OverlapPolicy::Forbid => TickAction::ForbidAbort,
78        }
79    }
80
81    /// An in-flight run finished.
82    pub fn on_run_finished(&mut self, outcome: RunOutcome) -> AfterRun {
83        match outcome {
84            RunOutcome::Success => {
85                self.consecutive_failures = 0;
86                self.successful_runs += 1;
87                if let Some(max) = self.max_runs
88                    && self.successful_runs >= max
89                {
90                    return AfterRun::ExitOk;
91                }
92            }
93            RunOutcome::Failure => {
94                self.consecutive_failures += 1;
95                if matches!(self.on_failure, ScheduleOnFailure::Stop) {
96                    return AfterRun::ExitFailure {
97                        consecutive: self.consecutive_failures,
98                    };
99                }
100                if let Some(max) = self.max_consecutive_failures
101                    && self.consecutive_failures >= max
102                {
103                    return AfterRun::ExitFailure {
104                        consecutive: self.consecutive_failures,
105                    };
106                }
107            }
108        }
109        let dispatch_pending = self.pending;
110        self.pending = false;
111        AfterRun::Continue { dispatch_pending }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::schedule::spec::ScheduleSpec;
119
120    fn state(yaml: &str) -> SchedulerState {
121        let spec: ScheduleSpec = serde_yaml::from_str(yaml).unwrap();
122        let compiled = CompiledSchedule::compile(&spec).unwrap();
123        SchedulerState::new(&compiled)
124    }
125
126    #[test]
127    fn dispatches_when_idle() {
128        let mut s = state("cron: \"* * * * *\"");
129        assert_eq!(s.on_tick(false), TickAction::Dispatch);
130    }
131
132    #[test]
133    fn skip_policy_drops_overlapping_tick() {
134        let mut s = state("cron: \"* * * * *\"\noverlap_policy: skip");
135        assert_eq!(s.on_tick(true), TickAction::Skip);
136    }
137
138    #[test]
139    fn queue_policy_buffers_and_dispatches_on_completion() {
140        let mut s = state("cron: \"* * * * *\"\noverlap_policy: queue");
141        assert_eq!(s.on_tick(true), TickAction::Queue);
142        assert_eq!(
143            s.on_run_finished(RunOutcome::Success),
144            AfterRun::Continue {
145                dispatch_pending: true
146            }
147        );
148        // Pending consumed exactly once.
149        assert_eq!(
150            s.on_run_finished(RunOutcome::Success),
151            AfterRun::Continue {
152                dispatch_pending: false
153            }
154        );
155    }
156
157    #[test]
158    fn forbid_policy_aborts_on_overlap() {
159        let mut s = state("cron: \"* * * * *\"\noverlap_policy: forbid");
160        assert_eq!(s.on_tick(true), TickAction::ForbidAbort);
161    }
162
163    #[test]
164    fn max_runs_counts_successes_only() {
165        let mut s = state("cron: \"* * * * *\"\nmax_runs: 2");
166        assert_eq!(
167            s.on_run_finished(RunOutcome::Failure),
168            AfterRun::Continue {
169                dispatch_pending: false
170            }
171        );
172        assert_eq!(
173            s.on_run_finished(RunOutcome::Success),
174            AfterRun::Continue {
175                dispatch_pending: false
176            }
177        );
178        assert_eq!(s.on_run_finished(RunOutcome::Success), AfterRun::ExitOk);
179    }
180
181    #[test]
182    fn on_failure_stop_exits_on_first_failure() {
183        let mut s = state("cron: \"* * * * *\"\non_failure: stop");
184        assert_eq!(
185            s.on_run_finished(RunOutcome::Failure),
186            AfterRun::ExitFailure { consecutive: 1 }
187        );
188    }
189
190    #[test]
191    fn max_consecutive_failures_trips_and_success_resets() {
192        let mut s = state("cron: \"* * * * *\"\nmax_consecutive_failures: 3");
193        assert_eq!(
194            s.on_run_finished(RunOutcome::Failure),
195            AfterRun::Continue {
196                dispatch_pending: false
197            }
198        );
199        assert_eq!(
200            s.on_run_finished(RunOutcome::Success),
201            AfterRun::Continue {
202                dispatch_pending: false
203            }
204        );
205        // Counter reset by the success above.
206        assert_eq!(
207            s.on_run_finished(RunOutcome::Failure),
208            AfterRun::Continue {
209                dispatch_pending: false
210            }
211        );
212        assert_eq!(
213            s.on_run_finished(RunOutcome::Failure),
214            AfterRun::Continue {
215                dispatch_pending: false
216            }
217        );
218        assert_eq!(
219            s.on_run_finished(RunOutcome::Failure),
220            AfterRun::ExitFailure { consecutive: 3 }
221        );
222    }
223}