fix(db-backup): take the pre-migration dump when pending migrations cannot be counted

Any error counting pending migrations (a connect or statement timeout, a
database still starting up) exited the step with "no dump before migrating",
and the migration that ran a second later went ahead without its way back.
An unknown count now takes the dump; a database that cannot be reached at
all then fails the dump, and the boot stops naming the switch that skips it.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 17:22:33 +01:00
1 parent 6f83e7e685
commit 756885eec0
2 files changed
+87 -8

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
/**
* The pre-migration dump decides whether to take a dump from the count of
* pending migrations. A count that fails (a connect or statement timeout, a
* database still starting up) used to read as "nothing pending": the step
* exited 0 with no dump, and the migration a second later ran without its
* way back. "Cannot tell" now takes the dump.
*
* The database is a stand-in: `createDatabaseDump` is a spy, `connect`
* answers the two facts the step reads (an applied migration, some tables).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const pendingMigrations = vi.fn<() => Promise<string[]>>();
const createDatabaseDump = vi.fn(async () => ({
dumpPath: "/b/healthlog-20261025T013000Z-pre-migration.dump",
manifest: { dump: { bytes: 1024 }, rowCounts: { "public.users": 1 } },
}));
vi.mock("../pending-migrations.mjs", () => ({
pendingMigrations: () => pendingMigrations(),
}));
vi.mock("../../src/lib/database-backup/dump-core.mjs", async (original) => ({
...(await original<Record<string, unknown>>()),
connect: async () => ({
query: async () => ({ rows: [] }),
end: async () => {},
}),
latestMigration: async () => "0488_account_deletion_grace",
listUserTables: async () => ["public.users"],
createDatabaseDump: () => createDatabaseDump(),
applyRetention: async () => [],
}));
import { preMigrationDump } from "../db-dump.mjs";
const env = {
DATABASE_URL: "postgresql://u:p@db:5432/healthlog",
DB_BACKUP_DIR: "/b",
};
const kept = () =>
({ writable: true, persistent: true, reason: "volume" }) as const;
beforeEach(() => {
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
createDatabaseDump.mockClear();
});
afterEach(() => vi.restoreAllMocks());
describe("the dump before migrating", () => {
it("takes the dump when the pending migrations cannot be counted", async () => {
pendingMigrations.mockRejectedValue(
new Error("the database system is starting up"),
);
expect(await preMigrationDump(env, { directoryStatus: kept })).toBe(0);
expect(createDatabaseDump).toHaveBeenCalledTimes(1);
});
it("takes none when nothing is pending", async () => {
pendingMigrations.mockResolvedValue([]);
expect(await preMigrationDump(env, { directoryStatus: kept })).toBe(0);
expect(createDatabaseDump).not.toHaveBeenCalled();
});
it("takes one before pending migrations", async () => {
pendingMigrations.mockResolvedValue(["0489_database_backup_run_key"]);
expect(await preMigrationDump(env, { directoryStatus: kept })).toBe(0);
expect(createDatabaseDump).toHaveBeenCalledTimes(1);
});
});
+17 -8
View File
@@ -12,7 +12,9 @@
* newest two, neither older than the oldest nightly dump except the newest.
*
* Exit 0: the dump was written, or there was nothing to do (no pending
* migration, a new database, `DB_BACKUP_BEFORE_MIGRATE=false`), or there is no
* migration, a new database, `DB_BACKUP_BEFORE_MIGRATE=false`; a count of
* pending migrations that fails is not "none", and the dump is taken), or
* there is no
* directory to put it in that survives the container (none writable, or only
* the image's own directory without the volume, which the update that brings
* these migrations is about to throw away), which is said as a warning and is
@@ -74,6 +76,11 @@ export async function preMigrationDump(env = process.env, options = {}) {
const databaseUrl = env.DATABASE_URL;
if (!databaseUrl) return 0;
// "Cannot tell" is not "nothing to do": a count that timed out on a
// database still starting up was read as no pending migration, and the
// migration a second later ran without its way back. Unknown, the dump is
// taken.
/** @type {string[] | null} */
let pending;
try {
pending = await pendingMigrations({
@@ -82,11 +89,15 @@ export async function preMigrationDump(env = process.env, options = {}) {
});
} catch (error) {
warn(
`could not count pending migrations (${error instanceof Error ? error.message : String(error)}); no dump before migrating.`,
`could not count pending migrations (${error instanceof Error ? error.message : String(error)}); taking the dump before migrating anyway.`,
);
return 0;
pending = null;
}
if (pending.length === 0) return 0;
if (pending !== null && pending.length === 0) return 0;
const migrations =
pending === null
? "any pending migrations"
: `${pending.length} pending migration(s)`;
// A database no migration has touched holds nothing to lose.
const client = await connect(databaseUrl);
@@ -115,15 +126,13 @@ export async function preMigrationDump(env = process.env, options = {}) {
? "is in memory (tmpfs) and would not keep a dump past a restart"
: "is inside the container, not on a mounted volume, and would not keep a dump past this update";
warn(
`${dir} ${why}, so no dump is taken before the ${pending.length} pending migration(s). ` +
`${dir} ${why}, so no dump is taken before ${pending === null ? migrations : `the ${migrations}`}. ` +
"Mount the backups volume (docker-compose.yml) to keep a way back; continuing.",
);
return 0;
}
log(
`taking a database dump before ${pending.length} pending migration(s) into ${dir}...`,
);
log(`taking a database dump before ${migrations} into ${dir}...`);
const started = Date.now();
try {
const { dumpPath, manifest } = await createDatabaseDump({