Skip to content

Commit

Permalink
Fix TimeZone.p.getXxxTransition() worst-case perf
Browse files Browse the repository at this point in the history
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
  povided 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.
  • Loading branch information
justingrant committed Feb 8, 2022
1 parent 30c4d4d commit dcbadd7
Show file tree
Hide file tree
Showing 2 changed files with 142 additions and 7 deletions.
77 changes: 70 additions & 7 deletions lib/ecmascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ const NS_MIN = JSBI.multiply(JSBI.BigInt(-86400), JSBI.BigInt(1e17));
const NS_MAX = JSBI.multiply(JSBI.BigInt(86400), JSBI.BigInt(1e17));
const YEAR_MIN = -271821;
const YEAR_MAX = 275760;
const BEFORE_FIRST_DST = JSBI.multiply(JSBI.BigInt(-388152), JSBI.BigInt(1e13)); // 1847-01-01T00:00:00Z
const BEFORE_FIRST_OFFSET_TRANSITION = JSBI.multiply(JSBI.BigInt(-388152), JSBI.BigInt(1e13)); // 1847-01-01T00:00:00Z
const ABOUT_TEN_YEARS_NANOS = JSBI.multiply(DAY_NANOS, JSBI.BigInt(366 * 10));
const ABOUT_ONE_YEAR_NANOS = JSBI.multiply(DAY_NANOS, JSBI.BigInt(366 * 5));

function IsInteger(value: unknown): value is number {
if (typeof value !== 'number' || !NumberIsFinite(value)) return false;
Expand Down Expand Up @@ -2835,9 +2837,40 @@ export function GetIANATimeZoneDateTimeParts(epochNanoseconds: JSBI, id: string)
return BalanceISODateTime(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond);
}

export function GetIANATimeZoneNextTransition(epochNanoseconds: JSBI, id: string) {
const uppercap = JSBI.add(SystemUTCEpochNanoSeconds(), JSBI.multiply(DAY_NANOS, JSBI.BigInt(366)));
let leftNanos = epochNanoseconds;
function maxJSBI(one: JSBI, two: JSBI) {
return JSBI.lessThan(one, two) ? two : one;
}

/**
* Our best guess at how far in advance new rules will be put into the TZDB for
* future offset transitions. We'll pick 10 years but can always revise it if
* we find that countries are being unusually proactive in their announcing
* of offset changes.
*/
function afterLatestPossibleTzdbRuleChange() {
return JSBI.add(SystemUTCEpochNanoSeconds(), ABOUT_TEN_YEARS_NANOS);
}

export function GetIANATimeZoneNextTransition(epochNanoseconds: JSBI, id: string): JSBI | null {
// Decide how far in the future after `epochNanoseconds` we'll look for an
// offset change. There are two cases:
// 1. If it's a past date (or a date in the near future) then it's possible
// that the time zone may have newly added DST in the next few years. So
// we'll have to look from the provided time until a few years after the
// current system time. (Changes to DST policy are usually announced a few
// years in the future.) Note that the first DST anywhere started in 1847,
// so we'll start checks in 1847 instead of wasting cycles on years where
// there will never be transitions.
// 2. If it's a future date beyond the next few years, then we'll just assume
// that the latest DST policy in TZDB will still be in effect. In this
// case, we only need to look one year in the future to see if there are
// any DST transitions. We actually only need to look 9-10 months because
// DST has two transitions per year, but we'll use a year just to be safe.
const oneYearLater = JSBI.add(epochNanoseconds, ABOUT_ONE_YEAR_NANOS);
const uppercap = maxJSBI(afterLatestPossibleTzdbRuleChange(), oneYearLater);
// The first transition (in any timezone) recorded in the TZDB was in 1847, so
// start there if an earlier date is supplied.
let leftNanos = maxJSBI(BEFORE_FIRST_OFFSET_TRANSITION, epochNanoseconds);
const leftOffsetNs = GetIANATimeZoneOffsetNanoseconds(leftNanos, id);
let rightNanos = leftNanos;
let rightOffsetNs = leftOffsetNs;
Expand All @@ -2859,8 +2892,25 @@ export function GetIANATimeZoneNextTransition(epochNanoseconds: JSBI, id: string
return result;
}

export function GetIANATimeZonePreviousTransition(epochNanoseconds: JSBI, id: string) {
const lowercap = BEFORE_FIRST_DST; // 1847-01-01T00:00:00Z
export function GetIANATimeZonePreviousTransition(epochNanoseconds: JSBI, id: string): JSBI | null {
// If a time zone uses DST (at the time of `epochNanoseconds`), then we only
// have to look back one year to find a transition. But if it doesn't use DST,
// then we need to look all the way back to 1847 (the earliest rule in the
// TZDB) to see if it had other offset transitions in the past. Looping back
// from a far-future date to 1847 is very slow (minutes of 100% CPU!), and is
// also unnecessary because DST rules aren't put into the TZDB more than a few
// years in the future because the political changes in time zones happen with
// only a few years' warning. Therefore, if a far-future date is provided,
// then we'll run the check in two parts:
// 1. First, we'll look back for up to one year to see if the latest TZDB
// rules have DST.
// 2. If not, then we'll "fast-reverse" back to a few years later than the
// current system time, and then look back to 1847. This reduces the
// worst-case loop from >200K years to <200 years, for a >1000x improvement
// in worst-case perf.
const afterLatestRule = afterLatestPossibleTzdbRuleChange();
const isFarFuture = JSBI.greaterThan(epochNanoseconds, afterLatestRule);
const lowercap = isFarFuture ? JSBI.subtract(epochNanoseconds, ABOUT_ONE_YEAR_NANOS) : BEFORE_FIRST_OFFSET_TRANSITION;
let rightNanos = JSBI.subtract(epochNanoseconds, ONE);
const rightOffsetNs = GetIANATimeZoneOffsetNanoseconds(rightNanos, id);
let leftNanos = rightNanos;
Expand All @@ -2872,7 +2922,20 @@ export function GetIANATimeZonePreviousTransition(epochNanoseconds: JSBI, id: st
rightNanos = leftNanos;
}
}
if (rightOffsetNs === leftOffsetNs) return null;
if (rightOffsetNs === leftOffsetNs) {
if (isFarFuture) {
// There was no DST after looking back one year, which means that the most
// recent TZDB rules don't have any recurring transitions. To check for
// transitions in older rules, back up to a few years after the current
// date and then look all the way back to 1847. Note that we move back one
// day from the latest possible rule so that when the recursion runs it
// won't consider the new time to be "far future" because the system clock
// has advanced in the meantime.
const newTimeToCheck = JSBI.subtract(afterLatestRule, DAY_NANOS);
return GetIANATimeZonePreviousTransition(newTimeToCheck, id);
}
return null;
}
const result = bisect(
(epochNs: JSBI) => GetIANATimeZoneOffsetNanoseconds(epochNs, id),
leftNanos,
Expand Down
72 changes: 72 additions & 0 deletions test/timezone.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,78 @@ describe('TimeZone', () => {
}
});
});
describe('Far-future transitions (time zone currently has DST)', () => {
const zone = new Temporal.TimeZone('America/Los_Angeles');
const inst = Temporal.Instant.from('+200000-01-01T00:00-08:00');
const nextTransition = zone.getNextTransition(inst, zone);
it('next transition is valid', () => {
const zdtTransition = nextTransition.toZonedDateTimeISO(zone);
equal(zdtTransition.offset, '-07:00');
equal(zdtTransition.month, 3);
equal(zdtTransition.subtract({ nanoseconds: 1 }).offset, '-08:00');
});
const prevTransition = zone.getPreviousTransition(inst, zone);
it('previous transition is valid', () => {
const zdtTransition = prevTransition.toZonedDateTimeISO(zone);
equal(zdtTransition.offset, '-08:00');
equal(zdtTransition.month, 11);
equal(zdtTransition.subtract({ nanoseconds: 1 }).offset, '-07:00');
});
});
describe('Far-future transitions (time zone has no DST now, but has past transitions)', () => {
const zone = new Temporal.TimeZone('Asia/Kolkata');
const inst = Temporal.Instant.from('+200000-01-01T00:00+05:30');
const nextTransition = zone.getNextTransition(inst, zone);
it('next transition is valid', () => {
equal(nextTransition, null);
});
const prevTransition = zone.getPreviousTransition(inst, zone);
it('previous transition is valid', () => {
const zdtTransition = prevTransition.toZonedDateTimeISO(zone);
equal(zdtTransition.offset, '+05:30');
equal(prevTransition.toString(), '1945-10-14T17:30:00Z');
equal(zdtTransition.subtract({ nanoseconds: 1 }).offset, '+06:30');
});
});
describe('Far-future transitions (time zone has never had any offset transitions', () => {
const zone = new Temporal.TimeZone('Etc/GMT+8');
const inst = Temporal.Instant.from('+200000-01-01T00:00-08:00');
const nextTransition = zone.getNextTransition(inst, zone);
it('next transition is valid', () => {
equal(nextTransition, null);
});
const prevTransition = zone.getPreviousTransition(inst, zone);
it('previous transition is valid', () => {
equal(prevTransition, null);
});
});
describe('Far-past transitions (time zone with some transitions)', () => {
const zone = new Temporal.TimeZone('America/Los_Angeles');
const inst = Temporal.Instant.from('-200000-01-01T00:00-08:00');
const zdt = inst.toZonedDateTimeISO(zone);
const nextTransition = zone.getNextTransition(inst, zone);
it('next transition is valid', () => {
const zdtTransition = nextTransition.toZonedDateTimeISO(zone);
equal(zdt.offset, '-07:52:58');
equal(zdtTransition.toString(), '1883-11-18T12:00:00-08:00[America/Los_Angeles]');
});
const prevTransition = zone.getPreviousTransition(inst, zone);
it('previous transition is valid', () => {
equal(prevTransition, null);
});
});
describe('Far-past transitions (time zone has never had any offset transitions', () => {
const zone = new Temporal.TimeZone('Etc/GMT+8');
const inst = Temporal.Instant.from('-200000-01-01T00:00-08:00');
const nextTransition = zone.getNextTransition(inst, zone);
it('next transition is valid', () => {
equal(nextTransition, null);
});
const prevTransition = zone.getPreviousTransition(inst, zone);
it('previous transition is valid', () => {
equal(prevTransition, null);
});
});
describe('sub-minute offset', () => {
const zone = new Temporal.TimeZone('Europe/Amsterdam');
const inst = Temporal.Instant.from('1900-01-01T12:00Z');
Expand Down

0 comments on commit dcbadd7

Please sign in to comment.