Files
HealthLog/tests/integration/restore-job-driver.ts
T
Marc-André Bombeck 7936ab9a57 feat(admin): say what a queued restore is waiting for
A restore never runs beside a backup pass or another large job: the
heavy lane puts its delivery back in the queue for a minute at a time.
The job's own row says queued for the whole wait, and the console said
the restore starts "as soon as the background worker picks it up",
which read as a stuck worker while a backup ran.

The status routes now ask the queue. A delivery the lane put back sits
in created with a start_after ahead of now, and the active row of a
heavy queue is what holds the lane. The job view carries
waiting: "backup" | "busy" | null, and the console says it waits for
the running backup, or for another large job, and since when it has
been queued, in all nine locales. A queue that cannot be asked answers
no wait and is reported at warn.

The job view and its reads move to backup-restore-view.ts, which keeps
the worker module under its size baseline.
2026-10-23 21:43:55 +02:00

89 lines
3.2 KiB
TypeScript

/**
* Drive a queued restore to its end, the way the worker does, in one call.
*
* `POST /api/admin/backups/[id]/restore` answers 202 and leaves the restore to
* the `backup-restore` queue. The tests that pin what a restore checks and
* writes are about the restore, not the queue, so they call this instead of
* the route: it POSTs through the real route, runs the job the route queued
* with the real handler body, and answers the way the synchronous route did
* (200 with the report, or the refusal's status and message), read back from
* the job's row. Every hop in between is production code; only the pg-boss
* delivery is replaced by a direct call.
*
* The asynchronous contract itself (202, the status route, 409, resuming) is
* pinned by `backup-restore-job.test.ts`, which does not use this.
*/
import type { PgBoss } from "pg-boss";
import { POST as queueRestore } from "@/app/api/admin/backups/[id]/restore/route";
import { getGlobalBoss, setGlobalBoss } from "@/lib/jobs/boss-instance";
import { runBackupRestoreJob } from "@/lib/jobs/backup-restore";
import { readBackupRestoreJob } from "@/lib/jobs/backup-restore-view";
/** The status the synchronous route answered each refusal with. */
const LEGACY_STATUS: Record<string, number> = {
backup_not_found: 404,
backup_changed: 409,
"backup.payload.undecryptable": 422,
schema_invalid: 422,
incompatible_schema_version: 422,
owner_mismatch: 409,
owner_not_found: 422,
"backup.section.missing": 422,
document_ciphertext_missing: 422,
time_budget: 503,
transaction_failed: 500,
};
/** A queue that accepts every send, for a test with no worker. */
export const acceptingBoss = {
send: async () => "test-boss-job",
complete: async () => ({}),
} as unknown as PgBoss;
export async function POST(
request: Parameters<typeof queueRestore>[0],
context: { params: Promise<{ id: string }> },
): Promise<Response> {
// A file that mocks the queue already has one; otherwise stand one in for
// the duration of the request only, so nothing else in the file sees it.
const previous = getGlobalBoss();
if (!previous) setGlobalBoss(acceptingBoss);
let queued: Response;
try {
queued = await queueRestore(request, context);
} finally {
if (!previous) setGlobalBoss(null);
}
if (queued.status !== 202) return queued;
const { data } = (await queued.json()) as { data: { jobId: string } };
try {
await runBackupRestoreJob(data.jobId);
} catch {
// The job recorded the failure on its row before rethrowing; the
// synchronous route turned the same throw into a 500 envelope.
}
const job = await readBackupRestoreJob(data.jobId);
if (!job) throw new Error(`restore job ${data.jobId} vanished`);
if (job.status === "succeeded" && job.result) {
return Response.json(
{ data: { restored: true, ...job.result }, error: null },
{ status: 200 },
);
}
const failure = job.failure ?? { code: "unexpected", message: "failed" };
return Response.json(
{
data: null,
error: failure.message,
meta: {
errorCode: failure.code,
...(failure.sections ? { sections: failure.sections } : {}),
},
},
{ status: LEGACY_STATUS[failure.code] ?? 500 },
);
}