-
Notifications
You must be signed in to change notification settings - Fork 597
/
cron.go
239 lines (214 loc) · 6.95 KB
/
cron.go
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
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
// package schedule provides utilities for managing template and workspace
// autostart and autostop schedules. This includes utilities for parsing and
// deserializing cron-style expressions.
package schedule
import (
"fmt"
"strings"
"time"
"github.com/robfig/cron/v3"
"golang.org/x/xerrors"
)
// For the purposes of this library, we only need minute, hour, and
// day-of-week. However to ensure interoperability we will use the standard
// five-valued cron format. Descriptors are not supported.
const parserFormat = cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow
var defaultParser = cron.NewParser(parserFormat)
// Weekly parses a Schedule from spec scoped to a recurring weekly event.
// Spec consists of the following space-delimited fields, in the following order:
// - timezone e.g. CRON_TZ=US/Central (optional)
// - minutes of hour e.g. 30 (required)
// - hour of day e.g. 9 (required)
// - day of month (must be *)
// - month (must be *)
// - day of week e.g. 1 (required)
//
// Example Usage:
//
// local_sched, _ := schedule.Weekly("59 23 *")
// fmt.Println(sched.Next(time.Now().Format(time.RFC3339)))
// // Output: 2022-04-04T23:59:00Z
//
// us_sched, _ := schedule.Weekly("CRON_TZ=US/Central 30 9 1-5")
// fmt.Println(sched.Next(time.Now()).Format(time.RFC3339))
// // Output: 2022-04-04T14:30:00Z
func Weekly(raw string) (*Schedule, error) {
if err := validateWeeklySpec(raw); err != nil {
return nil, xerrors.Errorf("validate weekly schedule: %w", err)
}
return parse(raw)
}
// Daily parses a Schedule from spec scoped to a recurring daily event.
// Spec consists of the following space-delimited fields, in the following order:
// - timezone e.g. CRON_TZ=US/Central (optional)
// - minutes of hour e.g. 30 (required)
// - hour of day e.g. 9 (required)
// - day of month (must be *)
// - month (must be *)
// - day of week (must be *)
//
// Example Usage:
//
// local_sched, _ := schedule.Weekly("59 23 * * *")
// fmt.Println(sched.Next(time.Now().Format(time.RFC3339)))
// // Output: 2022-04-04T23:59:00Z
//
// us_sched, _ := schedule.Weekly("CRON_TZ=US/Central 30 9 * * *")
// fmt.Println(sched.Next(time.Now()).Format(time.RFC3339))
// // Output: 2022-04-04T14:30:00Z
func Daily(raw string) (*Schedule, error) {
if err := validateDailySpec(raw); err != nil {
return nil, xerrors.Errorf("validate daily schedule: %w", err)
}
return parse(raw)
}
func parse(raw string) (*Schedule, error) {
// If schedule does not specify a timezone, default to UTC. Otherwise,
// the library will default to time.Local which we want to avoid.
if !strings.HasPrefix(raw, "CRON_TZ=") {
raw = "CRON_TZ=UTC " + raw
}
specSched, err := defaultParser.Parse(raw)
if err != nil {
return nil, xerrors.Errorf("parse schedule: %w", err)
}
schedule, ok := specSched.(*cron.SpecSchedule)
if !ok {
return nil, xerrors.Errorf("expected *cron.SpecSchedule but got %T", specSched)
}
if schedule.Location == time.Local {
return nil, xerrors.Errorf("schedules scoped to time.Local are not supported")
}
// Strip the leading CRON_TZ prefix so we just store the cron string.
// The timezone info is available in SpecSchedule.
cronStr := raw
if strings.HasPrefix(raw, "CRON_TZ=") {
cronStr = strings.Join(strings.Fields(raw)[1:], " ")
}
cronSched := &Schedule{
sched: schedule,
cronStr: cronStr,
}
return cronSched, nil
}
// Schedule represents a cron schedule.
// It's essentially a wrapper for robfig/cron/v3 that has additional
// convenience methods.
type Schedule struct {
sched *cron.SpecSchedule
// XXX: there isn't any nice way for robfig/cron to serialize
cronStr string
}
// String serializes the schedule to its original human-friendly format.
// The leading CRON_TZ is maintained.
func (s Schedule) String() string {
var sb strings.Builder
_, _ = sb.WriteString("CRON_TZ=")
_, _ = sb.WriteString(s.sched.Location.String())
_, _ = sb.WriteString(" ")
_, _ = sb.WriteString(s.cronStr)
return sb.String()
}
// Location returns the IANA location for the schedule.
func (s Schedule) Location() *time.Location {
return s.sched.Location
}
// Cron returns the cron spec for the schedule with the leading CRON_TZ
// stripped, if present.
func (s Schedule) Cron() string {
return s.cronStr
}
// Next returns the next time in the schedule relative to t.
func (s Schedule) Next(t time.Time) time.Time {
return s.sched.Next(t)
}
var (
t0 = time.Date(1970, 1, 1, 1, 1, 1, 0, time.UTC)
tMax = t0.Add(168 * time.Hour)
)
// Min returns the minimum duration of the schedule.
// This is calculated as follows:
// - Let t(0) be a given point in time (1970-01-01T01:01:01Z00:00)
// - Let t(max) be 168 hours after t(0).
// - Let t(1) be the next scheduled time after t(0).
// - Let t(n) be the next scheduled time after t(n-1).
// - Then, the minimum duration of s d(min)
// = min( t(n) - t(n-1) ∀ n ∈ N, t(n) < t(max) )
func (s Schedule) Min() time.Duration {
durMin := tMax.Sub(t0)
tPrev := s.Next(t0)
tCurr := s.Next(tPrev)
for {
dur := tCurr.Sub(tPrev)
if dur < durMin {
durMin = dur
}
tPrev = tCurr
tCurr = s.Next(tCurr)
if tCurr.After(tMax) {
break
}
}
return durMin
}
// Time returns a humanized form of the minute and hour fields.
func (s Schedule) Time() string {
minute := strings.Fields(s.cronStr)[0]
hour := strings.Fields(s.cronStr)[1]
maybeTime := fmt.Sprintf("%s:%s", hour, minute)
t, err := time.ParseInLocation("15:4", maybeTime, s.sched.Location)
if err != nil {
// return the original cronspec for minute and hour, who knows what's in there!
return fmt.Sprintf("cron(%s %s)", minute, hour)
}
return t.Format(time.Kitchen)
}
// DaysOfWeek returns a humanized form of the day-of-week field.
func (s Schedule) DaysOfWeek() string {
dow := strings.Fields(s.cronStr)[4]
if dow == "*" {
return "daily"
}
for _, weekday := range []time.Weekday{
time.Sunday,
time.Monday,
time.Tuesday,
time.Wednesday,
time.Thursday,
time.Friday,
time.Saturday,
} {
dow = strings.Replace(dow, fmt.Sprintf("%d", weekday), weekday.String()[:3], 1)
}
return dow
}
// validateWeeklySpec ensures that the day-of-month and month options of
// spec are both set to *
func validateWeeklySpec(spec string) error {
parts := strings.Fields(spec)
if len(parts) < 5 {
return xerrors.Errorf("expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix")
}
if len(parts) == 6 {
parts = parts[1:]
}
if parts[2] != "*" || parts[3] != "*" {
return xerrors.Errorf("expected day-of-month and month to be *")
}
return nil
}
// validateDailySpec ensures that the day-of-month, month and day-of-week
// options of spec are all set to *
func validateDailySpec(spec string) error {
parts := strings.Fields(spec)
if len(parts) < 5 {
return xerrors.Errorf("expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix")
}
if len(parts) == 6 {
parts = parts[1:]
}
if parts[2] != "*" || parts[3] != "*" || parts[4] != "*" {
return xerrors.Errorf("expected day-of-month, month and day-of-week to be *")
}
return nil
}