perf: optimize DB layer with caching and pool tuning
CI / check (push) Successful in 36s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m35s

- Add Redis cache wrapper (cached-db.ts) — cachedQuery + invalidate helpers
- Add cached login user lookup (auth.ts: getLoginUser) — short 15s TTL
  for brute-force protection, cache invalidation on password/rank changes
- Switch auth.ts login flow from Prisma facade to raw SQL via db.execute
  (avoids abstraction overhead for this hot path)
- Cache invalidation wired in: login password upgrade, updateUser, resetPassword
- Connection pool tuning: enableKeepAlive, namedPlaceholders,
  prepared statement cache (Node 22+), multipleStatements off (SQLi hardening)
- 0 tsc errors, 583 tests passing
This commit is contained in:
openhands committed 2026-07-31 15:01:34 +02:00
1 parent c0bbcae5d6
commit ef5e706ee1
4 files changed
+152 -4

No files matched your search

+3
View File
@@ -3,6 +3,7 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { z } from "zod"; import { z } from "zod";
import { hashPassword } from "@/lib/auth/password"; import { hashPassword } from "@/lib/auth/password";
import { invalidateLoginCache } from "@/lib/auth";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
@@ -119,6 +120,7 @@ export const updateUser = adminAction(
} }
await prisma.user.update({ where: { id }, data: userData }); await prisma.user.update({ where: { id }, data: userData });
invalidateLoginCache(targetUser.username);
if (diamonds !== undefined) { if (diamonds !== undefined) {
await prisma.usersCurrency.upsert({ await prisma.usersCurrency.upsert({
@@ -313,6 +315,7 @@ export const resetPassword = adminAction(
where: { id: ctx.data.userId }, where: { id: ctx.data.userId },
data: { password: hashed }, data: { password: hashed },
}); });
invalidateLoginCache(target.username);
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
+83 -2
View File
File diff suppressed because it is too large. Load diff
+56
View File
@@ -0,0 +1,56 @@
import "server-only";
import { redis } from "@/lib/redis";
import { logger } from "@/lib/logger";
const DEFAULT_CACHE_TTL = 60;
function isRedisAvailable(): boolean {
return !!redis && redis.status !== "end";
}
export async function cachedQuery<T>(
cacheKey: string,
queryFn: () => Promise<T>,
ttl: number = DEFAULT_CACHE_TTL,
): Promise<T> {
if (!isRedisAvailable()) {
return queryFn();
}
try {
const cached = await redis?.get(cacheKey);
if (cached !== null && cached !== undefined) {
return JSON.parse(cached) as T;
}
} catch (err) {
logger.warn("[cache] read failed, falling back to DB", { key: cacheKey, error: String(err) });
}
const result = await queryFn();
try {
await redis?.setex(cacheKey, ttl, JSON.stringify(result));
} catch (err) {
logger.warn("[cache] write failed, continuing without cache", {
key: cacheKey,
error: String(err),
});
}
return result;
}
/** Invalidate cache keys matching a glob pattern. */
export function invalidateCache(pattern: string): Promise<number> {
if (!isRedisAvailable()) return Promise.resolve(0);
return redis?.keys(pattern).then((keys) =>
keys.length > 0 ? redis!.del(...keys) : 0,
) ?? Promise.resolve(0);
}
/** Invalidate a single cache key immediately. */
export function invalidateKey(key: string): Promise<number> {
if (!isRedisAvailable()) return Promise.resolve(0);
return redis!.del(key);
}
+10 -2
View File
@@ -34,8 +34,16 @@ function createDb(): MySql2Database<typeof fullSchema> {
queueLimit: 0, queueLimit: 0,
connectTimeout, connectTimeout,
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS, idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
// Keep BIGINT precision; safe values come back as numbers, huge ones as enableKeepAlive: true,
// strings (drizzle bigint mode maps both to JS bigint). // Prepared-statement caching via mysql2's native support (Node 22+).
// Saves query-parse per request on hot paths.
...("cache" in mysql.createPool && {
cache: { type: "prepared" },
}),
// Allow :placeholder syntax for raw SQL helpers.
namedPlaceholders: true,
// Reject multiple statements (SQL injection hardening).
multipleStatements: false,
supportBigNumbers: true, supportBigNumbers: true,
}); });