feat(backups): tell an empty ledger apart from an empty bucket

On the release that adds the ledger the table is empty, so on a host that has
been uploading nightly for months every account read Never, under a sentence
saying no copy had ever reached the bucket. That sentence is untrue on exactly
the morning an operator opens the new card for the first time, and it is a
claim the host cannot make: what is empty is the record, not the bucket.

The run now records every account it walks, not only the ones an object came
of. A walk that produced nothing moves the attempt instant and leaves the
success columns alone, so the two cases separate: no row at all is no record
yet, a row without a success is never. Never now means what the word says —
a run looked at this account and has never put anything there.

The card names the last check on the never row, so the state is a fact with a
timestamp rather than an assertion. Runbook and all seven bundles carry the
same two sentences. The integration file proves both arms against real
Postgres, including that a failed run leaves the previous good copy on the
row.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 10:15:31 +02:00
1 parent cfc19a211c
commit 25f0310596
21 files changed
+340 -80

No files matched your search

@@ -63,6 +63,7 @@ describe("GET /api/admin/backups — off-host freshness", () => {
id: "u1",
username: "account-one",
offhostBackupState: {
lastAttemptAt: new Date(now - 3 * HOUR),
lastSuccessAt: new Date(now - 3 * HOUR),
// The column is BigInt — an object may be 80 GB — and the wire type
// is a plain number.
@@ -73,6 +74,7 @@ describe("GET /api/admin/backups — off-host freshness", () => {
id: "u2",
username: "account-two",
offhostBackupState: {
lastAttemptAt: new Date(now - 31 * HOUR),
lastSuccessAt: new Date(now - 31 * HOUR),
sizeBytes: BigInt(8192),
},
@@ -81,11 +83,24 @@ describe("GET /api/admin/backups — off-host freshness", () => {
id: "u3",
username: "account-three",
offhostBackupState: {
lastAttemptAt: new Date(now - 100 * HOUR),
lastSuccessAt: new Date(now - 100 * HOUR),
sizeBytes: BigInt(128),
},
},
{ id: "u4", username: "account-four", offhostBackupState: null },
{
id: "u4",
username: "account-four",
// Walked by a run, nothing landed.
offhostBackupState: {
lastAttemptAt: new Date(now - 2 * HOUR),
lastSuccessAt: null,
sizeBytes: null,
},
},
// No run has recorded this one at all — the state of every account on
// the morning the ledger ships.
{ id: "u5", username: "account-five", offhostBackupState: null },
]);
const body = await read();
@@ -99,12 +114,17 @@ describe("GET /api/admin/backups — off-host freshness", () => {
["account-two", "due"],
["account-three", "stale"],
["account-four", "never"],
["account-five", "unknown"],
]);
expect(body.offhost.rows[0]?.sizeBytes).toBe(4096);
expect(typeof body.offhost.rows[0]?.sizeBytes).toBe("number");
expect(body.offhost.rows[0]?.ageHours).toBe(3);
expect(body.offhost.rows[3]?.lastSuccessAt).toBeNull();
expect(body.offhost.rows[3]?.sizeBytes).toBeNull();
expect(body.offhost.rows[3]?.lastAttemptAt).not.toBeNull();
// `unknown` is the honest one: the ledger has no history for this
// account, which is not the same claim as "the bucket is empty".
expect(body.offhost.rows[4]?.lastAttemptAt).toBeNull();
});
it("says so and reads no account when off-host backup is not configured", async () => {
@@ -128,6 +148,7 @@ describe("GET /api/admin/backups — off-host freshness", () => {
id: "u1",
username: "account-one",
offhostBackupState: {
lastAttemptAt: new Date(),
lastSuccessAt: new Date(),
sizeBytes: BigInt(1),
},
@@ -139,6 +160,7 @@ describe("GET /api/admin/backups — off-host freshness", () => {
expect(Object.keys(body.offhost.rows[0] ?? {}).sort()).toEqual([
"ageHours",
"freshness",
"lastAttemptAt",
"lastSuccessAt",
"sizeBytes",
"userId",
+12 -3
View File
@@ -95,7 +95,11 @@ export const GET = apiHandler(async () => {
id: true,
username: true,
offhostBackupState: {
select: { lastSuccessAt: true, sizeBytes: true },
select: {
lastAttemptAt: true,
lastSuccessAt: true,
sizeBytes: true,
},
},
},
orderBy: { username: "asc" },
@@ -105,17 +109,22 @@ export const GET = apiHandler(async () => {
const offhostRows: OffhostAccountRow[] = offhostAccounts.map((account) => {
const state = account.offhostBackupState;
const verdict = classifyOffhostBackup({
lastAttemptAt: state?.lastAttemptAt ?? null,
lastSuccessAt: state?.lastSuccessAt ?? null,
now,
});
return {
userId: account.id,
username: account.username,
lastSuccessAt: state?.lastSuccessAt.toISOString() ?? null,
lastAttemptAt: state?.lastAttemptAt.toISOString() ?? null,
lastSuccessAt: state?.lastSuccessAt?.toISOString() ?? null,
// BigInt column -> number on the wire. The value is bytes of one
// object, capped by the uploader at 80 GB, so it is nowhere near
// Number.MAX_SAFE_INTEGER and JSON has no BigInt.
sizeBytes: state ? Number(state.sizeBytes) : null,
sizeBytes:
state?.sizeBytes === null || state?.sizeBytes === undefined
? null
: Number(state.sizeBytes),
ageHours: verdict.ageHours,
freshness: verdict.freshness,
};
@@ -59,6 +59,7 @@ describe("<OffhostBackupsSection>", () => {
{
userId: "u1",
username: "account-one",
lastAttemptAt: "2026-09-10T06:00:00.000Z",
lastSuccessAt: "2026-09-10T06:00:00.000Z",
sizeBytes: 4096,
ageHours: 3,
@@ -67,6 +68,7 @@ describe("<OffhostBackupsSection>", () => {
{
userId: "u2",
username: "account-two",
lastAttemptAt: "2026-09-06T06:00:00.000Z",
lastSuccessAt: "2026-09-06T06:00:00.000Z",
sizeBytes: 2048,
ageHours: 99,
@@ -75,20 +77,35 @@ describe("<OffhostBackupsSection>", () => {
{
userId: "u3",
username: "account-three",
lastAttemptAt: "2026-09-10T02:30:00.000Z",
lastSuccessAt: null,
sizeBytes: null,
ageHours: null,
freshness: "never",
},
{
userId: "u4",
username: "account-four",
lastAttemptAt: null,
lastSuccessAt: null,
sizeBytes: null,
ageHours: null,
freshness: "unknown",
},
],
});
expect(html).toContain('data-offhost-freshness="fresh"');
expect(html).toContain('data-offhost-freshness="stale"');
expect(html).toContain('data-offhost-freshness="never"');
expect(html).toContain('data-offhost-freshness="unknown"');
expect(html).toContain("account-two");
// The account with no object says so instead of showing a blank cell.
expect(html).toContain("No copy has ever reached the bucket");
// The account a run walked and found nothing for says that, with the
// instant it was checked.
expect(html).toContain("Nothing has landed for this account");
// The account no run has recorded says THAT — not "never", which would
// claim the bucket is empty on the strength of an empty ledger.
expect(html).toContain("No nightly run has recorded this account yet");
// Never the bucket's own coordinates.
expect(html).not.toMatch(/secret|access[- ]key/i);
});
@@ -34,16 +34,17 @@ import type { BackupsList, OffhostAccountRow } from "@/types/backups";
import { formatBytes } from "./backups-section";
/**
* Status wash per verdict. `never` is deliberately neutral rather than
* alarming: a host that turned the off-host job on this afternoon has not
* failed at anything yet, and painting every row red on day one is how an
* operator learns to stop reading the colour.
* Status wash per verdict. `never` and `unknown` are deliberately neutral
* rather than alarming: a host that turned the off-host job on this afternoon
* has not failed at anything yet, and painting every row red on day one is how
* an operator learns to stop reading the colour.
*/
const FRESHNESS_STYLE: Record<OffhostBackupFreshness, string> = {
fresh: "border-success/30 bg-success/10 text-success",
due: "border-warning/30 bg-warning/10 text-warning",
stale: "border-destructive/30 bg-destructive/10 text-destructive",
never: "border-border bg-muted text-muted-foreground",
unknown: "border-border bg-muted text-muted-foreground",
};
function freshnessLabel(
@@ -61,6 +62,8 @@ function freshnessLabel(
return t("admin.section.backups.offhost.stateStale");
case "never":
return t("admin.section.backups.offhost.stateNever");
case "unknown":
return t("admin.section.backups.offhost.stateUnknown");
}
}
@@ -87,14 +90,22 @@ function AccountRow({ row }: { row: OffhostAccountRow }) {
</Badge>
</div>
<p className="text-muted-foreground text-xs">
{row.lastSuccessAt === null || row.sizeBytes === null
? t("admin.section.backups.offhost.neverDetail")
: `${fmt.dateTime(row.lastSuccessAt)} · ${formatBytes(
row.sizeBytes,
fmt,
)} · ${t("admin.section.backups.offhost.age", {
hours: row.ageHours ?? 0,
})}`}
{row.lastAttemptAt === null
? // No run has recorded this account. Saying "never" here would
// claim the bucket is empty when what is empty is the ledger,
// which is exactly what every account looks like between the
// upgrade that adds it and the first nightly run after that.
t("admin.section.backups.offhost.unknownDetail")
: row.lastSuccessAt === null || row.sizeBytes === null
? t("admin.section.backups.offhost.neverDetail", {
when: fmt.dateTime(row.lastAttemptAt),
})
: `${fmt.dateTime(row.lastSuccessAt)} · ${formatBytes(
row.sizeBytes,
fmt,
)} · ${t("admin.section.backups.offhost.age", {
hours: row.ageHours ?? 0,
})}`}
</p>
</li>
</ListRow>
@@ -16,20 +16,40 @@ function ago(ms: number): Date {
}
describe("classifyOffhostBackup", () => {
it("says never when the worker has put nothing there for this account", () => {
expect(classifyOffhostBackup({ lastSuccessAt: null, now: NOW })).toEqual({
freshness: "never",
ageHours: null,
});
it("says unknown, not never, when no run has recorded this account", () => {
// The ledger starts empty. On the first morning after the upgrade every
// account on a perfectly healthy host looks like this, and `never` would
// assert something about the bucket the host has not looked at.
expect(
classifyOffhostBackup({
lastAttemptAt: null,
lastSuccessAt: null,
now: NOW,
}),
).toEqual({ freshness: "unknown", ageHours: null });
});
it("says never once a run has walked the account and put nothing there", () => {
expect(
classifyOffhostBackup({
lastAttemptAt: ago(HOUR),
lastSuccessAt: null,
now: NOW,
}),
).toEqual({ freshness: "never", ageHours: null });
});
it("is fresh right up to one period plus the grace and due one millisecond past it", () => {
expect(
classifyOffhostBackup({ lastSuccessAt: ago(PERIOD + GRACE), now: NOW })
.freshness,
classifyOffhostBackup({
lastAttemptAt: ago(0),
lastSuccessAt: ago(PERIOD + GRACE),
now: NOW,
}).freshness,
).toBe("fresh");
expect(
classifyOffhostBackup({
lastAttemptAt: ago(0),
lastSuccessAt: ago(PERIOD + GRACE + 1),
now: NOW,
}).freshness,
@@ -39,12 +59,14 @@ describe("classifyOffhostBackup", () => {
it("is due right up to two periods plus the grace and stale one millisecond past it", () => {
expect(
classifyOffhostBackup({
lastAttemptAt: ago(0),
lastSuccessAt: ago(PERIOD * 2 + GRACE),
now: NOW,
}).freshness,
).toBe("due");
expect(
classifyOffhostBackup({
lastAttemptAt: ago(0),
lastSuccessAt: ago(PERIOD * 2 + GRACE + 1),
now: NOW,
}).freshness,
@@ -66,6 +88,7 @@ describe("classifyOffhostBackup", () => {
// on every host, with nothing wrong anywhere.
expect(
classifyOffhostBackup({
lastAttemptAt: ago(0),
lastSuccessAt: beforeTheChange,
now: afterTheChange,
}).freshness,
@@ -76,14 +99,21 @@ describe("classifyOffhostBackup", () => {
// 31 hours: past one period even with six hours of slack, so a run that
// produced nothing for this account still shows.
expect(
classifyOffhostBackup({ lastSuccessAt: ago(31 * HOUR), now: NOW })
.freshness,
classifyOffhostBackup({
lastAttemptAt: ago(0),
lastSuccessAt: ago(31 * HOUR),
now: NOW,
}).freshness,
).toBe("due");
});
it("reports whole hours of age", () => {
expect(
classifyOffhostBackup({ lastSuccessAt: ago(90 * 60_000), now: NOW }),
classifyOffhostBackup({
lastAttemptAt: ago(0),
lastSuccessAt: ago(90 * 60_000),
now: NOW,
}),
).toEqual({ freshness: "fresh", ageHours: 1 });
});
@@ -93,10 +123,15 @@ describe("classifyOffhostBackup", () => {
// as zero so the thresholds under test are the caller's period alone.
const twelveHours = ago(12 * HOUR);
expect(
classifyOffhostBackup({ lastSuccessAt: twelveHours, now: NOW }).freshness,
classifyOffhostBackup({
lastAttemptAt: ago(0),
lastSuccessAt: twelveHours,
now: NOW,
}).freshness,
).toBe("fresh");
expect(
classifyOffhostBackup({
lastAttemptAt: ago(0),
lastSuccessAt: twelveHours,
now: NOW,
periodHours: 6,
@@ -105,6 +140,7 @@ describe("classifyOffhostBackup", () => {
).toBe("due");
expect(
classifyOffhostBackup({
lastAttemptAt: ago(0),
lastSuccessAt: twelveHours,
now: NOW,
periodHours: 5,
@@ -116,6 +152,7 @@ describe("classifyOffhostBackup", () => {
it("does not call a copy stale because the host clock ran backwards", () => {
expect(
classifyOffhostBackup({
lastAttemptAt: ago(0),
lastSuccessAt: new Date(NOW.getTime() + 2 * HOUR),
now: NOW,
}),
+18 -9
View File
@@ -334,16 +334,24 @@ describe("runOffhostBackup", () => {
expect(report.uploaded).toBe(1);
expect(report.failed).toBe(1);
// Both accounts are recorded as walked — that is what separates "a run
// looked and found nothing" from "no run has looked at this account" —
// but only the one whose object landed carries a success.
type Upsert = {
where: { userId: string };
update: Record<string, unknown>;
create: Record<string, unknown>;
};
const calls = upsert.mock.calls.map((call) => call[0] as Upsert);
expect(calls.map((call) => call.where.userId)).toEqual(["u1", "u2"]);
expect(calls[0]?.update).toHaveProperty("lastSuccessAt");
// The guarantee an operator leans on: an account whose upload failed keeps
// whatever `lastSuccessAt` it already had. A failed upload writes no
// success into the ledger, so yesterday's good copy is still what the
// console reports — the run does not overwrite it with today's failure.
expect(upsert).toHaveBeenCalledTimes(1);
expect(
upsert.mock.calls.map(
(call) => (call[0] as { where: { userId: string } }).where.userId,
),
).toEqual(["u1"]);
// whatever `lastSuccessAt` it already had. The failed arm names only the
// walk instant, so yesterday's good copy is still what the console
// reports — the run does not overwrite it with today's failure.
expect(calls[1]?.update).not.toHaveProperty("lastSuccessAt");
expect(calls[1]?.update).not.toHaveProperty("sizeBytes");
expect(calls[1]?.create).not.toHaveProperty("lastSuccessAt");
});
it("still reports a landed object as uploaded when the ledger write fails", async () => {
@@ -368,6 +376,7 @@ describe("runOffhostBackup", () => {
expect(report.failed).toBe(0);
expect(report.failures).toEqual([]);
expect(s3.store.has("2026-05-08/user-u1.json.enc")).toBe(true);
expect(prisma.offhostBackupState.upsert).toHaveBeenCalledTimes(1);
});
it("refuses an account whose object outgrows one multipart upload", async () => {
+18 -5
View File
@@ -14,10 +14,16 @@
* same whether or not the app process holds a read grant on the bucket, and a
* listing of somebody's backups never crosses the network to show a page.
*
* Four states, because the run is nightly and one missed night is not the same
* event as four:
* Five states, because the run is nightly and one missed night is not the same
* event as four — and because "we have not looked yet" is not a verdict:
*
* never — the worker has never put an object there for this account.
* unknown — no nightly run has recorded this account. The ledger starts
* empty, so this is what every account on a perfectly healthy host
* reads between the upgrade that adds the table and the first run
* after it. Saying "never" there would assert something the host
* cannot know.
* never — a run walked this account and put no object there, and no earlier
* run ever did either.
* fresh — inside one schedule period plus the grace below. This is what
* every account looks like on a host where the cron is doing its
* job.
@@ -54,7 +60,8 @@ export const OFFHOST_BACKUP_PERIOD_HOURS = 24;
*/
export const OFFHOST_BACKUP_GRACE_HOURS = 6;
export type OffhostBackupFreshness = "never" | "fresh" | "due" | "stale";
export type OffhostBackupFreshness =
"unknown" | "never" | "fresh" | "due" | "stale";
export interface OffhostBackupVerdict {
freshness: OffhostBackupFreshness;
@@ -63,6 +70,11 @@ export interface OffhostBackupVerdict {
}
export function classifyOffhostBackup(args: {
/**
* When a run last walked this account, or null when none has. Null is the
* only thing that separates `unknown` from `never`.
*/
lastAttemptAt: Date | null;
lastSuccessAt: Date | null;
now: Date;
/** Defaults to the nightly schedule. Tests pass their own. */
@@ -70,7 +82,8 @@ export function classifyOffhostBackup(args: {
/** Defaults to `OFFHOST_BACKUP_GRACE_HOURS`. Tests pass their own. */
graceHours?: number;
}): OffhostBackupVerdict {
const { lastSuccessAt, now } = args;
const { lastAttemptAt, lastSuccessAt, now } = args;
if (lastAttemptAt === null) return { freshness: "unknown", ageHours: null };
if (lastSuccessAt === null) return { freshness: "never", ageHours: null };
const periodMs =
+39 -22
View File
@@ -615,32 +615,49 @@ export async function runOffhostBackup(
// by here the object is durably in the bucket and this account's backup
// has succeeded whatever the database does next. A pool timeout on the
// row would otherwise un-count a copy that exists — the run would report
// one failure too many and the console would read `never` for an account
// one failure too many and the console would read stale for an account
// whose disaster-recovery object is fine, which is exactly the inversion
// the ledger exists to remove.
//
// The row is written whether or not an object came of the walk, because a
// ledger keyed only on success cannot tell "no run has reached this
// account" from "a run reached it and nothing landed" — and the first of
// those is what every account on a running host looks like the day this
// table ships. A failed walk touches `lastAttemptAt` and leaves
// `lastSuccessAt` exactly as it was, so yesterday's good copy still reads
// as the copy this account has.
//
// `new Date()` rather than the run's `now`, because on a large cohort the
// two are hours apart and the row is meant to say when this account's
// object landed.
if (objectBytes !== null) {
const bytes = BigInt(objectBytes);
try {
await prisma.offhostBackupState.upsert({
where: { userId: user.id },
update: { lastSuccessAt: new Date(), sizeBytes: bytes },
create: {
userId: user.id,
lastSuccessAt: new Date(),
sizeBytes: bytes,
},
});
} catch (err) {
ledgerWriteFailures++;
const message = (err as Error).message ?? "unknown";
evt?.addWarning(
`offhost-backup ledger write failed for ${user.id}: ${message.slice(0, 200)}`,
);
}
// two are hours apart and the row is meant to say when this account was
// reached.
const at = new Date();
try {
await prisma.offhostBackupState.upsert({
where: { userId: user.id },
update:
objectBytes === null
? { lastAttemptAt: at }
: {
lastAttemptAt: at,
lastSuccessAt: at,
sizeBytes: BigInt(objectBytes),
},
create:
objectBytes === null
? { userId: user.id, lastAttemptAt: at }
: {
userId: user.id,
lastAttemptAt: at,
lastSuccessAt: at,
sizeBytes: BigInt(objectBytes),
},
});
} catch (err) {
ledgerWriteFailures++;
const message = (err as Error).message ?? "unknown";
evt?.addWarning(
`offhost-backup ledger write failed for ${user.id}: ${message.slice(0, 200)}`,
);
}
}
if (ledgerWriteFailures > 0) {
+6
View File
@@ -29,6 +29,12 @@ export interface BackupRow {
export interface OffhostAccountRow {
userId: string;
username: string;
/**
* ISO instant a run last walked this account, or null when none has. Null
* is what the whole cohort reads until the first run after the upgrade that
* adds the ledger, and the card says so rather than claiming `never`.
*/
lastAttemptAt: string | null;
/** ISO instant the newest object landed, or null when there is none. */
lastSuccessAt: string | null;
/** Size of that object in bytes, or null when there is none. */