Skip to content

Commit

Permalink
Merge branch 'benjamin--integ-cache-offset-guesses-for-quickDT' into …
Browse files Browse the repository at this point in the history
…benjamin--integ-all-changes
  • Loading branch information
schleyfox committed Jan 20, 2024
2 parents bfb703b + c5bd5fd commit ca50179
Show file tree
Hide file tree
Showing 3 changed files with 259 additions and 108 deletions.
53 changes: 49 additions & 4 deletions src/datetime.js
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,36 @@ function normalizeUnitWithLocalWeeks(unit) {
}
}

// cache offsets for zones based on the current timestamp when this function is
// first called. When we are handling a datetime from components like (year,
// month, day, hour) in a time zone, we need a guess about what the timezone
// offset is so that we can convert into a UTC timestamp. One way is to find the
// offset of now in the zone. The actual date may have a different offset (for
// example, if we handle a date in June while we're in December in a zone that
// observes DST), but we can check and adjust that.
//
// When handling many dates, calculating the offset for now every time is
// expensive. It's just a guess, so we can cache the offset to use even if we
// are right on a time change boundary (we'll just correct in the other
// direction). Using a timestamp from first read is a slight optimization for
// handling dates close to the current date, since those dates will usually be
// in the same offset (we could set the timestamp statically, instead). We use a
// single timestamp for all zones to make things a bit more predictable.
//
// This is safe for quickDT (used by local() and utc()) because we don't fill in
// higher-order units from tsNow (as we do in fromObject, this requires that
// offset is calculated from tsNow).
function guessOffsetForZone(zone) {
if (!DateTime._zoneOffsetGuessCache[zone]) {
if (DateTime._zoneOffsetTs === undefined) {
DateTime._zoneOffsetTs = Settings.now();
}

DateTime._zoneOffsetGuessCache[zone] = zone.offset(DateTime._zoneOffsetTs);
}
return DateTime._zoneOffsetGuessCache[zone];
}

// this is a dumbed down version of fromObject() that runs about 60% faster
// but doesn't do any validation, makes a bunch of assumptions about what units
// are present, and so on.
Expand All @@ -378,8 +408,7 @@ function quickDT(obj, opts) {
return DateTime.invalid(unsupportedZone(zone));
}

const loc = Locale.fromObject(opts),
tsNow = Settings.now();
const loc = Locale.fromObject(opts);

let ts, o;

Expand All @@ -396,10 +425,10 @@ function quickDT(obj, opts) {
return DateTime.invalid(invalid);
}

const offsetProvis = zone.offset(tsNow);
const offsetProvis = guessOffsetForZone(zone);
[ts, o] = objToTS(obj, offsetProvis, zone);
} else {
ts = tsNow;
ts = Settings.now();
}

return new DateTime({ ts, zone, loc, o });
Expand Down Expand Up @@ -535,6 +564,22 @@ export default class DateTime {
this.isLuxonDateTime = true;
}

/**
* Timestamp to use for cached zone offset guesses (exposed for test)
*
* @access private
*/
static _zoneOffsetTs;
/**
* Cache for zone offset guesses (exposed for test).
*
* This optimizes quickDT via guessOffsetForZone to avoid repeated calls of
* zone.offset().
*
* @access private
*/
static _zoneOffsetGuessCache = {};

// CONSTRUCT

/**
Expand Down
291 changes: 187 additions & 104 deletions test/datetime/dst.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,111 +2,194 @@

import { DateTime, Settings } from "../../src/luxon";

const local = (year, month, day, hour) =>
DateTime.fromObject({ year, month, day, hour }, { zone: "America/New_York" });

test("Hole dates are bumped forward", () => {
const d = local(2017, 3, 12, 2);
expect(d.hour).toBe(3);
expect(d.offset).toBe(-4 * 60);
});

// this is questionable behavior, but I wanted to document it
test("Ambiguous dates pick the one with the current offset", () => {
const oldSettings = Settings.now;
try {
Settings.now = () => 1495653314595; // May 24, 2017
let d = local(2017, 11, 5, 1);
expect(d.hour).toBe(1);
expect(d.offset).toBe(-4 * 60);

Settings.now = () => 1484456400000; // Jan 15, 2017
d = local(2017, 11, 5, 1);
expect(d.hour).toBe(1);
expect(d.offset).toBe(-5 * 60);
} finally {
Settings.now = oldSettings;
}
});

test("Adding an hour to land on the Spring Forward springs forward", () => {
const d = local(2017, 3, 12, 1).plus({ hour: 1 });
expect(d.hour).toBe(3);
expect(d.offset).toBe(-4 * 60);
});

test("Subtracting an hour to land on the Spring Forward springs forward", () => {
const d = local(2017, 3, 12, 3).minus({ hour: 1 });
expect(d.hour).toBe(1);
expect(d.offset).toBe(-5 * 60);
});

test("Adding an hour to land on the Fall Back falls back", () => {
const d = local(2017, 11, 5, 0).plus({ hour: 2 });
expect(d.hour).toBe(1);
expect(d.offset).toBe(-5 * 60);
});

test("Subtracting an hour to land on the Fall Back falls back", () => {
let d = local(2017, 11, 5, 3).minus({ hour: 2 });
expect(d.hour).toBe(1);
expect(d.offset).toBe(-5 * 60);

d = d.minus({ hour: 1 });
expect(d.hour).toBe(1);
expect(d.offset).toBe(-4 * 60);
});

test("Changing a calendar date to land on a hole bumps forward", () => {
let d = local(2017, 3, 11, 2).plus({ day: 1 });
expect(d.hour).toBe(3);
expect(d.offset).toBe(-4 * 60);

d = local(2017, 3, 13, 2).minus({ day: 1 });
expect(d.hour).toBe(3);
expect(d.offset).toBe(-4 * 60);
});

test("Changing a calendar date to land on an ambiguous time chooses the closest one", () => {
let d = local(2017, 11, 4, 1).plus({ day: 1 });
expect(d.hour).toBe(1);
expect(d.offset).toBe(-4 * 60);

d = local(2017, 11, 6, 1).minus({ day: 1 });
expect(d.hour).toBe(1);
expect(d.offset).toBe(-5 * 60);
});

test("Start of a 0:00->1:00 DST day is 1:00", () => {
const d = DateTime.fromObject(
{
year: 2017,
month: 10,
day: 15,
},
{
zone: "America/Sao_Paulo",
const dateTimeConstructors = {
fromObject: (year, month, day, hour) =>
DateTime.fromObject({ year, month, day, hour }, { zone: "America/New_York" }),
local: (year, month, day, hour) =>
DateTime.local(year, month, day, hour, { zone: "America/New_York" }),
};

for (const [name, local] of Object.entries(dateTimeConstructors)) {
describe(`DateTime.${name}`, () => {
test("Hole dates are bumped forward", () => {
const d = local(2017, 3, 12, 2);
expect(d.hour).toBe(3);
expect(d.offset).toBe(-4 * 60);
});

if (name == "fromObject") {
// this is questionable behavior, but I wanted to document it
test("Ambiguous dates pick the one with the current offset", () => {
const oldSettings = Settings.now;
try {
Settings.now = () => 1495653314595; // May 24, 2017
let d = local(2017, 11, 5, 1);
expect(d.hour).toBe(1);
expect(d.offset).toBe(-4 * 60);

Settings.now = () => 1484456400000; // Jan 15, 2017
d = local(2017, 11, 5, 1);
expect(d.hour).toBe(1);
expect(d.offset).toBe(-5 * 60);
} finally {
Settings.now = oldSettings;
}
});
} else {
test("Ambiguous dates pick the one with the cached offset", () => {
const oldSettings = Settings.now;
try {
DateTime._zoneOffsetGuessCache = {};
DateTime._zoneOffsetTs = undefined;
Settings.now = () => 1495653314595; // May 24, 2017
let d = local(2017, 11, 5, 1);
expect(d.hour).toBe(1);
expect(d.offset).toBe(-4 * 60);

Settings.now = () => 1484456400000; // Jan 15, 2017
d = local(2017, 11, 5, 1);
expect(d.hour).toBe(1);
expect(d.offset).toBe(-4 * 60);

DateTime._zoneOffsetGuessCache = {};
DateTime._zoneOffsetTs = undefined;

Settings.now = () => 1484456400000; // Jan 15, 2017
d = local(2017, 11, 5, 1);
expect(d.hour).toBe(1);
expect(d.offset).toBe(-5 * 60);

Settings.now = () => 1495653314595; // May 24, 2017
d = local(2017, 11, 5, 1);
expect(d.hour).toBe(1);
expect(d.offset).toBe(-5 * 60);
} finally {
Settings.now = oldSettings;
}
});
}
).startOf("day");
expect(d.day).toBe(15);
expect(d.hour).toBe(1);
expect(d.minute).toBe(0);
expect(d.second).toBe(0);
});

test("End of a 0:00->1:00 DST day is 23:59", () => {
const d = DateTime.fromObject(
{
year: 2017,
month: 10,
day: 15,
},
{
zone: "America/Sao_Paulo",
test("Adding an hour to land on the Spring Forward springs forward", () => {
const d = local(2017, 3, 12, 1).plus({ hour: 1 });
expect(d.hour).toBe(3);
expect(d.offset).toBe(-4 * 60);
});

test("Subtracting an hour to land on the Spring Forward springs forward", () => {
const d = local(2017, 3, 12, 3).minus({ hour: 1 });
expect(d.hour).toBe(1);
expect(d.offset).toBe(-5 * 60);
});

test("Adding an hour to land on the Fall Back falls back", () => {
const d = local(2017, 11, 5, 0).plus({ hour: 2 });
expect(d.hour).toBe(1);
expect(d.offset).toBe(-5 * 60);
});

test("Subtracting an hour to land on the Fall Back falls back", () => {
let d = local(2017, 11, 5, 3).minus({ hour: 2 });
expect(d.hour).toBe(1);
expect(d.offset).toBe(-5 * 60);

d = d.minus({ hour: 1 });
expect(d.hour).toBe(1);
expect(d.offset).toBe(-4 * 60);
});

test("Changing a calendar date to land on a hole bumps forward", () => {
let d = local(2017, 3, 11, 2).plus({ day: 1 });
expect(d.hour).toBe(3);
expect(d.offset).toBe(-4 * 60);

d = local(2017, 3, 13, 2).minus({ day: 1 });
expect(d.hour).toBe(3);
expect(d.offset).toBe(-4 * 60);
});

test("Changing a calendar date to land on an ambiguous time chooses the closest one", () => {
let d = local(2017, 11, 4, 1).plus({ day: 1 });
expect(d.hour).toBe(1);
expect(d.offset).toBe(-4 * 60);

d = local(2017, 11, 6, 1).minus({ day: 1 });
expect(d.hour).toBe(1);
expect(d.offset).toBe(-5 * 60);
});

test("Start of a 0:00->1:00 DST day is 1:00", () => {
const d = DateTime.fromObject(
{
year: 2017,
month: 10,
day: 15,
},
{
zone: "America/Sao_Paulo",
}
).startOf("day");
expect(d.day).toBe(15);
expect(d.hour).toBe(1);
expect(d.minute).toBe(0);
expect(d.second).toBe(0);
});

test("End of a 0:00->1:00 DST day is 23:59", () => {
const d = DateTime.fromObject(
{
year: 2017,
month: 10,
day: 15,
},
{
zone: "America/Sao_Paulo",
}
).endOf("day");
expect(d.day).toBe(15);
expect(d.hour).toBe(23);
expect(d.minute).toBe(59);
expect(d.second).toBe(59);
});
});
}

describe("DateTime.local() with offset caching", () => {
const edtTs = 1495653314000; // May 24, 2017 15:15:14 -0400
const estTs = 1484456400000; // Jan 15, 2017 00:00 -0500

const edtDate = [2017, 5, 24, 15, 15, 14, 0];
const estDate = [2017, 1, 15, 0, 0, 0, 0];

const timestamps = { EDT: edtTs, EST: estTs };
const dates = { EDT: edtDate, EST: estDate };
const zoneObj = { zone: "America/New_York" };

for (const [cacheName, cacheTs] of Object.entries(timestamps)) {
for (const [nowName, nowTs] of Object.entries(timestamps)) {
for (const [dateName, date] of Object.entries(dates)) {
test(`cache = ${cacheName}, now = ${nowName}, date = ${dateName}`, () => {
const oldSettings = Settings.now;
try {
Settings.now = () => cacheTs;
DateTime._zoneOffsetGuessCache = {};
DateTime._zoneOffsetTs = undefined;
// load cache
DateTime.local(2020, 1, 1, 0, zoneObj);

Settings.now = () => nowTs;
const dt = DateTime.local(...date, zoneObj);
expect(dt.toMillis()).toBe(timestamps[dateName]);
expect(dt.year).toBe(date[0]);
expect(dt.month).toBe(date[1]);
expect(dt.day).toBe(date[2]);
expect(dt.hour).toBe(date[3]);
expect(dt.minute).toBe(date[4]);
expect(dt.second).toBe(date[5]);
} finally {
Settings.now = oldSettings;
}
});
}
}
).endOf("day");
expect(d.day).toBe(15);
expect(d.hour).toBe(23);
expect(d.minute).toBe(59);
expect(d.second).toBe(59);
}
});
Loading

0 comments on commit ca50179

Please sign in to comment.