fix: avoid duplicate capacity reservation for hardlinks

This commit is contained in:
2026-07-24 13:48:24 +00:00
parent 589fd4a41d
commit 748ca49837
8 changed files with 95 additions and 14 deletions
+59 -5
View File
@@ -5,11 +5,12 @@ from __future__ import annotations
import hashlib
import json
import shutil
import stat
import threading
import time
import uuid
from pathlib import Path, PurePosixPath
from typing import Callable
from typing import Callable, Iterable
from archive_clients.protocol import decode_message, encode_message
from archive_clients.eviction import (
@@ -541,15 +542,29 @@ class ClientJobExecutor:
sha256_hex=hashlib.sha256(resource.metainfo_bytes).hexdigest(),
)
del artifact
route_root = self.route_path(definition.transfer.route_id)
# Staging is normally zero-copy when qB's data root and the paired
# Syncthing route share a filesystem. Do not reserve the complete
# logical payload in that case: FileMaterializer will use link(2),
# which consumes only directory/inode metadata. Retain the metainfo
# allowance and reserve, and account for any source files that really
# must fall back to a data-copy path.
self._require_space(
self.route_path(definition.transfer.route_id),
definition.transfer.transfer_delta_logical_bytes
route_root,
self._copy_required_bytes(
self.qb_root,
route_root,
(
(entry.target_canonical_path, entry.logical_bytes)
for entry in manifest.files
),
)
+ len(resource.metainfo_bytes),
)
stage_transfer(
manifest,
source_root=self.qb_root,
sync_root=self.route_path(definition.transfer.route_id),
sync_root=route_root,
store=self.store,
artifact_sources={"metainfo/source.torrent": metainfo_path},
sparse_supported=self.sparse_supported,
@@ -607,9 +622,18 @@ class ClientJobExecutor:
"target materialization was sent to the wrong client"
)
published = load_published_transfer(self._job_directory(definition))
# The target can likewise hardlink an arrived Syncthing payload into
# qB's content root when those directories share a filesystem.
self._require_space(
self.qb_root,
definition.transfer.transfer_delta_logical_bytes,
self._copy_required_bytes(
published.job_directory,
self.qb_root,
(
(entry.payload_relative_path, entry.logical_bytes)
for entry in published.manifest.files
),
),
)
info_hash = _info_hash(definition)
resource = self.qbittorrent.get_resource(info_hash)
@@ -843,6 +867,36 @@ class ClientJobExecutor:
f"{required} bytes required including reserve"
)
@staticmethod
def _copy_required_bytes(
source_root: Path,
destination_root: Path,
files: Iterable[tuple[str, int]],
) -> int:
"""Return logical bytes that cannot be materialized by hardlink.
A hardlink is possible only for regular files on the destination
filesystem. Conservatively charge a file when it cannot be inspected;
the normal materializer will then provide the precise integrity error.
"""
destination_device = destination_root.stat().st_dev
required = 0
for relative_path, logical_bytes in files:
relative = PurePosixPath(relative_path)
source = source_root.joinpath(*relative.parts)
try:
metadata = source.stat(follow_symlinks=False)
except OSError:
required += logical_bytes
continue
if (
not stat.S_ISREG(metadata.st_mode)
or metadata.st_dev != destination_device
):
required += logical_bytes
return required
def _observer(
self, definition: job_pb2.JobDefinition
) -> SyncthingTransferObserver: