perf(delivery): release restored inventory before copying
This commit is contained in:
@@ -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()) == []
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user