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/// Extra delay before the next tick when a run tripped the circuit breaker.
39///
40/// Pure: maps a run result to the advisory cooldown carried by
41/// [`faucet_core::FaucetError::CircuitOpen`], or `None` for any other outcome.
42/// The scheduler loop uses this to push the next tick out by at least the
43/// cooldown after a fail-fast circuit-open run.
44pub fn cooldown_delay<T>(
45    result: &Result<T, faucet_core::FaucetError>,
46) -> Option<std::time::Duration> {
47    match result {
48        Err(faucet_core::FaucetError::CircuitOpen { cooldown, .. }) => Some(*cooldown),
49        _ => None,
50    }
51}
52
53/// Mutable scheduler counters + policy.
54pub struct SchedulerState {
55    overlap: OverlapPolicy,
56    on_failure: ScheduleOnFailure,
57    max_runs: Option<u64>,
58    max_consecutive_failures: Option<u64>,
59    successful_runs: u64,
60    consecutive_failures: u64,
61    pending: bool,
62}
63
64impl SchedulerState {
65    pub fn new(c: &CompiledSchedule) -> Self {
66        Self {
67            overlap: c.overlap_policy,
68            on_failure: c.on_failure,
69            max_runs: c.max_runs,
70            max_consecutive_failures: c.max_consecutive_failures,
71            successful_runs: 0,
72            consecutive_failures: 0,
73            pending: false,
74        }
75    }
76
77    pub fn consecutive_failures(&self) -> u64 {
78        self.consecutive_failures
79    }
80
81    /// A scheduled tick fired. `running` = a run is currently in flight.
82    pub fn on_tick(&mut self, running: bool) -> TickAction {
83        if !running {
84            return TickAction::Dispatch;
85        }
86        match self.overlap {
87            OverlapPolicy::Skip => TickAction::Skip,
88            OverlapPolicy::Queue => {
89                self.pending = true;
90                TickAction::Queue
91            }
92            OverlapPolicy::Forbid => TickAction::ForbidAbort,
93        }
94    }
95
96    /// An in-flight run finished.
97    pub fn on_run_finished(&mut self, outcome: RunOutcome) -> AfterRun {
98        match outcome {
99            RunOutcome::Success => {
100                self.consecutive_failures = 0;
101                self.successful_runs += 1;
102                if let Some(max) = self.max_runs
103                    && self.successful_runs >= max
104                {
105                    return AfterRun::ExitOk;
106                }
107            }
108            RunOutcome::Failure => {
109                self.consecutive_failures += 1;
110                if matches!(self.on_failure, ScheduleOnFailure::Stop) {
111                    return AfterRun::ExitFailure {
112                        consecutive: self.consecutive_failures,
113                    };
114                }
115                if let Some(max) = self.max_consecutive_failures
116                    && self.consecutive_failures >= max
117                {
118                    return AfterRun::ExitFailure {
119                        consecutive: self.consecutive_failures,
120                    };
121                }
122            }
123        }
124        let dispatch_pending = self.pending;
125        self.pending = false;
126        AfterRun::Continue { dispatch_pending }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::schedule::spec::ScheduleSpec;
134
135    fn state(yaml: &str) -> SchedulerState {
136        let spec: ScheduleSpec = serde_yaml::from_str(yaml).unwrap();
137        let compiled = CompiledSchedule::compile(&spec).unwrap();
138        SchedulerState::new(&compiled)
139    }
140
141    #[test]
142    fn circuit_open_yields_cooldown_delay() {
143        use faucet_core::FaucetError;
144        let err: Result<(), FaucetError> = Err(FaucetError::CircuitOpen {
145            failures: 3,
146            cooldown: std::time::Duration::from_secs(45),
147        });
148        assert_eq!(
149            cooldown_delay(&err),
150            Some(std::time::Duration::from_secs(45))
151        );
152        let ok: Result<(), FaucetError> = Ok(());
153        assert_eq!(cooldown_delay(&ok), None);
154        // A non-circuit error carries no cooldown.
155        let other: Result<(), FaucetError> = Err(FaucetError::Sink("down".into()));
156        assert_eq!(cooldown_delay(&other), None);
157    }
158
159    #[test]
160    fn dispatches_when_idle() {
161        let mut s = state("cron: \"* * * * *\"");
162        assert_eq!(s.on_tick(false), TickAction::Dispatch);
163    }
164
165    #[test]
166    fn skip_policy_drops_overlapping_tick() {
167        let mut s = state("cron: \"* * * * *\"\noverlap_policy: skip");
168        assert_eq!(s.on_tick(true), TickAction::Skip);
169    }
170
171    #[test]
172    fn queue_policy_buffers_and_dispatches_on_completion() {
173        let mut s = state("cron: \"* * * * *\"\noverlap_policy: queue");
174        assert_eq!(s.on_tick(true), TickAction::Queue);
175        assert_eq!(
176            s.on_run_finished(RunOutcome::Success),
177            AfterRun::Continue {
178                dispatch_pending: true
179            }
180        );
181        // Pending consumed exactly once.
182        assert_eq!(
183            s.on_run_finished(RunOutcome::Success),
184            AfterRun::Continue {
185                dispatch_pending: false
186            }
187        );
188    }
189
190    #[test]
191    fn forbid_policy_aborts_on_overlap() {
192        let mut s = state("cron: \"* * * * *\"\noverlap_policy: forbid");
193        assert_eq!(s.on_tick(true), TickAction::ForbidAbort);
194    }
195
196    #[test]
197    fn max_runs_counts_successes_only() {
198        let mut s = state("cron: \"* * * * *\"\nmax_runs: 2");
199        assert_eq!(
200            s.on_run_finished(RunOutcome::Failure),
201            AfterRun::Continue {
202                dispatch_pending: false
203            }
204        );
205        assert_eq!(
206            s.on_run_finished(RunOutcome::Success),
207            AfterRun::Continue {
208                dispatch_pending: false
209            }
210        );
211        assert_eq!(s.on_run_finished(RunOutcome::Success), AfterRun::ExitOk);
212    }
213
214    #[test]
215    fn on_failure_stop_exits_on_first_failure() {
216        let mut s = state("cron: \"* * * * *\"\non_failure: stop");
217        assert_eq!(
218            s.on_run_finished(RunOutcome::Failure),
219            AfterRun::ExitFailure { consecutive: 1 }
220        );
221    }
222
223    #[test]
224    fn max_consecutive_failures_trips_and_success_resets() {
225        let mut s = state("cron: \"* * * * *\"\nmax_consecutive_failures: 3");
226        assert_eq!(
227            s.on_run_finished(RunOutcome::Failure),
228            AfterRun::Continue {
229                dispatch_pending: false
230            }
231        );
232        assert_eq!(
233            s.on_run_finished(RunOutcome::Success),
234            AfterRun::Continue {
235                dispatch_pending: false
236            }
237        );
238        // Counter reset by the success above.
239        assert_eq!(
240            s.on_run_finished(RunOutcome::Failure),
241            AfterRun::Continue {
242                dispatch_pending: false
243            }
244        );
245        assert_eq!(
246            s.on_run_finished(RunOutcome::Failure),
247            AfterRun::Continue {
248                dispatch_pending: false
249            }
250        );
251        assert_eq!(
252            s.on_run_finished(RunOutcome::Failure),
253            AfterRun::ExitFailure { consecutive: 3 }
254        );
255    }
256}