feat(backup): restore a same-version backup in place, history kept, all or nothing (#5815)
CI / lint (push) Failing after 3s
CI / unit (push) Successful in 1m41s
CI / integration (push) Failing after 1m45s
CI / publish (push) Skipped

#5815 "Restore engine: in place for a same-version backup".

restore_in_place (steward/backup/restore.py) runs in one transaction:
1. It keeps aside what Steward records as it runs: ACTIVITY columns of rows
   that come back, and STATE_SETTINGS.
2. It drops every foreign key touching a replaced table.
3. It truncates the replaced tables and COPYs the backup's rows in
   unparsed, through asyncpg.
4. It puts the kept state back and moves sequences past their rows.
5. It puts right the rows whose parent didn't come back, by each key's own
   ON DELETE. A RESTRICT key that may be empty is cleared, so a run whose
   user is gone keeps its record.
6. It re-adds the keys, which checks every row.

A failure anywhere rolls the whole restore back. A backup made at another
schema is refused with NotSameSchema ("older": a full replace is the way;
"newer": update Steward).

The COPY-text reading moves into steward/backup/pgdata.py, shared by the
check and the restore. The integration tests stand in for pg_restore with
this database's own COPY output:
- settings come back while the audit log and the history of what remains
  stay;
- history of what didn't come back goes;
- a failure part-way changes nothing;
- a newer schema is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 10:53:38 -04:00
co-authored by Claude Opus 5.5
parent e8b75143f1
commit aab8581f20
6 changed files with 575 additions and 105 deletions
+6 -2
View File
@@ -8,8 +8,9 @@ from quart import render_template
from steward.backup import check
from steward.backup.check import (AreaCheck, CheckReport, RowDiff, TableCheck, _Opener, _Side,
compare_table, copy_rows, normalise, schema_of, script_tables,
unescape)
compare_table, normalise, schema_of)
from steward.backup.pgdata import (copy_rows, script_blocks, script_from, script_tables,
unescape)
from steward.core.crypto import _make_fernet
from tests.test_style_tokens import hex_colours
@@ -48,6 +49,9 @@ def test_a_restore_script_gives_each_tables_rows():
assert tables["app_settings"] == (["key", "value_json", "updated_at"],
[["smtp.port", "587", "2026-10-15 14:00:00+00"]])
assert tables["audit_events"][0] == ["id", "timestamp", "action"]
# Handed on unparsed, a block is the COPY text as the dump holds it.
assert script_blocks(script)["app_settings"][1] == b"smtp.port\t587\t2026-10-15 14:00:00+00\n"
assert script_tables(script_from(script_blocks(script))) == tables
def test_an_instant_is_the_same_whatever_zone_prints_it():
@@ -0,0 +1,32 @@
"""How an in-place restore puts right a row whose parent didn't come back
(steward/backup/restore.py, #5815): by the row's own foreign key."""
from steward.backup.restore import NotSameSchema, _ForeignKey, fk_fix
def _fk(on_delete, nullable=True, columns=("rule_id",)):
return _ForeignKey("fk", "alert_events", "alert_rules", list(columns), ["id"] * len(columns),
on_delete, nullable, "FOREIGN KEY (rule_id) REFERENCES alert_rules(id)")
def test_a_cascading_key_removes_the_row():
assert fk_fix(_fk("c")) == (
'DELETE FROM public."alert_events" x WHERE x."rule_id" IS NOT NULL AND NOT EXISTS '
'(SELECT 1 FROM public."alert_rules" p WHERE p."id" = x."rule_id")')
def test_set_null_and_a_restricting_key_that_may_be_empty_clear_the_link():
for action in ("n", "a", "r"):
assert fk_fix(_fk(action)).startswith('UPDATE public."alert_events" x SET "rule_id" = NULL')
def test_a_restricting_key_that_may_not_be_empty_removes_the_row():
assert fk_fix(_fk("r", nullable=False)).startswith('DELETE FROM public."alert_events"')
def test_a_key_over_several_columns_is_left_to_the_constraint():
assert fk_fix(_fk("c", columns=("a", "b"))) is None
def test_another_schema_says_what_to_do():
assert "update Steward first" in str(NotSameSchema("newer", ["x"], ["y"]))
assert "replaces everything" in str(NotSameSchema("older", ["x"], ["y"]))