Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

207 lines
6.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
createSingleFlightCache,
invalidateSingleFlightCache,
singleFlightCached,
} from "../../../src/utils/single-flight-cache.js";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** A promise that never settles — simulates a fetch whose owning request
* context was torn down mid-await (workerd cancels the continuation, so
* neither `then` nor `finally` ever runs). This is the production poisoning
* scenario. */
function neverSettles<T>(): Promise<T> {
return new Promise<T>(() => {});
}
describe("singleFlightCached", () => {
it("caches the resolved value across calls (one fetch)", async () => {
const cache = createSingleFlightCache<number>();
let calls = 0;
const fetch = async () => {
calls++;
return 5;
};
expect(await singleFlightCached(cache, fetch, { pollMs: 10 })).toBe(5);
expect(await singleFlightCached(cache, fetch, { pollMs: 10 })).toBe(5);
expect(calls).toBe(1);
});
it("caches a void/undefined result so it is not treated as a miss", async () => {
// The reason the cache stores a boxed value + presence flag rather
// than relying on a null check: a falsy/void result must still count
// as cached. Without the box this would refetch every call.
const cache = createSingleFlightCache<void>();
let calls = 0;
const fetch = async () => {
calls++;
};
await singleFlightCached(cache, fetch, { pollMs: 10 });
await singleFlightCached(cache, fetch, { pollMs: 10 });
expect(calls).toBe(1);
});
it("coalesces concurrent callers into a single fetch", async () => {
const cache = createSingleFlightCache<number>();
let calls = 0;
const fetch = async () => {
calls++;
await sleep(30);
return 7;
};
const results = await Promise.all(
Array.from({ length: 5 }, () => singleFlightCached(cache, fetch, { pollMs: 5 })),
);
expect(results).toEqual([7, 7, 7, 7, 7]);
expect(calls).toBe(1);
});
it("invalidateSingleFlightCache forces a refetch", async () => {
const cache = createSingleFlightCache<number>();
let n = 0;
const fetch = async () => ++n;
expect(await singleFlightCached(cache, fetch, { pollMs: 10 })).toBe(1);
expect(await singleFlightCached(cache, fetch, { pollMs: 10 })).toBe(1);
invalidateSingleFlightCache(cache);
expect(await singleFlightCached(cache, fetch, { pollMs: 10 })).toBe(2);
});
it("a stranded owner fetch does not poison later callers", async () => {
// The whole point of the rewrite. First caller claims, then its
// fetch never settles (cancelled request). With the old "cache the
// in-flight promise" approach every later caller awaited that dead
// promise forever. Here later callers poll a value, reclaim the stale
// lock after the deadline, and succeed.
const cache = createSingleFlightCache<string>();
void singleFlightCached(cache, () => neverSettles<string>(), {
deadlineMs: 100,
pollMs: 10,
});
expect(cache.lock.ownerStartedAt).not.toBeNull();
await sleep(120);
const result = await singleFlightCached(cache, async () => "recovered", {
deadlineMs: 100,
pollMs: 10,
maxWaitMs: 1000,
});
expect(result).toBe("recovered");
});
it("rejects the owner at ownerTimeoutMs instead of hanging forever", async () => {
const cache = createSingleFlightCache<string>();
await expect(
singleFlightCached(cache, () => neverSettles<string>(), {
ownerTimeoutMs: 50,
deadlineMs: 60_000, // high so reclaim doesn't mask the owner timeout
pollMs: 10,
}),
).rejects.toThrow(/exceeded/i);
});
it("anchored fetch still publishes for a later caller after the owner times out", async () => {
// A slow-but-live fetch: the owner gives up at ownerTimeoutMs, but the
// anchored copy keeps running and populates the cache, so the next
// caller is served without refetching.
const cache = createSingleFlightCache<string>();
let release!: (value: string) => void;
const slow = new Promise<string>((resolve) => {
release = resolve;
});
const anchored: Promise<void>[] = [];
await expect(
singleFlightCached(cache, () => slow, {
ownerTimeoutMs: 50,
deadlineMs: 60_000,
pollMs: 10,
anchor: (promise) => anchored.push(promise),
}),
).rejects.toThrow(/exceeded/i);
release("late-value");
await Promise.all(anchored);
let calls = 0;
const result = await singleFlightCached(
cache,
async () => {
calls++;
return "fresh";
},
{ pollMs: 10 },
);
expect(result).toBe("late-value");
expect(calls).toBe(0);
});
it("keeps the reclaim deadline above ownerTimeoutMs so a slow-but-live owner is not superseded", async () => {
// A deadlineMs smaller than ownerTimeoutMs, left unguarded, would let a
// waiter reclaim before the owner finishes — a self-sustaining stampede
// that never populates the cache. The helper must raise the effective
// deadline above the owner timeout.
const cache = createSingleFlightCache<string>();
let calls = 0;
const fetch = async () => {
calls++;
await sleep(150);
return "v";
};
const opts = { deadlineMs: 30, ownerTimeoutMs: 1000, pollMs: 10, maxWaitMs: 5000 };
const a = singleFlightCached(cache, fetch, opts);
await sleep(20);
const b = singleFlightCached(cache, fetch, opts);
expect(await a).toBe("v");
expect(await b).toBe("v");
expect(calls).toBe(1);
});
it("invalidation frees the lock so a fresh reader doesn't wait out a stale owner", async () => {
const cache = createSingleFlightCache<string>();
// A slow/stuck in-flight owner holds the lock.
void singleFlightCached(cache, () => neverSettles<string>(), {
deadlineMs: 10_000,
pollMs: 10,
});
expect(cache.lock.ownerStartedAt).not.toBeNull();
invalidateSingleFlightCache(cache);
expect(cache.lock.ownerStartedAt).toBeNull();
// maxWaitMs (200) is far below deadlineMs (10s): without the lock being
// freed, the fresh reader would give up before it could ever reclaim.
const result = await singleFlightCached(cache, async () => "fresh", {
deadlineMs: 10_000,
pollMs: 10,
maxWaitMs: 200,
});
expect(result).toBe("fresh");
});
it("ignores a non-positive ownerTimeoutMs instead of rejecting instantly", async () => {
const cache = createSingleFlightCache<string>();
const result = await singleFlightCached(cache, async () => "v", {
ownerTimeoutMs: 0,
pollMs: 10,
});
expect(result).toBe("v");
});
it("propagates a fetch rejection to the caller and lets the next caller retry", async () => {
const cache = createSingleFlightCache<string>();
await expect(
singleFlightCached(cache, () => Promise.reject(new Error("boom")), { pollMs: 10 }),
).rejects.toThrow("boom");
expect(cache.lock.ownerStartedAt).toBeNull();
const result = await singleFlightCached(cache, async () => "ok", { pollMs: 10 });
expect(result).toBe("ok");
});
});