web: harden the cache purge scheduler

This commit is contained in:
ponas committed 2026-10-14 00:36:54 +02:00
1 parent ef5dd54f2e
commit a4bfdb5657
6 files changed
+159 -13

No files matched your search

+43
View File
@@ -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");
});
+53
View File
@@ -74,6 +74,28 @@ describe("at startup", () => {
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([
["an IPv4 address", "10.0.0.5", "http://10.0.0.5:4321"],
["an IPv6 address", "::1", "http://[::1]:4321"],
@@ -132,6 +154,37 @@ describe("at Oslo midnight", () => {
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 () => {
await start();
fetchMock.mockResolvedValueOnce({ ok: false, status: 503 });
+16 -4
View File
@@ -12,8 +12,8 @@ import {
// 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
const CHECK_INTERVAL_MS = 30_000;
// Until the first purge has gone through; the server isn't listening yet
// when register() runs
// Until the first purge has gone through. The server is already listening
// when register() runs, so this is only for a purge that fails
const STARTUP_RETRY_MS = 1_000;
function keepCacheCurrent() {
@@ -24,6 +24,18 @@ function keepCacheCurrent() {
let failures = 0;
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();
let error: string | null = null;
@@ -50,14 +62,14 @@ function keepCacheCurrent() {
if (error) {
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) {
console.error(`Purging the page cache keeps failing: ${error}`);
}
} else {
failures = 0;
}
schedule();
}
function schedule() {
+8 -2
View File
@@ -20,9 +20,15 @@ export function setInternalRevalidateSecret(secret: string) {
const PURGE_TIMEOUT_MS = 10_000;
// The server is reached the way it listens (the standalone server binds to
// HOSTNAME); the wildcard addresses are reachable over loopback
// Where this server listens. Next records the address it actually bound to
// 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 {
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 port = process.env.PORT || 3000;
if (!hostname || hostname === "0.0.0.0" || hostname === "::") {
+28 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { secondsUntilOsloMidnight } from "./revalidation.ts";
@@ -28,3 +28,30 @@ describe("secondsUntilOsloMidnight", () => {
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
);
});
}
);
+11 -6
View File
@@ -1,5 +1,4 @@
import { addDays, startOfDay } from "date-fns";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
import { formatInTimeZone, fromZonedTime } from "date-fns-tz";
import type { OperationContext } from "@urql/core";
const timeZone = "Europe/Oslo";
@@ -28,10 +27,16 @@ export const CMS_CACHE_LIFE = {
};
export function secondsUntilOsloMidnight(now: Date = new Date()): number {
const osloNow = toZonedTime(now, timeZone);
const nextOsloMidnight = startOfDay(addDays(osloNow, 1));
const instant = fromZonedTime(nextOsloMidnight, timeZone);
return Math.ceil((instant.getTime() - now.getTime()) / 1000);
// Worked out from the Oslo date as text, so the timezone the process runs
// in (and its daylight saving changes) can't shift the result
const [year, month, day] = formatInTimeZone(now, timeZone, "yyyy-MM-dd")
.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