From cbf056838fc84206f442ac30fe57ba59384c0630 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 13 Sep 2026 13:32:51 +0200 Subject: [PATCH] perf(admin): cache global admin list totals via redisCache Introduce getCachedAdminCount to cache un-filtered table count(*) queries in Redis for admin lists (starting with UsersPage), avoiding heavy full table scans on every request while keeping exact counts for search/filtered queries. --- src/app/admin/users/page.tsx | 9 +++++---- src/lib/services/admin-stats.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 src/lib/services/admin-stats.ts diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 819462c2..f5f35437 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -1,4 +1,4 @@ -import { and, count, desc, eq, gte, like, or } from "drizzle-orm"; +import { and, desc, eq, gte, like, or } from "drizzle-orm"; import { Users } from "lucide-react"; import { getTranslations } from "next-intl/server"; import { AdminPageShell } from "@/components/admin/admin-page-shell"; @@ -6,6 +6,7 @@ import { fetchAdminList } from "@/lib/admin-list"; import { db, User } from "@/lib/db"; import { formatNumber } from "@/lib/format-date"; import { canAccess, PERMS } from "@/lib/permissions"; +import { getCachedAdminCount } from "@/lib/services/admin-stats"; import { CreateUserDialog } from "./_components/create-user-dialog"; import { UsersTable } from "./users-table"; @@ -43,7 +44,7 @@ export default async function UsersPage({ conditions.push(eq(User.online, rawParams.filter_online)); } const where = conditions.length > 0 ? and(...conditions) : undefined; - const [rows, totals] = await Promise.all([ + const [rows, total] = await Promise.all([ db .select({ id: User.id, @@ -64,9 +65,9 @@ export default async function UsersPage({ .orderBy(desc(User.id)) .limit(perPage) .offset((page - 1) * perPage), - db.select({ total: count() }).from(User).where(where), + getCachedAdminCount(User, "users:total", where), ]); - return { rows, total: totals[0]?.total ?? 0 }; + return { rows, total }; }, }, paramsPromise, diff --git a/src/lib/services/admin-stats.ts b/src/lib/services/admin-stats.ts new file mode 100644 index 00000000..245b6e4d --- /dev/null +++ b/src/lib/services/admin-stats.ts @@ -0,0 +1,26 @@ +import { count, type SQL } from "drizzle-orm"; +import type { MySqlTable } from "drizzle-orm/mysql-core"; +import { db } from "@/lib/db"; +import { redisCache } from "@/lib/redis-cache"; + +/** + * Caches admin list totals when no filters are applied (no 'where' clause), + * preventing heavy COUNT(*) table scans on every request for large tables. + * Filtered queries bypass cache to ensure exact search result counts. + */ +export async function getCachedAdminCount( + table: MySqlTable, + cacheKey: string, + where?: SQL | undefined, +): Promise { + // Only cache the global count (where = undefined) + if (where) { + const [row] = await db.select({ total: count() }).from(table).where(where); + return Number(row?.total ?? 0); + } + + return await redisCache(`admin:count:${cacheKey}`, 60, async () => { + const [row] = await db.select({ total: count() }).from(table); + return Number(row?.total ?? 0); + }); +}