fix(insights,cache): surface revalidation to clients and evict on interactive writes

Stale-served insights responses now carry a revalidating flag; the
advisor and comprehensive queries poll on a bounded 25 s cadence until
the background warm lands, so an open session converges instead of
pinning yesterday's briefing for an hour. Interactive measurement
writes (single create, edit, delete, bulk-delete, restore) hard-evict
the analytics cache so a manually entered value is reflected on the
next read; background batch sync keeps the mark-stale path. The
visit-triggered force warm collapses to one job per user instead of
one per locale, and the umami script proxy failure now annotates a
named action with the failure reason instead of a bare flag.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 19:45:32 +02:00
1 parent 78fe82e348
commit 03453aaed2
23 files changed
+461 -55

No files matched your search

+5
View File
@@ -11769,6 +11769,11 @@ components:
propertyNames:
type: string
additionalProperties: {}
revalidating:
description: True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs
in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the
fresh body.
type: boolean
required:
- summary
- recommendations
+1 -1
View File
@@ -51,7 +51,7 @@ export const GET = apiHandler(async () => {
// the former rate-limit bucket was an unconditional Postgres UPSERT
// per request, i.e. the limiter WAS the per-request DB load it claimed
// to prevent. The route stays authenticated; the only uncached work is
// one `appSettings` read per 60 s process-wide.
// one `appSettings` read per 10 min (the bucket's TTL) process-wide.
// Cache the global app-settings shape on a singleton key (per blueprint §3).
// `isAdmin` lives outside the cache because it varies per request. The
@@ -91,7 +91,7 @@ import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { __resetAllCachesForTests, caches } from "@/lib/cache/server-cache";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -438,3 +438,64 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
expect(body.data.weightBpCorrelation?.n).toBe(5);
});
});
// v1.16.7 — the route serves a marked-stale cache entry immediately
// (stale-while-revalidate) and must mark that body `revalidating: true`
// so the client can poll until the background rebuild lands. A fresh
// (cold or warm) read carries `revalidating: false`.
describe("GET /api/insights/comprehensive — stale-while-revalidate marker", () => {
const EMPTY_AGGREGATE = {
summaries: {},
bpRawRows: { sys: [], dia: [] },
dailyByType: {},
firstMeasurementAt: null,
totalMeasurements: 0,
};
it("marks a cold (inline-computed) read revalidating: false", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
).mockResolvedValue(EMPTY_AGGREGATE);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { revalidating: boolean } };
expect(body.data.revalidating).toBe(false);
});
it("marks a stale-served read revalidating: true and a converged read false again", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
).mockResolvedValue(EMPTY_AGGREGATE);
// Prime the cache entry (cold compute).
await callGet(makeReq());
// A measurement sync marks the bucket stale (the invalidate helper's
// default posture) — the next read serves the prior body + the flag.
caches.analytics.markStaleByPrefix(`${SESSION_OK.user.id}|`);
const staleRes = await callGet(makeReq());
expect(staleRes.status).toBe(200);
const staleBody = (await staleRes.json()) as {
data: { revalidating: boolean };
};
expect(staleBody.data.revalidating).toBe(true);
// The stale read kicked off a background rebuild; once it settles
// (the cache entry is fresh again) the next read is a plain hit and
// the flag drops so the poll stops.
await vi.waitFor(() => {
const entry = caches.analytics.getAllowStale(
`${SESSION_OK.user.id}|comprehensive`,
);
expect(entry?.stale).toBe(false);
});
const freshRes = await callGet(makeReq());
const freshBody = (await freshRes.json()) as {
data: { revalidating: boolean };
};
expect(freshBody.data.revalidating).toBe(false);
});
});
+13 -3
View File
@@ -25,7 +25,11 @@ import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import {
cachedSwrWithMeta,
caches,
type ServerCache,
} from "@/lib/cache/server-cache";
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
import {
ensureUserMoodRollupsFresh,
@@ -63,7 +67,13 @@ export const GET = apiHandler(async () => {
// rebuild on the very next mount, all day). The truly-cold first
// read of the day still computes inline — there is no prior body to
// serve — but it no longer recurs per visit.
const body = await cachedSwr(
//
// The `revalidating` flag marks a stale-served body honestly so the
// client can poll (bounded) until the background rebuild lands —
// mirroring the `/api/insights/generate` GET contract. It rides on
// the response, never inside the cached body, so a warmed entry
// doesn't freeze the flag.
const { value: body, outcome } = await cachedSwrWithMeta(
caches.analytics as ServerCache<Awaited<
ReturnType<typeof buildComprehensiveResponse>
>>,
@@ -72,7 +82,7 @@ export const GET = apiHandler(async () => {
annotate,
);
return apiSuccess(body);
return apiSuccess({ ...body, revalidating: outcome === "stale" });
});
type AuthedUser = AuthContext["user"];
@@ -448,14 +448,16 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { insights: unknown; cached: boolean };
data: { insights: unknown; cached: boolean; revalidating: boolean };
};
expect(body.data.cached).toBe(true);
expect(body.data.insights).toEqual(cached);
// No completion is ever run on the read path.
expect(resolveProvider).not.toHaveBeenCalled();
// A fresh cache (just now) does not trigger a warm.
// A fresh cache (just now) does not trigger a warm — and the payload
// says so, so the client never starts a convergence poll.
expect(enqueueForceWarm).not.toHaveBeenCalled();
expect(body.data.revalidating).toBe(false);
});
it("enqueues an out-of-band warm when the cache is stale and a provider exists", async () => {
@@ -475,6 +477,10 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
locale: "en",
});
expect(resolveProvider).not.toHaveBeenCalled();
// The stale serve is honest: `revalidating: true` rides on the
// payload so the client polls (bounded) until the warm lands.
const body = (await res.json()) as { data: { revalidating: boolean } };
expect(body.data.revalidating).toBe(true);
});
it("returns an empty payload (no warm) on a cold cache without a provider", async () => {
@@ -490,10 +496,12 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { insights: unknown; cached: boolean };
data: { insights: unknown; cached: boolean; revalidating: boolean };
};
expect(body.data.cached).toBe(false);
expect(body.data.insights).toBeNull();
expect(enqueueForceWarm).not.toHaveBeenCalled();
// No warm enqueued → no convergence poll for the client to run.
expect(body.data.revalidating).toBe(false);
});
});
+11 -1
View File
@@ -313,6 +313,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
cached: true,
cachedAt,
legacyPayload,
// Honest stale-serve marker: true while the out-of-band warm is
// in flight, so the client can poll (bounded) until the fresh
// briefing lands instead of sitting on the stale payload for the
// rest of the session.
revalidating,
});
} catch {
// Invalid cache row — fall through to the empty payload below. The
@@ -325,7 +330,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
action: { name: "insights.generate.read" },
meta: { cached: false, revalidating },
});
return apiSuccess({ insights: null, cached: false, legacyPayload: false });
return apiSuccess({
insights: null,
cached: false,
legacyPayload: false,
revalidating,
});
});
export const POST = apiHandler(async (request: NextRequest) => {
+7 -4
View File
@@ -148,8 +148,9 @@ export const PUT = apiHandler(
});
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
// caches so subsequent reads reflect the edited row.
invalidateUserMeasurements(user.id);
// caches so subsequent reads reflect the edited row. Interactive
// edit — hard-evict so the SWR readers don't serve the pre-edit body.
invalidateUserMeasurements(user.id, { evict: true });
// v1.5.0 — refresh the rollup row for the affected day. When the
// measuredAt moved across day boundaries (or the row was re-typed)
@@ -236,8 +237,10 @@ export const DELETE = apiHandler(
});
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
// caches so subsequent reads reflect the deletion.
invalidateUserMeasurements(user.id);
// caches so subsequent reads reflect the deletion. Interactive
// delete — hard-evict so the SWR readers don't serve the pre-delete
// body.
invalidateUserMeasurements(user.id, { evict: true });
// v1.5.0 — refresh the rollup row for the affected day (the
// recompute drops the row when the day's measurement count goes
@@ -125,7 +125,11 @@ describe("POST /api/measurements/bulk-delete", () => {
});
expect(call.data.deletedAt).toBeInstanceOf(Date);
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1");
// v1.16.7 — interactive write: hard-evict so the SWR readers
// never serve the pre-write analytics body back to the user.
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => {
@@ -105,8 +105,10 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
if (count > 0) {
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
// caches so subsequent reads reflect the deletions.
invalidateUserMeasurements(user.id);
// caches so subsequent reads reflect the deletions. Interactive
// multi-select delete from the management list — hard-evict so the
// SWR readers don't serve the pre-delete body.
invalidateUserMeasurements(user.id, { evict: true });
// Collapse the deleted rows to the unique `(type, day)` set BEFORE
// recomputing so a 200-row delete spanning one day fires ~1 recompute
@@ -122,7 +122,11 @@ describe("POST /api/measurements/restore", () => {
syncVersion: { increment: 1 },
});
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1");
// v1.16.7 — interactive write: hard-evict so the SWR readers
// never serve the pre-write analytics body back to the user.
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => {
+3 -2
View File
@@ -106,8 +106,9 @@ async function postRestore(request: NextRequest): Promise<Response> {
if (count > 0) {
// Bust per-user analytics + achievements + workouts caches so
// subsequent reads reflect the restored rows.
invalidateUserMeasurements(user.id);
// subsequent reads reflect the restored rows. Interactive undo —
// hard-evict so the SWR readers don't serve the pre-restore body.
invalidateUserMeasurements(user.id, { evict: true });
// Collapse the restored rows to the unique `(type, day)` set BEFORE
// recomputing — mirrors the bulk-delete path. Best-effort: a
+8 -4
View File
@@ -916,8 +916,10 @@ async function postMeasurement(request: NextRequest) {
});
// v1.4.34 IW-G — flush every cache that reflects this user's
// measurement set so the next read paints the new rows.
invalidateUserMeasurements(user.id);
// measurement set so the next read paints the new rows. Interactive
// multi-entry create (combined BP + pulse form) — hard-evict so the
// SWR readers don't serve the pre-write body back to the user.
invalidateUserMeasurements(user.id, { evict: true });
// v1.5.0 — refresh the persistent rollup table for every distinct
// (type, day) the batch touched so the next analytics / coach read
@@ -1027,8 +1029,10 @@ async function postMeasurement(request: NextRequest) {
});
// v1.4.34 IW-G — flush every cache that reflects this user's
// measurement set so the next read paints the new row.
invalidateUserMeasurements(user.id);
// measurement set so the next read paints the new row. Interactive
// single-entry create — hard-evict so the SWR readers don't serve the
// pre-write body back to the user.
invalidateUserMeasurements(user.id, { evict: true });
// v1.5.0 — refresh the persistent rollup row for the affected
// (type, day) tuple. Runs inline so the next read of the
@@ -0,0 +1,100 @@
/**
* Fail-soft contract for the Umami proxy route: an unreachable / refused
* upstream degrades to the noop script (200, never a 500) AND stays
* observable — the catch annotates a proper wide-event action
* (`monitoring.umami_script.fetch_failed`) with a truncated reason so
* dashboards can see the degraded state.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/monitoring-settings", () => ({
getPublicMonitoringSettings: vi.fn(),
}));
vi.mock("@/lib/safe-fetch", () => ({
safeFetch: vi.fn(),
}));
import { GET } from "../route";
import { annotate } from "@/lib/logging/context";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { safeFetch } from "@/lib/safe-fetch";
const callGet = GET as unknown as () => Promise<Response>;
const ENABLED_SETTINGS = {
umamiEnabled: true,
umamiScriptUrl: "https://stats.example.com/script.js",
umamiWebsiteId: "site-1",
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getPublicMonitoringSettings).mockResolvedValue(
ENABLED_SETTINGS as never,
);
});
describe("GET /api/monitoring/umami-script — fail-soft observability", () => {
it("serves the noop script (200) and annotates the failure action when the upstream fetch throws", async () => {
vi.mocked(safeFetch).mockRejectedValue(
new Error("connect ETIMEDOUT 192.0.2.10:443"),
);
const res = await callGet();
expect(res.status).toBe(200);
expect(await res.text()).toContain("umami disabled");
// The fail-soft must stay observable: a proper
// `<surface>.<noun>.<verb>` action + a truncated reason, not a bare
// meta flag.
expect(annotate).toHaveBeenCalledWith({
action: { name: "monitoring.umami_script.fetch_failed" },
meta: { reason: "connect ETIMEDOUT 192.0.2.10:443" },
});
});
it("truncates an oversized error message in the reason meta", async () => {
vi.mocked(safeFetch).mockRejectedValue(new Error("x".repeat(500)));
const res = await callGet();
expect(res.status).toBe(200);
const failureCall = vi
.mocked(annotate)
.mock.calls.find(
(call) =>
call[0]?.action?.name === "monitoring.umami_script.fetch_failed",
);
expect(failureCall).toBeTruthy();
expect(
(failureCall?.[0]?.meta as { reason: string }).reason,
).toHaveLength(200);
});
it("serves the upstream script untouched on a healthy fetch (no failure action)", async () => {
vi.mocked(safeFetch).mockResolvedValue(
new Response("/* real script */", { status: 200 }) as never,
);
const res = await callGet();
expect(res.status).toBe(200);
expect(await res.text()).toBe("/* real script */");
const failureCall = vi
.mocked(annotate)
.mock.calls.find(
(call) =>
call[0]?.action?.name === "monitoring.umami_script.fetch_failed",
);
expect(failureCall).toBeUndefined();
});
});
+13 -2
View File
@@ -43,8 +43,19 @@ export const GET = apiHandler(async () => {
// the input-time `isPublicUrl` accept and the socket connect.
{ requirePublicHost: true },
);
} catch {
annotate({ meta: { umami_script_fetch_failed: true } });
} catch (err) {
// Fail-soft stays a 200, but the failure must stay observable:
// a proper action name keeps the degraded state visible on the
// wide-event dashboards instead of hiding behind a bare meta flag.
annotate({
action: { name: "monitoring.umami_script.fetch_failed" },
meta: {
reason:
err instanceof Error
? err.message.slice(0, 200)
: String(err).slice(0, 200),
},
});
return new NextResponse(NOOP_SCRIPT, {
status: 200,
headers: {
@@ -78,3 +78,36 @@ describe("fetchAdvisor abort timeout — v1.4.31", () => {
expect(result).toBe("untouched");
});
});
// v1.16.7 — the read query polls (bounded) while the server reports a
// stale-served briefing (`revalidating: true`) so a stale serve converges
// in-session despite the 1 h staleTime + focus-refetch-off defaults.
describe("nextAdvisorPollInterval — bounded revalidation poll", () => {
it("returns the interval while revalidating is true and under the cap", async () => {
const {
nextAdvisorPollInterval,
ADVISOR_REVALIDATE_POLL_MS,
} = await import("../use-insights-advisor");
expect(nextAdvisorPollInterval(true, 1)).toBe(ADVISOR_REVALIDATE_POLL_MS);
expect(nextAdvisorPollInterval(true, 5)).toBe(ADVISOR_REVALIDATE_POLL_MS);
});
it("stops once a response comes back with revalidating falsy", async () => {
const { nextAdvisorPollInterval } = await import("../use-insights-advisor");
expect(nextAdvisorPollInterval(false, 1)).toBe(false);
expect(nextAdvisorPollInterval(undefined, 1)).toBe(false);
});
it("stops at the attempt ceiling even while still revalidating", async () => {
const {
nextAdvisorPollInterval,
ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS,
} = await import("../use-insights-advisor");
expect(
nextAdvisorPollInterval(true, ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS),
).toBe(false);
expect(
nextAdvisorPollInterval(true, ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS + 5),
).toBe(false);
});
});
@@ -10,7 +10,10 @@ import { useMounted } from "@/hooks/use-mounted";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useWorkouts } from "@/hooks/use-workouts";
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import {
nextAdvisorPollInterval,
useInsightsAdvisorQuery,
} from "@/components/insights/use-insights-advisor";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import { queryKeys } from "@/lib/query-keys";
@@ -41,6 +44,12 @@ import { apiGet } from "@/lib/api/api-fetch";
interface ComprehensivePayload {
moodSummary: { count: number } | null;
medications: Array<{ id: string }>;
/**
* v1.16.7 — true when the route served a stale cache body while a
* background rebuild runs (stale-while-revalidate). The query polls
* (bounded) while set so the fresh aggregate lands in-session.
*/
revalidating?: boolean;
}
export function InsightsLayoutShell({ children }: { children: ReactNode }) {
@@ -91,6 +100,14 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
return apiGet<ComprehensivePayload>("/api/insights/comprehensive");
},
enabled: isAuthenticated,
// v1.16.7 — converge a stale-served (SWR) body in-session: while the
// route reports `revalidating: true`, poll on the same bounded
// cadence the advisor query uses until a fresh body lands.
refetchInterval: (query) =>
nextAdvisorPollInterval(
query.state.data?.revalidating,
query.state.dataUpdateCount,
),
});
// v1.4.32 — workout-presence gate. The workouts pill gates on at
@@ -54,6 +54,13 @@ export interface InsightAdvisorPayload {
* left null when the cached payload predates PROMPT_VERSION 4.20.1.
*/
trendAnnotations?: TrendAnnotations | null;
/**
* v1.16.7 — true when the GET served a stale / missing briefing AND
* enqueued an out-of-band warm. The query polls (bounded) while this
* is set so the fresh briefing reaches the open page in-session
* instead of waiting for the next mount.
*/
revalidating?: boolean;
}
/**
@@ -80,6 +87,35 @@ const ADVISOR_TIMEOUT_MS = 8_000;
*/
const FORCE_ADVISOR_TIMEOUT_MS = 45_000;
/**
* v1.16.7 — poll cadence + ceiling while the server reports
* `revalidating: true` (stale briefing served, out-of-band warm in
* flight). The query's 1 h `staleTime` plus the app-default
* `refetchOnWindowFocus: false` means a stale-served briefing would
* otherwise never refresh in-session. 25 s comfortably covers the warm
* job's 45 s budget within two polls; the attempt ceiling stops a
* persistently failing generation from polling an open page forever.
* Same bounded-poll shape as `nextStatusPollInterval`
* (`src/hooks/use-insight-status.ts`).
*/
export const ADVISOR_REVALIDATE_POLL_MS = 25_000;
export const ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS = 10;
/**
* Decide whether the advisor query schedules its next poll. Pure so the
* ceiling + stop conditions are unit-testable: returns the interval
* while the last payload carries `revalidating: true`, `false` once a
* response comes back with the flag falsy OR the attempt cap is hit.
*/
export function nextAdvisorPollInterval(
revalidating: boolean | undefined,
dataUpdateCount: number,
): number | false {
if (!revalidating) return false;
if (dataUpdateCount >= ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS) return false;
return ADVISOR_REVALIDATE_POLL_MS;
}
/**
* v1.15.18 — the outcome of a force regenerate, so the UI can be HONEST:
* only a `fresh` outcome should toast "refreshed". A `timeout` (slow gen
@@ -244,6 +280,14 @@ export function useInsightsAdvisorQuery(
// 24h cache window matches the server-side `insightsCachedAt` TTL.
staleTime: 60 * 60 * 1000,
retry: false,
// v1.16.7 — converge a stale-served briefing in-session: while the
// last GET reported `revalidating: true` (warm enqueued), poll on a
// bounded interval until a response comes back with the flag falsy.
refetchInterval: (query) =>
nextAdvisorPollInterval(
query.state.data?.payload?.revalidating,
query.state.dataUpdateCount,
),
});
const mutation = useMutation({
+38
View File
@@ -117,6 +117,44 @@ describe("invalidateUserMeasurements", () => {
// Mood-analytics not touched by a measurement write.
expect(caches.moodAnalytics.get(USER_A)).not.toBeNull();
});
it("marks the analytics bucket stale (SWR-serveable) by default — background sync posture", async () => {
await primeAllCaches();
invalidateUserMeasurements(USER_A);
// v1.16.7 — the default (batch sync) path marks stale: the entry is
// gone for plain `get()` (asserted above) but stays serveable for
// the `cachedSwr` readers, so a high-frequency sync never busts the
// snapshot / comprehensive into an inline cold rebuild.
expect(caches.analytics.getAllowStale(`${USER_A}|default`)).toEqual({
value: { a: 1 },
stale: true,
});
expect(
caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)),
).toEqual({ value: { snap: 1 }, stale: true });
});
it("hard-evicts the analytics bucket with { evict: true } — interactive write posture", async () => {
await primeAllCaches();
invalidateUserMeasurements(USER_A, { evict: true });
// v1.16.7 — an interactive single-entry write must NOT leave a
// stale-serveable body behind: the SWR readers would hand the user
// back the pre-write payload. The evict drops the entries entirely.
expect(caches.analytics.getAllowStale(`${USER_A}|default`)).toBeNull();
expect(caches.analytics.getAllowStale(`${USER_A}|summaries`)).toBeNull();
expect(
caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)),
).toBeNull();
// User B stays warm; the non-analytics buckets evict as before.
expect(
caches.analytics.getAllowStale(`${USER_B}|default`),
).not.toBeNull();
expect(caches.achievements.get(USER_A)).toBeNull();
expect(caches.workouts.get(`${USER_A}|3|0||`)).toBeNull();
});
});
describe("invalidateUserMood", () => {
+29 -12
View File
@@ -47,22 +47,39 @@ export function invalidateUserDashboardSnapshot(userId: string): void {
* Covers: analytics aggregate, achievement progress, workouts cache.
* Mood-analytics is invalidated separately via `invalidateUserMood`
* because mood writes don't change measurement rows.
*
* v1.16.7 — `evict` splits the analytics bucket by write origin. The
* `cachedSwr` readers (dashboard snapshot, comprehensive) serve a
* marked-stale entry as-is, so a mark-stale right after an interactive
* write would hand the user back the pre-write body. Interactive
* single-entry routes (manual create / update / delete, bulk-delete,
* restore) pass `{ evict: true }` so the user's own entry is reflected
* on the very next read — matching the mood / medication hard-evict
* posture. Background batch syncs (Apple Health, Withings, workouts)
* keep the default mark-stale so a high-frequency sync never busts the
* bucket into inline cold-rebuild storms.
*/
export function invalidateUserMeasurements(userId: string): void {
export function invalidateUserMeasurements(
userId: string,
opts?: { evict?: boolean },
): void {
// 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.
//
// 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}|`);
if (opts?.evict) {
caches.analytics.deleteByPrefix(`${userId}|`);
} else {
// 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
+24 -1
View File
@@ -522,6 +522,29 @@ export async function cachedSwr<T>(
annotateFn?: (fields: { meta: Record<string, unknown> }) => void,
ttlMsOverride?: number,
): Promise<T> {
const { value } = await cachedSwrWithMeta(
cache,
key,
builder,
annotateFn,
ttlMsOverride,
);
return value;
}
/**
* v1.16.7 — `cachedSwr` variant that also surfaces the read outcome so
* a route can mark a stale-served response (`outcome === "stale"`) as
* `revalidating` for the client. Non-breaking addition: `cachedSwr`
* keeps its value-only shape and delegates here.
*/
export async function cachedSwrWithMeta<T>(
cache: ServerCache<T>,
key: string,
builder: () => Promise<T>,
annotateFn?: (fields: { meta: Record<string, unknown> }) => void,
ttlMsOverride?: number,
): Promise<{ value: T; outcome: "hit" | "stale" | "miss" | "stampede" }> {
const { value, outcome } = await cache.wrapSwr(key, builder, ttlMsOverride);
if (annotateFn) {
const name = cache.stats().name;
@@ -532,7 +555,7 @@ export async function cachedSwr<T>(
},
});
}
return value;
return { value, outcome };
}
/** Test helper — reset every cache in the registry. */
+12 -7
View File
@@ -35,12 +35,17 @@ export interface InsightPregeneratePayload {
/**
* Fire-and-forget enqueue used by the on-demand warm route. A
* `singletonKey` per `(user, locale)` collapses repeated requests within
* a short window into one queued job, so a client that taps the button
* twice (or the auto-trigger races a manual tap) can't double-warm. No-ops
* cleanly when the global boss instance is not available (e.g. a web
* process without an embedded worker) — the nightly cron remains the
* catch-net.
* `singletonKey` per user collapses repeated requests within a short
* window into one queued job, so a client that taps the button twice
* (or the auto-trigger races a manual tap) can't double-warm. The key
* is deliberately locale-free: `forceWarmUser` warms BOTH locale
* families per job (v1.16.7), so a per-(user, locale) key would let a
* German web session and an English Accept-Language client enqueue two
* near-identical full warms back to back — the second one force-
* regenerating the comprehensive briefing and re-evicting the
* per-status rows the first job just wrote. No-ops cleanly when the
* global boss instance is not available (e.g. a web process without an
* embedded worker) — the nightly cron remains the catch-net.
*/
export async function enqueueForceWarm(payload: {
userId: string;
@@ -63,7 +68,7 @@ export async function enqueueForceWarm(payload: {
locale: payload.locale,
} satisfies InsightPregeneratePayload,
{
singletonKey: `force:${payload.userId}:${payload.locale}`,
singletonKey: `force:${payload.userId}`,
// De-dupe within a 2-minute window so a double tap or an
// auto-trigger racing a manual tap enqueues one warm, not two.
singletonSeconds: 120,
+9 -9
View File
@@ -691,15 +691,15 @@ export async function forceWarmUser(
// outcome that has nothing to warm is a missing provider, and the
// generators detect that themselves at near-zero cost.
if (flags.insightStatus) {
// Mirror the nightly loop's force/refill split: only a `generated` /
// `cached` comprehensive ran `evictPerStatusInsightCache`, so only
// then must every card re-generate. On a failed / timed-out /
// skipped comprehensive the rows survived — refill-only (`force:
// false`) lets a card already generated today short-circuit to its
// cache, which is what keeps the second locale family cheap.
const evicted =
result.comprehensive === "generated" ||
result.comprehensive === "cached";
// Mirror the nightly loop's force/refill split: only a `generated`
// comprehensive ran `evictPerStatusInsightCache`, so only then must
// every card re-generate. (The forced generation above never returns
// `cached` — `force: true` skips the 24 h cache-hit branch.) On a
// failed / timed-out / skipped comprehensive the rows survived —
// refill-only (`force: false`) lets a card already generated today
// short-circuit to its cache, which is what keeps the second locale
// family cheap.
const evicted = result.comprehensive === "generated";
result.assessmentsWarmed = await warmPerStatusCaches(
userId,
locales,
+6
View File
@@ -28,6 +28,12 @@ const insightsComprehensiveResponse = z
.array(z.record(z.string(), z.unknown()))
.optional(),
metricSource: z.record(z.string(), z.unknown()).optional(),
revalidating: z
.boolean()
.optional()
.describe(
"True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body.",
),
})
.meta({
id: "InsightsComprehensiveResponse",