import "server-only"; import { asc, desc, eq, gte } from "drizzle-orm"; import { cached } from "@/lib/cache"; import { db, User, WebsiteTeams } from "@/lib/db"; import { logger } from "@/lib/logger"; import { apiCacheKey, redisCache } from "@/lib/redis-cache"; import { cacheNews } from "@/lib/services/news-cache"; import { countOnline, countPhotos, countRooms, countUsers, } from "@/lib/services/public-counters"; import { siteSettings } from "@/lib/services/site-settings"; /** * Prime the hot public cache keys right after boot. * * Every deploy starts with an empty in-process cache, and any key that also * expired while the old process was down has to be recomputed from the database. * With no warm-up, the first visitor after each deploy pays for a burst of * simultaneous misses; with it, the site is already warm before traffic arrives. * * Warming works by cache *key*, not by call site: these use exactly the keys the * routes and pages read, so priming an entry serves every reader of it. Failures * are logged and ignored — a warm-up that cannot reach the database must never * stop the server from serving. */ const COUNTER_TTL_MS = 300_000; const ONLINE_TTL_MS = 10_000; async function warm(name: string, load: () => Promise): Promise { try { await load(); } catch (error) { logger.warn("[cache] warm-up failed", { key: name, error: String(error), }); } } export async function warmPublicCaches(): Promise { // Small delays between groups: a boot-time burst of COUNT(*) queries against // a database that is still opening connections helps nobody. await warm("online_count", () => cached("online_count", ONLINE_TTL_MS, countOnline, { staleMs: 15_000 }), ); await warm("total_users", () => cached("total_users", COUNTER_TTL_MS, countUsers, { staleMs: COUNTER_TTL_MS, }), ); await warm("total_rooms", () => cached("total_rooms", COUNTER_TTL_MS, countRooms, { staleMs: COUNTER_TTL_MS, }), ); await warm("total_photos", () => cached("total_photos", COUNTER_TTL_MS, countPhotos, { staleMs: COUNTER_TTL_MS, }), ); await warm("online_users", () => cached("online_users", ONLINE_TTL_MS, listOnlineUsers, { staleMs: 15_000, }), ); await warm("api:staff", () => redisCache(apiCacheKey("staff"), 300, loadStaff, { staleMs: 300 }), ); await warm("api:teams", () => redisCache(apiCacheKey("teams"), 300, loadTeams, { staleMs: 600 }), ); await warm("news:home", () => cacheNews(apiCacheKey("home"), 15_000, async () => ({ primed: true })), ); } async function listOnlineUsers() { return db .select({ username: User.username, look: User.look }) .from(User) .where(eq(User.online, "1")) .limit(100); } async function loadStaff() { const minStaffRank = Number(await siteSettings.get("min_staff_rank", "7")) || 7; return 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); } async function loadTeams() { return 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)); }