From 4f6596e8a2a5d33033978d33504217023a685608 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 17:00:05 +0200 Subject: [PATCH] =?UTF-8?q?#288:=20reset=20schema=20before=20pg=5Frestore?= =?UTF-8?q?=20=E2=80=94=20partitioned=20tables=20broke=20--clean?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #224 read_events is partitioned; the dump carries per-partition primary keys as own entries, and pg_restore --clean emitted DROP CONSTRAINT against inherited constraints, which PostgreSQL refuses. The restore then reported FAILED although the content was restored. Dropping and recreating the public schema first makes every --clean drop a no-op and the restore faithful: objects created after the backup no longer survive. Verified in the isolated environment of #220 (set 20260731-132200, exit 0, readyz green). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8 --- apps/backup/src/pg.test.ts | 40 ++++++++++++++++++++++++++++++ apps/backup/src/pg.ts | 34 ++++++++++++++++++------- docs/architecture/operations.md | 2 +- docs/operations/restore-runbook.md | 3 ++- 4 files changed, 68 insertions(+), 11 deletions(-) create mode 100644 apps/backup/src/pg.test.ts diff --git a/apps/backup/src/pg.test.ts b/apps/backup/src/pg.test.ts new file mode 100644 index 0000000..1a2aee9 --- /dev/null +++ b/apps/backup/src/pg.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { pgEnvFromUrl, pgRestoreArgs, schemaResetArgs, schemaResetSql } from './pg.js'; + +describe('pgEnvFromUrl', () => { + it('maps the URL onto libpq env vars, never argv', () => { + expect(pgEnvFromUrl('postgresql://user:p%40ss@db:5433/dorfteich')).toEqual({ + PGHOST: 'db', + PGPORT: '5433', + PGUSER: 'user', + PGPASSWORD: 'p@ss', + PGDATABASE: 'dorfteich', + }); + }); +}); + +describe('restore command plan (issue #288)', () => { + it('resets the schema first — --clean alone cannot drop inherited partition PKs', () => { + expect(schemaResetSql).toBe('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;'); + expect(schemaResetArgs('dorfteich')).toEqual([ + '--dbname', + 'dorfteich', + '--set', + 'ON_ERROR_STOP=1', + '--command', + schemaResetSql, + ]); + }); + + it('keeps --clean --if-exists as no-op belt and braces on the empty schema', () => { + expect(pgRestoreArgs('dorfteich', '/backups/x.dump')).toEqual([ + '--clean', + '--if-exists', + '--no-owner', + '--dbname', + 'dorfteich', + '/backups/x.dump', + ]); + }); +}); diff --git a/apps/backup/src/pg.ts b/apps/backup/src/pg.ts index 1ec49c7..cc50422 100644 --- a/apps/backup/src/pg.ts +++ b/apps/backup/src/pg.ts @@ -39,16 +39,32 @@ export async function pgDump(databaseUrl: string, outFile: string): Promise { const database = pgEnvFromUrl(databaseUrl).PGDATABASE ?? ''; - await runPgTool( - 'pg_restore', - ['--clean', '--if-exists', '--no-owner', '--dbname', database, dumpFile], - databaseUrl, - ); + await runPgTool('psql', schemaResetArgs(database), databaseUrl); + await runPgTool('pg_restore', pgRestoreArgs(database, dumpFile), databaseUrl); } diff --git a/docs/architecture/operations.md b/docs/architecture/operations.md index b84a217..086296a 100644 --- a/docs/architecture/operations.md +++ b/docs/architecture/operations.md @@ -79,7 +79,7 @@ monitoring, structured logs, backup alerting — no dedicated metrics stack. - **Restore runbook** (also the Prod-relocation procedure) — automated by `deploy/backup/restore.sh `, run from the stage directory: 1. stop the app services (`web`, `api`, `collab`; the db stays up), - 2. restore DB: `pg_restore --clean --if-exists` into the `db` container, + 2. restore DB: schema reset + `pg_restore` into the `db` container (#288), 3. restore volume: unpack the matching uploads/plugins archive, 4. `docker compose up -d`, verify `/readyz`, spot-check a page + a file. - **Drills** (issue #87): monthly automated restore of the latest backup diff --git a/docs/operations/restore-runbook.md b/docs/operations/restore-runbook.md index f785117..b761902 100644 --- a/docs/operations/restore-runbook.md +++ b/docs/operations/restore-runbook.md @@ -17,7 +17,8 @@ On the stage host, from the stage directory (`/srv/DOCKER/dorfteich-/`): id in `status.json` → `lastSuccess.backupId`. 2. **Run the automated runbook:** `./restore.sh ` (`deploy/backup/restore.sh`). It stops `web`/`api`/`collab` (the db stays - up), replays the dump with `pg_restore --clean --if-exists` and unpacks + up), resets the `public` schema and replays the dump with `pg_restore` + (#288 — objects created after the backup do not survive) and unpacks the volume archive through the backup sidecar image, starts the stack, and polls `/readyz`. 3. **Verify:** `/readyz` fully green, spot-check one page and one uploaded