feat: complete transfer and eviction execution
This commit is contained in:
+564
-32
@@ -3,12 +3,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Callable
|
||||
|
||||
from archive_clients.protocol import decode_message, encode_message
|
||||
from archive_clients.eviction import (
|
||||
EvictionError,
|
||||
compensate_materialized_files,
|
||||
remove_qb_entry,
|
||||
safe_unlink,
|
||||
verify_and_snapshot,
|
||||
)
|
||||
from archive_clients.qbittorrent import (
|
||||
QBittorrentDownloadAttempt,
|
||||
QBittorrentReader,
|
||||
@@ -37,6 +47,10 @@ class JobExecutionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class JobCancelled(JobExecutionError):
|
||||
pass
|
||||
|
||||
|
||||
class ClientJobExecutor:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -50,6 +64,8 @@ class ClientJobExecutor:
|
||||
syncthing_transport: object,
|
||||
sparse_supported: bool,
|
||||
poll_interval: float = 1,
|
||||
verification_timeout: float = 30 * 60,
|
||||
free_space_reserve_bytes: int = 1024 * 1024 * 1024,
|
||||
):
|
||||
self.client_id = client_id
|
||||
self.qbittorrent = qbittorrent
|
||||
@@ -60,6 +76,12 @@ class ClientJobExecutor:
|
||||
self.syncthing_transport = syncthing_transport
|
||||
self.sparse_supported = sparse_supported
|
||||
self.poll_interval = poll_interval
|
||||
self.verification_timeout = verification_timeout
|
||||
self.free_space_reserve_bytes = free_space_reserve_bytes
|
||||
self._cancel_events: dict[str, threading.Event] = {}
|
||||
|
||||
def request_cancel(self, job_id: str) -> None:
|
||||
self._cancel_events.setdefault(job_id, threading.Event()).set()
|
||||
|
||||
def assign(
|
||||
self, command: control_pb2.AssignJobCommand
|
||||
@@ -83,7 +105,9 @@ class ClientJobExecutor:
|
||||
return [event]
|
||||
|
||||
def execute(
|
||||
self, command: control_pb2.ExecuteStepCommand
|
||||
self,
|
||||
command: control_pb2.ExecuteStepCommand,
|
||||
event_callback: Callable[[control_pb2.JobEvent], None] | None = None,
|
||||
) -> list[control_pb2.JobEvent]:
|
||||
definition = self._definition(command.job_id)
|
||||
replay = self._replay(
|
||||
@@ -97,6 +121,9 @@ class ClientJobExecutor:
|
||||
control_pb2.JOB_EVENT_TYPE_CLEANUP_REQUIRED,
|
||||
control_pb2.JOB_EVENT_TYPE_CANCELLED,
|
||||
}:
|
||||
if event_callback is not None:
|
||||
for event in replay:
|
||||
event_callback(event)
|
||||
return replay
|
||||
started = replay[0] if replay else self._event(
|
||||
definition,
|
||||
@@ -107,23 +134,124 @@ class ClientJobExecutor:
|
||||
committed=(
|
||||
self._committed(command.job_id)
|
||||
or command.step == job_pb2.JOB_STEP_KIND_STAGING_CLEANUP
|
||||
or (
|
||||
command.step == job_pb2.JOB_STEP_KIND_SAFE_FILE_UNLINK
|
||||
and self.store.get_job_artifact(
|
||||
command.job_id, "qb-entry-removed"
|
||||
) is not None
|
||||
)
|
||||
),
|
||||
step=command.step,
|
||||
step_state=job_pb2.STEP_STATE_RUNNING,
|
||||
)
|
||||
if not replay:
|
||||
self._record(definition, started)
|
||||
if event_callback is not None:
|
||||
event_callback(started)
|
||||
emitted = [started]
|
||||
cursor = started
|
||||
speed_sample = [time.monotonic(), 0]
|
||||
|
||||
def progress(
|
||||
fraction: float,
|
||||
bytes_complete: int = 0,
|
||||
bytes_total: int = 0,
|
||||
detail: str = "",
|
||||
) -> None:
|
||||
nonlocal cursor
|
||||
now = time.monotonic()
|
||||
elapsed = now - float(speed_sample[0])
|
||||
if fraction < 1 and elapsed < 1:
|
||||
return
|
||||
speed = (
|
||||
max(0, bytes_complete - int(speed_sample[1])) / elapsed
|
||||
if elapsed > 0 else 0
|
||||
)
|
||||
event = self._event(
|
||||
definition,
|
||||
sequence=cursor.sequence + 1,
|
||||
revision=cursor.job_revision + 1,
|
||||
event_type=control_pb2.JOB_EVENT_TYPE_PROGRESS,
|
||||
state=job_pb2.JOB_STATE_RUNNING,
|
||||
committed=cursor.committed,
|
||||
step=command.step,
|
||||
step_state=job_pb2.STEP_STATE_RUNNING,
|
||||
)
|
||||
event.progress.fraction_complete = max(
|
||||
0.0, min(float(fraction), 1.0)
|
||||
)
|
||||
event.progress.bytes_complete = max(0, bytes_complete)
|
||||
event.progress.bytes_total = max(0, bytes_total)
|
||||
event.progress.approximate_bytes_per_second = speed
|
||||
event.progress.detail = detail
|
||||
completed_steps = max(0, _step_number(command.step) - 1)
|
||||
event.progress.overall_fraction_complete = (
|
||||
completed_steps + event.progress.fraction_complete
|
||||
) / event.progress.display_step_total
|
||||
self._record(definition, event)
|
||||
emitted.append(event)
|
||||
cursor = event
|
||||
speed_sample[:] = [now, bytes_complete]
|
||||
if event_callback is not None:
|
||||
event_callback(event)
|
||||
try:
|
||||
result = self._execute_step(definition, command.step)
|
||||
result = self._execute_step(definition, command.step, progress)
|
||||
except JobCancelled as error:
|
||||
cancelling = self._event(
|
||||
definition,
|
||||
sequence=cursor.sequence + 1,
|
||||
revision=cursor.job_revision + 1,
|
||||
event_type=control_pb2.JOB_EVENT_TYPE_CANCELLING,
|
||||
state=job_pb2.JOB_STATE_CANCELLING,
|
||||
committed=started.committed,
|
||||
step=command.step,
|
||||
step_state=job_pb2.STEP_STATE_CANCELLED,
|
||||
)
|
||||
cancelling.error.code = common_pb2.ERROR_CODE_CANCELLED
|
||||
cancelling.error.message = str(error)
|
||||
self._record(definition, cancelling)
|
||||
emitted.append(cancelling)
|
||||
if event_callback is not None:
|
||||
event_callback(cancelling)
|
||||
return emitted
|
||||
except Exception as error:
|
||||
rollback_error: Exception | None = None
|
||||
if (
|
||||
definition.WhichOneof("spec") == "transfer"
|
||||
and not started.committed
|
||||
):
|
||||
try:
|
||||
if (
|
||||
self.client_id == definition.transfer.target_client_id
|
||||
and command.step in {
|
||||
job_pb2.JOB_STEP_KIND_TARGET_MATERIALIZE,
|
||||
job_pb2.JOB_STEP_KIND_QB_VERIFY,
|
||||
}
|
||||
):
|
||||
self._rollback_target(definition)
|
||||
elif (
|
||||
self.client_id == definition.transfer.source_client_id
|
||||
and command.step == job_pb2.JOB_STEP_KIND_SOURCE_STAGE
|
||||
):
|
||||
self._cleanup_staging(definition)
|
||||
except Exception as compensation_error:
|
||||
rollback_error = compensation_error
|
||||
cleanup = (
|
||||
command.step == job_pb2.JOB_STEP_KIND_STAGING_CLEANUP
|
||||
and started.committed
|
||||
(
|
||||
command.step == job_pb2.JOB_STEP_KIND_STAGING_CLEANUP
|
||||
and started.committed
|
||||
)
|
||||
or (
|
||||
command.step == job_pb2.JOB_STEP_KIND_SAFE_FILE_UNLINK
|
||||
and self.store.get_job_artifact(
|
||||
definition.job_id, "qb-entry-removed"
|
||||
) is not None
|
||||
)
|
||||
)
|
||||
failed = self._event(
|
||||
definition,
|
||||
sequence=started.sequence + 1,
|
||||
revision=started.job_revision + 1,
|
||||
sequence=cursor.sequence + 1,
|
||||
revision=cursor.job_revision + 1,
|
||||
event_type=(
|
||||
control_pb2.JOB_EVENT_TYPE_CLEANUP_REQUIRED
|
||||
if cleanup else control_pb2.JOB_EVENT_TYPE_FAILED
|
||||
@@ -139,11 +267,23 @@ class ClientJobExecutor:
|
||||
failed.error.code = _job_error_code(error)
|
||||
failed.error.message = str(error) or type(error).__name__
|
||||
failed.error.retryable = cleanup
|
||||
if rollback_error is not None:
|
||||
failed.error.code = (
|
||||
common_pb2.ERROR_CODE_MANUAL_INTERVENTION_REQUIRED
|
||||
)
|
||||
failed.error.message = (
|
||||
f"{failed.error.message}; compensation also failed: "
|
||||
f"{rollback_error}"
|
||||
)
|
||||
failed.error.retryable = False
|
||||
self._record(definition, failed)
|
||||
return [started, failed]
|
||||
sequence = started.sequence + 1
|
||||
revision = started.job_revision + 1
|
||||
committed = started.committed
|
||||
emitted.append(failed)
|
||||
if event_callback is not None:
|
||||
event_callback(failed)
|
||||
return emitted
|
||||
sequence = cursor.sequence + 1
|
||||
revision = cursor.job_revision + 1
|
||||
committed = cursor.committed
|
||||
event_type = control_pb2.JOB_EVENT_TYPE_STEP_SUCCEEDED
|
||||
state = job_pb2.JOB_STATE_RUNNING
|
||||
if command.step == job_pb2.JOB_STEP_KIND_QB_VERIFY:
|
||||
@@ -152,6 +292,10 @@ class ClientJobExecutor:
|
||||
elif command.step == job_pb2.JOB_STEP_KIND_STAGING_CLEANUP:
|
||||
event_type = control_pb2.JOB_EVENT_TYPE_SUCCEEDED
|
||||
state = job_pb2.JOB_STATE_SUCCEEDED
|
||||
elif command.step == job_pb2.JOB_STEP_KIND_SAFE_FILE_UNLINK:
|
||||
committed = True
|
||||
event_type = control_pb2.JOB_EVENT_TYPE_SUCCEEDED
|
||||
state = job_pb2.JOB_STATE_SUCCEEDED
|
||||
succeeded = self._event(
|
||||
definition,
|
||||
sequence=sequence,
|
||||
@@ -165,24 +309,91 @@ class ClientJobExecutor:
|
||||
if result is not None:
|
||||
succeeded.observed_placement.CopyFrom(result)
|
||||
self._record(definition, succeeded)
|
||||
return [started, succeeded]
|
||||
emitted.append(succeeded)
|
||||
if event_callback is not None:
|
||||
event_callback(succeeded)
|
||||
return emitted
|
||||
|
||||
def cancel(
|
||||
self, command: control_pb2.CancelJobCommand
|
||||
) -> list[control_pb2.JobEvent]:
|
||||
definition = self._definition(command.job_id)
|
||||
self.request_cancel(command.job_id)
|
||||
replay = self._replay(
|
||||
command.job_id, command.expected_last_event_sequence
|
||||
)
|
||||
if replay:
|
||||
return replay
|
||||
committed = self._committed(command.job_id)
|
||||
if definition.WhichOneof("spec") == "eviction":
|
||||
removed = self.store.get_job_artifact(
|
||||
definition.job_id, "qb-entry-removed"
|
||||
)
|
||||
placement = None
|
||||
if removed is not None:
|
||||
placement = self._execute_eviction_step(
|
||||
definition, job_pb2.JOB_STEP_KIND_SAFE_FILE_UNLINK
|
||||
)
|
||||
committed = True
|
||||
event_type = control_pb2.JOB_EVENT_TYPE_CANCELLED
|
||||
state = job_pb2.JOB_STATE_CANCELLED
|
||||
elif committed:
|
||||
if definition.transfer.source_client_id == self.client_id:
|
||||
self._cleanup_staging(definition)
|
||||
event_type = control_pb2.JOB_EVENT_TYPE_CANCELLED
|
||||
state = job_pb2.JOB_STATE_CANCELLED
|
||||
placement = None
|
||||
elif definition.transfer.source_client_id == self.client_id:
|
||||
self._cleanup_staging(definition)
|
||||
temporary = self._metainfo_path(definition.job_id)
|
||||
if temporary.exists():
|
||||
temporary.unlink()
|
||||
event_type = control_pb2.JOB_EVENT_TYPE_ROLLBACK_SUCCEEDED
|
||||
state = job_pb2.JOB_STATE_ROLLING_BACK
|
||||
placement = None
|
||||
else:
|
||||
self._rollback_target(definition)
|
||||
self._cleanup_staging(definition)
|
||||
event_type = control_pb2.JOB_EVENT_TYPE_CANCELLED
|
||||
state = job_pb2.JOB_STATE_CANCELLED
|
||||
placement = None
|
||||
event = self._event(
|
||||
definition,
|
||||
sequence=command.expected_last_event_sequence + 1,
|
||||
revision=command.expected_job_revision + 1,
|
||||
event_type=event_type,
|
||||
state=state,
|
||||
committed=committed,
|
||||
step=job_pb2.JOB_STEP_KIND_ROLLBACK,
|
||||
step_state=job_pb2.STEP_STATE_SUCCEEDED,
|
||||
)
|
||||
if placement is not None:
|
||||
event.observed_placement.CopyFrom(placement)
|
||||
self._record(definition, event)
|
||||
return [event]
|
||||
|
||||
def _execute_step(
|
||||
self, definition: job_pb2.JobDefinition, step: int
|
||||
self,
|
||||
definition: job_pb2.JobDefinition,
|
||||
step: int,
|
||||
progress: Callable[[float, int, int, str], None],
|
||||
):
|
||||
self._raise_if_cancelled(definition.job_id)
|
||||
if definition.WhichOneof("spec") == "eviction":
|
||||
return self._execute_eviction_step(definition, step)
|
||||
if step == job_pb2.JOB_STEP_KIND_SOURCE_STAGE:
|
||||
self._source_stage(definition)
|
||||
self._source_stage(definition, progress)
|
||||
return None
|
||||
if step == job_pb2.JOB_STEP_KIND_SYNCTHING_TRANSFER:
|
||||
self._wait_for_syncthing(definition)
|
||||
self._wait_for_syncthing(definition, progress)
|
||||
return None
|
||||
if step == job_pb2.JOB_STEP_KIND_TARGET_MATERIALIZE:
|
||||
self._target_materialize(definition)
|
||||
self._target_materialize(definition, progress)
|
||||
return None
|
||||
if step == job_pb2.JOB_STEP_KIND_QB_VERIFY:
|
||||
return self._qb_verify(definition)
|
||||
return self._qb_verify(definition, progress)
|
||||
if step == job_pb2.JOB_STEP_KIND_STAGING_CLEANUP:
|
||||
cleanup_transfer(self._job_directory(definition))
|
||||
self._cleanup_staging(definition)
|
||||
temporary_metainfo = self._metainfo_path(definition.job_id)
|
||||
if temporary_metainfo.exists():
|
||||
temporary_metainfo.unlink()
|
||||
@@ -197,10 +408,86 @@ class ClientJobExecutor:
|
||||
return None
|
||||
raise JobExecutionError("job step is unsupported")
|
||||
|
||||
def _source_stage(self, definition: job_pb2.JobDefinition) -> None:
|
||||
def _execute_eviction_step(
|
||||
self, definition: job_pb2.JobDefinition, step: int
|
||||
):
|
||||
if definition.eviction.cache_client_id != self.client_id:
|
||||
raise JobExecutionError("eviction step was sent to the wrong client")
|
||||
info_hash = _info_hash(definition)
|
||||
if step == job_pb2.JOB_STEP_KIND_VERIFY_ARCHIVE_COVERAGE:
|
||||
covered = {
|
||||
index
|
||||
for proof in definition.eviction.archive_coverage
|
||||
for index in _selection_indices(proof.covered_files)
|
||||
}
|
||||
requested = set(
|
||||
_selection_indices(definition.eviction.files_to_evict)
|
||||
)
|
||||
if not requested or requested - covered:
|
||||
raise JobExecutionError(
|
||||
"archive coverage no longer covers the cache selection"
|
||||
)
|
||||
resource = self._resource(definition)
|
||||
current = _resource_fingerprint(resource, self.client_id)
|
||||
if not _fingerprint_matches(
|
||||
current, definition.eviction.cache_fingerprint
|
||||
):
|
||||
raise JobExecutionError(
|
||||
"cache resource changed after eviction confirmation"
|
||||
)
|
||||
verify_and_snapshot(
|
||||
job_id=definition.job_id,
|
||||
resource=resource,
|
||||
selected_indices=requested,
|
||||
qb_root=self.qb_root,
|
||||
store=self.store,
|
||||
)
|
||||
return None
|
||||
if step == job_pb2.JOB_STEP_KIND_QB_REMOVE_ENTRY:
|
||||
remove_qb_entry(
|
||||
job_id=definition.job_id,
|
||||
torrent_hash=info_hash,
|
||||
qbittorrent=self.qbittorrent,
|
||||
store=self.store,
|
||||
)
|
||||
return None
|
||||
if step == job_pb2.JOB_STEP_KIND_SAFE_FILE_UNLINK:
|
||||
safe_unlink(
|
||||
job_id=definition.job_id,
|
||||
qb_root=self.qb_root,
|
||||
qbittorrent=self.qbittorrent,
|
||||
store=self.store,
|
||||
)
|
||||
placement = resource_pb2.Placement(
|
||||
client_id=self.client_id,
|
||||
state=resource_pb2.PLACEMENT_STATE_ABSENT,
|
||||
generation=definition.eviction.cache_placement_generation + 1,
|
||||
created_by_job_id=definition.job_id,
|
||||
)
|
||||
placement.resource_id.CopyFrom(definition.resource_id)
|
||||
placement.fingerprint.CopyFrom(
|
||||
definition.eviction.cache_fingerprint
|
||||
)
|
||||
placement.verified_at.GetCurrentTime()
|
||||
return placement
|
||||
raise JobExecutionError("eviction job step is unsupported")
|
||||
|
||||
def _source_stage(
|
||||
self,
|
||||
definition: job_pb2.JobDefinition,
|
||||
progress: Callable[[float, int, int, str], None],
|
||||
) -> None:
|
||||
if definition.transfer.source_client_id != self.client_id:
|
||||
raise JobExecutionError("source stage was sent to the wrong client")
|
||||
resource = self._resource(definition)
|
||||
if not _fingerprint_matches(
|
||||
_resource_fingerprint(resource, self.client_id),
|
||||
definition.transfer.source_fingerprint,
|
||||
):
|
||||
raise JobExecutionError(
|
||||
"source resource changed after job confirmation"
|
||||
)
|
||||
self._reject_unsafe_partfile(definition, resource)
|
||||
indices = _selection_indices(definition.transfer.transfer_delta_files)
|
||||
by_index = {item.file_index: item for item in resource.files}
|
||||
if not indices or any(index not in by_index for index in indices):
|
||||
@@ -254,6 +541,11 @@ class ClientJobExecutor:
|
||||
sha256_hex=hashlib.sha256(resource.metainfo_bytes).hexdigest(),
|
||||
)
|
||||
del artifact
|
||||
self._require_space(
|
||||
self.route_path(definition.transfer.route_id),
|
||||
definition.transfer.transfer_delta_logical_bytes
|
||||
+ len(resource.metainfo_bytes),
|
||||
)
|
||||
stage_transfer(
|
||||
manifest,
|
||||
source_root=self.qb_root,
|
||||
@@ -261,18 +553,43 @@ class ClientJobExecutor:
|
||||
store=self.store,
|
||||
artifact_sources={"metainfo/source.torrent": metainfo_path},
|
||||
sparse_supported=self.sparse_supported,
|
||||
cancel_check=lambda: self._raise_if_cancelled(
|
||||
definition.job_id
|
||||
),
|
||||
progress=lambda completed, total: progress(
|
||||
completed / total if total else 1,
|
||||
completed,
|
||||
total,
|
||||
f"staged {completed} of {total} bytes",
|
||||
),
|
||||
)
|
||||
self._observer(definition).rescan()
|
||||
|
||||
def _wait_for_syncthing(self, definition: job_pb2.JobDefinition) -> None:
|
||||
def _wait_for_syncthing(
|
||||
self,
|
||||
definition: job_pb2.JobDefinition,
|
||||
progress: Callable[[float, int, int, str], None],
|
||||
) -> None:
|
||||
if definition.transfer.target_client_id != self.client_id:
|
||||
raise JobExecutionError(
|
||||
"Syncthing completion was sent to the wrong client"
|
||||
)
|
||||
observer = self._observer(definition)
|
||||
last_reported: tuple[int, int] | None = None
|
||||
while True:
|
||||
self._raise_if_cancelled(definition.job_id)
|
||||
try:
|
||||
if observer.status().complete:
|
||||
status = observer.status()
|
||||
sample = (status.bytes_complete, status.bytes_total)
|
||||
if sample != last_reported:
|
||||
progress(
|
||||
status.fraction_complete,
|
||||
status.bytes_complete,
|
||||
status.bytes_total,
|
||||
f"{status.needed_items} Syncthing items still needed",
|
||||
)
|
||||
last_reported = sample
|
||||
if status.complete:
|
||||
return
|
||||
except (FileNotFoundError, TransferIntegrityError):
|
||||
# Syncthing can expose the per-job directory before the
|
||||
@@ -280,20 +597,73 @@ class ClientJobExecutor:
|
||||
pass
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
def _target_materialize(self, definition: job_pb2.JobDefinition) -> None:
|
||||
def _target_materialize(
|
||||
self,
|
||||
definition: job_pb2.JobDefinition,
|
||||
progress: Callable[[float, int, int, str], None],
|
||||
) -> None:
|
||||
if definition.transfer.target_client_id != self.client_id:
|
||||
raise JobExecutionError(
|
||||
"target materialization was sent to the wrong client"
|
||||
)
|
||||
published = load_published_transfer(self._job_directory(definition))
|
||||
self._require_space(
|
||||
self.qb_root,
|
||||
definition.transfer.transfer_delta_logical_bytes,
|
||||
)
|
||||
info_hash = _info_hash(definition)
|
||||
resource = self.qbittorrent.get_resource(info_hash)
|
||||
if resource is not None:
|
||||
self._reject_unsafe_partfile(definition, resource)
|
||||
if definition.transfer.HasField("target_baseline_fingerprint"):
|
||||
if resource is None or not _fingerprint_matches(
|
||||
_resource_fingerprint(resource, self.client_id),
|
||||
definition.transfer.target_baseline_fingerprint,
|
||||
):
|
||||
raise JobExecutionError(
|
||||
"target resource changed after job confirmation"
|
||||
)
|
||||
elif resource is not None:
|
||||
raise JobExecutionError(
|
||||
"target resource appeared after job confirmation"
|
||||
)
|
||||
baseline = {
|
||||
"present": resource is not None,
|
||||
"selected_file_indices": (
|
||||
_selection_indices(resource.summary.selected_files)
|
||||
if resource is not None else []
|
||||
),
|
||||
"stopped": (
|
||||
resource is not None
|
||||
and resource.summary.runtime_state
|
||||
== resource_pb2.TORRENT_RUNTIME_STATE_STOPPED
|
||||
),
|
||||
"total_file_count": len(published.manifest.files),
|
||||
}
|
||||
self.store.put_job_artifact(
|
||||
definition.job_id, "target-baseline", baseline
|
||||
)
|
||||
materialize_transfer(
|
||||
published,
|
||||
target_root=self.qb_root,
|
||||
store=self.store,
|
||||
sparse_supported=self.sparse_supported,
|
||||
cancel_check=lambda: self._raise_if_cancelled(
|
||||
definition.job_id
|
||||
),
|
||||
progress=lambda completed, total: progress(
|
||||
completed / total if total else 1,
|
||||
completed,
|
||||
total,
|
||||
f"materialized {completed} of {total} bytes",
|
||||
),
|
||||
)
|
||||
|
||||
def _qb_verify(self, definition: job_pb2.JobDefinition):
|
||||
def _qb_verify(
|
||||
self,
|
||||
definition: job_pb2.JobDefinition,
|
||||
progress: Callable[[float, int, int, str], None],
|
||||
):
|
||||
if definition.transfer.target_client_id != self.client_id:
|
||||
raise JobExecutionError("qB verification was sent to the wrong client")
|
||||
published = load_published_transfer(self._job_directory(definition))
|
||||
@@ -326,7 +696,16 @@ class ClientJobExecutor:
|
||||
)
|
||||
self.qbittorrent.set_selection(info_hash, selected, total_files)
|
||||
self.qbittorrent.recheck_and_wait(
|
||||
info_hash, selected, timeout=30 * 60, poll_interval=self.poll_interval
|
||||
info_hash,
|
||||
selected,
|
||||
timeout=self.verification_timeout,
|
||||
poll_interval=self.poll_interval,
|
||||
cancel_check=lambda: self._raise_if_cancelled(
|
||||
definition.job_id
|
||||
),
|
||||
progress_callback=lambda fraction: progress(
|
||||
fraction, 0, 0, "qBittorrent stopped recheck"
|
||||
),
|
||||
)
|
||||
if should_start:
|
||||
self.qbittorrent.start(info_hash)
|
||||
@@ -359,6 +738,102 @@ class ClientJobExecutor:
|
||||
placement.verified_at.GetCurrentTime()
|
||||
return placement
|
||||
|
||||
def _rollback_target(self, definition: job_pb2.JobDefinition) -> None:
|
||||
baseline_row = self.store.get_job_artifact(
|
||||
definition.job_id, "target-baseline"
|
||||
)
|
||||
if baseline_row is None:
|
||||
compensate_materialized_files(
|
||||
job_id=definition.job_id,
|
||||
qb_root=self.qb_root,
|
||||
store=self.store,
|
||||
)
|
||||
return
|
||||
baseline = baseline_row["value"]
|
||||
info_hash = _info_hash(definition)
|
||||
present = self.qbittorrent.get_resource(info_hash)
|
||||
if present is not None:
|
||||
if baseline.get("present"):
|
||||
selected = baseline.get("selected_file_indices")
|
||||
if isinstance(selected, list) and selected:
|
||||
self.qbittorrent.set_selection(
|
||||
info_hash, selected, len(present.files)
|
||||
)
|
||||
if baseline.get("stopped"):
|
||||
self.qbittorrent.stop(info_hash)
|
||||
else:
|
||||
self.qbittorrent.start(info_hash)
|
||||
else:
|
||||
self.qbittorrent.delete_entry(info_hash)
|
||||
compensate_materialized_files(
|
||||
job_id=definition.job_id,
|
||||
qb_root=self.qb_root,
|
||||
store=self.store,
|
||||
)
|
||||
|
||||
def _cleanup_staging(self, definition: job_pb2.JobDefinition) -> None:
|
||||
job_directory = self._job_directory(definition)
|
||||
try:
|
||||
if cleanup_transfer(job_directory):
|
||||
return
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if not job_directory.is_dir():
|
||||
return
|
||||
compensate_materialized_files(
|
||||
job_id=definition.job_id,
|
||||
qb_root=job_directory,
|
||||
store=self.store,
|
||||
)
|
||||
for name in ("ready.json", "manifest.json"):
|
||||
candidate = job_directory / name
|
||||
if candidate.is_file() and not candidate.is_symlink():
|
||||
candidate.unlink()
|
||||
try:
|
||||
job_directory.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _reject_unsafe_partfile(
|
||||
self,
|
||||
definition: job_pb2.JobDefinition,
|
||||
resource: NormalizedResource,
|
||||
) -> None:
|
||||
roots = {self.qb_root}
|
||||
for item in resource.files:
|
||||
candidate = self.qb_root / PurePosixPath(item.canonical_path).parts[0]
|
||||
roots.add(candidate if candidate.is_dir() else candidate.parent)
|
||||
hashes = {
|
||||
value.lower() for value in (
|
||||
definition.resource_id.info_hash_v1_hex,
|
||||
definition.resource_id.info_hash_v2_hex,
|
||||
) if value
|
||||
}
|
||||
for root in roots:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for candidate in root.glob("*.parts"):
|
||||
lowered = candidate.name.lower()
|
||||
if lowered == ".parts" or any(value in lowered for value in hashes):
|
||||
raise JobExecutionError(
|
||||
"resource uses a qBittorrent partfile that cannot be "
|
||||
"safely transferred by this client"
|
||||
)
|
||||
|
||||
def _raise_if_cancelled(self, job_id: str) -> None:
|
||||
event = self._cancel_events.get(job_id)
|
||||
if event is not None and event.is_set():
|
||||
raise JobCancelled("job cancellation requested")
|
||||
|
||||
def _require_space(self, root: Path, required_bytes: int) -> None:
|
||||
available = shutil.disk_usage(root).free
|
||||
required = required_bytes + self.free_space_reserve_bytes
|
||||
if available < required:
|
||||
raise JobExecutionError(
|
||||
f"insufficient free space: {available} bytes available, "
|
||||
f"{required} bytes required including reserve"
|
||||
)
|
||||
|
||||
def _observer(
|
||||
self, definition: job_pb2.JobDefinition
|
||||
) -> SyncthingTransferObserver:
|
||||
@@ -455,21 +930,29 @@ class ClientJobExecutor:
|
||||
)
|
||||
event.progress.overall_fraction_complete = _overall(step, step_state)
|
||||
event.progress.display_step_number = _step_number(step)
|
||||
event.progress.display_step_total = 5
|
||||
event.progress.display_step_total = (
|
||||
3
|
||||
if definition.operation == job_pb2.JOB_OPERATION_EVICT_CACHE
|
||||
else 5
|
||||
)
|
||||
event.progress.last_progress_at.CopyFrom(event.occurred_at)
|
||||
return event
|
||||
|
||||
@staticmethod
|
||||
def _validate_definition(definition: job_pb2.JobDefinition) -> None:
|
||||
if not definition.job_id:
|
||||
raise JobExecutionError("job definition has no ID")
|
||||
if definition.operation in {
|
||||
job_pb2.JOB_OPERATION_ARCHIVE,
|
||||
job_pb2.JOB_OPERATION_UNARCHIVE,
|
||||
} and definition.WhichOneof("spec") == "transfer":
|
||||
return
|
||||
if (
|
||||
not definition.job_id
|
||||
or definition.operation not in {
|
||||
job_pb2.JOB_OPERATION_ARCHIVE,
|
||||
job_pb2.JOB_OPERATION_UNARCHIVE,
|
||||
}
|
||||
or definition.WhichOneof("spec") != "transfer"
|
||||
definition.operation == job_pb2.JOB_OPERATION_EVICT_CACHE
|
||||
and definition.WhichOneof("spec") == "eviction"
|
||||
):
|
||||
raise JobExecutionError("transfer job definition is invalid")
|
||||
return
|
||||
raise JobExecutionError("job definition operation/spec is invalid")
|
||||
|
||||
|
||||
def _selection_indices(selection) -> list[int]:
|
||||
@@ -510,12 +993,27 @@ def _step_number(step: int) -> int:
|
||||
job_pb2.JOB_STEP_KIND_TARGET_MATERIALIZE: 3,
|
||||
job_pb2.JOB_STEP_KIND_QB_VERIFY: 4,
|
||||
job_pb2.JOB_STEP_KIND_STAGING_CLEANUP: 5,
|
||||
job_pb2.JOB_STEP_KIND_VERIFY_ARCHIVE_COVERAGE: 1,
|
||||
job_pb2.JOB_STEP_KIND_QB_REMOVE_ENTRY: 2,
|
||||
job_pb2.JOB_STEP_KIND_SAFE_FILE_UNLINK: 3,
|
||||
job_pb2.JOB_STEP_KIND_ROLLBACK: 1,
|
||||
}.get(step, 0)
|
||||
|
||||
|
||||
def _overall(step: int, state: int) -> float:
|
||||
number = _step_number(step)
|
||||
return max(0, number - (0 if state == job_pb2.STEP_STATE_SUCCEEDED else 1)) / 5
|
||||
total = (
|
||||
3
|
||||
if step in {
|
||||
job_pb2.JOB_STEP_KIND_VERIFY_ARCHIVE_COVERAGE,
|
||||
job_pb2.JOB_STEP_KIND_QB_REMOVE_ENTRY,
|
||||
job_pb2.JOB_STEP_KIND_SAFE_FILE_UNLINK,
|
||||
}
|
||||
else 5
|
||||
)
|
||||
return max(
|
||||
0, number - (0 if state == job_pb2.STEP_STATE_SUCCEEDED else 1)
|
||||
) / total
|
||||
|
||||
|
||||
def decode_message_metainfo(path: Path):
|
||||
@@ -543,6 +1041,38 @@ def _resource_fingerprint(
|
||||
return fingerprint
|
||||
|
||||
|
||||
def _info_hash(definition: job_pb2.JobDefinition) -> str:
|
||||
value = (
|
||||
definition.resource_id.info_hash_v1_hex
|
||||
or definition.resource_id.info_hash_v2_hex
|
||||
)
|
||||
if not value:
|
||||
raise JobExecutionError("job resource identity has no info hash")
|
||||
return value
|
||||
|
||||
|
||||
def _fingerprint_matches(
|
||||
current: resource_pb2.ResourceStateFingerprint,
|
||||
expected: resource_pb2.ResourceStateFingerprint,
|
||||
) -> bool:
|
||||
left = resource_pb2.ResourceStateFingerprint()
|
||||
right = resource_pb2.ResourceStateFingerprint()
|
||||
left.CopyFrom(current)
|
||||
right.CopyFrom(expected)
|
||||
left.ClearField("observed_at")
|
||||
right.ClearField("observed_at")
|
||||
for value in (left, right):
|
||||
if value.runtime_state in {
|
||||
resource_pb2.TORRENT_RUNTIME_STATE_QUEUED,
|
||||
resource_pb2.TORRENT_RUNTIME_STATE_SEEDING,
|
||||
resource_pb2.TORRENT_RUNTIME_STATE_STALLED,
|
||||
}:
|
||||
value.runtime_state = (
|
||||
resource_pb2.TORRENT_RUNTIME_STATE_SEEDING
|
||||
)
|
||||
return left == right
|
||||
|
||||
|
||||
def _job_error_code(error: Exception) -> int:
|
||||
if isinstance(error, QBittorrentDownloadAttempt):
|
||||
return common_pb2.ERROR_CODE_MANUAL_INTERVENTION_REQUIRED
|
||||
@@ -554,4 +1084,6 @@ def _job_error_code(error: Exception) -> int:
|
||||
return common_pb2.ERROR_CODE_PRECONDITION_FAILED
|
||||
if isinstance(error, TransferError):
|
||||
return common_pb2.ERROR_CODE_PATH_CONFLICT
|
||||
if isinstance(error, EvictionError):
|
||||
return common_pb2.ERROR_CODE_PRECONDITION_FAILED
|
||||
return common_pb2.ERROR_CODE_INTERNAL
|
||||
|
||||
Reference in New Issue
Block a user