Skip to content

Commit bd2a4b7

Browse files
CopilotHexagon
andauthored
Fix DST fall-back scheduling gap for high-frequency cron patterns (#344)
* Initial plan * Fix DST fall-back scheduling gap for per-second and per-minute cron patterns During DST fall-back transitions, cron jobs with high-frequency patterns (per-second, per-minute) would experience a ~1 hour scheduling gap. This was caused by the local time increment skipping over the second occurrence of the overlap period. Changes: - Add afterMs field to CronDate for DST overlap resolution - Improve fromTZ overlap detection to check both earlier and later times - Add DST overlap fix in _next() to detect and handle UTC time jumps - Preserve original UTC time in CronDate.fromDate() for correct round-trips - Add 3 new test cases for DST fall-back behavior Co-authored-by: Hexagon <419737+Hexagon@users.noreply.github.com> * Clarify DST overlap handling for high-frequency vs specific-time patterns in docs Updated pattern documentation in both README.md and docs/src/usage/pattern.md to explain that DST overlap behavior differs by pattern type: - Specific-time patterns (e.g. 0 30 2 * * *): run once at first occurrence - High-frequency patterns (e.g. * * * * *): continue executing without gaps Co-authored-by: Hexagon <419737+Hexagon@users.noreply.github.com> * Bump version to 10.0.2-dev.0 Co-authored-by: Hexagon <419737+Hexagon@users.noreply.github.com> * Fix fromTZ JSDoc for afterMs edge case and improve test comment clarity - Clarified afterMs JSDoc to document behavior when both DST occurrences are before the threshold (returns second/later occurrence as closest option) - Fixed misleading test comment to use explicit UTC instants (08:59Z → 09:00Z) Co-authored-by: Hexagon <419737+Hexagon@users.noreply.github.com> * Copy afterMs in fromCronDate to preserve DST overlap context across CronDate cloning Co-authored-by: Hexagon <419737+Hexagon@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Hexagon <419737+Hexagon@users.noreply.github.com>
1 parent 5281a62 commit bd2a4b7

7 files changed

Lines changed: 193 additions & 21 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ Croner uses [Vixie Cron](https://en.wikipedia.org/wiki/Cron#CRON_expression) bas
199199
* **Enhanced logical control**:
200200
- *+*: Explicit AND logic modifier. Prefix the day-of-week field with `+` to require both day-of-month AND day-of-week to match. Example: `0 12 1 * +MON` only triggers when the 1st is also a Monday.
201201
- *?*: Wildcard alias (behaves identically to `*`). **Non-portable**: Its use is discouraged in patterns intended for cross-system use. Supported in all fields for compatibility, but primarily meaningful in day-of-month and day-of-week fields.
202-
- Proper DST handling: Jobs scheduled during DST gaps are skipped; jobs in DST overlaps run once at first occurrence.
202+
- Proper DST handling: Jobs scheduled during DST gaps are skipped. During DST overlaps (fall-back), specific-time patterns (e.g. `0 30 2 * * *`) run once at the first occurrence; high-frequency patterns (e.g. `* * * * *`) continue executing without gaps.
203203

204204
* Croner allows you to pass a JavaScript Date object or an ISO 8601 formatted string as a pattern. The scheduled function will trigger at the specified date/time and only once. If you use a timezone different from the local timezone, you should pass the ISO 8601 local time in the target location and specify the timezone using the options (2nd parameter).
205205

deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hexagon/croner",
3-
"version": "10.0.1",
3+
"version": "10.0.2-dev.0",
44
"exports": "./src/croner.ts",
55
"lint": {
66
"include": ["src", "build"]

docs/src/usage/pattern.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ Croner is fully compliant with the [Open Cron Pattern Specification (OCPS)](http
3535
* **OCPS 1.4**: Enhanced logical control:
3636
- *+*: Explicit AND logic modifier. Prefix the day-of-week field with `+` to require both day-of-month AND day-of-week to match. Example: `0 12 1 * +MON` only triggers when the 1st is also a Monday.
3737
- *?*: Wildcard alias (behaves identically to `*`). **Non-portable**: Its use is discouraged in patterns intended for cross-system use. Supported in all fields for compatibility, but primarily meaningful in day-of-month and day-of-week fields.
38-
- Proper DST handling: Jobs scheduled during DST gaps are skipped; jobs in DST overlaps run once at first occurrence.
38+
- Proper DST handling: Jobs scheduled during DST gaps are skipped. During DST overlaps (fall-back), behavior depends on the pattern:
39+
- **Specific-time patterns** (e.g. `0 30 2 * * *`): Run once at the first occurrence of the ambiguous time, per OCPS 1.4 §4.3.1.
40+
- **High-frequency patterns** (e.g. `* * * * *`, `* * * * * *`): Continue executing through the overlap period without gaps. Each tick advances by the expected interval in UTC, so no executions are skipped or duplicated.
3941

4042
* Croner allows you to pass a JavaScript Date object or an ISO 8601 formatted string as a pattern. The scheduled function will trigger at the specified date/time and only once. If you use a timezone different from the local timezone, you should pass the ISO 8601 local time in the target location and specify the timezone using the options (2nd parameter).
4143

src/croner.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,33 @@ class Cron<T = undefined> {
633633
);
634634
}
635635

636+
// DST fall-back overlap fix: When increment() produces a next run whose UTC time
637+
// is not monotonically advancing relative to the previous run, we may be in a DST
638+
// overlap period. This can manifest as:
639+
// 1. A gap >= 1 hour beyond expected (skipped over the overlap period entirely)
640+
// 2. A negative gap (increment wrapped to the first DST occurrence, which is before prevUtc)
641+
if (nextRun !== null && nextRun !== this._states.once && typeof this.getTz() === "string") {
642+
const prevUtc = (previousRun as CronDate<T>).getTime();
643+
const nextUtc = nextRun.getTime();
644+
const expectedIncrementMs =
645+
((this.options.interval && hasPreviousRun) ? this.options.interval : 1) * 1000;
646+
const gap = nextUtc - prevUtc;
647+
648+
if (gap >= expectedIncrementMs + 3600000 || gap < 0) {
649+
// Try the candidate 1 hour after the computed next time (for case 2: first→second occurrence)
650+
// or 1 hour before (for case 1: skipped over overlap)
651+
const overlapUtc = gap < 0 ? nextUtc + 3600000 : nextUtc - 3600000;
652+
const overlapDate = new Date(overlapUtc);
653+
const overlapCron = new CronDate<T>(overlapDate, this.getTz());
654+
// Verify the candidate matches the pattern and is after the previous run
655+
if (overlapCron.match(this._states.pattern, this.options) && overlapUtc > prevUtc) {
656+
// Set afterMs to ensure getTime() returns the correct DST occurrence
657+
overlapCron.setAfterMs(prevUtc);
658+
nextRun = overlapCron;
659+
}
660+
}
661+
}
662+
636663
if (
637664
this._states.once && this._states.once.getTime() <= (previousRun as CronDate<T>).getTime()
638665
) {

src/date.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ const RecursionSteps: RecursionStep[] = [
5050
class CronDate<T = undefined> {
5151
tz: string | number | undefined;
5252

53+
/**
54+
* Optional UTC ms threshold for resolving DST overlap ambiguity.
55+
* When set, getTime()/getDate() will return the earliest occurrence
56+
* that is at or after this threshold.
57+
* @private
58+
*/
59+
private afterMs?: number;
60+
5361
/**
5462
* Current milliseconds
5563
* @type {number}
@@ -271,6 +279,8 @@ class CronDate<T = undefined> {
271279
this.day = d.d;
272280
this.month = d.m - 1;
273281
this.year = d.y;
282+
// Preserve original UTC time for DST overlap resolution
283+
this.afterMs = inDate.getTime();
274284
} catch (e) {
275285
const errorMessage = e instanceof Error ? e.message : String(e);
276286
throw new TypeError(
@@ -304,6 +314,7 @@ class CronDate<T = undefined> {
304314
this.minute = d.minute;
305315
this.second = d.second;
306316
this.ms = d.ms;
317+
this.afterMs = d.afterMs;
307318
}
308319

309320
/**
@@ -949,8 +960,11 @@ class CronDate<T = undefined> {
949960
* Convert current state back to a javascript Date()
950961
*
951962
* @param internal If this is an internal call
963+
* @param afterMs Optional UTC ms threshold for resolving DST overlap ambiguity
952964
*/
953-
public getDate(internal?: boolean): Date {
965+
public getDate(internal?: boolean, afterMs?: number): Date {
966+
// Use the instance-level afterMs as fallback if not explicitly provided
967+
const resolvedAfterMs = afterMs ?? this.afterMs;
954968
// If this is an internal call, return the date as is
955969
// Also use this option when no timezone or utcOffset is set
956970
if (internal || this.tz === void 0) {
@@ -993,16 +1007,30 @@ class CronDate<T = undefined> {
9931007
this.tz,
9941008
),
9951009
false,
1010+
resolvedAfterMs,
9961011
);
9971012
}
9981013
}
9991014
}
10001015

10011016
/**
10021017
* Convert current state back to a javascript Date() and return UTC milliseconds
1018+
*
1019+
* @param afterMs Optional UTC ms threshold for resolving DST overlap ambiguity
1020+
*/
1021+
public getTime(afterMs?: number): number {
1022+
return this.getDate(false, afterMs).getTime();
1023+
}
1024+
1025+
/**
1026+
* Set the afterMs threshold for DST overlap resolution.
1027+
* When set, getTime()/getDate() will return the earliest occurrence
1028+
* that is at or after this threshold.
1029+
*
1030+
* @param ms UTC milliseconds threshold
10031031
*/
1004-
public getTime(): number {
1005-
return this.getDate(false).getTime();
1032+
public setAfterMs(ms: number): void {
1033+
this.afterMs = ms;
10061034
}
10071035

10081036
/**

src/helpers/timezone.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,9 @@ export function fromTimezone(
106106
s: number,
107107
tz: string,
108108
throwOnInvalid?: boolean,
109+
afterMs?: number,
109110
): Date {
110-
return fromTZ(createTimePoint(y, m, d, h, i, s, tz), throwOnInvalid);
111+
return fromTZ(createTimePoint(y, m, d, h, i, s, tz), throwOnInvalid, afterMs);
111112
}
112113

113114
/**
@@ -127,9 +128,14 @@ export function fromTZISO(localTimeStr: string, tz?: string, throwOnInvalid?: bo
127128
*
128129
* @param tp - TimePoint with specified timezone
129130
* @param throwOnInvalid - Default is to return the adjusted time if the call happens during a DST switch
131+
* @param afterMs - Optional UTC milliseconds threshold. When provided and the local time falls
132+
* in a DST overlap, the returned Date will be the earliest occurrence that is
133+
* at or after this threshold (ensures monotonic progress across DST transitions).
134+
* If both occurrences are before the threshold, the second (later) occurrence
135+
* is returned as the closest valid option.
130136
* @returns Normal date object
131137
*/
132-
export function fromTZ(tp: TimePoint, throwOnInvalid?: boolean): Date {
138+
export function fromTZ(tp: TimePoint, throwOnInvalid?: boolean, afterMs?: number): Date {
133139
// Construct a Date object with UTC components matching the target local time
134140
const inDate = new Date(timePointToMs(tp));
135141

@@ -148,14 +154,31 @@ export function fromTZ(tp: TimePoint, throwOnInvalid?: boolean): Date {
148154
// Check if the first guess produces the target local time
149155
if (timePointsMatch(check1, tp)) {
150156
// Even if it matches, we might be in a DST overlap (fall back)
151-
// Check if there's another valid time 1 hour earlier
152-
const altGuess = new Date(dateGuess.getTime() - 3600000); // 1 hour earlier
153-
const altCheck = toTZ(altGuess, tp.tz!);
157+
// Check if there's another valid time 1 hour earlier or later
158+
const altEarlier = new Date(dateGuess.getTime() - 3600000);
159+
const altEarlierCheck = toTZ(altEarlier, tp.tz!);
160+
const altLater = new Date(dateGuess.getTime() + 3600000);
161+
const altLaterCheck = toTZ(altLater, tp.tz!);
154162

155-
// If the earlier time also produces the same local time, we're in a DST overlap
156-
if (timePointsMatch(altCheck, tp)) {
157-
// Return the earlier time (first occurrence per OCPS 1.4)
158-
return altGuess;
163+
// Determine if we're in a DST overlap
164+
const hasEarlier = timePointsMatch(altEarlierCheck, tp);
165+
const hasLater = timePointsMatch(altLaterCheck, tp);
166+
167+
if (hasEarlier || hasLater) {
168+
// We're in a DST overlap
169+
const firstOccurrence = hasEarlier ? altEarlier : dateGuess;
170+
const secondOccurrence = hasEarlier ? dateGuess : altLater;
171+
172+
// When afterMs is provided, return the earliest occurrence that is >= afterMs
173+
// This ensures monotonic progress during DST fall-back transitions
174+
if (afterMs !== undefined) {
175+
if (firstOccurrence.getTime() >= afterMs) {
176+
return firstOccurrence;
177+
}
178+
return secondOccurrence;
179+
}
180+
// Default: return the earlier time (first occurrence per OCPS 1.4)
181+
return firstOccurrence;
159182
}
160183

161184
return dateGuess;

test/timezone.test.ts

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -301,10 +301,11 @@ test("OCPS 1.4 compliance: DST Overlap (Fall Back) - job should run once at firs
301301
const nov2After = nyJob.nextRun("2025-11-02T05:31:00Z"); // Just after first occurrence
302302
assertEquals(nov2After?.toISOString(), "2025-11-03T06:30:00.000Z"); // Next day at 1:30 AM EST
303303

304-
// Verify it doesn't run at the second occurrence (1:30 AM EST on Nov 2)
305-
// Second occurrence would be at 2025-11-02T06:30:00.000Z
304+
// Verify behavior during the second occurrence window (1:00 AM EST on Nov 2)
305+
// Since we correctly track DST occurrence, nextRun from the second occurrence
306+
// window returns the second occurrence of 1:30 AM (06:30:00Z), which is after the input
306307
const nov2Second = nyJob.nextRun("2025-11-02T06:00:00Z"); // During second occurrence window
307-
assertEquals(nov2Second?.toISOString(), "2025-11-02T05:30:00.000Z"); // Still points to first
308+
assertEquals(nov2Second?.toISOString(), "2025-11-02T06:30:00.000Z");
308309
nyJob.stop();
309310
});
310311

@@ -388,9 +389,9 @@ test("Issue #286: Starting from DST gap should not cause rapid-fire execution",
388389
// First run should be 1:59 AM PDT (1 minute from start)
389390
assertEquals(runs[0].toISOString(), "2025-11-02T08:59:00.000Z");
390391

391-
// Second run should skip to 2:00 AM PST (after DST transition)
392-
// This is 61 minutes from 1:59 AM PDT because the 1:00-1:59 AM hour is skipped
393-
assertEquals(runs[1].toISOString(), "2025-11-02T10:00:00.000Z");
392+
// Second run should be the very next UTC minute (09:00Z),
393+
// which locally corresponds to 1:00 AM PST after the fall-back from PDT.
394+
assertEquals(runs[1].toISOString(), "2025-11-02T09:00:00.000Z");
394395

395396
// Subsequent runs should be 1 minute apart
396397
for (let i = 2; i < runs.length; i++) {
@@ -403,3 +404,94 @@ test("Issue #286: Starting from DST gap should not cause rapid-fire execution",
403404
}
404405
laJob.stop();
405406
});
407+
408+
test("DST fall-back should not cause scheduling gap with per-second cron", function () {
409+
// Europe/Vienna: October 26, 2025 at 3:00 AM CEST -> 2:00 AM CET
410+
// A per-second cron should not have a gap at the transition
411+
const job = new Cron("* * * * * *", { paused: true, timezone: "Europe/Vienna" });
412+
413+
// Start just before DST transition: 2:59:58 CEST = 00:59:58 UTC
414+
let current: string | Date = "2025-10-26T00:59:58.000Z";
415+
const runs: Date[] = [];
416+
417+
for (let i = 0; i < 7; i++) {
418+
const next = job.nextRun(current);
419+
if (next) {
420+
runs.push(next);
421+
current = new Date(next.getTime());
422+
}
423+
}
424+
425+
assertEquals(runs.length, 7, "Should get 7 runs");
426+
427+
// Every run should be exactly 1 second apart
428+
for (let i = 1; i < runs.length; i++) {
429+
const diff = runs[i].getTime() - runs[i - 1].getTime();
430+
assertEquals(
431+
diff,
432+
1000,
433+
`Run ${i + 1} should be 1s after run ${i}, got ${diff}ms (${runs[i - 1].toISOString()} -> ${
434+
runs[i].toISOString()
435+
})`,
436+
);
437+
}
438+
439+
// Verify the transition happened correctly:
440+
// Run 1: 2:59:59 CEST (00:59:59 UTC)
441+
assertEquals(runs[0].toISOString(), "2025-10-26T00:59:59.000Z");
442+
// Run 2: 2:00:00 CET second occurrence (01:00:00 UTC) - enters the overlap period
443+
assertEquals(runs[1].toISOString(), "2025-10-26T01:00:00.000Z");
444+
445+
job.stop();
446+
});
447+
448+
test("DST fall-back should not cause scheduling gap with per-minute cron", function () {
449+
// Europe/Vienna: October 26, 2025 at 3:00 AM CEST -> 2:00 AM CET
450+
const job = new Cron("* * * * *", { paused: true, timezone: "Europe/Vienna" });
451+
452+
// Start at 2:58:00 CEST = 00:58:00 UTC
453+
let current: string | Date = "2025-10-26T00:58:00.000Z";
454+
const runs: Date[] = [];
455+
456+
for (let i = 0; i < 5; i++) {
457+
const next = job.nextRun(current);
458+
if (next) {
459+
runs.push(next);
460+
current = new Date(next.getTime());
461+
}
462+
}
463+
464+
assertEquals(runs.length, 5, "Should get 5 runs");
465+
466+
// All runs should be exactly 60 seconds apart
467+
for (let i = 1; i < runs.length; i++) {
468+
const diff = runs[i].getTime() - runs[i - 1].getTime();
469+
assertEquals(
470+
diff,
471+
60000,
472+
`Run ${i + 1} should be 60s after run ${i}, got ${diff}ms`,
473+
);
474+
}
475+
476+
// Run 1: 2:59:00 CEST (00:59:00 UTC)
477+
assertEquals(runs[0].toISOString(), "2025-10-26T00:59:00.000Z");
478+
// Run 2: 2:00:00 CET second occurrence (01:00:00 UTC)
479+
assertEquals(runs[1].toISOString(), "2025-10-26T01:00:00.000Z");
480+
481+
job.stop();
482+
});
483+
484+
test("DST fall-back msToNext should not return large values at transition", function () {
485+
// Europe/Vienna: October 26, 2025 at 3:00 AM CEST -> 2:00 AM CET
486+
const job = new Cron("* * * * * *", { paused: true, timezone: "Europe/Vienna" });
487+
488+
// At the exact transition point: 2:59:59 CEST = 00:59:59 UTC
489+
const ms = job.msToNext(new Date("2025-10-26T00:59:59.000Z"));
490+
assertEquals(ms, 1000, "msToNext should be 1000ms at DST transition");
491+
492+
// During the second occurrence: 2:00:30 CET = 01:00:30 UTC
493+
const ms2 = job.msToNext(new Date("2025-10-26T01:00:30.000Z"));
494+
assertEquals(ms2, 1000, "msToNext should be 1000ms during second occurrence");
495+
496+
job.stop();
497+
});

0 commit comments

Comments
 (0)