|
| 1 | +--- |
| 2 | +doc_type: runbook |
| 3 | +status: active |
| 4 | +date: 2026-06-21 |
| 5 | +owner: acumenus |
| 6 | +module: infrastructure |
| 7 | +lineage_anchor: false |
| 8 | +supersedes: [] |
| 9 | +superseded_by: null |
| 10 | +related_code: |
| 11 | + - scripts/pg-host-logical-backup.sh |
| 12 | + - scripts/pg-host-basebackup.sh |
| 13 | + - scripts/db-restore.sh |
| 14 | + - docs/compliance/disaster-recovery-plan.md |
| 15 | +--- |
| 16 | + |
| 17 | +# PostgreSQL Backup & Restore Runbook (DR drill verified 2026-06-21) |
| 18 | + |
| 19 | +This runbook records a **non-destructive disaster-recovery drill** run against the |
| 20 | +host PostgreSQL 17 instance on 2026-06-21 and the verified restore procedures it |
| 21 | +produced. It supplements `docs/compliance/disaster-recovery-plan.md` and corrects |
| 22 | +several stale facts in it (see [Corrections](#corrections-to-the-dr-plan)). |
| 23 | + |
| 24 | +The drill touched **no production data**: every restore went into a throwaway |
| 25 | +`parthenon_dr_drill` database that was dropped afterward. |
| 26 | + |
| 27 | +## Backup architecture (as actually scheduled in cron) |
| 28 | + |
| 29 | +| Tier | Script | Cadence (cron) | Scope | Destination | |
| 30 | +|---|---|---|---|---| |
| 31 | +| **Physical basebackup** | `pg-host-basebackup.sh` | **Daily 02:23** | Full cluster + WAL | `/mnt/md0/postgres-backups/` | |
| 32 | +| **Logical (per-schema)** | `pg-host-logical-backup.sh` | **3×/day 06:17, 14:17, 22:17** | `app` schema only (`PG_LOGICAL_SCHEMAS`) | `/mnt/md0/postgres-backups/logical/` | |
| 33 | +| **WAL retention** | `pg-wal-retention.sh --purge` | Daily 06:30 | WAL segment pruning | — | |
| 34 | +| **Prune** | `pg-host-prune-backups.sh` | Daily 06:00 | Retention enforcement | — | |
| 35 | + |
| 36 | +The legacy `scripts/db-backup.sh` / `scripts/db-restore.sh` pair targets the |
| 37 | +**Docker** Postgres and is not the production path; `db-restore.sh` restores |
| 38 | +**over** the live `parthenon` database and must never be pointed at production |
| 39 | +during a drill. |
| 40 | + |
| 41 | +## Two recovery scenarios |
| 42 | + |
| 43 | +### A. Full-instance / full-corruption DR — use the physical basebackup |
| 44 | +The physical basebackup is the authoritative full-cluster recovery path: it |
| 45 | +contains every schema (`app`, `vocab`, `omop`, all CDM sources), all extensions, |
| 46 | +roles, and WAL for point-in-time recovery. This is the only tier that restores a |
| 47 | +**fresh** instance with no prerequisites. (Size: ~1.27 TB; restore is an |
| 48 | +infrastructure operation, not drilled inline here.) |
| 49 | + |
| 50 | +### B. Schema-level recovery of `app` — use the logical dump INTO an existing cluster |
| 51 | +`latest-app.sql.gz` (~5.2 GB gzipped) restores the `app` schema into a cluster |
| 52 | +that **already has** the `vocab` schema and the required extensions present |
| 53 | +(i.e., over the existing `parthenon` DB, or a basebackup-restored instance). It |
| 54 | +is **not** standalone-restorable into an empty database — see the drill findings. |
| 55 | + |
| 56 | +## Drill findings (2026-06-21) |
| 57 | + |
| 58 | +Restoring `latest-app.sql.gz` into an **empty** database failed three times, each |
| 59 | +exposing an undocumented prerequisite: |
| 60 | + |
| 61 | +1. `ERROR: type "public.geometry" does not exist` — `app.gis_admin_boundaries.geom` |
| 62 | + needs **PostGIS**, which a `--schema=app` dump does not create. |
| 63 | +2. `ERROR: type "public.vector" does not exist` — `*.embedding` columns need |
| 64 | + **pgvector**, likewise not created by the schema dump. |
| 65 | +3. `ERROR: relation "vocab.concept" does not exist` — the `app` schema has |
| 66 | + **cross-schema foreign keys into `vocab`**, which is not part of the `app` |
| 67 | + logical backup (vocab lives in the basebackup). |
| 68 | + |
| 69 | +**Fix applied:** `pg-host-logical-backup.sh` now prepends |
| 70 | +`CREATE EXTENSION IF NOT EXISTS postgis | vector | pg_trgm` to every dump, so the |
| 71 | +extension class of failure (1 and 2) is gone for future backups. The `vocab` |
| 72 | +cross-schema dependency (3) is **by design** — the logical `app` dump is a |
| 73 | +schema-restore tool for an existing cluster, not a standalone DR artifact. |
| 74 | + |
| 75 | +**Data integrity verified:** with extensions present and a real `vocab` schema, |
| 76 | +`app.analysis_executions` (173 rows) and `app.cohort_definitions` (164 rows) |
| 77 | +restored **byte-exact to production**, and the dump was confirmed to contain the |
| 78 | +full `COPY app.users / app.studies / app.sources` data blocks. The backup is |
| 79 | +complete and faithful. |
| 80 | + |
| 81 | +## Verified restore procedure (schema-level, into a throwaway for drills) |
| 82 | + |
| 83 | +```bash |
| 84 | +# 1. Create an isolated target — NEVER restore into the live parthenon DB during a drill |
| 85 | +psql "host=127.0.0.1 port=5432 dbname=postgres user=claude_dev" \ |
| 86 | + -c "CREATE DATABASE parthenon_dr_drill OWNER claude_dev;" |
| 87 | + |
| 88 | +# 2. Provide the prerequisites the app schema depends on |
| 89 | +psql "host=127.0.0.1 port=5432 dbname=parthenon_dr_drill user=claude_dev" -c " |
| 90 | + CREATE EXTENSION IF NOT EXISTS postgis; |
| 91 | + CREATE EXTENSION IF NOT EXISTS vector; |
| 92 | + CREATE EXTENSION IF NOT EXISTS pg_trgm;" |
| 93 | +# For a FULL standalone restore you must also restore the vocab schema first |
| 94 | +# (from the basebackup); for an in-cluster restore vocab is already present. |
| 95 | + |
| 96 | +# 3. Restore |
| 97 | +zcat /mnt/md0/postgres-backups/logical/latest-app.sql.gz \ |
| 98 | + | psql "host=127.0.0.1 port=5432 dbname=parthenon_dr_drill user=claude_dev" -v ON_ERROR_STOP=1 |
| 99 | + |
| 100 | +# 4. Verify row counts against production, then drop the throwaway |
| 101 | +psql "host=127.0.0.1 port=5432 dbname=postgres user=claude_dev" \ |
| 102 | + -c "DROP DATABASE IF EXISTS parthenon_dr_drill WITH (FORCE);" |
| 103 | +``` |
| 104 | + |
| 105 | +A clean run (post-fix, full vocab present) completes with `ON_ERROR_STOP=1` and |
| 106 | +matching row counts. A degraded environment (stub vocab) will partially restore — |
| 107 | +that is expected and not a backup defect. |
| 108 | + |
| 109 | +## Corrections to the DR plan |
| 110 | + |
| 111 | +`docs/compliance/disaster-recovery-plan.md` was written before this drill and |
| 112 | +states: basebackup "Weekly", logical "On demand", destination `backups/`. The |
| 113 | +**actual** cadence is basebackup daily and logical 3×/day, both writing to |
| 114 | +`/mnt/md0/postgres-backups/`. Its selective-schema restore section also omitted |
| 115 | +the extension + vocab prerequisites. Those entries are corrected in that file with |
| 116 | +a pointer here. |
| 117 | + |
| 118 | +## Open items (not addressed by this drill) |
| 119 | + |
| 120 | +- A scheduled **physical basebackup restore** rehearsal (Scenario A) into a spare |
| 121 | + instance to validate full-instance RTO end-to-end. |
| 122 | +- A **backup-failure alert**: cron success is currently observable only via the |
| 123 | + `/tmp/parthenon-pg-*.log` files; no Prometheus alert fires if a backup stops |
| 124 | + running (see the observability gap noted in the Gate B5 review). |
0 commit comments