mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
95ab0514b9
commit
2b89259cce
2 files changed
+226
-10
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user