fix(backup): notice a backup run the process died under, and take its account last

A weekly or nightly backup job whose process died (a restart, a
container out of memory) stayed active until its two- or four-hour
expiry, and its retry walked the accounts in the same order: the account
that killed it has the oldest copy, so it led again, and every account
after it went without a copy for as long as that record stayed too
large.

Both passes now send with a five-minute pg-boss heartbeat, so a dead run
fails within minutes and its retry picks it up. Each pass records when it
started and finished every account (backup_pass_attempts, migration
0373); an account whose last attempt never finished goes behind all the
others. The backups page shows when the run in progress started and names
an account a run died under, for the weekly and the off-host card, and a
heartbeat timeout reads as what it means.
This commit is contained in:
Marc-André Bombeck committed 2026-10-04 15:56:56 +02:00
1 parent d55e329c48
commit f18e6ed34b
31 files changed
+734 -19

No files matched your search

@@ -19,16 +19,24 @@ vi.mock("@/lib/api-handler", () => ({
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
const runningSinceMock = vi.fn(
async (_queue: string): Promise<string | null> => null,
);
vi.mock("@/lib/jobs/job-failures", () => ({
readLastQueueRun: vi.fn(async () => null),
readQueueRunningSince: (queue: string) => runningSinceMock(queue),
}));
const queryRawMock = vi.fn();
const findManyMock = vi.fn();
const attemptsMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: (...a: unknown[]) => queryRawMock(...a),
user: { findMany: (...a: unknown[]) => findManyMock(...a) },
backupPassAttempt: {
findMany: (...a: unknown[]) => attemptsMock(...a),
},
offhostPurgeRequest: {
count: async () => 2,
findFirst: async () => ({
@@ -41,6 +49,7 @@ vi.mock("@/lib/db", () => ({
const configuredMock = vi.fn(() => true);
vi.mock("@/lib/jobs/offhost-backup", () => ({
OFFHOST_BACKUP_QUEUE: "data-backup-offhost",
offhostBackupConfigured: () => configuredMock(),
probeOffhostLifecycle: async () => ({
state: "missing",
@@ -64,6 +73,8 @@ beforeEach(() => {
configuredMock.mockReturnValue(true);
queryRawMock.mockResolvedValue([]);
findManyMock.mockResolvedValue([]);
attemptsMock.mockResolvedValue([]);
runningSinceMock.mockResolvedValue(null);
});
describe("GET /api/admin/backups — off-host freshness", () => {
@@ -226,3 +237,51 @@ describe("GET /api/admin/backups — off-host freshness", () => {
});
});
});
describe("GET /api/admin/backups — a run in progress and one a process died under", () => {
it("names the account an earlier run never came back from, and says since when one runs", async () => {
const runningSince = "2026-10-04T02:00:00.000Z";
runningSinceMock.mockImplementation(async (queue: string) =>
queue === "data-backup" ? runningSince : null,
);
findManyMock.mockResolvedValue([
{ id: "u1", username: "account-one", offhostBackupState: null },
{ id: "u2", username: "account-two", offhostBackupState: null },
]);
attemptsMock.mockImplementation(
async ({ where }: { where: { pass: string } }) =>
where.pass === "data-backup"
? [
// Died under an earlier run.
{
userId: "u1",
startedAt: new Date("2026-09-27T02:10:00.000Z"),
finishedAt: new Date("2026-09-20T02:30:00.000Z"),
},
// The account the run in progress is on now.
{
userId: "u2",
startedAt: new Date("2026-10-04T02:05:00.000Z"),
finishedAt: null,
},
]
: [],
);
const body = await read();
expect(body.scheduleActivity).toEqual({
runningSince,
interrupted: [
{
userId: "u1",
username: "account-one",
startedAt: "2026-09-27T02:10:00.000Z",
},
],
});
expect(body.offhost.activity).toEqual({
runningSince: null,
interrupted: [],
});
});
});
+16 -1
View File
@@ -25,9 +25,14 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy";
import { readLastQueueRun } from "@/lib/jobs/job-failures";
import {
readLastQueueRun,
readQueueRunningSince,
} from "@/lib/jobs/job-failures";
import { readBackupPassActivity } from "@/lib/jobs/backup-pass-attempts";
import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status";
import {
OFFHOST_BACKUP_QUEUE,
offhostBackupConfigured,
probeOffhostLifecycle,
} from "@/lib/jobs/offhost-backup";
@@ -177,6 +182,14 @@ export const GET = apiHandler(async () => {
lastAttemptAt: { not: null },
},
};
const [weeklyRunningSince, offhostRunningSince] = await Promise.all([
readQueueRunningSince(DATA_BACKUP_QUEUE),
readQueueRunningSince(OFFHOST_BACKUP_QUEUE),
]);
const [scheduleActivity, offhostActivity] = await Promise.all([
readBackupPassActivity(prisma, DATA_BACKUP_QUEUE, weeklyRunningSince),
readBackupPassActivity(prisma, OFFHOST_BACKUP_QUEUE, offhostRunningSince),
]);
const [purgeCount, oldestPurge, lifecycle] = await Promise.all([
prisma.offhostPurgeRequest.count({ where: stillInBucket }),
prisma.offhostPurgeRequest.findFirst({
@@ -201,8 +214,10 @@ export const GET = apiHandler(async () => {
lastRun: await readLastQueueRun(DATA_BACKUP_QUEUE),
now,
}),
scheduleActivity,
offhost: {
configured,
activity: offhostActivity,
periodHours: OFFHOST_BACKUP_PERIOD_HOURS,
// An unconfigured host has nothing to list, and the card says so rather
// than painting an empty table that reads as "no problems".