daemon/internal/jobs: Add a cron expression parser

Add the five-field cron grammar of schedule triggers: numbers, lists,
ranges and steps.
Names, shortcuts and Quartz extensions are left to clients.
Day-of-month and day-of-week combine following Vixie's rule.

Occurrences follow the wall clock of the job's timezone: a time
skipped by a DST transition does not fire that day, and a repeated
one fires once.
The search is bounded by the 400-year Gregorian cycle, so impossible
dates never fire while sparse ones such as February 29 on a Sunday
still do.
Zone data is embedded, so timezones resolve on hosts without a zoneinfo
database, such as Windows.

Co-authored-by: Paweł Gronowski <pawel.gronowski@docker.com>
Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
This commit is contained in:
Guillaume LoursandPaweł Gronowski authored and Paweł Gronowski committed 2026-11-06 01:43:31 +01:00
1 parent 59d61f0d0a
commit 093882dc71
2 files changed
+469

No files matched your search

+244
View File
@@ -0,0 +1,244 @@
package jobs
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
// Schedules name IANA timezones and must evaluate the same on every
// host, including Windows and minimal images without zoneinfo. The
// system database still takes precedence where it exists.
_ "time/tzdata"
)
// cronSchedule is a parsed five-field crontab expression. Each set is a
// bitmask of the accepted values for its field.
type cronSchedule struct {
minutes uint64 // 0-59
hours uint64 // 0-23
dom uint64 // 1-31, day of month
months uint64 // 1-12
dow uint64 // 0-6, day of week, Sunday is 0
// The day-of-month and day-of-week fields follow the standard crontab
// quirk: when both are restricted (neither is *), a day matching either
// fires; otherwise the restricted one must match.
domRestricted, dowRestricted bool
}
// nextCronBoundYears caps the search for the next occurrence. The Gregorian
// calendar, weekdays included, repeats every 400 years, so an expression
// with no occurrence in that span never fires, as with impossible dates
// such as "0 0 30 2 *"; sparse ones such as "0 0 29 2 */7" (February 29 on
// a Sunday) still find occurrences decades apart. The scan jumps over
// unmatched months and days, so even a full-span search stays in the
// milliseconds.
const nextCronBoundYears = 400
// parseCron parses a five-field crontab expression: minute, hour,
// day-of-month, month, day-of-week. Each field accepts numbers, lists
// (a,b,c), ranges (a-b) and steps (*/n or a-b/n).
//
// The wire format is deliberately strict: month and weekday names, @daily
// style shortcuts, and Quartz extensions (L, #, W) are rejected. Clients
// expand shortcuts before submitting, so the daemon-side grammar can stay
// small and unambiguous.
func parseCron(expr string) (*cronSchedule, error) {
fields := strings.Fields(expr)
if len(fields) != 5 {
return nil, fmt.Errorf("cron expression must have 5 fields (minute hour day-of-month month day-of-week), got %d", len(fields))
}
var (
schedule cronSchedule
err error
)
if schedule.minutes, err = parseCronField(fields[0], 0, 59); err != nil {
return nil, fmt.Errorf("minute field: %w", err)
}
if schedule.hours, err = parseCronField(fields[1], 0, 23); err != nil {
return nil, fmt.Errorf("hour field: %w", err)
}
if schedule.dom, err = parseCronField(fields[2], 1, 31); err != nil {
return nil, fmt.Errorf("day-of-month field: %w", err)
}
if schedule.months, err = parseCronField(fields[3], 1, 12); err != nil {
return nil, fmt.Errorf("month field: %w", err)
}
if schedule.dow, err = parseCronField(fields[4], 0, 7); err != nil {
return nil, fmt.Errorf("day-of-week field: %w", err)
}
// Both 0 and 7 mean Sunday; fold 7 onto 0 so matching only checks 0-6.
if schedule.dow&(1<<7) != 0 {
schedule.dow = (schedule.dow &^ (1 << 7)) | 1
}
// Vixie's first-character rule: a field is "restricted" only when it
// does not start with *, so */2 counts as unrestricted for the AND/OR
// day-combination choice while its mask still applies.
schedule.domRestricted = !strings.HasPrefix(fields[2], "*")
schedule.dowRestricted = !strings.HasPrefix(fields[4], "*")
return &schedule, nil
}
// parseCronField parses one field into a bitmask of accepted values.
func parseCronField(field string, minValue, maxValue int) (uint64, error) {
var mask uint64
for part := range strings.SplitSeq(field, ",") {
rangeExpr, stepExpr, hasStep := strings.Cut(part, "/")
step := 1
if hasStep {
// Steps only make sense over a span; a step on a single value
// ("3/5") is a Vixie laxity this grammar deliberately rejects.
if rangeExpr != "*" && !strings.Contains(rangeExpr, "-") {
return 0, fmt.Errorf("invalid term %q: a step requires * or a range", part)
}
// Atoi tolerates a leading sign; the grammar is digits only.
if stepExpr == "" || stepExpr[0] < '0' || stepExpr[0] > '9' {
return 0, fmt.Errorf("invalid step in term %q", part)
}
parsed, err := strconv.Atoi(stepExpr)
if err != nil || parsed <= 0 {
return 0, fmt.Errorf("invalid step in term %q", part)
}
step = parsed
}
lo, hi := minValue, maxValue
if rangeExpr != "*" {
loExpr, hiExpr, isRange := strings.Cut(rangeExpr, "-")
var err error
if lo, err = parseCronValue(loExpr, minValue, maxValue); err != nil {
return 0, fmt.Errorf("invalid term %q: %w", part, err)
}
hi = lo
if isRange {
if hi, err = parseCronValue(hiExpr, minValue, maxValue); err != nil {
return 0, fmt.Errorf("invalid term %q: %w", part, err)
}
if hi < lo {
return 0, fmt.Errorf("invalid term %q: range is reversed", part)
}
}
}
for v := lo; v <= hi; v += step {
mask |= 1 << v
// Check the remaining span before adding an untrusted step,
// which may be large enough to overflow an int.
if step > hi-v {
break
}
}
}
if mask == 0 {
return 0, errors.New("field is empty")
}
return mask, nil
}
// parseCronValue parses one numeric field value and checks its bounds.
func parseCronValue(expr string, minValue, maxValue int) (int, error) {
// Atoi tolerates a leading sign; the grammar is digits only.
if expr == "" || expr[0] < '0' || expr[0] > '9' {
return 0, fmt.Errorf("%q is not a number (names and shortcuts are not supported)", expr)
}
value, err := strconv.Atoi(expr)
if err != nil {
return 0, fmt.Errorf("%q is not a number (names and shortcuts are not supported)", expr)
}
if value < minValue || value > maxValue {
return 0, fmt.Errorf("value %d is outside %d-%d", value, minValue, maxValue)
}
return value, nil
}
// next returns the first occurrence strictly after the given time, evaluated
// on the wall clock of loc, and false when no occurrence exists within the
// search bound.
//
// Daylight-saving transitions follow wall-clock semantics: an occurrence
// falling in the skipped hour of a spring-forward day does not fire that day
// (the wall-clock time never happens), and an occurrence in the repeated
// hour of a fall-back day fires once, at the first occurrence.
func (s *cronSchedule) next(after time.Time, loc *time.Location) (time.Time, bool) {
t := after.In(loc).Truncate(time.Minute).Add(time.Minute)
bound := after.AddDate(nextCronBoundYears, 0, 0)
for t.Before(bound) {
if s.months&(1<<uint(t.Month())) == 0 {
// Jump to the first minute of the next month.
t = jumpForward(t, time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, loc))
continue
}
if !s.dayMatches(t) {
t = jumpForward(t, time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, loc))
continue
}
if s.hours&(1<<uint(t.Hour())) == 0 {
// Advance in absolute time rather than by rebuilding wall-clock
// components: across a fall-back transition the rebuilt time is
// ambiguous and Go may resolve it to the second occurrence,
// skipping the first one; the absolute walk visits both in real
// order and the fields are re-checked on every loop anyway.
t = t.Add(time.Duration(60-t.Minute()) * time.Minute)
continue
}
if s.minutes&(1<<uint(t.Minute())) == 0 {
t = t.Add(time.Minute)
continue
}
// The first pass through a fall-back day's repeated hour already
// matched this wall-clock time, so it fires only once.
if end, repeated := repeatedWallClock(t); repeated {
t = end
continue
}
return t, true
}
return time.Time{}, false
}
// jumpForward returns target, or the minute after t when target is not after
// it. Where a transition skips midnight, as in America/Havana, time.Date
// normalizes a day's midnight back into the previous day, so jumping to it
// would repeat forever; walking minutes reaches the transition instead.
func jumpForward(t, target time.Time) time.Time {
if target.After(t) {
return target
}
return t.Add(time.Minute)
}
// repeatedWallClock reports whether t's wall-clock time already happened
// earlier, because a transition just set the clock back, and returns when
// the repeated span ends.
func repeatedWallClock(t time.Time) (end time.Time, repeated bool) {
start, _ := t.ZoneBounds()
if start.IsZero() {
return time.Time{}, false
}
_, offset := t.Zone()
_, before := start.Add(-time.Second).Zone()
setBack := time.Duration(before-offset) * time.Second
if setBack <= 0 || t.Sub(start) >= setBack {
return time.Time{}, false
}
return start.Add(setBack), true
}
// dayMatches applies Vixie cron's day-combination rule: both masks always
// apply, combined with OR only when both fields are restricted ("1,15 * Sun"
// means the 1st, the 15th AND Sundays) and with AND otherwise (an
// unrestricted field's mask is full, so it never excludes anything).
func (s *cronSchedule) dayMatches(t time.Time) bool {
domOK := s.dom&(1<<uint(t.Day())) != 0
dowOK := s.dow&(1<<uint(t.Weekday())) != 0
if s.domRestricted && s.dowRestricted {
return domOK || dowOK
}
return domOK && dowOK
}
+225
View File
@@ -0,0 +1,225 @@
package jobs
import (
"fmt"
"math"
"testing"
"time"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
func TestParseCron(t *testing.T) {
for _, tc := range []struct {
doc string
expr string
expectErr string
}{
{doc: "every minute", expr: "* * * * *"},
{doc: "daily at 3am", expr: "0 3 * * *"},
{doc: "lists ranges steps", expr: "*/15 0-6 1,15 */2 1-5"},
{doc: "sunday as 7", expr: "0 12 * * 7"},
{doc: "range with step", expr: "10-50/10 * * * *"},
{doc: "too few fields", expr: "* * * *", expectErr: "must have 5 fields"},
{doc: "too many fields", expr: "* * * * * *", expectErr: "must have 5 fields"},
{doc: "not a number", expr: "a * * * *", expectErr: "not a number"},
{doc: "minute out of range", expr: "60 * * * *", expectErr: "outside 0-59"},
{doc: "hour out of range", expr: "* 24 * * *", expectErr: "outside 0-23"},
{doc: "day-of-month zero", expr: "* * 0 * *", expectErr: "outside 1-31"},
{doc: "month out of range", expr: "* * * 13 *", expectErr: "outside 1-12"},
{doc: "weekday out of range", expr: "* * * * 8", expectErr: "outside 0-7"},
{doc: "reversed range", expr: "30-10 * * * *", expectErr: "range is reversed"},
{doc: "zero step", expr: "*/0 * * * *", expectErr: "invalid step"},
{doc: "step on single value", expr: "3/5 * * * *", expectErr: "a step requires * or a range"},
{doc: "month name", expr: "* * * jan *", expectErr: "not a number"},
{doc: "weekday name", expr: "* * * * mon", expectErr: "not a number"},
{doc: "shortcut", expr: "@daily", expectErr: "must have 5 fields"},
{doc: "double range", expr: "1-2-3 * * * *", expectErr: "not a number"},
{doc: "empty step", expr: "*/ * * * *", expectErr: "invalid step"},
{doc: "step without range", expr: "/5 * * * *", expectErr: "a step requires * or a range"},
{doc: "open-ended range", expr: "5- * * * *", expectErr: "not a number"},
{doc: "open-started range", expr: "-5 * * * *", expectErr: "not a number"},
{doc: "empty list entry", expr: "1,,2 * * * *", expectErr: "not a number"},
{doc: "leading sign", expr: "+5 * * * *", expectErr: "not a number"},
{doc: "leading sign in step", expr: "*/+5 * * * *", expectErr: "invalid step"},
} {
t.Run(tc.doc, func(t *testing.T) {
_, err := parseCron(tc.expr)
if tc.expectErr == "" {
assert.NilError(t, err)
} else {
assert.Check(t, is.ErrorContains(err, tc.expectErr))
}
})
}
}
func TestParseCronLargeStep(t *testing.T) {
t.Parallel()
schedule, err := parseCron(fmt.Sprintf("0 0 */%d * *", math.MaxInt))
assert.NilError(t, err)
assert.Equal(t, schedule.dom, uint64(1<<1))
}
func TestCronNext(t *testing.T) {
utc := func(y int, mo time.Month, d, h, mi int) time.Time {
return time.Date(y, mo, d, h, mi, 0, 0, time.UTC)
}
for _, tc := range []struct {
doc string
expr string
after time.Time
want time.Time
never bool
}{
{
doc: "daily before the hour",
expr: "0 3 * * *", after: utc(2026, time.January, 15, 2, 0),
want: utc(2026, time.January, 15, 3, 0),
},
{
doc: "strictly after an exact hit",
expr: "0 3 * * *", after: utc(2026, time.January, 15, 3, 0),
want: utc(2026, time.January, 16, 3, 0),
},
{
doc: "seconds are truncated away",
expr: "0 3 * * *", after: utc(2026, time.January, 15, 2, 59).Add(30 * time.Second),
want: utc(2026, time.January, 15, 3, 0),
},
{
doc: "quarter-hour step",
expr: "*/15 * * * *", after: utc(2026, time.January, 15, 10, 7),
want: utc(2026, time.January, 15, 10, 15),
},
{
doc: "step wraps to next hour",
expr: "*/15 * * * *", after: utc(2026, time.January, 15, 10, 45),
want: utc(2026, time.January, 15, 11, 0),
},
{
doc: "yearly",
expr: "0 0 1 1 *", after: utc(2026, time.June, 1, 0, 0),
want: utc(2027, time.January, 1, 0, 0),
},
{
doc: "weekday match",
expr: "0 12 * * 0", after: utc(2026, time.January, 15, 0, 0), // a Thursday
want: utc(2026, time.January, 18, 12, 0), // the next Sunday
},
{
doc: "sunday spelled as 7",
expr: "0 12 * * 7", after: utc(2026, time.January, 15, 0, 0),
want: utc(2026, time.January, 18, 12, 0),
},
{
doc: "restricted day-of-month and day-of-week combine as OR",
// The next Friday (March 6th) comes before the next 13th.
expr: "0 0 13 * 5", after: utc(2026, time.March, 1, 0, 0),
want: utc(2026, time.March, 6, 0, 0),
},
{
doc: "star-step day-of-month does not defeat the weekday",
// Vixie's first-character rule: */1 counts as unrestricted, so
// the weekday alone constrains the day.
expr: "0 3 */1 * 1", after: utc(2026, time.January, 15, 0, 0), // a Thursday
want: utc(2026, time.January, 19, 3, 0), // the next Monday
},
{
doc: "star-step day-of-month mask still applies",
// January 16th is even: the next odd day is the 17th.
expr: "0 3 */2 * *", after: utc(2026, time.January, 15, 4, 0),
want: utc(2026, time.January, 17, 3, 0),
},
{
doc: "february 29th waits for a leap year",
expr: "0 0 29 2 *", after: utc(2026, time.January, 1, 0, 0),
want: utc(2028, time.February, 29, 0, 0),
},
{
doc: "sparse weekday-bound february 29th",
// February 29th on a Sunday: after 2032 the next is 2060.
expr: "0 0 29 2 */7", after: utc(2032, time.March, 1, 0, 0),
want: utc(2060, time.February, 29, 0, 0),
},
{
doc: "impossible date never fires",
expr: "0 0 30 2 *", after: utc(2026, time.January, 1, 0, 0),
never: true,
},
} {
t.Run(tc.doc, func(t *testing.T) {
schedule, err := parseCron(tc.expr)
assert.NilError(t, err)
got, ok := schedule.next(tc.after, time.UTC)
if tc.never {
assert.Check(t, !ok)
return
}
assert.Assert(t, ok)
assert.Check(t, is.Equal(got.String(), tc.want.String()))
})
}
}
func TestCronNextDST(t *testing.T) {
paris, err := time.LoadLocation("Europe/Paris")
assert.NilError(t, err)
schedule, err := parseCron("30 2 * * *")
assert.NilError(t, err)
t.Run("spring forward skips the nonexistent hour", func(t *testing.T) {
// On 2026-03-29 the Paris wall clock jumps from 02:00 to 03:00;
// 02:30 never happens that day.
after := time.Date(2026, time.March, 29, 0, 0, 0, 0, paris)
got, ok := schedule.next(after, paris)
assert.Assert(t, ok)
assert.Check(t, is.Equal(got.String(), time.Date(2026, time.March, 30, 2, 30, 0, 0, paris).String()))
})
t.Run("fall back fires once in the repeated hour", func(t *testing.T) {
// On 2026-10-25 the Paris wall clock falls from 03:00 back to
// 02:00: 02:30 happens twice (CEST then CET). The occurrence fires
// on the first one only.
after := time.Date(2026, time.October, 25, 0, 0, 0, 0, paris)
first, ok := schedule.next(after, paris)
assert.Assert(t, ok)
// The first 02:30 is the CEST one, i.e. 00:30 UTC.
assert.Check(t, is.Equal(first.UTC().String(), time.Date(2026, time.October, 25, 0, 30, 0, 0, time.UTC).String()))
// The occurrence after it is the next day's, not the repeated
// (CET, 01:30 UTC) 02:30 of the same day.
second, ok := schedule.next(first, paris)
assert.Assert(t, ok)
assert.Check(t, is.Equal(second.String(), time.Date(2026, time.October, 26, 2, 30, 0, 0, paris).String()))
})
t.Run("fall back does not repeat the hour after a fire on its last minute", func(t *testing.T) {
// The occurrence after 02:59 CEST is a minute later in real time,
// at 02:00 CET, but that wall-clock time already fired an hour ago.
schedule, err := parseCron("0,59 2 * * *")
assert.NilError(t, err)
lastMinute := time.Date(2026, time.October, 25, 0, 59, 0, 0, time.UTC)
got, ok := schedule.next(lastMinute, paris)
assert.Assert(t, ok)
assert.Check(t, is.Equal(got.String(), time.Date(2026, time.October, 26, 2, 0, 0, 0, paris).String()))
})
t.Run("transition skipping midnight does not stall the day search", func(t *testing.T) {
// On 2026-03-08 the Havana wall clock jumps from 00:00 to 01:00, so
// that day's midnight normalizes back into March 7.
havana, err := time.LoadLocation("America/Havana")
assert.NilError(t, err)
schedule, err := parseCron("0 0 * * 1")
assert.NilError(t, err)
after := time.Date(2026, time.March, 7, 12, 0, 0, 0, havana) // a Saturday
got, ok := schedule.next(after, havana)
assert.Assert(t, ok)
assert.Check(t, is.Equal(got.String(), time.Date(2026, time.March, 9, 0, 0, 0, 0, havana).String()))
})
}