mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
perf(tz): memoise zone offsets per hour for wall-clock reads
Day keys and wall-clock fields are computed per row in the analytics, digest and personal-record loops, and each call ran Intl.DateTimeFormat#formatToParts plus a resolvedOptions() allocation to re-validate the zone. In a CPU profile of an Insights page load the zone helpers were 17-19% of the busy time on the request thread. Probe the offset once per UTC hour at both ends of the hour and derive the wall clock arithmetically; an hour that contains a transition, an instant outside 1971-2199 or an invalid date keeps the formatter path. Zone validity is memoised per name. userDayKey drops from 9.3 to 1.5 us per call, wallClockInTz from 6.4 to 0.5 us; a parity test checks the result against Intl around every 2026 transition in ten zones.
This commit is contained in:
1 parent
4ab2a8be38
commit
76e621b067
4 files changed
+351
-2
No files matched your search
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* The hourly offset memo must give the same wall clock as asking Intl
|
||||
* directly, for every instant, including the hours around a transition and
|
||||
* zones whose transitions do not fall on a UTC hour.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { formatInUserTz, tzOffsetMinutes, userDayKey } from "../format";
|
||||
import { wallClockInTz } from "../wall-clock";
|
||||
import { __resetZoneOffsetCacheForTests, zoneOffsetMs } from "../zone-offset";
|
||||
|
||||
const ZONES = [
|
||||
"UTC",
|
||||
"Europe/Berlin",
|
||||
"America/New_York",
|
||||
"America/St_Johns",
|
||||
"Australia/Lord_Howe",
|
||||
"Pacific/Chatham",
|
||||
"Asia/Kathmandu",
|
||||
"Asia/Kolkata",
|
||||
"Africa/Casablanca",
|
||||
"America/Sao_Paulo",
|
||||
];
|
||||
|
||||
const REFERENCE_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
weekday: "short",
|
||||
};
|
||||
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
function reference(date: Date, tz: string) {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
...REFERENCE_OPTIONS,
|
||||
timeZone: tz,
|
||||
}).formatToParts(date);
|
||||
const get = (type: string) => parts.find((p) => p.type === type)!.value;
|
||||
const hour = Number(get("hour")) % 24;
|
||||
const wall = {
|
||||
year: Number(get("year")),
|
||||
month: Number(get("month")),
|
||||
day: Number(get("day")),
|
||||
hour,
|
||||
minute: Number(get("minute")),
|
||||
second: Number(get("second")),
|
||||
weekday: WEEKDAYS.indexOf(get("weekday")),
|
||||
};
|
||||
const asIfUtc = Date.UTC(
|
||||
wall.year,
|
||||
wall.month - 1,
|
||||
wall.day,
|
||||
wall.hour,
|
||||
wall.minute,
|
||||
wall.second,
|
||||
);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return {
|
||||
wall,
|
||||
offsetMinutes: Math.round((asIfUtc - date.getTime()) / 60000),
|
||||
dayKey: `${wall.year}-${pad(wall.month)}-${pad(wall.day)}`,
|
||||
datetime: `${wall.year}-${pad(wall.month)}-${pad(wall.day)} ${pad(wall.hour)}:${pad(wall.minute)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Instants every 7 min 13.5 s across two days around each 2026 transition. */
|
||||
function instantsAroundTransitions(): Date[] {
|
||||
const anchors = [
|
||||
Date.UTC(2026, 2, 28), // EU spring forward (29 Mar)
|
||||
Date.UTC(2026, 9, 24), // EU fall back (25 Oct)
|
||||
Date.UTC(2026, 2, 7), // US spring forward (8 Mar)
|
||||
Date.UTC(2026, 9, 31), // US fall back (1 Nov)
|
||||
Date.UTC(2026, 3, 4), // Lord Howe / Chatham autumn (4-5 Apr)
|
||||
Date.UTC(2026, 8, 26), // Chatham spring (27 Sep)
|
||||
Date.UTC(2026, 9, 3), // Lord Howe spring (3 Oct 15:30 UTC) / Chatham (27 Sep)
|
||||
];
|
||||
const out: Date[] = [];
|
||||
for (const anchor of anchors) {
|
||||
for (let t = anchor; t < anchor + 2 * 86_400_000; t += 433_500) {
|
||||
out.push(new Date(t));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetZoneOffsetCacheForTests();
|
||||
});
|
||||
|
||||
describe("zone offset memo", () => {
|
||||
it("matches Intl for every instant around the transitions", () => {
|
||||
const instants = instantsAroundTransitions();
|
||||
for (const tz of ZONES) {
|
||||
for (const date of instants) {
|
||||
const ref = reference(date, tz);
|
||||
expect(wallClockInTz(date, tz)).toEqual(ref.wall);
|
||||
expect(tzOffsetMinutes(date, tz)).toBe(ref.offsetMinutes);
|
||||
expect(userDayKey(date, tz)).toBe(ref.dayKey);
|
||||
expect(formatInUserTz(date, tz, "datetime")).toBe(ref.datetime);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("matches Intl on a warm memo for scattered instants across decades", () => {
|
||||
let seed = 7;
|
||||
const next = () => {
|
||||
seed = (seed * 1_103_515_245 + 12_345) % 2_147_483_648;
|
||||
return seed / 2_147_483_648;
|
||||
};
|
||||
const from = Date.UTC(1972, 0, 1);
|
||||
const span = Date.UTC(2060, 0, 1) - from;
|
||||
for (let i = 0; i < 4000; i++) {
|
||||
const date = new Date(from + Math.floor(next() * span));
|
||||
const tz = ZONES[i % ZONES.length];
|
||||
// Twice: the first read fills the hour, the second reads the memo.
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
const ref = reference(date, tz);
|
||||
expect(wallClockInTz(date, tz)).toEqual(ref.wall);
|
||||
expect(tzOffsetMinutes(date, tz)).toBe(ref.offsetMinutes);
|
||||
expect(userDayKey(date, tz)).toBe(ref.dayKey);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses an hour that contains a transition", () => {
|
||||
// Europe/Berlin springs forward at 01:00 UTC on 29 March 2026.
|
||||
expect(
|
||||
zoneOffsetMs(new Date(Date.UTC(2026, 2, 29, 1, 30)), "Europe/Berlin"),
|
||||
).toBe(2 * 3_600_000);
|
||||
// Lord Howe springs forward half an hour at 15:30 UTC on 3 October:
|
||||
// the 15:00 UTC hour straddles it and stays on the formatter path,
|
||||
// on both sides of the change.
|
||||
expect(
|
||||
zoneOffsetMs(
|
||||
new Date(Date.UTC(2026, 9, 3, 15, 10)),
|
||||
"Australia/Lord_Howe",
|
||||
),
|
||||
).toBeNull();
|
||||
for (const minute of [10, 29, 30, 45]) {
|
||||
const at = new Date(Date.UTC(2026, 9, 3, 15, minute));
|
||||
expect(wallClockInTz(at, "Australia/Lord_Howe")).toEqual(
|
||||
reference(at, "Australia/Lord_Howe").wall,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves out-of-range and invalid inputs to the formatter path", () => {
|
||||
expect(
|
||||
zoneOffsetMs(new Date(Date.UTC(1950, 0, 1)), "Europe/Berlin"),
|
||||
).toBeNull();
|
||||
expect(zoneOffsetMs(new Date(Number.NaN), "Europe/Berlin")).toBeNull();
|
||||
expect(() => wallClockInTz(new Date(), "Not/A_Zone")).toThrow(RangeError);
|
||||
// The formatter reads a missing date as "now"; the memo must not turn
|
||||
// that into a TypeError.
|
||||
const now = reference(new Date(), "Europe/Berlin").wall;
|
||||
const loose = wallClockInTz(undefined as unknown as Date, "Europe/Berlin");
|
||||
expect(loose.year).toBe(now.year);
|
||||
expect(loose.day).toBe(now.day);
|
||||
});
|
||||
});
|
||||
+37
-2
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
import { getDateTimeFormat } from "./intl-cache";
|
||||
import { wallClockFromOffset, zoneOffsetMs } from "./zone-offset";
|
||||
|
||||
export const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
@@ -52,6 +53,9 @@ const WALL_CLOCK_PARTS_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> =
|
||||
*/
|
||||
const OFFSET_ZONE = /^[+-]\d/;
|
||||
|
||||
const validityByZone = new Map<string, boolean>();
|
||||
const MAX_VALIDITY_ENTRIES = 256;
|
||||
|
||||
/**
|
||||
* Validate a timezone string: a named IANA zone (or `UTC`) the runtime
|
||||
* accepts. Returns `false` for anything else. Cheap (microseconds) — call
|
||||
@@ -67,6 +71,12 @@ export function isValidTimezone(tz: string): boolean {
|
||||
if (!tz || typeof tz !== "string" || tz.length === 0 || tz.length > 64) {
|
||||
return false;
|
||||
}
|
||||
// The answer for a given name never changes within a process, and the
|
||||
// day-key helpers below ask it once per row; `resolvedOptions()` allocates
|
||||
// a fresh options object on every call.
|
||||
const memo = validityByZone.get(tz);
|
||||
if (memo !== undefined) return memo;
|
||||
let valid: boolean;
|
||||
try {
|
||||
// The memo only caches successful constructions, so probing an
|
||||
// invalid zone through it cannot poison the formatter map — and a
|
||||
@@ -76,10 +86,13 @@ export function isValidTimezone(tz: string): boolean {
|
||||
tz,
|
||||
VALIDATION_OPTIONS,
|
||||
).resolvedOptions().timeZone;
|
||||
return !OFFSET_ZONE.test(resolved);
|
||||
valid = !OFFSET_ZONE.test(resolved);
|
||||
} catch {
|
||||
return false;
|
||||
valid = false;
|
||||
}
|
||||
if (validityByZone.size >= MAX_VALIDITY_ENTRIES) validityByZone.clear();
|
||||
validityByZone.set(tz, valid);
|
||||
return valid;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -275,7 +288,23 @@ interface WallClockParts {
|
||||
second: string;
|
||||
}
|
||||
|
||||
function pad2(value: number): string {
|
||||
return value < 10 ? `0${value}` : String(value);
|
||||
}
|
||||
|
||||
function wallClockParts(date: Date, tz: string): WallClockParts {
|
||||
const offsetMs = zoneOffsetMs(date, tz);
|
||||
if (offsetMs !== null) {
|
||||
const at = wallClockFromOffset(date, offsetMs);
|
||||
return {
|
||||
year: String(at.year),
|
||||
month: pad2(at.month),
|
||||
day: pad2(at.day),
|
||||
hour: pad2(at.hour),
|
||||
minute: pad2(at.minute),
|
||||
second: pad2(at.second),
|
||||
};
|
||||
}
|
||||
const fmt = getDateTimeFormat("en-CA", tz, WALL_CLOCK_PARTS_OPTIONS);
|
||||
const parts = fmt.formatToParts(date);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
||||
@@ -319,6 +348,12 @@ function formatIsoWithOffset(date: Date, tz: string): string {
|
||||
* difference between the wall-clock parts and the UTC parts.
|
||||
*/
|
||||
export function tzOffsetMinutes(date: Date, tz: string): number {
|
||||
const offsetMs = zoneOffsetMs(date, tz);
|
||||
if (offsetMs !== null) {
|
||||
// Same arithmetic as below: the wall clock is read to the whole second,
|
||||
// so the sub-second part of the instant shifts the rounded result.
|
||||
return Math.round((offsetMs - (date.getTime() % 1000)) / 60000);
|
||||
}
|
||||
const fmt = getDateTimeFormat("en-CA", tz, WALL_CLOCK_PARTS_OPTIONS);
|
||||
const parts = fmt.formatToParts(date);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): number =>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
* silent drift a real failure mode.
|
||||
*/
|
||||
import { getDateTimeFormat } from "./intl-cache";
|
||||
import { wallClockFromOffset, zoneOffsetMs } from "./zone-offset";
|
||||
|
||||
export interface WallClockParts {
|
||||
year: number;
|
||||
@@ -190,6 +191,8 @@ export function wallClockInTz(
|
||||
weekday: date.getDay(),
|
||||
};
|
||||
}
|
||||
const offsetMs = zoneOffsetMs(date, tz);
|
||||
if (offsetMs !== null) return wallClockFromOffset(date, offsetMs);
|
||||
const parts = getDateTimeFormat(
|
||||
"en-US",
|
||||
tz,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Per-hour memo of a zone's UTC offset, so wall-clock conversions in hot loops
|
||||
* stop paying for `Intl.DateTimeFormat#formatToParts` on every row.
|
||||
*
|
||||
* A day-key or wall-clock read is pure arithmetic once the offset in force at
|
||||
* the instant is known. The offset is probed through the same cached
|
||||
* formatter the slow path uses, at both ends of the UTC hour that contains the
|
||||
* instant; only when both ends agree (and the offset is a whole number of
|
||||
* seconds) is it memoised for that hour. An hour that straddles a transition
|
||||
* is stored as "not constant" and every instant inside it keeps going through
|
||||
* the formatter, so a transition at any minute — Lord Howe's half-hour shift
|
||||
* at 15:30 UTC, a historical LMT offset with seconds — still resolves exactly.
|
||||
*
|
||||
* The fast path is limited to 1971..2199 so the arithmetic never meets the
|
||||
* Julian / proleptic-Gregorian split or a non-four-digit year, and so `t % 1000`
|
||||
* is never negative. Outside that range, for an invalid date, or for a zone
|
||||
* the formatter rejects, callers fall through to the formatter unchanged
|
||||
* (an invalid zone throws from the probe exactly as it throws from the
|
||||
* formatter path, and a value that is not a Date is left to the formatter).
|
||||
*/
|
||||
import { getDateTimeFormat } from "./intl-cache";
|
||||
|
||||
const PROBE_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> =
|
||||
Object.freeze({
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const BUCKET_MS = 3_600_000;
|
||||
const FAST_FROM_MS = Date.UTC(1971, 0, 1);
|
||||
const FAST_UNTIL_MS = Date.UTC(2200, 0, 1);
|
||||
/** Hours memoised across all zones before the memo starts over (~2 MB). */
|
||||
const MAX_BUCKETS = 20_000;
|
||||
/** Stored for an hour whose offset changes inside it. */
|
||||
const NOT_CONSTANT = Number.NaN;
|
||||
|
||||
const offsetsByZone = new Map<string, Map<number, number>>();
|
||||
let bucketCount = 0;
|
||||
|
||||
/** Exact offset (ms) at an instant that falls on a whole second. */
|
||||
function probeOffsetMs(instantMs: number, tz: string): number {
|
||||
const parts = getDateTimeFormat("en-CA", tz, PROBE_OPTIONS).formatToParts(
|
||||
new Date(instantMs),
|
||||
);
|
||||
let year = 0;
|
||||
let month = 0;
|
||||
let day = 0;
|
||||
let hour = 0;
|
||||
let minute = 0;
|
||||
let second = 0;
|
||||
for (const part of parts) {
|
||||
switch (part.type) {
|
||||
case "year":
|
||||
year = Number(part.value);
|
||||
break;
|
||||
case "month":
|
||||
month = Number(part.value);
|
||||
break;
|
||||
case "day":
|
||||
day = Number(part.value);
|
||||
break;
|
||||
case "hour":
|
||||
hour = Number(part.value);
|
||||
break;
|
||||
case "minute":
|
||||
minute = Number(part.value);
|
||||
break;
|
||||
case "second":
|
||||
second = Number(part.value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hour === 24) hour = 0;
|
||||
return Date.UTC(year, month - 1, day, hour, minute, second) - instantMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* The zone's offset from UTC in milliseconds at `date`, or `null` when the
|
||||
* caller must use the formatter (out of range, invalid date, or an hour that
|
||||
* contains a transition). Throws for a zone `Intl` rejects.
|
||||
*/
|
||||
export function zoneOffsetMs(date: Date, tz: string): number | null {
|
||||
// A caller that slips a non-Date through (an absent field read off a loose
|
||||
// row) keeps the formatter's own handling: `formatToParts(undefined)`
|
||||
// formats the current instant rather than throwing.
|
||||
if (!(date instanceof Date)) return null;
|
||||
const t = date.getTime();
|
||||
if (!(t >= FAST_FROM_MS && t < FAST_UNTIL_MS)) return null;
|
||||
const bucket = Math.floor(t / BUCKET_MS);
|
||||
let zone = offsetsByZone.get(tz);
|
||||
const memo = zone?.get(bucket);
|
||||
if (memo !== undefined) return Number.isNaN(memo) ? null : memo;
|
||||
|
||||
const start = bucket * BUCKET_MS;
|
||||
const atStart = probeOffsetMs(start, tz);
|
||||
const atEnd = probeOffsetMs(start + BUCKET_MS - 1000, tz);
|
||||
const offset =
|
||||
atStart === atEnd && atStart % 1000 === 0 ? atStart : NOT_CONSTANT;
|
||||
|
||||
if (bucketCount >= MAX_BUCKETS) {
|
||||
offsetsByZone.clear();
|
||||
bucketCount = 0;
|
||||
zone = undefined;
|
||||
}
|
||||
if (!zone) {
|
||||
zone = new Map();
|
||||
offsetsByZone.set(tz, zone);
|
||||
}
|
||||
zone.set(bucket, offset);
|
||||
bucketCount += 1;
|
||||
return Number.isNaN(offset) ? null : offset;
|
||||
}
|
||||
|
||||
/** Wall-clock fields at `date` in the zone whose offset is `offsetMs`. */
|
||||
export function wallClockFromOffset(
|
||||
date: Date,
|
||||
offsetMs: number,
|
||||
): {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
weekday: number;
|
||||
} {
|
||||
const local = new Date(date.getTime() + offsetMs);
|
||||
return {
|
||||
year: local.getUTCFullYear(),
|
||||
month: local.getUTCMonth() + 1,
|
||||
day: local.getUTCDate(),
|
||||
hour: local.getUTCHours(),
|
||||
minute: local.getUTCMinutes(),
|
||||
second: local.getUTCSeconds(),
|
||||
weekday: local.getUTCDay(),
|
||||
};
|
||||
}
|
||||
|
||||
export function __resetZoneOffsetCacheForTests(): void {
|
||||
offsetsByZone.clear();
|
||||
bucketCount = 0;
|
||||
}
|
||||
Reference in new issue
Block a user