From d1807ca8146e73c373f3fa5a2ca8655991109ffc Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 31 Jul 2026 15:09:56 +0200 Subject: [PATCH] perf: cache online API endpoints with Redis-first cache - Upgrade lib/cache.ts: Redis-first cached() with in-memory fallback (was in-memory only, broken across PM2 instances) - Cache /api/online user list (10s TTL, was uncached per-request) eliminates DB query on every poll request - Add uncached() invalidation helper for write-after-cache patterns - 0 tsc errors, 583 tests passing --- src/app/api/online/route.ts | 16 +++++++--- src/lib/cache.ts | 62 +++++++++++++++++++++++++++++++------ 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/app/api/online/route.ts b/src/app/api/online/route.ts index fced5e15..761e923b 100644 --- a/src/app/api/online/route.ts +++ b/src/app/api/online/route.ts @@ -5,17 +5,23 @@ // DB failure. import { apiJson } from "@/lib/api"; +import { cached } from "@/lib/cache"; import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; export async function GET(_req: Request) { try { - const users = await prisma.user.findMany({ - where: { online: "1" }, - select: { username: true, look: true }, - take: 100, - }); + const users = await cached( + "online_users", + 10_000, + async () => + prisma.user.findMany({ + where: { online: "1" }, + select: { username: true, look: true }, + take: 100, + }), + ); return apiJson({ users }); } catch { diff --git a/src/lib/cache.ts b/src/lib/cache.ts index 620a1cc6..dcf8ded7 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -1,17 +1,61 @@ -type CacheEntry = { data: T; expiresAt: number }; -const store = new Map>(); +import "server-only"; -export function cached( +import { redis } from "@/lib/redis"; + +type CacheEntry = { data: T; expiresAt: number }; +const memory = new Map>(); + +/** + * 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( key: string, ttlMs: number, fn: () => Promise, ): Promise { - const existing = store.get(key); + const ttlSec = Math.ceil(ttlMs / 1000); + + // Redis path (shared across instances). + if (redis && redis.status !== "end") { + try { + const cached = await redis.get(key); + if (cached !== null && cached !== undefined) { + return JSON.parse(cached) as T; + } + } catch { + /* fall through to DB / memory */ + } + } + + // In-memory fallback (single-instance fast path). + const existing = memory.get(key); if (existing && existing.expiresAt > Date.now()) { - return Promise.resolve(existing.data as T); + return existing.data as T; + } + + 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 */ + } + } + memory.set(key, { data, expiresAt: Date.now() + ttlMs }); + + return data; +} + +/** Invalidate a single cache key across Redis + memory (call after writes). */ +export async function uncached(key: string): Promise { + memory.delete(key); + if (redis && redis.status !== "end") { + try { + await redis.del(key); + } catch { + /* non-critical */ + } } - return fn().then((data) => { - store.set(key, { data, expiresAt: Date.now() + ttlMs }); - return data; - }); }