Files
EpicNext-Cms/src/lib/services/public-counters.test.ts
T
openhands f81b114b69
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m38s
CI / tests-unit (push) Successful in 1m43s
CI / tests-ui (push) Successful in 2m30s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m7s
perf(cache): single-flight avatar renders, cacheable public reads, cheap row counts
Three separate things that were each costing more than they needed to on the
hot path.

- Single-flight avatar renders. The disk cache was checked first and a miss
  went straight to the upstream, with nothing shared between callers, so a page
  requesting dozens of avatars at once turned N concurrent requests for one
  figure into N renders. A render is the most expensive operation this app
  does, and the duplication happened exactly when the cache had nothing to
  offer. Eight concurrent requests now cause one render instead of eight. The
  map lives on globalThis because Next can evaluate the module more than once
  per process, and two copies would each start their own render.

- Let public read-only routes be cached by a shared cache. Every JSON response
  was `cache-control: no-store`, so a CDN in front of the app could not answer
  any of it and every request reached the origin. publicCacheControl() opts a
  route in with s-maxage and stale-while-revalidate, using the same TTL as the
  server-side cache so the two layers cannot disagree. The default stays
  no-store: most routes here are personalised, admin-only or auth-dependent.
  /api/badges/leaderboard is deliberately left alone because it returns
  per-viewer rank entries to signed-in callers.

  Note this only takes effect once a cache rule exists for /api/* at the CDN, or
  the explicit `cache: "no-store"` is dropped from the client fetches (24 files
  do that today, including the /api/online poll). The headers alone are inert
  until one of those happens.

- Take the homepage row counts from the storage engine estimate instead of
  COUNT(*), which walks an index and gets slower as the tables grow. A missing
  or zero estimate falls back to the exact count rather than ever showing a
  wrong zero. The online count stays exact: it is an indexed read over a small
  subset and a few seconds of drift reads as broken rather than approximate.

The counters move into one module because the homepage and the boot warm-up
populate the same cache keys, so two implementations would race to write
different values into the same entry.

3223 tests pass.
2026-09-25 18:42:23 +02:00

119 lines
3.5 KiB
TypeScript

// @ts-nocheck
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
// Rows the fake information_schema reports, keyed by table name.
estimates: {} as Record<string, number | null>,
failEstimate: false,
// Value the exact COUNT(*) returns, and how often it actually ran.
exactCounts: 0,
exactRuns: 0,
}));
const tables = vi.hoisted(() => ({
User: { name: "users" },
Rooms: { name: "rooms" },
CameraWeb: { name: "camera_web" },
}));
vi.mock("@/lib/db", () => {
const chain: any = new Proxy(() => {}, {
get: (_t, prop) => {
if (prop === Symbol.toStringTag) return "Query";
if (prop === "where") return () => chain;
if (prop === "then")
return (resolve: any, reject: any) => {
// Only the estimate lookup is meant to fail here; the exact
// fallback has to keep working, so it is not gated.
state.exactRuns += 1;
return Promise.resolve([{ total: state.exactCounts }]).then(
resolve,
reject,
);
};
return () => chain;
},
});
return {
db: {
select: () => chain,
// The information_schema lookup, the only one that matters here.
execute: async () => {
if (state.failEstimate) throw Error("information_schema unavailable");
return Object.entries(state.estimates)
.filter(([, v]) => v !== null)
.map(([TABLE_NAME, TABLE_ROWS]) => ({ TABLE_NAME, TABLE_ROWS }));
},
},
...(tables as any),
};
});
import {
countOnline,
countPhotos,
countRooms,
countUsers,
estimatedRowCounts,
} from "./public-counters";
beforeEach(() => {
state.estimates = {};
state.failEstimate = false;
state.exactCounts = 0;
state.exactRuns = 0;
});
it("reads the engine estimate instead of scanning the table", async () => {
// An exact count is recorded, so a non-zero value here means one really ran.
state.estimates = { users: 123_456, rooms: 42, camera_web: 7 };
expect(await countUsers()).toBe(123_456);
expect(await countRooms()).toBe(42);
expect(await countPhotos()).toBe(7);
expect(state.exactRuns).toBe(0);
});
it("falls back to the exact count when the engine has no estimate", async () => {
// A freshly created InnoDB table can report 0, which would show "0 members".
state.estimates = { users: 0, rooms: null, camera_web: null };
state.exactCounts = 999;
expect(await countUsers()).toBe(999);
expect(await countRooms()).toBe(999);
expect(await countPhotos()).toBe(999);
expect(state.exactRuns).toBe(3);
});
it("falls back to the exact count when the estimate lookup fails", async () => {
state.failEstimate = true;
state.exactCounts = 55;
expect(await countUsers()).toBe(55);
});
it("never turns a missing estimate into a zero", async () => {
// Showing "0" is worse than being slow: it is visibly wrong.
state.estimates = { users: null };
state.exactCounts = 1;
expect(await countUsers()).toBe(1);
expect(await countUsers()).not.toBe(0);
});
it("handles the bigint string the driver may hand back", async () => {
state.estimates = { users: "98765" };
expect(await countUsers()).toBe(98_765);
});
it("returns a null for every requested table it has no estimate for", async () => {
expect(await estimatedRowCounts(["users", "rooms"])).toEqual({
users: null,
rooms: null,
});
});
it("keeps the online count exact", async () => {
// Being a few seconds behind looks broken, and it is an indexed count of a
// small subset, so there is nothing to gain by approximating it.
state.estimates = { users: 5_000_000 };
state.exactCounts = 12;
expect(await countOnline()).toBe(12);
});