celery/beat/schedule/cron/
mod.rs

1//! This module contains the implementation of cron schedules.
2//! The implementation is inspired by the
3//! [cron crate](https://crates.io/crates/cron).
4
5use chrono::{offset::Utc, TimeZone};
6use std::time::SystemTime;
7
8use super::Schedule;
9use crate::error::ScheduleError;
10
11mod parsing;
12mod time_units;
13use parsing::{parse_longhand, parse_shorthand, CronParsingError, Shorthand};
14use time_units::{Hours, Minutes, MonthDays, Months, TimeUnitField, WeekDays};
15
16/// The maximum year supported by a `CronSchedule`.
17pub const MAX_YEAR: Ordinal = 2100;
18
19/// The type used to represent a temporal element (minutes, hours...).
20type Ordinal = u32;
21
22/// A schedule that can be used to execute tasks using Celery's
23/// [crontab](https://docs.celeryproject.org/en/stable/reference/celery.schedules.html#celery.schedules.crontab)
24/// syntax.
25///
26/// # Examples
27///
28/// ```
29/// // Schedule a task every 5 minutes from Monday to Friday:
30/// celery::beat::CronSchedule::from_string("*/5 * * * mon-fri");
31///
32/// // Schedule a task each minute from 8 am to 5 pm on the first day
33/// // of every month but only if it is Sunday:
34/// celery::beat::CronSchedule::from_string("* 8-17 1 * sun");
35///
36/// // Execute every minute in march with a custom time zone:
37/// let time_zone = chrono::offset::FixedOffset::east(3600);
38/// celery::beat::CronSchedule::from_string_with_time_zone("* * * mar *", time_zone);
39/// ```
40///
41/// A schedule can also be defined using vectors with the required
42/// candidates:
43/// ```
44/// celery::beat::CronSchedule::new(
45///     vec![15,30,45,59,0],
46///     vec![0,23],
47///     vec![1,2,3],
48///     vec![1,2,3,4,12],
49///     (1..=6).collect(),
50/// );
51/// ```
52#[derive(Debug)]
53pub struct CronSchedule<Z>
54where
55    Z: TimeZone,
56{
57    minutes: Minutes,
58    hours: Hours,
59    month_days: MonthDays,
60    months: Months,
61    week_days: WeekDays,
62    time_zone: Z,
63}
64
65impl<Z> Schedule for CronSchedule<Z>
66where
67    Z: TimeZone,
68{
69    fn next_call_at(&self, _last_run_at: Option<SystemTime>) -> Option<SystemTime> {
70        let now = self.time_zone.from_utc_datetime(&Utc::now().naive_utc());
71        self.next(now).map(SystemTime::from)
72    }
73}
74
75impl CronSchedule<Utc> {
76    /// Create a new cron schedule which can be used to run a task
77    /// in the specified minutes/hours/month days/week days/months.
78    /// This schedule will use the UTC time zone.
79    ///
80    /// No vector should be empty and each argument must be in the range
81    /// of valid values for its respective time unit.
82    pub fn new(
83        minutes: Vec<Ordinal>,
84        hours: Vec<Ordinal>,
85        month_days: Vec<Ordinal>,
86        months: Vec<Ordinal>,
87        week_days: Vec<Ordinal>,
88    ) -> Result<Self, ScheduleError> {
89        Self::new_with_time_zone(minutes, hours, month_days, months, week_days, Utc)
90    }
91
92    /// Create a cron schedule from a *cron* string. This schedule will use the
93    /// UTC time zone.
94    ///
95    /// The string must be a space-separated list of five elements,
96    /// representing *minutes*, *hours*, *month days*, *months* and *week days*
97    /// (in this order). Each element can be:
98    /// - a number in the correct range for the given time unit: e.g. `3`
99    /// - a range: e.g. `2-5` which corresponds to 2,3,4,5
100    /// - a range with a step: e.g. `1-6/3` which corresponds to 1,4
101    /// - a wildcard: i.e. `*` which corresponds to all elements for the given time unit
102    /// - a wildcard with a step: e.g. `*/4` which corresponds to one every four elements
103    /// - a comma-separated list (without spaces) where each element is one
104    ///   of the previous ones: e.g. `8,2-4,1-5/2` which corresponds to 1,2,3,4,5,8
105    ///
106    /// Months and week days can also be represented using the first three letters instead
107    /// of numbers (e.g, `mon`, `thu`, `may`, `oct`...).
108    ///
109    /// As an alternative, a shorthand representation can be used. The following options
110    /// are available:
111    /// - `@yearly`: at 0:00 on the first of January each year
112    /// - `@monthly`: at 0:00 at the beginning of each month
113    /// - `@weekly`: at 0:00 on Monday each week
114    /// - `@daily`: at 0:00 each day
115    /// - `@hourly`: each hour at 00
116    pub fn from_string(schedule: &str) -> Result<Self, ScheduleError> {
117        Self::from_string_with_time_zone(schedule, Utc)
118    }
119}
120
121impl<Z> CronSchedule<Z>
122where
123    Z: TimeZone,
124{
125    /// Create a new cron schedule which can be used to run a task
126    /// in the specified minutes/hours/month days/week days/months.
127    /// This schedule will use the given time zone.
128    ///
129    /// No vector should be empty and each argument must be in the range
130    /// of valid values for its respective time unit.
131    pub fn new_with_time_zone(
132        mut minutes: Vec<Ordinal>,
133        mut hours: Vec<Ordinal>,
134        mut month_days: Vec<Ordinal>,
135        mut months: Vec<Ordinal>,
136        mut week_days: Vec<Ordinal>,
137        time_zone: Z,
138    ) -> Result<Self, ScheduleError> {
139        minutes.sort_unstable();
140        minutes.dedup();
141        hours.sort_unstable();
142        hours.dedup();
143        month_days.sort_unstable();
144        month_days.dedup();
145        months.sort_unstable();
146        months.dedup();
147        week_days.sort_unstable();
148        week_days.dedup();
149
150        Self::validate(&minutes, &hours, &month_days, &months, &week_days)?;
151
152        Ok(Self {
153            minutes: Minutes::from_vec(minutes),
154            hours: Hours::from_vec(hours),
155            month_days: MonthDays::from_vec(month_days),
156            months: Months::from_vec(months),
157            week_days: WeekDays::from_vec(week_days),
158            time_zone,
159        })
160    }
161
162    /// Create a cron schedule from a *cron* string. This schedule will use the
163    /// given time zone.
164    ///
165    /// The string must be a space-separated list of five elements,
166    /// representing *minutes*, *hours*, *month days*, *months* and *week days*
167    /// (in this order). Each element can be:
168    /// - a number in the correct range for the given time unit: e.g. `3`
169    /// - a range: e.g. `2-5` which corresponds to 2,3,4,5
170    /// - a range with a step: e.g. `1-6/3` which corresponds to 1,4
171    /// - a wildcard: i.e. `*` which corresponds to all elements for the given time unit
172    /// - a wildcard with a step: e.g. `*/4` which corresponds to one every four elements
173    /// - a comma-separated list (without spaces) where each element is one
174    ///   of the previous ones: e.g. `8,2-4,1-5/2` which corresponds to 1,2,3,4,5,8
175    ///
176    /// Months and week days can also be represented using the first three letters instead
177    /// of numbers (e.g, `mon`, `thu`, `may`, `oct`...).
178    ///
179    /// As an alternative, a shorthand representation can be used. The following options
180    /// are available:
181    /// - `@yearly`: at 0:00 on the first of January each year
182    /// - `@monthly`: at 0:00 at the beginning of each month
183    /// - `@weekly`: at 0:00 on Monday each week
184    /// - `@daily`: at 0:00 each day
185    /// - `@hourly`: each hour at 00
186    pub fn from_string_with_time_zone(schedule: &str, time_zone: Z) -> Result<Self, ScheduleError> {
187        if schedule.starts_with('@') {
188            Self::from_shorthand(schedule, time_zone)
189        } else {
190            Self::from_longhand(schedule, time_zone)
191        }
192    }
193
194    /// Check that the given vectors are in the correct range for each time unit
195    /// and are not empty.
196    fn validate(
197        minutes: &[Ordinal],
198        hours: &[Ordinal],
199        month_days: &[Ordinal],
200        months: &[Ordinal],
201        week_days: &[Ordinal],
202    ) -> Result<(), ScheduleError> {
203        use ScheduleError::CronScheduleError;
204
205        if minutes.is_empty() {
206            return Err(CronScheduleError("Minutes were not set".to_string()));
207        }
208        if *minutes.first().unwrap() < Minutes::inclusive_min() {
209            return Err(CronScheduleError(format!(
210                "Minutes cannot be less than {}",
211                Minutes::inclusive_min()
212            )));
213        }
214        if *minutes.last().unwrap() > Minutes::inclusive_max() {
215            return Err(CronScheduleError(format!(
216                "Minutes cannot be more than {}",
217                Minutes::inclusive_max()
218            )));
219        }
220
221        if hours.is_empty() {
222            return Err(CronScheduleError("Hours were not set".to_string()));
223        }
224        if *hours.first().unwrap() < Hours::inclusive_min() {
225            return Err(CronScheduleError(format!(
226                "Hours cannot be less than {}",
227                Hours::inclusive_min()
228            )));
229        }
230        if *hours.last().unwrap() > Hours::inclusive_max() {
231            return Err(CronScheduleError(format!(
232                "Hours cannot be more than {}",
233                Hours::inclusive_max()
234            )));
235        }
236
237        if month_days.is_empty() {
238            return Err(CronScheduleError("Month days were not set".to_string()));
239        }
240        if *month_days.first().unwrap() < MonthDays::inclusive_min() {
241            return Err(CronScheduleError(format!(
242                "Month days cannot be less than {}",
243                MonthDays::inclusive_min()
244            )));
245        }
246        if *month_days.last().unwrap() > MonthDays::inclusive_max() {
247            return Err(CronScheduleError(format!(
248                "Month days cannot be more than {}",
249                MonthDays::inclusive_max()
250            )));
251        }
252
253        if months.is_empty() {
254            return Err(CronScheduleError("Months were not set".to_string()));
255        }
256        if *months.first().unwrap() < Months::inclusive_min() {
257            return Err(CronScheduleError(format!(
258                "Months cannot be less than {}",
259                Months::inclusive_min()
260            )));
261        }
262        if *months.last().unwrap() > Months::inclusive_max() {
263            return Err(CronScheduleError(format!(
264                "Months cannot be more than {}",
265                Months::inclusive_max()
266            )));
267        }
268
269        if week_days.is_empty() {
270            return Err(CronScheduleError("Week days were not set".to_string()));
271        }
272        if *week_days.first().unwrap() < WeekDays::inclusive_min() {
273            return Err(CronScheduleError(format!(
274                "Week days cannot be less than {}",
275                WeekDays::inclusive_min()
276            )));
277        }
278        if *week_days.last().unwrap() > WeekDays::inclusive_max() {
279            return Err(CronScheduleError(format!(
280                "Week days cannot be more than {}",
281                WeekDays::inclusive_max()
282            )));
283        }
284
285        Ok(())
286    }
287
288    fn from_shorthand(schedule: &str, time_zone: Z) -> Result<Self, ScheduleError> {
289        use Shorthand::*;
290        match parse_shorthand(schedule)? {
291            Yearly => Ok(Self {
292                minutes: Minutes::List(vec![0]),
293                hours: Hours::List(vec![0]),
294                month_days: MonthDays::List(vec![1]),
295                week_days: WeekDays::All,
296                months: Months::List(vec![1]),
297                time_zone,
298            }),
299            Monthly => Ok(Self {
300                minutes: Minutes::List(vec![0]),
301                hours: Hours::List(vec![0]),
302                month_days: MonthDays::List(vec![1]),
303                week_days: WeekDays::All,
304                months: Months::All,
305                time_zone,
306            }),
307            Weekly => Ok(Self {
308                minutes: Minutes::List(vec![0]),
309                hours: Hours::List(vec![0]),
310                month_days: MonthDays::All,
311                week_days: WeekDays::List(vec![1]),
312                months: Months::All,
313                time_zone,
314            }),
315            Daily => Ok(Self {
316                minutes: Minutes::List(vec![0]),
317                hours: Hours::List(vec![0]),
318                month_days: MonthDays::All,
319                week_days: WeekDays::All,
320                months: Months::All,
321                time_zone,
322            }),
323            Hourly => Ok(Self {
324                minutes: Minutes::List(vec![0]),
325                hours: Hours::All,
326                month_days: MonthDays::All,
327                week_days: WeekDays::All,
328                months: Months::All,
329                time_zone,
330            }),
331        }
332    }
333
334    fn from_longhand(schedule: &str, time_zone: Z) -> Result<Self, ScheduleError> {
335        let components: Vec<_> = schedule.split_whitespace().collect();
336        if components.len() != 5 {
337            Err(ScheduleError::CronScheduleError(format!(
338                "'{schedule}' is not a valid cron schedule: invalid number of elements"
339            )))
340        } else {
341            let minutes = parse_longhand::<Minutes>(components[0])?;
342            let hours = parse_longhand::<Hours>(components[1])?;
343            let month_days = parse_longhand::<MonthDays>(components[2])?;
344            let months = parse_longhand::<Months>(components[3])?;
345            let week_days = parse_longhand::<WeekDays>(components[4])?;
346
347            CronSchedule::new_with_time_zone(
348                minutes, hours, month_days, months, week_days, time_zone,
349            )
350        }
351    }
352
353    /// Compute the next time a task should run according to this schedule
354    /// using `now` as starting point.
355    ///
356    /// Note that `Tz` in theory can be a time zone different from the time zone `Z`
357    /// associated with this instance of `CronSchedule`: this method will work regardless.
358    /// In practice, however, `Tz` and `Z` are always the same.
359    ///
360    /// ## Algorithm description
361    ///
362    /// The time units form the following hierarchy: year -> month -> days of month -> hour -> minute.
363    /// The algorithm loops over them in this order
364    /// and for every candidate checks if it corresponds to the correct week day
365    /// and is valid according to the given time zone. Using graph terminology, we are exploring
366    /// the tree of possible solutions using a depth-first search.
367    ///
368    /// For each unit in the hierarchy we only pick candidates which are valid
369    /// according to our cron schedule.
370    ///
371    /// ### Meaning of the `overflow` variable
372    ///
373    /// The starting point of the search is equal to
374    /// `(current year, current month, current month day, current hour, current minute)`.
375    ///
376    /// At first we require that the candidate for each level is equal to the corresponding
377    /// "current value". If no candidate is found for that value we have to try larger values.
378    /// When this happens all candidates at later levels are not required to be greater than the
379    /// "current value" anymore.
380    ///
381    /// The `overflow` variable is used to model this part of the algorithm.
382    fn next<Tz>(&self, now: chrono::DateTime<Tz>) -> Option<chrono::DateTime<Tz>>
383    where
384        Tz: chrono::TimeZone,
385    {
386        use chrono::{Datelike, Timelike};
387
388        let current_minute = now.minute();
389        let current_hour = now.hour();
390        let current_month_day = now.day();
391        let current_month = now.month();
392        let current_year = now.year() as Ordinal;
393        assert!(current_year <= MAX_YEAR);
394
395        let mut overflow = false;
396        for year in current_year..MAX_YEAR {
397            let month_start = if overflow { 1 } else { current_month };
398            for month in self.months.open_range(month_start) {
399                if month > current_month {
400                    overflow = true;
401                }
402                let month_day_start = if overflow { 1 } else { current_month_day };
403                let num_days_in_month = days_in_month(month, year);
404                'day_loop: for month_day in self
405                    .month_days
406                    .bounded_range(month_day_start, num_days_in_month)
407                {
408                    if month_day > current_month_day {
409                        overflow = true;
410                    }
411                    let hour_target = if overflow { 0 } else { current_hour };
412                    for hour in self.hours.open_range(hour_target) {
413                        if hour > current_hour {
414                            overflow = true;
415                        }
416                        let minute_target = if overflow { 0 } else { current_minute + 1 };
417                        for minute in self.minutes.open_range(minute_target) {
418                            // Check that date is real (time zones are complicated...)
419                            let timezone = now.timezone();
420                            if let chrono::offset::LocalResult::Single(candidate) = timezone
421                                .with_ymd_and_hms(year as i32, month, month_day, hour, minute, 0)
422                            {
423                                // Check that the day of week is correct
424                                if !self
425                                    .week_days
426                                    .contains(candidate.weekday().num_days_from_sunday())
427                                {
428                                    // It makes no sense trying different hours and
429                                    // minutes in the same day
430                                    continue 'day_loop;
431                                }
432
433                                return Some(candidate);
434                            }
435                        }
436                        overflow = true;
437                    }
438                    overflow = true;
439                }
440                overflow = true;
441            }
442            overflow = true;
443        }
444
445        None
446    }
447}
448
449fn is_leap_year(year: Ordinal) -> bool {
450    let by_four = year % 4 == 0;
451    let by_hundred = year % 100 == 0;
452    let by_four_hundred = year % 400 == 0;
453    by_four && ((!by_hundred) || by_four_hundred)
454}
455
456fn days_in_month(month: Ordinal, year: Ordinal) -> Ordinal {
457    let is_leap_year = is_leap_year(year);
458    match month {
459        4 | 6 | 9 | 11 => 30,
460        2 if is_leap_year => 29,
461        2 => 28,
462        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
463        x => panic!(
464            "{} is not a valid value for a month (it must be between 1 and 12)",
465            x
466        ),
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use chrono::{DateTime, NaiveDateTime};
474
475    fn make_utc_date(s: &str) -> DateTime<Utc> {
476        DateTime::<Utc>::from_naive_utc_and_offset(
477            NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S %z").unwrap(),
478            Utc,
479        )
480    }
481
482    #[test]
483    fn test_cron_next() {
484        let date = make_utc_date("2020-10-19 20:30:00 +0000");
485        let cron_schedule = CronSchedule::from_string("* * * * *").unwrap();
486        let expected_date = make_utc_date("2020-10-19 20:31:00 +0000");
487        assert_eq!(Some(expected_date), cron_schedule.next(date));
488
489        let date = make_utc_date("2020-10-19 20:30:00 +0000");
490        let cron_schedule = CronSchedule::from_string("31 20 * * *").unwrap();
491        let expected_date = make_utc_date("2020-10-19 20:31:00 +0000");
492        assert_eq!(Some(expected_date), cron_schedule.next(date));
493
494        let date = make_utc_date("2020-10-19 20:30:00 +0000");
495        let cron_schedule = CronSchedule::from_string("31 14 4 11 *").unwrap();
496        let expected_date = make_utc_date("2020-11-04 14:31:00 +0000");
497        assert_eq!(Some(expected_date), cron_schedule.next(date));
498
499        let date = make_utc_date("2020-10-19 20:29:23 +0000");
500        let cron_schedule = CronSchedule::from_string("*/5 9-18 1 * 6,0").unwrap();
501        let expected_date = make_utc_date("2020-11-01 09:00:00 +0000");
502        assert_eq!(Some(expected_date), cron_schedule.next(date));
503
504        let date = make_utc_date("2020-10-19 20:29:23 +0000");
505        let cron_schedule = CronSchedule::from_string("3 12 29-31 1-6 2-4").unwrap();
506        let expected_date = make_utc_date("2021-03-30 12:03:00 +0000");
507        assert_eq!(Some(expected_date), cron_schedule.next(date));
508
509        let date = make_utc_date("2020-10-19 20:29:23 +0000");
510        let cron_schedule = CronSchedule::from_string("* * 30 2 *").unwrap();
511        assert_eq!(None, cron_schedule.next(date));
512    }
513
514    #[test]
515    fn test_cron_next_with_date_time() {
516        let date =
517            chrono::DateTime::parse_from_str("2020-10-19 20:29:23 +0112", "%Y-%m-%d %H:%M:%S %z")
518                .unwrap();
519        let time_zone = chrono::offset::FixedOffset::east_opt(3600 + 600 + 120).unwrap();
520        let cron_schedule =
521            CronSchedule::from_string_with_time_zone("3 12 29-31 1-6 2-4", time_zone).unwrap();
522        let expected_date =
523            chrono::DateTime::parse_from_str("2021-03-30 12:03:00 +0112", "%Y-%m-%d %H:%M:%S %z")
524                .unwrap();
525        assert_eq!(Some(expected_date), cron_schedule.next(date));
526    }
527
528    fn cron_schedule_equal<Z: TimeZone>(
529        schedule: &CronSchedule<Z>,
530        minutes: &[Ordinal],
531        hours: &[Ordinal],
532        month_days: &[Ordinal],
533        months: &[Ordinal],
534        week_days: &[Ordinal],
535    ) -> bool {
536        let minutes_equal = match &schedule.minutes {
537            Minutes::All => minutes == (1..=60).collect::<Vec<_>>(),
538            Minutes::List(vec) => minutes == vec,
539        };
540        let hours_equal = match &schedule.hours {
541            Hours::All => hours == (0..=23).collect::<Vec<_>>(),
542            Hours::List(vec) => hours == vec,
543        };
544        let month_days_equal = match &schedule.month_days {
545            MonthDays::All => month_days == (1..=31).collect::<Vec<_>>(),
546            MonthDays::List(vec) => month_days == vec,
547        };
548        let months_equal = match &schedule.months {
549            Months::All => months == (1..=12).collect::<Vec<_>>(),
550            Months::List(vec) => months == vec,
551        };
552        let week_days_equal = match &schedule.week_days {
553            WeekDays::All => week_days == (0..=6).collect::<Vec<_>>(),
554            WeekDays::List(vec) => week_days == vec,
555        };
556
557        minutes_equal && hours_equal && month_days_equal && months_equal && week_days_equal
558    }
559
560    #[test]
561    fn test_from_string() -> Result<(), ScheduleError> {
562        let schedule = CronSchedule::from_string("2 12 8 1 *")?;
563        assert!(cron_schedule_equal(
564            &schedule,
565            &[2],
566            &[12],
567            &[8],
568            &[1],
569            &(0..=6).collect::<Vec<_>>(),
570        ));
571
572        let schedule = CronSchedule::from_string("@yearly")?;
573        assert!(cron_schedule_equal(
574            &schedule,
575            &[0],
576            &[0],
577            &[1],
578            &[1],
579            &(0..=6).collect::<Vec<_>>(),
580        ));
581        let schedule = CronSchedule::from_string("@monthly")?;
582        assert!(cron_schedule_equal(
583            &schedule,
584            &[0],
585            &[0],
586            &[1],
587            &(1..=12).collect::<Vec<_>>(),
588            &(0..=6).collect::<Vec<_>>(),
589        ));
590        let schedule = CronSchedule::from_string("@weekly")?;
591        assert!(cron_schedule_equal(
592            &schedule,
593            &[0],
594            &[0],
595            &(1..=31).collect::<Vec<_>>(),
596            &(1..=12).collect::<Vec<_>>(),
597            &[1],
598        ));
599        let schedule = CronSchedule::from_string("@daily")?;
600        assert!(cron_schedule_equal(
601            &schedule,
602            &[0],
603            &[0],
604            &(1..=31).collect::<Vec<_>>(),
605            &(1..=12).collect::<Vec<_>>(),
606            &(0..=6).collect::<Vec<_>>(),
607        ));
608        let schedule = CronSchedule::from_string("@hourly")?;
609        assert!(cron_schedule_equal(
610            &schedule,
611            &[0],
612            &(0..=23).collect::<Vec<_>>(),
613            &(1..=31).collect::<Vec<_>>(),
614            &(1..=12).collect::<Vec<_>>(),
615            &(0..=6).collect::<Vec<_>>(),
616        ));
617
618        Ok(())
619    }
620}