-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcron.go
More file actions
192 lines (170 loc) · 5.57 KB
/
Copy pathcron.go
File metadata and controls
192 lines (170 loc) · 5.57 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
package backstage
import (
"fmt"
"strconv"
"strings"
"time"
)
// cronSchedule implements Schedule using a standard 5-field cron expression.
//
// Format: "minute hour day-of-month month day-of-week"
// Example: "0 3 * * *" fires at 03:00 UTC every day.
//
// Supported field syntax:
// - * — every value
// - N — specific value
// - N-M — inclusive range
// - */N — every Nth value across the full range
// - N-M/N — every Nth value within range N-M
// - N,M,... — comma-separated list of any of the above
//
// Day-of-week: 0 = Sunday, 6 = Saturday.
// dom + dow use AND semantics: both fields must match for a time to be selected.
// All times are computed in UTC. Use [Cron] (or [MustCron]) to construct.
type cronSchedule struct {
minutes []bool // index 0..59
hours []bool // index 0..23
doms []bool // index 0..31 (index 0 unused; dom 1..31 at indices 1..31)
months []bool // index 0..12 (index 0 unused; month 1..12 at indices 1..12)
dows []bool // index 0..6
}
// Cron parses a 5-field cron expression and returns a Schedule that fires in UTC.
// Returns an error if the expression is invalid.
func Cron(expr string) (Schedule, error) {
fields := strings.Fields(expr)
if len(fields) != 5 {
return nil, fmt.Errorf("backstage: cron expression must have 5 fields, got %d: %q", len(fields), expr)
}
minutes, err := parseCronField(fields[0], 0, 59)
if err != nil {
return nil, fmt.Errorf("backstage: cron minute field %q: %w", fields[0], err)
}
hours, err := parseCronField(fields[1], 0, 23)
if err != nil {
return nil, fmt.Errorf("backstage: cron hour field %q: %w", fields[1], err)
}
doms, err := parseCronField(fields[2], 1, 31)
if err != nil {
return nil, fmt.Errorf("backstage: cron day-of-month field %q: %w", fields[2], err)
}
months, err := parseCronField(fields[3], 1, 12)
if err != nil {
return nil, fmt.Errorf("backstage: cron month field %q: %w", fields[3], err)
}
dows, err := parseCronField(fields[4], 0, 6)
if err != nil {
return nil, fmt.Errorf("backstage: cron day-of-week field %q: %w", fields[4], err)
}
return &cronSchedule{
minutes: minutes,
hours: hours,
doms: doms,
months: months,
dows: dows,
}, nil
}
// MustCron is like [Cron] but panics on an invalid expression.
// Intended for use in package-level variable declarations where a bad expression
// is a programmer error.
func MustCron(expr string) Schedule {
s, err := Cron(expr)
if err != nil {
panic(err)
}
return s
}
// Next returns the next UTC time at or after (after + 1 minute) that satisfies
// the cron expression. Returns zero time if no match is found within 5 years.
func (s *cronSchedule) Next(after time.Time) time.Time {
// Advance by one minute and truncate to minute boundary so we never
// re-fire at the same minute.
t := after.UTC().Add(time.Minute).Truncate(time.Minute)
limit := t.Add(5 * 365 * 24 * time.Hour)
for t.Before(limit) {
// ---- Month check -------------------------------------------------
if !s.months[int(t.Month())] {
// Jump to the first day of the next month.
t = time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, time.UTC)
continue
}
// ---- Day checks (dom AND dow must both match) ---------------------
if !s.doms[t.Day()] || !s.dows[int(t.Weekday())] {
// Jump to midnight of the next day.
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, time.UTC)
continue
}
// ---- Hour check --------------------------------------------------
if !s.hours[t.Hour()] {
// Jump to the start of the next hour.
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, time.UTC)
continue
}
// ---- Minute check ------------------------------------------------
if !s.minutes[t.Minute()] {
t = t.Add(time.Minute)
continue
}
return t
}
return time.Time{} // no match found within search window
}
// --------------------------------------------------------------------------
// Field parser
// --------------------------------------------------------------------------
// parseCronField parses a single cron field and returns a boolean slice of
// length max+1 where index i is true if value i is enabled.
func parseCronField(field string, min, max int) ([]bool, error) {
result := make([]bool, max+1)
for _, part := range strings.Split(field, ",") {
if err := applyCronPart(part, min, max, result); err != nil {
return nil, err
}
}
return result, nil
}
// applyCronPart applies a single cron field part (possibly containing / or -)
// to the result slice.
func applyCronPart(part string, min, max int, result []bool) error {
step := 1
// Extract optional step (/N suffix).
if idx := strings.Index(part, "/"); idx >= 0 {
n, err := strconv.Atoi(part[idx+1:])
if err != nil || n < 1 {
return fmt.Errorf("invalid step %q", part[idx+1:])
}
step = n
part = part[:idx]
}
// Wildcard.
if part == "*" {
for i := min; i <= max; i += step {
result[i] = true
}
return nil
}
// Range (N-M).
if idx := strings.Index(part, "-"); idx >= 0 {
lo, err1 := strconv.Atoi(part[:idx])
hi, err2 := strconv.Atoi(part[idx+1:])
if err1 != nil || err2 != nil {
return fmt.Errorf("invalid range %q", part)
}
if lo < min || hi > max || lo > hi {
return fmt.Errorf("range %d-%d out of bounds [%d, %d]", lo, hi, min, max)
}
for i := lo; i <= hi; i += step {
result[i] = true
}
return nil
}
// Single value.
n, err := strconv.Atoi(part)
if err != nil {
return fmt.Errorf("invalid value %q", part)
}
if n < min || n > max {
return fmt.Errorf("value %d out of bounds [%d, %d]", n, min, max)
}
result[n] = true
return nil
}