backup before migrating: once per set of pending changes, not on every restart of a crash-looping api

This commit is contained in:
iamdoubz committed 2026-10-21 11:43:24 -05:00
1 parent e2518b03d8
commit 22b3ef9a8c
4 files changed
+48 -16

No files matched your search

+10 -5
View File
@@ -24,19 +24,24 @@ export async function runMigrations(db: Db): Promise<void> {
}
/**
* This image has migrations the database hasn't run yet. False for an empty database (a fresh
* install: nothing to lose). Reads drizzle's own bookkeeping, `drizzle.__drizzle_migrations`.
* Migrations this image has and the database hasn't run yet: how many are applied, of how many.
* Null when none are, or for an empty database (a fresh install: nothing to lose). Reads drizzle's
* own bookkeeping, `drizzle.__drizzle_migrations`.
*/
export async function migrationsPending(db: Db, folder = MIGRATIONS_DIR): Promise<boolean> {
export async function migrationsPending(
db: Db,
folder = MIGRATIONS_DIR,
): Promise<{ applied: number; total: number } | null> {
const journal = JSON.parse(await readFile(join(folder, 'meta', '_journal.json'), 'utf8')) as {
entries: unknown[];
};
const t = await db.execute<{ t: string | null }>(
sql`select to_regclass('drizzle.__drizzle_migrations')::text as t`,
);
if (!t.rows[0]?.t) return false;
if (!t.rows[0]?.t) return null;
const r = await db.execute<{ n: number }>(
sql`select count(*)::int as n from drizzle.__drizzle_migrations`,
);
return r.rows[0]!.n < journal.entries.length;
const applied = r.rows[0]!.n;
return applied < journal.entries.length ? { applied, total: journal.entries.length } : null;
}
+9 -4
View File
@@ -84,12 +84,13 @@ watchWebhooks(db, live, app.log);
watchEvents(db, live, ami, app.log);
// A new version with database changes: back up first, so a bad update can be undone (08 §3.1).
if (await migrationsPending(db)) {
const pending = await migrationsPending(db);
if (pending) {
if (config.SKIP_BACKUP_BEFORE_MIGRATE)
app.log.warn('database changes pending; SKIP_BACKUP_BEFORE_MIGRATE is set, so no backup first');
else {
app.log.info('database changes pending: backing up first');
const backup = await backupBeforeMigrate(config, db).catch((err: unknown) => {
app.log.info(pending, 'database changes pending: backing up first');
const backup = await backupBeforeMigrate(config, db, pending).catch((err: unknown) => {
app.log.fatal(
{ err, dir: config.BACKUPS_DIR },
'backup before migrating failed, so the database was left as it is. Fix the cause ' +
@@ -98,7 +99,11 @@ if (await migrationsPending(db)) {
);
process.exit(1);
});
app.log.info({ backup: backup.name }, 'backup before migrating made');
// update.sh finds this line to restore from (the name, then the message).
app.log.info(
{ backup: backup.name },
backup.made ? 'backup before migrating made' : 'backup before migrating made earlier, kept',
);
}
}
await runMigrations(db);
+24 -3
View File
@@ -5,7 +5,7 @@
// Restore is scripts/restore.sh only.
import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdtemp, readdir, rename, rm, stat, writeFile, chmod } from 'node:fs/promises';
import { chmod, mkdtemp, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { promisify } from 'node:util';
@@ -160,10 +160,29 @@ export function runBackup(config: Config, db: Db): Promise<BackupDto> {
* backup has, without recordings (big, and no migration touches them). Prunes nothing, so it
* doesn't push a daily backup out; the daily job's pruning removes it in time. Reads as little of
* the old schema as it can: the time zone only, falling back to TZ.
*
* With `step` (the migrations about to run), once per step: a crash-looping api would otherwise
* back up on every restart until the disk fills. `.before-migrate.json` in the backups folder
* remembers the step and its backup; `made: false` means that one is reused.
*/
export async function backupBeforeMigrate(config: Config, db: Db): Promise<BackupDto> {
export async function backupBeforeMigrate(
config: Config,
db: Db,
step?: { applied: number; total: number },
): Promise<BackupDto & { made: boolean }> {
const dir = resolve(config.BACKUPS_DIR);
const marker = join(dir, '.before-migrate.json');
const key = step && `${step.applied}-${step.total}`;
if (key) {
const last = await readFile(marker, 'utf8').then(
(s) => JSON.parse(s) as { key: string; name: string },
() => null,
);
const kept = last?.key === key && (await listBackups(dir)).find((b) => b.name === last.name);
if (kept) return { ...kept, made: false };
}
const timezone = await getSetting(db, 'general.timezone').catch(() => config.TZ);
return createBackup(
const made = await createBackup(
{
databaseUrl: config.DATABASE_URL,
appSecret: config.APP_SECRET,
@@ -177,6 +196,8 @@ export async function backupBeforeMigrate(config: Config, db: Db): Promise<Backu
},
{ keep: Infinity },
);
if (key) await writeFile(marker, JSON.stringify({ key, name: made.name }));
return { ...made, made: true };
}
/** The daily backup is due: enabled, past `backup.time` here, and none made today yet. */
+5 -4
View File
@@ -29,10 +29,11 @@ describe.skipIf(!hasTestDb)('migrationsPending', () => {
await rm(newer, { recursive: true, force: true });
});
it('false when up to date, true when the image has more, false for an empty database', async () => {
expect(await migrationsPending(t.db)).toBe(false);
expect(await migrationsPending(t.db, newer)).toBe(true);
it('none when up to date, counts when the image has more, none for an empty database', async () => {
expect(await migrationsPending(t.db)).toBeNull();
const pending = await migrationsPending(t.db, newer);
expect(pending).toEqual({ applied: pending!.total - 1, total: pending!.total });
await t.db.execute(sql`drop schema drizzle cascade`);
expect(await migrationsPending(t.db, newer)).toBe(false);
expect(await migrationsPending(t.db, newer)).toBeNull();
});
});