Merge pull request #1144 from MBombeck/fix/restore-key-probe

fix(backup): restore opens legacy values, drill checks every account, backups hold less memory
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-04 17:44:57 +02:00
commit 1cb50ed21d
48 files changed
+2053 -343

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".
+13 -1
View File
@@ -221,6 +221,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
let measurementCount = 0;
let owner: { id: string; username: string } | null = null;
// Filled while the file is read; the preview is the summary this route
// answers with and the key uses it already holds (`backup-preview.ts`).
const keys = new BackupKeyIdCollector();
let created: { id: string };
try {
created = await storeBackupBlob(
@@ -229,6 +232,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
userId: admin.id,
type: uploadType,
ownerAfterRead: () => owner!.id,
preview: () =>
payload
? {
summary: {
...summarizeBackup(payload),
measurements: measurementCount,
},
keys,
}
: null,
},
async (write) => {
// One pass: every chunk is stored as it is checked, and a check that
@@ -240,7 +253,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
let firstWithoutId: number | null = null;
const keys = new BackupKeyIdCollector();
let scanned;
try {
scanned = await scanBackupJson(storedAsRead(), {