]> git.giacomolli.uk - onechat/blob - schedule_cron.go
Deliver routed WhatsApp batches to children
[onechat] / schedule_cron.go
1 package main
2
3 import (
4         "errors"
5         "fmt"
6         "strconv"
7         "strings"
8         "time"
9 )
10
11 const (
12         // scheduleCronGap is the shortest time between two runs of a cron
13         // expression. Each run is a full turn, so shorter gaps use the quota
14         // too fast.
15         scheduleCronGap = 5
16
17         // scheduleCronYears is how many years the search for the next time
18         // covers.
19         scheduleCronYears = 8
20 )
21
22 // scheduleCronFields are the fields of a cron expression, in order, with their
23 // lowest and highest values.
24 var scheduleCronFields = []struct {
25         name string
26         low  int
27         high int
28 }{
29         {
30                 name: "minute",
31                 low:  0,
32                 high: 59,
33         },
34         {
35                 name: "hour",
36                 low:  0,
37                 high: 23,
38         },
39         {
40                 name: "day of month",
41                 low:  1,
42                 high: 31,
43         },
44         {
45                 name: "month",
46                 low:  1,
47                 high: 12,
48         },
49         {
50                 name: "day of week",
51                 low:  0,
52                 high: 7,
53         },
54 }
55
56 // scheduleParseCron parses a cron expression of five fields: minute, hour, day
57 // of month, month, and day of week, where 0 and 7 are Sunday. A field is a
58 // comma list of items, and an item is *, N, or N-M, where * and N-M can have a
59 // step /S. Names, macros, the characters ? L W #, and N/S are errors.
60 //
61 // It refuses an expression whose runs can be less than scheduleCronGap minutes
62 // apart. The check takes the wall times of one day from the hour and the minute
63 // fields, and the gap across midnight as if the next day matches too. It also
64 // refuses an expression that matches no day in scheduleCronYears years, such as
65 // one on February 30.
66 func scheduleParseCron(expression string) (ScheduleCron, error) {
67         fields := strings.Fields(expression)
68         if len(fields) != len(scheduleCronFields) {
69                 return ScheduleCron{}, fmt.Errorf("the cron expression %q has %d fields, not 5: minute, hour, day of month, month, day of week", expression, len(fields))
70         }
71
72         var sets [5]uint64
73
74         for index, field := range fields {
75                 set, err := scheduleCronField(field, scheduleCronFields[index].low, scheduleCronFields[index].high)
76                 if err != nil {
77                         return ScheduleCron{}, fmt.Errorf("the %s field of %q: %w", scheduleCronFields[index].name, expression, err)
78                 }
79
80                 sets[index] = set
81         }
82
83         c := ScheduleCron{
84                 minutes:     sets[0],
85                 hours:       sets[1],
86                 days:        sets[2],
87                 months:      sets[3],
88                 weekdays:    sets[4],
89                 anyDay:      strings.HasPrefix(fields[2], "*"),
90                 anyWeekday:  strings.HasPrefix(fields[4], "*"),
91                 description: strings.Join(fields, " "),
92         }
93
94         if c.weekdays&(1<<7) != 0 {
95                 c.weekdays |= 1
96         }
97
98         if c.gap() < scheduleCronGap {
99                 return ScheduleCron{}, fmt.Errorf("the runs of %q can be less than %d minutes apart: the runs of a schedule must be at least %d minutes apart", expression, scheduleCronGap, scheduleCronGap)
100         }
101
102         start := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)
103         _, found := c.Next(start)
104         if !found {
105                 return ScheduleCron{}, fmt.Errorf("the cron expression %q matches no day in %d years", expression, scheduleCronYears)
106         }
107
108         return c, nil
109 }
110
111 // ScheduleCron is a parsed cron expression. Each set holds a bit for each value
112 // of its field. anyDay and anyWeekday tell that the day field or the weekday
113 // field starts with *. When neither does, a day matches when either field
114 // matches, as in classic cron. Otherwise both fields must match.
115 type ScheduleCron struct {
116         minutes     uint64
117         hours       uint64
118         days        uint64
119         months      uint64
120         weekdays    uint64
121         anyDay      bool
122         anyWeekday  bool
123         description string
124 }
125
126 // String returns the expression with one space between its fields.
127 func (c ScheduleCron) String() string {
128         return c.description
129 }
130
131 // Next returns the first time after after whose wall time in the location of
132 // after matches, or false when no time in scheduleCronYears years matches. A
133 // wall time goes through scheduleWallTime, so a wall time that the clock skips
134 // runs later by the length of the skip, and a wall time that the clock repeats
135 // runs once, at its first occurrence.
136 func (c ScheduleCron) Next(after time.Time) (time.Time, bool) {
137         location := after.Location()
138         year, month, day := after.Date()
139         first := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
140         last := first.AddDate(scheduleCronYears, 0, 0)
141         for date := first; date.Before(last); date = date.AddDate(0, 0, 1) {
142                 if !c.day(date) {
143                         continue
144                 }
145
146                 // A wall time that the clock skips moves later, so the times of
147                 // one day are not always in order. The first time is the
148                 // smallest one.
149                 var next time.Time
150
151                 for hour := range 24 {
152                         if c.hours&(1<<hour) == 0 {
153                                 continue
154                         }
155
156                         for minute := range 60 {
157                                 if c.minutes&(1<<minute) == 0 {
158                                         continue
159                                 }
160
161                                 t := scheduleWallTime(date.Year(), date.Month(), date.Day(), hour, minute, location)
162                                 if t.After(after) && (next.IsZero() || t.Before(next)) {
163                                         next = t
164                                 }
165                         }
166                 }
167
168                 if !next.IsZero() {
169                         return next, true
170                 }
171         }
172
173         return time.Time{}, false
174 }
175
176 // day tells if the calendar date matches the month and the day fields.
177 func (c ScheduleCron) day(date time.Time) bool {
178         if c.months&(1<<int(date.Month())) == 0 {
179                 return false
180         }
181
182         day := c.days&(1<<date.Day()) != 0
183         weekday := c.weekdays&(1<<int(date.Weekday())) != 0
184         if c.anyDay || c.anyWeekday {
185                 return day && weekday
186         }
187
188         return day || weekday
189 }
190
191 // gap returns the shortest time in minutes between two wall times of the hour
192 // and the minute fields, also across midnight.
193 func (c ScheduleCron) gap() int {
194         var times []int
195
196         for hour := range 24 {
197                 for minute := range 60 {
198                         if c.hours&(1<<hour) != 0 && c.minutes&(1<<minute) != 0 {
199                                 times = append(times, 60*hour+minute)
200                         }
201                 }
202         }
203
204         gap := times[0] + 24*60 - times[len(times)-1]
205         for index := 1; index < len(times); index++ {
206                 gap = min(gap, times[index]-times[index-1])
207         }
208
209         return gap
210 }
211
212 // scheduleWallTime returns the time of a wall time in location. A wall time
213 // that the clock skips, at the start of daylight saving time, is later by the
214 // length of the skip: 02:30 becomes 03:30 when the clock goes from 02:00 to
215 // 03:00. A wall time that the clock repeats, at the end of daylight saving
216 // time, is its first occurrence, as in cron.
217 func scheduleWallTime(year int, month time.Month, day, hour, minute int, location *time.Location) time.Time {
218         t := time.Date(year, month, day, hour, minute, 0, 0, location)
219         _, before := t.Add(-3 * time.Hour).Zone()
220         _, offset := t.Zone()
221         if t.Hour() != hour || t.Minute() != minute || t.Day() != day {
222                 wall := time.Date(year, month, day, hour, minute, 0, 0, time.UTC)
223
224                 return wall.Add(-time.Duration(before) * time.Second).In(location)
225         }
226
227         if before > offset {
228                 earlier := t.Add(-time.Duration(before-offset) * time.Second)
229                 if earlier.Day() == day && earlier.Hour() == hour && earlier.Minute() == minute {
230                         return earlier
231                 }
232         }
233
234         return t
235 }
236
237 // scheduleCronField returns the set of the values of one field, from low to
238 // high.
239 func scheduleCronField(field string, low, high int) (uint64, error) {
240         var set uint64
241
242         for item := range strings.SplitSeq(field, ",") {
243                 base, stepText, stepped := strings.Cut(item, "/")
244                 step := 1
245                 if stepped {
246                         var err error
247
248                         step, err = scheduleCronNumber(stepText, 1, high)
249                         if err != nil {
250                                 return 0, fmt.Errorf("the step of %q: %w", item, err)
251                         }
252                 }
253
254                 first, last := low, high
255                 startText, endText, ranged := strings.Cut(base, "-")
256                 switch {
257                 case base == "*":
258                 case ranged:
259                         var err error
260
261                         first, err = scheduleCronNumber(startText, low, high)
262                         if err == nil {
263                                 last, err = scheduleCronNumber(endText, low, high)
264                         }
265
266                         if err == nil && first > last {
267                                 err = errors.New("the start is after the end")
268                         }
269
270                         if err != nil {
271                                 return 0, fmt.Errorf("the range %q: %w", base, err)
272                         }
273                 case stepped:
274                         return 0, fmt.Errorf("%q has a step after a single value: write a range N-M/S or */S", item)
275                 default:
276                         var err error
277
278                         first, err = scheduleCronNumber(base, low, high)
279                         if err != nil {
280                                 return 0, err
281                         }
282
283                         last = first
284                 }
285
286                 for value := first; value <= last; value += step {
287                         set |= 1 << value
288                 }
289         }
290
291         return set, nil
292 }
293
294 // scheduleCronNumber returns the value of text, a decimal number from low to
295 // high.
296 func scheduleCronNumber(text string, low, high int) (int, error) {
297         if text == "" || strings.Trim(text, "0123456789") != "" {
298                 return 0, fmt.Errorf("%q is not a number from %d to %d", text, low, high)
299         }
300
301         value, err := strconv.Atoi(text)
302         if err != nil || value < low || value > high {
303                 return 0, fmt.Errorf("%q is not a number from %d to %d", text, low, high)
304         }
305
306         return value, nil
307 }