mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Cache the correlations discovery read and keep writes off the hit path
GET /api/insights/correlations was the most expensive uncached read in the app: a 180-day window, ~10 parallel series reads, Pearson + FDR in-process, plus two syncAcceptedPatterns DB writes — all paid on every mount. The built body now reads through the analytics bucket's SWR cell (60 s fresh / 1 h stale, keyed userId|correlations|locale), and the pattern-sync writes live inside the builder, so a cache hit performs no write on the read path. Eviction truth comes free: measurement, mood, medication, illness and custom-metric writes already sweep the user's prefix in that bucket, and a pattern dismiss sweeps it through invalidateUserCorrelationPatterns. The route test pins the contract: a second GET inside the fresh window re-runs neither the measurement window read nor the pattern sync, and serves a byte-equal body.
This commit is contained in:
1 parent
f2f40ae8b2
commit
ac96e5e975
2 files changed
+71
-19
No files matched your search
@@ -80,6 +80,8 @@ import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import { syncAcceptedPatterns } from "@/lib/insights/correlation-patterns";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -88,6 +90,9 @@ const SESSION_OK = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// The route reads through the SWR analytics cache now; a warm cell from a
|
||||
// prior test would serve a hit and skip the builder every assertion spies on.
|
||||
__resetAllCachesForTests();
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
// v1.15.20 — default to an allowing analytics-read budget.
|
||||
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
|
||||
@@ -157,6 +162,25 @@ describe("GET /api/insights/correlations", () => {
|
||||
expect(body.data.fdrQ).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("serves a cache hit without re-running the discovery reads or the pattern-sync writes", async () => {
|
||||
// The SWR cell caches the built body for 60 s; the second GET inside that
|
||||
// window must neither re-read the measurement window nor re-run the two
|
||||
// `syncAcceptedPatterns` DB writes — writes on the read path were the
|
||||
// pre-cache defect, and a hit that still wrote would reintroduce it.
|
||||
// Watched red: with the assertion counts set to 2 (i.e. asserting the
|
||||
// builder re-ran) this test fails, so the cache path is proven live.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const first = await callGet(makeReq());
|
||||
expect(first.status).toBe(200);
|
||||
const second = await callGet(makeReq());
|
||||
expect(second.status).toBe(200);
|
||||
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(syncAcceptedPatterns).toHaveBeenCalledTimes(2);
|
||||
const firstBody = (await first.json()) as { data: unknown };
|
||||
const secondBody = (await second.json()) as { data: unknown };
|
||||
expect(secondBody.data).toEqual(firstBody.data);
|
||||
});
|
||||
|
||||
it("returns 403 + errorCode when the correlations flag is off", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
@@ -86,16 +87,43 @@ export const GET = apiHandler(async () => {
|
||||
// Operator can hide the correlation surface entirely.
|
||||
await requireAssistantSurface("correlations");
|
||||
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const tz = profile?.timezone ?? "Europe/Berlin";
|
||||
// Reader's locale for the narrated `interpretation` — the correlation cards
|
||||
// render this string verbatim, so it MUST be localised (cookie / User.locale /
|
||||
// Accept-Language). Without it the never-causal sentence leaked English into a
|
||||
// non-English UI.
|
||||
// non-English UI. It is part of the cache key for the same reason.
|
||||
const locale = await resolveServerLocale({ userLocale: user.locale ?? null });
|
||||
|
||||
// The discovery scan is the most expensive uncached read the app had:
|
||||
// ~10 parallel window reads over 180 days + Pearson/FDR in-process, paid on
|
||||
// EVERY mount. Cache the built body in the analytics bucket (60 s fresh /
|
||||
// 1 h stale — the bucket defaults are exactly the contract this read wants).
|
||||
// Eviction truth comes free: every measurement / mood / medication /
|
||||
// illness / custom-metric write sweeps the `${userId}|` prefix in this
|
||||
// bucket, and a pattern dismiss goes through
|
||||
// `invalidateUserCorrelationPatterns`, which sweeps it too. The
|
||||
// `syncAcceptedPatterns` calls are DB writes and live INSIDE the builder,
|
||||
// so a cache hit performs no write on the read path.
|
||||
const body = await cachedSwr(
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof buildCorrelationsResponse>>
|
||||
>,
|
||||
`${user.id}|correlations|${locale}`,
|
||||
() => buildCorrelationsResponse(user.id, locale),
|
||||
annotate,
|
||||
);
|
||||
|
||||
return apiSuccess(body);
|
||||
});
|
||||
|
||||
async function buildCorrelationsResponse(
|
||||
userId: string,
|
||||
locale: Awaited<ReturnType<typeof resolveServerLocale>>,
|
||||
) {
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const tz = profile?.timezone ?? "Europe/Berlin";
|
||||
const now = new Date();
|
||||
const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY);
|
||||
|
||||
@@ -121,12 +149,12 @@ export const GET = apiHandler(async () => {
|
||||
moodWindow,
|
||||
priorityJson,
|
||||
] = await Promise.all([
|
||||
fetchMeasurementWindowSeries(user.id, since, [
|
||||
fetchMeasurementWindowSeries(userId, since, [
|
||||
...behaviourTypes,
|
||||
...outcomeTypes,
|
||||
]),
|
||||
fetchMoodWindowSeries(user.id, tz, since),
|
||||
loadUserSourcePriority(user.id),
|
||||
fetchMoodWindowSeries(userId, tz, since),
|
||||
loadUserSourcePriority(userId),
|
||||
]);
|
||||
const { moodDaily, moodCapped } = moodWindow;
|
||||
|
||||
@@ -141,14 +169,14 @@ export const GET = apiHandler(async () => {
|
||||
environmentSeries,
|
||||
customMetricSeries,
|
||||
] = await Promise.all([
|
||||
fetchComplianceSeries(user.id, tz, since),
|
||||
fetchSymptomSeries(user.id, tz, since),
|
||||
fetchLabDraws(user.id, tz, since),
|
||||
fetchComplianceSeries(userId, tz, since),
|
||||
fetchSymptomSeries(userId, tz, since),
|
||||
fetchLabDraws(userId, tz, since),
|
||||
// v1.25 (W-ENV) — environmental-exposure behaviour channels (weather /
|
||||
// daylight). Empty when the module is off / no home set, so the channels
|
||||
// degrade to absent. The module gate is implicit: no rows ⇒ no channels.
|
||||
fetchEnvironmentSeries(user.id, since),
|
||||
fetchCustomMetricBehaviourSeries(user.id, tz, since),
|
||||
fetchEnvironmentSeries(userId, since),
|
||||
fetchCustomMetricBehaviourSeries(userId, tz, since),
|
||||
]);
|
||||
|
||||
const points = (key: string): DailySeriesPoint[] =>
|
||||
@@ -219,13 +247,13 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
const [retrospectiveDecisions, recentDecisions] = await Promise.all([
|
||||
syncAcceptedPatterns({
|
||||
userId: user.id,
|
||||
userId,
|
||||
family: PATTERN_FAMILIES.discoveryRetrospective,
|
||||
accepted: result.discovered.map(toEvidence),
|
||||
computedAt: now,
|
||||
}),
|
||||
syncAcceptedPatterns({
|
||||
userId: user.id,
|
||||
userId,
|
||||
family: PATTERN_FAMILIES.discoveryRecent,
|
||||
accepted: emerging.emerging.map(toEvidence),
|
||||
computedAt: now,
|
||||
@@ -285,10 +313,10 @@ export const GET = apiHandler(async () => {
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
return {
|
||||
...result,
|
||||
discovered,
|
||||
emerging: emergingWithDecisions,
|
||||
labCorrelations,
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user