Files
FabledCurator/backend/app/services/backup_verify.py
T
bvandeusenandClaude Opus 5.5 75008ad048
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 22s
CI and images / extension-test (push) Successful in 24s
CI and images / backend-lint-and-test (push) Failing after 47s
CI and images / integration (push) Failing after 2m42s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
feat: test a database backup without touching the install
"Test a backup" on the Backups card loads a dump into a separate, temporary
database on the same Postgres server, the same way a restore does, checks every
table against the row counts recorded with the dump, brings the copy up to this
version, and drops it. The live database is only read.

Every dump now records its row counts, read in the snapshot pg_dump dumps from
(pg_export_snapshot + --snapshot), so the comparison is exact. Off-site the
counts sit beside the dump as <name>.counts.json; locally they go in the
manifest file. A dump taken before this is compared with the live database
instead.

A test cut off by a restart is ended, and its database dropped, when the long
maintenance lane starts. A test doesn't block backups; a restore and a second
test wait for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:21:25 -05:00

163 lines
6.6 KiB
Python

"""Testing a database dump without touching this install.
The dump is loaded into a separate, temporary database on the same Postgres
server, using the same steps a real restore takes. Every table is counted
there and compared, the copy is brought up to this version, and the copy is
then dropped. The live database is only read, to show its counts alongside.
Every dump records how many rows each table held, counted in the same
snapshot pg_dump dumped from, so the comparison is exact. For a dump in the
bucket the counts sit beside it as `<dump name>.counts.json`; for a dump on
this server they are in its manifest file. A dump taken before counts were
recorded can still be tested: it is then compared with the live database,
where a difference is what changed after the dump was taken, not a fault.
The temporary database needs about as much free space on the database's disk
as the live one takes, and only for the length of the test.
"""
from __future__ import annotations
import json
import tempfile
from collections.abc import Callable
from pathlib import Path
from sqlalchemy.engine import make_url
from . import backup_restore, backup_service
from .backup_s3 import ObjectMissing, S3Target, get_file, put_file
SCRATCH_SUFFIX = "_restore_test"
def counts_key(dump_key: str) -> str:
"""Where a dump's row counts sit in the bucket, beside the dump."""
return dump_key.removesuffix(".dump") + ".counts.json"
def store_counts(target: S3Target, dump_key: str, counts: dict) -> None:
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "counts.json"
path.write_text(json.dumps(counts))
put_file(target, counts_key(dump_key), path, content_type="application/json")
def fetch_counts(target: S3Target, dump_key: str) -> dict | None:
"""The counts recorded with a dump in the bucket, or None for a dump that
has none."""
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "counts.json"
try:
get_file(target, counts_key(dump_key), path)
except ObjectMissing:
return None
return json.loads(path.read_text())
def local_counts(manifest_path: str | None) -> dict | None:
"""The counts in a dump's manifest file on this server, or None."""
try:
return json.loads(Path(manifest_path).read_text()).get("counts")
except (TypeError, OSError, ValueError):
return None
def scratch_name(db_url: str) -> str:
"""The temporary database's name: the live one's, plus SCRATCH_SUFFIX."""
return f"{make_url(db_url).database}{SCRATCH_SUFFIX}"
def scratch_url(db_url: str) -> str:
return make_url(db_url).set(database=scratch_name(db_url)).render_as_string(
hide_password=False)
def _on_server(db_url: str, statement) -> None:
"""Run a CREATE or DROP DATABASE, which can't run inside a transaction."""
import psycopg
with psycopg.connect(backup_service._libpq_url(db_url), autocommit=True) as conn:
conn.execute(statement)
def drop_scratch(db_url: str) -> None:
"""Drop the temporary database if it is there, ending any connection to it."""
from psycopg import sql
_on_server(db_url, sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(
sql.Identifier(scratch_name(db_url))))
def _create_scratch(db_url: str) -> None:
from psycopg import sql
_on_server(db_url, sql.SQL("CREATE DATABASE {} TEMPLATE template0").format(
sql.Identifier(scratch_name(db_url))))
def trial_restore(*, db_url: str, dump_path: Path, recorded: dict | None,
note: Callable[[str], None] = lambda phase: None) -> dict:
"""Load the dump at `dump_path` into the temporary database, compare it,
bring it up to this version, and drop it. `note` is told each phase as it
starts: "loading", "comparing", "upgrading". Raises RestoreFailed (or
S3Error from the upgrade) when the dump doesn't load or can't be brought
up; the temporary database is dropped either way."""
scratch = scratch_url(db_url)
if make_url(scratch).database == make_url(db_url).database:
raise ValueError("The test database would be the live one.")
drop_scratch(db_url) # one left by a test cut off by a restart
_create_scratch(db_url)
try:
note("loading")
backup_service.restore_db(db_url=scratch, sql_path=dump_path)
note("comparing")
restored = backup_service.read_counts(scratch)
live = backup_service.read_counts(db_url)
note("upgrading")
backup_restore.migrate(db_name=scratch_name(db_url))
upgraded = backup_service.read_counts(scratch)["alembic"]
finally:
drop_scratch(db_url)
return {**compare(recorded, restored, live),
"alembic_dump": restored["alembic"], "alembic_now": upgraded}
def compare(recorded: dict | None, restored: dict, live: dict) -> dict:
"""Each table's rows in the dump (as recorded), in the restored copy, and
in the live database. With counts recorded, every table must come back
with exactly its count; `mismatched` names those that didn't."""
rec = (recorded or {}).get("tables")
names = sorted(set(restored["tables"]) | set(rec or {}) | set(live["tables"]))
tables = [
{"table": n, "dump": rec.get(n) if rec is not None else None,
"restored": restored["tables"].get(n), "live": live["tables"].get(n)}
for n in names
]
mismatched = [t["table"] for t in tables
if rec is not None and t["table"] in rec and t["restored"] != t["dump"]]
return {
"counted": rec is not None,
"tables": tables,
"mismatched": mismatched,
"rows_restored": sum(v for v in restored["tables"].values()),
}
def summary(result: dict) -> str:
"""One sentence on how a test went."""
n = len([t for t in result["tables"] if t["restored"] is not None])
rows = f"{result['rows_restored']:,}"
if result["mismatched"]:
shown = ", ".join(
f"{t['table']} ({t['dump']:,} in the dump, "
+ ("table missing)" if t["restored"] is None else f"{t['restored']:,} restored)")
for t in result["tables"] if t["table"] in result["mismatched"][:5]
)
more = len(result["mismatched"]) - 5
return (f"{len(result['mismatched'])} table(s) came back different: {shown}"
+ (f", and {more} more" if more > 0 else "") + ".")
if result["counted"]:
return f"All {n} tables came back with the {rows} rows the dump recorded."
return (f"The dump loaded cleanly: {n} tables, {rows} rows. It was taken before "
"dumps recorded their counts, so it is shown beside the live database instead.")