perf(delivery): streamline complete recovery inventories
This commit is contained in:
@@ -421,6 +421,15 @@ Every target's inode, ownership, mode, link count, size and nanosecond timestamp
|
||||
must remain unchanged until the final receipt. These observations are local to
|
||||
one stopped-node recovery and are never reused after an interrupted attempt.
|
||||
Directory entries are flushed after their children before completion is recorded.
|
||||
The private v4 inventory accepts up to 98304 logical file/directory entries
|
||||
and 20 MiB of metadata so retained software/runtime versions are included in full.
|
||||
The 12 GiB logical, 7 GiB physical and 256 MiB individual-file limits remain;
|
||||
capture keeps its 256 MiB memory, 50% CPU and original finite deadline. Version 3
|
||||
keeps its 65536-entry bound. All normalized directory nodes must have a declared
|
||||
direct parent, which also verifies every ancestor of every file. During restoration,
|
||||
only this completely verified inventory can reuse its checked target names;
|
||||
untrusted calls still validate paths. These bounds require actual capture and
|
||||
recovery validation on the target node; exceeding them is an explicit refusal.
|
||||
The cold operator, activation and immutable service/dependency/journal profile
|
||||
installation can reuse a public bundle's complete byte verification within one
|
||||
synchronous operation. Every file and directory identity, owner, mode, link count,
|
||||
|
||||
@@ -23,8 +23,8 @@ MAX_FILE_BYTES = 256 * 1024**2
|
||||
MAX_ENTRIES = 65536
|
||||
# v4 preserves logical versions independently of their deduplicated storage.
|
||||
MAX_LOGICAL_BYTES = 12 * 1024**3
|
||||
MAX_LOGICAL_ENTRIES = 81920
|
||||
MAX_METADATA_BYTES = 16 * 1024**2
|
||||
MAX_LOGICAL_ENTRIES = 98304
|
||||
MAX_METADATA_BYTES = 20 * 1024**2
|
||||
DOMAINS = {
|
||||
"postgres",
|
||||
"artifacts",
|
||||
@@ -279,10 +279,13 @@ def validate(value: dict) -> dict:
|
||||
directories = {item["path"] for item in dirs}
|
||||
if not DOMAINS <= directories:
|
||||
raise DeliveryError("private backup omitted a required source domain")
|
||||
# Every normalized file and directory is in seen. Requiring the direct
|
||||
# parent of every node proves the whole ancestor chain transitively,
|
||||
# including ancestors of directories, without rebuilding Path chains.
|
||||
for path in seen:
|
||||
for parent in Path(path).parents:
|
||||
if parent.as_posix() != "." and parent.as_posix() not in directories:
|
||||
raise DeliveryError("private backup parent missing")
|
||||
parent = path.rpartition("/")[0]
|
||||
if parent and parent not in directories:
|
||||
raise DeliveryError("private backup parent missing")
|
||||
lookup = {item["path"]: item for item in files}
|
||||
for reference in value["artifact_references"]["objects"]:
|
||||
item = lookup.get("artifacts/" + reference["digest"])
|
||||
|
||||
@@ -122,7 +122,11 @@ def stopped() -> None:
|
||||
|
||||
|
||||
def target(record: dict, recovery: Path) -> tuple[Path, int, int, int]:
|
||||
name = backup_manifest.name(record["path"])
|
||||
return _target(backup_manifest.name(record["path"]), record, recovery)
|
||||
|
||||
|
||||
def _target(name: str, record: dict, recovery: Path) -> tuple[Path, int, int, int]:
|
||||
"""Map a name already checked by target() or the complete backup verifier."""
|
||||
domain, _, suffix = name.partition("/")
|
||||
if domain in LIVE:
|
||||
path = LIVE[domain] / suffix if suffix else LIVE[domain]
|
||||
@@ -367,7 +371,7 @@ def restore(recovery_id: str, backup_id: str, expected: str) -> dict:
|
||||
for item in sorted(
|
||||
value["directories"], key=lambda row: (row["path"].count("/"), row["path"])
|
||||
):
|
||||
path, uid, gid, mode = target(item, recovery)
|
||||
path, uid, gid, mode = _target(item["path"], item, recovery)
|
||||
if path.exists() or path.is_symlink():
|
||||
member(path, uid, gid, mode, directory=True)
|
||||
else:
|
||||
@@ -381,7 +385,7 @@ def restore(recovery_id: str, backup_id: str, expected: str) -> dict:
|
||||
raise DeliveryError("cold recovery destination reserve insufficient")
|
||||
|
||||
def copy(item: dict) -> tuple[int, ...]:
|
||||
path, uid, gid, mode = target(item, recovery)
|
||||
path, uid, gid, mode = _target(item["path"], item, recovery)
|
||||
return write_file(
|
||||
backup_manifest.member_path(root, value, item),
|
||||
path,
|
||||
@@ -397,7 +401,7 @@ def restore(recovery_id: str, backup_id: str, expected: str) -> dict:
|
||||
verified = restore_io.materialize(
|
||||
value["files"],
|
||||
lambda item: backup_manifest.member_path(root, value, item),
|
||||
lambda item: target(item, recovery),
|
||||
lambda item: _target(item["path"], item, recovery),
|
||||
readback,
|
||||
filesystems,
|
||||
deadline,
|
||||
@@ -412,7 +416,7 @@ def restore(recovery_id: str, backup_id: str, expected: str) -> dict:
|
||||
for item in sorted(
|
||||
value["directories"], key=lambda row: row["path"].count("/"), reverse=True
|
||||
):
|
||||
path, uid, gid, mode = target(item, recovery)
|
||||
path, uid, gid, mode = _target(item["path"], item, recovery)
|
||||
current = path.lstat()
|
||||
if (current.st_uid, current.st_gid) != (uid, gid):
|
||||
os.chown(path, uid, gid, follow_symlinks=False)
|
||||
@@ -427,7 +431,7 @@ def restore(recovery_id: str, backup_id: str, expected: str) -> dict:
|
||||
assert filesystems is not None
|
||||
filesystems.sync(deadline)
|
||||
for item in value["directories"]:
|
||||
path, uid, gid, mode = target(item, recovery)
|
||||
path, uid, gid, mode = _target(item["path"], item, recovery)
|
||||
member(path, uid, gid, mode, directory=True)
|
||||
else:
|
||||
install.sync_directory(recovery / "history")
|
||||
@@ -438,7 +442,7 @@ def restore(recovery_id: str, backup_id: str, expected: str) -> dict:
|
||||
for item in value["files"]:
|
||||
if time.monotonic() >= deadline:
|
||||
raise DeliveryError("recovery verification deadline exceeded")
|
||||
path, uid, gid, mode = target(item, recovery)
|
||||
path, uid, gid, mode = _target(item["path"], item, recovery)
|
||||
unchanged_file(path, verified[item["path"]], uid, gid, mode)
|
||||
if filesystems is not None:
|
||||
filesystems.close()
|
||||
|
||||
@@ -120,8 +120,11 @@ def pending_file(
|
||||
) -> tuple[int, ...]:
|
||||
"""Materialize only; this witness cannot certify bytes before batch readback."""
|
||||
remaining(deadline)
|
||||
if path.exists() or path.is_symlink():
|
||||
try:
|
||||
info = path.lstat()
|
||||
except FileNotFoundError:
|
||||
info = None
|
||||
if info is not None:
|
||||
if (
|
||||
not stat.S_ISREG(info.st_mode)
|
||||
or info.st_nlink != 1
|
||||
@@ -143,9 +146,17 @@ def pending_file(
|
||||
):
|
||||
file_copy.transfer(incoming, outgoing, item["bytes"], deadline=deadline)
|
||||
outgoing.flush()
|
||||
os.fchown(outgoing.fileno(), uid, gid)
|
||||
os.fchmod(outgoing.fileno(), mode)
|
||||
witness = file_copy._identity(os.fstat(outgoing.fileno()))
|
||||
current = os.fstat(outgoing.fileno())
|
||||
changed = False
|
||||
if (current.st_uid, current.st_gid) != (uid, gid):
|
||||
os.fchown(outgoing.fileno(), uid, gid)
|
||||
changed = True
|
||||
# fchown may clear executable/set-ID bits; always restore the requested
|
||||
# allowed mode after an ownership change, even if it matched before.
|
||||
if changed or stat.S_IMODE(current.st_mode) != mode:
|
||||
os.fchmod(outgoing.fileno(), mode)
|
||||
changed = True
|
||||
witness = file_copy._identity(os.fstat(outgoing.fileno()) if changed else current)
|
||||
if file_copy._identity(path.lstat()) != witness:
|
||||
raise DeliveryError("recovery destination changed before synchronization")
|
||||
return witness
|
||||
|
||||
Reference in New Issue
Block a user