perf(delivery): release restored inventory before copying

This commit is contained in:
2026-10-29 20:56:46 +08:00
parent b3b0a5262a
commit cde9fab2d2
4 changed files with 275 additions and 8 deletions
+119 -1
View File
@@ -1,12 +1,15 @@
"""Ordinary pipe fixtures only; no Root, filesystem-copy or VM acceptance.""" """Ordinary pipe fixtures only; no Root, filesystem-copy or VM acceptance."""
import json import json
import os
import stat
import subprocess import subprocess
import sys import sys
import time import time
import weakref
import pytest import pytest
from scripts.release import restore_native from scripts.release import restore_io, restore_native
from scripts.release.policy import DeliveryError from scripts.release.policy import DeliveryError
@@ -113,3 +116,118 @@ def test_transport_deadline_reaps_its_owned_waiting_child(transport):
with pytest.raises(DeliveryError): with pytest.raises(DeliveryError):
restore_native.Session(payload, "bk-" + "1" * 32, "rr-" + "2" * 32, 1, 0, then + 0.2) restore_native.Session(payload, "bk-" + "1" * 32, "rr-" + "2" * 32, 1, 0, then + 0.2)
assert time.monotonic() - then < 5 assert time.monotonic() - then < 5
@pytest.mark.parametrize("owned", [False, True])
def test_owned_planner_spool_preserves_transport_counts_and_join(transport, owned):
payload, start, children = transport
start("""
import json,sys
count=total=batch=0
for line in sys.stdin:
value=json.loads(line)
kind=value['kind']
stage='opened'
if kind=='restore.io.batch':
count+=len(value['files'])
total+=sum(r['bytes'] for r in value['files'])
batch=value['batch_id']
stage='verified'
if kind=='restore.io.finish':stage='finished'
reply={'schema_version':1,'kind':'restore.io.reply','stage':stage,'batch_id':batch,'verified_files':count,'verified_logical_bytes':total,'execution_verified':False,'error':'none'}
print(json.dumps(reply),flush=True)
if stage=='finished':break
""")
rows = [{"path": f"artifacts/{n:04}", "bytes": n, "sha256": "1" * 64} for n in range(513)]
session = restore_native.Session(
payload,
"bk-" + "1" * 32,
"rr-" + "2" * 32,
len(rows),
sum(r["bytes"] for r in rows),
time.monotonic() + 10,
)
try:
if owned:
session.materialize_owned(rows, payload)
assert rows == []
else:
session.materialize(rows)
assert len(rows) == 513
session.finish()
assert session.finished and children[0].returncode == 0
assert session.files == 513 and session.batch_id == 3
finally:
session.close()
def test_owned_planner_spool_releases_rows_and_preserves_exact_sorted_batches(tmp_path):
class Row(dict):
pass
rows = [Row(path=f"artifacts/{n:04}", bytes=n, sha256=f"{n % 7:064x}") for n in range(600)]
expected = [
json.dumps(b, separators=(",", ":"), ensure_ascii=True).encode() + b"\n"
for b in restore_io.batches(rows)
]
references = [weakref.ref(row) for row in rows]
with restore_native.BatchSpool(rows, tmp_path, time.monotonic() + 10) as spool:
assert not rows and all(ref() is None for ref in references)
assert spool.stream is not None
info = os.fstat(spool.stream.fileno())
assert stat.S_ISREG(info.st_mode) and stat.S_IMODE(info.st_mode) == 0o600
assert info.st_nlink == 0 and not os.get_inheritable(spool.stream.fileno())
assert list(tmp_path.iterdir()) == []
actual = [
json.dumps(b, separators=(",", ":"), ensure_ascii=True).encode() + b"\n" for b in spool
]
assert actual == expected and spool.complete
with pytest.raises(DeliveryError, match="already consumed"):
list(spool)
assert spool.stream.closed and list(tmp_path.iterdir()) == []
def test_owned_planner_spool_partial_consumer_fails_and_closes(tmp_path):
spool = restore_native.BatchSpool(
[{"path": "artifacts/a", "bytes": 1, "sha256": "1" * 64}],
tmp_path,
time.monotonic() + 10,
)
with pytest.raises(DeliveryError, match="input incomplete"), spool:
next(iter(spool))
assert spool.stream.closed and list(tmp_path.iterdir()) == []
def test_owned_planner_spool_preserves_consumer_error_and_closes(tmp_path):
spool = restore_native.BatchSpool([], tmp_path, time.monotonic() + 10)
with pytest.raises(RuntimeError, match="fixture failure"), spool:
raise RuntimeError("fixture failure")
assert spool.stream.closed and list(tmp_path.iterdir()) == []
def test_owned_planner_spool_bounds_creation_and_releases_descriptor(tmp_path, monkeypatch):
original = restore_native.tempfile.TemporaryFile
opened = []
def temporary_file(**options):
stream = original(**options)
opened.append(stream)
return stream
monkeypatch.setattr(restore_native.tempfile, "TemporaryFile", temporary_file)
monkeypatch.setattr(restore_native, "MAX_SPOOL_BYTES", 1)
with pytest.raises(DeliveryError, match="input exceeds bound"):
restore_native.BatchSpool(
[{"path": "artifacts/a", "bytes": 1, "sha256": "1" * 64}],
tmp_path,
time.monotonic() + 10,
)
assert len(opened) == 1 and opened[0].closed and list(tmp_path.iterdir()) == []
def test_owned_planner_spool_retains_original_deadline(tmp_path):
spool = restore_native.BatchSpool([], tmp_path, time.monotonic() + 10)
spool.deadline = time.monotonic() - 1
with pytest.raises(DeliveryError, match="deadline exceeded"), spool:
list(spool)
assert spool.stream.closed and list(tmp_path.iterdir()) == []
+10
View File
@@ -430,6 +430,16 @@ limits, the read-only backup volume and stopped writers. It starts no programs
or services. Python retains its filesystem error cursors from before directory or services. Python retains its filesystem error cursors from before directory
creation; the native child holds its own cursors before file writes. Both cursor creation; the native child holds its own cursors before file writes. Both cursor
sets are synchronized before completion. The original deadlines remain. sets are synchronized before completion. The original deadlines remain.
After the complete private manifest and its objects have been verified, Python
writes the same sorted batches to a bounded, unlinked 0600 temporary descriptor
in the private recovery directory. It checks the complete spool digest and
stable descriptor identity before sending any batch, then releases the decoded
file inventory and selected bundle manifest. Each streamed batch remains bounded;
its descriptor identity, complete stream digest, count and logical byte total
are checked through EOF. The descriptor is closed on success or failure. This
reduces retained planner objects during copying without changing the native
copy, synchronization, full target readback or final witness checks. The spool
adds no recovery memory, CPU, process or time allowance.
Each batch copies, joins both workers, flushes the affected filesystems, then Each batch copies, joins both workers, flushes the affected filesystems, then
reads every target. Final directory sync and stopped/selected-identity checks reads every target. Final directory sync and stopped/selected-identity checks
precede the native inventory's complete metadata recheck and thread joins. precede the native inventory's complete metadata recheck and thread joins.
+8 -3
View File
@@ -348,6 +348,7 @@ def restore(recovery_id: str, backup_id: str, expected: str) -> dict:
deadline = time.monotonic() + 1700 deadline = time.monotonic() + 1700
filesystems = None filesystems = None
native = None native = None
file_count = len(value["files"])
try: try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
stopped() stopped()
@@ -405,11 +406,15 @@ def restore(recovery_id: str, backup_id: str, expected: str) -> dict:
payload, payload,
backup_id, backup_id,
recovery_id, recovery_id,
len(value["files"]), file_count,
backup_manifest.logical_bytes(value), backup_manifest.logical_bytes(value),
deadline, deadline,
) )
native.materialize(value["files"]) # Compatibility and every original private object were verified
# above. Release decoded file rows before copying the full logical
# inventory; the private input spool is completely verified too.
del data
native.materialize_owned(value.pop("files"), recovery)
else: else:
witnesses = file_copy.bounded_copies(value["files"], copy, deadline=deadline) witnesses = file_copy.bounded_copies(value["files"], copy, deadline=deadline)
verified = { verified = {
@@ -459,7 +464,7 @@ def restore(recovery_id: str, backup_id: str, expected: str) -> dict:
record.update( record.update(
data_restored=True, data_restored=True,
restored_payload_bytes=backup_manifest.logical_bytes(value), restored_payload_bytes=backup_manifest.logical_bytes(value),
restored_files=len(value["files"]), restored_files=file_count,
restored_directories=len(value["directories"]), restored_directories=len(value["directories"]),
historical_configuration_active=False, historical_configuration_active=False,
factory_replay_history_retained=True, factory_replay_history_retained=True,
+138 -4
View File
@@ -1,22 +1,141 @@
"""One bounded native data-copy child in the existing exclusive restore unit.""" """One bounded native data-copy child in the existing exclusive restore unit."""
import hashlib
import json import json
import os import os
import selectors import selectors
import signal import signal
import stat import stat
import subprocess import subprocess
import tempfile
import time import time
from collections.abc import Iterator
from pathlib import Path from pathlib import Path
from kf_oj.contracts.restore_io import RestoreIOReply from kf_oj.contracts.restore_io import RestoreIOReply
from . import file_copy, preflight, restore_io from . import backup_manifest, file_copy, preflight, restore_io
from .policy import DeliveryError, invalid_constant, unique_object from .policy import DeliveryError, invalid_constant, unique_object
MAX_REQUEST_BYTES = 256 * 1024 MAX_REQUEST_BYTES = 256 * 1024
MAX_REPLY_BYTES = 64 * 1024 MAX_REPLY_BYTES = 64 * 1024
MAX_STDERR_BYTES = 16 * 1024 MAX_STDERR_BYTES = 16 * 1024
# Only additional list/newline framing beyond the already bounded source metadata.
MAX_SPOOL_BYTES = backup_manifest.MAX_METADATA_BYTES + 4 * backup_manifest.MAX_LOGICAL_ENTRIES
class BatchSpool:
"""Consume validated rows into private, completely verified descriptor-only input."""
def __init__(self, rows: list[dict], directory: Path, deadline: float):
self.deadline = deadline
self.files = len(rows)
self.logical_bytes = sum(row["bytes"] for row in rows)
self.started = self.complete = False
self.size = 0
self.stream = None
restore_io.remaining(deadline)
if self.files > backup_manifest.MAX_LOGICAL_ENTRIES:
raise DeliveryError("recovery planner inventory exceeds bound")
try:
self.stream = tempfile.TemporaryFile(mode="w+b", dir=directory) # noqa: SIM115
# Ownership spans construction, consumption and every failure path.
info = os.fstat(self.stream.fileno())
if (
not stat.S_ISREG(info.st_mode)
or info.st_nlink != 0
or (info.st_uid, info.st_gid) != (os.geteuid(), os.getegid())
or stat.S_IMODE(info.st_mode) != 0o600
or os.get_inheritable(self.stream.fileno())
):
raise DeliveryError("private recovery planner descriptor required")
expected = hashlib.sha256()
batch = None
for batch in restore_io.batches(rows):
restore_io.remaining(deadline)
raw = json.dumps(batch, separators=(",", ":"), ensure_ascii=True).encode() + b"\n"
self.size += len(raw)
if len(raw) > MAX_REQUEST_BYTES or self.size > MAX_SPOOL_BYTES:
raise DeliveryError("recovery planner input exceeds bound")
if self.stream.write(raw) != len(raw):
raise DeliveryError("recovery planner write incomplete")
expected.update(raw)
# No row dictionary or duplicate decoded string remains here while
# the native child materializes the full logical data inventory.
batch = None
rows.clear()
self.stream.flush()
os.fsync(self.stream.fileno())
self.witness = file_copy._identity(os.fstat(self.stream.fileno()))
self.digest = expected.hexdigest()
self.stream.seek(0)
observed = hashlib.file_digest(self.stream, "sha256").hexdigest()
self.unchanged()
if observed != self.digest:
raise DeliveryError("recovery planner complete input verification failed")
self.stream.seek(0)
restore_io.remaining(deadline)
except BaseException:
self.close()
raise
def unchanged(self) -> None:
if (
self.stream is None
or self.stream.closed
or file_copy._identity(os.fstat(self.stream.fileno())) != self.witness
):
raise DeliveryError("recovery planner descriptor changed")
def __iter__(self) -> Iterator[list[dict]]:
if self.started:
raise DeliveryError("recovery planner input already consumed")
self.started = True
files = logical_bytes = 0
observed = hashlib.sha256()
assert self.stream is not None
while True:
restore_io.remaining(self.deadline)
self.unchanged()
raw = self.stream.readline(MAX_REQUEST_BYTES + 1)
if not raw:
break
if len(raw) > MAX_REQUEST_BYTES or not raw.endswith(b"\n"):
raise DeliveryError("recovery planner input framing differs")
observed.update(raw)
batch = json.loads(
raw, object_pairs_hook=unique_object, parse_constant=invalid_constant
)
if not isinstance(batch, list) or not 1 <= len(batch) <= restore_io.BATCH_FILES:
raise DeliveryError("recovery planner batch shape differs")
files += len(batch)
logical_bytes += sum(row["bytes"] for row in batch)
if files > self.files or logical_bytes > self.logical_bytes:
raise DeliveryError("recovery planner inventory differs")
yield batch
self.unchanged()
if (files, logical_bytes, observed.hexdigest()) != (
self.files,
self.logical_bytes,
self.digest,
):
raise DeliveryError("recovery planner complete input differs")
restore_io.remaining(self.deadline)
self.complete = True
def close(self) -> None:
if self.stream is not None:
self.stream.close()
def __enter__(self):
return self
def __exit__(self, kind, _value, _traceback):
try:
if kind is None and not self.complete:
raise DeliveryError("recovery planner input incomplete")
finally:
self.close()
class Session: class Session:
@@ -67,7 +186,11 @@ class Session:
if file_copy._identity(helper.lstat()) != witness: if file_copy._identity(helper.lstat()) != witness:
raise DeliveryError("selected native recovery helper changed") raise DeliveryError("selected native recovery helper changed")
assert self.process.stdin and self.process.stdout and self.process.stderr assert self.process.stdin and self.process.stdout and self.process.stderr
for handle in (self.process.stdin, self.process.stdout, self.process.stderr): for handle in (
self.process.stdin,
self.process.stdout,
self.process.stderr,
):
os.set_blocking(handle.fileno(), False) os.set_blocking(handle.fileno(), False)
self.selector.register(self.process.stdout, selectors.EVENT_READ, "stdout") self.selector.register(self.process.stdout, selectors.EVENT_READ, "stdout")
self.selector.register(self.process.stderr, selectors.EVENT_READ, "stderr") self.selector.register(self.process.stderr, selectors.EVENT_READ, "stderr")
@@ -145,7 +268,11 @@ class Session:
if ( if (
reply.stage != stage reply.stage != stage
or reply.error != "none" or reply.error != "none"
or (reply.batch_id, reply.verified_files, reply.verified_logical_bytes) or (
reply.batch_id,
reply.verified_files,
reply.verified_logical_bytes,
)
!= (self.batch_id, self.files, self.bytes) != (self.batch_id, self.files, self.bytes)
): ):
raise DeliveryError("native recovery file verification refused") raise DeliveryError("native recovery file verification refused")
@@ -158,7 +285,14 @@ class Session:
self.selector.unregister(self.process.stdin) self.selector.unregister(self.process.stdin)
def materialize(self, rows: list[dict]) -> None: def materialize(self, rows: list[dict]) -> None:
for batch in restore_io.batches(rows): self._materialize(restore_io.batches(rows))
def materialize_owned(self, rows: list[dict], directory: Path) -> None:
with BatchSpool(rows, directory, self.deadline) as batches:
self._materialize(iter(batches))
def _materialize(self, batches: Iterator[list[dict]]) -> None:
for batch in batches:
self.batch_id += 1 self.batch_id += 1
self.files += len(batch) self.files += len(batch)
self.bytes += sum(item["bytes"] for item in batch) self.bytes += sum(item["bytes"] for item in batch)