Files
bvandeusenandClaude Opus 5.5 aab8581f20
CI / lint (push) Failing after 3s
CI / unit (push) Successful in 1m41s
CI / integration (push) Failing after 1m45s
CI / publish (push) Skipped
feat(backup): restore a same-version backup in place, history kept, all or nothing (#5815)
#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>
2026-10-15 10:53:38 -04:00

132 lines
4.9 KiB
Python

"""A backup's rows as COPY text: read out of the dump by pg_restore, without a
database, and parsed or handed on as they are. The check (check.py) parses
them to compare; the in-place restore (restore.py) hands them to COPY
unparsed, so what goes back in is byte for byte what the dump holds.
"""
from __future__ import annotations
import asyncio
import re
from pathlib import Path
from .dump import BackupError
SCRIPT_DEADLINE_SECONDS = 5 * 60
_COPY = re.compile(r'^COPY ([^ ]+) \((.*)\) FROM stdin;$')
_ESCAPES = {ord("b"): b"\b", ord("f"): b"\f", ord("n"): b"\n", ord("r"): b"\r",
ord("t"): b"\t", ord("v"): b"\v"}
_HEX = "0123456789abcdefABCDEF"
def async_url(database_url: str) -> str:
from sqlalchemy.engine import make_url
url = make_url(database_url)
return str(url.set(drivername="postgresql+asyncpg")) if url.drivername == "postgresql" else database_url
async def restore_script(dump: Path) -> bytes:
"""The dump's rows as pg_restore would load them: its --data-only script.
Nothing connects; pg_restore only writes the script."""
try:
proc = await asyncio.create_subprocess_exec(
"pg_restore", "--data-only", "--no-owner", "--no-privileges", "--file=-", str(dump),
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
except FileNotFoundError:
raise BackupError("pg_restore isn't installed here.") from None
try:
out, err = await asyncio.wait_for(proc.communicate(), SCRIPT_DEADLINE_SECONDS)
except TimeoutError:
proc.kill()
await proc.wait()
raise BackupError(f"Reading the backup didn't finish within "
f"{SCRIPT_DEADLINE_SECONDS // 60} minutes.") from None
if proc.returncode != 0:
lines = [ln for ln in err.decode(errors="replace").splitlines() if ln.strip()]
raise BackupError("The backup can't be read: " + (lines[-1] if lines else
f"pg_restore exited {proc.returncode}."))
return out
def _ident(name: str) -> str:
name = name.strip()
return name[1:-1].replace('""', '"') if name.startswith('"') and name.endswith('"') else name
def script_blocks(script: bytes) -> dict[str, tuple[list[str], bytes]]:
"""Each table's (columns, COPY text) from a --data-only script, the text
as it stands: one line per row, ready for COPY … FROM in text format."""
out: dict[str, tuple[list[str], bytes]] = {}
lines = iter(script.split(b"\n"))
for line in lines:
m = _COPY.match(line.decode("utf-8", errors="replace"))
if not m:
continue
table = _ident(m.group(1).split(".")[-1])
columns = [_ident(c) for c in m.group(2).split(",")]
body = []
for row in lines:
if row == b"\\.":
break
body.append(row + b"\n")
out[table] = (columns, b"".join(body))
return out
def unescape(field: bytes) -> str | None:
"""One field of COPY's text format: \\N is NULL, backslash escapes are
undone (named, octal \\ooo and hex \\xhh)."""
if field == b"\\N":
return None
if b"\\" not in field:
return field.decode("utf-8", errors="replace")
out = bytearray()
i, n = 0, len(field)
while i < n:
c = field[i]
if c != 0x5C or i + 1 == n:
out.append(c)
i += 1
continue
nxt = field[i + 1]
if nxt in _ESCAPES:
out += _ESCAPES[nxt]
i += 2
elif 0x30 <= nxt <= 0x37:
j = i + 1
while j < n and j < i + 4 and 0x30 <= field[j] <= 0x37:
j += 1
out.append(int(field[i + 1:j], 8) & 0xFF)
i = j
elif nxt == ord("x") and i + 2 < n and chr(field[i + 2]) in _HEX:
j = i + 2
while j < n and j < i + 4 and chr(field[j]) in _HEX:
j += 1
out.append(int(field[i + 2:j], 16))
i = j
else:
out.append(nxt)
i += 2
return bytes(out).decode("utf-8", errors="replace")
def copy_rows(data: bytes) -> list[list[str | None]]:
"""COPY text rows (no header, no terminator) as lists of fields."""
return [[unescape(f) for f in line.split(b"\t")]
for line in data.split(b"\n") if line and line != b"\\."]
def script_tables(script: bytes) -> dict[str, tuple[list[str], list[list[str | None]]]]:
"""Each table's (columns, rows) from a --data-only script."""
return {t: (cols, copy_rows(raw)) for t, (cols, raw) in script_blocks(script).items()}
def script_from(blocks: dict[str, tuple[list[str], bytes]]) -> bytes:
"""A --data-only script holding `blocks`: the shape pg_restore writes,
so a test can stand in for a dump."""
out = []
for table, (columns, raw) in blocks.items():
out.append(f"COPY public.{table} ({', '.join(columns)}) FROM stdin;\n".encode())
out.append(raw)
out.append(b"\\.\n\n")
return b"".join(out)