Skip to main content

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