Added download settings and optimized the downloader system with concurrency.
This commit is contained in:
591
ptlinux/depot.py
591
ptlinux/depot.py
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from base64 import b64decode
|
||||
from binascii import Error as BinAsciiError
|
||||
from collections import deque
|
||||
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
@@ -35,8 +36,15 @@ FALLBACK_SERVERS = (
|
||||
READ_SIZE = 128 * 1024
|
||||
HASH_READ_SIZE = 1024 * 1024
|
||||
DOWNLOAD_WORKERS = 8
|
||||
DOWNLOAD_INITIAL_WORKERS = DOWNLOAD_WORKERS
|
||||
LOW_BANDWIDTH_WORKERS = 2
|
||||
LOW_BANDWIDTH_BYTES_PER_SECOND = 5_000_000
|
||||
DOWNLOAD_RETRY_DELAYS_SECONDS = (1.0, 2.0, 4.0, 8.0, 16.0)
|
||||
PROGRESS_UPDATE_INTERVAL_SECONDS = 0.075
|
||||
ADAPTIVE_EVALUATION_INTERVAL_SECONDS = 1.0
|
||||
ADAPTIVE_RETRY_WINDOW_SECONDS = 15.0
|
||||
ADAPTIVE_RETRY_RECOVERY_SECONDS = 5.0
|
||||
TRANSFER_RATE_WINDOW_SECONDS = 5.0
|
||||
MAX_CREATE_IF_MISSING_BYTES = 16 * 1024 * 1024
|
||||
MAX_LINE_ENDING_RECOVERY_BYTES = 1024 * 1024
|
||||
UNMANAGED_EXECUTABLE_SUFFIXES = frozenset(
|
||||
@@ -88,6 +96,275 @@ class _RetryableDownloadError(DepotError):
|
||||
self.discard_partial = discard_partial
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloadOptions:
|
||||
"""User-selected limits around the adaptive depot scheduler."""
|
||||
|
||||
bandwidth_limit_bytes_per_second: int | None = None
|
||||
low_bandwidth: bool = False
|
||||
minimum_workers: int = 1
|
||||
maximum_workers: int | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.low_bandwidth, bool):
|
||||
raise ValueError("low_bandwidth must be a boolean")
|
||||
if (
|
||||
self.bandwidth_limit_bytes_per_second is not None
|
||||
and (
|
||||
isinstance(self.bandwidth_limit_bytes_per_second, bool)
|
||||
or self.bandwidth_limit_bytes_per_second <= 0
|
||||
)
|
||||
):
|
||||
raise ValueError("bandwidth limit must be positive or None")
|
||||
if isinstance(self.minimum_workers, bool) or self.minimum_workers <= 0:
|
||||
raise ValueError("minimum download workers must be positive")
|
||||
if self.maximum_workers is not None and (
|
||||
isinstance(self.maximum_workers, bool) or self.maximum_workers <= 0
|
||||
):
|
||||
raise ValueError("maximum download workers must be positive or None")
|
||||
if (
|
||||
self.maximum_workers is not None
|
||||
and self.minimum_workers > self.maximum_workers
|
||||
):
|
||||
raise ValueError("minimum download workers exceed the maximum")
|
||||
if self.low_bandwidth and self.minimum_workers > LOW_BANDWIDTH_WORKERS:
|
||||
raise ValueError("low-bandwidth minimum workers exceed its worker cap")
|
||||
|
||||
@property
|
||||
def effective_bandwidth_limit(self) -> int | None:
|
||||
configured = self.bandwidth_limit_bytes_per_second
|
||||
if not self.low_bandwidth:
|
||||
return configured
|
||||
return min(
|
||||
configured if configured is not None else LOW_BANDWIDTH_BYTES_PER_SECOND,
|
||||
LOW_BANDWIDTH_BYTES_PER_SECOND,
|
||||
)
|
||||
|
||||
@property
|
||||
def effective_maximum_workers(self) -> int:
|
||||
configured = self.maximum_workers or DOWNLOAD_WORKERS
|
||||
if self.low_bandwidth:
|
||||
configured = min(configured, LOW_BANDWIDTH_WORKERS)
|
||||
return max(self.minimum_workers, configured)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloadTelemetry:
|
||||
"""One internal measurement produced while retrieving a depot file."""
|
||||
|
||||
kind: str
|
||||
file: str
|
||||
server: str
|
||||
attempt: int
|
||||
latency_seconds: float | None = None
|
||||
bytes_written: int = 0
|
||||
write_seconds: float = 0.0
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
DownloadObserver = Callable[[DownloadTelemetry], None]
|
||||
|
||||
|
||||
class BandwidthLimiter:
|
||||
"""Thread-safe aggregate byte-rate limiter with a one-chunk burst allowance."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bytes_per_second: int,
|
||||
*,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
sleeper: Callable[[float], None] = time.sleep,
|
||||
) -> None:
|
||||
if isinstance(bytes_per_second, bool) or bytes_per_second <= 0:
|
||||
raise ValueError("bandwidth limit must be positive")
|
||||
self.bytes_per_second = bytes_per_second
|
||||
self._clock = clock
|
||||
self._sleeper = sleeper
|
||||
self._lock = threading.Lock()
|
||||
self._next_available_at = clock()
|
||||
|
||||
def consume(self, byte_count: int) -> None:
|
||||
"""Wait until this newly received chunk's aggregate reservation begins.
|
||||
|
||||
The network read happens before this call. Reserving the chunk's *start*
|
||||
keeps all workers under one aggregate rate without charging again for time
|
||||
already spent receiving it. The first chunk is the bucket's burst allowance.
|
||||
"""
|
||||
|
||||
if byte_count < 0:
|
||||
raise ValueError("byte count must not be negative")
|
||||
if byte_count == 0:
|
||||
return
|
||||
with self._lock:
|
||||
now = self._clock()
|
||||
start = max(now, self._next_available_at)
|
||||
self._next_available_at = start + (byte_count / self.bytes_per_second)
|
||||
delay = start - now
|
||||
if delay > 0:
|
||||
self._sleeper(delay)
|
||||
|
||||
|
||||
class AdaptiveConcurrencyController:
|
||||
"""Select a bounded worker target from live network, host, and plan data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
options: DownloadOptions,
|
||||
*,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
cpu_load: Callable[[], float | None] | None = None,
|
||||
) -> None:
|
||||
self.options = options
|
||||
self._clock = clock
|
||||
self._cpu_load = cpu_load or _normalized_cpu_load
|
||||
self._lock = threading.Lock()
|
||||
self._target = max(
|
||||
options.minimum_workers,
|
||||
min(DOWNLOAD_INITIAL_WORKERS, options.effective_maximum_workers),
|
||||
)
|
||||
self._last_evaluation = clock()
|
||||
self._transferred_bytes = 0
|
||||
self._transfer_samples: deque[tuple[float, int]] = deque(
|
||||
[(self._last_evaluation, 0)]
|
||||
)
|
||||
self._response_samples: deque[tuple[float, float]] = deque()
|
||||
self._retry_samples: deque[float] = deque()
|
||||
self._retry_count = 0
|
||||
self._handled_retry_count = 0
|
||||
self._last_retry_at: float | None = None
|
||||
self._latency_seconds: float | None = None
|
||||
self._disk_samples: deque[tuple[float, int, float]] = deque()
|
||||
|
||||
def record_response(self, latency_seconds: float) -> None:
|
||||
if latency_seconds < 0:
|
||||
return
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
self._response_samples.append((now, latency_seconds))
|
||||
self._prune_health_samples(now)
|
||||
if self._latency_seconds is None:
|
||||
self._latency_seconds = latency_seconds
|
||||
else:
|
||||
self._latency_seconds = (
|
||||
(self._latency_seconds * 0.75) + (latency_seconds * 0.25)
|
||||
)
|
||||
|
||||
def record_retry(self) -> None:
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
self._retry_count += 1
|
||||
self._retry_samples.append(now)
|
||||
self._last_retry_at = now
|
||||
self._prune_health_samples(now)
|
||||
|
||||
def record_chunk(self, byte_count: int, write_seconds: float) -> None:
|
||||
if byte_count <= 0:
|
||||
return
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
self._transferred_bytes += byte_count
|
||||
self._transfer_samples.append((now, self._transferred_bytes))
|
||||
_prune_transfer_samples(self._transfer_samples, now)
|
||||
self._disk_samples.append((now, byte_count, max(0.0, write_seconds)))
|
||||
self._prune_disk_samples(now)
|
||||
|
||||
def _prune_health_samples(self, now: float) -> None:
|
||||
cutoff = now - ADAPTIVE_RETRY_WINDOW_SECONDS
|
||||
while self._response_samples and self._response_samples[0][0] < cutoff:
|
||||
self._response_samples.popleft()
|
||||
while self._retry_samples and self._retry_samples[0] < cutoff:
|
||||
self._retry_samples.popleft()
|
||||
|
||||
def _prune_disk_samples(self, now: float) -> None:
|
||||
cutoff = now - TRANSFER_RATE_WINDOW_SECONDS
|
||||
while self._disk_samples and self._disk_samples[0][0] < cutoff:
|
||||
self._disk_samples.popleft()
|
||||
|
||||
def target_workers(self, remaining_files: int, remaining_bytes: int) -> int:
|
||||
"""Return the next target; active workers are allowed to drain naturally."""
|
||||
|
||||
if remaining_files <= 0:
|
||||
return 0
|
||||
maximum = min(self.options.effective_maximum_workers, remaining_files)
|
||||
minimum = min(self.options.minimum_workers, maximum)
|
||||
with self._lock:
|
||||
self._target = min(maximum, max(minimum, self._target))
|
||||
now = self._clock()
|
||||
average_file_size = remaining_bytes / max(1, remaining_files)
|
||||
if now - self._last_evaluation < ADAPTIVE_EVALUATION_INTERVAL_SECONDS:
|
||||
return self._target
|
||||
self._last_evaluation = now
|
||||
_prune_transfer_samples(self._transfer_samples, now)
|
||||
self._prune_health_samples(now)
|
||||
self._prune_disk_samples(now)
|
||||
rate = _sample_rate(self._transfer_samples)
|
||||
attempts = len(self._response_samples) + len(self._retry_samples)
|
||||
error_rate = len(self._retry_samples) / attempts if attempts else 0.0
|
||||
latency = self._latency_seconds
|
||||
disk_bytes = sum(sample[1] for sample in self._disk_samples)
|
||||
disk_seconds = sum(sample[2] for sample in self._disk_samples)
|
||||
disk_rate = (
|
||||
disk_bytes / disk_seconds
|
||||
if disk_seconds > 0
|
||||
else None
|
||||
)
|
||||
try:
|
||||
cpu_load = self._cpu_load()
|
||||
except (OSError, ValueError):
|
||||
cpu_load = None
|
||||
|
||||
new_retries = self._retry_count - self._handled_retry_count
|
||||
if new_retries:
|
||||
self._handled_retry_count = self._retry_count
|
||||
if error_rate >= 0.12 and self._target > minimum:
|
||||
backoff = min(2, new_retries)
|
||||
self._target = max(minimum, self._target - backoff)
|
||||
return self._target
|
||||
|
||||
resource_constrained = (
|
||||
(cpu_load is not None and cpu_load >= 0.90)
|
||||
or (
|
||||
rate is not None
|
||||
and disk_rate is not None
|
||||
and disk_rate < rate * 0.75
|
||||
)
|
||||
)
|
||||
if resource_constrained and self._target > minimum:
|
||||
self._target -= 1
|
||||
return self._target
|
||||
|
||||
limit = self.options.effective_bandwidth_limit
|
||||
limit_saturated = (
|
||||
limit is not None and rate is not None and rate >= limit * 0.90
|
||||
)
|
||||
healthy = (
|
||||
(cpu_load is None or cpu_load < 0.80)
|
||||
and (
|
||||
rate is None
|
||||
or disk_rate is None
|
||||
or disk_rate >= rate
|
||||
)
|
||||
)
|
||||
retry_recovery_pending = (
|
||||
self._last_retry_at is not None
|
||||
and now - self._last_retry_at < ADAPTIVE_RETRY_RECOVERY_SECONDS
|
||||
)
|
||||
if (
|
||||
healthy
|
||||
and not retry_recovery_pending
|
||||
and not limit_saturated
|
||||
and self._target < maximum
|
||||
):
|
||||
# Parallel requests hide CDN round-trip time and are especially useful
|
||||
# for this depot's large population of small files.
|
||||
step = 2 if (
|
||||
(latency is not None and latency >= 0.75)
|
||||
or average_file_size <= 512 * 1024
|
||||
) else 1
|
||||
self._target = min(maximum, self._target + step)
|
||||
return min(maximum, max(minimum, self._target))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DepotFile:
|
||||
"""One exact file in the published depot build."""
|
||||
@@ -258,6 +535,14 @@ class SyncProgress:
|
||||
bytes_total: int
|
||||
current_file: str | None
|
||||
bytes_transferred: int = 0
|
||||
bytes_remaining: int = 0
|
||||
transfer_rate_bytes_per_second: float | None = None
|
||||
eta_seconds: float | None = None
|
||||
current_server: str | None = None
|
||||
retry_count: int = 0
|
||||
retry_reason: str | None = None
|
||||
active_workers: int = 0
|
||||
target_workers: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -315,6 +600,7 @@ class DepotClient:
|
||||
opener: HttpOpener = urlopen,
|
||||
retry_delays: tuple[float, ...] = DOWNLOAD_RETRY_DELAYS_SECONDS,
|
||||
sleeper: Callable[[float], None] = time.sleep,
|
||||
download_observer: DownloadObserver | None = None,
|
||||
) -> None:
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
@@ -325,6 +611,24 @@ class DepotClient:
|
||||
self._retry_delays = retry_delays
|
||||
self._sleeper = sleeper
|
||||
self._candidate_servers: tuple[str, ...] = ()
|
||||
self._download_observer = download_observer
|
||||
self._bandwidth_limiter: BandwidthLimiter | None = None
|
||||
|
||||
@property
|
||||
def download_observer(self) -> DownloadObserver | None:
|
||||
return self._download_observer
|
||||
|
||||
@download_observer.setter
|
||||
def download_observer(self, observer: DownloadObserver | None) -> None:
|
||||
self._download_observer = observer
|
||||
|
||||
@property
|
||||
def bandwidth_limiter(self) -> BandwidthLimiter | None:
|
||||
return self._bandwidth_limiter
|
||||
|
||||
@bandwidth_limiter.setter
|
||||
def bandwidth_limiter(self, limiter: BandwidthLimiter | None) -> None:
|
||||
self._bandwidth_limiter = limiter
|
||||
|
||||
def fetch_snapshot(self, *, preferred_region: str = "Europe") -> DepotSnapshot:
|
||||
"""Fetch the live depot and build manifests, trying trusted regional fallbacks."""
|
||||
@@ -379,11 +683,21 @@ class DepotClient:
|
||||
attempt_server,
|
||||
file,
|
||||
destination,
|
||||
attempt=attempt_index + 1,
|
||||
progress=progress,
|
||||
)
|
||||
return
|
||||
except _RetryableDownloadError as exc:
|
||||
last_error = exc
|
||||
self._notify_download(
|
||||
DownloadTelemetry(
|
||||
kind="retry",
|
||||
file=file.path,
|
||||
server=attempt_server,
|
||||
attempt=attempt_index + 1,
|
||||
reason=str(exc),
|
||||
)
|
||||
)
|
||||
if exc.discard_partial:
|
||||
temporary.unlink(missing_ok=True)
|
||||
if attempt_index >= len(self._retry_delays):
|
||||
@@ -407,6 +721,7 @@ class DepotClient:
|
||||
file: DepotFile,
|
||||
destination: Path,
|
||||
*,
|
||||
attempt: int,
|
||||
progress: Callable[[int], None] | None,
|
||||
) -> None:
|
||||
temporary = _download_partial_path(destination)
|
||||
@@ -455,6 +770,15 @@ class DepotClient:
|
||||
headers["Range"] = f"bytes={requested_offset}-"
|
||||
url = _file_url(server, file)
|
||||
request = Request(url, headers=headers, method="GET")
|
||||
self._notify_download(
|
||||
DownloadTelemetry(
|
||||
kind="request",
|
||||
file=file.path,
|
||||
server=server,
|
||||
attempt=attempt,
|
||||
)
|
||||
)
|
||||
request_started_at = time.monotonic()
|
||||
try:
|
||||
response = self._opener(request, timeout=self.timeout_seconds)
|
||||
except HTTPError as exc:
|
||||
@@ -470,6 +794,15 @@ class DepotClient:
|
||||
raise _RetryableDownloadError(
|
||||
f"could not connect to {server} for {file.path}: {exc}"
|
||||
) from exc
|
||||
self._notify_download(
|
||||
DownloadTelemetry(
|
||||
kind="response",
|
||||
file=file.path,
|
||||
server=server,
|
||||
attempt=attempt,
|
||||
latency_seconds=max(0.0, time.monotonic() - request_started_at),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
append = False
|
||||
@@ -528,8 +861,24 @@ class DepotClient:
|
||||
f"download exceeds expected size for {file.path}",
|
||||
discard_partial=True,
|
||||
)
|
||||
if self._bandwidth_limiter is not None:
|
||||
self._bandwidth_limiter.consume(len(chunk))
|
||||
digest.update(chunk)
|
||||
write_started_at = time.monotonic()
|
||||
handle.write(chunk)
|
||||
self._notify_download(
|
||||
DownloadTelemetry(
|
||||
kind="chunk",
|
||||
file=file.path,
|
||||
server=server,
|
||||
attempt=attempt,
|
||||
bytes_written=len(chunk),
|
||||
write_seconds=max(
|
||||
0.0,
|
||||
time.monotonic() - write_started_at,
|
||||
),
|
||||
)
|
||||
)
|
||||
if progress is not None:
|
||||
progress(written)
|
||||
except _RetryableDownloadError:
|
||||
@@ -559,6 +908,16 @@ class DepotClient:
|
||||
except (OSError, HTTPException):
|
||||
pass
|
||||
|
||||
def _notify_download(self, event: DownloadTelemetry) -> None:
|
||||
observer = self._download_observer
|
||||
if observer is not None:
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
observer(event)
|
||||
except Exception:
|
||||
# Telemetry must never weaken verified download behavior.
|
||||
pass
|
||||
|
||||
def _get_json(self, url: str) -> Mapping[str, object]:
|
||||
request = Request(url, headers=_headers(), method="GET")
|
||||
try:
|
||||
@@ -642,6 +1001,7 @@ def sync_managed_game(
|
||||
dry_run: bool = False,
|
||||
progress: ProgressCallback | None = None,
|
||||
client: DepotClient | None = None,
|
||||
download_options: DownloadOptions | None = None,
|
||||
) -> DepotSyncResult:
|
||||
"""Synchronize the managed game directory using only official public data."""
|
||||
|
||||
@@ -676,7 +1036,14 @@ def sync_managed_game(
|
||||
)
|
||||
|
||||
root = _prepare_game_root(paths.source_game, create=True)
|
||||
_download_plan(depot_client, snapshot, root, plan, progress)
|
||||
_download_plan(
|
||||
depot_client,
|
||||
snapshot,
|
||||
root,
|
||||
plan,
|
||||
progress,
|
||||
download_options or DownloadOptions(),
|
||||
)
|
||||
deleted = _apply_deletions(root, plan.deletes)
|
||||
created = _apply_create_if_missing(root, snapshot.create_if_missing)
|
||||
result = _sync_result(
|
||||
@@ -814,6 +1181,7 @@ def _download_plan(
|
||||
root: Path,
|
||||
plan: UpdatePlan,
|
||||
progress: ProgressCallback | None,
|
||||
options: DownloadOptions,
|
||||
) -> None:
|
||||
if not plan.downloads:
|
||||
return
|
||||
@@ -821,10 +1189,22 @@ def _download_plan(
|
||||
lock = threading.Lock()
|
||||
emit_lock = threading.Lock()
|
||||
current_bytes: dict[str, int] = {}
|
||||
current_servers: dict[str, str] = {}
|
||||
completed_files = 0
|
||||
completed_bytes = 0
|
||||
transferred_bytes = 0
|
||||
retry_count = 0
|
||||
retry_reason: str | None = None
|
||||
active_workers = 0
|
||||
target_workers = 0
|
||||
last_progress_emitted_at = 0.0
|
||||
started_at = time.monotonic()
|
||||
transfer_samples: deque[tuple[float, int]] = deque([(started_at, 0)])
|
||||
controller = AdaptiveConcurrencyController(options)
|
||||
bandwidth_limit = options.effective_bandwidth_limit
|
||||
limiter = BandwidthLimiter(bandwidth_limit) if bandwidth_limit is not None else None
|
||||
previous_observer = client.download_observer
|
||||
previous_limiter = client.bandwidth_limiter
|
||||
|
||||
def emit_download_progress(
|
||||
current_file: str,
|
||||
@@ -847,27 +1227,72 @@ def _download_plan(
|
||||
return
|
||||
last_progress_emitted_at = now
|
||||
with lock:
|
||||
bytes_completed = min(
|
||||
plan.bytes_total,
|
||||
completed_bytes + sum(current_bytes.values()),
|
||||
)
|
||||
_prune_transfer_samples(transfer_samples, now)
|
||||
transfer_rate = _sample_rate(transfer_samples)
|
||||
bytes_remaining = max(0, plan.bytes_total - bytes_completed)
|
||||
eta_seconds = (
|
||||
bytes_remaining / transfer_rate
|
||||
if transfer_rate is not None and transfer_rate > 0
|
||||
else None
|
||||
)
|
||||
event = SyncProgress(
|
||||
"downloading",
|
||||
completed_files,
|
||||
len(plan.downloads),
|
||||
completed_bytes + sum(current_bytes.values()),
|
||||
bytes_completed,
|
||||
plan.bytes_total,
|
||||
current_file,
|
||||
transferred_bytes,
|
||||
bytes_remaining,
|
||||
transfer_rate,
|
||||
eta_seconds,
|
||||
current_servers.get(
|
||||
current_file,
|
||||
_server_display_name(depot_snapshot.server),
|
||||
),
|
||||
retry_count,
|
||||
retry_reason,
|
||||
active_workers,
|
||||
target_workers,
|
||||
)
|
||||
progress(event)
|
||||
|
||||
def observe_download(event: DownloadTelemetry) -> None:
|
||||
nonlocal transferred_bytes, retry_count, retry_reason
|
||||
if event.kind == "response" and event.latency_seconds is not None:
|
||||
controller.record_response(event.latency_seconds)
|
||||
elif event.kind == "retry":
|
||||
controller.record_retry()
|
||||
elif event.kind == "chunk":
|
||||
controller.record_chunk(event.bytes_written, event.write_seconds)
|
||||
|
||||
force = event.kind == "retry"
|
||||
if event.kind in {"request", "response", "retry", "chunk"}:
|
||||
with lock:
|
||||
current_servers[event.file] = _server_display_name(event.server)
|
||||
if event.kind == "chunk":
|
||||
transferred_bytes += event.bytes_written
|
||||
now = time.monotonic()
|
||||
transfer_samples.append((now, transferred_bytes))
|
||||
_prune_transfer_samples(transfer_samples, now)
|
||||
if event.kind == "retry":
|
||||
retry_count += 1
|
||||
retry_reason = event.reason
|
||||
if previous_observer is not None:
|
||||
previous_observer(event)
|
||||
if event.kind != "chunk":
|
||||
emit_download_progress(event.file, force=force)
|
||||
|
||||
def download(planned_download: PlannedDownload) -> None:
|
||||
target = _safe_target(root, planned_download.file.path)
|
||||
_ensure_target_parent(root, target)
|
||||
|
||||
def report_file_bytes(written: int) -> None:
|
||||
nonlocal transferred_bytes
|
||||
with lock:
|
||||
previous = current_bytes.get(planned_download.file.path)
|
||||
if previous is not None and written > previous:
|
||||
transferred_bytes += written - previous
|
||||
current_bytes[planned_download.file.path] = written
|
||||
emit_download_progress(planned_download.file.path)
|
||||
|
||||
@@ -878,49 +1303,84 @@ def _download_plan(
|
||||
progress=report_file_bytes if progress is not None else None,
|
||||
)
|
||||
|
||||
worker_count = min(DOWNLOAD_WORKERS, len(plan.downloads))
|
||||
remaining_items = iter(plan.downloads)
|
||||
with ThreadPoolExecutor(max_workers=worker_count) as executor:
|
||||
future_items: dict[Future[None], PlannedDownload] = {}
|
||||
maximum_workers = min(options.effective_maximum_workers, len(plan.downloads))
|
||||
# Start large transfers first so they overlap the small-file request workload
|
||||
# instead of becoming a single-connection tail at the end of an install.
|
||||
remaining_items: deque[PlannedDownload] = deque(
|
||||
sorted(plan.downloads, key=lambda item: item.file.size, reverse=True)
|
||||
)
|
||||
client.download_observer = observe_download
|
||||
client.bandwidth_limiter = limiter
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=maximum_workers) as executor:
|
||||
future_items: dict[Future[None], PlannedDownload] = {}
|
||||
|
||||
def submit_next() -> bool:
|
||||
try:
|
||||
next_item = next(remaining_items)
|
||||
except StopIteration:
|
||||
return False
|
||||
future_items[executor.submit(download, next_item)] = next_item
|
||||
return True
|
||||
|
||||
try:
|
||||
for _worker in range(worker_count):
|
||||
if not submit_next():
|
||||
break
|
||||
while future_items:
|
||||
done, _pending = wait(
|
||||
tuple(future_items),
|
||||
return_when=FIRST_COMPLETED,
|
||||
)
|
||||
completed_items: list[PlannedDownload] = []
|
||||
for future in done:
|
||||
completed_item = future_items.pop(future)
|
||||
future.result()
|
||||
completed_items.append(completed_item)
|
||||
for completed_item in completed_items:
|
||||
def submit_until(worker_target: int) -> None:
|
||||
nonlocal active_workers, target_workers
|
||||
with lock:
|
||||
target_workers = worker_target
|
||||
while remaining_items and len(future_items) < worker_target:
|
||||
next_item = remaining_items.popleft()
|
||||
with lock:
|
||||
completed_files += 1
|
||||
completed_bytes += completed_item.file.size
|
||||
current_bytes.pop(completed_item.file.path, None)
|
||||
is_final_download = completed_files == len(plan.downloads)
|
||||
emit_download_progress(
|
||||
completed_item.file.path,
|
||||
force=is_final_download,
|
||||
active_workers += 1
|
||||
try:
|
||||
submitted_future = executor.submit(download, next_item)
|
||||
except BaseException:
|
||||
with lock:
|
||||
active_workers -= 1
|
||||
remaining_items.appendleft(next_item)
|
||||
raise
|
||||
future_items[submitted_future] = next_item
|
||||
|
||||
try:
|
||||
initial_target = controller.target_workers(
|
||||
len(remaining_items),
|
||||
plan.bytes_total,
|
||||
)
|
||||
submit_until(initial_target)
|
||||
while future_items:
|
||||
done, _pending = wait(
|
||||
tuple(future_items),
|
||||
timeout=ADAPTIVE_EVALUATION_INTERVAL_SECONDS,
|
||||
return_when=FIRST_COMPLETED,
|
||||
)
|
||||
for _item in completed_items:
|
||||
submit_next()
|
||||
except BaseException:
|
||||
for future in future_items:
|
||||
future.cancel()
|
||||
raise
|
||||
completed_items: list[PlannedDownload] = []
|
||||
for future in done:
|
||||
completed_item = future_items.pop(future)
|
||||
future.result()
|
||||
completed_items.append(completed_item)
|
||||
for completed_item in completed_items:
|
||||
with lock:
|
||||
active_workers -= 1
|
||||
completed_files += 1
|
||||
completed_bytes += completed_item.file.size
|
||||
current_bytes.pop(completed_item.file.path, None)
|
||||
is_final_download = completed_files == len(plan.downloads)
|
||||
emit_download_progress(
|
||||
completed_item.file.path,
|
||||
force=is_final_download,
|
||||
)
|
||||
with lock:
|
||||
current_servers.pop(completed_item.file.path, None)
|
||||
with lock:
|
||||
in_progress_bytes = sum(current_bytes.values())
|
||||
remaining_file_count = len(remaining_items) + len(future_items)
|
||||
remaining_byte_count = max(
|
||||
0,
|
||||
plan.bytes_total - completed_bytes - in_progress_bytes,
|
||||
)
|
||||
next_target = controller.target_workers(
|
||||
remaining_file_count,
|
||||
remaining_byte_count,
|
||||
)
|
||||
submit_until(next_target)
|
||||
except BaseException:
|
||||
for future in future_items:
|
||||
future.cancel()
|
||||
raise
|
||||
finally:
|
||||
client.download_observer = previous_observer
|
||||
client.bandwidth_limiter = previous_limiter
|
||||
|
||||
|
||||
def _apply_deletions(root: Path, relative_paths: Iterable[str]) -> int:
|
||||
@@ -1415,6 +1875,44 @@ def _content_range(value: str | None) -> tuple[int, int, int]:
|
||||
return start, end, total
|
||||
|
||||
|
||||
def _normalized_cpu_load() -> float | None:
|
||||
"""Return one-minute runnable load normalized by the available CPU count."""
|
||||
|
||||
try:
|
||||
load = os.getloadavg()[0]
|
||||
except (AttributeError, OSError):
|
||||
return None
|
||||
processors = os.cpu_count()
|
||||
if processors is None or processors <= 0:
|
||||
return None
|
||||
return max(0.0, load / processors)
|
||||
|
||||
|
||||
def _prune_transfer_samples(
|
||||
samples: deque[tuple[float, int]],
|
||||
now: float,
|
||||
) -> None:
|
||||
cutoff = now - TRANSFER_RATE_WINDOW_SECONDS
|
||||
while len(samples) > 2 and samples[1][0] <= cutoff:
|
||||
samples.popleft()
|
||||
|
||||
|
||||
def _sample_rate(samples: deque[tuple[float, int]]) -> float | None:
|
||||
if len(samples) < 2:
|
||||
return None
|
||||
first_time, first_bytes = samples[0]
|
||||
last_time, last_bytes = samples[-1]
|
||||
elapsed = last_time - first_time
|
||||
if elapsed <= 0 or last_bytes < first_bytes:
|
||||
return None
|
||||
return (last_bytes - first_bytes) / elapsed
|
||||
|
||||
|
||||
def _server_display_name(server: str) -> str:
|
||||
parsed = urlparse(server if "://" in server else f"//{server}")
|
||||
return parsed.hostname or server.split("/", 1)[0]
|
||||
|
||||
|
||||
def _headers() -> dict[str, str]:
|
||||
return {"User-Agent": f"PT-Linux/{__version__} (+https://pristontale.eu/)"}
|
||||
|
||||
@@ -1451,5 +1949,6 @@ def _emit_progress(
|
||||
bytes_completed,
|
||||
bytes_total,
|
||||
current_file,
|
||||
bytes_remaining=max(0, bytes_total - bytes_completed),
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user