fix(admin): record the pulse rebuild and let a stopped one run again

The rebuild after apply now writes its state beside the dry run
(queued, running, done with counts, failed); a run left queued or
running without a job reads as failed. The card shows it and offers a
new rebuild after a failure, which the route accepts once the method is
applied, without a new dry run.
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 05:55:31 +02:00
1 parent cc9d523bc8
commit f360d221e1
16 files changed
+483 -49

No files matched your search

@@ -66,10 +66,14 @@ const post = (body: unknown) =>
const NOW = new Date("2026-10-16T12:00:00Z");
const send = vi.fn();
function settings(dryRun: unknown, method = "hour_mean_v1") {
function settings(
dryRun: unknown,
method = "hour_mean_v1",
appliedAt: Date | null = null,
) {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
pulseDayMeanMethod: method,
pulseDayMeanAppliedAt: null,
pulseDayMeanAppliedAt: appliedAt,
pulseDayMeanDryRunJson: dryRun,
} as never);
}
@@ -136,6 +140,7 @@ describe("GET", () => {
method: "hour_mean_v1",
appliedAt: null,
lastDryRun: doneAt("2026-10-15T08:00:00.000Z"),
lastRebuild: null,
});
});
@@ -199,7 +204,9 @@ describe("POST dry_run", () => {
const res = await post({ action: "dry_run" });
expect(res.status).toBe(409);
expect(vi.mocked(prisma.appSettings.upsert).mock.lastCall?.[0]).toEqual(
expect.objectContaining({ update: { pulseDayMeanDryRunJson: previous } }),
expect.objectContaining({
update: { pulseDayMeanDryRunJson: { ...previous, rebuild: null } },
}),
);
});
});
@@ -264,10 +271,71 @@ describe("POST apply", () => {
});
});
describe("POST rebuild", () => {
const failedRebuild = {
status: "failed",
startedAt: "2026-10-15T09:00:00.000Z",
finishedAt: "2026-10-15T09:05:00.000Z",
accounts: null,
rows: null,
};
it("is refused before any apply", async () => {
settings(doneAt("2026-10-15T08:00:00.000Z"));
const res = await post({ action: "rebuild" });
expect(res.status).toBe(409);
expect((await res.json()).meta.errorCode).toBe(
"maintenance.pulseDayMean.dryRunRequired",
);
expect(send).not.toHaveBeenCalled();
});
it("runs the recalculation again after a stopped one, without a new dry run", async () => {
// The dry run is three weeks old: apply would refuse it, a rebuild does not.
settings(
{ ...doneAt("2026-09-20T08:00:00.000Z"), rebuild: failedRebuild },
"weighted_hour_mean_v2",
new Date("2026-09-20T09:00:00.000Z"),
);
const res = await post({ action: "rebuild" });
expect(res.status).toBe(202);
expect(send).toHaveBeenCalledWith(
"pulse-day-mean",
{ action: "rebuild" },
expect.anything(),
);
expect(
vi.mocked(prisma.appSettings.upsert).mock.lastCall?.[0],
).toMatchObject({
update: {
pulseDayMeanDryRunJson: { rebuild: { status: "queued" } },
},
});
expect(auditLog).toHaveBeenCalledWith(
"admin.pulseDayMean.rebuild",
expect.anything(),
);
});
it("shows a rebuild left queued with no job behind it as failed", async () => {
settings(
{
...doneAt("2026-10-15T08:00:00.000Z"),
rebuild: { ...failedRebuild, status: "running", finishedAt: null },
},
"weighted_hour_mean_v2",
new Date("2026-10-15T09:00:00.000Z"),
);
const body = await (await get()).json();
expect(body.data.lastRebuild.status).toBe("failed");
expect(body.data.lastDryRun.status).toBe("done");
});
});
describe("POST body", () => {
it("is strict", async () => {
expect((await post({ action: "apply", force: true })).status).toBe(422);
expect((await post({ action: "rebuild" })).status).toBe(422);
expect((await post({ action: "purge" })).status).toBe(422);
expect(send).not.toHaveBeenCalled();
});
@@ -4,7 +4,7 @@
*
* GET answers the active method, when it was applied and the last dry run
* (status and result: per-account ordinal labels only, never an id). POST
* `{ action: "dry_run" | "apply" }` answers 202 `{ enqueued }`: a dry run
* `{ action: "dry_run" | "apply" | "rebuild" }` answers 202 `{ enqueued }`: a dry run
* computes both methods for every account and day and writes nothing but its
* result; `apply` needs a successful dry run from the last seven days (409
* `maintenance.pulseDayMean.dryRunRequired`), switches the method and
@@ -31,6 +31,7 @@ import {
PULSE_DAY_MEAN_SEND_OPTIONS,
readPulseDayMeanStatus,
writeDryRun,
writeRebuild,
type PulseDayMeanPayload,
} from "@/lib/jobs/pulse-day-mean";
import { annotate } from "@/lib/logging/context";
@@ -41,6 +42,7 @@ import {
import {
hasFreshDryRun,
pulseDayMeanActionSchema,
type PulseDayMeanStatus,
} from "@/lib/validations/pulse-day-mean";
export const dynamic = "force-dynamic";
@@ -54,6 +56,33 @@ function busy() {
});
}
/**
* Queue the rebuild and record it as queued, so the card shows it from the
* moment it is asked for and a rebuild that never starts reads as failed.
* When another run holds the singleton, the previous rebuild state is put
* back and the answer is false.
*/
async function sendRebuild(
boss: NonNullable<ReturnType<typeof getGlobalBoss>>,
previous: PulseDayMeanStatus["lastRebuild"],
): Promise<boolean> {
await writeRebuild({
status: "queued",
startedAt: new Date().toISOString(),
finishedAt: null,
accounts: null,
rows: null,
});
const payload: PulseDayMeanPayload = { action: "rebuild" };
const jobId = await boss.send(
PULSE_DAY_MEAN_QUEUE,
payload,
PULSE_DAY_MEAN_SEND_OPTIONS,
);
if (jobId === null) await writeRebuild(previous);
return jobId !== null;
}
export const GET = apiHandler(async () => {
await requireAdmin();
const status = await readPulseDayMeanStatus();
@@ -110,6 +139,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiSuccess({ enqueued: true }, 202);
}
if (action === "rebuild") {
// The recalculation again, after an apply whose rebuild stopped. The
// method is already switched; the dry run that justified it stands.
const status = await readPulseDayMeanStatus();
if (status.appliedAt === null) {
return apiError(
"Nothing applied yet; run a dry run and apply first",
409,
{ errorCode: "maintenance.pulseDayMean.dryRunRequired" },
);
}
const enqueued = await sendRebuild(boss, status.lastRebuild);
if (!enqueued) return busy();
await auditLog("admin.pulseDayMean.rebuild", {
userId: user.id,
ipAddress: getClientIp(request),
details: { method: status.method },
});
annotate({ meta: { pulse_day_mean_enqueued: true } });
return apiSuccess({ enqueued: true }, 202);
}
if (!hasFreshDryRun(await readPulseDayMeanStatus())) {
return apiError(
"Run a dry run first; apply needs one finished in the last seven days",
@@ -135,13 +186,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
setActivePulseDayMeanMethod(APPLIED_METHOD);
const payload: PulseDayMeanPayload = { action: "rebuild" };
const jobId = await boss.send(
PULSE_DAY_MEAN_QUEUE,
payload,
PULSE_DAY_MEAN_SEND_OPTIONS,
const enqueued = await sendRebuild(
boss,
(await readPulseDayMeanStatus()).lastRebuild,
);
const enqueued = jobId !== null;
await auditLog("admin.pulseDayMean.apply", {
userId: user.id,