Skip to main content

tmprl_core/
schedule.rs

1//! Schedules: the things that start workflows on a timetable.
2
3use crate::workflow::humanize_age_ms;
4
5/// One row of the schedule list.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct ScheduleRow {
8    pub namespace: String,
9    pub schedule_id: String,
10    pub workflow_type: String,
11    pub paused: bool,
12    pub notes: String,
13    /// A readable form of the timetable, from [`describe_spec`].
14    pub spec: String,
15    /// Epoch millis of the next run, when the server offered one.
16    pub next_run: Option<i64>,
17    pub recent_runs: usize,
18}
19
20impl ScheduleRow {
21    /// Identity across refreshes. Schedule ids are unique within a namespace, so the pair is
22    /// the key, as it is for workflows.
23    pub fn key(&self) -> (&str, &str) {
24        (self.namespace.as_str(), self.schedule_id.as_str())
25    }
26
27    /// Shown in the list. Paused is the state worth spotting: a schedule that is not running
28    /// looks identical to one that is until you read it.
29    pub fn glyph(&self) -> char {
30        if self.paused { '‖' } else { '●' }
31    }
32}
33
34/// One `start..=end` step in a calendar field.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub struct Range {
37    pub start: i32,
38    pub end: i32,
39    pub step: i32,
40}
41
42/// A structured calendar, one list of ranges per field.
43///
44/// This is what the server actually stores. Creating a schedule with `--cron "0 2 * * *"`
45/// leaves `cron_string` empty and fills this in instead, so a list that reads only the cron
46/// string reports every cron schedule as having no timetable at all.
47#[derive(Debug, Clone, Default, PartialEq, Eq)]
48pub struct Calendar {
49    pub second: Vec<Range>,
50    pub minute: Vec<Range>,
51    pub hour: Vec<Range>,
52    pub day_of_month: Vec<Range>,
53    pub month: Vec<Range>,
54    pub day_of_week: Vec<Range>,
55}
56
57/// Render a structured calendar back to a cron expression.
58///
59/// The seconds field is included only when it is not plain zero, so an ordinary
60/// five-field cron reads as one.
61pub fn describe_calendar(c: &Calendar) -> String {
62    // "For all fields besides year, at least one Range must be present to match anything."
63    // The server populates all six on create, so this only catches a hand-built spec, but
64    // rendering an empty field as `*` would claim it fires when it never does.
65    if [
66        &c.second,
67        &c.minute,
68        &c.hour,
69        &c.day_of_month,
70        &c.month,
71        &c.day_of_week,
72    ]
73    .iter()
74    .any(|f| f.is_empty())
75    {
76        return "never".to_string();
77    }
78
79    let minute = field(&c.minute, 0, 59);
80    let hour = field(&c.hour, 0, 23);
81    let dom = field(&c.day_of_month, 1, 31);
82    let month = field(&c.month, 1, 12);
83    let dow = field(&c.day_of_week, 0, 6);
84    let five = format!("{minute} {hour} {dom} {month} {dow}");
85
86    let second = field(&c.second, 0, 59);
87    if second == "0" {
88        five
89    } else {
90        format!("{second} {five}")
91    }
92}
93
94/// One cron field. A range covering the whole domain with step 1 is `*`.
95fn field(ranges: &[Range], min: i32, max: i32) -> String {
96    let parts: Vec<String> = ranges
97        .iter()
98        .map(|r| {
99            let step = r.step.max(1);
100            let covers_all = r.start <= min && r.end >= max;
101            match (covers_all, step) {
102                (true, 1) => "*".to_string(),
103                (true, s) => format!("*/{s}"),
104                (false, 1) if r.start == r.end => r.start.to_string(),
105                (false, 1) => format!("{}-{}", r.start, r.end),
106                (false, s) => format!("{}-{}/{s}", r.start, r.end),
107            }
108        })
109        .collect();
110    parts.join(",")
111}
112
113/// An interval in a schedule spec: run every `every`, offset by `offset`, both in seconds.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub struct Interval {
116    pub every_secs: i64,
117    pub offset_secs: i64,
118}
119
120/// Turn a spec into something readable in one column.
121///
122/// A schedule can carry several cron strings and several intervals at once, and the protobuf
123/// keeps them in separate lists. Joining them is the only way one row can say what the
124/// timetable actually is.
125///
126/// Cron strings are shown verbatim. Anyone reading a schedule list already reads cron, and
127/// rewriting `0 9 * * 1-5` as prose makes it longer and no clearer.
128pub fn describe_spec(cron: &[String], calendars: &[Calendar], intervals: &[Interval]) -> String {
129    let mut parts: Vec<String> = cron.iter().filter(|c| !c.is_empty()).cloned().collect();
130    parts.extend(calendars.iter().map(describe_calendar));
131
132    for i in intervals {
133        let every = humanize_duration(i.every_secs);
134        parts.push(if i.offset_secs == 0 {
135            format!("every {every}")
136        } else {
137            format!("every {every} at +{}", humanize_duration(i.offset_secs))
138        });
139    }
140
141    if parts.is_empty() {
142        // A spec with neither is legal: the schedule only runs when triggered by hand.
143        "manual".to_string()
144    } else {
145        parts.join(", ")
146    }
147}
148
149/// Seconds as something short: `30s`, `5m`, `1h`, `7d`.
150fn humanize_duration(secs: i64) -> String {
151    match secs {
152        s if s <= 0 => "0s".into(),
153        s if s % 86_400 == 0 => format!("{}d", s / 86_400),
154        s if s % 3_600 == 0 => format!("{}h", s / 3_600),
155        s if s % 60 == 0 => format!("{}m", s / 60),
156        s => format!("{s}s"),
157    }
158}
159
160/// How long until the next run, or `None` when nothing is scheduled.
161///
162/// A paused schedule can still carry future action times, because the server computes them
163/// from the spec rather than from whether it will act on them. Callers show the pause state
164/// separately rather than reading it out of this.
165pub fn time_until(next_run: Option<i64>, now: i64) -> Option<String> {
166    let at = next_run?;
167    Some(if at <= now {
168        "due".to_string()
169    } else {
170        humanize_age_ms(at - now)
171    })
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn every(secs: i64) -> Interval {
179        Interval {
180            every_secs: secs,
181            offset_secs: 0,
182        }
183    }
184
185    #[test]
186    fn a_cron_string_is_shown_verbatim() {
187        // Anyone reading a schedule list already reads cron; prose would be longer and no
188        // clearer.
189        assert_eq!(
190            describe_spec(&["0 9 * * 1-5".into()], &[], &[]),
191            "0 9 * * 1-5"
192        );
193    }
194
195    #[test]
196    fn an_interval_reads_as_a_period() {
197        assert_eq!(describe_spec(&[], &[], &[every(3_600)]), "every 1h");
198        assert_eq!(describe_spec(&[], &[], &[every(86_400)]), "every 1d");
199        assert_eq!(describe_spec(&[], &[], &[every(300)]), "every 5m");
200        assert_eq!(describe_spec(&[], &[], &[every(45)]), "every 45s");
201    }
202
203    #[test]
204    fn an_offset_interval_says_where_it_lands() {
205        let i = Interval {
206            every_secs: 86_400,
207            offset_secs: 32_400,
208        };
209        assert_eq!(describe_spec(&[], &[], &[i]), "every 1d at +9h");
210    }
211
212    #[test]
213    fn several_rules_are_joined_rather_than_one_being_picked() {
214        // The protobuf keeps crons and intervals in separate lists and a schedule can carry
215        // both. Showing only one would misdescribe the timetable.
216        let out = describe_spec(&["0 9 * * 1-5".into()], &[], &[every(3_600)]);
217        assert_eq!(out, "0 9 * * 1-5, every 1h");
218    }
219
220    fn at(field: &[(i32, i32)]) -> Vec<Range> {
221        field
222            .iter()
223            .map(|(a, b)| Range {
224                start: *a,
225                end: *b,
226                step: 1,
227            })
228            .collect()
229    }
230
231    #[test]
232    fn a_cron_schedule_is_stored_as_a_calendar_and_reads_back_as_cron() {
233        // Creating a schedule with --cron leaves cron_string empty and fills in the
234        // structured calendar, so reading only the string reports "manual" for every one.
235        // These are the exact ranges a dev server stores for `0 2 * * *`.
236        let c = Calendar {
237            second: at(&[(0, 0)]),
238            minute: at(&[(0, 0)]),
239            hour: at(&[(2, 2)]),
240            day_of_month: at(&[(1, 31)]),
241            month: at(&[(1, 12)]),
242            day_of_week: at(&[(0, 6)]),
243        };
244        assert_eq!(describe_calendar(&c), "0 2 * * *");
245        assert_eq!(describe_spec(&[], &[c], &[]), "0 2 * * *");
246    }
247
248    /// Every field populated, as the server always sends them.
249    fn full() -> Calendar {
250        Calendar {
251            second: at(&[(0, 0)]),
252            minute: at(&[(0, 0)]),
253            hour: at(&[(0, 23)]),
254            day_of_month: at(&[(1, 31)]),
255            month: at(&[(1, 12)]),
256            day_of_week: at(&[(0, 6)]),
257        }
258    }
259
260    #[test]
261    fn a_full_range_is_a_star_and_a_step_keeps_its_slash() {
262        let mut c = full();
263        c.minute = vec![Range {
264            start: 0,
265            end: 59,
266            step: 15,
267        }];
268        c.hour = at(&[(9, 17)]);
269        assert_eq!(describe_calendar(&c), "*/15 9-17 * * *");
270    }
271
272    #[test]
273    fn a_seconds_field_shows_only_when_it_is_not_zero() {
274        let mut c = full();
275        c.second = at(&[(30, 30)]);
276        assert_eq!(describe_calendar(&c), "30 0 * * * *", "six fields");
277        assert_eq!(describe_calendar(&full()), "0 * * * *", "five fields");
278    }
279
280    #[test]
281    fn a_calendar_missing_a_field_never_fires() {
282        // The proto says every field besides year needs a range to match anything, so
283        // rendering the gap as `*` would claim it fires when it never does.
284        let mut c = full();
285        c.hour = Vec::new();
286        assert_eq!(describe_calendar(&c), "never");
287    }
288
289    #[test]
290    fn a_spec_with_no_rules_is_manual() {
291        // Legal, and it means the schedule only runs when triggered by hand.
292        assert_eq!(describe_spec(&[], &[], &[]), "manual");
293        assert_eq!(describe_spec(&[String::new()], &[], &[]), "manual");
294    }
295
296    #[test]
297    fn the_next_run_reads_as_a_countdown() {
298        let now = 1_000_000;
299        assert_eq!(
300            time_until(Some(now + 3_600_000), now).as_deref(),
301            Some("1h")
302        );
303        assert_eq!(time_until(Some(now + 45_000), now).as_deref(), Some("45s"));
304        assert_eq!(time_until(None, now), None);
305    }
306
307    #[test]
308    fn a_run_that_is_already_due_says_so_rather_than_showing_zero() {
309        let now = 1_000_000;
310        assert_eq!(time_until(Some(now), now).as_deref(), Some("due"));
311        assert_eq!(time_until(Some(now - 5_000), now).as_deref(), Some("due"));
312    }
313
314    #[test]
315    fn paused_shows_in_the_glyph() {
316        let row = |paused| ScheduleRow {
317            namespace: "d".into(),
318            schedule_id: "s".into(),
319            workflow_type: "W".into(),
320            paused,
321            notes: String::new(),
322            spec: "every 1h".into(),
323            next_run: None,
324            recent_runs: 0,
325        };
326        assert_ne!(row(true).glyph(), row(false).glyph());
327        assert_eq!(row(false).key(), ("d", "s"));
328    }
329}