Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m55s
CI / tests-unit (push) Failing after 2m14s
CI / tests-ui (push) Failing after 36m38s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Follow-up to f81b114b, addressing the three ways that commit could make things
worse rather than better. All three were verified against the real database or
by breaking the test and watching it fail.
- The grace window is now capped at 120s. A window is a cushion for the TTL
boundary, not a second TTL, but the call sites treated it as the latter: the
5 min values/staff routes and the 10 min teams route asked for a window as
long as or longer than their own TTL, so a single large staleMs silently
doubled how far behind a value could be served. Nothing marked those as
unsafe, because nothing looked wrong. The cap lives in the cache rather than
at the call sites so no future route can reintroduce it. Routes that asked
for less than 120s (the 10s online poll, the 20s news cache) are unchanged,
so their intended cushion still does its job.
- A request no longer queues behind an arbitrarily old render. Sharing a render
is what collapses a cold-cache stampede into one render, but a hung render
used to hold up everyone who arrived after it. A newcomer past 2s now serves
the placeholder instead of waiting, reusing the ImagerUnavailableError path
that "both upstreams down" already takes. The caller that actually started
the render keeps waiting, which is correct: it is the one whose image this
is. When the join window is removed the new test hangs for the full 10s it
was meant to prevent, which is the tail this bounds.
- The information_schema row-count estimate is gone; the counters are exact
again. Running it against the live database: users 165, rooms 92, camera_web
0, and the estimate was 0.00% off on all three. At 165 rows an index scan is
cheaper than the extra round trip the estimate needed, so the optimisation
bought nothing and traded a guaranteed-correct member count for an
approximation that InnoDB would only make less accurate as the table grows.
The exactness is now pinned by tests: a real zero stays zero, a database
error propagates instead of becoming a number, and each counter counts the
table it claims to. The module stays, because the homepage and the boot
warm-up writing different values to the same cache key is its own bug.
The module comment records the measured numbers, because "COUNT(*) is too slow"
sounds true in the abstract and is false here.
3223 tests pass.
139 lines
4.5 KiB
TypeScript
139 lines
4.5 KiB
TypeScript
// @ts-nocheck
|
|
import { beforeEach, expect, it, vi } from "vitest";
|
|
|
|
const state = vi.hoisted(() => ({ upstreamCalls: 0 }));
|
|
|
|
vi.mock("@/lib/imager-cache", () => ({
|
|
avatarCacheDir: () => "/tmp/imager-test",
|
|
imagingCacheKey: (value: string) => value,
|
|
// Nothing is ever cached on disk, so every call reaches the render path and
|
|
// the in-flight map is the only thing that can dedupe it.
|
|
readImagingCache: vi.fn(async () => null),
|
|
writeImagingCache: vi.fn(async () => {}),
|
|
}));
|
|
vi.mock("@/lib/runtime-asset-config", () => ({
|
|
resolveImagerBase: (origin: string) => `${origin}/imager`,
|
|
}));
|
|
|
|
import { fetchAvatarImage, ImagerUnavailableError } from "./imager-upstream";
|
|
|
|
const params = new URLSearchParams({ figure: "hr-1", direction: "2" });
|
|
|
|
beforeEach(() => {
|
|
state.upstreamCalls = 0;
|
|
globalThis.imagingInFlight?.clear();
|
|
// A render is not instantaneous, so every caller launched in the same tick
|
|
// is genuinely in flight at the same time. That overlap is what the
|
|
// in-flight map has to collapse.
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => {
|
|
state.upstreamCalls += 1;
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
return new Response(new Uint8Array([1, 2, 3]), {
|
|
headers: { "content-type": "image/png" },
|
|
});
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("renders once for many concurrent requests of the same figure", async () => {
|
|
// This is the whole point: a page asks for dozens of avatars at once and
|
|
// repeats figures, and a render is the most expensive thing this app does.
|
|
const results = await Promise.all(
|
|
Array.from({ length: 8 }, () =>
|
|
fetchAvatarImage("https://site.test", params),
|
|
),
|
|
);
|
|
|
|
expect(state.upstreamCalls).toBe(1);
|
|
for (const result of results) {
|
|
expect([...result.body]).toEqual([1, 2, 3]);
|
|
expect(result.source).toBe("primary");
|
|
}
|
|
});
|
|
|
|
it("does not dedupe different figures", async () => {
|
|
const other = new URLSearchParams({ figure: "hr-2", direction: "2" });
|
|
await Promise.all([
|
|
fetchAvatarImage("https://site.test", params),
|
|
fetchAvatarImage("https://site.test", other),
|
|
]);
|
|
expect(state.upstreamCalls).toBe(2);
|
|
});
|
|
|
|
it("clears the in-flight entry so a later request reads the cache again", async () => {
|
|
// A leaked entry would pin one promise per figure for the process lifetime
|
|
// and serve the same body forever.
|
|
await fetchAvatarImage("https://site.test", params);
|
|
expect(globalThis.imagingInFlight?.size ?? 0).toBe(0);
|
|
});
|
|
|
|
it("clears the in-flight entry when the render fails", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => {
|
|
throw Error("upstream down");
|
|
}),
|
|
);
|
|
await expect(
|
|
fetchAvatarImage("https://habbo.com/habbo-imaging/avatarimage", params),
|
|
).rejects.toThrow(ImagerUnavailableError);
|
|
expect(globalThis.imagingInFlight?.size ?? 0).toBe(0);
|
|
});
|
|
|
|
it("does not queue a new caller behind a render that is already too old", async () => {
|
|
// Sharing a render is what collapses a cold-cache stampede into one render,
|
|
// but a render that hangs must not hold an unbounded queue behind it. A
|
|
// newcomer past the join window gets the placeholder instead of waiting on
|
|
// somebody else's stalled render.
|
|
let calls = 0;
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(() => {
|
|
calls += 1;
|
|
return new Promise(() => {});
|
|
}),
|
|
);
|
|
|
|
// The hung request is the one that asked for the render; it keeps waiting,
|
|
// which is the intended behaviour. Swallow it so it is not an unhandled
|
|
// rejection when the test ends.
|
|
const stalled = fetchAvatarImage("https://site.test", params);
|
|
stalled.catch(() => {});
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(calls).toBe(1);
|
|
|
|
const entry = [...(globalThis.imagingInFlight?.values() ?? [])][0];
|
|
expect(entry).toBeDefined();
|
|
entry.startedAt = Date.now() - 60_000;
|
|
|
|
await expect(fetchAvatarImage("https://site.test", params)).rejects.toThrow(
|
|
ImagerUnavailableError,
|
|
);
|
|
// Critically, it must not have launched a second render.
|
|
expect(calls).toBe(1);
|
|
|
|
// A caller arriving inside the window still shares the render, so the
|
|
// dedupe that matters most is intact.
|
|
globalThis.imagingInFlight?.clear();
|
|
calls = 0;
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => {
|
|
calls += 1;
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
return new Response(new Uint8Array([9]), {
|
|
headers: { "content-type": "image/png" },
|
|
});
|
|
}),
|
|
);
|
|
const shared = await Promise.all([
|
|
fetchAvatarImage("https://site.test", params),
|
|
fetchAvatarImage("https://site.test", params),
|
|
]);
|
|
expect(calls).toBe(1);
|
|
expect([...shared[0].body]).toEqual([9]);
|
|
expect(shared[1].body).toBe(shared[0].body);
|
|
});
|