Skip to main content

dre_core/
occurrences.rs

1//! Schedule occurrences: when each schedules.yml entry fires within a window, and the exact
2//! command that runs each firing (`dre schedule ls`). Pure computation over the loaded project:
3//! no profiles, plugins, network or writes. Storing, diffing and dispatching occurrences is the
4//! caller's business.
5//!
6//! Every timing is expanded on naive wall-clock times, then each time is placed in the firing
7//! timezone with DRE's own rules: a time in a DST gap fires at the first instant after the gap,
8//! a time that happens twice fires once, on its first instance, and firings that land on the
9//! same instant are one firing. That keeps DST out of the cron and rrule crates.
10//!
11//! The JSON document is a public, versioned contract (`docs/schedule-ls.md`,
12//! `docs/schedule-ls.schema.json`): fields may be added in any release; removing or renaming one,
13//! or changing what a hash covers, bumps [`FORMAT_VERSION`].
14
15use std::collections::{BTreeMap, BTreeSet};
16
17use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
18use chrono_tz::Tz;
19use serde_json::{Map as JsonMap, Value as Json, json};
20use sha2::{Digest, Sha256};
21
22use crate::project::{Project, ScheduleEntry};
23use crate::schedule;
24
25/// The JSON document's format version (`dre_schedule_version`).
26pub const FORMAT_VERSION: u64 = 1;
27/// How far one call may reach.
28pub const MAX_WINDOW_DAYS: i64 = 366;
29/// The window when `--to` isn't given: five weeks, so a weekly refresh overlaps the last one.
30pub const DEFAULT_WINDOW_DAYS: i64 = 35;
31/// Most firings one rule may produce in a window.
32const MAX_FIRINGS: usize = u16::MAX as usize;
33
34/// Firings from `from` (inclusive) to `to` (exclusive).
35#[derive(Debug, Clone, Copy)]
36pub struct Window {
37    pub from: DateTime<Utc>,
38    pub to: DateTime<Utc>,
39}
40
41impl Window {
42    /// Checks the order and the [`MAX_WINDOW_DAYS`] cap.
43    pub fn new(from: DateTime<Utc>, to: DateTime<Utc>) -> Result<Self, String> {
44        if to <= from {
45            return Err(format!(
46                "--to ({}) must be after --from ({})",
47                rfc3339(to),
48                rfc3339(from)
49            ));
50        }
51        if to - from > Duration::days(MAX_WINDOW_DAYS) {
52            return Err(format!(
53                "the window from {} to {} is longer than {MAX_WINDOW_DAYS} days; ask for less, in several calls if needed",
54                rfc3339(from),
55                rfc3339(to)
56            ));
57        }
58        Ok(Window { from, to })
59    }
60}
61
62/// What `dre schedule ls` was asked for.
63#[derive(Debug, Clone)]
64pub struct Request {
65    pub window: Window,
66    /// `--schedule`: only these (empty: every schedule).
67    pub schedules: Vec<String>,
68    /// `-s`: only schedules whose Bindings include one of these reports.
69    pub select: Option<String>,
70    /// `--limit`: at most this many firings per schedule.
71    pub limit: Option<usize>,
72    /// `--split`: one occurrence per Binding instead of one per firing.
73    pub split: bool,
74}
75
76pub use crate::run::rfc3339;
77
78/// The timezone a schedule fires in: its own (or its timing's), then the project's, then UTC.
79/// A report's timezone and the run-time overrides never move a firing.
80pub fn firing_tz(project: &Project, e: &ScheduleEntry) -> Tz {
81    e.timezone
82        .as_ref()
83        .or(project.timezone.as_ref())
84        .and_then(|t| crate::dates::parse_tz(t).ok())
85        .unwrap_or(Tz::UTC)
86}
87
88/// The Bindings a schedule runs, as `(report, set)`, in the project's report order.
89pub fn bindings<'a>(project: &'a Project, name: &str) -> Vec<(&'a str, Option<&'a str>)> {
90    project
91        .reports
92        .iter()
93        .flat_map(|r| {
94            r.bindings
95                .iter()
96                .filter(|b| b.schedules.iter().any(|s| s == name))
97                .map(|b| (r.name.as_str(), b.set.as_deref()))
98        })
99        .collect()
100}
101
102/// Every instant `timing` (a schedule block: `cron`/`rrule`/`every`, `starting`, `at`,
103/// `except`, `also`) fires in `tz` within `window`, in order.
104pub fn expand(timing: &JsonMap<String, Json>, tz: Tz, window: &Window) -> Result<Vec<DateTime<Utc>>, String> {
105    // Wide enough for any UTC offset; the result is cut to the window at the end.
106    let lo = window.from.naive_utc() - Duration::days(2);
107    let hi = window.to.naive_utc() + Duration::days(2);
108    let s = |k: &str| timing.get(k).and_then(Json::as_str);
109    let starting = s("starting").and_then(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok());
110    let at = s("at").and_then(|t| NaiveTime::parse_from_str(t, "%H:%M").ok());
111    let mut times = if let Some(expr) = s("cron") {
112        cron_times(expr, lo, hi)?
113    } else if let Some(rule) = s("rrule") {
114        rule_times(rule, starting, at, lo, hi)?
115    } else if let Some(every) = timing.get("every").and_then(Json::as_object) {
116        let (unit, n) = every.iter().next().ok_or("`every` has no unit")?;
117        let freq = match unit.as_str() {
118            "days" => "DAILY",
119            "weeks" => "WEEKLY",
120            "months" => "MONTHLY",
121            u => return Err(format!("unknown `every` unit `{u}`")),
122        };
123        if starting.is_none() {
124            return Err("`every` needs `starting`".into());
125        }
126        let rule = format!("FREQ={freq};INTERVAL={}", n.as_u64().unwrap_or(1));
127        rule_times(&rule, starting, at, lo, hi)?
128    } else {
129        return Err("a schedule needs one of `cron`, `every` or `rrule`".into());
130    };
131    let dates = |k: &str| -> BTreeSet<NaiveDate> {
132        timing
133            .get(k)
134            .and_then(Json::as_array)
135            .into_iter()
136            .flatten()
137            .filter_map(|d| {
138                d.as_str()
139                    .and_then(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
140            })
141            .collect()
142    };
143    let except = dates("except");
144    times.retain(|t| !except.contains(&t.date()));
145    let also = dates("also");
146    if !also.is_empty() {
147        let time =
148            schedule::time_of_day(timing).ok_or("`also` needs a schedule that fires at one time of day")?;
149        times.extend(also.iter().map(|d| d.and_time(time)));
150    }
151    let fired: BTreeSet<DateTime<Utc>> = times
152        .into_iter()
153        .map(|t| place(tz, t))
154        .filter(|t| *t >= window.from && *t < window.to)
155        .collect();
156    Ok(fired.into_iter().collect())
157}
158
159/// A wall-clock time in `tz`: the earlier instance when it happens twice, the first instant after
160/// the gap when it doesn't happen at all.
161pub fn place(tz: Tz, t: NaiveDateTime) -> DateTime<Utc> {
162    let mut m = t;
163    // No zone skips more than a day.
164    for _ in 0..=24 * 60 {
165        if let Some(x) = tz.from_local_datetime(&m).earliest() {
166            return x.with_timezone(&Utc);
167        }
168        m += Duration::minutes(1);
169    }
170    Utc.from_utc_datetime(&t)
171}
172
173const CRON_MACROS: &[(&str, &str)] = &[
174    ("@yearly", "0 0 1 1 *"),
175    ("@annually", "0 0 1 1 *"),
176    ("@monthly", "0 0 1 * *"),
177    ("@weekly", "0 0 * * 0"),
178    ("@daily", "0 0 * * *"),
179    ("@midnight", "0 0 * * *"),
180    ("@hourly", "0 * * * *"),
181];
182
183/// The classic cron rule for the day fields: when either starts with `*`, a day must match both
184/// (one of them being every day); otherwise a day matching either is enough.
185fn cron_times(expr: &str, lo: NaiveDateTime, hi: NaiveDateTime) -> Result<Vec<NaiveDateTime>, String> {
186    schedule::validate_cron(expr)?;
187    let expr = expr.trim();
188    let expr = CRON_MACROS
189        .iter()
190        .find(|(m, _)| *m == expr)
191        .map(|(_, e)| *e)
192        .unwrap_or(expr)
193        .to_ascii_uppercase();
194    let fields: Vec<&str> = expr.split_whitespace().collect();
195    let both = fields[2].starts_with('*') || fields[4].starts_with('*');
196    let cron = croner::parser::CronParser::builder()
197        .seconds(croner::parser::Seconds::Disallowed)
198        .year(croner::parser::Year::Disallowed)
199        .dom_and_dow(both)
200        .build()
201        .parse(&expr)
202        .map_err(|e| format!("can't expand cron `{expr}`: {e}"))?;
203    let mut out = Vec::new();
204    for t in cron.iter_from(lo, croner::Direction::Forward) {
205        if t > hi {
206            break;
207        }
208        out.push(t);
209        // A minutely cron over the longest window is ~530,000 firings; past that it's a
210        // runaway, not a schedule.
211        if out.len() > MAX_FIRINGS * 8 {
212            return Err(format!("cron `{expr}` fires too often to list"));
213        }
214    }
215    Ok(out)
216}
217
218/// An RFC 5545 rule anchored at `starting` (else just before the window: only rules that don't
219/// need an anchor get here) and `at` (unless the rule sets the hour or minute itself).
220fn rule_times(
221    rule: &str,
222    starting: Option<NaiveDate>,
223    at: Option<NaiveTime>,
224    lo: NaiveDateTime,
225    hi: NaiveDateTime,
226) -> Result<Vec<NaiveDateTime>, String> {
227    let parts = schedule::rule_parts(rule).map_err(|e| format!("invalid rrule `{rule}`: {e}"))?;
228    let sets_time = parts.iter().any(|(k, _)| k == "BYHOUR" || k == "BYMINUTE");
229    let time = if sets_time {
230        NaiveTime::MIN
231    } else {
232        at.unwrap_or(NaiveTime::MIN)
233    };
234    let start = starting.unwrap_or(lo.date()).and_time(time);
235    // Wall-clock times throughout: UNTIL is too, so it's written the way the crate wants it.
236    let body: Vec<String> = parts
237        .iter()
238        .map(|(k, v)| match k.as_str() {
239            "UNTIL" if v.len() == 8 => format!("UNTIL={v}T235959Z"),
240            "UNTIL" => format!("UNTIL={}Z", v.trim_end_matches('Z')),
241            _ => format!("{k}={v}"),
242        })
243        .collect();
244    let text = format!(
245        "DTSTART:{}Z\nRRULE:{}",
246        start.format("%Y%m%dT%H%M%S"),
247        body.join(";")
248    );
249    let set: rrule::RRuleSet = text
250        .parse()
251        .map_err(|e| format!("can't expand rrule `{rule}`: {e}"))?;
252    let utc = |n: NaiveDateTime| rrule::Tz::UTC.from_utc_datetime(&n);
253    let found = set.after(utc(lo)).before(utc(hi)).all(u16::MAX);
254    if found.limited && found.dates.len() >= MAX_FIRINGS {
255        return Err(format!(
256            "rrule `{rule}` fires more than {MAX_FIRINGS} times in the window; ask for a shorter one"
257        ));
258    }
259    Ok(found.dates.iter().map(|d| d.naive_utc()).collect())
260}
261
262/// SHA-256 (hex) of a canonical JSON value, under this format version.
263fn hash(v: &Json) -> String {
264    let text = format!(
265        "dre_schedule_v{FORMAT_VERSION}\n{}",
266        crate::manifest::canonical(v)
267    );
268    crate::manifest::hex(&Sha256::digest(text.as_bytes()))
269}
270
271/// What a schedule's definition hash covers: its resolved timing (named or inline), firing
272/// timezone, vars, whether it's enabled, and the Bindings it runs.
273fn definition(project: &Project, e: &ScheduleEntry) -> Json {
274    json!({
275        "timing": e.timing,
276        "schedule": e.schedule,
277        "timezone": firing_tz(project, e).name(),
278        "vars": e.vars,
279        "enabled": e.enabled,
280        "bindings": bindings(project, &e.name)
281            .iter()
282            .map(|(r, s)| json!({"report": r, "set": s}))
283            .collect::<Vec<_>>(),
284    })
285}
286
287/// A schedule's definition hash: equal for an equal definition within a format version.
288pub fn definition_hash(project: &Project, e: &ScheduleEntry) -> String {
289    hash(&definition(project, e))
290}
291
292/// One hash over every schedule in the project (whatever was asked for), so a refresh can tell
293/// that nothing changed.
294pub fn project_hash(project: &Project) -> String {
295    let all: BTreeMap<&str, String> = project
296        .schedules
297        .iter()
298        .map(|e| (e.name.as_str(), definition_hash(project, e)))
299        .collect();
300    hash(&json!(all))
301}
302
303/// Which schedules a request covers, in schedules.yml order.
304fn chosen<'a>(project: &'a Project, req: &Request) -> Result<Vec<&'a ScheduleEntry>, String> {
305    for n in &req.schedules {
306        if !project.schedules.iter().any(|e| &e.name == n) {
307            return Err(crate::run::unknown_schedule(project, n));
308        }
309    }
310    let reports: Option<BTreeSet<&str>> = match &req.select {
311        None => None,
312        Some(s) => match crate::selector::resolve(project, s) {
313            Ok(r) if r.is_empty() => return Err(format!("selector `{s}` matches no report")),
314            Ok(r) => Some(r.into_iter().map(|r| r.name.as_str()).collect()),
315            Err(e) => return Err(e.to_string()),
316        },
317    };
318    Ok(project
319        .schedules
320        .iter()
321        .filter(|e| req.schedules.is_empty() || req.schedules.contains(&e.name))
322        .filter(|e| match &reports {
323            None => true,
324            Some(rs) => bindings(project, &e.name).iter().any(|(r, _)| rs.contains(r)),
325        })
326        .collect())
327}
328
329/// The command that runs one firing: `dre run --schedule <name>`, narrowed to one Binding under
330/// `--split`. Never a target, profile or credentials: the deployment decides those.
331fn invocation(
332    name: &str,
333    binding: Option<(&str, Option<&str>)>,
334    at: DateTime<Utc>,
335    run_date: NaiveDate,
336) -> Json {
337    let mut argv = vec![
338        "dre".to_string(),
339        "run".into(),
340        "--schedule".into(),
341        name.to_string(),
342    ];
343    if let Some((report, set)) = binding {
344        argv.extend(["-s".into(), report.to_string()]);
345        if let Some(set) = set {
346            argv.extend(["--set".into(), set.to_string()]);
347        }
348    }
349    json!({
350        "argv": argv,
351        "env": {"DRE_RUN_AT": rfc3339(at), "DRE_RUN_DATE": run_date.to_string()},
352    })
353}
354
355fn binding_json(report: &str, set: Option<&str>) -> Json {
356    json!({"report": report, "set": set, "binding": set.unwrap_or("default")})
357}
358
359/// The `dre schedule ls` document. An error is a usage problem (an unknown schedule, a selector
360/// that matches nothing); a schedule that can't be expanded is listed under `problems`.
361pub fn document(project: &Project, req: &Request) -> Result<Json, String> {
362    let entries = chosen(project, req)?;
363    let mut schedules = JsonMap::new();
364    let mut occurrences: Vec<(DateTime<Utc>, String, Json)> = Vec::new();
365    let mut problems = Vec::new();
366    for e in entries {
367        let tz = firing_tz(project, e);
368        let bs = bindings(project, &e.name);
369        schedules.insert(
370            e.name.clone(),
371            json!({
372                "definition_hash": definition_hash(project, e),
373                "enabled": e.enabled,
374                "timing": e.timing,
375                "timezone": tz.name(),
376                "vars": e.vars,
377                "bindings": bs.iter().map(|(r, s)| binding_json(r, *s)).collect::<Vec<_>>(),
378            }),
379        );
380        if !e.enabled {
381            continue;
382        }
383        let strict = schedule::strictness(&e.schedule);
384        if !strict.is_empty() {
385            for (code, message) in strict {
386                problems.push(json!({"schedule": e.name, "code": code, "message": message}));
387            }
388            continue;
389        }
390        let fired = match expand(&e.schedule, tz, &req.window) {
391            Ok(f) => f,
392            Err(message) => {
393                problems
394                    .push(json!({"schedule": e.name, "code": "schedule-not-expandable", "message": message}));
395                continue;
396            }
397        };
398        for at in fired.into_iter().take(req.limit.unwrap_or(usize::MAX)) {
399            let local = at.with_timezone(&tz);
400            let run_date = local.date_naive();
401            let occurrence = |key: &str, bindings: Json, invocation: Json| {
402                json!({
403                    "key": key,
404                    "schedule": e.name,
405                    "timing": e.timing,
406                    "fires_at": rfc3339(at),
407                    "fires_at_local": local.to_rfc3339_opts(chrono::SecondsFormat::Secs, false),
408                    "timezone": tz.name(),
409                    "run_date": run_date.to_string(),
410                    "bindings": bindings,
411                    "vars": e.vars,
412                    "invocation": invocation,
413                })
414            };
415            let key = format!("{}/{}", e.name, rfc3339(at));
416            if req.split {
417                for (r, s) in &bs {
418                    let key = format!("{key}/{r}/{}", s.unwrap_or("default"));
419                    let o = occurrence(
420                        &key,
421                        json!([binding_json(r, *s)]),
422                        invocation(&e.name, Some((r, *s)), at, run_date),
423                    );
424                    occurrences.push((at, key, o));
425                }
426            } else {
427                let all = bs.iter().map(|(r, s)| binding_json(r, *s)).collect::<Vec<_>>();
428                let o = occurrence(&key, json!(all), invocation(&e.name, None, at, run_date));
429                occurrences.push((at, key, o));
430            }
431        }
432    }
433    occurrences.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1)));
434    Ok(json!({
435        "dre_schedule_version": FORMAT_VERSION,
436        "dre_version": crate::version(),
437        "project": project.name,
438        "window": {"from": rfc3339(req.window.from), "to": rfc3339(req.window.to)},
439        "split": req.split,
440        "complete": req.schedules.is_empty() && req.select.is_none(),
441        "project_hash": project_hash(project),
442        "schedules": schedules,
443        "occurrences": occurrences.into_iter().map(|(_, _, o)| o).collect::<Vec<_>>(),
444        "problems": problems,
445    }))
446}