Skip to content

Commit 49bfd84

Browse files
committed
fix(cron): handle Daylight Saving Time transitions
1 parent 3e02985 commit 49bfd84

File tree

2 files changed

+78
-20
lines changed

2 files changed

+78
-20
lines changed

quartz/cron.go

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ import (
3434
type CronTrigger struct {
3535
expression string
3636
fields []*cronField
37-
lastDefined int
3837
location *time.Location
38+
lastDefined int
39+
tzPasses int
3940
}
4041

4142
// Verify CronTrigger satisfies the Trigger interface.
@@ -67,24 +68,39 @@ func NewCronTriggerWithLoc(expression string, location *time.Location) (*CronTri
6768
fields[0].values, _ = fillRangeValues(0, 59)
6869
}
6970

71+
// tzPasses determines the number of iterations required when calculating the next
72+
// fire time. Two iterations are used for time zones with Daylight Saving Time
73+
// (DST) to resolve ambiguities caused by clock adjustments. UTC requires only
74+
// one iteration as it's unaffected by DST.
75+
tzPasses := 2
76+
if location == time.UTC {
77+
tzPasses = 1
78+
}
79+
7080
return &CronTrigger{
7181
expression: expression,
7282
fields: fields,
73-
lastDefined: lastDefined,
7483
location: location,
84+
lastDefined: lastDefined,
85+
tzPasses: tzPasses,
7586
}, nil
7687
}
7788

7889
// NextFireTime returns the next time at which the CronTrigger is scheduled to fire.
7990
func (ct *CronTrigger) NextFireTime(prev int64) (int64, error) {
8091
prevTime := time.Unix(prev/int64(time.Second), 0).In(ct.location)
81-
// build a CronStateMachine and run once
92+
// Initialize a CronStateMachine from the previous fire time and cron fields.
8293
csm := newCSMFromFields(prevTime, ct.fields)
83-
nextDateTime := csm.NextTriggerTime(prevTime.Location())
84-
if nextDateTime.Before(prevTime) || nextDateTime.Equal(prevTime) {
85-
return 0, ErrTriggerExpired
94+
95+
// Iterate ct.tzPasses times to determine the correct next scheduled fire time.
96+
// This accounts for complexities like Daylight Saving Time (DST) transitions.
97+
for i := 0; i < ct.tzPasses; i++ {
98+
nextDateTime := csm.NextTriggerTime(ct.location)
99+
if nextDateTime.After(prevTime) {
100+
return nextDateTime.UnixNano(), nil
101+
}
86102
}
87-
return nextDateTime.UnixNano(), nil
103+
return 0, ErrTriggerExpired
88104
}
89105

90106
// Description returns the description of the cron trigger.

quartz/cron_test.go

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func TestCronExpression(t *testing.T) {
9393
t.Parallel()
9494
cronTrigger, err := quartz.NewCronTrigger(test.expression)
9595
assert.IsNil(t, err)
96-
result, _ := iterate(prev, cronTrigger, 50)
96+
result, _ := iterate(prev, cronTrigger, time.UTC, 50)
9797
assert.Equal(t, result, test.expected)
9898
})
9999
}
@@ -110,30 +110,67 @@ func TestCronExpressionExpired(t *testing.T) {
110110

111111
func TestCronExpressionWithLoc(t *testing.T) {
112112
t.Parallel()
113+
loc, err := time.LoadLocation("America/New_York")
114+
assert.IsNil(t, err)
115+
113116
tests := []struct {
114117
expression string
115118
expected string
119+
prev time.Time
120+
iterations int
116121
}{
117122
{
118123
expression: "0 5 22-23 * * Sun *",
119-
expected: "Mon Oct 16 03:05:00 2023",
124+
expected: "Sun Oct 15 23:05:00 2023",
125+
prev: time.Date(2023, 4, 29, 12, 00, 00, 00, loc),
126+
iterations: 50,
120127
},
121128
{
122129
expression: "0 0 10 * * Sun *",
123-
expected: "Sun Apr 7 14:00:00 2024",
130+
expected: "Sun Apr 7 10:00:00 2024",
131+
prev: time.Date(2023, 4, 29, 12, 00, 00, 00, loc),
132+
iterations: 50,
133+
},
134+
// Daylight Saving Time (DST) transition tests (spring forward, fall back)
135+
{
136+
expression: "0 0 2 9 3 ?",
137+
expected: "Sun Mar 9 01:00:00 2025", // 2 AM doesn't exist, triggers at 1 AM instead
138+
prev: time.Date(2025, 1, 9, 1, 00, 00, 00, loc),
139+
iterations: 1,
140+
},
141+
{
142+
expression: "0 0 2 * * *",
143+
expected: "Mon Mar 10 02:00:00 2025",
144+
prev: time.Date(2025, 3, 9, 1, 00, 00, 00, loc),
145+
iterations: 1,
146+
},
147+
{
148+
expression: "0 0 * * * ?",
149+
expected: "Sun Mar 9 03:00:00 2025",
150+
prev: time.Date(2025, 3, 9, 1, 30, 00, 00, loc),
151+
iterations: 1,
152+
},
153+
{
154+
expression: "0 30 1 * * ?",
155+
expected: "Mon Nov 3 01:30:00 2025",
156+
prev: time.Date(2025, 11, 2, 1, 30, 00, 00, loc),
157+
iterations: 1,
158+
},
159+
{
160+
expression: "0 30 1 * * ?",
161+
expected: "Sun Nov 2 01:30:00 2025",
162+
prev: time.Date(2025, 11, 2, 1, 00, 00, 00, loc),
163+
iterations: 1,
124164
},
125165
}
126166

127-
loc, err := time.LoadLocation("America/New_York")
128-
assert.IsNil(t, err)
129-
prev := time.Date(2023, 4, 29, 12, 00, 00, 00, loc).UnixNano()
130167
for _, tt := range tests {
131168
test := tt
132169
t.Run(test.expression, func(t *testing.T) {
133170
t.Parallel()
134171
cronTrigger, err := quartz.NewCronTriggerWithLoc(test.expression, loc)
135172
assert.IsNil(t, err)
136-
result, _ := iterate(prev, cronTrigger, 50)
173+
result, _ := iterate(test.prev.UnixNano(), cronTrigger, loc, test.iterations)
137174
assert.Equal(t, result, test.expected)
138175
})
139176
}
@@ -232,7 +269,7 @@ func TestCronExpressionSpecial(t *testing.T) {
232269
t.Parallel()
233270
cronTrigger, err := quartz.NewCronTrigger(test.expression)
234271
assert.IsNil(t, err)
235-
result, _ := iterate(prev, cronTrigger, 100)
272+
result, _ := iterate(prev, cronTrigger, time.UTC, 100)
236273
assert.Equal(t, result, test.expected)
237274
})
238275
}
@@ -277,7 +314,7 @@ func TestCronExpressionDayOfMonth(t *testing.T) {
277314
t.Parallel()
278315
cronTrigger, err := quartz.NewCronTrigger(test.expression)
279316
assert.IsNil(t, err)
280-
result, _ := iterate(prev, cronTrigger, 15)
317+
result, _ := iterate(prev, cronTrigger, time.UTC, 15)
281318
assert.Equal(t, result, test.expected)
282319
})
283320
}
@@ -334,7 +371,7 @@ func TestCronExpressionDayOfWeek(t *testing.T) {
334371
t.Parallel()
335372
cronTrigger, err := quartz.NewCronTrigger(test.expression)
336373
assert.IsNil(t, err)
337-
result, _ := iterate(prev, cronTrigger, 10)
374+
result, _ := iterate(prev, cronTrigger, time.UTC, 10)
338375
assert.Equal(t, result, test.expected)
339376
})
340377
}
@@ -383,17 +420,22 @@ func TestCronExpressionTrim(t *testing.T) {
383420

384421
const readDateLayout = "Mon Jan 2 15:04:05 2006"
385422

386-
func iterate(prev int64, cronTrigger *quartz.CronTrigger, iterations int) (string, error) {
423+
func formatTime(t int64, loc *time.Location) string {
424+
return time.Unix(t/int64(time.Second), 0).In(loc).Format(readDateLayout)
425+
}
426+
427+
func iterate(prev int64, cronTrigger *quartz.CronTrigger, loc *time.Location,
428+
iterations int) (string, error) {
387429
var err error
388430
for i := 0; i < iterations; i++ {
389431
prev, err = cronTrigger.NextFireTime(prev)
390-
// log.Print(time.Unix(prev/int64(time.Second), 0).UTC().Format(readDateLayout))
432+
// log.Print(formatTime(prev, loc))
391433
if err != nil {
392434
fmt.Println(err)
393435
return "", err
394436
}
395437
}
396-
return time.Unix(prev/int64(time.Second), 0).UTC().Format(readDateLayout), nil
438+
return formatTime(prev, loc), nil
397439
}
398440

399441
func TestCronExpressionParseError(t *testing.T) {

0 commit comments

Comments
 (0)