mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
|
||||
Reference in new issue
Block a user