Add performance optimizations and component refactors
- 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
This commit is contained in:
1 parent
54f2bc5e0c
commit
f285a7cd98
42 files changed
+1616
-1205
No files matched your search
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/redis", () => ({
|
||||
redis: null,
|
||||
}));
|
||||
|
||||
import { cached, invalidateMemory } from "./cache";
|
||||
|
||||
describe("cached (memory-only, no Redis)", () => {
|
||||
it("computes once and serves the cached value", async () => {
|
||||
const fn = vi.fn(async () => 42);
|
||||
const key = `once-${Math.random()}`;
|
||||
expect(await cached(key, 10_000, fn)).toBe(42);
|
||||
expect(await cached(key, 10_000, fn)).toBe(42);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("dedupes concurrent misses into a single computation", async () => {
|
||||
let resolveFn: (value: number) => void = () => {};
|
||||
const fn = vi.fn(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
resolveFn = resolve;
|
||||
}),
|
||||
);
|
||||
const key = `dedupe-${Math.random()}`;
|
||||
const p1 = cached(key, 10_000, fn);
|
||||
const p2 = cached(key, 10_000, fn);
|
||||
const p3 = cached(key, 10_000, fn);
|
||||
resolveFn(7);
|
||||
expect(await Promise.all([p1, p2, p3])).toEqual([7, 7, 7]);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("recomputes after the TTL expires", async () => {
|
||||
let count = 0;
|
||||
const fn = vi.fn(async () => ++count);
|
||||
const key = `ttl-${Math.random()}`;
|
||||
expect(await cached(key, 1, fn)).toBe(1);
|
||||
expect(await cached(key, 1, fn)).toBe(1);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(await cached(key, 1, fn)).toBe(2);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("invalidates a key so the next read recomputes", async () => {
|
||||
let count = 0;
|
||||
const fn = vi.fn(async () => ++count);
|
||||
const key = `inv-${Math.random()}`;
|
||||
expect(await cached(key, 10_000, fn)).toBe(1);
|
||||
invalidateMemory(key);
|
||||
expect(await cached(key, 10_000, fn)).toBe(2);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("evicts the oldest entry when the in-memory cache is full", async () => {
|
||||
const fn = vi.fn(async () => 1);
|
||||
for (let i = 0; i < 600; i++) {
|
||||
await cached(`bulk-${i}`, 10_000, fn);
|
||||
}
|
||||
// Key "bulk-0" was evicted (insertion order), so it must recompute.
|
||||
await cached("bulk-0", 10_000, fn);
|
||||
expect(fn).toHaveBeenCalledTimes(601);
|
||||
});
|
||||
});
|
||||
+62
-20
@@ -5,9 +5,40 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,30 +61,41 @@ export async function cached<T>(
|
||||
return existing.data as 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;
|
||||
memory.set(key, { data, expiresAt: Date.now() + ttlMs });
|
||||
return data;
|
||||
// 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 */
|
||||
}
|
||||
} catch {
|
||||
/* fall through to fn */
|
||||
}
|
||||
}
|
||||
|
||||
const data = await 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 */
|
||||
if (redis && redis.status !== "end") {
|
||||
try {
|
||||
await redis.setex(key, ttlSec, JSON.stringify(data));
|
||||
} catch {
|
||||
/* non-critical: memory cache still works */
|
||||
}
|
||||
}
|
||||
}
|
||||
memory.set(key, { data, expiresAt: Date.now() + ttlMs });
|
||||
setMemory(key, { data, expiresAt: Date.now() + ttlMs });
|
||||
|
||||
return data;
|
||||
return data;
|
||||
})().finally(() => {
|
||||
inFlight.delete(key);
|
||||
});
|
||||
|
||||
inFlight.set(key, compute);
|
||||
return compute;
|
||||
}
|
||||
@@ -20,3 +20,17 @@ export async function redisCache<T>(
|
||||
export function apiCacheKey(path: string): string {
|
||||
return `api:${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-convert a payload to a JSON-cache-safe form: BigInt → string (matching
|
||||
* the apiJson() wire format) and Date → ISO string, since JSON.stringify throws
|
||||
* on BigInt. Use for payloads cached through redisCache() that are serialised
|
||||
* straight to a response and never consumed by page logic.
|
||||
*/
|
||||
export function cacheSafe<T>(value: T): T {
|
||||
return JSON.parse(
|
||||
JSON.stringify(value, (_k, v) =>
|
||||
typeof v === "bigint" ? v.toString() : v,
|
||||
),
|
||||
) as T;
|
||||
}
|
||||
@@ -29,6 +29,10 @@ const IS_PRERENDER =
|
||||
|
||||
class SiteSettings {
|
||||
private cache: { map: Map<string, string>; expiresAt: number } | null = null;
|
||||
// Single-flight: one request (re)loads the map, the rest await it — the
|
||||
// root layout reads settings on every render, so concurrent misses must
|
||||
// not each hammer Redis/DB (cache-stampede protection).
|
||||
private inFlight: Promise<Map<string, string>> | null = null;
|
||||
|
||||
private async loadFromDb(): Promise<Map<string, string>> {
|
||||
try {
|
||||
@@ -42,13 +46,7 @@ class SiteSettings {
|
||||
}
|
||||
}
|
||||
|
||||
private async load(): Promise<Map<string, string>> {
|
||||
if (this.cache !== null) {
|
||||
if (IS_PRERENDER || this.cache.expiresAt > Date.now()) {
|
||||
return this.cache.map;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFromCacheOrDb(): Promise<Map<string, string>> {
|
||||
if (redis) {
|
||||
try {
|
||||
const cached = await redis.get(REDIS_CACHE_KEY);
|
||||
@@ -85,6 +83,22 @@ class SiteSettings {
|
||||
return map;
|
||||
}
|
||||
|
||||
private async load(): Promise<Map<string, string>> {
|
||||
if (this.cache !== null) {
|
||||
if (IS_PRERENDER || this.cache.expiresAt > Date.now()) {
|
||||
return this.cache.map;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.inFlight) return this.inFlight;
|
||||
|
||||
const run = this.loadFromCacheOrDb().finally(() => {
|
||||
this.inFlight = null;
|
||||
});
|
||||
this.inFlight = run;
|
||||
return run;
|
||||
}
|
||||
|
||||
async getAll(): Promise<Map<string, string>> {
|
||||
return await this.load();
|
||||
}
|
||||
@@ -127,6 +141,7 @@ class SiteSettings {
|
||||
|
||||
async reload(): Promise<void> {
|
||||
this.cache = null;
|
||||
this.inFlight = null;
|
||||
if (redis) {
|
||||
try {
|
||||
await redis.del(REDIS_CACHE_KEY);
|
||||
|
||||
Reference in new issue
Block a user