mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(core): compute cron next-fire time with epoch arithmetic across DST fall-back (#11723)
nextFireTime advanced its minute-by-minute scan with local Date setters (setSeconds / setMinutes). ECMA-262 resolves an ambiguous fall-back wall-clock to its earlier occurrence, so an anchor inside the repeated autumn hour was re-projected up to 59 minutes into the past, breaking the strictly-after contract, and a forward scan skipped the entire second occurrence of the hour. Traverse elapsed time instead: initialise the candidate with epoch arithmetic (floor(after/60000)*60000 + 60000) and advance with candidate.setTime(candidate.getTime() + 60000), while continuing to match cron fields with local getters. Cron syntax, jitter policy, and other scheduler behavior are unchanged. Fixes the root cause reported in #11720. Patrol-Run: qwen-issue-patrol/jmtygitr20b Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
1331c7af85
commit
b5c7635ff9
2 files changed
+73
-6
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { matches, nextFireTime, parseCron } from './cronParser.js';
|
||||
|
||||
describe('parseCron', () => {
|
||||
@@ -227,3 +227,67 @@ describe('nextFireTime', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextFireTime across DST transitions', () => {
|
||||
// America/New_York spring forward 2026-03-08 02:00, fall back 2026-11-01 02:00.
|
||||
// The repeated local hour 01:00–01:59 on fall-back day exists twice
|
||||
// (EDT = UTC-4 first, EST = UTC-5 second). Local Date setters resolve the
|
||||
// ambiguous wall-clock to the earlier occurrence, which used to re-project
|
||||
// a live instant into the past (see issue #11720).
|
||||
const originalTz = process.env.TZ;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.TZ = 'America/New_York';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalTz === undefined) {
|
||||
delete process.env.TZ;
|
||||
} else {
|
||||
process.env.TZ = originalTz;
|
||||
}
|
||||
});
|
||||
|
||||
it('returns a strictly-later instant for an anchor inside the repeated hour', () => {
|
||||
// 06:30:15Z is the second 01:30 (EST). The old local-setter scan snapped
|
||||
// back to 05:31Z, 59 minutes before the input.
|
||||
const after = new Date('2026-11-01T06:30:15.000Z');
|
||||
const next = nextFireTime('* * * * *', after);
|
||||
expect(next.toISOString()).toBe('2026-11-01T06:31:00.000Z');
|
||||
expect(next.getTime()).toBeGreaterThan(after.getTime());
|
||||
});
|
||||
|
||||
it('reaches the second occurrence of the repeated hour when scanning forward', () => {
|
||||
// 05:59Z is the first 01:59 (EDT). Local setMinutes(60) built local
|
||||
// 02:00 (EST only) and jumped straight to 07:00Z, skipping 06:00Z.
|
||||
const after = new Date('2026-11-01T05:59:00.000Z');
|
||||
const next = nextFireTime('* * * * *', after);
|
||||
expect(next.toISOString()).toBe('2026-11-01T06:00:00.000Z');
|
||||
});
|
||||
|
||||
it('fires a fixed-hour job in both occurrences of the repeated hour', () => {
|
||||
// Anchored at the first 01:30 (05:30Z), the second 01:30 (06:30Z) must
|
||||
// still be reachable rather than skipped to the next day.
|
||||
const after = new Date('2026-11-01T05:30:00.000Z');
|
||||
const next = nextFireTime('30 1 * * *', after);
|
||||
expect(next.toISOString()).toBe('2026-11-01T06:30:00.000Z');
|
||||
});
|
||||
|
||||
it('advances across the spring-forward gap', () => {
|
||||
// 06:59Z is 01:59 EST; 02:00–02:59 does not exist that morning, so the
|
||||
// next real minute is 03:00 EDT (07:00Z).
|
||||
const after = new Date('2026-03-08T06:59:00.000Z');
|
||||
const next = nextFireTime('* * * * *', after);
|
||||
expect(next.toISOString()).toBe('2026-03-08T07:00:00.000Z');
|
||||
expect(next.getTime()).toBeGreaterThan(after.getTime());
|
||||
});
|
||||
|
||||
it('returns a strictly-later instant when anchored exactly on a repeated-hour match', () => {
|
||||
// 06:00Z is exactly the second 01:00 (EST). The old scan re-materialised
|
||||
// 01:01 as EDT (05:01Z), one minute before the input.
|
||||
const after = new Date('2026-11-01T06:00:00.000Z');
|
||||
const next = nextFireTime('* * * * *', after);
|
||||
expect(next.toISOString()).toBe('2026-11-01T06:01:00.000Z');
|
||||
expect(next.getTime()).toBeGreaterThan(after.getTime());
|
||||
});
|
||||
});
|
||||
@@ -170,10 +170,13 @@ export function matches(cronExpr: string, date: Date): boolean {
|
||||
export function nextFireTime(cronExpr: string, after: Date): Date {
|
||||
const fields = parseCron(cronExpr);
|
||||
|
||||
// Start at the next whole minute after `after`
|
||||
const candidate = new Date(after.getTime());
|
||||
candidate.setSeconds(0, 0);
|
||||
candidate.setMinutes(candidate.getMinutes() + 1);
|
||||
// Start at the next whole minute after `after`. Use epoch arithmetic
|
||||
// rather than local Date setters: a local `setMinutes`/`setSeconds`
|
||||
// resolves an ambiguous fall-back wall-clock to its earlier occurrence,
|
||||
// which would re-project the candidate up to 59 minutes into the past.
|
||||
const candidate = new Date(
|
||||
Math.floor(after.getTime() / 60000) * 60000 + 60000,
|
||||
);
|
||||
|
||||
// Scan up to 4 years (~2.1M minutes) to avoid infinite loops
|
||||
const maxIterations = 4 * 366 * 24 * 60;
|
||||
@@ -193,7 +196,7 @@ export function nextFireTime(cronExpr: string, after: Date): Date {
|
||||
if (minuteOk && hourOk && monthOk && dayOk) {
|
||||
return candidate;
|
||||
}
|
||||
candidate.setMinutes(candidate.getMinutes() + 1);
|
||||
candidate.setTime(candidate.getTime() + 60000);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
|
||||
Reference in new issue
Block a user