perf(dashboard): cache today's cycle read and run the vitals reads together

The digest is polled every 120 seconds. Its cycle line read the whole
cycle history, 90 days of day logs and wrist temperature on every poll,
outside any cache, and the coincident-deviation engine walked its eleven
vitals one after another, two queries each.

The cycle read now rides the digest extras cell, which every cycle write
already hard-evicts; the cycle preferences route evicts it too, and a
module switch does through the health-score invalidator. A record without
a cycle profile stops after one point read. The per-vital reads run
together and are folded in registry order, so the result does not depend
on which finished first.

On a seeded four-year account (1.25M measurements, 52 cycles) under a
792 MB heap, the warm digest went from 9.9 to 5.0 ms and the vitals engine
from 11.1 to 4.6 ms (medians of four interleaved rounds).
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 08:34:28 +02:00
1 parent bfcf71b7eb
commit 4bb03600b3
5 files changed
+81 -38

No files matched your search

+4
View File
@@ -28,6 +28,7 @@ import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { isCycleEnabled } from "@/lib/cycle/gate";
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
import { toCycleProfileDTO } from "@/lib/cycle/dto";
import { cyclePrefsSchema } from "@/lib/validations/cycle";
@@ -118,6 +119,9 @@ export const PATCH = apiHandler(async (req: Request) => {
});
const resolved = isCycleEnabled(user.gender, updated);
// The typical lengths, the prediction switch and tracking itself all feed
// the dashboard's cycle line, which is cached in the digest extras cell.
invalidateUserHealthContext(user.id);
await auditLog("user.cycle-prefs.update", {
userId: user.id,
+8 -3
View File
@@ -41,8 +41,13 @@ export async function readTodayCycle(
? from
: addDays(today, -BBT_WINDOW);
const [profile, cycles, dayLogRows, nightlyTemps] = await Promise.all([
prisma.cycleProfile.findUnique({ where: { userId } }),
// A record that never set up cycle tracking has no profile: one point read
// and done, rather than three history reads that cannot be used.
const profile = await prisma.cycleProfile.findUnique({ where: { userId } });
if (!profile) return null;
// The whole history, as the calendar route reads it: the forecast rests on
// every observed cycle, and this read must agree with the cycle page.
const [cycles, dayLogRows, nightlyTemps] = await Promise.all([
prisma.menstrualCycle.findMany({
where: { userId, deletedAt: null },
orderBy: { startDate: "asc" },
@@ -74,7 +79,7 @@ export async function readTodayCycle(
select: { measuredAt: true, value: true },
}),
]);
if (!profile || cycles.length === 0) return null;
if (cycles.length === 0) return null;
// The intent fields and the presence flags never reach a phase or a day
// count; the grid carries them for the calendar's own overlay only.
@@ -81,6 +81,7 @@ import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
import { prisma } from "@/lib/db";
@@ -617,6 +618,18 @@ describe("loadDailyDigest — Today overview inputs", () => {
expect(digest.restMode).toBeNull();
});
it("reads the cycle once per extras cell, and again after a cycle write", async () => {
// The digest polls every 120 s. The cycle read (the whole cycle history,
// 90 days of day logs and wrist temperature) rides the cached cell, which
// a cycle write hard-evicts.
await loadDailyDigest(USER, NOW);
await loadDailyDigest(USER, NOW);
expect(readTodayCycle).toHaveBeenCalledTimes(1);
invalidateUserHealthContext(USER.id);
await loadDailyDigest(USER, NOW);
expect(readTodayCycle).toHaveBeenCalledTimes(2);
});
it("reads the cycle in the profile timezone and keeps a failed read quiet", async () => {
vi.mocked(readTodayCycle).mockRejectedValueOnce(new Error("boom"));
const digest = await loadDailyDigest(USER, NOW);
+39 -26
View File
@@ -79,7 +79,7 @@ import {
type UnitPreferences,
} from "@/lib/measurements/display-transform";
import { resolveRestMode } from "@/lib/illness/rest-mode";
import { readTodayCycle } from "@/lib/cycle/today-verdict";
import { readTodayCycle, type TodayCycleRead } from "@/lib/cycle/today-verdict";
import { addDays, dayDiff } from "@/lib/cycle/day-math";
import { makeFormatters, resolveIntlLocale } from "@/lib/format-locale";
import type {
@@ -243,6 +243,13 @@ interface DailyDigestExtras {
vitals: ExtrasVital[];
/** The stored daily scores the "steady for" line is read from. */
scoreDays: StoredScoreDay[];
/**
* Today's place in the cycle, read only while the cycle module is on. A
* cycle write hard-evicts the cell (`invalidateUserHealthContext`), and a
* module switch does too (`invalidateUserHealthScore`), so a cached
* answer never outlives the rows or the switch it was read under.
*/
cycle: TodayCycleRead | null;
}
interface ExtrasVital {
@@ -391,6 +398,7 @@ async function loadDailyDigestExtrasCached(
timezone: string,
todayLocalDate: string,
now: Date,
cycleOn: boolean,
): Promise<DailyDigestExtras> {
return cachedSwr(
caches.analytics as ServerCache<DailyDigestExtras>,
@@ -400,7 +408,7 @@ async function loadDailyDigestExtrasCached(
// 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, sameTime, vitals, scoreDays] =
const [milestone, tensionWindow, sameTime, vitals, scoreDays, cycle] =
await Promise.all([
gatherFreshMilestone(userId, timezone, now),
loadIntradayPulse(userId, timezone, todayLocalDate)
@@ -432,8 +440,13 @@ async function loadDailyDigestExtrasCached(
.catch(() => null),
gatherVitals(userId, timezone, now),
gatherScoreDays(userId, todayLocalDate),
// The whole cycle history, 90 days of day logs and wrist
// temperature: too much to read on every 120 s poll.
cycleOn
? readTodayCycle(userId, timezone, now).catch(() => null)
: Promise.resolve(null),
]);
return { milestone, tensionWindow, sameTime, vitals, scoreDays };
return { milestone, tensionWindow, sameTime, vitals, scoreDays, cycle };
},
annotate,
DIGEST_EXTRAS_CACHE_TTL_MS,
@@ -761,19 +774,12 @@ export async function loadDailyDigest(
timeLabel: visitTime(row.occurredAt),
}));
// Rest Mode and today's cycle day. Both are read per request rather than
// through the extras cell: an episode or a period logged a minute ago is not
// a measurement write, so nothing would evict a cached answer. Each read is
// skipped outright when its module is off, and fault-isolated, because the
// digest is a must-not-fail path.
const [restModeContext, cycleRead] = await Promise.all([
modules.illness !== false
? resolveRestMode(user.id, now)
: Promise.resolve(null),
modules.cycle !== false
? readTodayCycle(user.id, user.timezone, now).catch(() => null)
: Promise.resolve(null),
]);
// Rest Mode is read per request; today's cycle day rides the extras cell
// below, which every cycle write hard-evicts. The read is skipped outright
// when its module is off, and fault-isolated, because the digest is a
// must-not-fail path.
const restModeContext =
modules.illness !== false ? await resolveRestMode(user.id, now) : null;
const restMode: TodayRestMode | null =
restModeContext?.active && restModeContext.since
? {
@@ -787,9 +793,6 @@ export async function loadDailyDigest(
) + 1,
}
: null;
const cycle: TodayCycle | null = cycleRead
? { dayOfCycle: cycleRead.dayOfCycle, phase: cycleRead.phase }
: null;
// Last night, when it is in: the snapshot's sleep summary carries per-night
// time asleep in minutes, `latest` being the newest night and `avg30` the
@@ -845,13 +848,23 @@ export async function loadDailyDigest(
// 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 { milestone, tensionWindow, sameTime, vitals, scoreDays } =
await loadDailyDigestExtrasCached(
user.id,
user.timezone,
todayLocalDate,
now,
);
const {
milestone,
tensionWindow,
sameTime,
vitals,
scoreDays,
cycle: cycleRead,
} = await loadDailyDigestExtrasCached(
user.id,
user.timezone,
todayLocalDate,
now,
modules.cycle !== false,
);
const cycle: TodayCycle | null = cycleRead
? { dayOfCycle: cycleRead.dayOfCycle, phase: cycleRead.phase }
: null;
// Dismiss ledger (P — Today rail dismiss). Only the OBSERVATIONAL kinds
// (milestone / ecg_new_recording / tension_window) can ever be dismissed, so
@@ -231,17 +231,25 @@ export async function computeCoincidentDeviation(
const todayKey = userDayKey(now, tz);
// Fan the baseline engine across the supported vitals. Each baseline read
// shares the one coverage probe (no per-vital re-probe).
for (const type of VITALS_BASELINE_TYPES) {
const baseline = await computeVitalsBaseline(userId, profile, {
type,
windowDays,
now,
coverage,
});
// shares the one coverage probe (no per-vital re-probe). The types are
// independent, so their reads run together; the results are folded in the
// registry's order, so the output does not depend on which finished first.
const perType = await Promise.all(
VITALS_BASELINE_TYPES.map(async (type) => {
const baseline = await computeVitalsBaseline(userId, profile, {
type,
windowDays,
now,
coverage,
});
if (baseline.status !== "ok") return { type, baseline, latest: null };
const latest = await readLatestDayMean(userId, type, windowDays, now, tz);
return { type, baseline, latest };
}),
);
for (const { type, baseline, latest } of perType) {
if (baseline.status !== "ok") continue;
if (baseline.provenance.source === "DAY") anyDaySource = true;
const latest = await readLatestDayMean(userId, type, windowDays, now, tz);
if (!latest) continue;
if (latest.day > latestDay) latestDay = latest.day;
if (baseline.coverage.historyDays > maxHistoryDays) {