perf(dashboard): serve the snapshot stale-while-revalidate on a measurement write

The dashboard snapshot lives in the analytics cache bucket, which had no
stale window, and every measurement write hard-evicted the whole
${userId}| prefix. A high-frequency iOS Apple-Health sync therefore
busted the snapshot into a cold rebuild on the next read all day long
(the same class as the v1.8.7 insight-cache eviction).

Give the analytics bucket a stale-while-revalidate window, read the
snapshot via cachedSwr, and switch the measurement-write invalidation
from a hard deleteByPrefix to markStaleByPrefix. The snapshot now serves
the prior value immediately while a single background recompute warms a
fresh one. Slim / thick / summary cells read via plain cached are
unaffected: a marked-stale entry has expiresAt === now, so their next
read is a clean miss and rebuilds fresh. Widget reorder keeps its hard
point-evict so a layout change paints synchronously; mood and medication
writes keep their hard analytics sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 13:51:54 +02:00
1 parent ae468edca2
commit 6de5de0284
4 files changed
+90 -22

No files matched your search

@@ -145,15 +145,45 @@ describe("GET /api/dashboard/snapshot", () => {
vi.useRealTimers();
});
it("evicts the snapshot on a measurement-write invalidation", async () => {
it("rebuilds after a measurement write once the stale entry is gone", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq());
expect(buildDashboardSnapshot).toHaveBeenCalledTimes(1);
// A measurement write sweeps the `${userId}|` prefix, which covers
// the longer-TTL snapshot key.
// v1.12.7 — a measurement write now marks the `${userId}|` prefix
// stale rather than hard-evicting. A plain `get()` treats a
// marked-stale entry (expiresAt === now) as a miss and removes it.
invalidateUserMeasurements("user-1");
expect(caches.analytics.get("user-1|dashboard-snapshot")).toBeNull();
await callGet(makeReq());
expect(buildDashboardSnapshot).toHaveBeenCalledTimes(2);
});
it("serves the prior snapshot stale + warms a background rebuild on a measurement write (A4)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Warm the snapshot.
const first = await callGet(makeReq());
expect((await first.json()).data.briefingState).toBe("preparing");
expect(buildDashboardSnapshot).toHaveBeenCalledTimes(1);
// The next rebuild would produce a different body.
const FRESH_BODY = { ...SNAPSHOT_BODY, briefingState: "ready" };
buildDashboardSnapshot.mockResolvedValue(FRESH_BODY);
// A measurement write marks the bucket stale (no hard evict, no
// intervening get()). The entry stays servable inside the SWR window.
invalidateUserMeasurements("user-1");
// The next read serves the PRIOR (stale) body immediately and kicks
// off a single background recompute — the foreground request does not
// pay the cold rebuild.
const stale = await callGet(makeReq());
expect((await stale.json()).data.briefingState).toBe("preparing");
// Let the detached background rebuild settle, then a follow-up read
// sees the freshly-warmed body.
await new Promise((r) => setTimeout(r, 0));
expect(buildDashboardSnapshot).toHaveBeenCalledTimes(2);
const fresh = await callGet(makeReq());
expect((await fresh.json()).data.briefingState).toBe("ready");
});
});
+19 -10
View File
@@ -12,14 +12,16 @@
* cookie-session surface but the route does not gate on it. `userId`
* is narrowed from the resolved session — never a body field.
*
* The body is read-through `caches.analytics` keyed
* `${userId}|dashboard-snapshot`, but with a per-key TTL of
* `SNAPSHOT_CACHE_TTL_MS` (>= the client's 120 s refetch interval) so a
* scheduled refetch lands on a warm entry instead of re-running the
* full builder on every tick. It still rides the analytics bucket so
* the `${userId}|` eviction sweep on a measurement / mood / medication
* write covers it (see `src/lib/cache/invalidate.ts`) — correctness on
* writes is unchanged; only the idle-poll TTL is longer. Per-sub-query
* The body is read-through `caches.analytics` (stale-while-revalidate
* via `cachedSwr`) keyed `${userId}|dashboard-snapshot`, with a per-key
* TTL of `SNAPSHOT_CACHE_TTL_MS` (>= the client's 120 s refetch
* interval) so a scheduled refetch lands on a warm entry instead of
* re-running the full builder on every tick. It still rides the
* analytics bucket so a measurement / mood / medication write covers it
* (see `src/lib/cache/invalidate.ts`): a measurement write marks the
* bucket stale (the high-frequency iOS-sync path serves the prior
* snapshot + warms a background recompute), while a widget reorder still
* hard-evicts so the layout change paints synchronously. Per-sub-query
* timings surface under `meta.snapshot.sub_*_ms` on the cache-miss path
* so a regression is attributable without re-instrumenting.
*
@@ -32,7 +34,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
import {
buildDashboardSnapshot,
@@ -87,7 +89,14 @@ export const GET = apiHandler(async () => {
dashboardWidgetsJson: user.dashboardWidgetsJson,
};
const body = await cached(
// Stale-while-revalidate: a measurement write marks the analytics
// bucket stale rather than hard-evicting the snapshot, so a busy iOS
// sync serves the prior snapshot immediately (within the bucket's
// stale window) while a single background recompute warms a fresh one
// — the foreground request never pays the cold rebuild. Hard-evicting
// writes (widget reorder) still drop the key outright, forcing a clean
// miss + synchronous rebuild here.
const body = await cachedSwr(
caches.analytics as ServerCache<DashboardSnapshot>,
`${user.id}|dashboard-snapshot`,
() => buildDashboardSnapshot(prisma, snapshotUser, { time }),