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

@@ -24,6 +24,7 @@ import { I18nProvider } from "@/lib/i18n/context";
import { OffhostBackupsSection } from "../offhost-backups-section";
const QUIET = {
activity: { runningSince: null, interrupted: [] },
pendingDeletions: { count: 0, oldestRequestedAt: null, lastFailure: null },
lifecycle: { state: "configured" as const, expirationDays: 30 },
};
@@ -126,6 +127,7 @@ describe("<OffhostBackupsSection>", () => {
configured: true,
periodHours: 24,
rows: [],
activity: { runningSince: null, interrupted: [] },
pendingDeletions: {
count: 2,
oldestRequestedAt: "2026-09-20T10:00:00.000Z",
@@ -149,4 +151,26 @@ describe("<OffhostBackupsSection>", () => {
expect(html).toContain("after 30 days");
expect(html).not.toContain('data-slot="offhost-pending-deletions"');
});
it("names an account a run died under, and says since when one runs", () => {
const html = render({
configured: true,
periodHours: 24,
rows: [],
...QUIET,
activity: {
runningSince: "2026-10-04T02:30:00.000Z",
interrupted: [
{
userId: "u1",
username: "account-one",
startedAt: "2026-10-03T02:31:00.000Z",
},
],
},
});
expect(html).toContain('data-slot="backup-pass-running"');
expect(html).toContain('data-interrupted-username="account-one"');
expect(html).toContain("never finished");
});
});
@@ -0,0 +1,78 @@
"use client";
/**
* What a backup pass is doing now, and which accounts a run died under.
*
* A pass whose process died (a restart, or a container out of memory) used
* to leave nothing on this page: the job sat `active` until its expiry and
* the last finished run still read fine. The run in progress and the
* accounts whose last attempt started and never finished are both recorded
* now (`backup-pass-attempts.ts`), so the page says so while it matters.
*/
import { AlertTriangle } from "lucide-react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { BackupPassActivity } from "@/lib/jobs/backup-pass-attempts";
export function BackupPassActivityNotice({
activity,
}: {
activity: BackupPassActivity;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
if (activity.runningSince === null && activity.interrupted.length === 0) {
return null;
}
return (
<div className="space-y-2" data-slot="backup-pass-activity">
{activity.runningSince !== null ? (
<p
role="status"
className="text-muted-foreground text-xs"
data-slot="backup-pass-running"
>
{t("admin.section.backups.activity.running", {
when: fmt.dateTime(activity.runningSince),
})}
</p>
) : null}
{activity.interrupted.length > 0 ? (
<div
role="alert"
data-slot="backup-pass-interrupted"
className="border-destructive/40 bg-destructive/10 rounded-md border px-3 py-2 text-sm"
>
<div className="flex items-start gap-2">
<AlertTriangle
className="mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div>
<p className="font-medium">
{t("admin.section.backups.activity.interruptedTitle")}
</p>
<ul className="text-xs">
{activity.interrupted.map((attempt) => (
<li
key={attempt.userId}
data-interrupted-username={attempt.username}
>
{t("admin.section.backups.activity.interruptedAccount", {
username: attempt.username,
when: fmt.dateTime(attempt.startedAt),
})}
</li>
))}
</ul>
<p className="text-muted-foreground mt-2 text-xs">
{t("admin.section.backups.activity.interruptedRemedy")}
</p>
</div>
</div>
</div>
) : null}
</div>
);
}
+4
View File
@@ -35,6 +35,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { BackupPassActivityNotice } from "@/components/admin/backup-pass-activity";
import { Badge } from "@/components/ui/badge";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsCardActions } from "@/components/settings/_card-actions";
@@ -1149,6 +1150,9 @@ export function BackupsSection() {
{data?.schedule ? (
<ScheduleHealthNotice schedule={data.schedule} />
) : null}
{data?.scheduleActivity ? (
<BackupPassActivityNotice activity={data.scheduleActivity} />
) : null}
{/* Upload card — separate from the table so admins can ingest a
backup file independently of any existing rows. The visible
@@ -21,6 +21,7 @@
import { useQuery } from "@tanstack/react-query";
import { CloudUpload } from "lucide-react";
import { BackupPassActivityNotice } from "@/components/admin/backup-pass-activity";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Badge } from "@/components/ui/badge";
@@ -182,6 +183,7 @@ export function OffhostBackupsSection() {
/>
) : offhost ? (
<div className="space-y-2">
<BackupPassActivityNotice activity={offhost.activity} />
<ul className="space-y-2" data-slot="offhost-backup-rows">
{offhost.rows.map((row) => (
<AccountRow key={row.userId} row={row} />