]> git.giacomolli.uk - onechat/blob - schedule_cron.go
Order model and chat declarations
[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 // ScheduleCron is a parsed cron expression. Each set holds a bit for each value
57 // of its field. anyDay and anyWeekday tell that the day field or the weekday
58 // field starts with *. When neither does, a day matches when either field
59 // matches, as in classic cron. Otherwise both fields must match.
60 type ScheduleCron struct {
61 minutes uint64
62 hours uint64
63 days uint64
64 months uint64
65 weekdays uint64
66 anyDay bool
67 anyWeekday bool
68 description string
69 }
70
71 // String returns the expression with one space between its fields.
72 func (c ScheduleCron) String() string {
73 return c.description
74 }
75
76 // Next returns the first time after after whose wall time in the location of
77 // after matches, or false when no time in scheduleCronYears years matches. A
78 // wall time goes through scheduleWallTime, so a wall time that the clock skips
79 // runs later by the length of the skip, and a wall time that the clock repeats
80 // runs once, at its first occurrence.
81 func (c ScheduleCron) Next(after time.Time) (time.Time, bool) {
82 location := after.Location()
83 year, month, day := after.Date()
84 first := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
85 last := first.AddDate(scheduleCronYears, 0, 0)
86 for date := first; date.Before(last); date = date.AddDate(0, 0, 1) {
87 if !c.day(date) {
88 continue
89 }
90
91 // A wall time that the clock skips moves later, so the times of
92 // one day are not always in order. The first time is the
93 // smallest one.
94 var next time.Time
95
96 for hour := range 24 {
97 if c.hours&(1<<hour) == 0 {
98 continue
99 }
100
101 for minute := range 60 {
102 if c.minutes&(1<<minute) == 0 {
103 continue
104 }
105
106 t := scheduleWallTime(date.Year(), date.Month(), date.Day(), hour, minute, location)
107 if t.After(after) && (next.IsZero() || t.Before(next)) {
108 next = t
109 }
110 }
111 }
112
113 if !next.IsZero() {
114 return next, true
115 }
116 }
117
118 return time.Time{}, false
119 }
120
121 // day tells if the calendar date matches the month and the day fields.
122 func (c ScheduleCron) day(date time.Time) bool {
123 if c.months&(1<<int(date.Month())) == 0 {
124 return false
125 }
126
127 day := c.days&(1<<date.Day()) != 0
128 weekday := c.weekdays&(1<<int(date.Weekday())) != 0
129 if c.anyDay || c.anyWeekday {
130 return day && weekday
131 }
132
133 return day || weekday
134 }
135
136 // gap returns the shortest time in minutes between two wall times of the hour
137 // and the minute fields, also across midnight.
138 func (c ScheduleCron) gap() int {
139 var times []int
140
141 for hour := range 24 {
142 for minute := range 60 {
143 if c.hours&(1<<hour) != 0 && c.minutes&(1<<minute) != 0 {
144 times = append(times, 60*hour+minute)
145 }
146 }
147 }
148
149 gap := times[0] + 24*60 - times[len(times)-1]
150 for index := 1; index < len(times); index++ {
151 gap = min(gap, times[index]-times[index-1])
152 }
153
154 return gap
155 }
156
157 // scheduleParseCron parses a cron expression of five fields: minute, hour, day
158 // of month, month, and day of week, where 0 and 7 are Sunday. A field is a
159 // comma list of items, and an item is *, N, or N-M, where * and N-M can have a
160 // step /S. Names, macros, the characters ? L W #, and N/S are errors.
161 //
162 // It refuses an expression whose runs can be less than scheduleCronGap minutes
163 // apart. The check takes the wall times of one day from the hour and the minute
164 // fields, and the gap across midnight as if the next day matches too. It also
165 // refuses an expression that matches no day in scheduleCronYears years, such as
166 // one on February 30.
167 func scheduleParseCron(expression string) (ScheduleCron, error) {
168 fields := strings.Fields(expression)
169 if len(fields) != len(scheduleCronFields) {
170 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))
171 }
172
173 var sets [5]uint64
174
175 for index, field := range fields {
176 set, err := scheduleCronField(field, scheduleCronFields[index].low, scheduleCronFields[index].high)
177 if err != nil {
178 return ScheduleCron{}, fmt.Errorf("the %s field of %q: %w", scheduleCronFields[index].name, expression, err)
179 }
180
181 sets[index] = set
182 }
183
184 c := ScheduleCron{
185 minutes: sets[0],
186 hours: sets[1],
187 days: sets[2],
188 months: sets[3],
189 weekdays: sets[4],
190 anyDay: strings.HasPrefix(fields[2], "*"),
191 anyWeekday: strings.HasPrefix(fields[4], "*"),
192 description: strings.Join(fields, " "),
193 }
194
195 if c.weekdays&(1<<7) != 0 {
196 c.weekdays |= 1
197 }
198
199 if c.gap() < scheduleCronGap {
200 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)
201 }
202
203 start := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)
204 _, found := c.Next(start)
205 if !found {
206 return ScheduleCron{}, fmt.Errorf("the cron expression %q matches no day in %d years", expression, scheduleCronYears)
207 }
208
209 return c, nil
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 }