faucet_cli/schedule/
state.rs1use crate::schedule::compiled::CompiledSchedule;
5use crate::schedule::spec::{OverlapPolicy, ScheduleOnFailure};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum TickAction {
10 Dispatch,
12 Skip,
14 Queue,
16 ForbidAbort,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum RunOutcome {
23 Success,
24 Failure,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum AfterRun {
30 Continue { dispatch_pending: bool },
32 ExitOk,
34 ExitFailure { consecutive: u64 },
36}
37
38pub 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
53pub 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 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 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 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 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 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}