From 9e9ba825e0a9179b029b4e56f13cd3a9d7e145c7 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Mon, 1 Jun 2026 21:32:16 +0200 Subject: [PATCH] Dev build 2026-06-01 21:32 --- containers/novela/routers/backup.py | 41 +++++++++++++++++++++++------ containers/novela/version.py | 2 +- docs/changelog-develop.md | 6 +++++ 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/containers/novela/routers/backup.py b/containers/novela/routers/backup.py index fa18a38..9a1945c 100644 --- a/containers/novela/routers/backup.py +++ b/containers/novela/routers/backup.py @@ -778,17 +778,13 @@ def _real_restore_errors(stderr: str) -> list[str]: 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. +def _apply_pg_dump(dump_bytes: bytes) -> None: + """Reset the public schema and load a dump into it. 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. + `transaction_timeout` against an older server) don't abort the load. + stderr is then inspected: any non-benign `ERROR:` line raises. """ env = os.environ.copy() env["PGPASSWORD"] = os.environ.get("POSTGRES_PASSWORD", "") @@ -821,6 +817,35 @@ def _run_pg_restore(dump_bytes: bytes) -> None: tmp_path.unlink(missing_ok=True) +def _run_pg_restore(dump_bytes: bytes) -> None: + """Restore a full PostgreSQL dump, with automatic rollback on failure. + + Before the destructive load, a safety dump of the CURRENT database is taken. + If applying the requested dump fails, the database is rolled back to that + safety snapshot so a failed restore never leaves an empty/broken database. + """ + try: + safety_bytes, _ = _run_pg_dump() + except Exception: + safety_bytes = None + + try: + _apply_pg_dump(dump_bytes) + except Exception as restore_err: + if safety_bytes is not None: + try: + _apply_pg_dump(safety_bytes) + except Exception as rollback_err: + raise RuntimeError( + f"restore failed: {restore_err}; AND rollback failed: {rollback_err}. " + "Database may be in an inconsistent state." + ) + raise RuntimeError( + f"restore failed and was rolled back to the pre-restore state: {restore_err}" + ) + raise + + def _list_pg_dump_paths(client: dropbox.Dropbox, postgres_root: str) -> list[str]: files = _dropbox_list_files_recursive(client, postgres_root) return sorted([p for p in files if p.endswith(".sql")], reverse=True) diff --git a/containers/novela/version.py b/containers/novela/version.py index ff2a190..ce888a5 100644 --- a/containers/novela/version.py +++ b/containers/novela/version.py @@ -10,7 +10,7 @@ from __future__ import annotations from changelog import CHANGELOG -BUILD = 3 +BUILD = 4 def _release_version() -> str: diff --git a/docs/changelog-develop.md b/docs/changelog-develop.md index 650a4c4..de2ae45 100644 --- a/docs/changelog-develop.md +++ b/docs/changelog-develop.md @@ -1,5 +1,11 @@ # Develop Changelog +## 2026-06-01 — Full Database Restore: safety dump + automatic rollback + +### Fixed +- Full Database Restore could leave the database **empty** if the dump failed to load: the restore dropped and recreated the public schema first and only then applied the dump, so any failure during the load (e.g. a header `transaction_timeout` error with `ON_ERROR_STOP`) wiped all data with nothing to fall back to. + - `routers/backup.py`: split the load into `_apply_pg_dump`; `_run_pg_restore` now takes a safety `pg_dump` of the current database before the destructive load and, if applying the requested dump fails, automatically rolls back to that safety snapshot. A failed restore therefore no longer leaves an empty or broken database. + ## 2026-06-01 — Full Database Restore: tolerate PostgreSQL version mismatch ### Fixed