fix(records): repair cumulative records in one transaction

The repair for the inflated cumulative records deleted the suspect rows,
committed, and then re-ran the detector. When the detector failed, which
a timeout on a large account or a restart can cause, the records stayed
deleted, and since discovery looks for the suspect rows the account was
never repaired again (#1031).

The delete and the re-derivation now run in one transaction, with the
detector reading through it, so they commit together or not at all. The
transaction gets five minutes; the detector took 19.8 s on the largest
seeded account.
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 02:05:19 +02:00
1 parent 77bfa2f4e1
commit 7071fa91af
3 files changed
+141 -16

No files matched your search

@@ -8,12 +8,20 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
const deleteMany = vi.fn();
const findMany = vi.fn();
// The delete and the re-derivation share one transaction; the stub hands the
// callback a client that routes to the same spies.
const txClient = {
personalRecord: {
deleteMany: (a: unknown) => deleteMany(a),
},
};
vi.mock("@/lib/db", () => ({
prisma: {
personalRecord: {
deleteMany: (a: unknown) => deleteMany(a),
findMany: (a: unknown) => findMany(a),
},
$transaction: (fn: (tx: unknown) => Promise<unknown>) => fn(txClient),
},
}));
@@ -68,8 +76,10 @@ describe("runCumulativePrRederivationForUser", () => {
});
// Re-derivation always runs silent — this is data hygiene, not a new
// achievement, so no push should fire for it.
// It reads through the transaction, so it sees the deletion.
expect(detectPersonalRecordsForUser).toHaveBeenCalledWith("u1", {
silent: true,
prisma: txClient,
});
expect(summary).toEqual({ rowsDeleted: 1, rowsReinserted: 1 });
});
@@ -41,7 +41,10 @@ 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";
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
/** Budget for one account's delete-and-re-derive transaction. */
const REDERIVE_TRANSACTION_TIMEOUT_MS = 5 * 60 * 1000;
export const CUMULATIVE_PR_REDERIVE_QUEUE = "cumulative-pr-rederive";
@@ -90,22 +93,40 @@ export interface CumulativePrRederiveSummary {
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 },
// One transaction: the suspect rows go and the re-derived ones arrive
// together, or neither happens. Deleting first and re-deriving after the
// commit lost the records whenever the detector failed (a timeout on a
// large account, a restart): discovery looks for the suspect rows, so once
// they were gone the account was never repaired again (#1031). The detector
// reads through the transaction, so it sees the suspect rows as deleted and
// compares against what is left. It only reads and inserts, and sends no
// notification when `silent`.
const { rowsDeleted, rowsReinserted } = await prisma.$transaction(
async (tx) => {
const { count } = await tx.personalRecord.deleteMany({
where: {
userId,
metricSlot: null,
metricType: { in: CUMULATIVE_TYPES_ARRAY },
createdAt: { lt: CUMULATIVE_PR_FIX_CUTOFF },
},
});
if (count === 0) return { rowsDeleted: 0, rowsReinserted: 0 };
const result = await detectPersonalRecordsForUser(userId, {
silent: true,
prisma: tx as unknown as PrismaClient,
});
return {
rowsDeleted: count,
rowsReinserted: result.inserted + result.ties,
};
},
});
let rowsReinserted = 0;
if (rowsDeleted > 0) {
const result = await detectPersonalRecordsForUser(userId, {
silent: true,
});
rowsReinserted = result.inserted + result.ties;
}
// The detector walks every trackable type of the account; on a large
// record that is tens of seconds (19.8 s measured for 1.25 million
// measurements plus 784 000 energy rows), so the default five seconds
// would roll back every repair that matters.
{ timeout: REDERIVE_TRANSACTION_TIMEOUT_MS, maxWait: 10_000 },
);
annotate({
action: {