Skip to main content

areev_trigger/
schedule.rs

1//! When is this trigger next due?
2//!
3//! Interval, cron and one-shot are three declarations of one primitive: a
4//! function from "when did it last fire" to "when should it fire next". They
5//! are kept apart in the vocabulary because they read differently to a person,
6//! not because the evaluator treats them differently.
7//!
8//! Two behaviours here are worth stating because they are the ones people get
9//! wrong:
10//!
11//! **Rate drifts; cron does not.** `interval_secs` anchors to the *last firing*,
12//! so a 3-minute job on a 5-minute interval leaves a 2-minute gap and the
13//! effective period depends on runtime. `*/5 * * * *` anchors to wall-clock
14//! boundaries and does not. Both are correct; users need to know which they
15//! chose, so the two are separate kinds rather than one field with a flag.
16//!
17//! **Catch-up collapses by default.** A laptop closed over a weekend has missed
18//! hundreds of occurrences. `Catchup::Last` fires once on resume, which is what
19//! anacron does and what systemd's `Persistent=true` means. `All` replays them
20//! and `None` drops them; the three-way is Quartz's misfire vocabulary — fire
21//! once now / skip / replay all.
22
23use areev_core::types::{Catchup, Trigger, TriggerKind};
24use croner::Cron;
25
26use crate::error::{Result, TriggerError};
27
28/// This release evaluates cron in UTC only.
29///
30/// IANA timezone support means `chrono-tz` (~794 KB, and it pulls `phf`) or a
31/// second time library, and it means picking a side in a disagreement that real
32/// implementations have not settled: AWS EventBridge and robfig/cron *skip* an
33/// occurrence that falls in a spring-forward gap, while Vixie cron fires it
34/// immediately. They agree on the fall-back fold — fire once, do not repeat —
35/// so only the gap is contested, but that is enough to make a silent guess the
36/// wrong move. Refusing a timezone we would only mishandle is better than
37/// firing at the wrong hour and being believed.
38const SUPPORTED_TIMEZONE: &str = "UTC";
39
40/// Parse and validate a declaration's schedule without evaluating it.
41///
42/// Called at authoring time so a bad expression is refused when it is written
43/// rather than discovered as silence.
44pub fn validate(trigger: &Trigger) -> Result<()> {
45    if let Some(why) = trigger.incoherence() {
46        return Err(TriggerError::Malformed { what: why });
47    }
48    // A workflow reference that cannot be decoded can never start anything,
49    // and `incoherence()` only established it is non-empty. Checking it HERE
50    // is what puts it in `trigger status`'s `unusable` column (#67) instead
51    // of leaving it to report `waiting` forever and fail at fire time (#73).
52    // The scheme prefix is stripped first: both spellings are accepted on
53    // write, so both have to be readable.
54    if let Err(e) = areev_core::error::Hash::from_hex(trigger.workflow_hash()) {
55        return Err(TriggerError::UnresolvedWorkflow {
56            hash: trigger.workflow.clone(),
57            why: format!(
58                "{e} — a workflow is named by its 64-hex content address, \
59                 optionally prefixed `sha256:`"
60            ),
61        });
62    }
63    // A malformed `--context-query` spelling (#92) can never fire — refuse
64    // it at authoring time with the parser's own message. The fire path
65    // re-parses (fail-closed) for declarations that bypassed validation.
66    if let Some(spec) = trigger.context_query.as_deref() {
67        if let Err(why) = crate::context::parse_context_query(spec) {
68            return Err(TriggerError::Malformed {
69                what: format!("context_query: {why}"),
70            });
71        }
72    }
73    if trigger.kind == TriggerKind::Schedule {
74        let expr = trigger.cron.as_deref().unwrap_or_default();
75        parse_cron(expr)?;
76        if let Some(tz) = trigger.config_str(areev_core::types::config_keys::TIMEZONE) {
77            if !tz.eq_ignore_ascii_case(SUPPORTED_TIMEZONE) {
78                return Err(TriggerError::BadSchedule {
79                    expr: expr.to_string(),
80                    why: format!(
81                        "timezone {tz:?} is not supported in this release — cron is evaluated \
82                         in UTC. Declare the expression in UTC rather than having it fire at \
83                         the wrong local hour across a DST boundary"
84                    ),
85                });
86            }
87        }
88    }
89    if trigger.kind == TriggerKind::Composite {
90        // Refuse at authoring time exactly what firing refuses later. A gate
91        // naming a member the declaration does not carry can never be
92        // satisfied, so the trigger is dead on arrival -- and a dead trigger's
93        // only symptom is nothing happening, which is the whole category this
94        // validation exists to catch. Same TRG-E008 either way: the code names
95        // the defect, not the moment it was noticed.
96        //
97        // `incoherence()` has already established the predicate is present.
98        if let Some(raw) = trigger.predicate.as_ref() {
99            let predicate = crate::predicate::from_value(raw)?;
100            for referenced in crate::predicate::referenced_members(&predicate) {
101                if !trigger.members.contains_key(&referenced) {
102                    return Err(TriggerError::UnknownMember {
103                        trigger: "being declared".into(),
104                        member: referenced,
105                    });
106                }
107            }
108        }
109    }
110    Ok(())
111}
112
113fn parse_cron(expr: &str) -> Result<Cron> {
114    Cron::new(expr)
115        .with_seconds_optional()
116        .parse()
117        .map_err(|e| TriggerError::BadSchedule { expr: expr.to_string(), why: e.to_string() })
118}
119
120/// The next instant at or after `after_ms` at which `trigger` should fire.
121///
122/// `None` means "never again", which only a one-shot past its instant can be.
123pub fn next_due_after(trigger: &Trigger, after_ms: i64) -> Result<Option<i64>> {
124    match trigger.kind {
125        TriggerKind::Once => {
126            let at = trigger.at_ms.ok_or_else(|| TriggerError::Malformed {
127                what: "a once trigger needs at_ms".into(),
128            })?;
129            Ok(if at > after_ms { Some(at) } else { None })
130        }
131        TriggerKind::Interval | TriggerKind::Polling => {
132            let secs = trigger.interval_secs.ok_or_else(|| TriggerError::Malformed {
133                what: format!("a {} trigger needs interval_secs", trigger.kind.as_str()),
134            })?;
135            Ok(Some(after_ms.saturating_add(i64::from(secs) * 1000)))
136        }
137        TriggerKind::Schedule => {
138            let expr = trigger.cron.as_deref().unwrap_or_default();
139            let cron = parse_cron(expr)?;
140            let after = chrono::DateTime::from_timestamp_millis(after_ms).ok_or_else(|| {
141                TriggerError::BadSchedule {
142                    expr: expr.to_string(),
143                    why: format!("{after_ms} is not a representable instant"),
144                }
145            })?;
146            let next = cron.find_next_occurrence(&after, false).map_err(|e| {
147                TriggerError::BadSchedule { expr: expr.to_string(), why: e.to_string() }
148            })?;
149            Ok(Some(next.timestamp_millis()))
150        }
151        // Push and gate kinds are not clocked: they fire when something arrives.
152        TriggerKind::Memory | TriggerKind::Webhook | TriggerKind::Manual | TriggerKind::Composite => {
153            Ok(None)
154        }
155    }
156}
157
158/// Where to set `next_due_at` after a firing that happened at `fired_at_ms`,
159/// given that it is now `now_ms`.
160///
161/// This is where catch-up policy lands. If evaluation stalled — the host was
162/// asleep, the process was down — the naive next instant can be far in the
163/// past, and the question is whether to replay the gap.
164pub fn advance_after_firing(trigger: &Trigger, fired_at_ms: i64, now_ms: i64) -> Result<Option<i64>> {
165    let Some(mut next) = next_due_after(trigger, fired_at_ms)? else {
166        return Ok(None);
167    };
168    if next > now_ms {
169        return Ok(Some(next));
170    }
171    match trigger.catchup {
172        // Replay: leave the schedule where it is and let each stale occurrence
173        // fire in turn.
174        Catchup::All => Ok(Some(next)),
175        // Collapse the backlog into a single firing. Walk forward to the first
176        // occurrence still ahead of us; one firing already happened, and the
177        // others are missed rather than owed.
178        Catchup::Last | Catchup::None => {
179            // Bounded so a long-dormant memory with a fast interval cannot spin:
180            // a year of one-second occurrences would otherwise be 31 million
181            // steps. Past the bound we jump straight off `now`.
182            const MAX_STEPS: u32 = 10_000;
183            let mut steps = 0;
184            while next <= now_ms && steps < MAX_STEPS {
185                match next_due_after(trigger, next)? {
186                    Some(n) if n > next => next = n,
187                    // A schedule that does not advance would loop forever.
188                    _ => break,
189                }
190                steps += 1;
191            }
192            if next <= now_ms {
193                next = next_due_after(trigger, now_ms)?.unwrap_or(next);
194            }
195            Ok(Some(next))
196        }
197    }
198}
199
200/// When a never-evaluated trigger first becomes due.
201///
202/// `None` means "now". `Some(t)` means "not yet — at `t`", and the evaluator
203/// records it so the trigger wakes at the right moment instead of being asked
204/// this question again on every pass.
205///
206/// The split is between two kinds of cadence:
207///
208/// - **Relative** (`Interval`, `Polling`) has no baseline to wait out, so it
209///   fires at once — the loop gate's rule, and what makes a polling trigger
210///   seed its cursor promptly instead of an interval later.
211/// - **Absolute** (`Schedule`, `Once`) waits for the instant it names. A cron of
212///   `0 9 * * *` must not fire at midnight merely because it was just declared.
213/// - **Unclocked** kinds are eligible immediately; what gates them is whether
214///   anything arrived, which the evaluator decides.
215pub fn initial_due(trigger: &Trigger, now_ms: i64) -> Result<Option<i64>> {
216    Ok(match trigger.kind {
217        TriggerKind::Schedule => next_due_after(trigger, now_ms)?,
218        // A one-shot whose instant has already passed fires once, now.
219        TriggerKind::Once => trigger.at_ms.filter(|at| *at > now_ms),
220        TriggerKind::Interval
221        | TriggerKind::Polling
222        | TriggerKind::Memory
223        | TriggerKind::Webhook
224        | TriggerKind::Manual
225        | TriggerKind::Composite => None,
226    })
227}
228
229/// Is this trigger due at `now_ms`?
230///
231/// The complete check: state alone cannot answer it, because an absent
232/// `next_due_at` means "never evaluated" and what that implies depends on the
233/// declaration. See [`initial_due`].
234pub fn is_due(trigger: &Trigger, state: &areev_store::TriggerState, now_ms: i64) -> Result<bool> {
235    if !state.permits_firing(now_ms) {
236        return Ok(false);
237    }
238    if state.next_due_at.is_some() {
239        return Ok(true); // already compared against `now` above
240    }
241    Ok(initial_due(trigger, now_ms)?.is_none())
242}
243
244/// Whether this trigger can never fire again after a firing at `fired_at_ms`.
245///
246/// Only a one-shot exhausts. The other kinds that return no next instant
247/// (`Memory`, `Webhook`, `Manual`, `Composite`) are not clocked at all — they
248/// are eligible on every pass and gated by whether anything arrived — so
249/// treating "no next instant" as exhaustion would silence them permanently.
250pub fn exhausts_after(trigger: &Trigger, fired_at_ms: i64) -> bool {
251    trigger.kind == TriggerKind::Once
252        && next_due_after(trigger, fired_at_ms).ok().flatten().is_none()
253}
254
255/// Back off `next_due_at` after a failure, so a broken connector does not
256/// hot-loop against a source that is down.
257///
258/// Exponential on consecutive failures, capped, and never shorter than the
259/// declared interval — a trigger that fails fast should not end up polling
260/// *more* often than a healthy one.
261pub fn backoff_until(trigger: &Trigger, consecutive_failures: u32, now_ms: i64) -> i64 {
262    const BASE_MS: i64 = 30_000;
263    const CAP_MS: i64 = 3_600_000;
264    let shift = consecutive_failures.saturating_sub(1).min(7);
265    let penalty = (BASE_MS << shift).min(CAP_MS);
266    let floor = trigger.interval_secs.map(|s| i64::from(s) * 1000).unwrap_or(0);
267    now_ms.saturating_add(penalty.max(floor))
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    const WF: &str = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
275    /// 2026-01-01T00:00:00Z
276    const T0: i64 = 1_767_225_600_000;
277
278    fn interval(secs: u32) -> Trigger {
279        Trigger::new(TriggerKind::Interval, WF).interval_secs(secs)
280    }
281    fn schedule(expr: &str) -> Trigger {
282        Trigger::new(TriggerKind::Schedule, WF).cron(expr)
283    }
284
285    #[test]
286    fn interval_anchors_to_the_last_firing() {
287        let t = interval(120);
288        assert_eq!(next_due_after(&t, T0).unwrap(), Some(T0 + 120_000));
289        // …and therefore drifts: firing late pushes the next one out.
290        assert_eq!(next_due_after(&t, T0 + 5_000).unwrap(), Some(T0 + 125_000));
291    }
292
293    #[test]
294    fn cron_anchors_to_wall_clock_boundaries() {
295        let t = schedule("*/5 * * * *");
296        let first = next_due_after(&t, T0).unwrap().unwrap();
297        assert_eq!(first, T0 + 300_000, "next boundary from midnight");
298        // Firing late still lands on the boundary — no drift.
299        let after_late = next_due_after(&t, T0 + 301_000).unwrap().unwrap();
300        assert_eq!(after_late, T0 + 600_000);
301    }
302
303    #[test]
304    fn a_five_field_weekday_expression_parses_with_posix_numbering() {
305        // The trap this guards: the most-downloaded Rust cron crate requires 7
306        // fields with mandatory seconds and Quartz day numbering where 1=Sun and
307        // 0 is rejected, so it reads this expression as the wrong days.
308        let t = schedule("0 9 * * 1-5");
309        let next = next_due_after(&t, T0).unwrap().unwrap();
310        let dt = chrono::DateTime::from_timestamp_millis(next).unwrap();
311        use chrono::{Datelike, Timelike};
312        assert_eq!(dt.hour(), 9);
313        assert_eq!(dt.minute(), 0);
314        let wd = dt.weekday().num_days_from_monday();
315        assert!(wd < 5, "must land Mon-Fri, got {:?}", dt.weekday());
316    }
317
318    #[test]
319    fn a_one_shot_fires_once_and_then_never() {
320        let t = Trigger::new(TriggerKind::Once, WF).at_ms(T0 + 1_000);
321        assert_eq!(next_due_after(&t, T0).unwrap(), Some(T0 + 1_000));
322        assert_eq!(next_due_after(&t, T0 + 1_000).unwrap(), None, "not due at its own instant again");
323        assert_eq!(next_due_after(&t, T0 + 9_999).unwrap(), None);
324    }
325
326    #[test]
327    fn push_and_gate_kinds_are_not_clocked() {
328        for kind in [TriggerKind::Webhook, TriggerKind::Manual, TriggerKind::Memory] {
329            let t = Trigger::new(kind, WF);
330            assert_eq!(next_due_after(&t, T0).unwrap(), None, "{kind:?} should not be clocked");
331        }
332    }
333
334    #[test]
335    fn catchup_last_collapses_a_weekend_of_missed_occurrences() {
336        // Fired Friday, evaluated Monday: a 5-minute schedule missed ~800
337        // occurrences. Exactly one firing is owed, not eight hundred.
338        let t = schedule("*/5 * * * *").catchup(Catchup::Last);
339        let three_days = 3 * 86_400_000;
340        let next = advance_after_firing(&t, T0, T0 + three_days).unwrap().unwrap();
341        assert!(next > T0 + three_days, "must be scheduled ahead of now, not in the backlog");
342        assert!(next <= T0 + three_days + 300_000, "and at the very next boundary");
343    }
344
345    #[test]
346    fn catchup_all_leaves_the_backlog_in_place() {
347        let t = schedule("*/5 * * * *").catchup(Catchup::All);
348        let three_days = 3 * 86_400_000;
349        let next = advance_after_firing(&t, T0, T0 + three_days).unwrap().unwrap();
350        assert_eq!(next, T0 + 300_000, "the stale occurrence stays due, to be replayed");
351    }
352
353    #[test]
354    fn collapsing_is_bounded_for_a_fast_interval_over_a_long_gap() {
355        // A one-second interval dormant for a year is ~31M occurrences. The walk
356        // must not attempt them.
357        let t = interval(1).catchup(Catchup::Last);
358        let a_year = 365 * 86_400_000i64;
359        let started = std::time::Instant::now();
360        let next = advance_after_firing(&t, T0, T0 + a_year).unwrap().unwrap();
361        assert!(next > T0 + a_year);
362        assert!(started.elapsed().as_millis() < 500, "the collapse walk must be bounded");
363    }
364
365    #[test]
366    fn a_firing_that_is_still_ahead_of_now_is_left_alone() {
367        let t = interval(300);
368        let next = advance_after_firing(&t, T0, T0 + 1_000).unwrap().unwrap();
369        assert_eq!(next, T0 + 300_000);
370    }
371
372    #[test]
373    fn backoff_grows_then_caps_and_never_polls_faster_than_the_interval() {
374        let t = interval(600); // 10 minutes
375        let first = backoff_until(&t, 1, T0) - T0;
376        let second = backoff_until(&t, 2, T0) - T0;
377        assert!(second >= first, "backoff must not shrink as failures accumulate");
378
379        // Never more often than the declared interval: a failing trigger must
380        // not end up hammering a source harder than a healthy one.
381        assert!(first >= 600_000, "floor is the declared interval");
382
383        let far = backoff_until(&t, 99, T0) - T0;
384        assert!(far <= 3_600_000, "and it caps rather than growing without bound");
385    }
386
387    #[test]
388    fn an_unparseable_expression_is_refused_with_its_code() {
389        let t = schedule("not a cron expression");
390        let e = validate(&t).unwrap_err();
391        assert_eq!(e.code(), "TRG-E006");
392        assert!(e.to_string().starts_with("TRG-E006"));
393    }
394
395    #[test]
396    fn a_non_utc_timezone_is_refused_rather_than_mishandled() {
397        // Firing at the wrong local hour and being believed is worse than
398        // refusing, because nobody notices a schedule that is quietly an hour
399        // off for half the year.
400        let t = schedule("0 9 * * *").config(serde_json::json!({
401            "int:timezone": "America/New_York"
402        }));
403        let e = validate(&t).unwrap_err();
404        assert_eq!(e.code(), "TRG-E006");
405        assert!(e.to_string().contains("UTC"), "the message should say what IS supported");
406
407        // UTC spelled either way is fine.
408        let ok = schedule("0 9 * * *").config(serde_json::json!({ "int:timezone": "utc" }));
409        assert!(validate(&ok).is_ok());
410    }
411
412    #[test]
413    fn validate_rejects_an_incoherent_declaration_before_the_schedule() {
414        let t = Trigger::new(TriggerKind::Interval, WF); // no interval_secs
415        let e = validate(&t).unwrap_err();
416        assert_eq!(e.code(), "TRG-E001");
417    }
418}