mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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 }),
|
||||
|
||||
Vendored
+19
-9
@@ -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
|
||||
|
||||
Vendored
+19
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user