From b0ef80dcd4bf269ed8a71c315ee8b44c1ad2e519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 02:01:09 +0200 Subject: [PATCH] perf(notifications): cache the dispatch-localised user lookup --- .../dispatch-localised-cache.test.ts | 165 ++++++++++++++++++ src/lib/notifications/dispatch-localised.ts | 69 +++++++- 2 files changed, 225 insertions(+), 9 deletions(-) create mode 100644 src/lib/notifications/__tests__/dispatch-localised-cache.test.ts diff --git a/src/lib/notifications/__tests__/dispatch-localised-cache.test.ts b/src/lib/notifications/__tests__/dispatch-localised-cache.test.ts new file mode 100644 index 000000000..5105ba270 --- /dev/null +++ b/src/lib/notifications/__tests__/dispatch-localised-cache.test.ts @@ -0,0 +1,165 @@ +/** + * v1.4.28 R3d (R1.2 H5) — cache behaviour for the per-recipient locale + * lookup behind `dispatchLocalisedNotification`. + * + * Pre-fix, every dispatch hit `prisma.user.findUnique` to resolve + * `User.locale`. The helper now keeps a process-level TTL LRU; repeat + * dispatches to the same recipient inside 30 s share one Prisma query. + * + * The three test paths cover: + * 1. Cache hit within TTL → second call does not re-query Prisma. + * 2. Cache miss after TTL → second call refreshes the entry. + * 3. Cache reset (`__resetDispatchLocaleCacheForTests`) → next call + * re-queries. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + }, + }, +})); + +const dispatchNotificationMock = vi.fn().mockResolvedValue(undefined); +vi.mock("@/lib/notifications/dispatcher", () => ({ + dispatchNotification: (...args: unknown[]) => + dispatchNotificationMock(...args), +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: vi.fn(), addMeta: vi.fn() }), +})); + +vi.mock("@/lib/i18n/server-translator", () => ({ + getServerTranslator: (locale: string) => ({ + locale, + t: (key: string) => `${locale}:${key}`, + }), +})); + +import { + __resetDispatchLocaleCacheForTests, + dispatchLocalisedNotification, +} from "../dispatch-localised"; +import { prisma } from "@/lib/db"; + +beforeEach(() => { + vi.useFakeTimers(); + vi.resetAllMocks(); + __resetDispatchLocaleCacheForTests(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe("dispatchLocalisedNotification — locale-lookup cache", () => { + it("collapses repeat dispatches to the same user onto one Prisma query", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + locale: "de", + } as never); + + await dispatchLocalisedNotification({ + userId: "u-1", + titleKey: "k.title", + messageKey: "k.body", + }); + await dispatchLocalisedNotification({ + userId: "u-1", + titleKey: "k.title", + messageKey: "k.body", + }); + + expect(prisma.user.findUnique).toHaveBeenCalledTimes(1); + expect(dispatchNotificationMock).toHaveBeenCalledTimes(2); + }); + + it("re-queries after the 30-second TTL elapses", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + locale: "de", + } as never); + + await dispatchLocalisedNotification({ + userId: "u-2", + titleKey: "k.title", + messageKey: "k.body", + }); + + // Advance the clock past the 30-second TTL. + vi.advanceTimersByTime(30_001); + + await dispatchLocalisedNotification({ + userId: "u-2", + titleKey: "k.title", + messageKey: "k.body", + }); + + expect(prisma.user.findUnique).toHaveBeenCalledTimes(2); + }); + + it("keeps the cache entry across a 29-second gap (just inside TTL)", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + locale: "de", + } as never); + + await dispatchLocalisedNotification({ + userId: "u-3", + titleKey: "k.title", + messageKey: "k.body", + }); + + vi.advanceTimersByTime(29_000); + + await dispatchLocalisedNotification({ + userId: "u-3", + titleKey: "k.title", + messageKey: "k.body", + }); + + expect(prisma.user.findUnique).toHaveBeenCalledTimes(1); + }); + + it("reset helper drops cached entries so the next call re-queries", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + locale: "de", + } as never); + + await dispatchLocalisedNotification({ + userId: "u-4", + titleKey: "k.title", + messageKey: "k.body", + }); + + __resetDispatchLocaleCacheForTests(); + + await dispatchLocalisedNotification({ + userId: "u-4", + titleKey: "k.title", + messageKey: "k.body", + }); + + expect(prisma.user.findUnique).toHaveBeenCalledTimes(2); + }); + + it("isolates distinct users — second user is a cache miss even if first is cached", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + locale: "de", + } as never); + + await dispatchLocalisedNotification({ + userId: "u-5a", + titleKey: "k.title", + messageKey: "k.body", + }); + await dispatchLocalisedNotification({ + userId: "u-5b", + titleKey: "k.title", + messageKey: "k.body", + }); + + expect(prisma.user.findUnique).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/notifications/dispatch-localised.ts b/src/lib/notifications/dispatch-localised.ts index b58e46a66..5d20d5f39 100644 --- a/src/lib/notifications/dispatch-localised.ts +++ b/src/lib/notifications/dispatch-localised.ts @@ -25,6 +25,16 @@ * is reserved for a future per-channel routing pass and is currently * a no-op (the dispatcher fans the message across every enabled * channel as before). + * + * v1.4.28 R3d (R1.2 H5) — the locale lookup previously fired + * `prisma.user.findUnique` on every call. A 30-second TTL LRU keyed on + * `userId` collapses repeat reads inside the same recipient burst onto + * one DB round-trip; the next read after 30 s re-validates. The + * trade-off: a user who flips their locale in Settings won't see the + * change reflected in dispatch output for up to 30 s. That's + * acceptable for the call-sites this helper serves (admin alerts, + * deploy webhooks, reminder cron) — none of them are user-initiated + * within a tight feedback loop. */ import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; @@ -57,27 +67,68 @@ function isLocale(value: string | null | undefined): value is Locale { } /** - * Resolve a user's persisted locale, falling back to the project - * default when the column is null/empty. We deliberately do NOT chain - * through cookies or Accept-Language here — this helper runs from - * background jobs and webhook handlers where the request context may - * not belong to the recipient (an admin alert resolves the admin's - * locale, not the affected user's), so the only authoritative source - * is the persisted column. + * Process-level locale cache. + * + * `Map`-based; capped to 1 000 entries with FIFO-style eviction once + * we hit the cap (LRU on insertion order — `Map` iterates in insertion + * order, so deleting + re-inserting on read marks the entry "fresh"). + * Each entry stores the resolved `Locale` and the `Date.now()` at + * which it expires. Reads past the expiry re-fire the Prisma query + * and refresh the entry. + * + * Memory footprint at the cap is trivial (1 000 short strings + 1 000 + * numbers ≈ 50 KB worst-case), so no eviction-by-size pressure is + * needed. The cap exists only to bound unbounded growth in workers + * that dispatch to many distinct users. */ +const LOCALE_CACHE_TTL_MS = 30_000; +const LOCALE_CACHE_MAX = 1_000; +const localeCache = new Map(); + +/** + * Reset hook for tests. Production code never calls this — the cache + * is intentionally process-lifetime. + */ +export function __resetDispatchLocaleCacheForTests(): void { + localeCache.clear(); +} + async function resolveRecipientLocale(userId: string): Promise { + // v1.4.28 R3d (R1.2 H5) — fast path: cached entry within TTL. + const cached = localeCache.get(userId); + if (cached && cached.expiresAt > Date.now()) { + // Mark as fresh by re-inserting (Map iteration order doubles as + // LRU order — older entries sit at the head, newer at the tail). + localeCache.delete(userId); + localeCache.set(userId, cached); + return cached.locale; + } + + // Cache miss or stale → resolve from Prisma. + let resolved: Locale = defaultLocale; try { const row = await prisma.user.findUnique({ where: { id: userId }, select: { locale: true }, }); - if (isLocale(row?.locale)) return row.locale; + if (isLocale(row?.locale)) resolved = row.locale; } catch (err) { getEvent()?.addWarning( `dispatchLocalisedNotification — locale lookup failed: ${err instanceof Error ? err.message : String(err)}`, ); + return defaultLocale; } - return defaultLocale; + + // Insert + opportunistic eviction at the head when over the cap. + if (localeCache.size >= LOCALE_CACHE_MAX) { + const firstKey = localeCache.keys().next().value; + if (firstKey !== undefined) localeCache.delete(firstKey); + } + localeCache.set(userId, { + locale: resolved, + expiresAt: Date.now() + LOCALE_CACHE_TTL_MS, + }); + return resolved; } export async function dispatchLocalisedNotification(