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
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.
120 lines
3.7 KiB
TypeScript
120 lines
3.7 KiB
TypeScript
import "server-only";
|
|
|
|
import { count, eq, sql } from "drizzle-orm";
|
|
import { CameraWeb, db, Rooms, User } from "@/lib/db";
|
|
|
|
/**
|
|
* Row counters for the public homepage.
|
|
*
|
|
* These live in one place on purpose: the homepage and the boot warm-up both
|
|
* populate the same cache keys, so two different implementations would race to
|
|
* write different values into the same entry.
|
|
*/
|
|
|
|
/** Physical table names, needed because `information_schema` speaks in strings. */
|
|
const TABLE = {
|
|
users: "users",
|
|
rooms: "rooms",
|
|
photos: "camera_web",
|
|
} as const;
|
|
|
|
/**
|
|
* Ask the storage engine for its own row estimates.
|
|
*
|
|
* An exact `COUNT(*)` on InnoDB walks an index, so it gets slower as the table
|
|
* grows. For a homepage counter that a visitor sees as a rounded "member since"
|
|
* number, an approximate count is the right trade: it is a single indexed
|
|
* lookup on `information_schema` instead of a scan of the whole table.
|
|
*
|
|
* InnoDB's estimate can lag behind reality and, right after a bulk write, can be
|
|
* badly off. It is therefore never used as a reason to show zero: a missing
|
|
* estimate falls back to the exact count, so the worst case is the old
|
|
* behaviour.
|
|
*
|
|
* Returns null for any table the engine has no estimate for.
|
|
*/
|
|
export async function estimatedRowCounts(
|
|
tables: string[],
|
|
): Promise<Record<string, number | null>> {
|
|
const result: Record<string, number | null> = {};
|
|
for (const table of tables) result[table] = null;
|
|
if (tables.length === 0) return result;
|
|
|
|
try {
|
|
const names = tables.map((table) => sql`${table}`);
|
|
const rows = await db.execute<{
|
|
TABLE_NAME: string;
|
|
TABLE_ROWS: number | string | null;
|
|
}>(sql`
|
|
SELECT TABLE_NAME, TABLE_ROWS
|
|
FROM information_schema.TABLES
|
|
WHERE TABLE_SCHEMA = DATABASE()
|
|
AND TABLE_NAME IN (${sql.join(names, sql`, `)})
|
|
`);
|
|
|
|
for (const row of rows as unknown as {
|
|
TABLE_NAME: string;
|
|
TABLE_ROWS: number | string | null;
|
|
}[]) {
|
|
const value = Number(row.TABLE_ROWS);
|
|
// TABLE_ROWS is a BIGINT that node-mysql hands back as a string or a
|
|
// double; anything non-finite means "no estimate", not zero rows.
|
|
if (row.TABLE_NAME in result && Number.isFinite(value) && value >= 0)
|
|
result[row.TABLE_NAME] = Math.floor(value);
|
|
}
|
|
} catch {
|
|
// No estimate available: every caller falls back to the exact count.
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Count rows in a table, preferring the cheap estimate.
|
|
*
|
|
* `estimate` names the table to ask the engine about, `exact` is the original
|
|
* query, kept as the fallback.
|
|
*/
|
|
async function countTable(
|
|
estimate: string,
|
|
exact: () => Promise<number>,
|
|
): Promise<number> {
|
|
const estimates = await estimatedRowCounts([estimate]);
|
|
const value = estimates[estimate];
|
|
if (value !== null && value > 0) return value;
|
|
return exact();
|
|
}
|
|
|
|
export function countUsers(): Promise<number> {
|
|
return countTable(TABLE.users, async () => {
|
|
const [row] = await db.select({ total: count() }).from(User);
|
|
return row?.total ?? 0;
|
|
});
|
|
}
|
|
|
|
export function countRooms(): Promise<number> {
|
|
return countTable(TABLE.rooms, async () => {
|
|
const [row] = await db.select({ total: count() }).from(Rooms);
|
|
return row?.total ?? 0;
|
|
});
|
|
}
|
|
|
|
export function countPhotos(): Promise<number> {
|
|
return countTable(TABLE.photos, async () => {
|
|
const [row] = await db.select({ total: count() }).from(CameraWeb);
|
|
return row?.total ?? 0;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Online users is deliberately *not* estimated: it is read from an index over a
|
|
* small subset of rows, it is the one counter people watch closely, and being a
|
|
* few seconds behind looks broken rather than approximate.
|
|
*/
|
|
export async function countOnline(): Promise<number> {
|
|
const [row] = await db
|
|
.select({ total: count() })
|
|
.from(User)
|
|
.where(eq(User.online, "1"));
|
|
return row?.total ?? 0;
|
|
}
|