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