From ec0f67f8505d568959688c4c8b78a93d11debfe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 11:04:21 +0100 Subject: [PATCH] fix(admin): give the single-account rollup recompute the boot fold's budget The admin recompute for one account called the full fold without a statement budget, so on the large accounts it is meant for each yearly statement ran under the database's ordinary limit and timed out, as the boot job had before it got its own. It now passes the same per-statement budget. --- src/app/api/admin/rollups/recompute/route.ts | 13 +- .../admin-rollups-recompute.test.ts | 129 ++++++++++++++++++ 2 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 tests/integration/admin-rollups-recompute.test.ts diff --git a/src/app/api/admin/rollups/recompute/route.ts b/src/app/api/admin/rollups/recompute/route.ts index 63f612c49..e947f635c 100644 --- a/src/app/api/admin/rollups/recompute/route.ts +++ b/src/app/api/admin/rollups/recompute/route.ts @@ -16,8 +16,9 @@ * * - `userId` — single user mode. Synchronously calls * `foldUserRollupHistory(userId)` (the full fold, a calendar year per - * statement) and returns the row count when the fold completes. The call is awaited inside the request, so - * the operator sees the duration in the response. + * statement, each statement under the boot job's budget) and returns the + * row count when the fold completes. The call is awaited inside the + * request, so the operator sees the duration in the response. * - `userId` omitted — calls `enqueueBootTimeRollupBackfill()` to * re-run the worker-boot discovery (`{ enqueued, skipped }` shape). * @@ -38,6 +39,7 @@ import { annotate } from "@/lib/logging/context"; import { enqueueBootTimeRollupBackfill, foldUserRollupHistory, + ROLLUP_FULL_BACKFILL_STATEMENT_BUDGET_MS, } from "@/lib/rollups/full-backfill"; interface RecomputeBody { @@ -70,7 +72,12 @@ export const POST = apiHandler(async (request: NextRequest) => { let result: Awaited>; try { - result = await foldUserRollupHistory(userId); + // The per-statement budget the boot job folds with: this mode is for + // the accounts the boot job could not finish, and under the database's + // ordinary limit their yearly statements time out here too. + result = await foldUserRollupHistory(userId, { + statementTimeoutMs: ROLLUP_FULL_BACKFILL_STATEMENT_BUDGET_MS, + }); } catch (err) { const message = err instanceof Error ? err.message : String(err); return apiError(message, 500); diff --git a/tests/integration/admin-rollups-recompute.test.ts b/tests/integration/admin-rollups-recompute.test.ts new file mode 100644 index 000000000..0ae49e279 --- /dev/null +++ b/tests/integration/admin-rollups-recompute.test.ts @@ -0,0 +1,129 @@ +/** + * `POST /api/admin/rollups/recompute` for one account, against Postgres. + * + * The single-account mode exists for the accounts the boot backfill could not + * finish, which are the large ones. It folds the account in the request, and + * it must give every statement the same budget the boot job gives it + * (`ROLLUP_FULL_BACKFILL_STATEMENT_BUDGET_MS`): under the database's ordinary + * statement limit the yearly fold of such an account times out, which is the + * very failure the operator is trying to get past. The fold itself runs for + * real; the wrapper only records what the route handed it. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ROLLUP_FULL_BACKFILL_STATEMENT_BUDGET_MS } from "@/lib/rollups/full-backfill"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const folds = vi.hoisted(() => ({ + calls: [] as Array<{ userId: string; statementTimeoutMs?: number }>, +})); + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rollups/full-backfill", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + foldUserRollupHistory: async ( + ...args: Parameters + ) => { + folds.calls.push({ + userId: args[0], + statementTimeoutMs: args[1]?.statementTimeoutMs, + }); + return actual.foldUserRollupHistory(...args); + }, + }; +}); + +const ADMIN = "user-admin-recompute"; +const TARGET = "user-recompute-target"; + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + cookieJar.clear(); + folds.calls.length = 0; + await prisma.user.createMany({ + data: [ + { + id: ADMIN, + username: "admin-recompute", + email: "admin-recompute@example.test", + role: "ADMIN", + }, + { + id: TARGET, + username: "recompute-target", + email: "recompute-target@example.test", + }, + ], + }); + const session = await prisma.session.create({ + data: { userId: ADMIN, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + await prisma.measurement.create({ + data: { + userId: TARGET, + type: "WEIGHT", + value: 80, + unit: "kg", + source: "MANUAL", + measuredAt: new Date(Date.now() - 30 * 86_400_000), + }, + }); +}); + +describe("POST /api/admin/rollups/recompute for one account", () => { + it("folds it with the boot job's statement budget", async () => { + const { POST } = await import("@/app/api/admin/rollups/recompute/route"); + const res = await POST( + new NextRequest("http://localhost/api/admin/rollups/recompute", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ userId: TARGET }), + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { mode: string; rowsUpserted: number }; + }; + expect(body.data.mode).toBe("single"); + expect(body.data.rowsUpserted).toBeGreaterThan(0); + + expect(folds.calls).toEqual([ + { + userId: TARGET, + statementTimeoutMs: ROLLUP_FULL_BACKFILL_STATEMENT_BUDGET_MS, + }, + ]); + }); +});