mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
perf(analytics): read the latest value per type with one probe per type
The latest canonical reading per type ran as `DISTINCT ON (type)` over every live row of the account in five places (summaries slice warm and cold path, comprehensive aggregator warm and cold path, targets). With no skip scan in Postgres 16 that sorted the whole account to keep one row per type. `readLatestCanonical` drives the read from the type list instead: one index probe for each type's latest instant, then a closed one-day window ranked by the source ladder. The closed window also keeps the plan under `jit_above_cost`. The targets read reuses `readLatestEver` with a floor. The summaries slice was built inside three cache cells (the tile-strip route, the thick analytics body, the dashboard snapshot). It now has one cell, read through `readSummariesSlice`; composing callers read it fresh-only so a refresh after a write cannot cache the pre-write slice again, and the snapshot folds the water tile into a copy instead of the cached object.
This commit is contained in:
1 parent
8eb4ef3d56
commit
9c51dd0d30
14 files changed
+436
-185
No files matched your search
@@ -6,7 +6,7 @@ import { analyticsQuerySchema } from "@/lib/validations/analytics";
|
||||
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
|
||||
import { readSummariesSlice } from "@/lib/analytics/summaries-slice";
|
||||
import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range";
|
||||
import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
@@ -103,14 +103,7 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
// tile strip's next mount should repaint from the prior summaries
|
||||
// instantly while one background recompute refreshes them, instead
|
||||
// of paying the 2-SQL-pass rebuild inline.
|
||||
const slim = await cachedSwr(
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof computeSummariesSlice>>
|
||||
>,
|
||||
`${user.id}|summaries`,
|
||||
() => computeSummariesSlice(user.id),
|
||||
annotate,
|
||||
);
|
||||
const slim = await readSummariesSlice(user.id, { serveStale: true });
|
||||
// Drop the types whose module the user turned off, from the SAME map
|
||||
// the snapshot builder gates on (`SUMMARY_TYPE_MODULE`, via
|
||||
// `gateSummariesByModules`). With `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false`
|
||||
@@ -282,7 +275,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
// release; previously only the live walk produced it)
|
||||
// - `avg30LastYear` populated for any type whose WMY tier carries
|
||||
// the year-ago window
|
||||
// - `lastSeenByType` from the `DISTINCT ON (type)` latest read
|
||||
// - `lastSeenByType` from the per-type latest read
|
||||
//
|
||||
// `anomalyCount` and `slope90` left this wire in v1.37.19: the slim
|
||||
// path could never compute the former (it always reported 0) and no
|
||||
@@ -292,7 +285,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
// `avg30LastYear` on types with no WMY coverage, which matches the
|
||||
// pre-fix behaviour for tenants whose year-ago window happened to
|
||||
// fall outside the 425-day floor.
|
||||
const slim = await computeSummariesSlice(user.id);
|
||||
const slim = await readSummariesSlice(user.id);
|
||||
// Same module gate as the slim slice and the snapshot builder — the
|
||||
// default slice feeds the dashboard too (it is the thick half of the
|
||||
// legacy two-cell path), so it cannot be the one feed that leaks a
|
||||
|
||||
@@ -170,9 +170,9 @@ beforeEach(() => {
|
||||
(
|
||||
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
// v1.28.25 — the latest-ever-per-type read is a raw `DISTINCT ON`
|
||||
// (Prisma's `distinct` dedups client-side after pulling every row).
|
||||
(prisma.$queryRawUnsafe as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
// The latest-ever-per-type read (`readLatestEver`).
|
||||
(prisma.$queryRaw as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
vi.mocked(readGlucoseTargetSummaries).mockResolvedValue([]);
|
||||
vi.mocked(readRestingPulseProxy).mockResolvedValue([]);
|
||||
});
|
||||
@@ -321,35 +321,30 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
expect(moodTarget).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads latest-ever-per-type via raw DISTINCT ON with the one-year floor", async () => {
|
||||
// Audit High finding 3 established the 365-day floor; v1.28.25 moves
|
||||
// the read onto a real Postgres `DISTINCT ON` (Prisma's `distinct`
|
||||
// dedups in the driver AFTER pulling every row in the window, which
|
||||
// on a dense-type tenant shipped a year of raw rows to Node just to
|
||||
// keep seven). Pin the raw query: DISTINCT ON, per-type descending
|
||||
// walk, user id + floored date as bound parameters.
|
||||
it("reads latest-ever-per-type one index probe per type, floored at one year", async () => {
|
||||
// Audit High finding 3 established the 365-day floor. The read is the
|
||||
// shared `readLatestEver`: driven from the type list, one LIMIT 1 probe
|
||||
// per type, so a dense type never sorts a year of raw rows to keep one.
|
||||
await callGet(makeReq());
|
||||
|
||||
const rawCalls = (prisma.$queryRawUnsafe as ReturnType<typeof vi.fn>).mock
|
||||
.calls;
|
||||
const distinctCall = rawCalls.find(
|
||||
(c) => typeof c[0] === "string" && c[0].includes("DISTINCT ON"),
|
||||
const rawCalls = (prisma.$queryRaw as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const latestCall = rawCalls.find(
|
||||
(c) =>
|
||||
Array.isArray(c[0]) &&
|
||||
(c[0] as string[]).join("?").includes("CROSS JOIN LATERAL"),
|
||||
);
|
||||
expect(distinctCall).toBeDefined();
|
||||
const [sql, boundUserId, flooredAt] = distinctCall as [
|
||||
string,
|
||||
string,
|
||||
Date,
|
||||
];
|
||||
expect(sql).toContain(`DISTINCT ON (m."type")`);
|
||||
expect(sql).toContain(`ORDER BY m."type" ASC, m."measured_at" DESC`);
|
||||
// Parameter-bound, never spliced: user id + date ride $1/$2.
|
||||
expect(sql).toContain("$1");
|
||||
expect(sql).toContain("$2");
|
||||
expect(boundUserId).toBe("user-targets-1");
|
||||
expect(latestCall).toBeDefined();
|
||||
const [strings, ...values] = latestCall as [string[], ...unknown[]];
|
||||
expect(strings.join("?")).not.toContain("DISTINCT ON");
|
||||
expect(values).toContain("user-targets-1");
|
||||
const floor = values.find(
|
||||
(v): v is { values: unknown[] } =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v),
|
||||
);
|
||||
const flooredAt = floor?.values[0];
|
||||
expect(flooredAt).toBeInstanceOf(Date);
|
||||
const oneYearMs = 365 * 24 * 60 * 60 * 1000;
|
||||
const ageMs = Date.now() - flooredAt.getTime();
|
||||
const ageMs = Date.now() - (flooredAt as Date).getTime();
|
||||
// Floor is somewhere around one year ago — give a ±1-hour window
|
||||
// for clock drift between the route's `Date.now()` and the test
|
||||
// assertion.
|
||||
@@ -454,7 +449,7 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
timeZone,
|
||||
),
|
||||
);
|
||||
(prisma.$queryRawUnsafe as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
(prisma.$queryRaw as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ type: "WEIGHT", value: 70 },
|
||||
{ type: "BLOOD_PRESSURE_SYS", value: 125 },
|
||||
{ type: "BLOOD_PRESSURE_DIA", value: 75 },
|
||||
|
||||
Reference in new issue
Block a user