Skip to content

Commit e70d632

Browse files
committed
Fix TimeZone.p.getXxxTransition() worst-case perf
When passed vary far-past or far-future dates, `TimeZone.p.get(Next|Previous)Transition()` would take minutes of 100% CPU to finish. This commit improves worst-case perf more than 1000x by: * Skipping looping for any dates before 1847 CE, which is the first entry in the TZDB. * Optimizing handling of dates 10 years or later than the current system time. Offset changes are only planned a few years in advance, so when provided a far-future date, we only need to check one year away to see if there's DST projected forward from the most recent rule in that TZ. If there's not, then when calling `getPreviousTransition` we can skip all the way back to current time + 10 years because no transitions will be in the skipped period. These optimizations reduce a worst-case loop of over 200K years to under 200 years, for a more-than-1000X worst-case perf improvement.
1 parent 21272b2 commit e70d632

File tree

2 files changed

+181
-9
lines changed

2 files changed

+181
-9
lines changed

lib/ecmascript.ts

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,10 @@ const NS_MIN = JSBI.multiply(JSBI.BigInt(-86400), JSBI.BigInt(1e17));
8888
const NS_MAX = JSBI.multiply(JSBI.BigInt(86400), JSBI.BigInt(1e17));
8989
const YEAR_MIN = -271821;
9090
const YEAR_MAX = 275760;
91-
const BEFORE_FIRST_DST = JSBI.multiply(JSBI.BigInt(-388152), JSBI.BigInt(1e13)); // 1847-01-01T00:00:00Z
91+
const BEFORE_FIRST_OFFSET_TRANSITION = JSBI.multiply(JSBI.BigInt(-388152), JSBI.BigInt(1e13)); // 1847-01-01T00:00:00Z
92+
const ABOUT_TEN_YEARS_NANOS = JSBI.multiply(DAY_NANOS, JSBI.BigInt(366 * 10));
93+
const ABOUT_ONE_YEAR_NANOS = JSBI.multiply(DAY_NANOS, JSBI.BigInt(366 * 1));
94+
const TWO_WEEKS_NANOS = JSBI.multiply(DAY_NANOS, JSBI.BigInt(2 * 7));
9295

9396
function IsInteger(value: unknown): value is number {
9497
if (typeof value !== 'number' || !NumberIsFinite(value)) return false;
@@ -2835,14 +2838,45 @@ export function GetIANATimeZoneDateTimeParts(epochNanoseconds: JSBI, id: string)
28352838
return BalanceISODateTime(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond);
28362839
}
28372840

2838-
export function GetIANATimeZoneNextTransition(epochNanoseconds: JSBI, id: string) {
2839-
const uppercap = JSBI.add(SystemUTCEpochNanoSeconds(), JSBI.multiply(DAY_NANOS, JSBI.BigInt(366)));
2840-
let leftNanos = epochNanoseconds;
2841+
function maxJSBI(one: JSBI, two: JSBI) {
2842+
return JSBI.lessThan(one, two) ? two : one;
2843+
}
2844+
2845+
/**
2846+
* Our best guess at how far in advance new rules will be put into the TZDB for
2847+
* future offset transitions. We'll pick 10 years but can always revise it if
2848+
* we find that countries are being unusually proactive in their announcing
2849+
* of offset changes.
2850+
*/
2851+
function afterLatestPossibleTzdbRuleChange() {
2852+
return JSBI.add(SystemUTCEpochNanoSeconds(), ABOUT_TEN_YEARS_NANOS);
2853+
}
2854+
2855+
export function GetIANATimeZoneNextTransition(epochNanoseconds: JSBI, id: string): JSBI | null {
2856+
// Decide how far in the future after `epochNanoseconds` we'll look for an
2857+
// offset change. There are two cases:
2858+
// 1. If it's a past date (or a date in the near future) then it's possible
2859+
// that the time zone may have newly added DST in the next few years. So
2860+
// we'll have to look from the provided time until a few years after the
2861+
// current system time. (Changes to DST policy are usually announced a few
2862+
// years in the future.) Note that the first DST anywhere started in 1847,
2863+
// so we'll start checks in 1847 instead of wasting cycles on years where
2864+
// there will never be transitions.
2865+
// 2. If it's a future date beyond the next few years, then we'll just assume
2866+
// that the latest DST policy in TZDB will still be in effect. In this
2867+
// case, we only need to look one year in the future to see if there are
2868+
// any DST transitions. We actually only need to look 9-10 months because
2869+
// DST has two transitions per year, but we'll use a year just to be safe.
2870+
const oneYearLater = JSBI.add(epochNanoseconds, ABOUT_ONE_YEAR_NANOS);
2871+
const uppercap = maxJSBI(afterLatestPossibleTzdbRuleChange(), oneYearLater);
2872+
// The first transition (in any timezone) recorded in the TZDB was in 1847, so
2873+
// start there if an earlier date is supplied.
2874+
let leftNanos = maxJSBI(BEFORE_FIRST_OFFSET_TRANSITION, epochNanoseconds);
28412875
const leftOffsetNs = GetIANATimeZoneOffsetNanoseconds(leftNanos, id);
28422876
let rightNanos = leftNanos;
28432877
let rightOffsetNs = leftOffsetNs;
28442878
while (leftOffsetNs === rightOffsetNs && JSBI.lessThan(JSBI.BigInt(leftNanos), uppercap)) {
2845-
rightNanos = JSBI.add(leftNanos, JSBI.multiply(DAY_NANOS, JSBI.BigInt(2 * 7)));
2879+
rightNanos = JSBI.add(leftNanos, TWO_WEEKS_NANOS);
28462880
rightOffsetNs = GetIANATimeZoneOffsetNanoseconds(rightNanos, id);
28472881
if (leftOffsetNs === rightOffsetNs) {
28482882
leftNanos = rightNanos;
@@ -2859,20 +2893,50 @@ export function GetIANATimeZoneNextTransition(epochNanoseconds: JSBI, id: string
28592893
return result;
28602894
}
28612895

2862-
export function GetIANATimeZonePreviousTransition(epochNanoseconds: JSBI, id: string) {
2863-
const lowercap = BEFORE_FIRST_DST; // 1847-01-01T00:00:00Z
2896+
export function GetIANATimeZonePreviousTransition(epochNanoseconds: JSBI, id: string): JSBI | null {
2897+
// If a time zone uses DST (at the time of `epochNanoseconds`), then we only
2898+
// have to look back one year to find a transition. But if it doesn't use DST,
2899+
// then we need to look all the way back to 1847 (the earliest rule in the
2900+
// TZDB) to see if it had other offset transitions in the past. Looping back
2901+
// from a far-future date to 1847 is very slow (minutes of 100% CPU!), and is
2902+
// also unnecessary because DST rules aren't put into the TZDB more than a few
2903+
// years in the future because the political changes in time zones happen with
2904+
// only a few years' warning. Therefore, if a far-future date is provided,
2905+
// then we'll run the check in two parts:
2906+
// 1. First, we'll look back for up to one year to see if the latest TZDB
2907+
// rules have DST.
2908+
// 2. If not, then we'll "fast-reverse" back to a few years later than the
2909+
// current system time, and then look back to 1847. This reduces the
2910+
// worst-case loop from 273K years to 175 years, for a ~1500x improvement
2911+
// in worst-case perf.
2912+
const afterLatestRule = afterLatestPossibleTzdbRuleChange();
2913+
const isFarFuture = JSBI.greaterThan(epochNanoseconds, afterLatestRule);
2914+
const lowercap = isFarFuture ? JSBI.subtract(epochNanoseconds, ABOUT_ONE_YEAR_NANOS) : BEFORE_FIRST_OFFSET_TRANSITION;
28642915
let rightNanos = JSBI.subtract(epochNanoseconds, ONE);
28652916
const rightOffsetNs = GetIANATimeZoneOffsetNanoseconds(rightNanos, id);
28662917
let leftNanos = rightNanos;
28672918
let leftOffsetNs = rightOffsetNs;
28682919
while (rightOffsetNs === leftOffsetNs && JSBI.greaterThan(rightNanos, lowercap)) {
2869-
leftNanos = JSBI.subtract(rightNanos, JSBI.multiply(DAY_NANOS, JSBI.BigInt(2 * 7)));
2920+
leftNanos = JSBI.subtract(rightNanos, TWO_WEEKS_NANOS);
28702921
leftOffsetNs = GetIANATimeZoneOffsetNanoseconds(leftNanos, id);
28712922
if (rightOffsetNs === leftOffsetNs) {
28722923
rightNanos = leftNanos;
28732924
}
28742925
}
2875-
if (rightOffsetNs === leftOffsetNs) return null;
2926+
if (rightOffsetNs === leftOffsetNs) {
2927+
if (isFarFuture) {
2928+
// There was no DST after looking back one year, which means that the most
2929+
// recent TZDB rules don't have any recurring transitions. To check for
2930+
// transitions in older rules, back up to a few years after the current
2931+
// date and then look all the way back to 1847. Note that we move back one
2932+
// day from the latest possible rule so that when the recursion runs it
2933+
// won't consider the new time to be "far future" because the system clock
2934+
// has advanced in the meantime.
2935+
const newTimeToCheck = JSBI.subtract(afterLatestRule, DAY_NANOS);
2936+
return GetIANATimeZonePreviousTransition(newTimeToCheck, id);
2937+
}
2938+
return null;
2939+
}
28762940
const result = bisect(
28772941
(epochNs: JSBI) => GetIANATimeZoneOffsetNanoseconds(epochNs, id),
28782942
leftNanos,

test/timezone.mjs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,114 @@ describe('TimeZone', () => {
217217
}
218218
});
219219
});
220+
const checkTime = (limitMsecs, func) => {
221+
const now = Date.now();
222+
func();
223+
const msecs = Date.now() - now;
224+
if (msecs > limitMsecs) assert(false, `Expected ${limitMsecs}ms or less, actual: ${msecs}ms`);
225+
};
226+
describe('Far-future transitions (time zone currently has DST)', () => {
227+
const zone = new Temporal.TimeZone('America/Los_Angeles');
228+
const inst = Temporal.Instant.from('+200000-01-01T00:00-08:00');
229+
it('next transition is valid', () => {
230+
const nextTransition = zone.getNextTransition(inst, zone);
231+
const zdtTransition = nextTransition.toZonedDateTimeISO(zone);
232+
equal(zdtTransition.offset, '-07:00');
233+
equal(zdtTransition.month, 3);
234+
equal(zdtTransition.subtract({ nanoseconds: 1 }).offset, '-08:00');
235+
});
236+
it('getNextTransition takes less than 800ms', () => {
237+
checkTime(800, () => zone.getNextTransition(inst, zone));
238+
});
239+
it('previous transition is valid', () => {
240+
const prevTransition = zone.getPreviousTransition(inst, zone);
241+
const zdtTransition = prevTransition.toZonedDateTimeISO(zone);
242+
equal(zdtTransition.offset, '-08:00');
243+
equal(zdtTransition.month, 11);
244+
equal(zdtTransition.subtract({ nanoseconds: 1 }).offset, '-07:00');
245+
});
246+
it('getPreviousTransition takes less than 800ms', () => {
247+
checkTime(800, () => zone.getPreviousTransition(inst, zone));
248+
});
249+
});
250+
describe('Far-future transitions (time zone has no DST now, but has past transitions)', () => {
251+
const zone = new Temporal.TimeZone('Asia/Kolkata');
252+
const inst = Temporal.Instant.from('+200000-01-01T00:00+05:30');
253+
it('next transition is valid', () => {
254+
const nextTransition = zone.getNextTransition(inst, zone);
255+
equal(nextTransition, null);
256+
});
257+
it('getNextTransition takes less than 800ms', () => {
258+
checkTime(800, () => zone.getNextTransition(inst, zone));
259+
});
260+
it('previous transition is valid', () => {
261+
const prevTransition = zone.getPreviousTransition(inst, zone);
262+
const zdtTransition = prevTransition.toZonedDateTimeISO(zone);
263+
equal(zdtTransition.offset, '+05:30');
264+
equal(prevTransition.toString(), '1945-10-14T17:30:00Z');
265+
equal(zdtTransition.subtract({ nanoseconds: 1 }).offset, '+06:30');
266+
});
267+
it('getPreviousTransition takes less than 800ms', () => {
268+
checkTime(800, () => zone.getPreviousTransition(inst, zone));
269+
});
270+
});
271+
describe('Far-future transitions (time zone has never had any offset transitions)', () => {
272+
const zone = new Temporal.TimeZone('Etc/GMT+8');
273+
const inst = Temporal.Instant.from('+200000-01-01T00:00-08:00');
274+
it('next transition is valid', () => {
275+
const nextTransition = zone.getNextTransition(inst, zone);
276+
equal(nextTransition, null);
277+
});
278+
it('getNextTransition takes less than 800ms', () => {
279+
checkTime(800, () => zone.getNextTransition(inst, zone));
280+
});
281+
it('previous transition is valid', () => {
282+
const prevTransition = zone.getPreviousTransition(inst, zone);
283+
equal(prevTransition, null);
284+
});
285+
it('getPreviousTransition takes less than 800ms', () => {
286+
checkTime(800, () => zone.getPreviousTransition(inst, zone));
287+
});
288+
});
289+
describe('Far-past transitions (time zone with some transitions)', () => {
290+
const zone = new Temporal.TimeZone('America/Los_Angeles');
291+
const inst = Temporal.Instant.from('-200000-01-01T00:00-08:00');
292+
const zdt = inst.toZonedDateTimeISO(zone);
293+
it('next transition is valid', () => {
294+
const nextTransition = zone.getNextTransition(inst, zone);
295+
const zdtTransition = nextTransition.toZonedDateTimeISO(zone);
296+
equal(zdt.offset, '-07:52:58');
297+
equal(zdtTransition.toString(), '1883-11-18T12:00:00-08:00[America/Los_Angeles]');
298+
});
299+
it('getNextTransition takes less than 800ms', () => {
300+
checkTime(800, () => zone.getNextTransition(inst, zone));
301+
});
302+
it('previous transition is valid', () => {
303+
const prevTransition = zone.getPreviousTransition(inst, zone);
304+
equal(prevTransition, null);
305+
});
306+
it('getPreviousTransition takes less than 800ms', () => {
307+
checkTime(800, () => zone.getPreviousTransition(inst, zone));
308+
});
309+
});
310+
describe('Far-past transitions (time zone has never had any offset transitions)', () => {
311+
const zone = new Temporal.TimeZone('Etc/GMT+8');
312+
const inst = Temporal.Instant.from('-200000-01-01T00:00-08:00');
313+
it('next transition is valid', () => {
314+
const nextTransition = zone.getNextTransition(inst, zone);
315+
equal(nextTransition, null);
316+
});
317+
it('getNextTransition takes less than 800ms', () => {
318+
checkTime(800, () => zone.getNextTransition(inst, zone));
319+
});
320+
it('previous transition is valid', () => {
321+
const prevTransition = zone.getPreviousTransition(inst, zone);
322+
equal(prevTransition, null);
323+
});
324+
it('getPreviousTransition takes less than 800ms', () => {
325+
checkTime(800, () => zone.getPreviousTransition(inst, zone));
326+
});
327+
});
220328
describe('sub-minute offset', () => {
221329
const zone = new Temporal.TimeZone('Europe/Amsterdam');
222330
const inst = Temporal.Instant.from('1900-01-01T12:00Z');

0 commit comments

Comments
 (0)