feat: execute durable archive transfers

This commit is contained in:
2026-07-23 07:21:19 +00:00
parent 68db3ec4c5
commit 884aed9921
14 changed files with 1618 additions and 8 deletions
+7
View File
@@ -26,6 +26,13 @@ control worktree. `E2E_WAIT_SECONDS` overrides the 360-second assertion
deadline. `down.sh` removes only the five exact Compose projects and the
labelled E2E network; it intentionally retains all bind-mounted runtime state.
Set `E2E_RUN_TRANSFER=1` to follow route verification with a real
cache-1 → archive-1 transfer followed by archive-1 → cache-2 unarchive. The
scenario creates a deterministic one-file torrent in the isolated cache
qBittorrent, submits both jobs through the test HTTP adapter, waits for all five
durable steps in each direction, and verifies target qBittorrent selections,
retained source entries, and target file digests.
The Syncthing 2.1.2 and LinuxServer qBittorrent multi-platform image indexes are
digest-pinned. Runtime secrets are generated with mode 0600 and ignored by
Git. The qBittorrent test config limits its authentication bypass to the
+1 -1
View File
@@ -8,6 +8,7 @@ services:
user: "1001:1001"
volumes:
- ./config/config.json:/etc/archive-control/config.json:ro
- ../scenarios:/e2e/scenarios:ro
- ./secrets:/run/secrets:ro
- ./runtime/state:/var/lib/archive-control
- ./runtime/backups:/var/backups/archive-control
@@ -26,4 +27,3 @@ networks:
archive-control-e2e:
external: true
name: archive-control-e2e
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Drive one real transfer through the test HTTP adapter."""
from __future__ import annotations
import argparse
import json
import sys
import time
import urllib.parse
import urllib.request
import uuid
from datetime import datetime, timezone
CONTROL = "http://127.0.0.1:18081/test/v1"
INFO_HASH = "330be0cb7c2201135a2de63b28e77993745ff688"
LOGICAL_BYTES = 31
def get_json(url: str):
with urllib.request.urlopen(url, timeout=5) as response:
return json.load(response)
def post_json(url: str, value: object):
encoded = json.dumps(value, separators=(",", ":")).encode()
request = urllib.request.Request(
url,
data=encoded,
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=5) as response:
return json.load(response)
def qb_json(endpoint: str, path: str, **parameters: str):
query = urllib.parse.urlencode(parameters)
suffix = f"?{query}" if query else ""
return get_json(f"{endpoint}{path}{suffix}")
def wait_for(predicate, timeout: float, detail: str):
deadline = time.monotonic() + timeout
last = None
while time.monotonic() < deadline:
try:
last = predicate()
if last:
return last
except (OSError, ValueError, KeyError, IndexError) as exc:
last = f"{type(exc).__name__}: {exc}"
time.sleep(0.5)
raise RuntimeError(f"timed out waiting for {detail}; last={last!r}")
def qb_resource_is_complete(endpoint: str):
records = qb_json(endpoint, "/torrents/info", hashes=INFO_HASH)
if len(records) != 1 or float(records[0].get("progress", 0)) < 1:
return None
files = qb_json(endpoint, "/torrents/files", hash=INFO_HASH)
if (
len(files) != 1
or float(files[0].get("progress", 0)) < 1
or int(files[0].get("priority", 0)) <= 0
):
return None
return records[0]
def ready_route(cache_client: str, archive_client: str):
for route in get_json(f"{CONTROL}/routes"):
if (
route["cache_client_id"] == cache_client
and route["archive_client_id"] == archive_client
and route["state"] == "ready"
):
return route
return None
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--operation", choices=("archive", "unarchive"), required=True
)
parser.add_argument("--source-client", required=True)
parser.add_argument("--target-client", required=True)
parser.add_argument("--source-qb", required=True)
parser.add_argument("--target-qb", required=True)
parser.add_argument("--cache-client", required=True)
parser.add_argument("--archive-client", required=True)
args = parser.parse_args()
wait_for(
lambda: qb_resource_is_complete(args.source_qb),
60,
"source qBittorrent verification",
)
route = wait_for(
lambda: ready_route(args.cache_client, args.archive_client),
60,
f"{args.cache_client}/{args.archive_client} route",
)
job_id = str(uuid.uuid4())
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
definition = {
"jobId": job_id,
"idempotencyKey": str(uuid.uuid4()),
"operation": (
"JOB_OPERATION_ARCHIVE"
if args.operation == "archive"
else "JOB_OPERATION_UNARCHIVE"
),
"resourceId": {"infoHashV1Hex": INFO_HASH},
"resourceDisplayName": "fixture.bin",
"createdAt": now,
"transfer": {
"sourceClientId": args.source_client,
"targetClientId": args.target_client,
"routeId": route["route_id"],
"requestedFiles": {"ranges": [{"first": 0, "last": 0}]},
"transferDeltaFiles": {"ranges": [{"first": 0, "last": 0}]},
"requestedLogicalBytes": str(LOGICAL_BYTES),
"transferDeltaLogicalBytes": str(LOGICAL_BYTES),
},
}
created = post_json(f"{CONTROL}/jobs", definition)
if created["job_id"] != job_id:
raise RuntimeError("control returned the wrong job")
def finished_job():
job = get_json(f"{CONTROL}/jobs/{job_id}")
if job["state"] == "JOB_STATE_FAILED":
raise RuntimeError(f"{args.operation} job failed: {job}")
if job["state"] == "JOB_STATE_SUCCEEDED" and job["committed"]:
return job
return None
job = wait_for(finished_job, 180, f"committed {args.operation} job")
wait_for(
lambda: qb_resource_is_complete(args.target_qb),
30,
"target qBittorrent placement",
)
if qb_resource_is_complete(args.source_qb) is None:
raise RuntimeError(
f"{args.operation} transfer did not retain its source placement"
)
print(json.dumps({
"operation": args.operation,
"job_id": job_id,
"state": job["state"],
"committed": job["committed"],
"route_id": route["route_id"],
"source_retained": True,
"target_verified": True,
}, sort_keys=True))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"transfer happy-path failed: {exc}", file=sys.stderr)
raise
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/lib.sh"
info_hash=330be0cb7c2201135a2de63b28e77993745ff688
torrent_base64=ZDQ6aW5mb2Q2Omxlbmd0aGkzMWU0Om5hbWUxMTpmaXh0dXJlLmJpbjEyOnBpZWNlIGxlbmd0aGkxNjM4NGU2OnBpZWNlczIwOlFlB05MtIUU4oem2MNz5LJW/GI6ZWU=
wait_qb_absent() {
local node=$1
local deadline=$((SECONDS + 30))
while (( SECONDS < deadline )); do
local records
records=$(compose_node "$node" exec -T qbittorrent curl -fsS \
"http://127.0.0.1:8080/api/v2/torrents/info?hashes=$info_hash")
if [[ "$records" == "[]" ]]; then
return
fi
sleep 1
done
printf 'timed out waiting for fixture removal on %s\n' "$node" >&2
exit 1
}
for node in cache-1 archive-1; do
compose_node "$node" exec -T qbittorrent curl -fsS \
-X POST http://127.0.0.1:8080/api/v2/torrents/delete \
--data-urlencode "hashes=$info_hash" \
--data-urlencode "deleteFiles=true" >/dev/null || true
wait_qb_absent "$node"
done
compose_node cache-1 exec -T qbittorrent /bin/sh -c \
'printf "archive-control-e2e-happy-path\n" > /downloads/fixture.bin'
compose_node cache-1 exec -T qbittorrent /bin/sh -c \
"printf '%s' '$torrent_base64' | base64 -d > /tmp/fixture.torrent"
compose_node cache-1 exec -T qbittorrent curl -fsS \
-X POST http://127.0.0.1:8080/api/v2/torrents/add \
-F torrents=@/tmp/fixture.torrent \
-F savepath=/downloads \
-F stopped=true >/dev/null
compose_node cache-1 exec -T qbittorrent curl -fsS \
-X POST http://127.0.0.1:8080/api/v2/torrents/recheck \
--data-urlencode "hashes=$info_hash" >/dev/null
compose_control exec -T control \
python /e2e/scenarios/archive_happy.py \
--operation archive \
--source-client cache-1 \
--target-client archive-1 \
--source-qb http://qb-cache-1:8080/api/v2 \
--target-qb http://qb-archive-1:8080/api/v2 \
--cache-client cache-1 \
--archive-client archive-1
target_digest=$(compose_node archive-1 exec -T qbittorrent \
sha256sum /downloads/fixture.bin | awk '{print $1}')
if [[ "$target_digest" != \
"2cb91dd48809bd418a6a54a8f90a9009eb9eef9259ab46052bc1fe108a317ecb" ]]; then
printf 'archive target content digest mismatch: %s\n' \
"$target_digest" >&2
exit 1
fi
printf 'archive filesystem content verified: sha256=%s\n' "$target_digest"
compose_node cache-2 exec -T qbittorrent curl -fsS \
-X POST http://127.0.0.1:8080/api/v2/torrents/delete \
--data-urlencode "hashes=$info_hash" \
--data-urlencode "deleteFiles=true" >/dev/null || true
wait_qb_absent cache-2
compose_control exec -T control \
python /e2e/scenarios/archive_happy.py \
--operation unarchive \
--source-client archive-1 \
--target-client cache-2 \
--source-qb http://qb-archive-1:8080/api/v2 \
--target-qb http://qb-cache-2:8080/api/v2 \
--cache-client cache-2 \
--archive-client archive-1
cache_digest=$(compose_node cache-2 exec -T qbittorrent \
sha256sum /downloads/fixture.bin | awk '{print $1}')
if [[ "$cache_digest" != \
"2cb91dd48809bd418a6a54a8f90a9009eb9eef9259ab46052bc1fe108a317ecb" ]]; then
printf 'unarchive target content digest mismatch: %s\n' \
"$cache_digest" >&2
exit 1
fi
printf 'unarchive filesystem content verified: sha256=%s\n' "$cache_digest"
+3
View File
@@ -21,3 +21,6 @@ for node in "${E2E_NODES[@]}"; do
compose_node "$node" up -d --build
done
"$E2E_ROOT/scripts/wait-routes.sh"
if [[ "${E2E_RUN_TRANSFER:-0}" == "1" ]]; then
"$E2E_ROOT/scripts/archive-happy.sh"
fi
+210 -3
View File
@@ -7,6 +7,7 @@ import logging
import random
import time
import uuid
from pathlib import Path, PurePosixPath
from typing import Any
from websockets.asyncio.client import connect
@@ -14,6 +15,7 @@ from websockets.asyncio.client import connect
from archive_clients.backup import SQLiteBackupManager
from archive_clients.config import ClientConfig
from archive_clients.inventory import InventoryService
from archive_clients.jobs import ClientJobExecutor, JobExecutionError
from archive_clients.locking import DatabaseLease
from archive_clients.probes import FilesystemProbe
from archive_clients.protocol import (
@@ -73,12 +75,40 @@ class ArchiveClientDaemon:
if healthy_syncthing and probes[1].writable
else None
)
self._known_route_paths: dict[str, Path] = {}
for probe in service_probes:
if probe.service != "syncthing":
continue
for route in probe.routes:
api_path = (
config.syncthing.api_root
/ PurePosixPath(route.local_relative_path)
).as_posix()
self._known_route_paths[route.route_id] = (
config.syncthing.roots.api_to_local(api_path)
)
self.store = ClientStore(config.state_db)
self.backups = SQLiteBackupManager(
config.state_db, config.backup_dir, config.backup
)
self._lease = DatabaseLease(config.state_db)
self._active_route_commands: set[str] = set()
self._active_job_commands: set[str] = set()
self._job_execution_lock = asyncio.Lock()
self.jobs = (
ClientJobExecutor(
client_id=config.client_id,
qbittorrent=resource_reader,
store=self.store,
qb_root=config.qbittorrent.local_root,
qb_api_root=config.qbittorrent.api_root,
route_path=self._route_path,
syncthing_transport=self.routes.transport,
sparse_supported=all(probe.sparse_files for probe in probes),
)
if resource_reader is not None and self.routes is not None
else None
)
async def run(self) -> None:
await asyncio.to_thread(self._lease.acquire)
@@ -169,7 +199,7 @@ class ArchiveClientDaemon:
writer = asyncio.create_task(self._writer(websocket, outbound))
command_tasks: set[asyncio.Task[None]] = set()
try:
await self._resume_route_commands(outbound, command_tasks)
await self._resume_commands(outbound, command_tasks)
async for frame in websocket:
await self._handle(decode(frame), outbound, command_tasks)
finally:
@@ -382,12 +412,25 @@ class ArchiveClientDaemon:
outbound,
command_tasks,
)
elif (
accepted is not None
and accepted_for_execution
and command.WhichOneof("payload") in {"assign_job", "execute_step"}
and self.jobs is not None
):
self._schedule_job_command(
command,
envelope.message_id,
outbound,
command_tasks,
)
async def _resume_route_commands(
async def _resume_commands(
self,
outbound: asyncio.Queue[str],
command_tasks: set[asyncio.Task[None]],
) -> None:
job_commands: list[control_pb2.Command] = []
for row in await asyncio.to_thread(self.store.list_accepted_commands):
acknowledgement = decode_message(
str(row["acknowledgement_json"]), control_pb2.CommandAck()
@@ -401,6 +444,120 @@ class ArchiveClientDaemon:
self._schedule_route_command(
command, "", outbound, command_tasks
)
elif (
command.WhichOneof("payload") in {"assign_job", "execute_step"}
and self.jobs is not None
):
job_commands.append(command)
if job_commands:
task = asyncio.create_task(
self._resume_job_commands(job_commands, outbound),
name="resume-job-commands",
)
command_tasks.add(task)
task.add_done_callback(
lambda completed: self._command_finished(
completed, command_tasks
)
)
async def _resume_route_commands(
self,
outbound: asyncio.Queue[str],
command_tasks: set[asyncio.Task[None]],
) -> None:
"""Backward-compatible test hook."""
await self._resume_commands(outbound, command_tasks)
def _schedule_job_command(
self,
command: control_pb2.Command,
correlation_id: str,
outbound: asyncio.Queue[str],
command_tasks: set[asyncio.Task[None]],
) -> None:
if command.command_id in self._active_job_commands:
return
self._active_job_commands.add(command.command_id)
task = asyncio.create_task(
self._execute_job_command(command, correlation_id, outbound),
name=f"job-command-{command.command_id}",
)
command_tasks.add(task)
task.add_done_callback(
lambda completed: self._job_command_finished(
command.command_id, completed, command_tasks
)
)
async def _resume_job_commands(
self,
commands: list[control_pb2.Command],
outbound: asyncio.Queue[str],
) -> None:
for command in commands:
if command.command_id in self._active_job_commands:
continue
self._active_job_commands.add(command.command_id)
try:
await self._execute_job_command(command, "", outbound)
except asyncio.CancelledError:
raise
except Exception as error:
logger.error(
"background_command_failed",
extra={
"error_type": type(error).__name__,
"error_detail": str(error),
},
)
finally:
self._active_job_commands.discard(command.command_id)
def _job_command_finished(
self,
command_id: str,
task: asyncio.Task[None],
command_tasks: set[asyncio.Task[None]],
) -> None:
self._active_job_commands.discard(command_id)
self._command_finished(task, command_tasks)
async def _execute_job_command(
self,
command: control_pb2.Command,
correlation_id: str,
outbound: asyncio.Queue[str],
) -> None:
async with self._job_execution_lock:
await self._execute_job_command_locked(
command, correlation_id, outbound
)
async def _execute_job_command_locked(
self,
command: control_pb2.Command,
correlation_id: str,
outbound: asyncio.Queue[str],
) -> None:
assert self.jobs is not None
payload = command.WhichOneof("payload")
if payload == "assign_job":
events = await asyncio.to_thread(
self.jobs.assign, command.assign_job
)
elif payload == "execute_step":
events = await asyncio.to_thread(
self.jobs.execute, command.execute_step
)
else:
raise JobExecutionError("job command payload is unsupported")
for event in events:
response = new_envelope()
response.correlation_id = correlation_id
response.job_event.CopyFrom(event)
await outbound.put(encode(response))
def _schedule_route_command(
self,
@@ -495,6 +652,7 @@ class ArchiveClientDaemon:
configured = await asyncio.to_thread(
self.routes.configure, spec, deadline
)
self._known_route_paths[spec.route_id] = configured.local_path
await asyncio.to_thread(
self.store.record_route_ownership,
command.command_id,
@@ -658,7 +816,10 @@ class ArchiveClientDaemon:
if error is not None:
logger.error(
"background_command_failed",
extra={"error_type": type(error).__name__},
extra={
"error_type": type(error).__name__,
"error_detail": str(error),
},
)
def _initial_acknowledgement(
@@ -717,6 +878,46 @@ class ArchiveClientDaemon:
acknowledgement.error.message = "ensure route specification is invalid"
else:
acknowledgement.status = control_pb2.COMMAND_ACK_STATUS_ACCEPTED
elif command.WhichOneof("payload") == "assign_job":
definition = command.assign_job.job
if self.jobs is None:
acknowledgement.status = control_pb2.COMMAND_ACK_STATUS_REJECTED
acknowledgement.error.code = common_pb2.ERROR_CODE_UNAVAILABLE
acknowledgement.error.message = "job executor is unavailable"
elif (
not definition.job_id
or definition.WhichOneof("spec") != "transfer"
or self.config.client_id not in {
definition.transfer.source_client_id,
definition.transfer.target_client_id,
}
):
acknowledgement.status = control_pb2.COMMAND_ACK_STATUS_REJECTED
acknowledgement.error.code = common_pb2.ERROR_CODE_INVALID_ARGUMENT
acknowledgement.error.message = "job assignment is invalid"
else:
acknowledgement.status = control_pb2.COMMAND_ACK_STATUS_ACCEPTED
elif command.WhichOneof("payload") == "execute_step":
step = command.execute_step
if self.jobs is None:
acknowledgement.status = control_pb2.COMMAND_ACK_STATUS_REJECTED
acknowledgement.error.code = common_pb2.ERROR_CODE_UNAVAILABLE
acknowledgement.error.message = "job executor is unavailable"
elif (
not step.job_id
or step.step not in {
job_pb2.JOB_STEP_KIND_SOURCE_STAGE,
job_pb2.JOB_STEP_KIND_SYNCTHING_TRANSFER,
job_pb2.JOB_STEP_KIND_TARGET_MATERIALIZE,
job_pb2.JOB_STEP_KIND_QB_VERIFY,
job_pb2.JOB_STEP_KIND_STAGING_CLEANUP,
}
):
acknowledgement.status = control_pb2.COMMAND_ACK_STATUS_REJECTED
acknowledgement.error.code = common_pb2.ERROR_CODE_INVALID_ARGUMENT
acknowledgement.error.message = "job step is invalid"
else:
acknowledgement.status = control_pb2.COMMAND_ACK_STATUS_ACCEPTED
else:
acknowledgement.status = control_pb2.COMMAND_ACK_STATUS_REJECTED
acknowledgement.error.code = common_pb2.ERROR_CODE_UNSUPPORTED
@@ -725,6 +926,12 @@ class ArchiveClientDaemon:
)
return acknowledgement
def _route_path(self, route_id: str) -> Path:
try:
return self._known_route_paths[route_id]
except KeyError as exc:
raise JobExecutionError("job route is not configured locally") from exc
def _route_error(exc: Exception) -> tuple[int, bool]:
if isinstance(exc, RoutePathConflict):
+557
View File
@@ -0,0 +1,557 @@
"""Control-commanded happy-path transfer execution."""
from __future__ import annotations
import hashlib
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.qbittorrent import (
QBittorrentDownloadAttempt,
QBittorrentReader,
)
from archive_clients.resources import NormalizedResource
from archive_clients.state import ClientStore
from archive_clients.syncthing import SyncthingTransferObserver
from archive_clients.transfer import (
TransferError,
TransferIntegrityError,
cleanup_transfer,
load_published_transfer,
materialize_transfer,
stage_transfer,
)
from archive_control.v1 import (
common_pb2,
control_pb2,
job_pb2,
resource_pb2,
transfer_pb2,
)
class JobExecutionError(RuntimeError):
pass
class ClientJobExecutor:
def __init__(
self,
*,
client_id: str,
qbittorrent: QBittorrentReader,
store: ClientStore,
qb_root: Path,
qb_api_root: PurePosixPath,
route_path: Callable[[str], Path],
syncthing_transport: object,
sparse_supported: bool,
poll_interval: float = 1,
):
self.client_id = client_id
self.qbittorrent = qbittorrent
self.store = store
self.qb_root = qb_root
self.qb_api_root = qb_api_root
self.route_path = route_path
self.syncthing_transport = syncthing_transport
self.sparse_supported = sparse_supported
self.poll_interval = poll_interval
def assign(
self, command: control_pb2.AssignJobCommand
) -> list[control_pb2.JobEvent]:
definition = command.job
self._validate_definition(definition)
replay = self._replay(
definition.job_id, command.expected_last_event_sequence
)
if replay:
return replay
event = self._event(
definition,
sequence=command.expected_last_event_sequence + 1,
revision=command.expected_job_revision,
event_type=control_pb2.JOB_EVENT_TYPE_ASSIGNED,
state=job_pb2.JOB_STATE_PREPARING,
committed=False,
)
self._record(definition, event)
return [event]
def execute(
self, command: control_pb2.ExecuteStepCommand
) -> list[control_pb2.JobEvent]:
definition = self._definition(command.job_id)
replay = self._replay(
command.job_id, command.expected_last_event_sequence
)
if replay and replay[-1].type in {
control_pb2.JOB_EVENT_TYPE_STEP_SUCCEEDED,
control_pb2.JOB_EVENT_TYPE_COMMITTED,
control_pb2.JOB_EVENT_TYPE_SUCCEEDED,
control_pb2.JOB_EVENT_TYPE_FAILED,
control_pb2.JOB_EVENT_TYPE_CLEANUP_REQUIRED,
control_pb2.JOB_EVENT_TYPE_CANCELLED,
}:
return replay
started = replay[0] if replay else self._event(
definition,
sequence=command.expected_last_event_sequence + 1,
revision=command.expected_job_revision + 1,
event_type=control_pb2.JOB_EVENT_TYPE_STEP_STARTED,
state=job_pb2.JOB_STATE_RUNNING,
committed=(
self._committed(command.job_id)
or command.step == job_pb2.JOB_STEP_KIND_STAGING_CLEANUP
),
step=command.step,
step_state=job_pb2.STEP_STATE_RUNNING,
)
if not replay:
self._record(definition, started)
try:
result = self._execute_step(definition, command.step)
except Exception as error:
cleanup = (
command.step == job_pb2.JOB_STEP_KIND_STAGING_CLEANUP
and started.committed
)
failed = self._event(
definition,
sequence=started.sequence + 1,
revision=started.job_revision + 1,
event_type=(
control_pb2.JOB_EVENT_TYPE_CLEANUP_REQUIRED
if cleanup else control_pb2.JOB_EVENT_TYPE_FAILED
),
state=(
job_pb2.JOB_STATE_CLEANUP_REQUIRED
if cleanup else job_pb2.JOB_STATE_FAILED
),
committed=started.committed,
step=command.step,
step_state=job_pb2.STEP_STATE_FAILED,
)
failed.error.code = _job_error_code(error)
failed.error.message = str(error) or type(error).__name__
failed.error.retryable = cleanup
self._record(definition, failed)
return [started, failed]
sequence = started.sequence + 1
revision = started.job_revision + 1
committed = started.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:
committed = True
event_type = control_pb2.JOB_EVENT_TYPE_COMMITTED
elif command.step == job_pb2.JOB_STEP_KIND_STAGING_CLEANUP:
event_type = control_pb2.JOB_EVENT_TYPE_SUCCEEDED
state = job_pb2.JOB_STATE_SUCCEEDED
succeeded = self._event(
definition,
sequence=sequence,
revision=revision,
event_type=event_type,
state=state,
committed=committed,
step=command.step,
step_state=job_pb2.STEP_STATE_SUCCEEDED,
)
if result is not None:
succeeded.observed_placement.CopyFrom(result)
self._record(definition, succeeded)
return [started, succeeded]
def _execute_step(
self, definition: job_pb2.JobDefinition, step: int
):
if step == job_pb2.JOB_STEP_KIND_SOURCE_STAGE:
self._source_stage(definition)
return None
if step == job_pb2.JOB_STEP_KIND_SYNCTHING_TRANSFER:
self._wait_for_syncthing(definition)
return None
if step == job_pb2.JOB_STEP_KIND_TARGET_MATERIALIZE:
self._target_materialize(definition)
return None
if step == job_pb2.JOB_STEP_KIND_QB_VERIFY:
return self._qb_verify(definition)
if step == job_pb2.JOB_STEP_KIND_STAGING_CLEANUP:
cleanup_transfer(self._job_directory(definition))
temporary_metainfo = self._metainfo_path(definition.job_id)
if temporary_metainfo.exists():
temporary_metainfo.unlink()
for directory in (
temporary_metainfo.parent,
temporary_metainfo.parent.parent,
):
try:
directory.rmdir()
except OSError:
break
return None
raise JobExecutionError("job step is unsupported")
def _source_stage(self, definition: job_pb2.JobDefinition) -> None:
if definition.transfer.source_client_id != self.client_id:
raise JobExecutionError("source stage was sent to the wrong client")
resource = self._resource(definition)
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):
raise JobExecutionError("transfer delta file selection is invalid")
if any(
not by_index[index].selected
or by_index[index].completed_bytes != by_index[index].logical_bytes
for index in indices
):
raise JobExecutionError("transfer delta contains unavailable files")
manifest = transfer_pb2.TransferManifest(
manifest_version=1,
job_id=definition.job_id,
source_client_id=definition.transfer.source_client_id,
target_client_id=definition.transfer.target_client_id,
route_id=definition.transfer.route_id,
)
manifest.resource_id.CopyFrom(definition.resource_id)
manifest.requested_files.CopyFrom(definition.transfer.requested_files)
manifest.target_baseline_files.CopyFrom(
definition.transfer.target_baseline_files
)
manifest.transfer_delta_files.CopyFrom(
definition.transfer.transfer_delta_files
)
manifest.source_fingerprint.CopyFrom(
definition.transfer.source_fingerprint
)
if definition.transfer.HasField("target_baseline_fingerprint"):
manifest.target_baseline_fingerprint.CopyFrom(
definition.transfer.target_baseline_fingerprint
)
manifest.created_at.GetCurrentTime()
for index in indices:
item = by_index[index]
manifest.files.add(
file_index=index,
payload_relative_path=f"payload/{item.canonical_path}",
target_canonical_path=item.canonical_path,
logical_bytes=item.logical_bytes,
)
metainfo_path = self._metainfo_path(definition.job_id)
metainfo_path.parent.mkdir(parents=True, exist_ok=True)
if not metainfo_path.exists():
metainfo_path.write_bytes(resource.metainfo_bytes)
artifact = manifest.artifacts.add(
kind=transfer_pb2.ARTIFACT_KIND_TORRENT_FILE,
payload_relative_path="metainfo/source.torrent",
logical_bytes=len(resource.metainfo_bytes),
format="application/x-bittorrent",
sha256_hex=hashlib.sha256(resource.metainfo_bytes).hexdigest(),
)
del artifact
stage_transfer(
manifest,
source_root=self.qb_root,
sync_root=self.route_path(definition.transfer.route_id),
store=self.store,
artifact_sources={"metainfo/source.torrent": metainfo_path},
sparse_supported=self.sparse_supported,
)
self._observer(definition).rescan()
def _wait_for_syncthing(self, definition: job_pb2.JobDefinition) -> None:
if definition.transfer.target_client_id != self.client_id:
raise JobExecutionError(
"Syncthing completion was sent to the wrong client"
)
observer = self._observer(definition)
while True:
try:
if observer.status().complete:
return
except (FileNotFoundError, TransferIntegrityError):
# Syncthing can expose the per-job directory before the
# ready marker and manifest have arrived atomically as a set.
pass
time.sleep(self.poll_interval)
def _target_materialize(self, definition: job_pb2.JobDefinition) -> 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))
materialize_transfer(
published,
target_root=self.qb_root,
store=self.store,
sparse_supported=self.sparse_supported,
)
def _qb_verify(self, definition: job_pb2.JobDefinition):
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))
metainfo_path = (
published.job_directory / "metainfo" / "source.torrent"
)
info_hash = (
definition.resource_id.info_hash_v1_hex
or definition.resource_id.info_hash_v2_hex
)
resource = self.qbittorrent.get_resource(info_hash)
should_start = (
resource is None
or resource.summary.runtime_state
!= resource_pb2.TORRENT_RUNTIME_STATE_STOPPED
)
if resource is None:
self.qbittorrent.add_stopped_with_retry(
metainfo_path.read_bytes(),
self.qb_api_root.as_posix(),
info_hash,
)
target_union = _union_selection(
definition.transfer.target_baseline_files,
definition.transfer.transfer_delta_files,
)
selected = _selection_indices(target_union)
total_files = len(
decode_message_metainfo(metainfo_path).files
)
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
)
if should_start:
self.qbittorrent.start(info_hash)
verified = self.qbittorrent.get_resource(info_hash)
if verified is None:
raise JobExecutionError(
"verified qBittorrent resource disappeared"
)
placement = resource_pb2.Placement(
client_id=self.client_id,
state=resource_pb2.PLACEMENT_STATE_PRESENT,
generation=(
definition.transfer.expected_target_placement_generation + 1
if definition.transfer.HasField(
"expected_target_placement_generation"
)
else 1
),
created_by_job_id=definition.job_id,
)
placement.resource_id.CopyFrom(definition.resource_id)
placement.verified_files.CopyFrom(target_union)
metainfo = decode_message_metainfo(metainfo_path)
placement.verified_logical_bytes = sum(
metainfo.files[index].logical_bytes for index in selected
)
placement.fingerprint.CopyFrom(
_resource_fingerprint(verified, self.client_id)
)
placement.verified_at.GetCurrentTime()
return placement
def _observer(
self, definition: job_pb2.JobDefinition
) -> SyncthingTransferObserver:
return SyncthingTransferObserver(
self.syncthing_transport,
definition.transfer.route_id,
f".archive-control/jobs/{definition.job_id}",
self._job_directory(definition),
)
def _job_directory(self, definition: job_pb2.JobDefinition) -> Path:
return (
self.route_path(definition.transfer.route_id)
/ ".archive-control" / "jobs" / definition.job_id
)
def _metainfo_path(self, job_id: str) -> Path:
return self.store.database.parent / "artifacts" / job_id / "source.torrent"
def _resource(self, definition: job_pb2.JobDefinition) -> NormalizedResource:
for info_hash in (
definition.resource_id.info_hash_v1_hex,
definition.resource_id.info_hash_v2_hex,
):
if info_hash:
resource = self.qbittorrent.get_resource(info_hash)
if resource is not None:
return resource
raise JobExecutionError("source resource is not present")
def _definition(self, job_id: str) -> job_pb2.JobDefinition:
rows = self.store.job_snapshot_rows([job_id])
if not rows:
raise JobExecutionError("job is not assigned")
return decode_message(
str(rows[0]["definition_json"]), job_pb2.JobDefinition()
)
def _committed(self, job_id: str) -> bool:
rows = self.store.job_snapshot_rows([job_id])
return bool(rows and rows[0]["committed"])
def _replay(
self, job_id: str, after_sequence: int
) -> list[control_pb2.JobEvent]:
return [
decode_message(str(row["event_json"]), control_pb2.JobEvent())
for row in self.store.job_event_rows(job_id, after_sequence)
]
def _record(
self,
definition: job_pb2.JobDefinition,
event: control_pb2.JobEvent,
) -> None:
self.store.record_job_event(
job_id=definition.job_id,
definition_json=encode_message(definition),
event_id=event.event_id,
event_json=encode_message(event),
state=job_pb2.JobState.Name(event.state),
revision=event.job_revision,
sequence=event.sequence,
committed=event.committed,
)
@staticmethod
def _event(
definition: job_pb2.JobDefinition,
*,
sequence: int,
revision: int,
event_type: int,
state: int,
committed: bool,
step: int = job_pb2.JOB_STEP_KIND_UNSPECIFIED,
step_state: int = job_pb2.STEP_STATE_UNSPECIFIED,
) -> control_pb2.JobEvent:
event = control_pb2.JobEvent(
event_id=str(uuid.uuid4()),
job_id=definition.job_id,
sequence=sequence,
job_revision=revision,
type=event_type,
state=state,
committed=committed,
)
event.occurred_at.GetCurrentTime()
if step != job_pb2.JOB_STEP_KIND_UNSPECIFIED:
event.progress.step = step
event.progress.state = step_state
event.progress.fraction_complete = (
1 if step_state == job_pb2.STEP_STATE_SUCCEEDED else 0
)
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.last_progress_at.CopyFrom(event.occurred_at)
return event
@staticmethod
def _validate_definition(definition: job_pb2.JobDefinition) -> None:
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"
):
raise JobExecutionError("transfer job definition is invalid")
def _selection_indices(selection) -> list[int]:
result: list[int] = []
previous = -1
for item in selection.ranges:
if item.first > item.last or item.first <= previous:
raise JobExecutionError("selection ranges are invalid")
result.extend(range(item.first, item.last + 1))
previous = item.last
return result
def _union_selection(*selections) -> resource_pb2.SelectionSet:
indices = sorted({
index
for selection in selections
for index in _selection_indices(selection)
})
result = resource_pb2.SelectionSet()
if not indices:
return result
first = previous = indices[0]
for index in indices[1:]:
if index == previous + 1:
previous = index
continue
result.ranges.add(first=first, last=previous)
first = previous = index
result.ranges.add(first=first, last=previous)
return result
def _step_number(step: int) -> int:
return {
job_pb2.JOB_STEP_KIND_SOURCE_STAGE: 1,
job_pb2.JOB_STEP_KIND_SYNCTHING_TRANSFER: 2,
job_pb2.JOB_STEP_KIND_TARGET_MATERIALIZE: 3,
job_pb2.JOB_STEP_KIND_QB_VERIFY: 4,
job_pb2.JOB_STEP_KIND_STAGING_CLEANUP: 5,
}.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
def decode_message_metainfo(path: Path):
from archive_clients.bencode import decode_metainfo
return decode_metainfo(path.read_bytes())
def _resource_fingerprint(
resource: NormalizedResource, client_id: str
) -> resource_pb2.ResourceStateFingerprint:
summary = resource.summary
fingerprint = resource_pb2.ResourceStateFingerprint(
client_id=client_id,
qb_torrent_id=summary.qb_torrent_id,
content_revision=summary.content_revision,
runtime_state=summary.runtime_state,
)
fingerprint.resource_id.CopyFrom(summary.resource_id)
fingerprint.selected_files.CopyFrom(summary.selected_files)
fingerprint.selected_complete_files.CopyFrom(
summary.selected_complete_files
)
fingerprint.observed_at.CopyFrom(summary.observed_at)
return fingerprint
def _job_error_code(error: Exception) -> int:
if isinstance(error, QBittorrentDownloadAttempt):
return common_pb2.ERROR_CODE_MANUAL_INTERVENTION_REQUIRED
if isinstance(error, TransferIntegrityError):
return common_pb2.ERROR_CODE_INTEGRITY_CHECK_FAILED
if isinstance(error, PermissionError):
return common_pb2.ERROR_CODE_PERMISSION_DENIED
if isinstance(error, JobExecutionError):
return common_pb2.ERROR_CODE_PRECONDITION_FAILED
if isinstance(error, TransferError):
return common_pb2.ERROR_CODE_PATH_CONFLICT
return common_pb2.ERROR_CODE_INTERNAL
+82 -2
View File
@@ -126,6 +126,37 @@ class QBittorrentReader:
)
_require_mutation_success(response, "add torrent")
def add_stopped_with_retry(
self,
metainfo: bytes,
save_path: str,
torrent_hash: str,
*,
max_attempts: int = 3,
initial_delay: float = 1,
) -> None:
if max_attempts < 1 or initial_delay < 0:
raise QBittorrentError("torrent add retry values are invalid")
delay = initial_delay
last_error: QBittorrentError | None = None
for attempt in range(1, max_attempts + 1):
try:
self.add_stopped(metainfo, save_path)
self.wait_until_present(
torrent_hash, timeout=15, poll_interval=0.1
)
return
except QBittorrentError as error:
last_error = error
if self._torrent_is_present(torrent_hash):
return
if attempt == max_attempts:
break
time.sleep(delay)
delay *= 2
assert last_error is not None
raise last_error
def set_selection(
self,
torrent_hash: str,
@@ -141,7 +172,9 @@ class QBittorrentReader:
"/api/v2/torrents/filePrio",
{
"hash": torrent_hash,
"id": f"0-{total_file_count - 1}",
"id": "|".join(
str(index) for index in range(total_file_count)
),
"priority": "0",
},
)
@@ -166,6 +199,53 @@ class QBittorrentReader:
"/api/v2/torrents/pause", {"hashes": torrent_hash}
)
def start(self, torrent_hash: str) -> None:
try:
self._post_form(
"/api/v2/torrents/start", {"hashes": torrent_hash}
)
except QBittorrentHttpError as exc:
if exc.status != 404:
raise
self._post_form(
"/api/v2/torrents/resume", {"hashes": torrent_hash}
)
def wait_until_present(
self,
torrent_hash: str,
*,
timeout: float,
poll_interval: float = 0.1,
) -> None:
if timeout <= 0 or poll_interval < 0:
raise QBittorrentError("torrent lookup timing values are invalid")
deadline = time.monotonic() + timeout
while True:
if self._torrent_is_present(torrent_hash):
return
if time.monotonic() >= deadline:
raise QBittorrentError(
"qBittorrent did not expose the added torrent in time"
)
time.sleep(
min(poll_interval, max(0, deadline - time.monotonic()))
)
def _torrent_is_present(self, torrent_hash: str) -> bool:
torrents = self._json(
"/api/v2/torrents/info", {"hashes": torrent_hash}
)
if not isinstance(torrents, list):
raise QBittorrentError(
"qBittorrent lookup response is invalid"
)
return any(
isinstance(item, dict)
and str(item.get("hash", "")).lower() == torrent_hash.lower()
for item in torrents
)
def delete_entry(self, torrent_hash: str) -> None:
self._post_form(
"/api/v2/torrents/delete",
@@ -430,7 +510,7 @@ class QBittorrentReader:
def _is_download_state(state: str) -> bool:
return state not in {"checkingDL"} and (
return state not in {"checkingDL", "stoppedDL", "pausedDL"} and (
state.endswith("DL")
or state in {"downloading", "metaDL", "forcedMetaDL"}
)
+2 -1
View File
@@ -22,6 +22,7 @@ class NormalizedResource:
summary: resource_pb2.ResourceSummary
files: tuple[resource_pb2.TorrentFile, ...]
metainfo: Metainfo
metainfo_bytes: bytes = b""
def build_content_tree(
@@ -162,7 +163,7 @@ def normalize_resource(
revision_data, sort_keys=True, separators=(",", ":"),
).encode("utf-8")).hexdigest()
summary.observed_at.FromDatetime(observed_at)
return NormalizedResource(summary, tuple(files), metainfo)
return NormalizedResource(summary, tuple(files), metainfo, metainfo_bytes)
def _set_selection(target: Any, indices: list[int]) -> None:
+107
View File
@@ -268,6 +268,113 @@ class ClientStore:
).fetchall()
return [dict(row) for row in rows]
def record_job_event(
self,
*,
job_id: str,
definition_json: str,
event_id: str,
event_json: str,
state: str,
revision: int,
sequence: int,
committed: bool,
) -> dict[str, object]:
definition = _canonical(json.loads(definition_json))
event = _canonical(json.loads(event_json))
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
existing_event = connection.execute(
"""
SELECT job_id, sequence, event_json
FROM events WHERE event_id = ?
""",
(event_id,),
).fetchone()
if existing_event:
if (
existing_event["job_id"] != job_id
or existing_event["sequence"] != sequence
or existing_event["event_json"] != event
):
raise JobConflict(
"job event ID was reused with different content"
)
return dict(existing_event)
existing_sequence = connection.execute(
"""
SELECT event_id, event_json FROM events
WHERE job_id = ? AND sequence = ?
""",
(job_id, sequence),
).fetchone()
if existing_sequence:
if existing_sequence["event_json"] != event:
raise JobConflict(
"job event sequence has conflicting content"
)
return dict(existing_sequence)
job = connection.execute(
"""
SELECT definition_json, revision, last_event_sequence,
committed
FROM jobs WHERE job_id = ?
""",
(job_id,),
).fetchone()
if job and job["definition_json"] != definition:
raise JobConflict("job definition is immutable")
if job and (
revision < job["revision"]
or sequence <= job["last_event_sequence"]
or (job["committed"] and not committed)
):
raise JobConflict("job event cursor cannot move backwards")
connection.execute(
"""
INSERT INTO jobs (
job_id, definition_json, state, revision,
last_event_sequence, committed
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(job_id) DO UPDATE SET
state = excluded.state,
revision = excluded.revision,
last_event_sequence = excluded.last_event_sequence,
committed = excluded.committed
""",
(
job_id, definition, state, revision, sequence,
int(committed),
),
)
connection.execute(
"""
INSERT INTO events (
event_id, job_id, sequence, event_json
) VALUES (?, ?, ?, ?)
""",
(event_id, job_id, sequence, event),
)
row = connection.execute(
"SELECT * FROM events WHERE event_id = ?", (event_id,)
).fetchone()
return dict(row)
def job_event_rows(
self, job_id: str, after_sequence: int = 0
) -> list[dict[str, object]]:
with self._connect() as connection:
rows = connection.execute(
"""
SELECT event_id, job_id, sequence, event_json
FROM events
WHERE job_id = ? AND sequence > ?
ORDER BY sequence
""",
(job_id, after_sequence),
).fetchall()
return [dict(row) for row in rows]
def begin_route_attempt(
self,
command_id: str,
+44
View File
@@ -387,6 +387,50 @@ def materialize_transfer(
return result_manifest
def cleanup_transfer(job_directory: Path) -> bool:
"""Remove only manifest-declared files and known job metadata."""
try:
published = load_published_transfer(job_directory)
except FileNotFoundError:
return False
paths = [
job_directory / _relative_path(entry.payload_relative_path)
for entry in published.manifest.files
]
paths.extend(
job_directory / _relative_path(artifact.payload_relative_path)
for artifact in published.manifest.artifacts
)
paths.extend((published.ready_path, published.manifest_path))
directories: set[Path] = set()
for path in paths:
try:
metadata = path.lstat()
except FileNotFoundError:
continue
if not stat.S_ISREG(metadata.st_mode):
raise TransferIntegrityError(
"job-owned cleanup path is not a regular file"
)
path.unlink()
parent = path.parent
while parent != job_directory.parent:
directories.add(parent)
if parent == job_directory:
break
parent = parent.parent
for directory in sorted(
directories, key=lambda item: len(item.parts), reverse=True
):
try:
directory.rmdir()
except OSError as exc:
if exc.errno not in {errno.ENOTEMPTY, errno.ENOENT}:
raise
return True
def canonical_message_json(message: object) -> bytes:
value = json_format.MessageToDict(
message,
+1 -1
View File
@@ -344,7 +344,7 @@ class DaemonTransportTests(unittest.IsolatedAsyncioTestCase):
observed["rejected"],
(
control_pb2.COMMAND_ACK_STATUS_REJECTED,
common_pb2.ERROR_CODE_UNSUPPORTED,
common_pb2.ERROR_CODE_INVALID_ARGUMENT,
),
)
self.assertEqual(
+255
View File
@@ -0,0 +1,255 @@
import hashlib
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from uuid import uuid4
from archive_clients.bencode import encode
from archive_clients.jobs import ClientJobExecutor, JobExecutionError
from archive_clients.resources import normalize_resource
from archive_clients.state import ClientStore
from archive_control.v1 import control_pb2, job_pb2
class CompleteSyncthing:
def __init__(self):
self.posts = []
def get_json(self, path):
if path.startswith("/rest/db/completion?"):
return {"completion": 100}
if path.startswith("/rest/db/need?"):
return {"progress": [], "queued": [], "rest": []}
raise AssertionError(path)
def post(self, path):
self.posts.append(path)
class ClientJobHappyPathTests(unittest.TestCase):
def test_archive_and_unarchive_five_step_execution(self):
for operation in (
job_pb2.JOB_OPERATION_ARCHIVE,
job_pb2.JOB_OPERATION_UNARCHIVE,
):
with self.subTest(operation=operation), tempfile.TemporaryDirectory() as directory:
self._run_transfer(Path(directory), operation)
def _run_transfer(self, root: Path, operation: int):
source_root = root / "source"
target_root = root / "target"
route_root = root / "route"
source_root.mkdir()
target_root.mkdir()
route_root.mkdir()
content = b"archive-control-happy-path"
(source_root / "fixture.bin").write_bytes(content)
info = {
b"length": len(content),
b"name": b"fixture.bin",
b"piece length": 16384,
b"pieces": hashlib.sha1(content).digest(),
}
metainfo = encode({b"info": info})
torrent_hash = hashlib.sha1(encode(info)).hexdigest()
resource = normalize_resource(
{
"hash": torrent_hash,
"name": "fixture.bin",
"state": "uploading",
},
[{
"index": 0,
"name": "fixture.bin",
"size": len(content),
"completed": len(content),
"priority": 1,
}],
metainfo,
)
source_id, target_id = (
("cache-1", "archive-1")
if operation == job_pb2.JOB_OPERATION_ARCHIVE
else ("archive-1", "cache-1")
)
definition = job_pb2.JobDefinition(
job_id=str(uuid4()),
idempotency_key=str(uuid4()),
operation=operation,
resource_display_name="fixture.bin",
transfer={
"source_client_id": source_id,
"target_client_id": target_id,
"route_id": "route-1",
"requested_logical_bytes": len(content),
"transfer_delta_logical_bytes": len(content),
},
)
definition.resource_id.info_hash_v1_hex = torrent_hash
definition.created_at.GetCurrentTime()
definition.transfer.requested_files.ranges.add(first=0, last=0)
definition.transfer.transfer_delta_files.ranges.add(first=0, last=0)
source_store = ClientStore(root / "source.db")
target_store = ClientStore(root / "target.db")
source_store.initialize()
target_store.initialize()
source_qb = Mock()
source_qb.get_resource.return_value = resource
target_qb = Mock()
target_qb.get_resource.side_effect = [None, resource]
syncthing = CompleteSyncthing()
source = ClientJobExecutor(
client_id=source_id,
qbittorrent=source_qb,
store=source_store,
qb_root=source_root,
qb_api_root=Path("/downloads"),
route_path=lambda _: route_root,
syncthing_transport=syncthing,
sparse_supported=True,
poll_interval=0,
)
target = ClientJobExecutor(
client_id=target_id,
qbittorrent=target_qb,
store=target_store,
qb_root=target_root,
qb_api_root=Path("/downloads"),
route_path=lambda _: route_root,
syncthing_transport=syncthing,
sparse_supported=True,
poll_interval=0,
)
source_assigned = source.assign(control_pb2.AssignJobCommand(
job=definition,
expected_job_revision=1,
expected_last_event_sequence=0,
))
target_assigned = target.assign(control_pb2.AssignJobCommand(
job=definition,
expected_job_revision=1,
expected_last_event_sequence=1,
))
self.assertEqual(source_assigned[0].sequence, 1)
self.assertEqual(target_assigned[0].sequence, 2)
cursor_revision = 1
cursor_sequence = 2
pipeline = (
(source, job_pb2.JOB_STEP_KIND_SOURCE_STAGE),
(target, job_pb2.JOB_STEP_KIND_SYNCTHING_TRANSFER),
(target, job_pb2.JOB_STEP_KIND_TARGET_MATERIALIZE),
(target, job_pb2.JOB_STEP_KIND_QB_VERIFY),
(source, job_pb2.JOB_STEP_KIND_STAGING_CLEANUP),
)
final = None
for executor, step in pipeline:
events = executor.execute(control_pb2.ExecuteStepCommand(
job_id=definition.job_id,
expected_job_revision=cursor_revision,
expected_last_event_sequence=cursor_sequence,
step=step,
attempt=1,
))
self.assertEqual(
[event.sequence for event in events],
[cursor_sequence + 1, cursor_sequence + 2],
)
cursor_sequence = events[-1].sequence
cursor_revision = events[-1].job_revision
final = events[-1]
self.assertEqual((target_root / "fixture.bin").read_bytes(), content)
target_qb.add_stopped_with_retry.assert_called_once_with(
metainfo, "/downloads", torrent_hash
)
target_qb.set_selection.assert_called_once_with(torrent_hash, [0], 1)
target_qb.recheck_and_wait.assert_called_once()
target_qb.start.assert_called_once_with(torrent_hash)
self.assertIsNotNone(final)
self.assertTrue(final.committed)
self.assertEqual(final.type, control_pb2.JOB_EVENT_TYPE_SUCCEEDED)
self.assertFalse(
(route_root / ".archive-control/jobs" / definition.job_id).exists()
)
replay = source.execute(control_pb2.ExecuteStepCommand(
job_id=definition.job_id,
expected_job_revision=cursor_revision - 2,
expected_last_event_sequence=cursor_sequence - 2,
step=job_pb2.JOB_STEP_KIND_STAGING_CLEANUP,
attempt=1,
))
self.assertEqual(replay[-1].event_id, final.event_id)
def test_step_failure_is_durable_and_reports_clear_reason(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
store = ClientStore(root / "client.db")
store.initialize()
definition = job_pb2.JobDefinition(
job_id=str(uuid4()),
idempotency_key=str(uuid4()),
operation=job_pb2.JOB_OPERATION_ARCHIVE,
resource_display_name="fixture",
transfer={
"source_client_id": "cache-1",
"target_client_id": "archive-1",
"route_id": "route-1",
},
)
definition.resource_id.info_hash_v1_hex = "a" * 40
definition.created_at.GetCurrentTime()
executor = ClientJobExecutor(
client_id="cache-1",
qbittorrent=Mock(),
store=store,
qb_root=root,
qb_api_root=Path("/downloads"),
route_path=lambda _: root,
syncthing_transport=Mock(),
sparse_supported=True,
poll_interval=0,
)
executor.assign(control_pb2.AssignJobCommand(
job=definition,
expected_job_revision=1,
expected_last_event_sequence=0,
))
command = control_pb2.ExecuteStepCommand(
job_id=definition.job_id,
expected_job_revision=1,
expected_last_event_sequence=1,
step=job_pb2.JOB_STEP_KIND_SOURCE_STAGE,
attempt=1,
)
with patch.object(
executor,
"_execute_step",
side_effect=JobExecutionError(
"partfile cannot be handled safely"
),
):
events = executor.execute(command)
self.assertEqual(
[event.type for event in events],
[
control_pb2.JOB_EVENT_TYPE_STEP_STARTED,
control_pb2.JOB_EVENT_TYPE_FAILED,
],
)
self.assertEqual(events[-1].state, job_pb2.JOB_STATE_FAILED)
self.assertIn("partfile", events[-1].error.message)
self.assertEqual(
executor.execute(command)[-1].event_id,
events[-1].event_id,
)
self.assertEqual(store.list_active_job_cursors(), [])
if __name__ == "__main__":
unittest.main()
+91
View File
@@ -108,6 +108,11 @@ class QBittorrentReaderTests(unittest.TestCase):
b"", # stop before recheck
b'{"total_downloaded":0}',
b"", # recheck
json.dumps([{
"hash": torrent_hash, "state": "stoppedDL",
}]).encode(),
b'{"total_downloaded":0}',
b'[{"index":0,"progress":0},{"index":1,"progress":0}]',
json.dumps([{
"hash": torrent_hash, "state": "checkingUP",
}]).encode(),
@@ -118,6 +123,7 @@ class QBittorrentReaderTests(unittest.TestCase):
}]).encode(),
b'{"total_downloaded":0}',
b'[{"index":0,"progress":1},{"index":1,"progress":0}]',
b"", # start after successful recheck
b"", # entry-only delete
]
with tempfile.TemporaryDirectory() as directory:
@@ -140,6 +146,7 @@ class QBittorrentReaderTests(unittest.TestCase):
result = adapter.recheck_and_wait(
torrent_hash, [0], timeout=1, poll_interval=0
)
adapter.start(torrent_hash)
adapter.delete_entry(torrent_hash)
self.assertEqual(result.final_state, "stoppedUP")
@@ -155,6 +162,7 @@ class QBittorrentReaderTests(unittest.TestCase):
for call in calls[2:]
if getattr(call, "data", None)
]
self.assertIn("id=0%7C1", form_bodies[0])
self.assertIn("priority=0", form_bodies[0])
self.assertIn("priority=1", form_bodies[1])
self.assertIn("deleteFiles=false", form_bodies[-1])
@@ -182,6 +190,89 @@ class QBittorrentReaderTests(unittest.TestCase):
self.assertTrue(opener.calls[-1].full_url.endswith("/torrents/pause"))
def test_start_falls_back_to_qbittorrent_4_resume_endpoint(self):
missing = error.HTTPError(
"http://qb/api/v2/torrents/start", 404, "not found", {}, None
)
responses = [b"Ok.", missing, b""]
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
password = root / "password"
password.write_text("secret", encoding="utf-8")
os.chmod(password, 0o600)
config = ServiceConfig(
"http://qb", PurePosixPath("/downloads"), root,
username="admin", password_file=password,
)
opener = _Opener(responses)
with patch(
"archive_clients.qbittorrent.request.build_opener",
return_value=opener,
):
QBittorrentReader(config).start("a" * 40)
self.assertTrue(opener.calls[-1].full_url.endswith("/torrents/resume"))
def test_wait_until_present_polls_until_added_torrent_is_visible(self):
torrent_hash = "a" * 40
responses = [
b"Ok.",
b"[]",
json.dumps([{"hash": torrent_hash}]).encode(),
]
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
password = root / "password"
password.write_text("secret", encoding="utf-8")
os.chmod(password, 0o600)
config = ServiceConfig(
"http://qb", PurePosixPath("/downloads"), root,
username="admin", password_file=password,
)
opener = _Opener(responses)
with patch(
"archive_clients.qbittorrent.request.build_opener",
return_value=opener,
):
QBittorrentReader(config).wait_until_present(
torrent_hash, timeout=1, poll_interval=0
)
self.assertEqual(len(opener.calls), 3)
def test_stopped_add_retries_while_recently_deleted_hash_is_busy(self):
torrent_hash = "a" * 40
responses = [
b"Ok.",
b"Fails.",
b"[]",
b"Ok.",
json.dumps([{"hash": torrent_hash}]).encode(),
]
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
password = root / "password"
password.write_text("secret", encoding="utf-8")
os.chmod(password, 0o600)
config = ServiceConfig(
"http://qb", PurePosixPath("/downloads"), root,
username="admin", password_file=password,
)
opener = _Opener(responses)
with patch(
"archive_clients.qbittorrent.request.build_opener",
return_value=opener,
):
QBittorrentReader(config).add_stopped_with_retry(
b"torrent",
"/downloads",
torrent_hash,
max_attempts=2,
initial_delay=0,
)
self.assertEqual(len(opener.calls), 5)
if __name__ == "__main__":
unittest.main()