perf(notifications): cache the dispatch-localised user lookup

This commit is contained in:
Marc-André Bombeck committed 2026-05-16 02:01:09 +02:00
1 parent 8f7cbd49b9
commit b0ef80dcd4
2 files changed
+225 -9

No files matched your search

@@ -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);
});
});
+60 -9
View File
@@ -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<string, { locale: Locale; expiresAt: number }>();
/**
* 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<Locale> {
// 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(