Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cron/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ For example:

The prefix "TZ=(TIME ZONE)" is also supported for legacy compatibility.

A time zone prefix is not supported on "@every <duration>" schedules. These fire
at a fixed interval rather than at a wall-clock time, so there is no wall clock
for a time zone to apply to, and such a schedule is rejected at parse time.

Be aware that jobs scheduled during daylight-savings leap-ahead transitions will
not be run!

Expand Down
16 changes: 15 additions & 1 deletion cron/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"strconv"
"strings"
"time"
"unicode"
)

// ParseOption is a configuration option for creating a parser. Most options specify which
Expand Down Expand Up @@ -115,15 +116,28 @@ func (p Parser) Parse(spec string) (Schedule, error) {
if strings.HasPrefix(spec, "TZ=") || strings.HasPrefix(spec, "CRON_TZ=") {
var err error

i := strings.Index(spec, " ")
prefixed := spec

i := strings.IndexFunc(spec, unicode.IsSpace)
eq := strings.Index(spec, "=")

if i == -1 {
return nil, fmt.Errorf("timezone prefix %s is not followed by a schedule", spec)
}

loc, err = time.LoadLocation(spec[eq+1 : i])
if err != nil {
return nil, fmt.Errorf("provided bad location %s: %v", spec[eq+1:i], err)
}

spec = strings.TrimSpace(spec[i:])

// An @every schedule fires at a fixed interval and has no wall clock for
// a timezone to apply to, so reject the pair rather than silently
// dropping the location.
if strings.HasPrefix(spec, "@every ") {
return nil, fmt.Errorf("timezone is not supported for @every schedules: %s", prefixed)
}
Comment on lines 133 to +140
}

// Handle named schedules (descriptors), if configured
Expand Down
56 changes: 56 additions & 0 deletions cron/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ func TestParseScheduleErrors(t *testing.T) {
{"@unrecognized", "unrecognized descriptor"},
{"* * * *", "expected 5 to 6 fields"},
{"", "empty spec string"},
{"TZ=UTC", "not followed by a schedule"},
{"CRON_TZ=Asia/Tokyo", "not followed by a schedule"},
{"TZ=", "not followed by a schedule"},
{"CRON_TZ=Europe/Rome @every 1h", "not supported for @every"},
{"TZ=Asia/Tokyo @every 30m", "not supported for @every"},
}
for _, c := range tests {
actual, err := secondParser.Parse(c.expr)
Expand All @@ -158,6 +163,7 @@ func TestParseScheduleErrors(t *testing.T) {

func TestParseSchedule(t *testing.T) {
tokyo, _ := time.LoadLocation("Asia/Tokyo")
rome, _ := time.LoadLocation("Europe/Rome")
entries := []struct {
parser Parser
expr string
Expand All @@ -168,12 +174,14 @@ func TestParseSchedule(t *testing.T) {
{secondParser, "CRON_TZ=UTC 0 5 * * * *", every5min(time.UTC)},
{standardParser, "CRON_TZ=UTC 5 * * * *", every5min(time.UTC)},
{secondParser, "CRON_TZ=Asia/Tokyo 0 5 * * * *", every5min(tokyo)},
{secondParser, "CRON_TZ=Asia/Tokyo\t0 5 * * * *", every5min(tokyo)},
{secondParser, "@every 5m", ConstantDelaySchedule{5 * time.Minute}},
{secondParser, "@every 5ms", ConstantDelaySchedule{5 * time.Millisecond}},
{secondParser, "@every 5ns", ConstantDelaySchedule{5 * time.Nanosecond}},
{secondParser, "@midnight", midnight(time.Local)}, //nolint:gosmopolitan
{secondParser, "TZ=UTC @midnight", midnight(time.UTC)},
{secondParser, "TZ=Asia/Tokyo @midnight", midnight(tokyo)},
{secondParser, "CRON_TZ=Europe/Rome @daily", midnight(rome)},
{secondParser, "@yearly", annual(time.Local)}, //nolint:gosmopolitan
{secondParser, "@annually", annual(time.Local)}, //nolint:gosmopolitan
{
Expand Down Expand Up @@ -409,3 +417,51 @@ func annual(loc *time.Location) *SpecSchedule {
Location: loc,
}
}

// A timezone-prefixed schedule holds its local wall clock across a DST
// transition, shifting the underlying UTC instant by itself.
func TestParseScheduleTimezoneDST(t *testing.T) {
rome, err := time.LoadLocation("Europe/Rome")
if err != nil {
t.Fatalf("loading Europe/Rome: %v", err)
}

sched, err := secondParser.Parse("CRON_TZ=Europe/Rome 0 0 9 * * *")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

// Europe/Rome leaves DST on 2026-10-25.
next := time.Date(2026, 10, 23, 12, 0, 0, 0, rome)
for _, exp := range []struct{ local, utc string }{
{"2026-10-24T09:00:00+02:00", "2026-10-24T07:00:00Z"},
{"2026-10-25T09:00:00+01:00", "2026-10-25T08:00:00Z"},
{"2026-10-26T09:00:00+01:00", "2026-10-26T08:00:00Z"},
} {
next = sched.Next(next)
if got := next.In(rome).Format(time.RFC3339); got != exp.local {
t.Errorf("local => expected %s, got %s", exp.local, got)
}

if got := next.UTC().Format(time.RFC3339); got != exp.utc {
t.Errorf("utc => expected %s, got %s", exp.utc, got)
}
}
}

// A constant delay has no wall clock for a timezone to apply to, so the pair is
// rejected rather than silently dropping the location.
func TestParseScheduleTimezoneRejectedForEvery(t *testing.T) {
_, err := secondParser.Parse("CRON_TZ=Europe/Rome @every 1h")
if err == nil {
t.Error("expected an error, got nil")
} else if !strings.Contains(err.Error(), "CRON_TZ=Europe/Rome @every 1h") {
t.Errorf("error should include the original spec, got: %v", err)
}

// An unprefixed @every is still valid.
_, err = secondParser.Parse("@every 1h")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
Loading