|
| 1 | +# Runbook — Backup & Restore / Disaster Recovery |
| 2 | + |
| 3 | +PCMI keeps all durable state in **PostgreSQL** (memory entries and their |
| 4 | +versions, links, sessions, distilled knowledge, entity graph tables, tenants, |
| 5 | +API keys, audit log). Redis holds only the transient event stream and rate-limit |
| 6 | +counters — it is **not** a source of truth and does not need backing up. |
| 7 | + |
| 8 | +This runbook covers logical backups with `pg_dump` and restore with |
| 9 | +`pg_restore`. For very large or low-RPO deployments, prefer your platform's |
| 10 | +**physical** backup / point-in-time recovery (PITR) — see *Beyond logical dumps*. |
| 11 | + |
| 12 | +## Tooling |
| 13 | + |
| 14 | +Two wrappers under `scripts/backup/`: |
| 15 | + |
| 16 | +| Script | Purpose | |
| 17 | +|---|---| |
| 18 | +| `pcmi_backup.sh [OUT_DIR]` | `pg_dump` → compressed, timestamped custom-format archive. Prints the archive path. | |
| 19 | +| `pcmi_restore.sh <archive>` | `pg_restore` into `DATABASE_URL`. **Refuses a non-empty target unless `FORCE=1`.** | |
| 20 | + |
| 21 | +Both read the target from `DATABASE_URL`. Makefile shortcuts: |
| 22 | + |
| 23 | +```bash |
| 24 | +make backup # → ./backups/pcmi-<ts>.dump |
| 25 | +make backup BACKUP_DIR=/mnt/backups |
| 26 | +make restore BACKUP_FILE=./backups/pcmi-20260101T000000Z.dump |
| 27 | +make restore BACKUP_FILE=... FORCE=1 # overwrite a populated DB |
| 28 | +``` |
| 29 | + |
| 30 | +> **Version note:** run `pg_dump` with a client version **equal to or newer than |
| 31 | +> the server**, and restore with a client **matching the target server major |
| 32 | +> version**. A newer client dumping for an older server can emit settings the |
| 33 | +> older server rejects (e.g. `transaction_timeout`, added in PG 17). PCMI targets |
| 34 | +> **PostgreSQL 16**; use `postgresql-client-16`. The dockerized DB already ships |
| 35 | +> matching tools — `docker exec pcmi-postgres pg_dump …` always version-matches. |
| 36 | +
|
| 37 | +## Take a backup |
| 38 | + |
| 39 | +```bash |
| 40 | +export DATABASE_URL='postgres://pcmi:pcmi@db-host:5432/pcmi?sslmode=disable' |
| 41 | +./scripts/backup/pcmi_backup.sh /mnt/backups |
| 42 | +# → /mnt/backups/pcmi-20260724T093000Z.dump |
| 43 | +``` |
| 44 | + |
| 45 | +Store the archive off-box (object storage, another region). Automate with a cron |
| 46 | +/ CronJob calling the same script; keep N daily + M weekly copies. |
| 47 | + |
| 48 | +## Restore |
| 49 | + |
| 50 | +Into an **empty** database (fresh DR target): |
| 51 | + |
| 52 | +```bash |
| 53 | +export DATABASE_URL='postgres://pcmi:pcmi@dr-host:5432/pcmi?sslmode=disable' |
| 54 | +createdb -h dr-host -U pcmi pcmi # if the database does not exist yet |
| 55 | +./scripts/backup/pcmi_restore.sh /mnt/backups/pcmi-20260724T093000Z.dump |
| 56 | +``` |
| 57 | + |
| 58 | +Over an **existing** database (accepts data loss — the current contents are |
| 59 | +dropped and replaced): |
| 60 | + |
| 61 | +```bash |
| 62 | +FORCE=1 ./scripts/backup/pcmi_restore.sh /mnt/backups/pcmi-20260724T093000Z.dump |
| 63 | +``` |
| 64 | + |
| 65 | +The restore uses `pg_restore --clean --if-exists --exit-on-error`, so a partial |
| 66 | +or corrupt archive fails loudly instead of leaving a half-restored database. |
| 67 | + |
| 68 | +## Verify a backup (do this regularly — an untested backup is not a backup) |
| 69 | + |
| 70 | +Restore into a throwaway database and check row counts: |
| 71 | + |
| 72 | +```bash |
| 73 | +createdb -h localhost -U pcmi pcmi_verify |
| 74 | +DATABASE_URL='postgres://pcmi:pcmi@localhost:5432/pcmi_verify?sslmode=disable' \ |
| 75 | + ./scripts/backup/pcmi_restore.sh <archive> |
| 76 | +psql "$DATABASE_URL" -c "SELECT count(*) FROM memory_entries;" |
| 77 | +dropdb -h localhost -U pcmi pcmi_verify |
| 78 | +``` |
| 79 | + |
| 80 | +CI runs the full **seed → backup → wipe → restore → assert** cycle on every PR |
| 81 | +(`scripts/backup/ci_backup_restore_test.sh`, the `backup-restore` job), so a |
| 82 | +regression that breaks restore fails the build. |
| 83 | + |
| 84 | +## Disaster recovery — order of operations |
| 85 | + |
| 86 | +1. Provision a PostgreSQL 16 instance (with the `vector`, `ltree`, `pg_trgm` |
| 87 | + extensions available; the dump recreates them). |
| 88 | +2. Restore the most recent verified archive (see *Restore*, empty target). |
| 89 | +3. Point `DATABASE_URL` (and `DATABASE_READ_URL`, if used) at the new instance. |
| 90 | +4. Start API + worker. Redis can be empty — the worker rebuilds its consumer |
| 91 | + group; embeddings already persisted are restored with the dump. |
| 92 | +5. Smoke: `curl $API/v1/ready` → `database_ok:true`, then a `POST /v1/retrieve`. |
| 93 | + |
| 94 | +**RPO/RTO:** with periodic logical dumps, RPO = your backup interval and RTO = |
| 95 | +restore time (minutes for small/medium corpora). For tighter objectives use PITR. |
| 96 | + |
| 97 | +## Beyond logical dumps (PITR) |
| 98 | + |
| 99 | +For large corpora or near-zero RPO, use physical backups + WAL archiving |
| 100 | +(`pg_basebackup` + `archive_command`, or a managed service's continuous backup — |
| 101 | +RDS/Cloud SQL/Crunchy). PITR replays WAL to any point in time; logical dumps |
| 102 | +remain useful for portable, cross-version, per-database exports. |
0 commit comments