Add performance optimizations and component refactors
CI / check (push) Successful in 34s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m23s

- 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:
openhands committed 2026-08-17 22:02:21 +02:00
1 parent 54f2bc5e0c
commit f285a7cd98
42 files changed
+1616 -1205

No files matched your search

+62 -20
View File
@@ -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;
}