diff --git a/containers/novela/routers/backup.py b/containers/novela/routers/backup.py index d039889..fa18a38 100644 --- a/containers/novela/routers/backup.py +++ b/containers/novela/routers/backup.py @@ -756,12 +756,39 @@ def _psql_base_args() -> list[str]: ] +# Session-GUC SET statements a dump may carry that an OLDER server doesn't know. +# These are emitted in the pg_dump header and are harmless to skip — they only +# affect the restoring session, not the data. (e.g. `transaction_timeout` was +# introduced in PostgreSQL 17; restoring such a dump into a <17 server errors on +# that line.) We must not let these abort an otherwise valid restore, but any +# OTHER error still fails the restore. +_BENIGN_RESTORE_ERROR_MARKERS = ( + "unrecognized configuration parameter", +) + + +def _real_restore_errors(stderr: str) -> list[str]: + errors = [] + for line in (stderr or "").splitlines(): + if "ERROR:" not in line: + continue + if any(marker in line for marker in _BENIGN_RESTORE_ERROR_MARKERS): + continue + errors.append(line.strip()) + return errors + + def _run_pg_restore(dump_bytes: bytes) -> None: """Restore a full PostgreSQL dump. Resets the public schema first so any plain pg_dump (with or without --clean) restores cleanly into an empty schema. This is destructive: it drops and recreates the entire public schema before applying the dump. + + The dump is applied WITHOUT ON_ERROR_STOP so that benign header SETs the + target server doesn't recognize (e.g. a newer pg_dump emitting + `transaction_timeout` against an older server) don't abort the restore. + stderr is then inspected: any non-benign `ERROR:` line fails the restore. """ env = os.environ.copy() env["PGPASSWORD"] = os.environ.get("POSTGRES_PASSWORD", "") @@ -782,13 +809,14 @@ def _run_pg_restore(dump_bytes: bytes) -> None: try: proc = subprocess.run( - ["psql", *base, "-v", "ON_ERROR_STOP=1", "-f", str(tmp_path)], + ["psql", *base, "-f", str(tmp_path)], env=env, capture_output=True, text=True, ) - if proc.returncode != 0: - raise RuntimeError(f"psql restore failed: {(proc.stderr or '').strip()[:500] or 'unknown error'}") + real_errors = _real_restore_errors(proc.stderr) + if real_errors: + raise RuntimeError("psql restore failed: " + " | ".join(real_errors)[:500]) finally: tmp_path.unlink(missing_ok=True) diff --git a/containers/novela/version.py b/containers/novela/version.py index 4253908..cbf3acb 100644 --- a/containers/novela/version.py +++ b/containers/novela/version.py @@ -10,7 +10,7 @@ from __future__ import annotations from changelog import CHANGELOG -BUILD = 1 +BUILD = 2 def _release_version() -> str: diff --git a/docs/changelog-develop.md b/docs/changelog-develop.md index dbaf738..650a4c4 100644 --- a/docs/changelog-develop.md +++ b/docs/changelog-develop.md @@ -1,5 +1,11 @@ # Develop Changelog +## 2026-06-01 — Full Database Restore: tolerate PostgreSQL version mismatch + +### Fixed +- Full Database Restore failed with `ERROR: unrecognized configuration parameter "transaction_timeout"` when the dump was produced by a newer `pg_dump` (PostgreSQL 17+, which emits `SET transaction_timeout = 0;` in the header) but restored into an older server (<17) that doesn't know that session parameter. The restore ran with `ON_ERROR_STOP=1` and aborted on that harmless header line. + - `routers/backup.py` (`_run_pg_restore`): the dump is now applied without `ON_ERROR_STOP`; stderr is inspected afterwards via `_real_restore_errors`, which ignores benign "unrecognized configuration parameter" errors but still fails the restore on any other `ERROR:` line. The schema-reset step keeps `ON_ERROR_STOP=1` (it is our own controlled SQL). + ## 2026-06-01 — Backup/Restore: database-stored books are now restorable ### Fixed