fix(labs): let the lab date repair skip a damaged fact and read the app's zone

One staged fact that would not decrypt threw out of the whole repair, so
the job failed, retried, and was enqueued again on every boot. It is now
skipped and counted as undecryptable.

The candidate query read an account zone Postgres does not know as UTC,
while the repair resolves it to the instance default and then
Europe/Berlin. A value confirmed near midnight stayed a candidate the
repair always left alone, so the set never converged. The query now
falls back the same way.
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 11:00:01 +01:00
1 parent a6f4c386fe
commit 6df0fad3b7
2 files changed
+98 -10

No files matched your search

+39 -9
View File
@@ -44,7 +44,7 @@ import { annotate } from "@/lib/logging/context";
import { withBackgroundEvent } from "@/lib/logging/background";
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
import { userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { DEFAULT_TIMEZONE, resolveUserTimezone } from "@/lib/tz/resolver";
import type { ObservationFactData } from "@/lib/validations/inbound-documents";
import {
@@ -76,11 +76,25 @@ interface CandidateRow {
function candidateQuery(limit: number | null): Prisma.Sql {
// The day comparison runs here as well as in the repair, so a value that
// really was taken on its confirmation day drops out of the boot check and
// the pass converges to an empty set. A zone Postgres does not know is read
// as UTC rather than failing the query; the repair re-checks every row in
// the zone the app resolves.
// the pass converges to an empty set. Both must read the same zone: a
// stored zone Postgres does not know falls back the way
// `resolveUserTimezone` falls back, to the instance default and then to
// `DEFAULT_TIMEZONE`. Reading it as UTC instead kept a value confirmed
// near midnight in the candidate set that the repair, in the app's zone,
// left alone, so every boot enqueued the job again. The repair still
// re-checks every row in the zone the app resolves.
return Prisma.sql`
WITH "known_zones" AS (SELECT "name" FROM pg_timezone_names)
WITH "known_zones" AS (SELECT "name" FROM pg_timezone_names),
"instance_zone" AS (
SELECT COALESCE(
(
SELECT s."default_user_timezone" FROM "app_settings" s
WHERE s."id" = 'singleton'
AND s."default_user_timezone" IN (SELECT "name" FROM "known_zones")
),
${DEFAULT_TIMEZONE}
) AS "name"
)
SELECT r."id",
r."user_id" AS "userId",
r."taken_at" AS "takenAt",
@@ -106,7 +120,8 @@ function candidateQuery(limit: number | null): Prisma.Sql {
<> to_char(
(r."taken_at" AT TIME ZONE 'UTC') AT TIME ZONE (
CASE WHEN u."timezone" IN (SELECT "name" FROM "known_zones")
THEN u."timezone" ELSE 'UTC' END
THEN u."timezone"
ELSE (SELECT "name" FROM "instance_zone") END
),
'YYYY-MM-DD'
)
@@ -128,6 +143,13 @@ export interface LabDocumentDateRepairResult {
updated: number;
/** Rows left alone: an own date, or already on the document's day. */
unchanged: number;
/**
* Rows whose staged fact would not decrypt (a key retired too early, a
* damaged row). Skipped: without the fact there is no telling whether the
* value carries a date of its own, and one such row must not stop the
* repair of every other account.
*/
undecryptable: number;
}
/** The repair itself, across every account. */
@@ -138,9 +160,16 @@ export async function repairUndatedDocumentLabValues(
const touchedUsers = new Set<string>();
let updated = 0;
let unchanged = 0;
let undecryptable = 0;
for (const row of rows) {
const data = decryptFactData(row.dataEncrypted) as ObservationFactData;
let data: ObservationFactData;
try {
data = decryptFactData(row.dataEncrypted) as ObservationFactData;
} catch {
undecryptable += 1;
continue;
}
// A value with a date of its own is never touched, whatever its row says.
if (statedDay(data.effectiveDate ?? null) !== null) {
unchanged += 1;
@@ -175,7 +204,7 @@ export async function repairUndatedDocumentLabValues(
}
for (const userId of touchedUsers) invalidateUserHealthScore(userId);
return { scanned: rows.length, updated, unchanged };
return { scanned: rows.length, updated, unchanged, undecryptable };
}
/** The queue handler: one run over every account. */
@@ -193,7 +222,7 @@ export async function handleLabDocumentDateRepair(
}
workerLog(
"info",
`[lab-document-date-repair] scanned=${result.scanned} updated=${result.updated} unchanged=${result.unchanged}`,
`[lab-document-date-repair] scanned=${result.scanned} updated=${result.updated} unchanged=${result.unchanged} undecryptable=${result.undecryptable}`,
);
annotate({
action: { name: "labs.documentDate.repair" },
@@ -203,6 +232,7 @@ export async function handleLabDocumentDateRepair(
candidates_scanned: result.scanned,
updated: result.updated,
unchanged: result.unchanged,
undecryptable: result.undecryptable,
});
});
}
@@ -8,12 +8,15 @@
* at noon UTC, and leaves alone a value with a date of its own, a value that
* really was taken on its confirmation day in the owner's zone, one the
* person re-dated since, and one whose document has no date. A second run
* changes nothing.
* changes nothing. A fact that does not decrypt is skipped and counted
* rather than stopping the run, and an account zone Postgres does not know
* is read the way the app reads it, so the candidate set still converges.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { encryptFactData, encryptFactProvenance } from "@/lib/documents/store";
import { repairUndatedDocumentLabValues } from "@/lib/jobs/lab-document-date-repair";
import { invalidateUserTimezone } from "@/lib/tz/resolver";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER = "user-lab-document-date-repair";
@@ -22,6 +25,7 @@ const noon = (day: string) => new Date(`${day}T12:00:00.000Z`);
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
invalidateUserTimezone(USER);
await prisma.user.create({
data: {
id: USER,
@@ -229,4 +233,58 @@ describe("lab document date repair", () => {
expect(result.updated).toBe(0);
expect((await takenAt())["linked-twice"]).toBe(confirmed.toISOString());
});
it("skips and counts a fact that does not decrypt, and repairs the rest", async () => {
const confirmed = new Date("2026-10-20T08:00:00.000Z");
for (const id of ["damaged", "misdated"]) {
await confirmedValue({
id,
reportDate: "2021-03-01",
documentDate: null,
effectiveDate: null,
createdAt: confirmed,
takenAt: confirmed,
});
}
const prisma = getPrismaClient();
await prisma.extractedFact.updateMany({
where: { committedRecordId: "damaged" },
data: { dataEncrypted: Buffer.from("not a ciphertext at all") },
});
const result = await repairUndatedDocumentLabValues(prisma);
expect(result).toMatchObject({ updated: 1, undecryptable: 1 });
expect(await takenAt()).toEqual({
damaged: confirmed.toISOString(),
misdated: "2021-03-01T12:00:00.000Z",
});
});
it("reads a zone Postgres does not know the way the app does, so the candidates converge", async () => {
const prisma = getPrismaClient();
await prisma.user.update({
where: { id: USER },
data: { timezone: "Mars/Olympus_Mons" },
});
// 00:30 on the report day in the instance default (Europe/Berlin), the
// evening before in UTC: on its document's day for the app.
const justAfterMidnight = new Date("2026-10-19T22:30:00.000Z");
await confirmedValue({
id: "taken-on-confirm-day",
reportDate: "2026-10-20",
documentDate: null,
effectiveDate: null,
createdAt: justAfterMidnight,
takenAt: justAfterMidnight,
});
const result = await repairUndatedDocumentLabValues(prisma);
// Not even a candidate: the boot check finds nothing to enqueue.
expect(result).toMatchObject({ scanned: 0, updated: 0 });
expect((await takenAt())["taken-on-confirm-day"]).toBe(
justAfterMidnight.toISOString(),
);
});
});