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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 00:37:24 +02:00
1 parent 95ab0514b9
commit 2b89259cce
2 files changed
+226 -10

No files matched your search

+154
View File
@@ -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();
});
});
+72 -10
View File
@@ -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<DailyDigestExtras> {
return cachedSwr(
caches.analytics as ServerCache<DailyDigestExtras>,
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