Skip to main content

faucet_cli/schedule/
compiled.rs

1//! Validated, compiled form of a [`ScheduleSpec`]: the parsed cron + timezone
2//! plus the numeric knobs. `next_after` is pure (no wall clock) and is the
3//! single source of truth for "when does the next run fire".
4
5use crate::error::{CliError, CliResult};
6use crate::schedule::spec::{OverlapPolicy, ScheduleOnFailure, ScheduleSpec};
7use chrono::{DateTime, Utc};
8use chrono_tz::Tz;
9use croner::Cron;
10use croner::parser::{CronParser, Seconds};
11use std::time::Duration;
12
13/// A `ScheduleSpec` whose cron + timezone have been parsed and whose invariants
14/// have been checked. Built once at startup; `next_after` is called per tick.
15#[derive(Debug)]
16pub struct CompiledSchedule {
17    cron: Cron,
18    tz: Tz,
19    pub overlap_policy: OverlapPolicy,
20    pub on_failure: ScheduleOnFailure,
21    pub max_runs: Option<u64>,
22    pub max_consecutive_failures: Option<u64>,
23    pub start_immediately: bool,
24    pub run_timeout: Option<Duration>,
25    pub shutdown_grace: Duration,
26}
27
28impl CompiledSchedule {
29    /// Validate + compile. Every problem surfaces here, never mid-run.
30    pub fn compile(spec: &ScheduleSpec) -> CliResult<Self> {
31        let tz: Tz = spec.timezone.parse().map_err(|_| {
32            CliError::Config(format!("schedule: unknown timezone '{}'", spec.timezone))
33        })?;
34
35        let cron = CronParser::builder()
36            .seconds(Seconds::Optional)
37            .build()
38            .parse(&spec.cron)
39            .map_err(|e| {
40                CliError::Config(format!("schedule: invalid cron '{}': {e}", spec.cron))
41            })?;
42
43        if matches!(spec.max_runs, Some(0)) {
44            return Err(CliError::Config(
45                "schedule: max_runs must be >= 1 (use `faucet schedule --once` for a single run, or remove the schedule block)".into(),
46            ));
47        }
48        if matches!(spec.max_consecutive_failures, Some(0)) {
49            return Err(CliError::Config(
50                "schedule: max_consecutive_failures must be >= 1".into(),
51            ));
52        }
53        if matches!(spec.run_timeout_secs, Some(0)) {
54            return Err(CliError::Config(
55                "schedule: run_timeout_secs must be >= 1 (omit it for no timeout)".into(),
56            ));
57        }
58
59        let compiled = Self {
60            cron,
61            tz,
62            overlap_policy: spec.overlap_policy,
63            on_failure: spec.on_failure,
64            max_runs: spec.max_runs,
65            max_consecutive_failures: spec.max_consecutive_failures,
66            start_immediately: spec.start_immediately,
67            run_timeout: spec.run_timeout_secs.map(Duration::from_secs),
68            shutdown_grace: Duration::from_secs(spec.shutdown_grace_secs),
69        };
70
71        // Reject a cron that can never fire (e.g. Feb 30): if there is no
72        // occurrence at all, croner returns no next, so guard against a spin.
73        if compiled.next_after(Utc::now()).is_none() {
74            return Err(CliError::Config(format!(
75                "schedule: cron '{}' has no upcoming occurrence in timezone '{}'",
76                spec.cron, spec.timezone
77            )));
78        }
79        Ok(compiled)
80    }
81
82    /// Render a UTC instant in the schedule's timezone, as a fixed-offset clock
83    /// for `${now.*}` interpolation.
84    pub fn clock_at(
85        &self,
86        at: chrono::DateTime<chrono::Utc>,
87    ) -> chrono::DateTime<chrono::FixedOffset> {
88        at.with_timezone(&self.tz).fixed_offset()
89    }
90
91    /// The next UTC instant strictly after `after` that matches the cron in the
92    /// configured timezone. `None` when there is no such occurrence.
93    ///
94    /// Operating on strictly-increasing UTC instants gives the production-cron
95    /// semantics: a DST fall-back repeated hour fires once, a spring-forward
96    /// skipped hour rolls to the next valid time, and occurrences that elapsed
97    /// between two `after` values are simply skipped (no backfill).
98    pub fn next_after(&self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
99        let local = after.with_timezone(&self.tz);
100        self.cron
101            .find_next_occurrence(&local, false)
102            .ok()
103            .map(|dt| dt.with_timezone(&Utc))
104    }
105
106    /// The next due tick after firing the tick scheduled for `fired`, given the
107    /// current wall clock `now`.
108    ///
109    /// Advances from the *scheduled* tick (not wall-clock `now`) so an
110    /// occurrence isn't silently skipped merely because dispatch latency pushed
111    /// the clock past it — the loop fires the returned tick promptly even if it
112    /// is already slightly in the past (this is the sub-minute-skip fix). But if
113    /// firing that occurrence would still leave a *further* occurrence already
114    /// elapsed (the process was suspended across many ticks), the backlog is
115    /// collapsed to the next occurrence strictly after `now`, so a long sleep
116    /// produces a single catch-up run rather than a flood of backfilled runs.
117    /// Returns `None` when the schedule has no further occurrence.
118    pub fn next_due_after_tick(
119        &self,
120        fired: DateTime<Utc>,
121        now: DateTime<Utc>,
122    ) -> Option<DateTime<Utc>> {
123        let next = self.next_after(fired)?;
124        if next > now {
125            // On schedule: the next occurrence is still ahead.
126            return Some(next);
127        }
128        // `next` already elapsed. If the occurrence after it is *also* in the
129        // past we are badly behind — collapse the backlog. Otherwise run the
130        // single just-missed `next` (possibly a touch late) and resume.
131        match self.next_after(next) {
132            Some(after) if after <= now => self.next_after(now),
133            _ => Some(next),
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::schedule::spec::ScheduleSpec;
142    use chrono::TimeZone;
143
144    fn spec(cron: &str, tz: &str) -> ScheduleSpec {
145        serde_yaml::from_str(&format!("cron: \"{cron}\"\ntimezone: \"{tz}\"\n")).unwrap()
146    }
147
148    #[test]
149    fn compiles_standard_five_field_cron() {
150        assert!(CompiledSchedule::compile(&spec("0 2 * * *", "UTC")).is_ok());
151    }
152
153    #[test]
154    fn compiles_six_field_seconds_cron() {
155        assert!(CompiledSchedule::compile(&spec("*/30 * * * * *", "UTC")).is_ok());
156    }
157
158    #[test]
159    fn rejects_bad_cron() {
160        let err = CompiledSchedule::compile(&spec("not a cron", "UTC")).unwrap_err();
161        assert!(err.to_string().contains("invalid cron"));
162    }
163
164    #[test]
165    fn rejects_unknown_timezone() {
166        let err = CompiledSchedule::compile(&spec("0 2 * * *", "Mars/Olympus")).unwrap_err();
167        assert!(err.to_string().contains("unknown timezone"));
168    }
169
170    #[test]
171    fn rejects_zero_max_runs() {
172        let mut s = spec("0 2 * * *", "UTC");
173        s.max_runs = Some(0);
174        let err = CompiledSchedule::compile(&s).unwrap_err();
175        assert!(err.to_string().contains("max_runs"));
176    }
177
178    #[test]
179    fn rejects_zero_max_consecutive_failures() {
180        let mut s = spec("0 2 * * *", "UTC");
181        s.max_consecutive_failures = Some(0);
182        let err = CompiledSchedule::compile(&s).unwrap_err();
183        assert!(err.to_string().contains("max_consecutive_failures"));
184    }
185
186    #[test]
187    fn rejects_zero_run_timeout() {
188        let mut s = spec("0 2 * * *", "UTC");
189        s.run_timeout_secs = Some(0);
190        let err = CompiledSchedule::compile(&s).unwrap_err();
191        assert!(err.to_string().contains("run_timeout_secs"));
192    }
193
194    #[test]
195    fn rejects_never_firing_cron() {
196        // Feb 30 never exists.
197        let err = CompiledSchedule::compile(&spec("0 0 30 2 *", "UTC")).unwrap_err();
198        assert!(err.to_string().contains("no upcoming occurrence"));
199    }
200
201    #[test]
202    fn next_after_is_strictly_after_and_skips_missed() {
203        let c = CompiledSchedule::compile(&spec("0 0 * * *", "UTC")).unwrap(); // midnight daily
204        // 2026-03-10 06:00Z → next midnight is 2026-03-11 00:00Z.
205        let after = Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 0).unwrap();
206        let next = c.next_after(after).unwrap();
207        assert_eq!(next, Utc.with_ymd_and_hms(2026, 3, 11, 0, 0, 0).unwrap());
208        // From just before midnight we still get this midnight (strictly after).
209        let just_before = Utc.with_ymd_and_hms(2026, 3, 10, 23, 59, 59).unwrap();
210        assert_eq!(
211            c.next_after(just_before).unwrap(),
212            Utc.with_ymd_and_hms(2026, 3, 11, 0, 0, 0).unwrap()
213        );
214    }
215
216    #[test]
217    fn next_due_after_tick_runs_a_single_missed_sub_minute_occurrence() {
218        // Every-second cron. The tick scheduled for 06:00:00 fired, but
219        // dispatch latency pushed the clock to 06:00:01.3. The 06:00:01
220        // occurrence must still run (not be skipped) — that is the bug:
221        // recomputing from `now` would jump straight to 06:00:02.
222        let c = CompiledSchedule::compile(&spec("* * * * * *", "UTC")).unwrap();
223        let fired = Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 0).unwrap();
224        let now = Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 1).unwrap()
225            + chrono::Duration::milliseconds(300);
226        let due = c.next_due_after_tick(fired, now).unwrap();
227        assert_eq!(due, Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 1).unwrap());
228        // The old wall-clock recompute would have skipped to 06:00:02.
229        assert_ne!(due, c.next_after(now).unwrap());
230    }
231
232    #[test]
233    fn next_due_after_tick_returns_future_occurrence_when_on_schedule() {
234        let c = CompiledSchedule::compile(&spec("* * * * * *", "UTC")).unwrap();
235        let fired = Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 0).unwrap();
236        // Fired essentially on time.
237        let due = c.next_due_after_tick(fired, fired).unwrap();
238        assert_eq!(due, Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 1).unwrap());
239    }
240
241    #[test]
242    fn next_due_after_tick_collapses_a_long_backlog_to_one_catch_up() {
243        // The process was suspended for 100s on an every-second cron. Instead
244        // of replaying 100 ticks, collapse to the next future occurrence.
245        let c = CompiledSchedule::compile(&spec("* * * * * *", "UTC")).unwrap();
246        let fired = Utc.with_ymd_and_hms(2026, 3, 10, 6, 0, 0).unwrap();
247        let now = Utc.with_ymd_and_hms(2026, 3, 10, 6, 1, 40).unwrap(); // +100s
248        let due = c.next_due_after_tick(fired, now).unwrap();
249        assert_eq!(due, Utc.with_ymd_and_hms(2026, 3, 10, 6, 1, 41).unwrap());
250        assert!(due > now, "collapsed catch-up must be in the future");
251    }
252
253    #[test]
254    fn dst_spring_forward_rolls_to_next_valid_time() {
255        // America/Los_Angeles springs forward 2026-03-08 02:00→03:00. A 02:30
256        // daily job has no 02:30 that day; it must roll forward, not vanish.
257        let c = CompiledSchedule::compile(&spec("30 2 * * *", "America/Los_Angeles")).unwrap();
258        // 2026-03-08 09:00Z == 01:00 PST, before the skipped 02:30 local.
259        let after = Utc.with_ymd_and_hms(2026, 3, 8, 9, 0, 0).unwrap();
260        let next = c
261            .next_after(after)
262            .expect("must produce a valid occurrence");
263        // Must be strictly after `after` and exist as a real instant.
264        assert!(next > after);
265    }
266
267    #[test]
268    fn dst_fall_back_does_not_double_fire() {
269        // America/Los_Angeles falls back 2026-11-01 02:00→01:00 (01:xx repeats).
270        // A 01:30 daily job must fire once, not twice. We assert the next two
271        // occurrences computed monotonically are >= 23h apart (i.e. next day),
272        // proving the repeated local hour did not yield a second same-day fire.
273        let c = CompiledSchedule::compile(&spec("30 1 * * *", "America/Los_Angeles")).unwrap();
274        let after = Utc.with_ymd_and_hms(2026, 11, 1, 8, 0, 0).unwrap(); // 01:00 PDT
275        let first = c.next_after(after).unwrap();
276        let second = c.next_after(first).unwrap();
277        assert!(
278            (second - first) >= chrono::Duration::hours(23),
279            "fall-back produced a duplicate same-day fire: {first} -> {second}"
280        );
281    }
282}