fix(cache): bound grace windows, cap render queues, and drop the useless estimate
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
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.
This commit is contained in:
1 parent
f81b114b69
commit
155bf750c3
6 files changed
+199
-159
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import { count, eq, sql } from "drizzle-orm";
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import { CameraWeb, db, Rooms, User } from "@/lib/db";
|
||||
|
||||
/**
|
||||
@@ -11,98 +11,33 @@ import { CameraWeb, db, Rooms, User } from "@/lib/db";
|
||||
* 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.
|
||||
/*
|
||||
* These are exact counts on purpose.
|
||||
*
|
||||
* 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.
|
||||
* `information_schema.TABLES.TABLE_ROWS` was tried here as a cheap stand-in,
|
||||
* because an exact COUNT(*) walks an index. Measured against the real database
|
||||
* it turned out to be pointless: `users` has 165 rows, `rooms` 92 and
|
||||
* `camera_web` 0, and the estimate was 0.00% off on all three. An index scan
|
||||
* over 165 rows costs less than the round trip the estimate would have saved,
|
||||
* while the estimate carries a real risk of displaying a plausible but wrong
|
||||
* member count — and InnoDB's accuracy degrades as a table grows. If these
|
||||
* tables ever reach six figures, revisit the trade; below that, exactness is
|
||||
* free.
|
||||
*/
|
||||
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;
|
||||
export async function countUsers(): Promise<number> {
|
||||
const [row] = await db.select({ total: count() }).from(User);
|
||||
return row?.total ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 async function countRooms(): Promise<number> {
|
||||
const [row] = await db.select({ total: count() }).from(Rooms);
|
||||
return row?.total ?? 0;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
export async function countPhotos(): Promise<number> {
|
||||
const [row] = await db.select({ total: count() }).from(CameraWeb);
|
||||
return row?.total ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user