feat: complete transfer and eviction execution

This commit is contained in:
2026-07-23 09:14:40 +00:00
parent 884aed9921
commit b6de490b7e
17 changed files with 1445 additions and 81 deletions
+6 -5
View File
@@ -27,11 +27,12 @@ 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.
cache-1 → archive-1 transfer, covered cache-1 eviction, and
archive-1 → cache-2 unarchive. The scenario creates a deterministic one-file
torrent in the isolated cache qBittorrent, uses fresh preview revisions for all
jobs, waits for the durable five/three/five-step flows, and verifies target
qBittorrent selections, archive retention, safe cache removal, 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
+12 -26
View File
@@ -9,8 +9,6 @@ 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"
@@ -103,30 +101,18 @@ def main() -> int:
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)
preview = post_json(f"{CONTROL}/jobs/preview", {
"operation": args.operation,
"source_client_id": args.source_client,
"target_client_id": args.target_client,
"resource_id": {"info_hash_v1_hex": INFO_HASH},
})
definition = preview["definition"]
job_id = definition["job_id"]
created = post_json(f"{CONTROL}/jobs", {
"preview_revision": preview["preview_revision"],
"definition": definition,
})
if created["job_id"] != job_id:
raise RuntimeError("control returned the wrong job")
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Drive a covered cache eviction through the bot-free HTTP adapter."""
from __future__ import annotations
import json
import sys
import time
import urllib.request
CONTROL = "http://127.0.0.1:18081/test/v1"
INFO_HASH = "330be0cb7c2201135a2de63b28e77993745ff688"
def get_json(url: str):
with urllib.request.urlopen(url, timeout=35) as response:
return json.load(response)
def post_json(url: str, value: object):
request = urllib.request.Request(
url,
data=json.dumps(value, separators=(",", ":")).encode(),
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=35) as response:
return json.load(response)
def main() -> int:
preview = post_json(f"{CONTROL}/jobs/preview", {
"operation": "evict_cache",
"cache_client_id": "cache-1",
"resource_id": {"info_hash_v1_hex": INFO_HASH},
})
definition = preview["definition"]
job_id = definition["job_id"]
post_json(f"{CONTROL}/jobs", {
"preview_revision": preview["preview_revision"],
"definition": definition,
})
deadline = time.monotonic() + 180
while time.monotonic() < deadline:
job = get_json(f"{CONTROL}/jobs/{job_id}")
if job["state"] == "JOB_STATE_SUCCEEDED" and job["committed"]:
print(json.dumps({
"job_id": job_id,
"state": job["state"],
"committed": job["committed"],
}, sort_keys=True))
return 0
if job["state"] in {"JOB_STATE_FAILED", "JOB_STATE_CANCELLED"}:
raise RuntimeError(f"eviction did not succeed: {job}")
time.sleep(0.5)
raise RuntimeError("timed out waiting for cache eviction")
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"eviction happy-path failed: {exc}", file=sys.stderr)
raise
+10
View File
@@ -63,6 +63,16 @@ if [[ "$target_digest" != \
fi
printf 'archive filesystem content verified: sha256=%s\n' "$target_digest"
compose_control exec -T control \
python /e2e/scenarios/evict_happy.py
wait_qb_absent cache-1
if compose_node cache-1 exec -T qbittorrent \
test -e /downloads/fixture.bin; then
printf 'eviction left the unshared cache file behind\n' >&2
exit 1
fi
printf 'covered cache eviction verified\n'
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" \