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 }),
+19 -9
View File
@@ -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
+19
View File
@@ -339,10 +339,29 @@ export class ServerCache<T> {
* `/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<unknown>({
name: "analytics",
maxEntries: 1000,
ttlMs: 60_000,
staleTtlMs: 600_000,
}),
medications: new ServerCache<unknown>({
name: "medications",