mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 20:02:45 +03:00
fix(sse): bound daily quota cooldowns around daylight-saving transitions (#13671)
Fixes the DST-gap bug in `nextDailyResetAtMs`: a reset hour that does not exist on the transition day landed one hour early (New York 02:00 came out as 01:00; Havana/Santiago midnight as 23:00 the day before). The walk across the gap is bounded to one day and uses a cached formatter.
Maintainer rework before merge (kept the idea, no default behavior change):
- Dropped the 24h clamp in `getMsUntilTomorrow` (on a 25h fall-back day 24.5h is the correct wait; clamping expired the lock 30 minutes early) and the unreachable `ms <= 0` branch, with their tests; characterization tests pin ordinary and fall-back days.
Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests.
Thanks @maxmad64bis!
This commit is contained in:
1
changelog.d/fixes/13671-dst-gap.md
Normal file
1
changelog.d/fixes/13671-dst-gap.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(sse):** a configured daily-quota reset hour that falls inside a daylight-saving gap (New York 02:00 on spring-forward, Havana/Santiago midnight) now resolves to the first wall-clock time that exists instead of landing an hour early, sometimes on the previous day ([#13671](https://github.com/diegosouzapw/OmniRoute/pull/13671)) — thanks @maxmad64bis
|
||||
@@ -32,17 +32,30 @@ type ZonedParts = {
|
||||
second: number;
|
||||
};
|
||||
|
||||
// Formatter construction dominates zonedParts; the DST-gap walk below calls it
|
||||
// hundreds of times, so reuse one formatter per (validated) IANA zone.
|
||||
const zonedFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function zonedFormatter(timeZone: string): Intl.DateTimeFormat {
|
||||
let fmt = zonedFormatters.get(timeZone);
|
||||
if (!fmt) {
|
||||
fmt = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
zonedFormatters.set(timeZone, fmt);
|
||||
}
|
||||
return fmt;
|
||||
}
|
||||
|
||||
function zonedParts(ms: number, timeZone: string): ZonedParts {
|
||||
const fmt = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
const fmt = zonedFormatter(timeZone);
|
||||
const bag: Record<string, string> = {};
|
||||
for (const part of fmt.formatToParts(new Date(ms))) {
|
||||
if (part.type !== "literal") bag[part.type] = part.value;
|
||||
@@ -71,6 +84,35 @@ function addCalendarDay(
|
||||
return { year: dt.getUTCFullYear(), month: dt.getUTCMonth() + 1, day: dt.getUTCDate() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Offset-iteration wall-clock → epoch conversion. `exact` is false when the
|
||||
* iteration never lands on the wanted wall time, which is what a wall time
|
||||
* inside a DST gap (a local time that does not exist) does.
|
||||
*/
|
||||
function convergeWallTime(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hour: number,
|
||||
minute: number,
|
||||
second: number,
|
||||
timeZone: string
|
||||
): { ms: number; exact: boolean } {
|
||||
const wanted = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||
let guess = wanted;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const p = zonedParts(guess, timeZone);
|
||||
const asIfUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
|
||||
const delta = asIfUtc - wanted;
|
||||
if (delta === 0) return { ms: guess, exact: true };
|
||||
guess -= delta;
|
||||
}
|
||||
return { ms: guess, exact: false };
|
||||
}
|
||||
|
||||
/** Gap-walk bound: one full day covers every civil gap, including a skipped calendar day. */
|
||||
const MAX_GAP_WALK_MINUTES = 24 * 60;
|
||||
|
||||
/** Convert wall-clock time in `timeZone` to epoch ms. */
|
||||
function zonedLocalToUtc(
|
||||
year: number,
|
||||
@@ -81,16 +123,33 @@ function zonedLocalToUtc(
|
||||
second: number,
|
||||
timeZone: string
|
||||
): number {
|
||||
const wanted = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||
let guess = wanted;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const p = zonedParts(guess, timeZone);
|
||||
const asIfUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
|
||||
const delta = asIfUtc - wanted;
|
||||
if (delta === 0) return guess;
|
||||
guess -= delta;
|
||||
const first = convergeWallTime(year, month, day, hour, minute, second, timeZone);
|
||||
if (first.exact) return first.ms;
|
||||
// DST gap (New York 02:00 on spring-forward, Havana/Santiago 00:00): the offset
|
||||
// iteration settles an hour EARLY. Walk the wall clock forward minute by minute to
|
||||
// the first wall time that exists; gap widths vary (30 min, 1 h), so never add a
|
||||
// fixed offset.
|
||||
let date = { year, month, day };
|
||||
let minuteOfDay = hour * 60 + minute;
|
||||
for (let step = 0; step < MAX_GAP_WALK_MINUTES; step++) {
|
||||
minuteOfDay += 1;
|
||||
if (minuteOfDay >= 24 * 60) {
|
||||
minuteOfDay -= 24 * 60;
|
||||
date = addCalendarDay(date.year, date.month, date.day);
|
||||
}
|
||||
const h = Math.floor(minuteOfDay / 60);
|
||||
const candidate = convergeWallTime(
|
||||
date.year,
|
||||
date.month,
|
||||
date.day,
|
||||
h,
|
||||
minuteOfDay % 60,
|
||||
second,
|
||||
timeZone
|
||||
);
|
||||
if (candidate.exact) return candidate.ms;
|
||||
}
|
||||
return guess;
|
||||
return first.ms;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -247,6 +247,7 @@
|
||||
"tests/unit/correctness/sanitizers.property.test.ts",
|
||||
"tests/unit/cursor-renewal.test.ts",
|
||||
"tests/unit/custom-model-target-format.test.ts",
|
||||
"tests/unit/daily-reset-dst-gap.test.ts",
|
||||
"tests/unit/db-reset-module-state.test.ts",
|
||||
"tests/unit/db-server-tool-executions-migration.test.ts",
|
||||
"tests/unit/db/stats-dbstat-optional.test.ts",
|
||||
|
||||
79
tests/unit/daily-reset-dst-gap.test.ts
Normal file
79
tests/unit/daily-reset-dst-gap.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* #13671 — a configured daily reset hour that does not exist on a DST
|
||||
* spring-forward day must resolve to the first wall-clock time that exists,
|
||||
* never an hour early (and never on the previous calendar day).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { nextDailyResetAtMs } = await import("../../open-sse/services/dailyQuotaReset.ts");
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
function wallClock(timeZone: string, ms: number): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(ms));
|
||||
}
|
||||
|
||||
test("New York 02:00 on spring-forward resolves to 03:00 EDT, not 01:00 EST", () => {
|
||||
// 2026-03-08: 02:00 -> 03:00 in America/New_York; 02:00 does not exist.
|
||||
const nowMs = Date.parse("2026-03-08T00:30:00-05:00");
|
||||
const next = nextDailyResetAtMs("America/New_York", 2, nowMs);
|
||||
assert.equal(new Date(next).toISOString(), "2026-03-08T07:00:00.000Z");
|
||||
assert.equal(wallClock("America/New_York", next), "2026-03-08, 03:00");
|
||||
});
|
||||
|
||||
test("Havana midnight on spring-forward resolves to 01:00 the same day, not 23:00 the day before", () => {
|
||||
// 2026-03-08: 00:00 -> 01:00 in America/Havana; midnight does not exist.
|
||||
const nowMs = Date.parse("2026-03-07T20:00:00-05:00");
|
||||
const next = nextDailyResetAtMs("America/Havana", 0, nowMs);
|
||||
assert.equal(wallClock("America/Havana", next), "2026-03-08, 01:00");
|
||||
assert.equal(next - nowMs, 4 * HOUR_MS);
|
||||
});
|
||||
|
||||
test("Santiago midnight on spring-forward resolves to 01:00 the same day, not 23:00 the day before", () => {
|
||||
// 2026-09-06: 00:00 -> 01:00 in America/Santiago; midnight does not exist.
|
||||
const nowMs = Date.parse("2026-09-05T20:00:00-04:00");
|
||||
const next = nextDailyResetAtMs("America/Santiago", 0, nowMs);
|
||||
assert.equal(wallClock("America/Santiago", next), "2026-09-06, 01:00");
|
||||
assert.equal(next - nowMs, 4 * HOUR_MS);
|
||||
});
|
||||
|
||||
test("between the old (wrong) 23:00 and the real 01:00 the reset is still ahead", () => {
|
||||
const nowMs = Date.parse("2026-03-07T23:30:00-05:00"); // Havana 23:30, before the gap
|
||||
const next = nextDailyResetAtMs("America/Havana", 0, nowMs);
|
||||
assert.equal(wallClock("America/Havana", next), "2026-03-08, 01:00");
|
||||
assert.equal(next - nowMs, 30 * 60 * 1000);
|
||||
});
|
||||
|
||||
test("fold hour keeps the first occurrence (characterization)", () => {
|
||||
// 2026-11-01: fall back, 01:00 occurs twice; the first (EDT) occurrence wins.
|
||||
const nowMs = Date.parse("2026-11-01T00:30:00-04:00");
|
||||
const next = nextDailyResetAtMs("America/New_York", 1, nowMs);
|
||||
assert.equal(new Date(next).toISOString(), "2026-11-01T05:00:00.000Z");
|
||||
});
|
||||
|
||||
test("a 25h fall-back day keeps its real 24.5h magnitude (characterization, no clamp)", () => {
|
||||
const nowMs = Date.parse("2026-11-01T00:30:00-04:00");
|
||||
const next = nextDailyResetAtMs("America/New_York", 0, nowMs);
|
||||
assert.equal(next - nowMs, 24.5 * HOUR_MS);
|
||||
});
|
||||
|
||||
test("ordinary days are unchanged", () => {
|
||||
const nowMs = Date.parse("2026-01-15T10:00:00Z");
|
||||
assert.equal(
|
||||
new Date(nextDailyResetAtMs("Europe/Paris", 0, nowMs)).toISOString(),
|
||||
"2026-01-15T23:00:00.000Z"
|
||||
);
|
||||
assert.equal(
|
||||
new Date(nextDailyResetAtMs("Asia/Kolkata", 0, nowMs)).toISOString(),
|
||||
"2026-01-15T18:30:00.000Z"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user