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