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
This commit is contained in:
openhands committed 2026-07-31 15:09:56 +02:00
1 parent ef5e706ee1
commit d1807ca814
2 files changed
+64 -14

No files matched your search

+11 -5
View File
@@ -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 {
+53 -9
View File
@@ -1,17 +1,61 @@
type CacheEntry<T> = { data: T; expiresAt: number };
const store = new Map<string, CacheEntry<unknown>>();
import "server-only";
export function cached<T>(
import { redis } from "@/lib/redis";
type CacheEntry<T> = { data: T; expiresAt: number };
const memory = new Map<string, CacheEntry<unknown>>();
/**
* 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 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<void> {
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;
});
}