fix(personal-records): re-derive the cumulative-type records the pre-fix SUM inflated

Before the source-collapse fix, a day with both an Apple Health AND a
Withings write for the same cumulative metric (steps, active energy,
flights, distance, daylight, falls) summed both sources into one
inflated total and locked it in as the all-time PersonalRecord. The
worker's improvement gate only ever compares a freshly-computed day
against the stored best, so the false record silently outranked every
honest day forever and the affected metric could never set a genuine
record again.

Adds a boot-time one-shot repair (queue: cumulative-pr-rederive),
mirroring the existing step-consolidation-repair pattern: deletes the
suspect measurement-driven records created before the fix landed, then
re-runs detection with silent:true so the honest re-derived best
replaces it without firing a "new record" push for what is data
hygiene, not an achievement. Self-converging by a fixed cutoff instant
rather than a marker column or reused SQL migration number.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 06:47:32 +02:00
1 parent 6c2eb15f05
commit 1e8e46f58c
4 files changed
+461

No files matched your search

@@ -0,0 +1,48 @@
/**
* v1.30.3 (QA F4) — cumulative-PersonalRecord rederivation queue
* registration guard.
*
* Same source-text-grep approach as the step-consolidation-repair guard:
* assert the queue is registered in `allQueues`, a `boss.work` handler is
* wired against it, and the boot-discovery enqueue helper fires — without
* booting pg-boss + Prisma. An unregistered queue silently never drains
* (the v1.4.37 dead-queue class).
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const REGISTRAR_PATH = join(__dirname, "..", "reminder", "register-rollup.ts");
const source = readFileSync(REGISTRAR_PATH, "utf8");
describe("reminder-worker — cumulative-pr-rederive wiring", () => {
it("imports the queue symbols from the rederivation module", () => {
expect(source).toMatch(
/from\s*["']@\/lib\/personal-records\/cumulative-pr-rederivation["']/,
);
expect(source).toMatch(/\bCUMULATIVE_PR_REDERIVE_QUEUE\b/);
expect(source).toMatch(/\brunCumulativePrRederivationForUser\b/);
expect(source).toMatch(/\benqueueBootTimeCumulativePrRederivation\b/);
});
it("registers the queue in the allQueues loop", () => {
const allQueuesMatch = source.match(
/const allQueues\s*=\s*\[([\s\S]*?)\];/,
);
expect(allQueuesMatch).not.toBeNull();
expect(allQueuesMatch![1]).toMatch(/\bCUMULATIVE_PR_REDERIVE_QUEUE\b/);
});
it("registers a boss.work handler against the queue", () => {
expect(source).toMatch(
/boss\.work[\s\S]{0,200}CUMULATIVE_PR_REDERIVE_QUEUE[\s\S]{0,400}runCumulativePrRederivationForUser/,
);
});
it("fires the boot-discovery enqueue helper", () => {
expect(source).toMatch(
/await enqueueBootTimeCumulativePrRederivation\([^)]*\)/,
);
});
});
+63
View File
@@ -67,6 +67,13 @@ import {
enqueueBootTimeStepConsolidationRepair,
type StepConsolidationRepairPayload,
} from "@/lib/jobs/step-consolidation-repair";
import {
CUMULATIVE_PR_REDERIVE_QUEUE,
CUMULATIVE_PR_REDERIVE_CONCURRENCY,
runCumulativePrRederivationForUser,
enqueueBootTimeCumulativePrRederivation,
type CumulativePrRederivePayload,
} from "@/lib/personal-records/cumulative-pr-rederivation";
import {
MEAN_CONSOLIDATION_QUEUE,
MEAN_CONSOLIDATION_CONCURRENCY,
@@ -132,6 +139,10 @@ const allQueues = [
// v1.28.37 — one-shot repair for the provider step rows the pre-fix
// consolidation swept. Boot-discovery driven, self-converging.
STEP_CONSOLIDATION_REPAIR_QUEUE,
// v1.30.3 — one-shot repair for the cumulative-type PersonalRecord rows
// the pre-fix multi-source SUM inflated. Boot-discovery driven,
// self-converging (QA F4).
CUMULATIVE_PR_REDERIVE_QUEUE,
// v1.7.0 — daily-mean consolidation for high-frequency spot HealthKit
// metrics (walking speed/step length, respiratory rate, audio exposure).
MEAN_CONSOLIDATION_QUEUE,
@@ -248,6 +259,27 @@ export async function registerRollupQueues(
},
);
// v1.30.3 — cumulative-PersonalRecord rederivation worker (QA F4). The
// boot enqueue helper below sends one job per user still holding a
// suspect pre-fix inflated cumulative-type record; this handler deletes
// it and re-runs detection silently so the honest re-derived best takes
// its place. Serial concurrency so the repair never crowds the
// dashboard request pool.
await boss.work<CumulativePrRederivePayload>(
CUMULATIVE_PR_REDERIVE_QUEUE,
{ localConcurrency: CUMULATIVE_PR_REDERIVE_CONCURRENCY },
async (jobs) => {
for (const job of jobs) {
const { userId } = job.data;
const summary = await runCumulativePrRederivationForUser(userId);
workerLog(
"info",
`[cumulative-pr-rederive] user=${userId} rowsDeleted=${summary.rowsDeleted} rowsReinserted=${summary.rowsReinserted}`,
);
}
},
);
// v1.7.0 — daily-mean consolidation worker. The boot enqueue helper
// below sends one job per user holding live per-sample high-frequency
// mean-type rows; this handler collapses each completed day to its
@@ -771,4 +803,35 @@ export async function enqueueRollupBootDiscovery(): Promise<void> {
err,
);
}
// v1.30.3 (QA F4) — cumulative-PersonalRecord rederivation. Finds every
// account still holding a suspect measurement-driven cumulative-type
// record created before the source-collapse fix landed and enqueues one
// repair job per account. Self-converging: a deleted-and-redetected
// record's `createdAt` sits after the fix cutoff, so the account drops
// out of the discovery predicate for good.
try {
// Stage 6.
const { enqueued, skipped, error } =
await enqueueBootTimeCumulativePrRederivation(
BOOT_BACKFILL_STAGGER_SECONDS * 6,
);
if (error) {
workerLog(
"error",
`[cumulative-pr-rederive] boot discovery failed: ${error}`,
);
} else {
workerLog(
"info",
`[cumulative-pr-rederive] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
);
}
} catch (err) {
workerLog(
"error",
"[cumulative-pr-rederive] boot discovery threw an unexpected error",
err,
);
}
}
@@ -0,0 +1,166 @@
/**
* v1.30.3 (QA F4) — the one-shot repair for the pre-fix inflated
* cumulative-type PersonalRecord rows: deletes the suspect row(s) for a
* user and re-runs detection silently so the honest re-derived best takes
* its place, plus the boot-discovery enqueue that finds affected accounts.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
const deleteMany = vi.fn();
const findMany = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
personalRecord: {
deleteMany: (a: unknown) => deleteMany(a),
findMany: (a: unknown) => findMany(a),
},
},
}));
const detectPersonalRecordsForUser = vi.fn();
vi.mock("@/lib/personal-records/pr-detection-worker", () => ({
detectPersonalRecordsForUser: (...args: unknown[]) =>
detectPersonalRecordsForUser(...args),
}));
const send = vi.fn();
const getGlobalBoss = vi.fn();
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: () => getGlobalBoss(),
}));
const annotate = vi.fn();
vi.mock("@/lib/logging/context", () => ({
annotate: (...a: unknown[]) => annotate(...a),
}));
import {
runCumulativePrRederivationForUser,
enqueueBootTimeCumulativePrRederivation,
CUMULATIVE_PR_FIX_CUTOFF,
} from "@/lib/personal-records/cumulative-pr-rederivation";
describe("runCumulativePrRederivationForUser", () => {
beforeEach(() => {
deleteMany.mockReset();
detectPersonalRecordsForUser.mockReset();
annotate.mockReset();
});
it("deletes suspect rows created before the fix cutoff and re-derives silently", async () => {
deleteMany.mockResolvedValue({ count: 1 });
detectPersonalRecordsForUser.mockResolvedValue({
inserted: 1,
ties: 0,
scanned: 6,
silent: true,
});
const summary = await runCumulativePrRederivationForUser("u1");
expect(deleteMany).toHaveBeenCalledWith({
where: {
userId: "u1",
metricSlot: null,
metricType: { in: expect.arrayContaining(["ACTIVITY_STEPS"]) },
createdAt: { lt: CUMULATIVE_PR_FIX_CUTOFF },
},
});
// Re-derivation always runs silent — this is data hygiene, not a new
// achievement, so no push should fire for it.
expect(detectPersonalRecordsForUser).toHaveBeenCalledWith("u1", {
silent: true,
});
expect(summary).toEqual({ rowsDeleted: 1, rowsReinserted: 1 });
});
it("skips re-derivation entirely when nothing was deleted", async () => {
deleteMany.mockResolvedValue({ count: 0 });
const summary = await runCumulativePrRederivationForUser("u2");
expect(detectPersonalRecordsForUser).not.toHaveBeenCalled();
expect(summary).toEqual({ rowsDeleted: 0, rowsReinserted: 0 });
});
it("counts a tie as a reinserted row (the honest re-derived value can equal a still-live sibling metric's best)", async () => {
deleteMany.mockResolvedValue({ count: 2 });
detectPersonalRecordsForUser.mockResolvedValue({
inserted: 0,
ties: 1,
scanned: 6,
silent: true,
});
const summary = await runCumulativePrRederivationForUser("u3");
expect(summary).toEqual({ rowsDeleted: 2, rowsReinserted: 1 });
});
});
describe("enqueueBootTimeCumulativePrRederivation", () => {
beforeEach(() => {
findMany.mockReset();
send.mockReset();
getGlobalBoss.mockReset();
});
it("no-ops when no boss is attached (test / scriptless context)", async () => {
getGlobalBoss.mockReturnValue(null);
const result = await enqueueBootTimeCumulativePrRederivation();
expect(result).toEqual({ enqueued: 0, skipped: 0, error: null });
expect(findMany).not.toHaveBeenCalled();
});
it("finds zero affected users and enqueues nothing", async () => {
getGlobalBoss.mockReturnValue({ send });
findMany.mockResolvedValue([]);
const result = await enqueueBootTimeCumulativePrRederivation();
expect(result).toEqual({ enqueued: 0, skipped: 0, error: null });
expect(send).not.toHaveBeenCalled();
});
it("enqueues one job per distinct affected user with a coalescing singletonKey", async () => {
getGlobalBoss.mockReturnValue({ send });
findMany.mockResolvedValue([{ userId: "u1" }, { userId: "u2" }]);
send.mockResolvedValue("job-id");
const result = await enqueueBootTimeCumulativePrRederivation(30);
expect(result).toEqual({ enqueued: 2, skipped: 0, error: null });
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
metricSlot: null,
createdAt: { lt: CUMULATIVE_PR_FIX_CUTOFF },
}),
distinct: ["userId"],
}),
);
expect(send).toHaveBeenCalledWith(
"cumulative-pr-rederive",
expect.objectContaining({ userId: "u1" }),
expect.objectContaining({
singletonKey: "cumulative-pr-rederive|u1",
startAfter: 30,
}),
);
});
it("counts a coalesced send (pg-boss returns null) as skipped, not an error", async () => {
getGlobalBoss.mockReturnValue({ send });
findMany.mockResolvedValue([{ userId: "u1" }]);
send.mockResolvedValue(null);
const result = await enqueueBootTimeCumulativePrRederivation();
expect(result).toEqual({ enqueued: 0, skipped: 1, error: null });
});
it("returns the error message instead of throwing on a discovery failure", async () => {
getGlobalBoss.mockReturnValue({ send });
findMany.mockRejectedValue(new Error("db down"));
const result = await enqueueBootTimeCumulativePrRederivation();
expect(result.enqueued).toBe(0);
expect(result.error).toBe("db down");
});
});
@@ -0,0 +1,184 @@
/**
* v1.30.3 (QA F4) — one-shot repair for the `PersonalRecord` rows the
* pre-fix cumulative-day picker double-counted.
*
* Background: before `8902d3985` (v1.29.6), `findBestCumulativeDay`
* (`pr-detection-worker.ts`) summed EVERY row on a calendar day regardless
* of source — a day with both an Apple Health AND a Withings write for the
* same cumulative metric (steps, active energy, flights, distance,
* daylight, falls) summed both sources into one inflated total and wrote
* it as the all-time "best". The fix (source-collapse via
* `pickCanonicalSourceRows` before summing) stops the inflation going
* forward, but the worker's improvement gate only ever compares a
* freshly-computed day against the STORED best — an already-inflated
* stored row can never be beaten again, so the false record silently
* outranks every honest day forever (PR list, digest milestone, doctor
* report), and the affected user can never set a genuine record on that
* metric again.
*
* This job deletes the suspect rows (measurement-driven — `metricSlot`
* null — one of `CUMULATIVE_HK_TYPES`, created BEFORE the fix landed) and
* immediately re-runs detection for the affected user with `silent: true`
* so the (already-fixed) worker inserts the honest re-derived best without
* firing a "new record" push for what is data hygiene, not an achievement.
*
* Converges to zero without a marker column: the cutoff is the fixed
* instant the source-collapse fix went live upstream (safely after the
* `8902d3985` merge). Any row created before it predates the bug fix and
* is suspect; any row this job (or the ordinary worker) writes going
* forward is always created after that instant, so it can never re-match
* the discovery predicate. A re-run (reboot mid-walk, or an install that
* upgrades straight past this release) simply finds fewer/no affected
* users each time.
*
* The queue name MUST be registered in `allQueues` in
* `src/lib/jobs/reminder/register-rollup.ts` or pg-boss never provisions
* it and the boot enqueue silently never drains (the v1.4.37 dead-queue
* class).
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { detectPersonalRecordsForUser } from "@/lib/personal-records/pr-detection-worker";
import type { MeasurementType } from "@/generated/prisma/client";
export const CUMULATIVE_PR_REDERIVE_QUEUE = "cumulative-pr-rederive";
/**
* Serial concurrency — a repair walks one account's PersonalRecord rows
* and re-runs full detection; matches the other one-shot repair jobs
* (`STEP_CONSOLIDATION_REPAIR_CONCURRENCY`).
*/
export const CUMULATIVE_PR_REDERIVE_CONCURRENCY = 1;
/**
* The instant the cumulative-day source-collapse fix (`8902d3985`,
* v1.29.6) went live. Any measurement-driven `PersonalRecord` row for a
* cumulative type `createdAt` STRICTLY BEFORE this instant was written by
* the pre-fix multi-source SUM and is suspect. Hardcoded (not derived from
* git/commit metadata at runtime) so the predicate is a compile-time
* constant every self-hosted install evaluates identically regardless of
* when it upgrades to this release.
*/
export const CUMULATIVE_PR_FIX_CUTOFF = new Date("2026-07-18T00:00:00.000Z");
const CUMULATIVE_TYPES_ARRAY: MeasurementType[] = [
...CUMULATIVE_HK_TYPES,
] as MeasurementType[];
export interface CumulativePrRederivePayload {
userId: string;
enqueuedAt: string;
}
export interface CumulativePrRederiveSummary {
/** Suspect rows deleted for this user. */
rowsDeleted: number;
/** Records the (already-fixed) worker re-inserted after the delete. */
rowsReinserted: number;
}
/**
* Per-user repair handler. Deletes the suspect measurement-driven
* cumulative-type `PersonalRecord` rows for `userId`, then re-runs
* detection silently so the honest re-derived best (if any — the warm-up
* gate can still apply) replaces it. Safe to re-run: a second pass finds
* zero suspect rows once the honest one has replaced them (its
* `createdAt` is always after the cutoff).
*/
export async function runCumulativePrRederivationForUser(
userId: string,
): Promise<CumulativePrRederiveSummary> {
const { count: rowsDeleted } = await prisma.personalRecord.deleteMany({
where: {
userId,
metricSlot: null,
metricType: { in: CUMULATIVE_TYPES_ARRAY },
createdAt: { lt: CUMULATIVE_PR_FIX_CUTOFF },
},
});
let rowsReinserted = 0;
if (rowsDeleted > 0) {
const result = await detectPersonalRecordsForUser(userId, {
silent: true,
});
rowsReinserted = result.inserted + result.ties;
}
annotate({
action: {
name: "personal-record.cumulative.rederive",
details: { rows_deleted: rowsDeleted, rows_reinserted: rowsReinserted },
},
});
return { rowsDeleted, rowsReinserted };
}
/**
* Boot-time discovery. Finds every account still holding a suspect
* measurement-driven cumulative-type `PersonalRecord` row (created before
* the fix cutoff) and enqueues one repair job per account.
*
* Self-converging: once a suspect row is deleted and (when the warm-up
* gate clears) replaced by an honestly re-derived one, its `createdAt`
* sits after the cutoff and the account drops off the discovery list for
* good. Best-effort: errors return through the result so worker boot never
* fails on a repair miss.
*/
export async function enqueueBootTimeCumulativePrRederivation(
startAfterSeconds: number = 0,
): Promise<{ enqueued: number; skipped: number; error: string | null }> {
const boss = getGlobalBoss();
if (!boss) {
return { enqueued: 0, skipped: 0, error: null };
}
try {
const affected = await prisma.personalRecord.findMany({
where: {
metricSlot: null,
metricType: { in: CUMULATIVE_TYPES_ARRAY },
createdAt: { lt: CUMULATIVE_PR_FIX_CUTOFF },
},
distinct: ["userId"],
select: { userId: true },
});
if (affected.length === 0) {
return { enqueued: 0, skipped: 0, error: null };
}
let enqueued = 0;
let skipped = 0;
for (const { userId } of affected) {
const payload: CumulativePrRederivePayload = {
userId,
enqueuedAt: new Date().toISOString(),
};
const jobId = await boss.send(CUMULATIVE_PR_REDERIVE_QUEUE, payload, {
retryLimit: 3,
retryDelay: 60,
retryBackoff: true,
// Coalesce: if a repair for this user is already queued and we
// restart, pg-boss returns null instead of duplicating.
singletonKey: `cumulative-pr-rederive|${userId}`,
...(startAfterSeconds > 0 ? { startAfter: startAfterSeconds } : {}),
});
if (jobId) {
enqueued += 1;
} else {
skipped += 1;
}
}
return { enqueued, skipped, error: null };
} catch (err) {
return {
enqueued: 0,
skipped: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}