Files
EpicNext-Cms/src/lib/imager-upstream.ts
T
openhands 155bf750c3
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
fix(cache): bound grace windows, cap render queues, and drop the useless estimate
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.
2026-09-25 18:55:33 +02:00

271 lines
8.8 KiB
TypeScript

import "server-only";
import {
avatarCacheDir,
imagingCacheKey,
readImagingCache,
writeImagingCache,
} from "@/lib/imager-cache";
import { resolveImagerBase } from "@/lib/runtime-asset-config";
export const FIGURE_RE = /^[a-z]{2}-\d+(?:-\d+)*(?:\.[a-z]{2}-\d+(?:-\d+)*)*$/i;
export const FIGURE_MAX_LEN = 512;
export const FIGURE_MAX_PARTS = 24;
const HABBO_PUBLIC_UPSTREAM = "https://www.habbo.com/habbo-imaging/avatarimage";
// Effect renders (the client asks for effect=14 by default) are encoded as
// APNG animations and consistently need a little over 4s, so the primary
// budget has to stay well above that or every effected avatar times out. The
// public Habbo renderer is a best-effort safety net only: it is slow to fail,
// so it gets a short budget and the disk cache absorbs the difference.
export const PRIMARY_TIMEOUT_MS = 8_000;
export const FALLBACK_TIMEOUT_MS = 2_000;
// Keyed by cache key, so one render serves every concurrent request for the same
// figure. Held on globalThis because Next can evaluate this module more than once
// per process (multiple server bundles); separate copies would each start their
// own render and defeat the point entirely.
const globalForRenders = globalThis as typeof globalThis & {
imagingInFlight?: Map<
string,
{ startedAt: number; render: Promise<ImagerResult> }
>;
};
const inFlight =
globalForRenders.imagingInFlight ??
new Map<string, { startedAt: number; render: Promise<ImagerResult> }>();
globalForRenders.imagingInFlight = inFlight;
const MAX_IN_FLIGHT_RENDERS = 256;
// How long a newcomer is willing to queue behind a render somebody else started.
//
// Sharing one render across concurrent requests is what keeps a cold cache from
// launching N renders, but it also means a render that hangs holds up everyone
// who arrives after it rather than only itself. Past this point a new caller
// stops joining and serves the placeholder instead: the render in flight is
// still worth finishing for the request that asked for it, but it must not be
// able to stall an unbounded queue behind it. Callers that arrive inside the
// window are the normal case — a render that takes longer than this is already
// heading for its 8s timeout and would return a degraded image anyway.
const RENDER_JOIN_WINDOW_MS = 2_000;
export type ImagerSource =
| "primary"
| "fallback"
| "cache"
| "cache-stale"
| "unavailable";
export interface ImagerResult {
body: Uint8Array;
contentType: string;
source: ImagerSource;
}
export type AvatarQuery =
| { ok: true; params: URLSearchParams }
| { ok: false; message: string };
export class ImagerUnavailableError extends Error {
constructor(message = "Avatar imager unavailable") {
super(message);
this.name = "ImagerUnavailableError";
}
}
export function parseAvatarQuery(searchParams: URLSearchParams): AvatarQuery {
const figure = (
searchParams.get("figure") ??
searchParams.get("look") ??
""
).trim();
if (!figure) return { ok: false, message: "Missing figure parameter" };
if (figure.length > FIGURE_MAX_LEN || !FIGURE_RE.test(figure))
return { ok: false, message: "Invalid figure format" };
if (figure.split(".").length > FIGURE_MAX_PARTS)
return { ok: false, message: "Figure has too many parts" };
const sizeParam = searchParams.get("size");
const size: "s" | "m" | "l" =
sizeParam === "s" || sizeParam === "l" ? sizeParam : "m";
const direction = clampInt(searchParams.get("direction"), 0, 7, 2);
const headDirection = clampInt(
searchParams.get("head_direction") ?? searchParams.get("headDirection"),
0,
7,
3,
);
const headOnly =
searchParams.get("headonly") === "1" ||
searchParams.get("headOnly") === "1";
const gesture = searchParams.get("gesture") ?? undefined;
const action = searchParams.get("action") ?? undefined;
const effect = searchParams.get("effect") ?? undefined;
const params = new URLSearchParams({
figure,
direction: String(direction),
head_direction: String(headDirection),
size,
img_format: "png",
});
if (headOnly) params.set("headonly", "1");
if (gesture) params.set("gesture", gesture);
if (action) params.set("action", action);
if (effect) params.set("effect", effect);
const imgFormat = searchParams.get("img_format");
if (imgFormat) params.set("img_format", imgFormat);
return { ok: true, params };
}
export async function fetchAvatarImage(
origin: string,
params: URLSearchParams,
): Promise<ImagerResult> {
const primary = resolveImagerBase(origin);
// Renders are immutable per figure/variant: serve a previously cached
// render without touching the upstream at all. This keeps repeated avatar
// requests off the local renderer and survives upstream downtime.
const cacheKey = imagingCacheKey(`${primary}\n${params.toString()}`);
const cached = await readImagingCache(avatarCacheDir(), cacheKey);
if (cached) return { ...cached, source: "cache" };
// Single-flight a render. A page requests dozens of avatars at once and
// repeats figures, so without this a cold cache turns N concurrent requests
// for one figure into N upstream renders — the most expensive operation in
// the app, launched exactly when the cache has nothing to offer. The winner
// writes to the disk cache; the others wait for that one result.
const pending = inFlight.get(cacheKey);
if (pending) {
if (Date.now() - pending.startedAt < RENDER_JOIN_WINDOW_MS)
return pending.render;
// The shared render has been going too long to be worth joining. Fall
// through to the placeholder rather than extending an unbounded queue.
throw new ImagerUnavailableError(
"Avatar render already in progress too long",
);
}
// Defensive bound: entries are removed as soon as they settle, so this only
// matters if renders are somehow never cleaned up.
if (inFlight.size >= MAX_IN_FLIGHT_RENDERS) inFlight.clear();
const entry = {
startedAt: Date.now(),
render: renderAvatar(primary, cacheKey, params),
};
inFlight.set(cacheKey, entry);
try {
return await entry.render;
} finally {
if (inFlight.get(cacheKey) === entry) inFlight.delete(cacheKey);
}
}
async function renderAvatar(
primary: string,
cacheKey: string,
params: URLSearchParams,
): Promise<ImagerResult> {
const primaryResult = await fetchUpstream(
primary,
params,
PRIMARY_TIMEOUT_MS,
);
if (primaryResult) {
await writeImagingCache(
avatarCacheDir(),
cacheKey,
primaryResult.body,
primaryResult.contentType,
);
return { ...primaryResult, source: "primary" };
}
if (isSameUpstream(primary, HABBO_PUBLIC_UPSTREAM)) {
throw new ImagerUnavailableError(
"Configured imager is already the fallback upstream",
);
}
const fallbackResult = await fetchUpstream(
HABBO_PUBLIC_UPSTREAM,
buildFallbackParams(params),
FALLBACK_TIMEOUT_MS,
);
// Deliberately not cached: a fallback render drops the effect and is only a
// degraded stand-in, so persisting it would keep serving the worse image
// for the whole cache lifetime. The local renderer caches its own renders,
// so the next request for this figure is cheap and returns the real one.
if (fallbackResult) {
return { ...fallbackResult, source: "fallback" };
}
// Both upstreams are down and nothing was cached before. Try once more
// against the cache in case a concurrent request just wrote this render,
// then give up so the caller can serve a graceful placeholder.
const recent = await readImagingCache(avatarCacheDir(), cacheKey);
if (recent) return { ...recent, source: "cache-stale" };
throw new ImagerUnavailableError();
}
async function fetchUpstream(
baseUrl: string,
params: URLSearchParams,
timeoutMs: number,
): Promise<Omit<ImagerResult, "source"> | null> {
const url = new URL(baseUrl);
for (const [key, value] of params) url.searchParams.set(key, value);
try {
const res = await fetch(url, {
signal: AbortSignal.timeout(timeoutMs),
cache: "no-store",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
});
if (!res.ok) return null;
return {
body: new Uint8Array(await res.arrayBuffer()),
contentType: res.headers.get("content-type") || "image/png",
};
} catch {
return null;
}
}
function buildFallbackParams(params: URLSearchParams): URLSearchParams {
const fallback = new URLSearchParams(params);
// The public Habbo renderer has no effect rendering support.
fallback.delete("effect");
// The public Habbo renderer emits PNG/GIF, not APNG.
fallback.set("img_format", "png");
return fallback;
}
function isSameUpstream(a: string, b: string): boolean {
const normalize = (value: string) =>
value.replace(/\/+$/, "").replace(/^https?:/, "");
return normalize(a) === normalize(b);
}
function clampInt(
raw: string | null,
min: number,
max: number,
fallback: number,
): number {
if (raw == null) return fallback;
const n = Number.parseInt(raw, 10);
if (Number.isNaN(n) || n < min || n > max) return fallback;
return n;
}