fix(cache): true LRU, stale-while-revalidate and cross-process invalidation
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 32s
CI / tests-integration (push) Successful in 1m38s
CI / tests-unit (push) Successful in 1m42s
CI / tests-ui (push) Successful in 2m33s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m43s

The in-process cache was a FIFO of 500 entries that was never touched on a
read, so a key polled on every request could be evicted by an unrelated burst
of dynamic keys. That looked exactly like the cache being cleared at random,
and it is what made the site fall back to the database unpredictably.

- Evict least-recently-used instead, and raise the default budget to 2000
  (CACHE_MEMORY_MAX_ENTRIES). Reading a key now marks it as used, so a hot key
  only leaves when a hotter one takes its place.
- Add opt-in stale-while-revalidate (CachedOptions.staleMs). The grace window
  lives on the entry, so one call site opting in protects every reader of that
  key. A failed background refresh keeps serving the last good value instead of
  falling through to the origin, and is reported once rather than per read.
- Invalidate across processes. invalidateKey() now clears memory, deletes the
  Redis key and publishes a signal, so a value written by one process is no
  longer served stale by the others for the rest of its TTL. A failed Redis
  delete no longer skips the broadcast.
- Guard against a refresh that started before an invalidation writing its
  outdated result back into the cache.
- Read the news revision at most once a second per process instead of on every
  call, with a pub/sub signal to drop the local copy when it rotates. A Redis
  outage now degrades to the in-process cache rather than to no cache at all.
- Warm the hot public keys on boot, so the first visitors after a deploy do not
  each pay for a miss.
- Count hits, misses, stale serves, errors and evictions per key, exposed at
  GET /api/admin/devops/cache. Without it a wrong REDIS_URL, a full budget and
  a dead origin all look identical from the outside.
- Enforce the imaging cache budget for real: records are .img/.json pairs, so
  the old cap counted files and never removed anything while entries were
  fresh. Sweeps are throttled per directory and prune to a low-water mark.
- Cap the JWT version map, and stop per-test scratch roots from littering the
  runtime imaging cache.

Public read-only endpoints get grace windows; admin, account and auth data
deliberately stays fresh. Redis TTLs get a little jitter so keys written
together no longer expire together.

3209 tests pass. next build could not be verified on this host: the optimized
build is OOM-killed before prerender, so this has not run in a real Next
runtime yet.
This commit is contained in:
openhands committed 2026-09-25 18:26:45 +02:00
1 parent f490fcc9da
commit 203399aab7
43 files changed
+1737 -212

No files matched your search

+45 -33
View File
@@ -56,16 +56,20 @@ type Row = { username: string; look: string; value: number };
async function loadCreditsRows(): Promise<Row[]> {
try {
const users = await cached("lb_credits", 60_000, () =>
db
.select({
username: User.username,
look: User.look,
credits: User.credits,
})
.from(User)
.orderBy(desc(User.credits))
.limit(20),
const users = await cached(
"lb_credits",
60_000,
() =>
db
.select({
username: User.username,
look: User.look,
credits: User.credits,
})
.from(User)
.orderBy(desc(User.credits))
.limit(20),
{ staleMs: 120000 },
);
return users.map((u) => ({
username: u.username,
@@ -79,18 +83,22 @@ async function loadCreditsRows(): Promise<Row[]> {
async function loadCurrencyRows(type: number): Promise<Row[]> {
try {
return await cached(`lb_currency_${type}`, 60_000, () =>
db
.select({
username: User.username,
look: User.look,
value: UsersCurrency.amount,
})
.from(UsersCurrency)
.innerJoin(User, eq(UsersCurrency.userId, User.id))
.where(eq(UsersCurrency.type, type))
.orderBy(desc(UsersCurrency.amount))
.limit(20),
return await cached(
`lb_currency_${type}`,
60_000,
() =>
db
.select({
username: User.username,
look: User.look,
value: UsersCurrency.amount,
})
.from(UsersCurrency)
.innerJoin(User, eq(UsersCurrency.userId, User.id))
.where(eq(UsersCurrency.type, type))
.orderBy(desc(UsersCurrency.amount))
.limit(20),
{ staleMs: 120000 },
);
} catch {
return [];
@@ -102,17 +110,21 @@ async function loadSettingsRows(
): Promise<Row[]> {
try {
const column = UsersSettings[field];
return await cached(`lb_settings_${field}`, 60_000, () =>
db
.select({
username: User.username,
look: User.look,
value: column,
})
.from(UsersSettings)
.innerJoin(User, eq(UsersSettings.userId, User.id))
.orderBy(desc(column))
.limit(20),
return await cached(
`lb_settings_${field}`,
60_000,
() =>
db
.select({
username: User.username,
look: User.look,
value: column,
})
.from(UsersSettings)
.innerJoin(User, eq(UsersSettings.userId, User.id))
.orderBy(desc(column))
.limit(20),
{ staleMs: 120000 },
);
} catch {
return [];
+47 -27
View File
@@ -181,40 +181,60 @@ async function getHotelData() {
.where(eq(User.online, "1"))
.then((rows) => rows[0]?.total ?? 0),
).catch(publicReadFailure("home.online")),
cached("total_users", 300_000, () =>
db
.select({ total: count() })
.from(User)
.then((rows) => rows[0]?.total ?? 0),
cached(
"total_users",
300_000,
() =>
db
.select({ total: count() })
.from(User)
.then((rows) => rows[0]?.total ?? 0),
{ staleMs: 300000 },
).catch(publicReadFailure("home.users")),
cached("total_rooms", 300_000, () =>
db
.select({ total: count() })
.from(Rooms)
.then((rows) => rows[0]?.total ?? 0),
cached(
"total_rooms",
300_000,
() =>
db
.select({ total: count() })
.from(Rooms)
.then((rows) => rows[0]?.total ?? 0),
{ staleMs: 300000 },
).catch(publicReadFailure("home.rooms")),
cached("total_photos", 300_000, () =>
db
.select({ total: count() })
.from(CameraWeb)
.then((rows) => rows[0]?.total ?? 0),
cached(
"total_photos",
300_000,
() =>
db
.select({ total: count() })
.from(CameraWeb)
.then((rows) => rows[0]?.total ?? 0),
{ staleMs: 300000 },
).catch(publicReadFailure("home.photos-count")),
getNewsList(4, { throwOnError: true }).catch(
publicReadFailure("home.news"),
),
cached("home_online_users", 15_000, () =>
db
.select({ username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(12),
cached(
"home_online_users",
15_000,
() =>
db
.select({ username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(12),
{ staleMs: 30000 },
).catch(publicReadFailure("home.recent-users")),
cached("home_recent_photos", 60_000, () =>
db
.select({ id: CameraWeb.id, url: CameraWeb.url })
.from(CameraWeb)
.orderBy(desc(CameraWeb.timestamp))
.limit(4),
cached(
"home_recent_photos",
60_000,
() =>
db
.select({ id: CameraWeb.id, url: CameraWeb.url })
.from(CameraWeb)
.orderBy(desc(CameraWeb.timestamp))
.limit(4),
{ staleMs: 120000 },
).catch(publicReadFailure("home.photos")),
]);
+10 -2
View File
@@ -38,8 +38,16 @@ export default async function PhotosPage() {
let photos: Photo[] | null = [];
try {
photos = await cached("photos:grid", 60_000, async () =>
db.select().from(CameraWeb).orderBy(desc(CameraWeb.timestamp)).limit(48),
photos = await cached(
"photos:grid",
60_000,
async () =>
db
.select()
.from(CameraWeb)
.orderBy(desc(CameraWeb.timestamp))
.limit(48),
{ staleMs: 120000 },
);
} catch (error) {
photos = publicReadFailure("photos")(error);
+16 -12
View File
@@ -40,18 +40,22 @@ export default async function RankingsPage() {
const t = await getTranslations("pages.rankings");
let users: TopUser[] | null = [];
try {
users = await cached("rankings:top", 60_000, async () =>
db
.select({
username: User.username,
look: User.look,
credits: User.credits,
rank: User.rank,
online: User.online,
})
.from(User)
.orderBy(desc(User.credits))
.limit(12),
users = await cached(
"rankings:top",
60_000,
async () =>
db
.select({
username: User.username,
look: User.look,
credits: User.credits,
rank: User.rank,
online: User.online,
})
.from(User)
.orderBy(desc(User.credits))
.limit(12),
{ staleMs: 120000 },
);
} catch (error) {
users = publicReadFailure("rankings")(error);
+20 -12
View File
@@ -31,19 +31,27 @@ export default async function RegisterPage() {
.where(eq(User.online, "1"))
.then((rows) => rows[0]?.total ?? 0),
).catch(() => 0),
cached("register_online_users", 10_000, () =>
db
.select({ username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(8),
cached(
"register_online_users",
10_000,
() =>
db
.select({ username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(8),
{ staleMs: 30000 },
).catch(() => []),
cached("register_latest_users", 30_000, () =>
db
.select({ username: User.username, look: User.look })
.from(User)
.orderBy(desc(User.accountCreated))
.limit(8),
cached(
"register_latest_users",
30_000,
() =>
db
.select({ username: User.username, look: User.look })
.from(User)
.orderBy(desc(User.accountCreated))
.limit(8),
{ staleMs: 60000 },
).catch(() => []),
]);
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { withAdmin } from "@/lib/api-handler";
import { cacheBudget } from "@/lib/cache";
import { readCacheStats } from "@/lib/cache-stats";
import { PERMS } from "@/lib/permissions";
/**
* Cache health report: per-key hit/miss/stale/error/eviction counters plus the
* in-process budget, so a cache that is silently doing nothing is visible
* instead of only showing up as "the site got slower".
*/
export const GET = withAdmin({ permission: PERMS.DEVOPS_VIEW }, async () => {
const stats = await readCacheStats();
return NextResponse.json({ ...stats, budget: cacheBudget() });
});
+1
View File
@@ -362,6 +362,7 @@ export async function GET(req: Request) {
return cacheSafe({ badgeStats, totalBadges, achievementLevel, rarity });
},
{ staleMs: 120_000 },
);
// Per-viewer rank entries (only when a user is signed in).
+1
View File
@@ -56,6 +56,7 @@ export async function GET(
const { userId, ...rest } = guild;
return cacheSafe({ data: { ...rest, ownerId: userId, memberCount } });
},
{ staleMs: 120_000 },
);
if (data === null) {
+1
View File
@@ -50,6 +50,7 @@ export async function GET(req: Request) {
},
});
},
{ staleMs: 120_000 },
);
return apiJson(data);
+3
View File
@@ -84,6 +84,9 @@ export async function GET(req: Request) {
? await loadCreditsRows()
: // eslint-disable-next-line security/detect-object-injection -- type validated to "diamonds"|"duckets"
await loadCurrencyRows(CURRENCY_TYPE[type]),
// Rankings are re-sorted on every read; serving the previous order for a
// minute keeps a busy leaderboard off the database.
{ staleMs: 60 },
);
return apiJson({ type, data: rows }, { status: 200 });
+15 -7
View File
@@ -10,13 +10,21 @@ import { db, User } from "@/lib/db";
export async function GET(_req: Request) {
try {
const result = await cached("online_count", 10_000, async () => {
const [row] = await db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"));
return row?.total ?? 0;
});
// Polled by every page view and the in-game SSE stream, so a count that
// is one interval behind is worthless as "wrong" but a blocking COUNT(*)
// on each expiry is not. The grace window keeps the origin off the path.
const result = await cached(
"online_count",
10_000,
async () => {
const [row] = await db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"));
return row?.total ?? 0;
},
{ staleMs: 15_000 },
);
return apiJson({ count: result });
} catch {
return apiJson({ count: 0 });
+20 -9
View File
@@ -13,13 +13,19 @@ import { rcon } from "@/lib/services/rcon";
async function fetchOnlineCount(): Promise<number> {
try {
return await cached("online_count", 10_000, async () => {
const [row] = await db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"));
return row?.total ?? 0;
});
return await cached(
"online_count",
10_000,
async () => {
const [row] = await db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"));
return row?.total ?? 0;
},
// A subscriber must never wait on a COUNT(*) to get its next event.
{ staleMs: 15_000 },
);
} catch {
return 0;
}
@@ -30,8 +36,13 @@ async function fetchOnlineCount(): Promise<number> {
// degradation on failure so the stream never errors out.
async function fetchEmulatorStatus(): Promise<boolean> {
try {
return await cached("emulator_rcon_status", 15_000, () =>
rcon.send("ping", null).catch(() => false),
return await cached(
"emulator_rcon_status",
15_000,
() => rcon.send("ping", null).catch(() => false),
// Reachability is reported, never acted on, so a previous answer is
// good enough while the ping is re-run.
{ staleMs: 15_000 },
);
} catch {
return false;
+12 -6
View File
@@ -10,12 +10,18 @@ import { db, User } from "@/lib/db";
export async function GET(_req: Request) {
try {
const users = await cached("online_users", 10_000, async () =>
db
.select({ username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(100),
// A briefly stale online list is harmless; blocking the first request
// after the TTL on a COUNT + SELECT is not.
const users = await cached(
"online_users",
10_000,
async () =>
db
.select({ username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(100),
{ staleMs: 15_000 },
);
return apiJson({ users });
+1
View File
@@ -44,6 +44,7 @@ export async function GET(req: Request) {
},
});
},
{ staleMs: 120_000 },
);
return apiJson(data);
+1
View File
@@ -38,6 +38,7 @@ export async function GET(_req: Request) {
return { username: user.username, look: user.look };
},
{ staleMs: 30_000 },
);
return apiJson({ dj });
@@ -48,6 +48,7 @@ export async function GET(_req: Request) {
};
});
},
{ staleMs: 120_000 },
);
return apiJson({ data });
+1
View File
@@ -58,6 +58,7 @@ export async function GET(_req: Request) {
};
});
},
{ staleMs: 10_000 },
);
return apiJson({ shouts: data });
+1
View File
@@ -26,6 +26,7 @@ export async function GET(_req: Request) {
asc(WebsiteShopCategories.name),
),
),
{ staleMs: 120_000 },
);
return apiJson({ data });
+1
View File
@@ -71,6 +71,7 @@ export async function GET(req: Request) {
},
});
},
{ staleMs: 120_000 },
);
return apiJson(data);
+18 -12
View File
@@ -12,18 +12,24 @@ export async function GET(_req: Request) {
const minStaffRank =
Number(await siteSettings.get("min_staff_rank", "7")) || 7;
const staff = await redisCache(apiCacheKey("staff"), 300, () =>
db
.select({
username: User.username,
look: User.look,
rank: User.rank,
motto: User.motto,
})
.from(User)
.where(gte(User.rank, minStaffRank))
.orderBy(desc(User.rank), asc(User.username))
.limit(100),
const staff = await redisCache(
apiCacheKey("staff"),
300,
() =>
db
.select({
username: User.username,
look: User.look,
rank: User.rank,
motto: User.motto,
})
.from(User)
.where(gte(User.rank, minStaffRank))
.orderBy(desc(User.rank), asc(User.username))
.limit(100),
// Staff badges are rendered on every public page; a roster that is a
// few minutes behind must never turn into a query on each visitor.
{ staleMs: 300 },
);
return apiJson({ data: staff }, { status: 200 });
+18 -14
View File
@@ -8,20 +8,24 @@ import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
export async function GET(_req: Request) {
try {
const rows = await redisCache(apiCacheKey("teams"), 300, async () =>
cacheSafe(
await db
.select({
id: WebsiteTeams.id,
rankName: WebsiteTeams.rankName,
badge: WebsiteTeams.badge,
jobDescription: WebsiteTeams.jobDescription,
staffColor: WebsiteTeams.staffColor,
})
.from(WebsiteTeams)
.where(eq(WebsiteTeams.hiddenRank, false))
.orderBy(asc(WebsiteTeams.id)),
),
const rows = await redisCache(
apiCacheKey("teams"),
300,
async () =>
cacheSafe(
await db
.select({
id: WebsiteTeams.id,
rankName: WebsiteTeams.rankName,
badge: WebsiteTeams.badge,
jobDescription: WebsiteTeams.jobDescription,
staffColor: WebsiteTeams.staffColor,
})
.from(WebsiteTeams)
.where(eq(WebsiteTeams.hiddenRank, false))
.orderBy(asc(WebsiteTeams.id)),
),
{ staleMs: 600_000 },
);
// apiJson serialises BigInt ids → string automatically.
+1
View File
@@ -76,6 +76,7 @@ export async function GET(
return cacheSafe({ data: { ...value, category } });
},
{ staleMs: 300_000 },
);
if (data === null) {
+1
View File
@@ -26,6 +26,7 @@ export async function GET(_req: Request) {
asc(WebsiteRareValueCategories.name),
),
),
{ staleMs: 600_000 },
);
return apiJson({ data });
+1
View File
@@ -61,6 +61,7 @@ export async function GET(req: Request) {
},
});
},
{ staleMs: 300_000 },
);
return apiJson(data);