mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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 {
|
||||
|
||||
Reference in new issue
Block a user