web: harden the cache purge scheduler
This commit is contained in:
6 files changed
+159
-13
No files matched your search
@@ -0,0 +1,43 @@
|
|||||||
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const purgeOwnCache = vi.fn();
|
||||||
|
vi.mock("@/lib/internalRevalidate", () => ({
|
||||||
|
purgeOwnCache: (...args: unknown[]) => purgeOwnCache(...args),
|
||||||
|
setInternalRevalidateSecret: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { register } from "./instrumentation.ts";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
// 23:59:50 in Oslo (CEST)
|
||||||
|
vi.setSystemTime(new Date("2026-07-07T21:59:50Z"));
|
||||||
|
vi.stubEnv("NEXT_RUNTIME", "nodejs");
|
||||||
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
purgeOwnCache.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The cache only rolls over because this loop keeps running, so nothing
|
||||||
|
// thrown inside one check may end it
|
||||||
|
it("keeps checking after a check has thrown", async () => {
|
||||||
|
purgeOwnCache.mockResolvedValue(null);
|
||||||
|
register();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
purgeOwnCache.mockClear();
|
||||||
|
|
||||||
|
purgeOwnCache.mockRejectedValueOnce(new Error("boom"));
|
||||||
|
await vi.advanceTimersByTimeAsync(11_000); // 00:00:01: throws
|
||||||
|
expect(purgeOwnCache).toHaveBeenCalledTimes(1);
|
||||||
|
expect(console.error).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000); // the next check purges
|
||||||
|
expect(purgeOwnCache).toHaveBeenCalledTimes(2);
|
||||||
|
expect(purgeOwnCache).toHaveBeenLastCalledWith("today");
|
||||||
|
});
|
||||||
@@ -74,6 +74,28 @@ describe("at startup", () => {
|
|||||||
expect(purgedTags()).toEqual(["cms", "today", "cms", "today"]);
|
expect(purgedTags()).toEqual(["cms", "today", "cms", "today"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives up on a purge that hangs", async () => {
|
||||||
|
register();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a wildcard bind", "http://localhost:8080", "http://127.0.0.1:8080"],
|
||||||
|
["a specific address", "http://10.0.0.5:8080", "http://10.0.0.5:8080"],
|
||||||
|
["IPv6 loopback", "http://[::1]:8080", "http://[::1]:8080"],
|
||||||
|
])("uses the address Next recorded for %s", async (_what, recorded, origin) => {
|
||||||
|
vi.stubEnv("__NEXT_PRIVATE_ORIGIN", recorded);
|
||||||
|
|
||||||
|
register();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
||||||
|
`${origin}/api/revalidate?tag=cms`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["an IPv4 address", "10.0.0.5", "http://10.0.0.5:4321"],
|
["an IPv4 address", "10.0.0.5", "http://10.0.0.5:4321"],
|
||||||
["an IPv6 address", "::1", "http://[::1]:4321"],
|
["an IPv6 address", "::1", "http://[::1]:4321"],
|
||||||
@@ -132,6 +154,37 @@ describe("at Oslo midnight", () => {
|
|||||||
expect(purgedTags()).toEqual(["today"]);
|
expect(purgedTags()).toEqual(["today"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("retries the follow-up purge if that one fails", async () => {
|
||||||
|
await start();
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce({ ok: true, status: 200 }) // 00:00:01
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 503 }); // the follow-up
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(11_000);
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
expect(purgedTags()).toEqual(["today", "today"]);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
expect(purgedTags()).toEqual(["today", "today", "today"]);
|
||||||
|
|
||||||
|
// and then it is done for the day
|
||||||
|
await vi.advanceTimersByTimeAsync(3600 * 1000);
|
||||||
|
expect(purgedTags()).toEqual(["today", "today", "today"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits the full 25 hours on the day the clocks go back", async () => {
|
||||||
|
// 00:00:30 on søndag 25. oktober 2026 in Oslo; the next midnight is 25 h off
|
||||||
|
vi.setSystemTime(new Date("2026-10-24T22:00:30Z"));
|
||||||
|
await start();
|
||||||
|
|
||||||
|
// 23:59:30, 24 hours and 59 minutes later
|
||||||
|
await vi.advanceTimersByTimeAsync(24 * 3600 * 1000 + 59 * 60 * 1000);
|
||||||
|
expect(purgedTags()).toEqual([]);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(31 * 1000);
|
||||||
|
expect(purgedTags()).toEqual(["today"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("retries a failed purge at the next check, and says so", async () => {
|
it("retries a failed purge at the next check, and says so", async () => {
|
||||||
await start();
|
await start();
|
||||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 503 });
|
fetchMock.mockResolvedValueOnce({ ok: false, status: 503 });
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
// How often the clock is checked. Timers alone can't be trusted to fire at
|
// How often the clock is checked. Timers alone can't be trusted to fire at
|
||||||
// midnight: they stand still while a host or VM is paused
|
// midnight: they stand still while a host or VM is paused
|
||||||
const CHECK_INTERVAL_MS = 30_000;
|
const CHECK_INTERVAL_MS = 30_000;
|
||||||
// Until the first purge has gone through; the server isn't listening yet
|
// Until the first purge has gone through. The server is already listening
|
||||||
// when register() runs
|
// when register() runs, so this is only for a purge that fails
|
||||||
const STARTUP_RETRY_MS = 1_000;
|
const STARTUP_RETRY_MS = 1_000;
|
||||||
|
|
||||||
function keepCacheCurrent() {
|
function keepCacheCurrent() {
|
||||||
@@ -24,6 +24,18 @@ function keepCacheCurrent() {
|
|||||||
let failures = 0;
|
let failures = 0;
|
||||||
|
|
||||||
async function check() {
|
async function check() {
|
||||||
|
try {
|
||||||
|
await purgeWhatIsDue();
|
||||||
|
} catch (error) {
|
||||||
|
// nothing in there is expected to throw, but if something does the
|
||||||
|
// loop must survive it: without it the cache never rolls over
|
||||||
|
console.error("Checking the page cache failed", error);
|
||||||
|
} finally {
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purgeWhatIsDue() {
|
||||||
const day = osloDay();
|
const day = osloDay();
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
|
|
||||||
@@ -50,14 +62,14 @@ function keepCacheCurrent() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
failures += 1;
|
failures += 1;
|
||||||
// at startup, connection errors are expected for the first moments
|
// report the first failure once running, and then now and again; at
|
||||||
|
// startup only if it goes on for a minute
|
||||||
if (failures === 1 ? purgedDay !== undefined : failures % 60 === 0) {
|
if (failures === 1 ? purgedDay !== undefined : failures % 60 === 0) {
|
||||||
console.error(`Purging the page cache keeps failing: ${error}`);
|
console.error(`Purging the page cache keeps failing: ${error}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
failures = 0;
|
failures = 0;
|
||||||
}
|
}
|
||||||
schedule();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedule() {
|
function schedule() {
|
||||||
|
|||||||
@@ -20,9 +20,15 @@ export function setInternalRevalidateSecret(secret: string) {
|
|||||||
|
|
||||||
const PURGE_TIMEOUT_MS = 10_000;
|
const PURGE_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
// The server is reached the way it listens (the standalone server binds to
|
// Where this server listens. Next records the address it actually bound to
|
||||||
// HOSTNAME); the wildcard addresses are reachable over loopback
|
// once it is listening (an internal variable, hence the fallback, which
|
||||||
|
// rebuilds it from the HOSTNAME and PORT the standalone server reads)
|
||||||
function ownOrigin(): string {
|
function ownOrigin(): string {
|
||||||
|
const recorded = process.env.__NEXT_PRIVATE_ORIGIN;
|
||||||
|
if (recorded) {
|
||||||
|
// "localhost" stands for a wildcard bind; skip the name lookup
|
||||||
|
return recorded.replace("//localhost:", "//127.0.0.1:");
|
||||||
|
}
|
||||||
const hostname = process.env.HOSTNAME;
|
const hostname = process.env.HOSTNAME;
|
||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
if (!hostname || hostname === "0.0.0.0" || hostname === "::") {
|
if (!hostname || hostname === "0.0.0.0" || hostname === "::") {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { secondsUntilOsloMidnight } from "./revalidation.ts";
|
import { secondsUntilOsloMidnight } from "./revalidation.ts";
|
||||||
|
|
||||||
@@ -28,3 +28,30 @@ describe("secondsUntilOsloMidnight", () => {
|
|||||||
expect(secondsUntilOsloMidnight(now)).toBe(23 * 3600);
|
expect(secondsUntilOsloMidnight(now)).toBe(23 * 3600);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The Dockerfile sets TZ=Europe/Oslo, but the result must not depend on it
|
||||||
|
describe.each(["Europe/Oslo", "UTC", "Asia/Tokyo", "America/Santiago"])(
|
||||||
|
"secondsUntilOsloMidnight with the process in %s",
|
||||||
|
(tz) => {
|
||||||
|
const originalTz = process.env.TZ;
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.TZ = originalTz;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts to the next Oslo midnight", () => {
|
||||||
|
process.env.TZ = tz;
|
||||||
|
// 14:00 CEST
|
||||||
|
expect(secondsUntilOsloMidnight(new Date("2026-09-05T12:00:00Z"))).toBe(
|
||||||
|
10 * 3600
|
||||||
|
);
|
||||||
|
// 23:00 CEST, the night Chile moves its clocks
|
||||||
|
expect(secondsUntilOsloMidnight(new Date("2026-09-05T21:00:00Z"))).toBe(
|
||||||
|
3600
|
||||||
|
);
|
||||||
|
// the 25-hour day in Oslo
|
||||||
|
expect(secondsUntilOsloMidnight(new Date("2026-10-24T22:00:00Z"))).toBe(
|
||||||
|
25 * 3600
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { addDays, startOfDay } from "date-fns";
|
import { formatInTimeZone, fromZonedTime } from "date-fns-tz";
|
||||||
import { fromZonedTime, toZonedTime } from "date-fns-tz";
|
|
||||||
import type { OperationContext } from "@urql/core";
|
import type { OperationContext } from "@urql/core";
|
||||||
|
|
||||||
const timeZone = "Europe/Oslo";
|
const timeZone = "Europe/Oslo";
|
||||||
@@ -28,10 +27,16 @@ export const CMS_CACHE_LIFE = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function secondsUntilOsloMidnight(now: Date = new Date()): number {
|
export function secondsUntilOsloMidnight(now: Date = new Date()): number {
|
||||||
const osloNow = toZonedTime(now, timeZone);
|
// Worked out from the Oslo date as text, so the timezone the process runs
|
||||||
const nextOsloMidnight = startOfDay(addDays(osloNow, 1));
|
// in (and its daylight saving changes) can't shift the result
|
||||||
const instant = fromZonedTime(nextOsloMidnight, timeZone);
|
const [year, month, day] = formatInTimeZone(now, timeZone, "yyyy-MM-dd")
|
||||||
return Math.ceil((instant.getTime() - now.getTime()) / 1000);
|
.split("-")
|
||||||
|
.map(Number);
|
||||||
|
const tomorrow = new Date(Date.UTC(year, month - 1, day + 1))
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10);
|
||||||
|
const nextOsloMidnight = fromZonedTime(`${tomorrow}T00:00:00`, timeZone);
|
||||||
|
return Math.ceil((nextOsloMidnight.getTime() - now.getTime()) / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For per-user (search) and token-scoped (preview) queries, which must
|
// For per-user (search) and token-scoped (preview) queries, which must
|
||||||
|
|||||||
Reference in new issue
Block a user