From 6de5de0284e71ceaf0d05c8e7b9461b30416a178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:48:54 +0200 Subject: [PATCH] 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) --- .../snapshot/__tests__/route.test.ts | 36 +++++++++++++++++-- src/app/api/dashboard/snapshot/route.ts | 29 +++++++++------ src/lib/cache/invalidate.ts | 28 ++++++++++----- src/lib/cache/server-cache.ts | 19 ++++++++++ 4 files changed, 90 insertions(+), 22 deletions(-) diff --git a/src/app/api/dashboard/snapshot/__tests__/route.test.ts b/src/app/api/dashboard/snapshot/__tests__/route.test.ts index a88e5fc80..634de7dc8 100644 --- a/src/app/api/dashboard/snapshot/__tests__/route.test.ts +++ b/src/app/api/dashboard/snapshot/__tests__/route.test.ts @@ -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"); + }); }); diff --git a/src/app/api/dashboard/snapshot/route.ts b/src/app/api/dashboard/snapshot/route.ts index f700e5e21..bade4ac73 100644 --- a/src/app/api/dashboard/snapshot/route.ts +++ b/src/app/api/dashboard/snapshot/route.ts @@ -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, `${user.id}|dashboard-snapshot`, () => buildDashboardSnapshot(prisma, snapshotUser, { time }), diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index c919aa245..ada0837bf 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -22,12 +22,12 @@ import { caches } from "./server-cache"; /** * v1.7.0 W6 — server cache key for the unified dashboard first-paint - * snapshot. Lives under the analytics cache bucket so the existing - * `caches.analytics.deleteByPrefix(`${userId}|`)` sweep on a - * measurement / mood / medication write already covers it; the widget - * + insight invalidators that DON'T touch the analytics bucket call - * `invalidateUserDashboardSnapshot` explicitly. Exported so the cache - * key stays single-source-of-truth across the route and the tests. + * snapshot. Lives under the analytics cache bucket so the `${userId}|` + * sweep on a measurement / mood / medication write already covers it + * (measurement writes mark it stale; mood / medication writes hard-evict + * it); the widget + insight invalidators that DON'T touch the analytics + * bucket call `invalidateUserDashboardSnapshot` explicitly. Exported so + * the cache key stays single-source-of-truth across route and tests. */ export function dashboardSnapshotCacheKey(userId: string): string { return `${userId}|dashboard-snapshot`; @@ -49,10 +49,20 @@ export function invalidateUserDashboardSnapshot(userId: string): void { * because mood writes don't change measurement rows. */ export function invalidateUserMeasurements(userId: string): void { - // The `${userId}|` prefix sweep covers the slim / thick analytics - // cells, the iOS summary cell, AND the v1.7.0 dashboard snapshot + // The `${userId}|` prefix covers the slim / thick analytics cells, the + // iOS summary cell, AND the v1.7.0 dashboard snapshot // (`${userId}|dashboard-snapshot`) in one pass. - caches.analytics.deleteByPrefix(`${userId}|`); + // + // v1.12.7 — mark stale rather than hard-evict. Measurement writes are + // the highest-frequency dirty signal (every iOS Apple-Health sync + // posts a batch), and a hard evict busts the snapshot into a cold + // rebuild on the next read all day long. Marking stale lets the + // `cachedSwr` snapshot read serve the prior value immediately while a + // single background recompute warms a fresh one. The 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 — identical to the old evict for those keys. + caches.analytics.markStaleByPrefix(`${userId}|`); caches.achievements.deleteByPrefix(userId); caches.workouts.deleteByPrefix(`${userId}|`); // v1.4.36 W1 — measurement writes change the per-target consistency diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 741af07cb..2ede5fe63 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -339,10 +339,29 @@ export class ServerCache { * `/api/mood/analytics`) without churning the helper module. */ export const caches = { + /** + * Slim / thick analytics cells, the iOS summary cell, the dashboard + * summary, AND the unified dashboard first-paint snapshot (per-key TTL + * override). 60 s fresh TTL keeps multiple sub-page mounts inside a + * minute on a warm entry. + * + * v1.12.7 — stale-while-revalidate window. The snapshot read uses + * `cachedSwr` and a measurement write marks the bucket stale (not a + * hard evict) via `invalidateUserMeasurements`, so a high-frequency + * iOS Apple-Health sync no longer busts the snapshot into a cold + * rebuild on every batch (the v1.8.7 "sync evicting the cache all day" + * class, on the dashboard snapshot). Slim / thick / summary cells read + * via plain `cached` are unaffected: a marked-stale entry has + * `expiresAt === now`, so their next `cached` read is a clean miss and + * rebuilds fresh — only the `cachedSwr` snapshot serves stale + warms a + * background recompute. The 10-minute stale window bounds how old a + * served snapshot can be when no reader triggers a revalidation. + */ analytics: new ServerCache({ name: "analytics", maxEntries: 1000, ttlMs: 60_000, + staleTtlMs: 600_000, }), medications: new ServerCache({ name: "medications",