Skip to main content

rustlavel_queue/
time.rs

1//! The small amount of calendar arithmetic a scheduler needs.
2//!
3//! The framework has no date type: rustlavel-db deliberately leaves a
4//! `timestamptz` column as text so precision survives, and core has nothing
5//! richer than `SystemTime`. A cron schedule cannot be computed without real
6//! calendar maths, so this module carries the minimum — seconds since the Unix
7//! epoch in and out of a civil UTC date, and nothing else.
8//!
9//! Everything the queue stores is epoch seconds rather than a timestamp column,
10//! for the same reason: an integer compares and sorts identically in every
11//! driver, and no part of the system has to agree on a text format.
12//!
13//! The conversions are Howard Hinnant's `days_from_civil` / `civil_from_days`,
14//! chosen because they are exact for every year in range and short enough to
15//! verify by reading.
16
17/// Seconds in a day. Leap seconds do not exist in Unix time, so this is exact.
18pub const SECONDS_PER_DAY: i64 = 86_400;
19
20/// A civil date and time in UTC.
21///
22/// UTC and not local time on purpose: a schedule that silently shifts by an
23/// hour twice a year, differently on every machine in a cluster, is a bug
24/// generator. An application that wants local time can offset the expression.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct DateTime {
27    pub year: i64,
28    /// 1–12.
29    pub month: u32,
30    /// 1–31.
31    pub day: u32,
32    pub hour: u32,
33    pub minute: u32,
34    pub second: u32,
35    /// 0 = Sunday, matching cron's numbering rather than ISO's.
36    pub weekday: u32,
37}
38
39/// Now, in seconds since the epoch.
40///
41/// A clock set before 1970 would make this negative, which every consumer here
42/// handles, so there is no need to fail.
43pub fn unix_now() -> i64 {
44    match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
45        Ok(elapsed) => elapsed.as_secs() as i64,
46        Err(before) => -(before.duration().as_secs() as i64),
47    }
48}
49
50/// Split epoch seconds into a civil UTC date and time.
51pub fn from_unix(seconds: i64) -> DateTime {
52    // `div_euclid` rather than `/` so times before 1970 round the right way.
53    let days = seconds.div_euclid(SECONDS_PER_DAY);
54    let time_of_day = seconds.rem_euclid(SECONDS_PER_DAY);
55    let (year, month, day) = civil_from_days(days);
56
57    DateTime {
58        year,
59        month,
60        day,
61        hour: (time_of_day / 3600) as u32,
62        minute: ((time_of_day % 3600) / 60) as u32,
63        second: (time_of_day % 60) as u32,
64        weekday: weekday_of(days),
65    }
66}
67
68/// Build epoch seconds from a civil UTC date and time.
69pub fn to_unix(year: i64, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> i64 {
70    days_from_civil(year, month, day) * SECONDS_PER_DAY
71        + i64::from(hour) * 3600
72        + i64::from(minute) * 60
73        + i64::from(second)
74}
75
76/// The start of the day containing `seconds`.
77pub fn start_of_day(seconds: i64) -> i64 {
78    seconds - seconds.rem_euclid(SECONDS_PER_DAY)
79}
80
81/// The start of the minute containing `seconds`.
82pub fn start_of_minute(seconds: i64) -> i64 {
83    seconds - seconds.rem_euclid(60)
84}
85
86/// Day of the week for a day number, 0 = Sunday.
87///
88/// Day 0 of the epoch — 1 January 1970 — was a Thursday, which is where the
89/// `+ 4` comes from.
90fn weekday_of(days: i64) -> u32 {
91    (days + 4).rem_euclid(7) as u32
92}
93
94/// Days since 1970-01-01 for a civil date.
95///
96/// The trick is shifting the year to start in March, which puts the leap day at
97/// the end of the year and makes the month-length table a straight line.
98fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
99    let month = i64::from(month);
100    let year = year - i64::from(month <= 2);
101
102    let era = year.div_euclid(400);
103    let year_of_era = year - era * 400;
104    let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + i64::from(day) - 1;
105    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
106
107    era * 146_097 + day_of_era - 719_468
108}
109
110/// The inverse of [`days_from_civil`].
111fn civil_from_days(days: i64) -> (i64, u32, u32) {
112    let shifted = days + 719_468;
113    let era = shifted.div_euclid(146_097);
114    let day_of_era = shifted - era * 146_097;
115    let year_of_era =
116        (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
117    let year = year_of_era + era * 400;
118    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
119    let shifted_month = (5 * day_of_year + 2) / 153;
120    let day = day_of_year - (153 * shifted_month + 2) / 5 + 1;
121    let month = shifted_month + if shifted_month < 10 { 3 } else { -9 };
122
123    (year + i64::from(month <= 2), month as u32, day as u32)
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn converts_known_dates_in_both_directions() {
132        // The epoch itself, a Thursday.
133        let epoch = from_unix(0);
134        assert_eq!((epoch.year, epoch.month, epoch.day), (1970, 1, 1));
135        assert_eq!(epoch.weekday, 4);
136        assert_eq!(to_unix(1970, 1, 1, 0, 0, 0), 0);
137
138        // A date every developer can check against a wall calendar.
139        let known = to_unix(2026, 8, 29, 13, 45, 30);
140        let back = from_unix(known);
141        assert_eq!((back.year, back.month, back.day), (2026, 8, 29));
142        assert_eq!((back.hour, back.minute, back.second), (13, 45, 30));
143        assert_eq!(back.weekday, 6, "29 August 2026 is a Saturday");
144    }
145
146    #[test]
147    fn handles_leap_days_and_century_rules() {
148        // 2000 was a leap year; 1900 was not.
149        let leap = from_unix(to_unix(2000, 2, 29, 0, 0, 0));
150        assert_eq!((leap.year, leap.month, leap.day), (2000, 2, 29));
151
152        let march_1900 = from_unix(to_unix(1900, 2, 28, 0, 0, 0) + SECONDS_PER_DAY);
153        assert_eq!((march_1900.year, march_1900.month, march_1900.day), (1900, 3, 1));
154    }
155
156    #[test]
157    fn round_trips_every_day_for_a_decade() {
158        let mut seconds = to_unix(2020, 1, 1, 0, 0, 0);
159        let end = to_unix(2030, 1, 1, 0, 0, 0);
160        let mut previous_weekday = from_unix(seconds).weekday;
161
162        while seconds < end {
163            let date = from_unix(seconds);
164            assert_eq!(
165                to_unix(date.year, date.month, date.day, date.hour, date.minute, date.second),
166                seconds
167            );
168            seconds += SECONDS_PER_DAY;
169
170            let weekday = from_unix(seconds).weekday;
171            assert_eq!(weekday, (previous_weekday + 1) % 7, "weekdays must advance by one");
172            previous_weekday = weekday;
173        }
174    }
175
176    #[test]
177    fn dates_before_the_epoch_do_not_round_the_wrong_way() {
178        let date = from_unix(-1);
179        assert_eq!((date.year, date.month, date.day), (1969, 12, 31));
180        assert_eq!((date.hour, date.minute, date.second), (23, 59, 59));
181    }
182
183    #[test]
184    fn truncates_to_day_and_minute_boundaries() {
185        let noon = to_unix(2026, 8, 29, 12, 34, 56);
186        assert_eq!(start_of_day(noon), to_unix(2026, 8, 29, 0, 0, 0));
187        assert_eq!(start_of_minute(noon), to_unix(2026, 8, 29, 12, 34, 0));
188    }
189}