Disciplines · Runbooks

Metis Disaster Recovery

Covered: taking a backup of the Metis database and its source bucket, restoring both into isolated copies, verifying each by hashing, and reading the recovery point off the estate itself.

13sections7 minread

On this page

title: Metis Disaster Recovery owner: unassigned — see "Who owns this" below version: 2.0.0 last_reviewed: 2026-09-18 next_review: 2026-12-17 applies_to: the Metis courseware service, its Postgres database and its source object bucket#

Written for M10.18.e from the shipped code paths, with every command below executed once against the compose Postgres while writing it. The measured numbers are from that run and are named as such; where something could not be executed, this document says so rather than describing what it would have done.

Revised 2026-09-18 (M10.17). Three of the gaps version 1.0.0 recorded have moved, and one of its statements was wrong:

  • backups are now scheduled and recordedmetis.tasks.durable.back_up_estate runs every backup_interval_seconds and writes a row per attempt, so the recovery point is readable rather than folklore;
  • the object store half is now exercised, and it carries a constraint this document did not have: the objects must come back under the bucket name the receipts already name (see "Restore the object store");
  • version 1.0.0 named the wrong buckets for blob recovery. The durable source bytes a revision's receipt points at live in metis-sources (s3_bucket_sources, M10.4) — not in metis-courses, metis-assets, metis-exports or metis-uploads, which hold derivatives, republishable exports, or objects a daily cleanup task deletes;
  • the whole procedure below is now executable as one test: apps/metis/service/tests/test_estate_restore_drill_real_stack.py. Prefer running it to reading this when what you need is confidence that a restore works; read this when you are performing one.

What this covers, and what it does not#

Covered: taking a backup of the Metis database and its source bucket, restoring both into isolated copies, verifying each by hashing, and reading the recovery point off the estate itself.

Not covered, because the capability does not exist yet:

  • Point-in-time recovery. archive_mode is off on the compose Postgres and wal_level is replica. A restore can only reach the state of the last dump. There is no procedure here for recovering to a chosen moment because there is no mechanism for it.
  • Encrypted backups. The dumps land in object storage unencrypted, and where a key would live is an operator decision nobody has made. Treat the backup bucket as holding everything the database holds.
  • A stated recovery objective. The schedule bounds the recovery point at backup_interval_seconds (default 86,400 — one day), but that is a default and not a commitment, and extrapolating the measured recovery time needs a production volume nobody has recorded (M10.14.b).

Who owns this#

Nobody, and that is a gap rather than an omission in this document. The estate has no accessibility or performance owner registry either (M10.13.f, M10.14.e), and this runbook cannot invent an on-call rota. Assign an owner before relying on it.

Find the last successful backup first#

Before anything else, learn what you are restoring to. The estate records every attempt, and a failed run is in the table beside the successes so a gap in the schedule is visible:

bash
psql $H -d metis -c "SELECT finished_at, state, database_name, dump_bucket,
  dump_key, dump_bytes, tables, objects_copied FROM estate_backup_run
  ORDER BY finished_at DESC LIMIT 10"

The newest succeeded row is the recovery point. Its dump_key is the object to fetch from dump_bucket, and dump_sha256 is what the bytes must hash to — check that before restoring anything, not after.

If the newest success is old, or there are none, the schedule is not running: check that a beat worker is up and look for estate_backup_unavailable or estate_backup_failed in the logs. unavailable means a precondition — no pg_dump on the worker, or no backup bucket — and writes no row, which is why the log matters when the table is silent.

Prerequisites#

  • pg_dump, pg_restore, createdb, dropdb on the path.
  • Credentials for the compose Postgres. In development that is oshun:oshun_dev at 127.0.0.1:5432; never put the password in a committed script or in command text — export PGPASSWORD from a throwaway file and delete it afterwards.
  • Enough disk for the dump. The development database was 1.1 MB compressed; production volume is unrecorded, which M10.14.b is blocked on.

Take a backup#

bash
export PGPASSWORD=…            # from the operator's secret store, not here
H="-h 127.0.0.1 -U oshun"
pg_dump $H -Fc -f /tmp/metis-$(date +%Y%m%dT%H%M%SZ).dump metis

-Fc is the custom format, which pg_restore can restore selectively and in parallel. Measured: 204 ms for the 1.1 MB development database.

Restore into an isolated database#

Never restore over the database you are recovering from. Create a new one and compare before switching anything.

bash
dropdb $H --if-exists metis_restore_drill
createdb $H metis_restore_drill
pg_restore $H -d metis_restore_drill --no-owner --no-privileges <dump>

Measured: 1,981 ms, total 2.2 s end to end. That is a floor for a development-sized database and says nothing about production.

--no-owner --no-privileges drops grants. On the development database that loses nothing — it carries zero role grants on public beyond the owner and PUBLIC, checked with:

bash
psql $H -d metis -tAc "SELECT count(*) FROM information_schema.role_table_grants
  WHERE table_schema='public' AND grantee NOT IN ('oshun','PUBLIC')"

On a deployment that grants to real roles, drop those two flags and restore as a superuser, then re-run the verification below.

Verify the restore by hashing, never by counting#

A row count says two tables agree whenever they hold the same number of different rows. Hash the content instead:

bash
for DB in metis metis_restore_drill; do
  : > "/tmp/hash-$DB.txt"
  for T in $(psql $H -d "$DB" -tAc \
      "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY 1"); do
    HASH=$(psql $H -d "$DB" -tAc \
      "SELECT COALESCE(md5(string_agg(x::text, E'\n' ORDER BY x::text)),'EMPTY')
       FROM \"$T\" x")
    echo "$T=$HASH" >> "/tmp/hash-$DB.txt"
  done
done
diff /tmp/hash-metis.txt /tmp/hash-metis_restore_drill.txt && echo IDENTICAL

Measured: 163 tables hashed on both sides, all 163 digests identical, with 18 tables non-empty. Check that last number — a comparison in which every table is EMPTY passes and proves nothing.

A second, independent measurement on 2026-09-18, from the drill test on a freshly provisioned stack carrying one source-to-course journey: 172 tables, all digests identical, 7 non-empty, dump 938,995 bytes. Dump 0.181 s, restore 1.790 s.

One table legitimately differs when you restore a dump this estate's own scheduled backup produced: estate_backup_run. The run row is written after the dump completes, so a dump cannot contain its own record. Every other table must match.

Tenant integrity needs no separate step: tenant_id is part of every row that was hashed.

Restore the object store#

The objects must come back under the bucket name they were stored under. This is not a preference. A revision records each blob as a BlobReceipt that carries its bucket, and BlobStore.verify reads the bucket out of the receipt rather than out of configuration — so objects restored into a differently-named bucket are objects nothing can verify, and the receipts cannot be edited to suit: they live in store_revisions, which migration 047 makes immutable outside an approved retention or legal workflow.

Measured 2026-09-18: with the objects mirrored to a second bucket and the original emptied, the reconciler reported every blob broken — and reported them broken again when pointed at the bucket that held them. Restoring into the original name made the estate reconcile.

The scheduled backup keeps the source objects under the objects/ prefix of the backup bucket, keyed exactly as they are in metis-sources. Restoring them is estate_backup.restore_sources, and it is a function rather than a shell line for two reasons. Neither mc nor the aws CLI is installed on the hosts this estate runs on (checked 2026-09-18 — which aws mc finds neither); either could be installed, but the function is the one the service's own tests exercise, and a restore procedure that is only ever run by hand is a procedure nobody has verified.

bash
cd apps/metis/service
PYTHONPATH=src .venv/bin/python -c "
from metis.config import get_settings
from metis.services.durable_runtime import s3_client
from metis.services.estate_backup import restore_sources
settings = get_settings()
print(restore_sources(s3_client(settings), settings, settings.s3_bucket_backups))
"

It restores into s3_bucket_sources and nowhere else, refuses a bucket that is not there rather than creating one, and is idempotent — an object already present under its content-addressed key is the object the backup holds. It prints (objects_restored, bytes_restored).

Rehearsed 2026-09-18 against MinIO with one object in a backup bucket: the command above printed (1, 27), and running it again printed (0, 0).

Then verify the bytes rather than the listing — every object's length and digest, on both sides. tests/test_estate_restore_drill_real_stack.py and tests/test_estate_backup_real_stack.py do exactly that, and running them is the faster way to check the procedure still holds.

Measured: mirror 0.029 s, restore 0.045 s for 2 objects / 1,723 bytes. That is a floor at development volume and nothing more.

Reconcile after the restore#

A restored database holds outbox events nobody delivered and projections nobody has compared. Drain and reconcile before switching traffic:

bash
# the worker task beat runs every 10 seconds; run it once against the restore
METIS_DATABASE_URL=<restored> celery -A metis.tasks.celery_app call \
  metis.tasks.durable.drain_outbox
METIS_DATABASE_URL=<restored> celery -A metis.tasks.celery_app call \
  metis.tasks.durable.reconcile_estate

reconcile_estate repairs nothing; it reports. A clean pass means the blobs, the search index, the outbox, the published exports, the grade projections and the aggregate store's own record legs all agree with the restored record. Any finding is for a person to act on before the estate is declared recovered.

Clean up#

bash
dropdb $H --if-exists metis_restore_drill
rm -f /tmp/metis-*.dump /tmp/hash-*.txt

Leaving a restored copy on the server is how a stale database gets mistaken for the live one.

What to do when the verification fails#

Do not switch traffic. A digest mismatch means the dump, the restore or the source changed between the two hashes — take a fresh dump with the service stopped and repeat. If the mismatch persists on a quiesced source, the dump is suspect and the correct action is to escalate rather than to accept the closest copy.

Known gaps, as of the last review#

Gap Consequence
No WAL archiving No recovery to a chosen point; only to the last dump
Dumps are not encrypted The backup bucket holds everything the database holds
Interval is a default 86,400 s is configured, not committed to
No owner Nobody is accountable for running any of this
Production volume unknown The measured RTO cannot be extrapolated

Closed since version 1.0.0: the schedule (back-up-estate, with every attempt recorded in estate_backup_run) and the object-store half (mirrored by the backup, restored and verified by the drill test).