From e64435f6a5a625496e123c746b0127e378b01b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 10:07:28 +0200 Subject: [PATCH] fix(backups): size the off-host ledger column for the object the uploader allows The column was INTEGER. A single object may occupy 8 MiB across 10 000 multipart parts, which is the 80 GB the uploader explicitly permits, and Postgres INTEGER stops at 2.1 GB. An account between the two uploads fine and then cannot be recorded, so the biggest account on the host is the one the console would have to report on and could not. BIGINT in the table, BigInt in the model, Number() where the route builds the wire row so the payload type stays a plain number. The migration is edited in place rather than followed by a second one: the table has not shipped, and a column widened before its first release is one column, not a history. The comparison with the in-database backup does not carry over, for the record: that size is octet_length over a bytea Postgres caps at 1 GB, while this object never touches the database at all. --- .../0336_offhost_backup_ledger/migration.sql | 5 ++++- prisma/schema.prisma | 8 ++++++-- src/app/api/admin/backups/__tests__/route.test.ts | 11 +++++++---- src/app/api/admin/backups/route.ts | 5 ++++- src/lib/jobs/__tests__/offhost-backup.test.ts | 6 ++++-- src/lib/jobs/offhost-backup.ts | 2 +- tests/integration/offhost-backup-ledger.test.ts | 2 +- 7 files changed, 27 insertions(+), 12 deletions(-) diff --git a/prisma/migrations/0336_offhost_backup_ledger/migration.sql b/prisma/migrations/0336_offhost_backup_ledger/migration.sql index 9239d5eaf..dd0ad6f39 100644 --- a/prisma/migrations/0336_offhost_backup_ledger/migration.sql +++ b/prisma/migrations/0336_offhost_backup_ledger/migration.sql @@ -5,7 +5,10 @@ CREATE TABLE "offhost_backup_state" ( "user_id" TEXT NOT NULL, "last_success_at" TIMESTAMP(3) NOT NULL, - "size_bytes" INTEGER NOT NULL, + -- BIGINT, not INTEGER: a single object may occupy 8 MiB x 10 000 multipart + -- parts, so the uploader permits 80 GB and a 32-bit column would refuse + -- every account past 2.1 GB. + "size_bytes" BIGINT NOT NULL, CONSTRAINT "offhost_backup_state_pkey" PRIMARY KEY ("user_id") ); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5d38625d1..540af229c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4958,8 +4958,12 @@ model OffhostBackupState { lastSuccessAt DateTime @map("last_success_at") /// Size of that object in bytes — the same number the run reports as its - /// largest, per account. Consumer: the off-host card's size column. - sizeBytes Int @map("size_bytes") + /// largest, per account. `BigInt` because the uploader permits an object of + /// up to 8 MiB × 10 000 parts = 80 GB and a 32-bit column stops at 2.1 GB: + /// the biggest account on the host, the one an operator cares most about, + /// is exactly the one whose row would refuse to be written. + /// Consumer: the off-host card's size column. + sizeBytes BigInt @map("size_bytes") user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/app/api/admin/backups/__tests__/route.test.ts b/src/app/api/admin/backups/__tests__/route.test.ts index e96b759e4..adbdd45e4 100644 --- a/src/app/api/admin/backups/__tests__/route.test.ts +++ b/src/app/api/admin/backups/__tests__/route.test.ts @@ -64,7 +64,9 @@ describe("GET /api/admin/backups — off-host freshness", () => { username: "account-one", offhostBackupState: { lastSuccessAt: new Date(now - 3 * HOUR), - sizeBytes: 4096, + // The column is BigInt — an object may be 80 GB — and the wire type + // is a plain number. + sizeBytes: BigInt(4096), }, }, { @@ -72,7 +74,7 @@ describe("GET /api/admin/backups — off-host freshness", () => { username: "account-two", offhostBackupState: { lastSuccessAt: new Date(now - 30 * HOUR), - sizeBytes: 8192, + sizeBytes: BigInt(8192), }, }, { @@ -80,7 +82,7 @@ describe("GET /api/admin/backups — off-host freshness", () => { username: "account-three", offhostBackupState: { lastSuccessAt: new Date(now - 100 * HOUR), - sizeBytes: 128, + sizeBytes: BigInt(128), }, }, { id: "u4", username: "account-four", offhostBackupState: null }, @@ -99,6 +101,7 @@ describe("GET /api/admin/backups — off-host freshness", () => { ["account-four", "never"], ]); 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(); @@ -126,7 +129,7 @@ describe("GET /api/admin/backups — off-host freshness", () => { username: "account-one", offhostBackupState: { lastSuccessAt: new Date(), - sizeBytes: 1, + sizeBytes: BigInt(1), }, }, ]); diff --git a/src/app/api/admin/backups/route.ts b/src/app/api/admin/backups/route.ts index b1885683f..bebfffa5b 100644 --- a/src/app/api/admin/backups/route.ts +++ b/src/app/api/admin/backups/route.ts @@ -112,7 +112,10 @@ export const GET = apiHandler(async () => { userId: account.id, username: account.username, lastSuccessAt: state?.lastSuccessAt.toISOString() ?? null, - sizeBytes: state?.sizeBytes ?? 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, ageHours: verdict.ageHours, freshness: verdict.freshness, }; diff --git a/src/lib/jobs/__tests__/offhost-backup.test.ts b/src/lib/jobs/__tests__/offhost-backup.test.ts index 118b0f1ea..508e28bab 100644 --- a/src/lib/jobs/__tests__/offhost-backup.test.ts +++ b/src/lib/jobs/__tests__/offhost-backup.test.ts @@ -240,9 +240,11 @@ describe("runOffhostBackup", () => { ); expect(ledgered).toEqual(["u1", "u2"]); const first = prisma.offhostBackupState.upsert.mock.calls[0]?.[0] as { - create: { sizeBytes: number; lastSuccessAt: Date }; + create: { sizeBytes: bigint; lastSuccessAt: Date }; }; - expect(first.create.sizeBytes).toBe(ct.byteLength); + // BigInt, because the uploader permits an 80 GB object and a 32-bit + // column would refuse the biggest account on the host. + expect(first.create.sizeBytes).toBe(BigInt(ct.byteLength)); expect(first.create.lastSuccessAt).toBeInstanceOf(Date); }); diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index 863c22c65..e46e5cc96 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -623,7 +623,7 @@ export async function runOffhostBackup( // two are hours apart and the row is meant to say when this account's // object landed. if (objectBytes !== null) { - const bytes = objectBytes; + const bytes = BigInt(objectBytes); try { await prisma.offhostBackupState.upsert({ where: { userId: user.id }, diff --git a/tests/integration/offhost-backup-ledger.test.ts b/tests/integration/offhost-backup-ledger.test.ts index 2c8f8c186..d588eaf89 100644 --- a/tests/integration/offhost-backup-ledger.test.ts +++ b/tests/integration/offhost-backup-ledger.test.ts @@ -104,7 +104,7 @@ describe("off-host backup ledger (real Postgres)", () => { `${new Date().toISOString().slice(0, 10)}/user-${row.userId}.json.enc`, ); expect(object).toBeDefined(); - expect(row.sizeBytes).toBe(object?.byteLength); + expect(Number(row.sizeBytes)).toBe(object?.byteLength); expect( classifyOffhostBackup({