- Cache read-heavy public API routes via redisCache (leaderboard, values, shop, articles, photos, guilds, teams, staff, users, home, radio, badges) - Add single-flight and bounded-memory cache layer with unit tests - Parallelize independent DB queries on search, rares, shop, staff, polls and profile pages - Push radio points leaderboard aggregation to SQL with a LIMIT - Split studio-client and import-furni-client into focused modules - Clean up next.config.ts
102 lines
2.9 KiB
TypeScript
102 lines
2.9 KiB
TypeScript
import "server-only";
|
|
|
|
import { redis } from "@/lib/redis";
|
|
|
|
type CacheEntry<T> = { data: T; expiresAt: number };
|
|
const memory = new Map<string, CacheEntry<unknown>>();
|
|
|
|
// Cap the in-process map so dynamic keys (leaderboard currencies, article
|
|
// slugs, …) can never grow it without bound. Oldest entries are evicted.
|
|
const MAX_MEMORY_ENTRIES = 500;
|
|
|
|
// Single-flight: a key being (re)computed is awaited by concurrent callers
|
|
// instead of each starting its own `fn()` (cache-stampede protection).
|
|
const inFlight = new Map<string, Promise<unknown>>();
|
|
|
|
/** Drop a key from the in-process cache (used when an upstream value changes). */
|
|
export function invalidateMemory(key: string): void {
|
|
memory.delete(key);
|
|
inFlight.delete(key);
|
|
}
|
|
|
|
function pruneExpired(now: number): void {
|
|
for (const [key, entry] of memory) {
|
|
if (entry.expiresAt <= now) memory.delete(key);
|
|
}
|
|
}
|
|
|
|
function setMemory<T>(key: string, entry: CacheEntry<T>): void {
|
|
if (memory.has(key)) {
|
|
memory.set(key, entry);
|
|
return;
|
|
}
|
|
if (memory.size >= MAX_MEMORY_ENTRIES) {
|
|
// Drop expired entries first, then evict the oldest (insertion order).
|
|
pruneExpired(Date.now());
|
|
if (memory.size >= MAX_MEMORY_ENTRIES) {
|
|
const oldest = memory.keys().next().value;
|
|
if (oldest !== undefined) memory.delete(oldest);
|
|
}
|
|
}
|
|
memory.set(key, entry);
|
|
}
|
|
|
|
/**
|
|
* Redis-first cached query with an in-memory fallback.
|
|
* Use for read-heavy endpoints polled by the browser (online count, etc.).
|
|
*/
|
|
export async function cached<T>(
|
|
key: string,
|
|
ttlMs: number,
|
|
fn: () => Promise<T>,
|
|
): Promise<T> {
|
|
const ttlSec = Math.ceil(ttlMs / 1000);
|
|
|
|
// In-memory fast path — served first so repeated reads within a TTL window
|
|
// don't each pay a Redis round-trip (Redis is still the shared fallback).
|
|
// `existing &&` short-circuits so Date.now() is never evaluated during
|
|
// prerender when the map is empty (keeps `next build` prerendering clean).
|
|
const existing = memory.get(key);
|
|
if (existing && existing.expiresAt > Date.now()) {
|
|
return existing.data as T;
|
|
}
|
|
|
|
// Single-flight: a concurrent request already recomputing this key.
|
|
const pending = inFlight.get(key);
|
|
if (pending) return (await pending) as T;
|
|
|
|
const compute = (async (): Promise<T> => {
|
|
// Redis path (shared across instances).
|
|
if (redis && redis.status !== "end") {
|
|
try {
|
|
const cached = await redis.get(key);
|
|
if (cached !== null && cached !== undefined) {
|
|
const data = JSON.parse(cached) as T;
|
|
setMemory(key, { data, expiresAt: Date.now() + ttlMs });
|
|
return data;
|
|
}
|
|
} catch {
|
|
/* fall through to fn */
|
|
}
|
|
}
|
|
|
|
const data = await fn();
|
|
|
|
if (redis && redis.status !== "end") {
|
|
try {
|
|
await redis.setex(key, ttlSec, JSON.stringify(data));
|
|
} catch {
|
|
/* non-critical: memory cache still works */
|
|
}
|
|
}
|
|
setMemory(key, { data, expiresAt: Date.now() + ttlMs });
|
|
|
|
return data;
|
|
})().finally(() => {
|
|
inFlight.delete(key);
|
|
});
|
|
|
|
inFlight.set(key, compute);
|
|
return compute;
|
|
}
|