mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(insights): drop the per-metric assessment cache on fresh measurements
Hang `invalidateStatusInsightsForTypes` off every measurement ingest path so a new reading of a type drops the cached assessment rows that reading dirties — the next mount or nightly warm pass then regenerates them against the new data instead of serving the pre-measurement text for the rest of the day. Wired into both `/api/measurements` modes (single + multi-entry batch), `/api/measurements/batch`, and the three Withings sync paths (measures + activity + sleep), reusing the `collapseToTypeDayKeys` result each already computes for its rollup recompute. The call is fire-and-forget after the user's write commits: idempotent, never a blocker on the request path, and a failure only warns.
This commit is contained in:
1 parent
177ce576d3
commit
cd8849cd51
6 files changed
+153
No files matched your search
@@ -46,6 +46,7 @@ import { deviceTypeEnum } from "@/lib/validations/source-priority";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import {
|
||||
recomputeBucketsForMeasurement,
|
||||
collapseToTypeDayKeys,
|
||||
@@ -484,6 +485,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
for (const k of insertedKeys) {
|
||||
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
|
||||
}
|
||||
|
||||
// v1.8.0 — drop the cached per-metric assessment rows the ingested
|
||||
// types dirty so the next mount / nightly warm pass regenerates
|
||||
// them against the new data instead of serving stale text.
|
||||
// Fire-and-forget: never a blocker on the user's ingest.
|
||||
invalidateStatusInsightsForTypes(
|
||||
user.id,
|
||||
insertedKeys.map((k) => k.type),
|
||||
).catch((err) => {
|
||||
console.warn("[measurements] status-insight invalidate failed", err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[measurements] rollup recompute failed", err);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import {
|
||||
recomputeBucketsForMeasurement,
|
||||
collapseToTypeDayKeys,
|
||||
@@ -664,6 +665,16 @@ async function postMeasurement(request: NextRequest) {
|
||||
for (const k of keys) {
|
||||
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
|
||||
}
|
||||
|
||||
// v1.8.0 — drop the cached per-metric assessment rows the ingested
|
||||
// types dirty so the next mount / nightly warm pass regenerates
|
||||
// them against the new data. Fire-and-forget: never blocks ingest.
|
||||
invalidateStatusInsightsForTypes(
|
||||
user.id,
|
||||
keys.map((k) => k.type),
|
||||
).catch((err) => {
|
||||
console.warn("[measurements] status-insight invalidate failed", err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[measurements] rollup recompute failed", err);
|
||||
}
|
||||
@@ -768,5 +779,12 @@ async function postMeasurement(request: NextRequest) {
|
||||
console.warn("[measurements] rollup recompute failed", err);
|
||||
}
|
||||
|
||||
// v1.8.0 — drop the cached per-metric assessment rows this type
|
||||
// dirties so the next mount / nightly warm pass regenerates against
|
||||
// the new row. Fire-and-forget: never blocks the user's write.
|
||||
invalidateStatusInsightsForTypes(user.id, [measurement.type]).catch((err) => {
|
||||
console.warn("[measurements] status-insight invalidate failed", err);
|
||||
});
|
||||
|
||||
return apiSuccess(measurement, 201);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* v1.8.0 — measurement-driven per-metric assessment invalidation.
|
||||
*
|
||||
* A fresh measurement of a given type must drop the cached
|
||||
* `insights.<scope>-status.<locale>` rows for the scopes that reading
|
||||
* dirties, so the next mount / nightly warm pass regenerates them
|
||||
* against the new data instead of serving the pre-measurement text for
|
||||
* the rest of the day. These tests pin the type → scope mapping and the
|
||||
* delete shape without a live DB.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const deleteMany = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
auditLog: { deleteMany: (...a: unknown[]) => deleteMany(...a) },
|
||||
},
|
||||
}));
|
||||
|
||||
import { invalidateStatusInsightsForTypes } from "../comprehensive-generate";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
deleteMany.mockResolvedValue({ count: 0 });
|
||||
});
|
||||
|
||||
function deletedScopes(): string[] {
|
||||
const arg = deleteMany.mock.calls[0][0];
|
||||
// Each OR clause is `{ action: { startsWith: "insights.<scope>-status." } }`.
|
||||
return (arg.where.OR as Array<{ action: { startsWith: string } }>)
|
||||
.map((c) => c.action.startsWith.replace(/^insights\.(.+)-status\.$/, "$1"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
describe("invalidateStatusInsightsForTypes", () => {
|
||||
it("maps WEIGHT to weight + bmi + general", async () => {
|
||||
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
|
||||
expect(deleteMany).toHaveBeenCalledTimes(1);
|
||||
expect(deletedScopes()).toEqual(["bmi", "general", "weight"]);
|
||||
expect(deleteMany.mock.calls[0][0].where.userId).toBe("u1");
|
||||
});
|
||||
|
||||
it("maps both blood-pressure components to blood-pressure + general", async () => {
|
||||
await invalidateStatusInsightsForTypes("u1", [
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
]);
|
||||
expect(deletedScopes()).toEqual(["blood-pressure", "general"]);
|
||||
});
|
||||
|
||||
it("maps PULSE and RESTING_HEART_RATE to pulse + general", async () => {
|
||||
await invalidateStatusInsightsForTypes("u1", [
|
||||
"PULSE",
|
||||
"RESTING_HEART_RATE",
|
||||
]);
|
||||
expect(deletedScopes()).toEqual(["general", "pulse"]);
|
||||
});
|
||||
|
||||
it("maps an unmapped metric to general only", async () => {
|
||||
await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]);
|
||||
expect(deletedScopes()).toEqual(["general"]);
|
||||
});
|
||||
|
||||
it("dedupes scopes across a mixed batch", async () => {
|
||||
await invalidateStatusInsightsForTypes("u1", [
|
||||
"WEIGHT",
|
||||
"PULSE",
|
||||
"BLOOD_GLUCOSE",
|
||||
]);
|
||||
// weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪ general
|
||||
expect(deletedScopes()).toEqual([
|
||||
"bmi",
|
||||
"general",
|
||||
"pulse",
|
||||
"weight",
|
||||
]);
|
||||
});
|
||||
|
||||
it("is a no-op for an empty type set (no DB call)", async () => {
|
||||
await invalidateStatusInsightsForTypes("u1", []);
|
||||
expect(deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
collapseToTypeDayKeys,
|
||||
recomputeBucketsForMeasurement,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
|
||||
import { hasActivityScope } from "./client";
|
||||
import {
|
||||
@@ -371,6 +372,17 @@ export async function syncUserActivity(
|
||||
for (const k of keys) {
|
||||
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
|
||||
}
|
||||
|
||||
// v1.8.0 — drop the per-metric assessment caches the synced types
|
||||
// dirty (steps feed the general overview). Fire-and-forget.
|
||||
invalidateStatusInsightsForTypes(
|
||||
userId,
|
||||
keys.map((k) => k.type),
|
||||
).catch((err) => {
|
||||
getEvent()?.addWarning(
|
||||
`withings activity: status-insight invalidate failed for ${userId}: ${err}`,
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`withings activity: rollup recompute failed for ${userId}: ${err}`,
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
collapseToTypeDayKeys,
|
||||
recomputeBucketsForMeasurement,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
|
||||
import { hasActivityScope } from "./client";
|
||||
import {
|
||||
@@ -314,6 +315,18 @@ export async function syncUserSleep(
|
||||
for (const k of keys) {
|
||||
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
|
||||
}
|
||||
|
||||
// v1.8.0 — drop the per-metric assessment caches the synced types
|
||||
// dirty (sleep + resting-HR feed the pulse / general cards).
|
||||
// Fire-and-forget.
|
||||
invalidateStatusInsightsForTypes(
|
||||
userId,
|
||||
keys.map((k) => k.type),
|
||||
).catch((err) => {
|
||||
getEvent()?.addWarning(
|
||||
`withings sleep: status-insight invalidate failed for ${userId}: ${err}`,
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`withings sleep: rollup recompute failed for ${userId}: ${err}`,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
collapseToTypeDayKeys,
|
||||
recomputeBucketsForMeasurement,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
|
||||
/**
|
||||
* Build the callback URL handed to Withings at `Notify.subscribe` time.
|
||||
@@ -250,6 +251,19 @@ export async function syncUserMeasurements(
|
||||
for (const k of keys) {
|
||||
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
|
||||
}
|
||||
|
||||
// v1.8.0 — a Withings sync that lands new weight / BP / pulse /
|
||||
// body-comp rows dirties the matching per-metric assessment caches.
|
||||
// Drop them so the next mount / nightly warm pass regenerates
|
||||
// against the new data. Fire-and-forget: never fails the sync.
|
||||
invalidateStatusInsightsForTypes(
|
||||
userId,
|
||||
keys.map((k) => k.type),
|
||||
).catch((err) => {
|
||||
getEvent()?.addWarning(
|
||||
`withings: status-insight invalidate failed for ${userId}: ${err}`,
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`withings: rollup recompute failed for ${userId}: ${err}`,
|
||||
|
||||
Reference in new issue
Block a user