mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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".
|
||||
|
||||
Reference in new issue
Block a user