Files
HealthLog/src/lib/jobs/database-backup-queue.ts
T
Marc-André Bombeck 6d3a1582fa feat(backup): take a nightly whole-database dump with a manifest
The per-account copies roll one account back on the same server; nothing
the app made could rebuild a lost one. A scheduled job now runs pg_dump
over the whole database (every schema, pg-boss included) into
DB_BACKUP_DIR as healthlog-<UTC time>-daily.dump, with a manifest beside
it: app version and build, newest migration, Postgres version, size and
SHA-256, exact row counts of every table, and the fingerprints of the
keys the database needs.

The counts read the same exported snapshot pg_dump reads, so a dump
taken while the app writes still matches its manifest. The archive is
written as .partial, listed back with pg_restore --list, and renamed
after its manifest; a failed run leaves nothing named like a good dump.
Retention removes only files this job wrote, oldest first, and never
the newest good one (DB_BACKUP_KEEP, default 7; two pre-migration dumps).

With the off-host bucket configured, each dump and its manifest go up
under database/ in the envelope the account copies use, streamed. Every
run lands in the new database_backup_runs ledger (migration 0486), and
getDatabaseBackupHealth() answers the last good dump, the last failure,
whether the directory is writable and whether off-host is configured.
The boot readiness summary says where dumps go, or that none can be kept.

The dump, naming, retention and restore rules live in one plain ESM
module so the image's own scripts can share them without tsx.
2026-10-25 12:56:33 +01:00

41 lines
1.7 KiB
TypeScript

/**
* The queue facts of the nightly whole-database dump: its name, its schedule
* and its send options. Apart from the job so the worker registry can read
* them without loading it (`job-registry-graph.test.ts`).
*/
import { BACKUP_HEARTBEAT_SECONDS } from "@/lib/jobs/data-backup-policy";
import { resolveBackupSchedule } from "@/lib/database-backup/dump-core.mjs";
export const DATABASE_BACKUP_QUEUE = "database-backup";
/**
* `DB_BACKUP_SCHEDULE`, read once when the worker loads its registry, in the
* zone every cron runs in (`createAndSchedule`, Europe/Berlin). Default 01:30,
* an hour before the off-host account copies so the two uploads do not meet.
* A value that is not a five-field cron keeps the default, and the boot
* readiness summary says so.
*/
export const DATABASE_BACKUP_CRON = resolveBackupSchedule(process.env).cron;
/**
* Four hours. `pg_dump` of a large instance takes minutes, the upload of the
* dump about as long as the dump; the window is there to be impossible to hit
* for a working run. The handler kills `pg_dump` at three quarters of it
* (`jobDeadline`), so a hung dump ends as a failed run with a reason rather
* than an expired job.
*/
export const DATABASE_BACKUP_EXPIRE_SECONDS = 4 * 60 * 60;
/**
* What the nightly schedule sends with: the expiry, the backups' heartbeat,
* and one retry. A dump that failed for a passing reason (a lock it waited
* out, a bucket that answered 503) gets a second attempt; one that fails
* again waits for the next night, and the failure stays on the admin page.
*/
export const DATABASE_BACKUP_SEND_OPTIONS = {
expireInSeconds: DATABASE_BACKUP_EXPIRE_SECONDS,
heartbeatSeconds: BACKUP_HEARTBEAT_SECONDS,
retryLimit: 1,
retryDelay: 15 * 60,
} as const;