edge_schema/schema/
cron.rs

1use std::time::Duration;
2
3use anyhow::bail;
4use serde::{Deserialize, Serialize};
5use time::OffsetDateTime;
6use uuid::Uuid;
7
8use crate::pretty_duration::PrettyDuration;
9
10use super::{EntityDescriptorConst, JobDefinition};
11
12pub type CronJobId = Uuid;
13
14/// A cronjob.
15#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, schemars::JsonSchema)]
16pub struct CronJobSpecV1 {
17    /// Define when to execute the job.
18    /// May be either:
19    /// - an interval, eg "2days", "1h[our]", "30s", ...
20    /// - A cron expression, eg "0 0 * * *", "0 0 1 * *", ...
21    ///   See https://en.wikipedia.org/wiki/Cron for the syntax
22    pub schedule: String,
23
24    /// Defines the time range in which the job may be executed.
25    ///
26    /// The system will try to backfill missed job executions to ensure each
27    /// job is executed.
28    /// This setting limits the time range in which backfilling is performed.
29    pub max_schedule_drift: Option<PrettyDuration>,
30
31    #[serde(flatten)]
32    pub job: JobDefinition,
33}
34
35impl CronJobSpecV1 {
36    pub fn parse_schedule(&self) -> Result<CronSchedule, CronTabParseError> {
37        self.schedule.parse()
38    }
39}
40
41impl EntityDescriptorConst for CronJobSpecV1 {
42    const NAMESPACE: &'static str = "wasmer.io";
43    const NAME: &'static str = "CronJob";
44    const VERSION: &'static str = "v1-alpha1";
45    const KIND: &'static str = "wasmer.io/CronJob.v1-alpha1";
46    type Spec = Self;
47    type State = ();
48}
49
50#[derive(PartialEq, Eq, Clone, Debug)]
51pub enum CronSchedule {
52    Interval(std::time::Duration),
53    CronTab(CronTab),
54}
55
56impl CronSchedule {
57    pub fn next(
58        &self,
59        last: Option<time::OffsetDateTime>,
60        drift: Option<Duration>,
61    ) -> Result<OffsetDateTime, anyhow::Error> {
62        match self {
63            CronSchedule::Interval(duration) => {
64                if let Some(last) = last {
65                    Ok(last + *duration)
66                } else {
67                    Ok(OffsetDateTime::now_utc())
68                }
69            }
70            CronSchedule::CronTab(c) => c.next(last, drift),
71        }
72    }
73
74    /// Get the maximum time allowed between job invocations.
75    pub fn max_timewindow(&self) -> Duration {
76        match self {
77            CronSchedule::Interval(duration) => *duration,
78            CronSchedule::CronTab(c) => c.max_timewindow(),
79        }
80    }
81}
82
83impl std::fmt::Display for CronSchedule {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        match self {
86            CronSchedule::Interval(d) => write!(f, "{}", PrettyDuration::from(*d)),
87            CronSchedule::CronTab(c) => c.fmt(f),
88        }
89    }
90}
91
92impl std::str::FromStr for CronSchedule {
93    type Err = CronTabParseError;
94
95    fn from_str(s: &str) -> Result<Self, Self::Err> {
96        match s.parse::<crate::pretty_duration::PrettyDuration>() {
97            Ok(d) => Ok(Self::Interval(d.0)),
98            Err(_) => match s.parse::<CronTab>() {
99                Ok(c) => Ok(Self::CronTab(c)),
100                Err(_) => Err(CronTabParseError::new(
101                    s,
102                    "invalid cron schedule - expected either an interval like '1m10s' or a valid crontab".to_string(),
103                )),
104            },
105        }
106    }
107}
108
109#[derive(PartialEq, Eq, Clone, Debug)]
110enum CronTabValue {
111    All,
112    Value(u8),
113}
114
115impl std::fmt::Display for CronTabValue {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        match self {
118            CronTabValue::All => write!(f, "*"),
119            CronTabValue::Value(x) => write!(f, "{x}"),
120        }
121    }
122}
123
124/// Crontab schedule.
125///
126/// See https://en.wikipedia.org/wiki/Cron
127///
128/// # ┌───────────── minute (0 - 59)
129/// # │ ┌───────────── hour (0 - 23)
130/// # │ │ ┌───────────── day of the month (1 - 31)
131/// # │ │ │ ┌───────────── month (1 - 12)
132/// # │ │ │ │ ┌───────────── day of the week (0 - 6) (Sunday to Saturday;
133/// # │ │ │ │ │                                   7 is also Sunday on some systems)
134/// # │ │ │ │ │
135/// # │ │ │ │ │
136/// # * * * * *
137#[derive(PartialEq, Eq, Clone, Debug)]
138pub struct CronTab {
139    // NOTE: fields are private to prevent invalid values.
140
141    // * / 0-59
142    minute: CronTabValue,
143    // * / 0-23
144    hour: CronTabValue,
145    // * / 1-31
146    day_of_month: CronTabValue,
147    // * / 1-12
148    month: CronTabValue,
149    // 0-6
150    day_of_week: CronTabValue,
151}
152
153impl CronTab {
154    pub fn next(
155        &self,
156        _last: Option<time::OffsetDateTime>,
157        _drift: Option<Duration>,
158    ) -> Result<OffsetDateTime, anyhow::Error> {
159        let now = OffsetDateTime::now_utc();
160
161        // Round up to next full minute.
162        let mut target =
163            now.replace_nanosecond(0)? + std::time::Duration::from_secs(60 - now.second() as u64);
164
165        match self.minute {
166            CronTabValue::All => {}
167            CronTabValue::Value(val) => {
168                if target.minute() < val {
169                    let diff = 60 - val - target.minute();
170                    target += Duration::from_secs(diff as u64 * 60);
171                } else {
172                    target = target.replace_minute(val)?;
173                }
174            }
175        }
176
177        match self.hour {
178            CronTabValue::All => {}
179            CronTabValue::Value(hour) => {
180                if target.hour() < hour {
181                    let diff = 24 - hour - target.hour();
182                    target += Duration::from_secs(diff as u64 * 60 * 60);
183                } else {
184                    target = target.replace_hour(hour)?;
185                }
186            }
187        }
188
189        match self.month {
190            CronTabValue::All => {}
191            CronTabValue::Value(month) => {
192                let cur_month: u8 = target.month().into();
193                if month < cur_month {
194                    let diff = 12 - cur_month - month;
195                    target += Duration::from_secs(diff as u64 * 60 * 60 * 24 * 30);
196                } else {
197                    target = target.replace_month(month.try_into()?)?;
198                }
199            }
200        }
201
202        match self.day_of_week {
203            CronTabValue::All => {}
204            CronTabValue::Value(_dm) => {
205                // FIXME: implement day of week in crons
206                bail!("day of week schedule not supported yet");
207            }
208        }
209
210        Ok(target)
211    }
212
213    pub fn max_timewindow(&self) -> Duration {
214        Duration::from_secs(60 * 5)
215    }
216}
217
218impl std::str::FromStr for CronTab {
219    type Err = CronTabParseError;
220
221    fn from_str(s: &str) -> Result<Self, Self::Err> {
222        let mut parts = s.split_whitespace();
223
224        let part = parts
225            .next()
226            .ok_or_else(|| CronTabParseError::new(s, "missing minute specifier"))?;
227
228        let minute = match part {
229            "*" => CronTabValue::All,
230            x => {
231                let x = x.parse::<u8>().map_err(|err| {
232                    CronTabParseError::new(
233                        s,
234                        format!("invalid minute specifier '{x}' - expected * or [0-59]: '{err}'"),
235                    )
236                })?;
237
238                if x > 59 {
239                    return Err(CronTabParseError::new(s, format!("invalid minute specifier '{x}': expected * or a number between 0 and 59")));
240                }
241
242                CronTabValue::Value(x)
243            }
244        };
245
246        let part = parts
247            .next()
248            .ok_or_else(|| CronTabParseError::new(s, "missing hour specifier"))?;
249        let hour = match part {
250            "*" => CronTabValue::All,
251            x => {
252                let x = x.parse::<u8>().map_err(|err| {
253                    CronTabParseError::new(
254                        s,
255                        format!("invalid hour specifier '{x}' - expected * or [0-23]: '{err}'"),
256                    )
257                })?;
258
259                if x > 23 {
260                    return Err(CronTabParseError::new(
261                        s,
262                        format!(
263                            "invalid hour specifier '{x}': expected * or a number between 0 and 23"
264                        ),
265                    ));
266                }
267
268                CronTabValue::Value(x)
269            }
270        };
271
272        let part = parts
273            .next()
274            .ok_or_else(|| CronTabParseError::new(s, "missing day of month specifier"))?;
275        let day_of_month = match part {
276            "*" => CronTabValue::All,
277            x => {
278                let x = x.parse::<u8>().map_err(|err| {
279                    CronTabParseError::new(
280                        s,
281                        format!(
282                            "invalid day of month specifier '{x}' - expected * or [1-31]: '{err}'",
283                        ),
284                    )
285                })?;
286
287                if x < 1 || x > 31 {
288                    return Err(CronTabParseError::new(
289                        s,
290                        format!(
291                            "invalid day of month specifier '{x}': expected * or a number between 1 and 31",
292                        ),
293                    ));
294                }
295
296                CronTabValue::Value(x)
297            }
298        };
299
300        let part = parts
301            .next()
302            .ok_or_else(|| CronTabParseError::new(s, "missing month specifier"))?;
303        let month = match part {
304            "*" => CronTabValue::All,
305            x => {
306                let x = x.parse::<u8>().map_err(|err| {
307                    CronTabParseError::new(
308                        s,
309                        format!("invalid month specifier '{x}' - expected * or [1-12]: '{err}'"),
310                    )
311                })?;
312
313                if x < 1 || x > 12 {
314                    return Err(CronTabParseError::new(
315                        s,
316                        format!(
317                            "invalid month specifier '{x}': expected * or a number between 1 and 12",
318                        ),
319                    ));
320                }
321
322                CronTabValue::Value(x)
323            }
324        };
325
326        let part = parts
327            .next()
328            .ok_or_else(|| CronTabParseError::new(s, "missing day of week specifier"))?;
329        let day_of_week = match part {
330            "*" => CronTabValue::All,
331            x => {
332                let x = x.parse::<u8>().map_err(|err| {
333                    CronTabParseError::new(
334                        s,
335                        format!(
336                            "invalid day of week specifier '{x}' - expected * or [0-6]: '{err}'",
337                        ),
338                    )
339                })?;
340
341                if x > 6 {
342                    return Err(CronTabParseError::new(
343                        s,
344                        format!(
345                            "invalid day of week specifier '{x}': expected * or a number between 0 and 6",
346                        ),
347                    ));
348                }
349
350                CronTabValue::Value(x)
351            }
352        };
353
354        Ok(Self {
355            minute,
356            hour,
357            day_of_month,
358            month,
359            day_of_week,
360        })
361    }
362}
363
364impl std::fmt::Display for CronTab {
365    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366        write!(
367            f,
368            "{minute} {hour} {day_of_month} {month} {day_of_week}",
369            minute = self.minute,
370            hour = self.hour,
371            day_of_month = self.day_of_month,
372            month = self.month,
373            day_of_week = self.day_of_week,
374        )
375    }
376}
377
378#[derive(Debug)]
379pub struct CronTabParseError {
380    error: String,
381    value: String,
382}
383
384impl CronTabParseError {
385    pub fn new(tab: impl Into<String>, error: impl Into<String>) -> Self {
386        Self {
387            value: tab.into(),
388            error: error.into(),
389        }
390    }
391}
392
393impl std::fmt::Display for CronTabParseError {
394    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395        write!(f, "Invalid cron tab '{}': {}", self.value, self.error,)
396    }
397}
398
399impl std::error::Error for CronTabParseError {}
400
401#[cfg(test)]
402mod tests {
403    use std::{str::FromStr, time::Duration};
404
405    use super::*;
406
407    #[test]
408    fn test_parse_schedule() {
409        assert_eq!(
410            CronSchedule::from_str("1m").unwrap(),
411            CronSchedule::Interval(Duration::from_secs(60)),
412        );
413
414        assert_eq!(
415            CronSchedule::from_str("* * * * *").unwrap(),
416            CronSchedule::CronTab(CronTab {
417                minute: CronTabValue::All,
418                hour: CronTabValue::All,
419                day_of_month: CronTabValue::All,
420                month: CronTabValue::All,
421                day_of_week: CronTabValue::All,
422            })
423        );
424    }
425
426    #[test]
427    fn test_parse_crontab() {
428        assert_eq!(
429            CronTab::from_str("* * * * *").unwrap(),
430            CronTab {
431                minute: CronTabValue::All,
432                hour: CronTabValue::All,
433                day_of_month: CronTabValue::All,
434                month: CronTabValue::All,
435                day_of_week: CronTabValue::All,
436            },
437        );
438
439        assert_eq!(
440            CronTab::from_str("1 1 1 1 1").unwrap(),
441            CronTab {
442                minute: CronTabValue::Value(1),
443                hour: CronTabValue::Value(1),
444                day_of_month: CronTabValue::Value(1),
445                month: CronTabValue::Value(1),
446                day_of_week: CronTabValue::Value(1),
447            },
448        );
449    }
450}