Skip to content

Commit 652b381

Browse files
marco-spagnclaude
andcommitted
feat(ops): backup / restore / DR tooling with a CI zero-data-loss gate
PCMI keeps all durable state in PostgreSQL but shipped no backup strategy or tooling — an undefendable "production-grade" claim. Adds: - scripts/backup/pcmi_backup.sh — pg_dump → compressed, timestamped custom-format archive (prints the path). - scripts/backup/pcmi_restore.sh — pg_restore into DATABASE_URL; REFUSES a non-empty target unless FORCE=1, with --clean --if-exists --exit-on-error. - scripts/backup/ci_backup_restore_test.sh — seed → backup → wipe → restore → assert (tenant/table counts + a marker row survive; and that restore refuses to clobber without FORCE). - Makefile: backup, restore, backup-restore-test. - docs/runbooks/backup-restore.md — scheduling, restore, verification, DR order-of-operations, PITR guidance, and a pg client/server version note. - CI job `backup-restore` (pg16 service) runs the full cycle on every PR, so a regression that breaks restore fails the build. Verified locally: full round-trip with matching pg16 tools preserves every row across backup → schema wipe → restore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8d2f5da commit 652b381

7 files changed

Lines changed: 312 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
# integration-e2e → needs [go, golangci-lint] (skips if no OPENAI_API_KEY; no retry)
1212
# integration-graph → needs [go, golangci-lint] (complete cognitive graph matrix gate)
1313
# retrieval-eval → needs [go, golangci-lint] (skips if no OPENAI_API_KEY; gates recall@k/nDCG on eval/retrieval/thresholds.json)
14+
# backup-restore → needs [go, golangci-lint] (seed→backup→wipe→restore→assert zero data loss)
1415
# CodeQL: separate workflow (.github/workflows/codeql.yml)
1516
# Local parity: make ci-like-github → scripts/ci_like_github.sh (phases A–G)
1617
# Version for smoke: scripts/ci/resolve_version.sh (internal/version/version.go)
@@ -655,6 +656,38 @@ jobs:
655656
if: always() && steps.openai.outputs.enabled == 'true'
656657
run: docker compose down -v --remove-orphans
657658

659+
backup-restore:
660+
name: Backup / restore (zero data loss)
661+
runs-on: ubuntu-latest
662+
timeout-minutes: 15
663+
needs: [go, golangci-lint]
664+
services:
665+
postgres:
666+
image: pgvector/pgvector:pg16
667+
env:
668+
POSTGRES_DB: pcmi
669+
POSTGRES_USER: pcmi
670+
POSTGRES_PASSWORD: pcmi
671+
ports:
672+
- 5432:5432
673+
options: >-
674+
--health-cmd "pg_isready -U pcmi -d pcmi"
675+
--health-interval 5s
676+
--health-timeout 5s
677+
--health-retries 10
678+
steps:
679+
- uses: actions/checkout@v7
680+
681+
- name: Install postgresql-client
682+
run: sudo apt-get update && sudo apt-get install -y postgresql-client
683+
684+
- uses: ./.github/actions/pcmi-postgres-migrate
685+
686+
- name: Seed → backup → wipe → restore → assert
687+
env:
688+
DATABASE_URL: postgres://pcmi:pcmi@127.0.0.1:5432/pcmi?sslmode=disable
689+
run: bash scripts/backup/ci_backup_restore_test.sh
690+
658691
integration-graph:
659692
name: Integration graph (complete AGE matrix)
660693
runs-on: ubuntu-latest

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ the public API version exposed by `/v1/version` and the gRPC `Version` RPC.
1111

1212
### Added
1313

14+
- **Backup / restore / DR tooling** (`scripts/backup/`): `pcmi_backup.sh` wraps `pg_dump` into a compressed, timestamped custom-format archive; `pcmi_restore.sh` wraps `pg_restore` and **refuses to overwrite a populated database unless `FORCE=1`**. Makefile targets `backup`, `restore`, `backup-restore-test`. A `docs/runbooks/backup-restore.md` runbook covers scheduled backups, restore, backup verification, DR order-of-operations, and PITR guidance. A CI job (`backup-restore`) runs the full **seed → backup → wipe → restore → assert** cycle on every PR so a regression that breaks restore fails the build. Redis holds only transient state and is not backed up.
15+
1416
- **Entity extraction Phase A**: migration `022_extraction_profiles.sql`; `EXTRACTION_ENABLED` worker/API flag; tenant profiles (`GET/PUT/DELETE /v1/extraction-profiles/{id}`); LLM slot extraction into `metadata.pcmi_extract` (`GET/POST /v1/memories/extraction/{memory_id}`). See [cognitive-graph-entities.md](docs/cognitive-graph-entities.md).
1517

1618
- **Entity extraction Phase B**: migration `023_entity_graph.sql`; promoted slots become `:Entity` vertices with `:mentions` edges from `:Memory` when extraction validates and AGE is available; `GET /v1/graph/entities/memory`, `GET /v1/graph/entities/related` (by `kind`+`key` or shared entities via `memory_id`). See [cognitive-graph-entities.md](docs/cognitive-graph-entities.md).

Makefile

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
changelog-unreleased changelog-tag tag-release examples-smoke-structural examples-smoke \
77
helm-lint helm-template helm-package admin-list-keys bench quickstart graph-ui graph-ui-entities demo test-cognitive-graph test-cognitive-graph-matrix \
88
graph-realistic-generate graph-realistic-validate graph-realistic-smoke graph-realistic-audit graph-soc-loader-test demo_2 demo-cti-operational demo_soc demo_cti \
9-
eval-retrieval eval-retrieval-validate
9+
eval-retrieval eval-retrieval-validate backup restore backup-restore-test
1010

1111
GOLANGCI_LINT_VERSION ?= v2.12.2
1212
GRPC_HOST ?= localhost:50051
@@ -127,6 +127,22 @@ smoke-dedup:
127127
admin-list-keys:
128128
DATABASE_URL=$(DATABASE_URL) go run ./cmd/pcmi-admin list
129129

130+
# ── Backup / restore / DR (docs/runbooks/backup-restore.md) ───────────────────
131+
# make backup → ./backups/pcmi-<ts>.dump
132+
# make backup BACKUP_DIR=/mnt/bk
133+
# make restore BACKUP_FILE=./backups/pcmi-<ts>.dump [FORCE=1]
134+
BACKUP_DIR ?= ./backups
135+
backup:
136+
@DATABASE_URL="$(DATABASE_URL)" bash scripts/backup/pcmi_backup.sh "$(BACKUP_DIR)"
137+
138+
restore:
139+
@test -n "$(BACKUP_FILE)" || (echo "usage: make restore BACKUP_FILE=<archive> [FORCE=1]" && exit 2)
140+
@DATABASE_URL="$(DATABASE_URL)" FORCE="$(FORCE)" bash scripts/backup/pcmi_restore.sh "$(BACKUP_FILE)"
141+
142+
# End-to-end: seed → backup → wipe → restore → assert (destructive; test DB only).
143+
backup-restore-test:
144+
@DATABASE_URL="$(DATABASE_URL)" bash scripts/backup/ci_backup_restore_test.sh
145+
130146
# Shortcuts
131147
up: infra-up
132148
down: infra-down

docs/runbooks/backup-restore.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#!/usr/bin/env bash
2+
# End-to-end proof that a PCMI backup can be restored with zero data loss:
3+
# seed a marker → backup → wipe the schema → restore → assert the marker and
4+
# row counts survived. Exits non-zero on any mismatch (CI gate).
5+
#
6+
# Requires: DATABASE_URL pointing at a migrated PCMI database + postgresql-client.
7+
set -euo pipefail
8+
9+
DATABASE_URL="${DATABASE_URL:?DATABASE_URL is required}"
10+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11+
WORK="$(mktemp -d)"
12+
trap 'rm -rf "$WORK"' EXIT
13+
14+
psql_q() { psql "$DATABASE_URL" -tAc "$1"; }
15+
16+
marker="backup-restore-$(date +%s)"
17+
18+
echo "== 1. seed a marker tenant =="
19+
psql_q "INSERT INTO tenants (slug, name) VALUES ('$marker', '$marker')" >/dev/null
20+
tenants_before="$(psql_q "SELECT count(*) FROM tenants")"
21+
tables_before="$(psql_q "SELECT count(*) FROM information_schema.tables WHERE table_schema='public'")"
22+
echo " tenants=$tenants_before tables=$tables_before"
23+
24+
echo "== 2. backup =="
25+
backup="$(DATABASE_URL="$DATABASE_URL" bash "$HERE/pcmi_backup.sh" "$WORK")"
26+
[ -f "$backup" ] || { echo "FAIL: backup file missing"; exit 1; }
27+
28+
echo "== 3. wipe the schema =="
29+
psql "$DATABASE_URL" -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" >/dev/null
30+
wiped="$(psql_q "SELECT count(*) FROM information_schema.tables WHERE table_schema='public'")"
31+
[ "$wiped" = "0" ] || { echo "FAIL: schema not empty after wipe ($wiped tables)"; exit 1; }
32+
echo " confirmed empty"
33+
34+
echo "== 4. restore =="
35+
DATABASE_URL="$DATABASE_URL" bash "$HERE/pcmi_restore.sh" "$backup"
36+
37+
echo "== 5. assert no data loss =="
38+
tenants_after="$(psql_q "SELECT count(*) FROM tenants")"
39+
tables_after="$(psql_q "SELECT count(*) FROM information_schema.tables WHERE table_schema='public'")"
40+
found="$(psql_q "SELECT count(*) FROM tenants WHERE slug='$marker'")"
41+
echo " tenants=$tenants_after tables=$tables_after marker_found=$found"
42+
43+
fail=0
44+
[ "$tenants_after" = "$tenants_before" ] || { echo "FAIL: tenant count $tenants_before$tenants_after"; fail=1; }
45+
[ "$tables_after" = "$tables_before" ] || { echo "FAIL: table count $tables_before$tables_after"; fail=1; }
46+
[ "$found" = "1" ] || { echo "FAIL: marker tenant not restored"; fail=1; }
47+
48+
# The restore safety gate must refuse a second restore over the now-populated DB.
49+
echo "== 6. assert restore refuses to clobber without FORCE =="
50+
if DATABASE_URL="$DATABASE_URL" bash "$HERE/pcmi_restore.sh" "$backup" >/dev/null 2>&1; then
51+
echo "FAIL: restore overwrote a populated DB without FORCE=1"; fail=1
52+
else
53+
echo " refused (as expected)"
54+
fi
55+
56+
if [ "$fail" != "0" ]; then
57+
echo "BACKUP/RESTORE TEST: FAILED"; exit 1
58+
fi
59+
echo "BACKUP/RESTORE TEST: PASSED — zero data loss across backup → wipe → restore"

scripts/backup/pcmi_backup.sh

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env bash
2+
# PCMI backup — pg_dump wrapper producing a compressed, timestamped custom-format
3+
# archive suitable for pcmi_restore.sh / pg_restore.
4+
#
5+
# Usage:
6+
# DATABASE_URL=postgres://user:pass@host:5432/pcmi ./scripts/backup/pcmi_backup.sh [OUT_DIR]
7+
#
8+
# Env:
9+
# DATABASE_URL required — libpq connection URI
10+
# PCMI_BACKUP_DIR default output dir when OUT_DIR arg is omitted (default ./backups)
11+
#
12+
# Prints the archive path on stdout (so callers can capture it); progress on stderr.
13+
set -euo pipefail
14+
15+
DATABASE_URL="${DATABASE_URL:-}"
16+
OUT_DIR="${1:-${PCMI_BACKUP_DIR:-./backups}}"
17+
18+
if [ -z "$DATABASE_URL" ]; then
19+
echo "[backup] DATABASE_URL is required" >&2
20+
exit 2
21+
fi
22+
if ! command -v pg_dump >/dev/null 2>&1; then
23+
echo "[backup] pg_dump not found — install postgresql-client" >&2
24+
exit 2
25+
fi
26+
27+
mkdir -p "$OUT_DIR"
28+
ts="$(date -u +%Y%m%dT%H%M%SZ)"
29+
out="$OUT_DIR/pcmi-${ts}.dump"
30+
31+
echo "[backup] pg_dump → $out" >&2
32+
# --format=custom: compressed, restorable with pg_restore (selective/parallel).
33+
# --no-owner/--no-privileges: portable across roles (restore into any owner).
34+
pg_dump "$DATABASE_URL" \
35+
--format=custom \
36+
--no-owner \
37+
--no-privileges \
38+
--file="$out"
39+
40+
size="$(du -h "$out" | cut -f1)"
41+
echo "[backup] wrote ${size}$out" >&2
42+
echo "$out"

scripts/backup/pcmi_restore.sh

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/usr/bin/env bash
2+
# PCMI restore — pg_restore wrapper. Refuses to overwrite a populated database
3+
# unless FORCE=1, so an accidental restore cannot silently clobber live data.
4+
#
5+
# Usage:
6+
# DATABASE_URL=postgres://user:pass@host:5432/pcmi \
7+
# ./scripts/backup/pcmi_restore.sh <backup.dump>
8+
#
9+
# Env:
10+
# DATABASE_URL required — target libpq connection URI
11+
# FORCE set to 1 to restore over a non-empty target (drops+recreates
12+
# objects via pg_restore --clean --if-exists)
13+
set -euo pipefail
14+
15+
BACKUP_FILE="${1:-}"
16+
DATABASE_URL="${DATABASE_URL:-}"
17+
FORCE="${FORCE:-0}"
18+
19+
if [ -z "$BACKUP_FILE" ]; then
20+
echo "usage: pcmi_restore.sh <backup.dump> (DATABASE_URL env required)" >&2
21+
exit 2
22+
fi
23+
if [ ! -f "$BACKUP_FILE" ]; then
24+
echo "[restore] no such file: $BACKUP_FILE" >&2
25+
exit 2
26+
fi
27+
if [ -z "$DATABASE_URL" ]; then
28+
echo "[restore] DATABASE_URL is required" >&2
29+
exit 2
30+
fi
31+
if ! command -v pg_restore >/dev/null 2>&1 || ! command -v psql >/dev/null 2>&1; then
32+
echo "[restore] pg_restore/psql not found — install postgresql-client" >&2
33+
exit 2
34+
fi
35+
36+
# Safety gate: count existing tables in the public schema.
37+
existing="$(psql "$DATABASE_URL" -tAc \
38+
"SELECT count(*) FROM information_schema.tables WHERE table_schema='public'" 2>/dev/null || echo 0)"
39+
existing="$(echo "$existing" | tr -d '[:space:]')"
40+
if [ "${existing:-0}" -gt 0 ] && [ "$FORCE" != "1" ]; then
41+
echo "[restore] target already has ${existing} public tables." >&2
42+
echo "[restore] refusing to overwrite — re-run with FORCE=1 to proceed." >&2
43+
exit 3
44+
fi
45+
46+
echo "[restore] pg_restore ← $BACKUP_FILE" >&2
47+
# --clean --if-exists: drop existing objects first (safe on an empty DB too).
48+
# --no-owner/--no-privileges: restore regardless of the dump's original roles.
49+
# --exit-on-error keeps a partial/corrupt restore from looking successful.
50+
pg_restore \
51+
--clean --if-exists \
52+
--no-owner --no-privileges \
53+
--exit-on-error \
54+
--dbname="$DATABASE_URL" \
55+
"$BACKUP_FILE"
56+
57+
echo "[restore] done" >&2

0 commit comments

Comments
 (0)