From 2b89259cced75eaadc6d3bc24b012e0696245ef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 00:05:12 +0200 Subject: [PATCH] perf(daily): cache the digest's milestone + tension gather Every 120s poll of every open tab re-derived the S11 tension window and the S12 milestone from ~8.5k rows even though both are usually null and change at most once a day. Wrap the two independent reads in one SWR cell under the existing analytics bucket, keyed per user and local day, with a TTL that mirrors the dashboard-snapshot cell so a scheduled poll lands warm. Riding the analytics bucket's `${userId}|` prefix means the same measurement-write invalidation the snapshot cell already relies on covers this cell too, so a fresh sleep landing still refreshes it. Also hoists the milestone gather and the tension read into one Promise.all instead of two sequential awaits. --- src/lib/daily/__tests__/load-digest.test.ts | 154 ++++++++++++++++++++ src/lib/daily/load-digest.ts | 82 +++++++++-- 2 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 src/lib/daily/__tests__/load-digest.test.ts diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts new file mode 100644 index 000000000..8a6dae2b9 --- /dev/null +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * `loadDailyDigest` — cache behaviour for the S11/S12 "extras" (the milestone + * gather + the intraday-tension read). Perf finding: both re-derived from + * scratch on every 120 s poll of every open tab, reading ~8.5k rows to + * surface a usually-null marker. Now wrapped in one SWR cell + * (`loadDailyDigestExtrasCached`, internal to `load-digest.ts`) under the + * shared `analytics` bucket, so this file drives the reads its builder + * touches (`probeRollupCoverage`, `loadIntradayPulse`) and asserts they run + * once per cache generation, not once per request. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + integrationStatus: { findMany: vi.fn().mockResolvedValue([]) }, + measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, + coachPlan: { findMany: vi.fn().mockResolvedValue([]) }, + ecgRecording: { findFirst: vi.fn().mockResolvedValue(null) }, + dismissedPriorityItem: { findMany: vi.fn().mockResolvedValue([]) }, + personalRecord: { findMany: vi.fn().mockResolvedValue([]) }, + }, +})); + +vi.mock("@/lib/dashboard/snapshot-read", () => ({ + readDashboardSnapshotCached: vi.fn(), +})); + +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: vi.fn().mockResolvedValue(new Map()), +})); + +vi.mock("@/lib/insights/derived/baseline", () => ({ + readDayMeanSeries: vi + .fn() + .mockResolvedValue({ points: [], source: "none" }), +})); + +vi.mock("@/lib/analytics/intraday-pulse-io", () => ({ + loadIntradayPulse: vi.fn(), +})); + +vi.mock("@/lib/i18n/server-translator", () => ({ + getServerTranslator: vi.fn().mockReturnValue({ t: (key: string) => key }), +})); + +import type { User } from "@/generated/prisma/client"; +import { loadDailyDigest } from "../load-digest"; +import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; +import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; + +const SNAPSHOT = { + body: { + tiles: { lastSeenByType: {} }, + medsToday: { + activeCount: 0, + scheduledToday: 0, + takenToday: 0, + skippedToday: 0, + nextDueAt: null, + nextDueOverdue: false, + }, + healthScore: null, + briefing: null, + briefingState: "ready", + briefingUpdatedAt: null, + briefingStale: false, + }, + locale: "en", +}; + +const USER = { + id: "user-1", + timezone: "Europe/Berlin", + morningDigestRefreshedOn: null, +} as unknown as User; + +const NOW = new Date("2026-07-17T09:00:00.000Z"); + +beforeEach(() => { + vi.clearAllMocks(); + __resetAllCachesForTests(); + vi.mocked(readDashboardSnapshotCached).mockResolvedValue(SNAPSHOT as never); + vi.mocked(resolveModuleMap).mockResolvedValue({} as never); + vi.mocked(probeRollupCoverage).mockResolvedValue(new Map()); + vi.mocked(loadIntradayPulse).mockResolvedValue({ tension: null } as never); +}); + +describe("loadDailyDigest — S11/S12 extras cache", () => { + it("gathers the milestone + tension inputs once, then serves the second poll from cache", async () => { + await loadDailyDigest(USER, NOW); + await loadDailyDigest(USER, NOW); + + expect(probeRollupCoverage).toHaveBeenCalledTimes(1); + expect(loadIntradayPulse).toHaveBeenCalledTimes(1); + }); + + it("runs the milestone gather and the tension read in parallel, not sequentially", async () => { + const order: string[] = []; + vi.mocked(probeRollupCoverage).mockImplementation(async () => { + order.push("milestone-start"); + await new Promise((r) => setTimeout(r, 5)); + order.push("milestone-end"); + return new Map(); + }); + vi.mocked(loadIntradayPulse).mockImplementation(async () => { + order.push("tension-start"); + await new Promise((r) => setTimeout(r, 5)); + order.push("tension-end"); + return { tension: null } as never; + }); + + await loadDailyDigest(USER, NOW); + + // Sequential awaits would read as [milestone-start, milestone-end, + // tension-start, tension-end]. Promise.all interleaves the starts + // before either finishes. + expect(order.slice(0, 2).sort()).toEqual([ + "milestone-start", + "tension-start", + ]); + }); + + it("refreshes on the next read after a measurement write invalidates the user's cache", async () => { + await loadDailyDigest(USER, NOW); + expect(probeRollupCoverage).toHaveBeenCalledTimes(1); + + // A fresh sleep / vitals landing (interactive write) hard-evicts the + // `${userId}|` prefix — the same sweep the dashboard-snapshot cell + // already relies on. + invalidateUserMeasurements(USER.id, { evict: true }); + + await loadDailyDigest(USER, NOW); + expect(probeRollupCoverage).toHaveBeenCalledTimes(2); + expect(loadIntradayPulse).toHaveBeenCalledTimes(2); + }); + + it("never gathers the extras when the insights module is disabled", async () => { + vi.mocked(resolveModuleMap).mockResolvedValue({ insights: false } as never); + + await loadDailyDigest(USER, NOW); + + expect(probeRollupCoverage).not.toHaveBeenCalled(); + expect(loadIntradayPulse).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 2873c7166..9464470e9 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -22,6 +22,8 @@ import { getServerTranslator } from "@/lib/i18n/server-translator"; import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { userDayKey } from "@/lib/tz/format"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval"; import { buildDailyDigest, type DailyDigest, @@ -140,6 +142,61 @@ async function gatherFreshMilestone( return selectFreshMilestone(candidates, todayKey); } +/** + * P — the milestone gather (~8.5k rows via the streak/PR reads) and the + * intraday-tension read (a bounded but real per-sample day read) are the two + * heaviest inputs the digest computes on every request, yet both are usually + * null and change at most once per local day. Every open tab's 120 s poll + * (`DASHBOARD_REFETCH_INTERVAL_MS`) was re-deriving them from scratch. + * + * Cached together in ONE cell — keyed `${userId}|daily-digest-extras|${dateKey}` + * under the shared `analytics` bucket so the write-invalidation semantics + * `readDashboardSnapshotCached` already relies on cover this cell too: every + * measurement write sweeps the `${userId}|` prefix (mark-stale for a + * background sync, hard-evict for an interactive write — see + * `invalidateUserMeasurements`), so a fresh sleep/vitals landing still + * refreshes this within the same SWR contract the snapshot cell uses. TTL + * mirrors `SNAPSHOT_CACHE_TTL_MS` — strictly greater than the client poll + * interval so a scheduled refetch lands warm. + */ +const DIGEST_EXTRAS_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000; + +interface DailyDigestExtras { + milestone: Milestone | null; + tensionWindow: DailyDigestTensionWindow | null; +} + +function digestExtrasCacheKey(userId: string, dateKey: string): string { + return `${userId}|daily-digest-extras|${dateKey}`; +} + +async function loadDailyDigestExtrasCached( + userId: string, + timezone: string, + todayLocalDate: string, + now: Date, +): Promise { + return cachedSwr( + caches.analytics as ServerCache, + digestExtrasCacheKey(userId, todayLocalDate), + async () => { + // S11 / S12 — independent reads, hoisted into one Promise.all (a prior + // revision ran them sequentially). Each is already fault-isolated + // internally (a milestone read-hiccup or a tension-read failure leaves + // its own field quiet rather than breaking the other). + const [milestone, tensionWindow] = await Promise.all([ + gatherFreshMilestone(userId, now), + loadIntradayPulse(userId, timezone, todayLocalDate) + .then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null)) + .catch(() => null), + ]); + return { milestone, tensionWindow }; + }, + annotate, + DIGEST_EXTRAS_CACHE_TTL_MS, + ); +} + /** S10 — how recently an ECG recording counts as "new" for the rail read. */ const ECG_NEW_WINDOW_MS = 24 * 60 * 60 * 1000; @@ -306,20 +363,25 @@ export async function loadDailyDigest( // S12 — the calm reward layer. Only gather when the insights module (the // narrative layer that hosts the milestone card) is on; the builder gates on // it too, so a disabled account never surfaces one either way. - const insightsEnabled = modules.insights !== false; - const milestone = insightsEnabled - ? await gatherFreshMilestone(user.id, now) - : null; - + // // S11 — the day's elevated-at-rest window, computed on demand from raw for // TODAY only (read-swap, one bounded day-read; never persisted). Gated on the // insights module and fault-isolated: a tension-read failure must never break // the digest (a hot, must-not-fail path), it just omits the calm marker. - const tensionWindow: DailyDigestTensionWindow | null = insightsEnabled - ? await loadIntradayPulse(user.id, user.timezone, todayLocalDate) - .then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null)) - .catch(() => null) - : null; + // + // Both ride the cached, single-flight `loadDailyDigestExtrasCached` cell so + // a 120 s poll of every open tab doesn't re-run the ~8.5k-row streak + + // per-sample tension reads on every request — see its docblock for the + // cache/invalidation contract. + const insightsEnabled = modules.insights !== false; + const { milestone, tensionWindow } = insightsEnabled + ? await loadDailyDigestExtrasCached( + user.id, + user.timezone, + todayLocalDate, + now, + ) + : { milestone: null, tensionWindow: null }; // Dismiss ledger (P — Today rail dismiss). Only the OBSERVATIONAL kinds // (milestone / ecg_new_recording / tension_window) can ever be dismissed, so