Sweep the measurement caches after Google Health and Withings syncs

Both hourly sync tails left the per-user analytics, correlations,
targets, derived and achievements caches serving pre-sync bodies until
their TTL lapsed — the correlations route documents the exact invariant
these tails were breaking. Mirror the Fitbit tail: mark the cells stale
whenever rows landed, on partial failures too.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 02:12:49 +02:00
1 parent 4e12aef5fd
commit 30aad51fac
6 files changed
+79 -1

No files matched your search

@@ -18,6 +18,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const {
recordSyncFailureMock,
recordSyncSuccessMock,
invalidateUserMeasurementsMock,
prismaMock,
getUserGoogleHealthCredentialsMock,
refreshAccessTokenMock,
@@ -25,10 +26,12 @@ const {
} = vi.hoisted(() => ({
recordSyncFailureMock: vi.fn(async () => {}),
recordSyncSuccessMock: vi.fn(async () => {}),
invalidateUserMeasurementsMock: vi.fn(),
prismaMock: {
googleHealthConnection: {
findUnique: vi.fn(),
update: vi.fn(async () => ({})),
updateMany: vi.fn(async () => ({ count: 1 })),
},
},
getUserGoogleHealthCredentialsMock: vi.fn(async (): Promise<unknown> => null),
@@ -58,6 +61,9 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: invalidateUserMeasurementsMock,
}));
vi.mock("../credentials", () => ({
getUserGoogleHealthCredentials: getUserGoogleHealthCredentialsMock,
}));
@@ -248,3 +254,31 @@ describe("getValidToken — dead-token cycle verdict", () => {
}
});
});
// Watched red: with the `invalidateUserMeasurements(userId)` call removed
// from the sync tail this fails — the pre-fix tail dropped only the
// per-type status insights, so correlations / analytics / targets served
// pre-sync bodies until TTL after every hourly sync.
describe("syncUserGoogleHealth — measurement-cache invalidation", () => {
it("marks the per-user measurement caches stale when rows landed", async () => {
invalidateUserMeasurementsMock.mockClear();
prismaMock.googleHealthConnection.findUnique.mockResolvedValue({
id: "conn-1",
userId: "user-1",
googleUserId: "g-user-1",
accessToken: "enc-access",
refreshToken: "enc-refresh",
// Comfortably outside the refresh buffer so getValidToken answers
// from the stored token without a refresh round-trip.
tokenExpiresAt: new Date(Date.now() + 60 * 60_000),
lastSyncedAt: new Date("2026-07-01T00:00:00.000Z"),
} as never);
const { decrypt } = await import("@/lib/crypto");
vi.mocked(decrypt).mockReturnValue("tok" as never);
const res = await syncUserGoogleHealth("user-1");
expect(res.imported).toBeGreaterThan(0);
expect(invalidateUserMeasurementsMock).toHaveBeenCalledWith("user-1");
});
});
+10
View File
@@ -7,6 +7,7 @@ import {
recomputeUserRollups,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import {
runWithGoogleHealthClientOutcome,
type GoogleHealthClientOutcome,
@@ -280,6 +281,15 @@ export async function syncUserGoogleHealth(
await recordSyncSuccess(userId, GOOGLE_HEALTH_INTEGRATION_KEY);
}
// Background-sync posture (mirrors the Fitbit tail): mark the per-user
// analytics / correlations / targets / achievements cells stale so the
// imported rows reach the cached readers before their TTL lapses — the
// correlations route documents exactly this invariant. Fires on a
// partial failure too: rows that DID land must not stay invisible.
if (total > 0) {
invalidateUserMeasurements(userId);
}
annotate({
action: { name: "googleHealth.sync", details: { imported: total, failed } },
});
@@ -70,6 +70,7 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserDashboardSnapshot: vi.fn(),
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({
@@ -75,6 +75,7 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserDashboardSnapshot: vi.fn(),
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({
@@ -225,4 +226,27 @@ describe("syncUserMeasurements — watermark hold on hard row failure (F4)", ()
});
expect(recordSyncFailure).not.toHaveBeenCalled();
});
// Watched red: with the `invalidateUserMeasurements(userId)` call removed
// from the sync tail this fails — the pre-fix tail evicted only the
// dashboard snapshot, so correlations / analytics / targets / derived
// served pre-sync bodies until their TTL lapsed after every hourly sync
// (the correlations route documents the invariant this restores).
it("marks the per-user measurement caches stale when rows landed", async () => {
vi.mocked(fetchMeasurements).mockResolvedValue([
{
type: "WEIGHT",
value: 80,
measuredAt: new Date("2026-05-16T08:00:00Z"),
},
] as never);
await syncUserMeasurements("user-1");
const { invalidateUserMeasurements } =
await import("@/lib/cache/invalidate");
expect(vi.mocked(invalidateUserMeasurements)).toHaveBeenCalledWith(
"user-1",
);
});
});
@@ -76,6 +76,7 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserDashboardSnapshot: vi.fn(),
invalidateUserMeasurements: vi.fn(),
}));
import { prisma } from "@/lib/db";
+9 -1
View File
@@ -32,7 +32,10 @@ import {
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
import {
invalidateUserDashboardSnapshot,
invalidateUserMeasurements,
} from "@/lib/cache/invalidate";
import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
import {
acquireProviderTokenRefreshLock,
@@ -380,6 +383,11 @@ export async function syncUserMeasurements(
// pre-sync body until its ~180 s TTL lapsed.
if (keys.length > 0) {
invalidateUserDashboardSnapshot(userId);
// Background-sync posture (mirrors the Fitbit tail): mark the
// per-user analytics / correlations / targets / achievements cells
// stale so the imported rows reach the cached readers before their
// TTL lapses — the correlations route documents this invariant.
invalidateUserMeasurements(userId);
}
} catch (err) {
getEvent()?.addWarning(