From a49715e93862dd960fd3189417f52e652234c0e7 Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Fri, 31 Jul 2026 18:20:34 +0800 Subject: [PATCH] Raise catalog detail cache TTL to 24h and optimize global search. Longer L1/KV TTL improves section KV hit rate for repeat traffic; search now skips COUNT queries and caches responses for 120s. --- .../server/global-search-catalog-queries.ts | 75 +++++++++++++++++ .../server/global-search-response-cache.ts | 71 ++++++++++++++++ .../search/server/global-search-service.ts | 56 +++++++------ src/lib/catalog-detail-runtime-cache.ts | 9 ++- tests/unit/global-search-service.test.ts | 80 ++++++++++++------- 5 files changed, 234 insertions(+), 57 deletions(-) create mode 100644 src/features/search/server/global-search-catalog-queries.ts create mode 100644 src/features/search/server/global-search-response-cache.ts diff --git a/src/features/search/server/global-search-catalog-queries.ts b/src/features/search/server/global-search-catalog-queries.ts new file mode 100644 index 000000000..f49ae95c0 --- /dev/null +++ b/src/features/search/server/global-search-catalog-queries.ts @@ -0,0 +1,75 @@ +import { buildCourseListWhere } from "@/features/catalog/server/course-query-filters"; +import { buildSectionListQuery } from "@/features/catalog/server/section-query-filters"; +import { SECTION_SUMMARY_DEFAULT_ORDER_BY } from "@/features/catalog/server/section-summary-read-model"; +import { buildTeacherWhere } from "@/features/catalog/server/teacher-query"; +import type { AppLocale } from "@/i18n/config"; +import { getPrisma } from "@/lib/db/prisma"; + +export async function searchCoursesForGlobal( + query: string, + locale: AppLocale, + limit: number, +) { + return getPrisma(locale).course.findMany({ + where: buildCourseListWhere({ search: query }), + orderBy: [{ code: "asc" }, { jwId: "asc" }], + select: { + code: true, + jwId: true, + nameCn: true, + namePrimary: true, + }, + take: limit, + }); +} + +export async function searchSectionsForGlobal( + query: string, + locale: AppLocale, + limit: number, +) { + const { orderBy, where } = buildSectionListQuery({ search: query }); + return getPrisma(locale).section.findMany({ + where, + orderBy: orderBy ?? SECTION_SUMMARY_DEFAULT_ORDER_BY, + select: { + code: true, + jwId: true, + course: { + select: { + code: true, + nameCn: true, + namePrimary: true, + }, + }, + semester: { + select: { + nameCn: true, + }, + }, + }, + take: limit, + }); +} + +export async function searchTeachersForGlobal( + query: string, + locale: AppLocale, + limit: number, +) { + return getPrisma(locale).teacher.findMany({ + where: buildTeacherWhere({ search: query }), + orderBy: { nameCn: "asc" }, + select: { + code: true, + id: true, + nameCn: true, + department: { + select: { + nameCn: true, + }, + }, + }, + take: limit, + }); +} diff --git a/src/features/search/server/global-search-response-cache.ts b/src/features/search/server/global-search-response-cache.ts new file mode 100644 index 000000000..61488e0fb --- /dev/null +++ b/src/features/search/server/global-search-response-cache.ts @@ -0,0 +1,71 @@ +import type { GlobalSearchResponse } from "@/features/search/server/global-search-types"; +import type { AppLocale } from "@/i18n/config"; + +const SEARCH_CACHE_TTL_MS = 120_000; +const MAX_SEARCH_CACHE_ENTRIES = 256; + +type SearchCacheEntry = { + expiresAt: number; + value: GlobalSearchResponse; +}; + +const globalForSearchCache = globalThis as typeof globalThis & { + __lifeUstcGlobalSearchCache?: Map; +}; + +function searchCacheStore() { + globalForSearchCache.__lifeUstcGlobalSearchCache ??= new Map(); + return globalForSearchCache.__lifeUstcGlobalSearchCache; +} + +function searchCacheKey( + locale: AppLocale, + query: string, + limit: number, + userId: string | null | undefined, +) { + return `${locale}:${userId ?? "anon"}:${limit}:${query}`; +} + +export function readCachedGlobalSearch( + locale: AppLocale, + query: string, + limit: number, + userId: string | null | undefined, +) { + const key = searchCacheKey(locale, query, limit, userId); + const entry = searchCacheStore().get(key); + if (!entry) return null; + if (entry.expiresAt <= Date.now()) { + searchCacheStore().delete(key); + return null; + } + return entry.value; +} + +export function writeCachedGlobalSearch( + locale: AppLocale, + query: string, + limit: number, + userId: string | null | undefined, + value: GlobalSearchResponse, +) { + const store = searchCacheStore(); + const now = Date.now(); + for (const [key, entry] of store) { + if (entry.expiresAt <= now) store.delete(key); + } + while (store.size >= MAX_SEARCH_CACHE_ENTRIES) { + const oldestKey = store.keys().next().value; + if (!oldestKey) break; + store.delete(oldestKey); + } + store.set(searchCacheKey(locale, query, limit, userId), { + expiresAt: now + SEARCH_CACHE_TTL_MS, + value, + }); +} + +export function resetGlobalSearchCacheForTest() { + searchCacheStore().clear(); +} diff --git a/src/features/search/server/global-search-service.ts b/src/features/search/server/global-search-service.ts index bea9815ec..a22a07f1a 100644 --- a/src/features/search/server/global-search-service.ts +++ b/src/features/search/server/global-search-service.ts @@ -1,8 +1,12 @@ import { - listCourseSummaries, - listSectionSummaries, - listTeacherSummaries, -} from "@/features/catalog/server/course-section-queries"; + searchCoursesForGlobal, + searchSectionsForGlobal, + searchTeachersForGlobal, +} from "@/features/search/server/global-search-catalog-queries"; +import { + readCachedGlobalSearch, + writeCachedGlobalSearch, +} from "@/features/search/server/global-search-response-cache"; import type { GlobalSearchResponse, GlobalSearchResultGroup, @@ -68,40 +72,28 @@ async function searchCatalogGroups( limit: number, ): Promise { const [courses, sections, teachers] = await Promise.all([ - listCourseSummaries({ - filters: { search: query }, - locale, - pagination: { page: 1, pageSize: limit }, - }), - listSectionSummaries({ - filters: { search: query }, - locale, - pagination: { page: 1, pageSize: limit }, - }), - listTeacherSummaries({ - filters: { search: query }, - locale, - pagination: { page: 1, pageSize: limit }, - }), + searchCoursesForGlobal(query, locale, limit), + searchSectionsForGlobal(query, locale, limit), + searchTeachersForGlobal(query, locale, limit), ]); const groups: GlobalSearchResultGroup[] = []; - if (courses.data.length > 0) { + if (courses.length > 0) { groups.push({ type: "courses", - items: courses.data.map(toCourseItem), + items: courses.map(toCourseItem), }); } - if (sections.data.length > 0) { + if (sections.length > 0) { groups.push({ type: "sections", - items: sections.data.map((section) => toSectionItem(section, locale)), + items: sections.map((section) => toSectionItem(section, locale)), }); } - if (teachers.data.length > 0) { + if (teachers.length > 0) { groups.push({ type: "teachers", - items: teachers.data.map((teacher) => ({ + items: teachers.map((teacher) => ({ id: `teacher:${teacher.id}`, title: teacher.nameCn, description: teacher.department?.nameCn ?? teacher.code, @@ -209,6 +201,16 @@ export async function searchGlobally(input: { return { query, groups: [] }; } + const cached = readCachedGlobalSearch( + input.locale, + query, + limit, + input.userId, + ); + if (cached) { + return cached; + } + const [catalogGroups, workspaceGroups] = await Promise.all([ searchCatalogGroups(query, input.locale, limit), input.userId @@ -216,10 +218,12 @@ export async function searchGlobally(input: { : Promise.resolve([]), ]); - return { + const response = { query, groups: [...catalogGroups, ...workspaceGroups], }; + writeCachedGlobalSearch(input.locale, query, limit, input.userId, response); + return response; } export function hasGlobalSearchQuery(query: string) { diff --git a/src/lib/catalog-detail-runtime-cache.ts b/src/lib/catalog-detail-runtime-cache.ts index 2643b16e9..b9a891d17 100644 --- a/src/lib/catalog-detail-runtime-cache.ts +++ b/src/lib/catalog-detail-runtime-cache.ts @@ -5,8 +5,13 @@ import { publicDetailKvCacheKey, } from "@/lib/public-runtime-cache"; -export const PUBLIC_DETAIL_RUNTIME_CACHE_TTL_MS = 60_000; -export const PUBLIC_DETAIL_KV_CACHE_TTL_MS = 3_600_000; +const HOUR_MS = 3_600_000; + +/** L1 isolate + colo Cache API TTL for anonymous catalog entity core. */ +export const PUBLIC_DETAIL_RUNTIME_CACHE_TTL_MS = 24 * HOUR_MS; + +/** Cross-PoP KV TTL; revision-scoped keys invalidate on static import. */ +export const PUBLIC_DETAIL_KV_CACHE_TTL_MS = 24 * HOUR_MS; export async function buildPublicDetailRuntimeCacheOptions(input: { coloCacheKey?: string; diff --git a/tests/unit/global-search-service.test.ts b/tests/unit/global-search-service.test.ts index c3bb3655d..0c23916e3 100644 --- a/tests/unit/global-search-service.test.ts +++ b/tests/unit/global-search-service.test.ts @@ -1,27 +1,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { - listCourseSummariesMock, - listSectionSummariesMock, - listTeacherSummariesMock, + searchCoursesForGlobalMock, + searchSectionsForGlobalMock, + searchTeachersForGlobalMock, withUserDbContextMock, } = vi.hoisted(() => ({ - listCourseSummariesMock: vi.fn(), - listSectionSummariesMock: vi.fn(), - listTeacherSummariesMock: vi.fn(), + searchCoursesForGlobalMock: vi.fn(), + searchSectionsForGlobalMock: vi.fn(), + searchTeachersForGlobalMock: vi.fn(), withUserDbContextMock: vi.fn(), })); -vi.mock("@/features/catalog/server/course-section-queries", () => ({ - listCourseSummaries: listCourseSummariesMock, - listSectionSummaries: listSectionSummariesMock, - listTeacherSummaries: listTeacherSummariesMock, +vi.mock("@/features/search/server/global-search-catalog-queries", () => ({ + searchCoursesForGlobal: searchCoursesForGlobalMock, + searchSectionsForGlobal: searchSectionsForGlobalMock, + searchTeachersForGlobal: searchTeachersForGlobalMock, })); vi.mock("@/lib/db/prisma", () => ({ withUserDbContext: withUserDbContextMock, })); +import { resetGlobalSearchCacheForTest } from "@/features/search/server/global-search-response-cache"; import { hasGlobalSearchQuery, searchGlobally, @@ -30,9 +31,10 @@ import { describe("global search service", () => { beforeEach(() => { vi.clearAllMocks(); - listCourseSummariesMock.mockResolvedValue({ data: [] }); - listSectionSummariesMock.mockResolvedValue({ data: [] }); - listTeacherSummariesMock.mockResolvedValue({ data: [] }); + resetGlobalSearchCacheForTest(); + searchCoursesForGlobalMock.mockResolvedValue([]); + searchSectionsForGlobalMock.mockResolvedValue([]); + searchTeachersForGlobalMock.mockResolvedValue([]); withUserDbContextMock.mockImplementation( async (_userId: string, work: (tx: unknown) => Promise) => work({ @@ -49,31 +51,35 @@ describe("global search service", () => { }); expect(result.groups).toEqual([]); - expect(listCourseSummariesMock).not.toHaveBeenCalled(); + expect(searchCoursesForGlobalMock).not.toHaveBeenCalled(); }); it("searches catalog entities for public users", async () => { - listCourseSummariesMock.mockResolvedValue({ - data: [ - { - jwId: 101, - code: "CS101", - nameCn: "数据结构", - namePrimary: "数据结构", - }, - ], - }); + searchCoursesForGlobalMock.mockResolvedValue([ + { + jwId: 101, + code: "CS101", + nameCn: "数据结构", + namePrimary: "数据结构", + }, + ]); const result = await searchGlobally({ locale: "zh-cn", query: "数据", }); - expect(listCourseSummariesMock).toHaveBeenCalledWith({ - filters: { search: "数据" }, - locale: "zh-cn", - pagination: { page: 1, pageSize: 5 }, - }); + expect(searchCoursesForGlobalMock).toHaveBeenCalledWith("数据", "zh-cn", 5); + expect(searchSectionsForGlobalMock).toHaveBeenCalledWith( + "数据", + "zh-cn", + 5, + ); + expect(searchTeachersForGlobalMock).toHaveBeenCalledWith( + "数据", + "zh-cn", + 5, + ); expect(result.groups).toEqual([ { type: "courses", @@ -89,6 +95,22 @@ describe("global search service", () => { ]); }); + it("serves repeated queries from the response cache", async () => { + searchCoursesForGlobalMock.mockResolvedValue([ + { + jwId: 101, + code: "CS101", + nameCn: "数据结构", + namePrimary: "数据结构", + }, + ]); + + await searchGlobally({ locale: "zh-cn", query: "数据" }); + await searchGlobally({ locale: "zh-cn", query: "数据" }); + + expect(searchCoursesForGlobalMock).toHaveBeenCalledTimes(1); + }); + it("includes workspace groups for signed-in users", async () => { const homeworkFindMany = vi.fn().mockResolvedValue([ {