b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
443 lines
16 KiB
TypeScript
443 lines
16 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("virtual:emdash/wait-until", () => ({ waitUntil: undefined }), { virtual: true });
|
|
|
|
import { decode, encode } from "../../src/object-cache/codec.js";
|
|
import {
|
|
__setObjectCacheBackendForTests,
|
|
cachedQuery,
|
|
invalidateObjectCache,
|
|
type ObjectCacheBackend,
|
|
} from "../../src/object-cache/index.js";
|
|
import { createObjectCache as createMemoryCache } from "../../src/object-cache/memory.js";
|
|
import { runWithContext } from "../../src/request-context.js";
|
|
|
|
/** Flush the microtask + macrotask queue so deferred `after()` writes land. */
|
|
async function flush(): Promise<void> {
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
}
|
|
|
|
/** A simple in-memory backend with call spies, isolated per test. */
|
|
function spyBackend(): ObjectCacheBackend & { store: Map<string, string> } {
|
|
const store = new Map<string, string>();
|
|
return {
|
|
store,
|
|
get: vi.fn((key: string) => Promise.resolve(store.get(key) ?? null)),
|
|
set: vi.fn((key: string, value: string) => {
|
|
store.set(key, value);
|
|
return Promise.resolve();
|
|
}),
|
|
delete: vi.fn((key: string) => {
|
|
store.delete(key);
|
|
return Promise.resolve();
|
|
}),
|
|
};
|
|
}
|
|
|
|
describe("object-cache codec", () => {
|
|
it("round-trips primitives, arrays, and nested objects", () => {
|
|
const value = { a: 1, b: "two", c: [3, 4], d: { e: true, f: null } };
|
|
expect(decode(encode(value))).toEqual(value);
|
|
});
|
|
|
|
it("preserves Date instances", () => {
|
|
const value = { createdAt: new Date("2024-01-02T03:04:05.678Z"), nested: { d: new Date(0) } };
|
|
const decoded = decode(encode(value)) as typeof value;
|
|
expect(decoded.createdAt).toBeInstanceOf(Date);
|
|
expect(decoded.createdAt.toISOString()).toBe("2024-01-02T03:04:05.678Z");
|
|
expect(decoded.nested.d).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("drops functions and symbol-keyed properties (not JSON-representable)", () => {
|
|
const sym = Symbol("hidden");
|
|
const value: Record<string, unknown> = { keep: 1, fn: () => 42 };
|
|
Object.defineProperty(value, sym, { value: "x", enumerable: false });
|
|
const decoded = decode(encode(value)) as Record<string, unknown>;
|
|
expect(decoded).toEqual({ keep: 1 });
|
|
});
|
|
|
|
it("returns undefined for malformed input (treated as a miss)", () => {
|
|
expect(decode("not json{")).toBeUndefined();
|
|
});
|
|
|
|
it("does not collapse a multi-key user object that carries the date tag", () => {
|
|
const value = { meta: { $$emdashDate: "not actually a date marker", other: 1 } };
|
|
expect(decode(encode(value))).toEqual(value);
|
|
});
|
|
});
|
|
|
|
describe("memory backend", () => {
|
|
it("stores and retrieves values", async () => {
|
|
const cache = createMemoryCache({ maxEntries: 10 });
|
|
await cache.set("k", "v");
|
|
expect(await cache.get("k")).toBe("v");
|
|
});
|
|
|
|
it("returns null after delete", async () => {
|
|
const cache = createMemoryCache({});
|
|
await cache.set("k", "v");
|
|
await cache.delete("k");
|
|
expect(await cache.get("k")).toBeNull();
|
|
});
|
|
|
|
it("expires entries past their TTL", async () => {
|
|
const cache = createMemoryCache({});
|
|
await cache.set("k", "v", 1);
|
|
expect(await cache.get("k")).toBe("v");
|
|
vi.spyOn(Date, "now").mockReturnValue(Date.now() + 2000);
|
|
expect(await cache.get("k")).toBeNull();
|
|
vi.restoreAllMocks();
|
|
});
|
|
});
|
|
|
|
describe("cachedQuery", () => {
|
|
beforeEach(() => {
|
|
__setObjectCacheBackendForTests(spyBackend(), { revalidate: 1000, defaultTtl: 3600 });
|
|
});
|
|
afterEach(() => {
|
|
__setObjectCacheBackendForTests(null);
|
|
});
|
|
|
|
it("passes through to load when no backend is configured", async () => {
|
|
__setObjectCacheBackendForTests(null);
|
|
const load = vi.fn(() => Promise.resolve({ n: 1 }));
|
|
const result = await cachedQuery({ namespace: "t", key: "k", load });
|
|
expect(result).toEqual({ n: 1 });
|
|
expect(load).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("serves the second call from cache without calling load", async () => {
|
|
const load = vi.fn(() => Promise.resolve({ n: Math.random() }));
|
|
const first = await cachedQuery({ namespace: "t", key: "k", load });
|
|
await flush(); // let the deferred set land
|
|
const second = await cachedQuery({ namespace: "t", key: "k", load });
|
|
expect(second).toEqual(first);
|
|
expect(load).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("preserves Date values through the cache round-trip", async () => {
|
|
const load = vi.fn(() => Promise.resolve({ when: new Date("2025-06-01T00:00:00.000Z") }));
|
|
await cachedQuery({ namespace: "t", key: "d", load });
|
|
await flush();
|
|
const hit = await cachedQuery<{ when: Date }>({ namespace: "t", key: "d", load });
|
|
expect(hit.when).toBeInstanceOf(Date);
|
|
expect(hit.when.toISOString()).toBe("2025-06-01T00:00:00.000Z");
|
|
expect(load).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not cache values rejected by `cacheable`", async () => {
|
|
const load = vi.fn(() => Promise.resolve({ ok: false }));
|
|
await cachedQuery({ namespace: "t", key: "e", load, cacheable: (v) => v.ok });
|
|
await flush();
|
|
await cachedQuery({ namespace: "t", key: "e", load, cacheable: (v) => v.ok });
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("bypasses the cache in edit mode", async () => {
|
|
const load = vi.fn(() => Promise.resolve({ n: 1 }));
|
|
await runWithContext({ editMode: true }, async () => {
|
|
await cachedQuery({ namespace: "t", key: "k", load });
|
|
await cachedQuery({ namespace: "t", key: "k", load });
|
|
});
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("bypasses the cache for preview requests", async () => {
|
|
const load = vi.fn(() => Promise.resolve({ n: 1 }));
|
|
await runWithContext(
|
|
{ editMode: false, preview: { collection: "posts", id: "1" } },
|
|
async () => {
|
|
await cachedQuery({ namespace: "t", key: "k", load });
|
|
await cachedQuery({ namespace: "t", key: "k", load });
|
|
},
|
|
);
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("reloads after the namespace is invalidated", async () => {
|
|
const load = vi.fn(() => Promise.resolve({ n: Math.random() }));
|
|
await cachedQuery({ namespace: "posts", key: "k", load });
|
|
await flush();
|
|
await cachedQuery({ namespace: "posts", key: "k", load });
|
|
expect(load).toHaveBeenCalledTimes(1);
|
|
|
|
invalidateObjectCache("posts");
|
|
await flush();
|
|
|
|
await cachedQuery({ namespace: "posts", key: "k", load });
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("invalidates when any namespace in a multi-namespace key is bumped", async () => {
|
|
const load = vi.fn(() => Promise.resolve({ n: Math.random() }));
|
|
const ns = ["content:posts", "bylines", "taxonomies"];
|
|
await cachedQuery({ namespace: ns, key: "k", load });
|
|
await flush();
|
|
await cachedQuery({ namespace: ns, key: "k", load });
|
|
expect(load).toHaveBeenCalledTimes(1);
|
|
|
|
// Bump only the shared bylines namespace.
|
|
invalidateObjectCache("bylines");
|
|
await flush();
|
|
|
|
await cachedQuery({ namespace: ns, key: "k", load });
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not let a stale in-flight epoch read clobber a concurrent invalidation", async () => {
|
|
// An epoch read started before an invalidation must not, on resolving with
|
|
// the pre-bump backend value, lower the freshly-bumped local epoch — that
|
|
// would resurrect the values the invalidation just orphaned.
|
|
const store = new Map<string, string>();
|
|
let releaseEpoch: ((v: string | null) => void) | undefined;
|
|
let gate = false;
|
|
const backend: ObjectCacheBackend = {
|
|
get: (key) => {
|
|
if (gate && key.includes(":epoch:")) {
|
|
return new Promise<string | null>((resolve) => {
|
|
releaseEpoch = resolve;
|
|
});
|
|
}
|
|
return Promise.resolve(store.get(key) ?? null);
|
|
},
|
|
set: (key, value) => {
|
|
store.set(key, value);
|
|
return Promise.resolve();
|
|
},
|
|
delete: (key) => {
|
|
store.delete(key);
|
|
return Promise.resolve();
|
|
},
|
|
};
|
|
__setObjectCacheBackendForTests(backend, { revalidate: 0, defaultTtl: 3600 });
|
|
|
|
const load = vi.fn(() => Promise.resolve({ n: Math.random() }));
|
|
|
|
// Prime: value stored under epoch 0.
|
|
const primed = await cachedQuery({ namespace: "posts", key: "k", load });
|
|
await flush();
|
|
|
|
// Start a query whose epoch read parks in flight.
|
|
gate = true;
|
|
const inflight = cachedQuery({ namespace: "posts", key: "k", load });
|
|
await flush();
|
|
|
|
// Invalidate mid-flight: bumps the local epoch above 0.
|
|
invalidateObjectCache("posts");
|
|
|
|
// The parked epoch read now resolves with the stale backend epoch.
|
|
releaseEpoch?.(null);
|
|
const inflightResult = await inflight;
|
|
await flush();
|
|
|
|
// The bump must survive: the in-flight query reloads rather than serving
|
|
// the pre-invalidation value.
|
|
expect(inflightResult).not.toEqual(primed);
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("captures epochs before load even when the value read fails", async () => {
|
|
// When the value read errors, the epochs must still be captured *before*
|
|
// load() runs. Re-reading them afterwards would pick up a write that
|
|
// landed mid-load and stamp the stale value under the new epoch, so a
|
|
// later read would serve it as a HIT.
|
|
const store = new Map<string, string>();
|
|
let failValueGet = true;
|
|
let releaseLoad: (() => void) | undefined;
|
|
const backend: ObjectCacheBackend = {
|
|
get: (key) => {
|
|
if (key.includes(":epoch:")) return Promise.resolve(store.get(key) ?? null);
|
|
if (failValueGet) return Promise.reject(new Error("value get down"));
|
|
return Promise.resolve(store.get(key) ?? null);
|
|
},
|
|
set: (key, value) => {
|
|
store.set(key, value);
|
|
return Promise.resolve();
|
|
},
|
|
delete: (key) => {
|
|
store.delete(key);
|
|
return Promise.resolve();
|
|
},
|
|
};
|
|
__setObjectCacheBackendForTests(backend, { revalidate: 0, defaultTtl: 3600 });
|
|
|
|
let n = 0;
|
|
let parkFirst = true;
|
|
const load = vi.fn(() => {
|
|
const v = ++n;
|
|
if (parkFirst) {
|
|
parkFirst = false;
|
|
return new Promise<{ n: number }>((resolve) => {
|
|
releaseLoad = () => resolve({ n: v });
|
|
});
|
|
}
|
|
return Promise.resolve({ n: v });
|
|
});
|
|
|
|
// Value read rejects → load path. Hold load open.
|
|
const q1 = cachedQuery<{ n: number }>({ namespace: "posts", key: "k", load });
|
|
await flush();
|
|
|
|
// A write invalidates the namespace while load is in flight.
|
|
invalidateObjectCache("posts");
|
|
await flush();
|
|
|
|
releaseLoad?.();
|
|
const first = await q1;
|
|
await flush();
|
|
|
|
// Value reads work again; the value cached during the load must have been
|
|
// stamped with the pre-load epoch, so the bump orphans it and we reload.
|
|
failValueGet = false;
|
|
const second = await cachedQuery<{ n: number }>({ namespace: "posts", key: "k", load });
|
|
expect(second).not.toEqual(first);
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("treats a backend read error as a miss without throwing", async () => {
|
|
const backend = spyBackend();
|
|
backend.get = vi.fn(() => Promise.reject(new Error("kv down")));
|
|
__setObjectCacheBackendForTests(backend, { revalidate: 1000, defaultTtl: 3600 });
|
|
const load = vi.fn(() => Promise.resolve({ n: 1 }));
|
|
const result = await cachedQuery({ namespace: "t", key: "k", load });
|
|
expect(result).toEqual({ n: 1 });
|
|
expect(load).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not hang when a backend read never settles — times out to a miss", async () => {
|
|
// A backend.get that never resolves or rejects (the production hang:
|
|
// a stalled KV read). With a short timeout the query must still settle.
|
|
const backend = spyBackend();
|
|
backend.get = vi.fn(() => new Promise<string | null>(() => {})); // never settles
|
|
__setObjectCacheBackendForTests(backend, { revalidate: 1000, defaultTtl: 3600, timeout: 20 });
|
|
const load = vi.fn(() => Promise.resolve({ n: 1 }));
|
|
|
|
const result = await cachedQuery({ namespace: "t", key: "k", load });
|
|
expect(result).toEqual({ n: 1 });
|
|
expect(load).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("reclaims an epoch read whose owner was cancelled and never settles", async () => {
|
|
// On workerd a cancelled request's continuations never run — including
|
|
// the epoch read's own timeout timer — so the shared in-flight promise
|
|
// neither settles nor gets replaced. Simulated here with a get that
|
|
// never settles and a timeout too long to fire during the test; the
|
|
// deadline check is driven by Date.now, not timers.
|
|
const store = new Map<string, string>();
|
|
let stallEpoch = true;
|
|
const backend: ObjectCacheBackend = {
|
|
get: (key) => {
|
|
if (stallEpoch && key.includes(":epoch:")) return new Promise<string | null>(() => {});
|
|
return Promise.resolve(store.get(key) ?? null);
|
|
},
|
|
set: (key, value) => {
|
|
store.set(key, value);
|
|
return Promise.resolve();
|
|
},
|
|
delete: (key) => {
|
|
store.delete(key);
|
|
return Promise.resolve();
|
|
},
|
|
};
|
|
__setObjectCacheBackendForTests(backend, { revalidate: 0, defaultTtl: 3600, timeout: 5000 });
|
|
|
|
const load = vi.fn(() => Promise.resolve({ n: 1 }));
|
|
|
|
// The cancelled owner: parks on the epoch read and is never awaited.
|
|
void cachedQuery({ namespace: "posts", key: "k", load }).catch(() => {});
|
|
await flush();
|
|
|
|
// Past the read deadline (timeout + grace) the owner is presumed dead:
|
|
// the next caller must start a fresh read instead of joining the dead
|
|
// promise — which, on a recovered backend, settles normally.
|
|
const realNow = Date.now();
|
|
vi.spyOn(Date, "now").mockReturnValue(realNow + 6001);
|
|
stallEpoch = false;
|
|
|
|
await expect(cachedQuery({ namespace: "posts", key: "k", load })).resolves.toEqual({
|
|
n: 1,
|
|
});
|
|
expect(load).toHaveBeenCalledTimes(1);
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("bounds a waiter on a foreign in-flight epoch read with its own timer", async () => {
|
|
// A waiter must never await another request's epoch-read promise bare:
|
|
// if the owner was cancelled the promise never settles. The waiter's
|
|
// race timer belongs to the waiter's own (live) request, so the query
|
|
// degrades to the last-known epoch instead of hanging.
|
|
const store = new Map<string, string>();
|
|
const backend: ObjectCacheBackend = {
|
|
get: (key) => {
|
|
if (key.includes(":epoch:")) return new Promise<string | null>(() => {}); // never settles
|
|
return Promise.resolve(store.get(key) ?? null);
|
|
},
|
|
set: (key, value) => {
|
|
store.set(key, value);
|
|
return Promise.resolve();
|
|
},
|
|
delete: (key) => {
|
|
store.delete(key);
|
|
return Promise.resolve();
|
|
},
|
|
};
|
|
__setObjectCacheBackendForTests(backend, {
|
|
revalidate: 0,
|
|
defaultTtl: 3600,
|
|
timeout: 20_000,
|
|
});
|
|
|
|
const load = vi.fn(() => Promise.resolve({ n: 1 }));
|
|
|
|
// The cancelled owner parks on the epoch read.
|
|
void cachedQuery({ namespace: "posts", key: "k", load }).catch(() => {});
|
|
await flush();
|
|
|
|
// A waiter arriving late in the share window: its remaining bound is
|
|
// short, so its own timer fires and the query settles. (Unfixed, the
|
|
// waiter awaits the dead promise and hangs past the test timeout.)
|
|
const realNow = Date.now();
|
|
vi.spyOn(Date, "now").mockReturnValue(realNow + 20_900);
|
|
await expect(cachedQuery({ namespace: "posts", key: "k", load })).resolves.toEqual({
|
|
n: 1,
|
|
});
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("self-heals after a stalled read instead of poisoning the namespace", async () => {
|
|
// First the backend stalls (epoch + value reads hang); after the timeout
|
|
// the namespace must recover and serve from cache on a healthy backend.
|
|
const store = new Map<string, string>();
|
|
let healthy = false;
|
|
const backend: ObjectCacheBackend = {
|
|
get: (key) =>
|
|
healthy ? Promise.resolve(store.get(key) ?? null) : new Promise<string | null>(() => {}), // stalls while unhealthy
|
|
set: (key, value) => {
|
|
store.set(key, value);
|
|
return Promise.resolve();
|
|
},
|
|
delete: (key) => {
|
|
store.delete(key);
|
|
return Promise.resolve();
|
|
},
|
|
};
|
|
__setObjectCacheBackendForTests(backend, { revalidate: 0, defaultTtl: 3600, timeout: 20 });
|
|
|
|
const load = vi.fn(() => Promise.resolve({ n: 1 }));
|
|
|
|
// While stalled: degrades to load() instead of hanging.
|
|
await expect(cachedQuery({ namespace: "posts", key: "k", load })).resolves.toEqual({ n: 1 });
|
|
|
|
// Backend recovers; the stuck epoch promise must have settled (timed out)
|
|
// and been replaced, so the namespace is usable again.
|
|
healthy = true;
|
|
await flush();
|
|
await cachedQuery({ namespace: "posts", key: "k", load });
|
|
await flush();
|
|
const calls = load.mock.calls.length;
|
|
await cachedQuery({ namespace: "posts", key: "k", load });
|
|
// The second post-recovery call is served from cache (load not re-run).
|
|
expect(load.mock.calls.length).toBe(calls);
|
|
});
|
|
});
|