fix(insights): regenerate cached assessments when a measurement invalidates them

The measurement-sync invalidation deleted the dirtied per-scope
assessment rows and waited for the user's next category open (a cache
miss → a worker round-trip while they wait) or the nightly cron to
re-warm them. Enqueue a debounced regenerate for each dirtied scope
instead, coalesced per (user, scope, locale) through the queue's
singleton key so the constant Apple-Health sync collapses a burst of
samples into one regenerate per scope rather than a job storm.
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 10:00:48 +02:00
1 parent a8e7325a08
commit f4d5114dcd
2 files changed
+57

No files matched your search

@@ -11,6 +11,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const deleteMany = vi.fn();
const enqueueStatusGeneration = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
@@ -18,13 +19,29 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({
enqueueStatusGeneration: (...a: unknown[]) => enqueueStatusGeneration(...a),
}));
import { invalidateStatusInsightsForTypes } from "../comprehensive-generate";
beforeEach(() => {
vi.clearAllMocks();
deleteMany.mockResolvedValue({ count: 0 });
enqueueStatusGeneration.mockResolvedValue(undefined);
});
/** Distinct scopes the invalidator enqueued a regenerate for. */
function enqueuedScopes(): string[] {
return [
...new Set(
enqueueStatusGeneration.mock.calls.map(
(c) => (c[0] as { metric: string }).metric,
),
),
].sort();
}
function deletedScopes(): string[] {
const arg = deleteMany.mock.calls[0][0];
// Each OR clause is `{ action: { startsWith: "insights.<scope>-status." } }`.
@@ -80,5 +97,28 @@ describe("invalidateStatusInsightsForTypes", () => {
it("is a no-op for an empty type set (no DB call)", async () => {
await invalidateStatusInsightsForTypes("u1", []);
expect(deleteMany).not.toHaveBeenCalled();
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
});
it("enqueues a debounced regenerate for every dirtied scope, both locales", async () => {
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
// weight + bmi + general, each warmed for de + en.
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
expect(enqueueStatusGeneration).toHaveBeenCalledTimes(6);
const locales = new Set(
enqueueStatusGeneration.mock.calls.map(
(c) => (c[0] as { locale: string }).locale,
),
);
expect([...locales].sort()).toEqual(["de", "en"]);
});
it("does not blanket-evict general for an unrelated synced type", async () => {
// A steps sample touches only the general overview, never the weight /
// pulse / bp scopes — the constant Apple-Health sync must not thrash
// every card's cache.
await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"]);
expect(deletedScopes()).toEqual(["general"]);
expect(enqueuedScopes()).toEqual(["general"]);
});
});
@@ -47,6 +47,7 @@ import {
runRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
import { invalidateUserInsights } from "@/lib/cache/invalidate";
import { enqueueStatusGeneration } from "@/lib/jobs/insight-status-generate-shared";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
@@ -231,6 +232,22 @@ export async function invalidateStatusInsightsForTypes(
})),
},
});
// v1.8.7 — regenerate-on-invalidate. Deleting the today-row alone left
// the card to re-warm only on the user's next category open (a miss → a
// worker round-trip while they wait) or the nightly cron. Instead,
// proactively enqueue a debounced regenerate for each dirtied scope so
// the cache is re-warmed in the background. The enqueue is coalesced per
// `(user, metric, locale)` via the queue's `singletonKey` (120 s window),
// so the constant Apple-Health sync cannot fan out a storm of jobs — a
// burst of samples collapses into one regenerate per scope per locale.
// The stale-while-revalidate read keeps the previous assessment visible
// until the fresh one lands, so the user never waits on a card.
for (const scope of scopes) {
for (const locale of ["de", "en"] as const) {
void enqueueStatusGeneration({ userId, metric: scope, locale });
}
}
}
interface GenerateOptions {