feat: execute durable archive transfers
This commit is contained in:
@@ -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
|
deadline. `down.sh` removes only the five exact Compose projects and the
|
||||||
labelled E2E network; it intentionally retains all bind-mounted runtime state.
|
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
|
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
|
digest-pinned. Runtime secrets are generated with mode 0600 and ignored by
|
||||||
Git. The qBittorrent test config limits its authentication bypass to the
|
Git. The qBittorrent test config limits its authentication bypass to the
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ services:
|
|||||||
user: "1001:1001"
|
user: "1001:1001"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/config.json:/etc/archive-control/config.json:ro
|
- ./config/config.json:/etc/archive-control/config.json:ro
|
||||||
|
- ../scenarios:/e2e/scenarios:ro
|
||||||
- ./secrets:/run/secrets:ro
|
- ./secrets:/run/secrets:ro
|
||||||
- ./runtime/state:/var/lib/archive-control
|
- ./runtime/state:/var/lib/archive-control
|
||||||
- ./runtime/backups:/var/backups/archive-control
|
- ./runtime/backups:/var/backups/archive-control
|
||||||
@@ -26,4 +27,3 @@ networks:
|
|||||||
archive-control-e2e:
|
archive-control-e2e:
|
||||||
external: true
|
external: true
|
||||||
name: archive-control-e2e
|
name: archive-control-e2e
|
||||||
|
|
||||||
|
|||||||
Executable
+168
@@ -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
|
||||||
Executable
+90
@@ -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"
|
||||||
@@ -21,3 +21,6 @@ for node in "${E2E_NODES[@]}"; do
|
|||||||
compose_node "$node" up -d --build
|
compose_node "$node" up -d --build
|
||||||
done
|
done
|
||||||
"$E2E_ROOT/scripts/wait-routes.sh"
|
"$E2E_ROOT/scripts/wait-routes.sh"
|
||||||
|
if [[ "${E2E_RUN_TRANSFER:-0}" == "1" ]]; then
|
||||||
|
"$E2E_ROOT/scripts/archive-happy.sh"
|
||||||
|
fi
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import logging
|
|||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from websockets.asyncio.client import connect
|
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.backup import SQLiteBackupManager
|
||||||
from archive_clients.config import ClientConfig
|
from archive_clients.config import ClientConfig
|
||||||
from archive_clients.inventory import InventoryService
|
from archive_clients.inventory import InventoryService
|
||||||
|
from archive_clients.jobs import ClientJobExecutor, JobExecutionError
|
||||||
from archive_clients.locking import DatabaseLease
|
from archive_clients.locking import DatabaseLease
|
||||||
from archive_clients.probes import FilesystemProbe
|
from archive_clients.probes import FilesystemProbe
|
||||||
from archive_clients.protocol import (
|
from archive_clients.protocol import (
|
||||||
@@ -73,12 +75,40 @@ class ArchiveClientDaemon:
|
|||||||
if healthy_syncthing and probes[1].writable
|
if healthy_syncthing and probes[1].writable
|
||||||
else None
|
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.store = ClientStore(config.state_db)
|
||||||
self.backups = SQLiteBackupManager(
|
self.backups = SQLiteBackupManager(
|
||||||
config.state_db, config.backup_dir, config.backup
|
config.state_db, config.backup_dir, config.backup
|
||||||
)
|
)
|
||||||
self._lease = DatabaseLease(config.state_db)
|
self._lease = DatabaseLease(config.state_db)
|
||||||
self._active_route_commands: set[str] = set()
|
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:
|
async def run(self) -> None:
|
||||||
await asyncio.to_thread(self._lease.acquire)
|
await asyncio.to_thread(self._lease.acquire)
|
||||||
@@ -169,7 +199,7 @@ class ArchiveClientDaemon:
|
|||||||
writer = asyncio.create_task(self._writer(websocket, outbound))
|
writer = asyncio.create_task(self._writer(websocket, outbound))
|
||||||
command_tasks: set[asyncio.Task[None]] = set()
|
command_tasks: set[asyncio.Task[None]] = set()
|
||||||
try:
|
try:
|
||||||
await self._resume_route_commands(outbound, command_tasks)
|
await self._resume_commands(outbound, command_tasks)
|
||||||
async for frame in websocket:
|
async for frame in websocket:
|
||||||
await self._handle(decode(frame), outbound, command_tasks)
|
await self._handle(decode(frame), outbound, command_tasks)
|
||||||
finally:
|
finally:
|
||||||
@@ -382,12 +412,25 @@ class ArchiveClientDaemon:
|
|||||||
outbound,
|
outbound,
|
||||||
command_tasks,
|
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,
|
self,
|
||||||
outbound: asyncio.Queue[str],
|
outbound: asyncio.Queue[str],
|
||||||
command_tasks: set[asyncio.Task[None]],
|
command_tasks: set[asyncio.Task[None]],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
job_commands: list[control_pb2.Command] = []
|
||||||
for row in await asyncio.to_thread(self.store.list_accepted_commands):
|
for row in await asyncio.to_thread(self.store.list_accepted_commands):
|
||||||
acknowledgement = decode_message(
|
acknowledgement = decode_message(
|
||||||
str(row["acknowledgement_json"]), control_pb2.CommandAck()
|
str(row["acknowledgement_json"]), control_pb2.CommandAck()
|
||||||
@@ -401,6 +444,120 @@ class ArchiveClientDaemon:
|
|||||||
self._schedule_route_command(
|
self._schedule_route_command(
|
||||||
command, "", outbound, command_tasks
|
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(
|
def _schedule_route_command(
|
||||||
self,
|
self,
|
||||||
@@ -495,6 +652,7 @@ class ArchiveClientDaemon:
|
|||||||
configured = await asyncio.to_thread(
|
configured = await asyncio.to_thread(
|
||||||
self.routes.configure, spec, deadline
|
self.routes.configure, spec, deadline
|
||||||
)
|
)
|
||||||
|
self._known_route_paths[spec.route_id] = configured.local_path
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
self.store.record_route_ownership,
|
self.store.record_route_ownership,
|
||||||
command.command_id,
|
command.command_id,
|
||||||
@@ -658,7 +816,10 @@ class ArchiveClientDaemon:
|
|||||||
if error is not None:
|
if error is not None:
|
||||||
logger.error(
|
logger.error(
|
||||||
"background_command_failed",
|
"background_command_failed",
|
||||||
extra={"error_type": type(error).__name__},
|
extra={
|
||||||
|
"error_type": type(error).__name__,
|
||||||
|
"error_detail": str(error),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def _initial_acknowledgement(
|
def _initial_acknowledgement(
|
||||||
@@ -717,6 +878,46 @@ class ArchiveClientDaemon:
|
|||||||
acknowledgement.error.message = "ensure route specification is invalid"
|
acknowledgement.error.message = "ensure route specification is invalid"
|
||||||
else:
|
else:
|
||||||
acknowledgement.status = control_pb2.COMMAND_ACK_STATUS_ACCEPTED
|
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:
|
else:
|
||||||
acknowledgement.status = control_pb2.COMMAND_ACK_STATUS_REJECTED
|
acknowledgement.status = control_pb2.COMMAND_ACK_STATUS_REJECTED
|
||||||
acknowledgement.error.code = common_pb2.ERROR_CODE_UNSUPPORTED
|
acknowledgement.error.code = common_pb2.ERROR_CODE_UNSUPPORTED
|
||||||
@@ -725,6 +926,12 @@ class ArchiveClientDaemon:
|
|||||||
)
|
)
|
||||||
return acknowledgement
|
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]:
|
def _route_error(exc: Exception) -> tuple[int, bool]:
|
||||||
if isinstance(exc, RoutePathConflict):
|
if isinstance(exc, RoutePathConflict):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -126,6 +126,37 @@ class QBittorrentReader:
|
|||||||
)
|
)
|
||||||
_require_mutation_success(response, "add torrent")
|
_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(
|
def set_selection(
|
||||||
self,
|
self,
|
||||||
torrent_hash: str,
|
torrent_hash: str,
|
||||||
@@ -141,7 +172,9 @@ class QBittorrentReader:
|
|||||||
"/api/v2/torrents/filePrio",
|
"/api/v2/torrents/filePrio",
|
||||||
{
|
{
|
||||||
"hash": torrent_hash,
|
"hash": torrent_hash,
|
||||||
"id": f"0-{total_file_count - 1}",
|
"id": "|".join(
|
||||||
|
str(index) for index in range(total_file_count)
|
||||||
|
),
|
||||||
"priority": "0",
|
"priority": "0",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -166,6 +199,53 @@ class QBittorrentReader:
|
|||||||
"/api/v2/torrents/pause", {"hashes": torrent_hash}
|
"/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:
|
def delete_entry(self, torrent_hash: str) -> None:
|
||||||
self._post_form(
|
self._post_form(
|
||||||
"/api/v2/torrents/delete",
|
"/api/v2/torrents/delete",
|
||||||
@@ -430,7 +510,7 @@ class QBittorrentReader:
|
|||||||
|
|
||||||
|
|
||||||
def _is_download_state(state: str) -> bool:
|
def _is_download_state(state: str) -> bool:
|
||||||
return state not in {"checkingDL"} and (
|
return state not in {"checkingDL", "stoppedDL", "pausedDL"} and (
|
||||||
state.endswith("DL")
|
state.endswith("DL")
|
||||||
or state in {"downloading", "metaDL", "forcedMetaDL"}
|
or state in {"downloading", "metaDL", "forcedMetaDL"}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class NormalizedResource:
|
|||||||
summary: resource_pb2.ResourceSummary
|
summary: resource_pb2.ResourceSummary
|
||||||
files: tuple[resource_pb2.TorrentFile, ...]
|
files: tuple[resource_pb2.TorrentFile, ...]
|
||||||
metainfo: Metainfo
|
metainfo: Metainfo
|
||||||
|
metainfo_bytes: bytes = b""
|
||||||
|
|
||||||
|
|
||||||
def build_content_tree(
|
def build_content_tree(
|
||||||
@@ -162,7 +163,7 @@ def normalize_resource(
|
|||||||
revision_data, sort_keys=True, separators=(",", ":"),
|
revision_data, sort_keys=True, separators=(",", ":"),
|
||||||
).encode("utf-8")).hexdigest()
|
).encode("utf-8")).hexdigest()
|
||||||
summary.observed_at.FromDatetime(observed_at)
|
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:
|
def _set_selection(target: Any, indices: list[int]) -> None:
|
||||||
|
|||||||
@@ -268,6 +268,113 @@ class ClientStore:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(row) for row in rows]
|
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(
|
def begin_route_attempt(
|
||||||
self,
|
self,
|
||||||
command_id: str,
|
command_id: str,
|
||||||
|
|||||||
@@ -387,6 +387,50 @@ def materialize_transfer(
|
|||||||
return result_manifest
|
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:
|
def canonical_message_json(message: object) -> bytes:
|
||||||
value = json_format.MessageToDict(
|
value = json_format.MessageToDict(
|
||||||
message,
|
message,
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ class DaemonTransportTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
observed["rejected"],
|
observed["rejected"],
|
||||||
(
|
(
|
||||||
control_pb2.COMMAND_ACK_STATUS_REJECTED,
|
control_pb2.COMMAND_ACK_STATUS_REJECTED,
|
||||||
common_pb2.ERROR_CODE_UNSUPPORTED,
|
common_pb2.ERROR_CODE_INVALID_ARGUMENT,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -108,6 +108,11 @@ class QBittorrentReaderTests(unittest.TestCase):
|
|||||||
b"", # stop before recheck
|
b"", # stop before recheck
|
||||||
b'{"total_downloaded":0}',
|
b'{"total_downloaded":0}',
|
||||||
b"", # recheck
|
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([{
|
json.dumps([{
|
||||||
"hash": torrent_hash, "state": "checkingUP",
|
"hash": torrent_hash, "state": "checkingUP",
|
||||||
}]).encode(),
|
}]).encode(),
|
||||||
@@ -118,6 +123,7 @@ class QBittorrentReaderTests(unittest.TestCase):
|
|||||||
}]).encode(),
|
}]).encode(),
|
||||||
b'{"total_downloaded":0}',
|
b'{"total_downloaded":0}',
|
||||||
b'[{"index":0,"progress":1},{"index":1,"progress":0}]',
|
b'[{"index":0,"progress":1},{"index":1,"progress":0}]',
|
||||||
|
b"", # start after successful recheck
|
||||||
b"", # entry-only delete
|
b"", # entry-only delete
|
||||||
]
|
]
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
@@ -140,6 +146,7 @@ class QBittorrentReaderTests(unittest.TestCase):
|
|||||||
result = adapter.recheck_and_wait(
|
result = adapter.recheck_and_wait(
|
||||||
torrent_hash, [0], timeout=1, poll_interval=0
|
torrent_hash, [0], timeout=1, poll_interval=0
|
||||||
)
|
)
|
||||||
|
adapter.start(torrent_hash)
|
||||||
adapter.delete_entry(torrent_hash)
|
adapter.delete_entry(torrent_hash)
|
||||||
|
|
||||||
self.assertEqual(result.final_state, "stoppedUP")
|
self.assertEqual(result.final_state, "stoppedUP")
|
||||||
@@ -155,6 +162,7 @@ class QBittorrentReaderTests(unittest.TestCase):
|
|||||||
for call in calls[2:]
|
for call in calls[2:]
|
||||||
if getattr(call, "data", None)
|
if getattr(call, "data", None)
|
||||||
]
|
]
|
||||||
|
self.assertIn("id=0%7C1", form_bodies[0])
|
||||||
self.assertIn("priority=0", form_bodies[0])
|
self.assertIn("priority=0", form_bodies[0])
|
||||||
self.assertIn("priority=1", form_bodies[1])
|
self.assertIn("priority=1", form_bodies[1])
|
||||||
self.assertIn("deleteFiles=false", 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"))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user