mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
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,
|
||||
|
||||
Reference in new issue
Block a user