perf(delivery): bound copy I/O and streamline metadata scans
This commit is contained in:
@@ -108,6 +108,7 @@ jobs:
|
|||||||
tests/test_dependency_errors.py \
|
tests/test_dependency_errors.py \
|
||||||
tests/test_production_logging.py \
|
tests/test_production_logging.py \
|
||||||
tests/test_delivery.py \
|
tests/test_delivery.py \
|
||||||
|
tests/test_delivery_io.py \
|
||||||
tests/test_operator_imports.py \
|
tests/test_operator_imports.py \
|
||||||
tests/test_health.py \
|
tests/test_health.py \
|
||||||
tests/test_identity.py \
|
tests/test_identity.py \
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import importlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -200,3 +202,114 @@ def test_fixed_regular_transfer_preserves_exact_bytes_and_offsets(
|
|||||||
file_copy.transfer(incoming, outgoing, length, deadline=time.monotonic() + 30)
|
file_copy.transfer(incoming, outgoing, length, deadline=time.monotonic() + 30)
|
||||||
assert target.read_bytes() == content
|
assert target.read_bytes() == content
|
||||||
assert policy.digest(target) == policy.digest(source)
|
assert policy.digest(target) == policy.digest(source)
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_inventory_prefetch_is_bounded_and_results_keep_input_order():
|
||||||
|
# Trusted callbacks only: ordinary concurrency control, no VM/Root acceptance.
|
||||||
|
release, saturated, second = (threading.Event() for _ in range(3))
|
||||||
|
lock = threading.Lock()
|
||||||
|
consumed, results, errors = [], [], []
|
||||||
|
active = maximum = 0
|
||||||
|
|
||||||
|
class Inventory(Sequence):
|
||||||
|
def __len__(self):
|
||||||
|
return 32
|
||||||
|
|
||||||
|
def __getitem__(self, index):
|
||||||
|
if not 0 <= index < len(self):
|
||||||
|
raise IndexError
|
||||||
|
with lock:
|
||||||
|
consumed.append(index)
|
||||||
|
return index
|
||||||
|
|
||||||
|
def copy(index):
|
||||||
|
nonlocal active, maximum
|
||||||
|
with lock:
|
||||||
|
active += 1
|
||||||
|
maximum = max(active, maximum)
|
||||||
|
if active == 4:
|
||||||
|
saturated.set()
|
||||||
|
try:
|
||||||
|
assert release.wait(5)
|
||||||
|
if index == 0:
|
||||||
|
assert second.wait(5)
|
||||||
|
if index == 1:
|
||||||
|
second.set()
|
||||||
|
return index * 3
|
||||||
|
finally:
|
||||||
|
with lock:
|
||||||
|
active -= 1
|
||||||
|
|
||||||
|
def collect():
|
||||||
|
try:
|
||||||
|
results.extend(file_copy.bounded_copies(Inventory(), copy))
|
||||||
|
except BaseException as error:
|
||||||
|
errors.append(error)
|
||||||
|
|
||||||
|
collector = threading.Thread(target=collect)
|
||||||
|
collector.start()
|
||||||
|
try:
|
||||||
|
assert saturated.wait(5)
|
||||||
|
with lock:
|
||||||
|
assert consumed == [0, 1, 2, 3]
|
||||||
|
assert maximum == 4
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
collector.join(10)
|
||||||
|
assert not collector.is_alive() and not errors
|
||||||
|
assert results == [index * 3 for index in range(32)]
|
||||||
|
assert active == 0 and maximum == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_failure_joins_dispatched_calls_before_propagating():
|
||||||
|
# No files/privileges: observe failure quiescence of trusted callbacks.
|
||||||
|
together = threading.Barrier(4)
|
||||||
|
release, failing, returned = (threading.Event() for _ in range(3))
|
||||||
|
lock = threading.Lock()
|
||||||
|
active = 0
|
||||||
|
errors, active_when_returned = [], []
|
||||||
|
|
||||||
|
def copy(index):
|
||||||
|
nonlocal active
|
||||||
|
with lock:
|
||||||
|
active += 1
|
||||||
|
try:
|
||||||
|
if index < 4:
|
||||||
|
together.wait(timeout=5)
|
||||||
|
if index == 0:
|
||||||
|
failing.set()
|
||||||
|
raise policy.DeliveryError("ordinary callback refusal model")
|
||||||
|
assert release.wait(5)
|
||||||
|
return index
|
||||||
|
finally:
|
||||||
|
with lock:
|
||||||
|
active -= 1
|
||||||
|
|
||||||
|
def collect():
|
||||||
|
try:
|
||||||
|
file_copy.bounded_copies(list(range(32)), copy)
|
||||||
|
except BaseException as error:
|
||||||
|
errors.append(error)
|
||||||
|
finally:
|
||||||
|
with lock:
|
||||||
|
active_when_returned.append(active)
|
||||||
|
returned.set()
|
||||||
|
|
||||||
|
collector = threading.Thread(target=collect)
|
||||||
|
collector.start()
|
||||||
|
try:
|
||||||
|
assert failing.wait(5)
|
||||||
|
assert not returned.wait(0.05)
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
collector.join(10)
|
||||||
|
assert not collector.is_alive() and active_when_returned == [0]
|
||||||
|
assert len(errors) == 1 and isinstance(errors[0], policy.DeliveryError)
|
||||||
|
assert str(errors[0]) == "ordinary callback refusal model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_copy_deadline_refuses_before_dispatch():
|
||||||
|
calls = []
|
||||||
|
with pytest.raises(policy.DeliveryError, match="copy deadline exceeded"):
|
||||||
|
file_copy.bounded_copies(list(range(8)), calls.append, deadline=time.monotonic() - 1)
|
||||||
|
assert calls == []
|
||||||
|
|||||||
@@ -408,6 +408,12 @@ failure. Further bundles receive full verification; separate commands start fres
|
|||||||
Fixed-size regular-file copies can use Linux kernel transfer with bounded chunks.
|
Fixed-size regular-file copies can use Linux kernel transfer with bounded chunks.
|
||||||
Unsupported transfer falls back at the same offsets. Source size/EOF, file fsync
|
Unsupported transfer falls back at the same offsets. Source size/EOF, file fsync
|
||||||
and independent destination byte verification remain required.
|
and independent destination byte verification remain required.
|
||||||
|
Verified installer and stopped-node recovery inventories can keep at most four
|
||||||
|
file operations in flight. Each file retains its durability and byte checks;
|
||||||
|
directory finalization and success receipts wait for all dispatched work. On
|
||||||
|
failure, queued work is cancelled and running calls are joined before the error
|
||||||
|
returns. Private partial data is retained for inspection. The original operator
|
||||||
|
CPU, memory, PID and deadline limits still apply.
|
||||||
Old configuration and maintenance receipts go into inactive
|
Old configuration and maintenance receipts go into inactive
|
||||||
`recovery/ID/history/`. The new boot's Sandbox preparation owns cgroup recreation.
|
`recovery/ID/history/`. The new boot's Sandbox preparation owns cgroup recreation.
|
||||||
Unknown/nonempty targets and differing existing files are refused; partial restores
|
Unknown/nonempty targets and differing existing files are refused; partial restores
|
||||||
|
|||||||
@@ -4,12 +4,63 @@ import errno
|
|||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
import time
|
import time
|
||||||
from typing import BinaryIO
|
from collections.abc import Callable, Sequence
|
||||||
|
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||||
|
from itertools import islice
|
||||||
|
from typing import BinaryIO, cast
|
||||||
|
|
||||||
from .policy import MAX_FILE_BYTES, DeliveryError
|
from .policy import MAX_FILE_BYTES, DeliveryError
|
||||||
|
|
||||||
CHUNK = 1024 * 1024
|
CHUNK = 1024 * 1024
|
||||||
UNSUPPORTED = {errno.ENOSYS, errno.EINVAL, errno.EOPNOTSUPP, errno.EXDEV}
|
UNSUPPORTED = {errno.ENOSYS, errno.EINVAL, errno.EOPNOTSUPP, errno.EXDEV}
|
||||||
|
IN_FLIGHT = 4
|
||||||
|
|
||||||
|
|
||||||
|
def bounded_copies[Item, Result](
|
||||||
|
rows: Sequence[Item],
|
||||||
|
operation: Callable[[Item], Result],
|
||||||
|
*,
|
||||||
|
deadline: float | None = None,
|
||||||
|
) -> list[Result]:
|
||||||
|
"""Join a finite verified inventory, with at most four operations in flight."""
|
||||||
|
|
||||||
|
def remaining() -> float | None:
|
||||||
|
if deadline is None:
|
||||||
|
return None
|
||||||
|
value = deadline - time.monotonic()
|
||||||
|
if value <= 0:
|
||||||
|
raise DeliveryError("copy deadline exceeded; private partial target retained")
|
||||||
|
return value
|
||||||
|
|
||||||
|
remaining()
|
||||||
|
if len(rows) < 2:
|
||||||
|
return [operation(row) for row in rows]
|
||||||
|
results: list[Result | None] = [None] * len(rows)
|
||||||
|
source = iter(enumerate(rows))
|
||||||
|
with ThreadPoolExecutor(max_workers=min(IN_FLIGHT, len(rows))) as executor:
|
||||||
|
pending = {
|
||||||
|
executor.submit(operation, row): index for index, row in islice(source, IN_FLIGHT)
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
while pending:
|
||||||
|
ready, _ = wait(pending, timeout=remaining(), return_when=FIRST_COMPLETED)
|
||||||
|
if not ready:
|
||||||
|
raise DeliveryError("copy deadline exceeded; private partial target retained")
|
||||||
|
# Observe every completed failure before dispatching more work.
|
||||||
|
completed = [(pending.pop(future), future.result()) for future in ready]
|
||||||
|
for index, result in completed:
|
||||||
|
results[index] = result
|
||||||
|
following = next(source, None)
|
||||||
|
if following is not None:
|
||||||
|
index, row = following
|
||||||
|
remaining()
|
||||||
|
pending[executor.submit(operation, row)] = index
|
||||||
|
finally:
|
||||||
|
for future in pending:
|
||||||
|
future.cancel()
|
||||||
|
# The executor joins running calls before this function can return
|
||||||
|
# or propagate an error. No success receipt can outrun file I/O.
|
||||||
|
return cast(list[Result], results)
|
||||||
|
|
||||||
|
|
||||||
def _identity(info: os.stat_result) -> tuple[int, ...]:
|
def _identity(info: os.stat_result) -> tuple[int, ...]:
|
||||||
|
|||||||
@@ -235,7 +235,8 @@ def frozen_copy(
|
|||||||
target.mkdir(mode=0o700)
|
target.mkdir(mode=0o700)
|
||||||
for name in sorted(directories, key=lambda name: (name.count("/"), name)):
|
for name in sorted(directories, key=lambda name: (name.count("/"), name)):
|
||||||
(target / name).mkdir(mode=0o700)
|
(target / name).mkdir(mode=0o700)
|
||||||
for record in records:
|
|
||||||
|
def copy(record: dict) -> None:
|
||||||
# The complete sealed tree is read back immediately below. Avoid
|
# The complete sealed tree is read back immediately below. Avoid
|
||||||
# hashing each destination both here and again in that inventory.
|
# hashing each destination both here and again in that inventory.
|
||||||
_copy_verified(
|
_copy_verified(
|
||||||
@@ -246,6 +247,8 @@ def frozen_copy(
|
|||||||
readback=False,
|
readback=False,
|
||||||
expected_bytes=record["bytes"],
|
expected_bytes=record["bytes"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
file_copy.bounded_copies(records, copy)
|
||||||
for name in sorted(directories, key=lambda name: name.count("/"), reverse=True):
|
for name in sorted(directories, key=lambda name: name.count("/"), reverse=True):
|
||||||
(target / name).chmod(0o555)
|
(target / name).chmod(0o555)
|
||||||
sync_directory(target / name)
|
sync_directory(target / name)
|
||||||
|
|||||||
+47
-30
@@ -45,24 +45,24 @@ _MAX_SCOPED_METADATA = 2 * MAX_METADATA_BYTES
|
|||||||
|
|
||||||
def _bundle_signature(bundle: Path) -> dict[str, tuple[int, ...]]:
|
def _bundle_signature(bundle: Path) -> dict[str, tuple[int, ...]]:
|
||||||
"""Observe topology and all metadata that can invalidate a prior byte read."""
|
"""Observe topology and all metadata that can invalidate a prior byte read."""
|
||||||
if not bundle.is_dir() or bundle.is_symlink():
|
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
||||||
raise DeliveryError("bundle directory required")
|
descriptor = os.open(bundle, flags)
|
||||||
members = set()
|
members = {}
|
||||||
for path in bundle.iterdir():
|
try:
|
||||||
members.add(path.name)
|
root = os.fstat(descriptor)
|
||||||
if len(members) > 2:
|
with os.scandir(descriptor) as entries:
|
||||||
raise DeliveryError("unexpected bundle member")
|
for entry in entries:
|
||||||
if members != {"manifest.json", "payload"}:
|
members[entry.name] = entry.stat(follow_symlinks=False)
|
||||||
|
if len(members) > 2:
|
||||||
|
raise DeliveryError("unexpected bundle member")
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
if set(members) != {"manifest.json", "payload"}:
|
||||||
raise DeliveryError("unexpected bundle member")
|
raise DeliveryError("unexpected bundle member")
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
def add(path: Path, *, directory: bool) -> None:
|
def identity(info: os.stat_result) -> tuple[int, ...]:
|
||||||
info = path.lstat()
|
return (
|
||||||
if not (stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)):
|
|
||||||
raise DeliveryError("bundle proof requires ordinary directories and files")
|
|
||||||
if not directory and info.st_nlink != 1:
|
|
||||||
raise DeliveryError("bundle proof requires single-link files")
|
|
||||||
result[path.relative_to(bundle).as_posix()] = (
|
|
||||||
info.st_dev,
|
info.st_dev,
|
||||||
info.st_ino,
|
info.st_ino,
|
||||||
info.st_mode,
|
info.st_mode,
|
||||||
@@ -73,25 +73,42 @@ def _bundle_signature(bundle: Path) -> dict[str, tuple[int, ...]]:
|
|||||||
info.st_mtime_ns,
|
info.st_mtime_ns,
|
||||||
info.st_ctime_ns,
|
info.st_ctime_ns,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def add(name: str, info: os.stat_result, *, directory: bool) -> None:
|
||||||
|
if not (stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)):
|
||||||
|
raise DeliveryError("bundle proof requires ordinary directories and files")
|
||||||
|
if not directory and info.st_nlink != 1:
|
||||||
|
raise DeliveryError("bundle proof requires single-link files")
|
||||||
|
result[name] = identity(info)
|
||||||
if len(result) > MAX_FILES + 3:
|
if len(result) > MAX_FILES + 3:
|
||||||
raise DeliveryError("bundle proof entry count limit")
|
raise DeliveryError("bundle proof entry count limit")
|
||||||
|
|
||||||
def failed(error: OSError) -> None:
|
add(".", root, directory=True)
|
||||||
raise error
|
add("manifest.json", members["manifest.json"], directory=False)
|
||||||
|
|
||||||
add(bundle, directory=True)
|
|
||||||
add(bundle / "manifest.json", directory=False)
|
|
||||||
payload = bundle / "payload"
|
payload = bundle / "payload"
|
||||||
add(payload, directory=True)
|
add("payload", members["payload"], directory=True)
|
||||||
for directory, names, files in os.walk(payload, followlinks=False, onerror=failed):
|
pending = [(str(payload), "", result["payload"])]
|
||||||
for name in names:
|
while pending:
|
||||||
path = Path(directory) / name
|
directory, prefix, expected = pending.pop()
|
||||||
relative_name(path.relative_to(payload).as_posix() + "/.__directory__")
|
# Close each iterator before visiting another directory. Wide or deep
|
||||||
add(path, directory=True)
|
# verified inventories never require one open descriptor per directory.
|
||||||
for name in files:
|
descriptor = os.open(directory, flags)
|
||||||
path = Path(directory) / name
|
try:
|
||||||
relative_name(path.relative_to(payload).as_posix())
|
if identity(os.fstat(descriptor)) != expected:
|
||||||
add(path, directory=False)
|
raise DeliveryError("verified bundle changed during the operator operation")
|
||||||
|
with os.scandir(descriptor) as entries:
|
||||||
|
for entry in entries:
|
||||||
|
name = prefix + entry.name
|
||||||
|
info = entry.stat(follow_symlinks=False)
|
||||||
|
is_directory = stat.S_ISDIR(info.st_mode)
|
||||||
|
relative_name(name + "/.__directory__" if is_directory else name)
|
||||||
|
add("payload/" + name, info, directory=is_directory)
|
||||||
|
if is_directory:
|
||||||
|
pending.append(
|
||||||
|
(os.path.join(directory, entry.name), name + "/", identity(info))
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+12
-10
@@ -121,7 +121,7 @@ def target(record: dict, recovery: Path) -> tuple[Path, int, int, int]:
|
|||||||
return path, 0, 0, 0o700 if "bytes" not in record else 0o600
|
return path, 0, 0, 0o700 if "bytes" not in record else 0o600
|
||||||
|
|
||||||
|
|
||||||
def member(path: Path, uid: int, gid: int, mode: int, *, directory: bool) -> None:
|
def member(path: Path, uid: int, gid: int, mode: int, *, directory: bool) -> os.stat_result:
|
||||||
info = path.lstat()
|
info = path.lstat()
|
||||||
if (
|
if (
|
||||||
(info.st_uid, info.st_gid, stat.S_IMODE(info.st_mode)) != (uid, gid, mode)
|
(info.st_uid, info.st_gid, stat.S_IMODE(info.st_mode)) != (uid, gid, mode)
|
||||||
@@ -129,6 +129,7 @@ def member(path: Path, uid: int, gid: int, mode: int, *, directory: bool) -> Non
|
|||||||
or (not directory and info.st_nlink != 1)
|
or (not directory and info.st_nlink != 1)
|
||||||
):
|
):
|
||||||
raise DeliveryError("existing recovery destination differs; no overwrite")
|
raise DeliveryError("existing recovery destination differs; no overwrite")
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
def file_witness(info: os.stat_result) -> tuple[int, ...]:
|
def file_witness(info: os.stat_result) -> tuple[int, ...]:
|
||||||
@@ -148,8 +149,7 @@ def file_witness(info: os.stat_result) -> tuple[int, ...]:
|
|||||||
def readback(
|
def readback(
|
||||||
path: Path, item: dict, uid: int, gid: int, mode: int, deadline: float
|
path: Path, item: dict, uid: int, gid: int, mode: int, deadline: float
|
||||||
) -> tuple[int, ...]:
|
) -> tuple[int, ...]:
|
||||||
member(path, uid, gid, mode, directory=False)
|
before = file_witness(member(path, uid, gid, mode, directory=False))
|
||||||
before = file_witness(path.lstat())
|
|
||||||
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||||
with os.fdopen(descriptor, "rb") as stream:
|
with os.fdopen(descriptor, "rb") as stream:
|
||||||
if file_witness(os.fstat(stream.fileno())) != before:
|
if file_witness(os.fstat(stream.fileno())) != before:
|
||||||
@@ -169,8 +169,7 @@ def readback(
|
|||||||
|
|
||||||
|
|
||||||
def unchanged_file(path: Path, witness: tuple[int, ...], uid: int, gid: int, mode: int) -> None:
|
def unchanged_file(path: Path, witness: tuple[int, ...], uid: int, gid: int, mode: int) -> None:
|
||||||
member(path, uid, gid, mode, directory=False)
|
if file_witness(member(path, uid, gid, mode, directory=False)) != witness:
|
||||||
if file_witness(path.lstat()) != witness:
|
|
||||||
raise DeliveryError("verified recovery file changed before completion")
|
raise DeliveryError("verified recovery file changed before completion")
|
||||||
|
|
||||||
|
|
||||||
@@ -290,12 +289,15 @@ def restore(recovery_id: str, backup_id: str, expected: str) -> dict:
|
|||||||
free = os.statvfs(Path("/var/lib/kf-onlinejudge"))
|
free = os.statvfs(Path("/var/lib/kf-onlinejudge"))
|
||||||
if free.f_frsize * free.f_bavail < value["payload_bytes"] + 128 * 1024**2:
|
if free.f_frsize * free.f_bavail < value["payload_bytes"] + 128 * 1024**2:
|
||||||
raise DeliveryError("cold recovery destination reserve insufficient")
|
raise DeliveryError("cold recovery destination reserve insufficient")
|
||||||
verified = {}
|
|
||||||
for item in value["files"]:
|
def copy(item: dict) -> tuple[int, ...]:
|
||||||
path, uid, gid, mode = target(item, recovery)
|
path, uid, gid, mode = target(item, recovery)
|
||||||
verified[item["path"]] = write_file(
|
return write_file(root / "payload" / item["path"], path, item, uid, gid, mode, deadline)
|
||||||
root / "payload" / item["path"], path, item, uid, gid, mode, deadline
|
|
||||||
)
|
witnesses = file_copy.bounded_copies(value["files"], copy, deadline=deadline)
|
||||||
|
verified = {
|
||||||
|
item["path"]: witness for item, witness in zip(value["files"], witnesses, strict=True)
|
||||||
|
}
|
||||||
for item in sorted(
|
for item in sorted(
|
||||||
value["directories"], key=lambda row: row["path"].count("/"), reverse=True
|
value["directories"], key=lambda row: row["path"].count("/"), reverse=True
|
||||||
):
|
):
|
||||||
|
|||||||
Reference in New Issue
Block a user