Separate game file downloads and verification with progress timing
This commit is contained in:
+266
-6
@@ -277,13 +277,60 @@ class AgentTests(unittest.TestCase):
|
||||
for path, content in contents.items():
|
||||
self.assertEqual((self.agent.library_dir("t6", "downloaded") / "content" / path).read_bytes(), content)
|
||||
|
||||
def test_fresh_download_hashes_stream_without_rereading_whole_file(self):
|
||||
content = b"game-file" * 200000
|
||||
manifest = self.manifest(content)
|
||||
self.agent.artifact_open = lambda method, path, **kwargs: Response(json.dumps(manifest).encode() if path == "/manifest" else content)
|
||||
with patch.object(module, "file_digest", side_effect=AssertionError("download reread the file")):
|
||||
def test_all_downloads_finish_before_any_file_is_hashed_and_verified_from_disk(self):
|
||||
contents = {"zone/first.ff": b"game-file" * 200000, "zone/second.ff": b"second-file"}
|
||||
manifest = self.manifest_files(contents)
|
||||
self.agent.artifact_open = lambda method, path, **kwargs: Response(json.dumps(manifest).encode() if path == "/manifest" else contents[path.removeprefix("/files/")])
|
||||
original_hash, original_open = module.hashlib.sha256, Path.open
|
||||
reads = []
|
||||
|
||||
def hashing(*args, **kwargs):
|
||||
progress = self.agent.library_progress.get("downloaded")
|
||||
if progress:
|
||||
self.assertEqual(progress["status"], "verifying")
|
||||
self.assertEqual(progress["bytesDownloaded"], manifest["sizeBytes"])
|
||||
return original_hash(*args, **kwargs)
|
||||
|
||||
def opening(path, mode="r", *args, **kwargs):
|
||||
if mode == "rb" and path.name.endswith(".ff"):
|
||||
self.assertFalse(self.agent.library_dir("t6", "downloaded").exists())
|
||||
reads.append(path.name)
|
||||
return original_open(path, mode, *args, **kwargs)
|
||||
|
||||
with patch.object(module.hashlib, "sha256", side_effect=hashing), patch.object(Path, "open", opening):
|
||||
result = self.agent.library_download({"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files"})
|
||||
self.assertEqual(result["state"], "ready")
|
||||
self.assertCountEqual(reads, ["first.ff", "second.ff"])
|
||||
cache = next(item for item in self.agent.inventory()["libraryCaches"] if item["libraryVersionId"] == "downloaded")
|
||||
self.assertEqual(cache["bytesVerified"], manifest["sizeBytes"])
|
||||
self.assertEqual(cache["transfer"]["phase"], "verifying")
|
||||
|
||||
def test_phase_boundaries_are_reported_even_between_heartbeats_and_status_errors_are_nonfatal(self):
|
||||
content = b"game-file"
|
||||
manifest = self.manifest(content)
|
||||
reports = []
|
||||
|
||||
def fetch(method, path, data=None, extra_headers=None, timeout=60):
|
||||
if path == "/status":
|
||||
self.assertEqual(timeout, 5)
|
||||
reports.append(json.loads(data))
|
||||
raise AgentError("Controller temporarily unavailable")
|
||||
self.assertEqual(timeout, 60)
|
||||
return Response(json.dumps(manifest).encode() if path == "/manifest" else content)
|
||||
|
||||
self.agent.artifact_open = fetch
|
||||
result = self.agent.library_download({"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files", "statusUrl": "/status"})
|
||||
self.assertEqual(result["state"], "ready")
|
||||
self.assertEqual([item["status"] for item in reports], ["downloading", "verifying", "ready"])
|
||||
self.assertEqual([item["bytesVerified"] for item in reports], [0, 0, len(content)])
|
||||
self.assertEqual(reports[1]["bytesDownloaded"], len(content))
|
||||
|
||||
def test_status_socket_timeout_is_short_without_reducing_download_timeout(self):
|
||||
with patch.object(module.urllib.request, "build_opener") as build_opener:
|
||||
self.agent.artifact_open("GET", "/file")
|
||||
self.assertEqual(build_opener.return_value.open.call_args.kwargs["timeout"], 60)
|
||||
self.agent.artifact_open("POST", "/status", b"{}", timeout=5)
|
||||
self.assertEqual(build_opener.return_value.open.call_args.kwargs["timeout"], 5)
|
||||
|
||||
def test_ignored_range_restarts_file_and_checksum(self):
|
||||
content = b"complete-game-file"
|
||||
@@ -313,6 +360,80 @@ class AgentTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(AgentError, "integrity"):
|
||||
self.agent.library_download({"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files"})
|
||||
self.assertFalse(self.agent.library_dir("t6", "downloaded").exists())
|
||||
self.assertFalse((staging / "test.ff").exists())
|
||||
self.assertEqual(self.agent.library_progress["downloaded"]["bytesDownloaded"], 0)
|
||||
requests = []
|
||||
|
||||
def fetch(method, path, data=None, extra_headers=None):
|
||||
if path != "/manifest":
|
||||
requests.append(extra_headers)
|
||||
return Response(json.dumps(manifest).encode() if path == "/manifest" else content)
|
||||
|
||||
self.agent.artifact_open = fetch
|
||||
self.assertEqual(self.agent.library_download({"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files"})["state"], "ready")
|
||||
self.assertEqual(requests, [{}])
|
||||
|
||||
def test_all_corrupt_complete_files_are_removed_so_retry_downloads_them_again(self):
|
||||
contents = {"zone/first.ff": b"first", "zone/second.ff": b"second", "zone/good.ff": b"good"}
|
||||
manifest = self.manifest_files(contents)
|
||||
staging = self.agent.root / "libraries/t6/.downloaded.partial/content/zone"
|
||||
staging.mkdir(parents=True)
|
||||
for name, content in contents.items():
|
||||
(staging / Path(name).name).write_bytes(content if name.endswith("good.ff") else b"x" * len(content))
|
||||
requests = []
|
||||
|
||||
def fetch(method, path, data=None, extra_headers=None):
|
||||
requests.append(path)
|
||||
return Response(json.dumps(manifest).encode() if path == "/manifest" else contents[path.removeprefix("/files/")])
|
||||
|
||||
self.agent.artifact_open = fetch
|
||||
payload = {"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files"}
|
||||
with self.assertRaisesRegex(AgentError, "integrity"):
|
||||
self.agent.library_download(payload)
|
||||
self.assertEqual(requests, ["/manifest"])
|
||||
self.assertEqual([path.name for path in staging.iterdir()], ["good.ff"])
|
||||
self.assertEqual(self.agent.library_download(payload)["state"], "ready")
|
||||
self.assertCountEqual(requests, ["/manifest", "/manifest", "/files/zone/first.ff", "/files/zone/second.ff"])
|
||||
|
||||
def test_verification_keeps_download_phase_complete_until_failure_reports_retained_bytes(self):
|
||||
contents = {"zone/first.ff": b"first", "zone/second.ff": b"second"}
|
||||
for mode in ("staged-corrupt", "existing-missing", "existing-short"):
|
||||
with self.subTest(mode=mode):
|
||||
manifest = {**self.manifest_files(contents), "id": mode}
|
||||
directory = self.agent.library_dir("t6", mode)
|
||||
if mode.startswith("existing"):
|
||||
(directory / "content/zone").mkdir(parents=True)
|
||||
(directory / "content/zone/second.ff").write_bytes(contents["zone/second.ff"])
|
||||
if mode == "existing-short":
|
||||
(directory / "content/zone/first.ff").write_bytes(b"f")
|
||||
module.atomic_json(directory / "manifest.json", {"libraryId": mode, "game": "t6", "size": manifest["sizeBytes"], "files": manifest["files"], "manifestSha256": manifest["manifestSha256"]})
|
||||
|
||||
def fetch(method, path, **kwargs):
|
||||
if path == "/manifest":
|
||||
return Response(json.dumps(manifest).encode())
|
||||
content = contents[path.removeprefix("/files/")]
|
||||
return Response(b"x" * len(content) if path.endswith("first.ff") else content)
|
||||
|
||||
self.agent.artifact_open = fetch
|
||||
original_open = Path.open
|
||||
snapshots = []
|
||||
|
||||
def opening(path, open_mode="r", *args, **kwargs):
|
||||
open_mode = kwargs.pop("mode", open_mode)
|
||||
if open_mode == "rb" and path.name == "second.ff":
|
||||
cache = next(item for item in self.agent.inventory()["libraryCaches"] if item["libraryVersionId"] == mode)
|
||||
snapshots.append(cache)
|
||||
self.assertEqual(cache["status"], "verifying")
|
||||
self.assertEqual(cache["bytesDownloaded"], cache["bytesTotal"])
|
||||
self.assertEqual(cache["transfer"]["phase"], "verifying")
|
||||
return original_open(path, open_mode, *args, **kwargs)
|
||||
|
||||
with patch.object(Path, "open", opening), self.assertRaisesRegex(AgentError, "integrity"):
|
||||
self.agent.library_download({"libraryVersionId": mode, "manifestUrl": "/manifest", "filesBaseUrl": "/files"})
|
||||
self.assertEqual(len(snapshots), 1)
|
||||
failed = next(item for item in self.agent.inventory()["libraryCaches"] if item["libraryVersionId"] == mode)
|
||||
self.assertEqual(failed["status"], "failed")
|
||||
self.assertEqual(failed["bytesDownloaded"], len(contents["zone/second.ff"]) + (1 if mode == "existing-short" else 0))
|
||||
|
||||
def test_parallel_failure_retains_partial_files_for_verified_resume(self):
|
||||
contents = {"zone/first.ff": b"first-game-file", "zone/second.ff": b"second-game-file"}
|
||||
@@ -369,7 +490,13 @@ class AgentTests(unittest.TestCase):
|
||||
self.agent.journal.close()
|
||||
self.agent = Agent(self.config, self.docker)
|
||||
cache = next(item for item in self.agent.inventory()["libraryCaches"] if item["libraryVersionId"] == "downloaded")
|
||||
self.assertEqual(cache, {"libraryVersionId": "downloaded", "status": "missing", "bytesDownloaded": 8, "bytesTotal": len(content), "manifestSha256": manifest["manifestSha256"]})
|
||||
self.assertEqual(cache["status"], "failed")
|
||||
self.assertEqual(cache["bytesDownloaded"], 8)
|
||||
self.assertEqual(cache["bytesVerified"], 0)
|
||||
self.assertEqual(cache["bytesTotal"], len(content))
|
||||
self.assertEqual(cache["manifestSha256"], manifest["manifestSha256"])
|
||||
self.assertEqual(cache["transfer"]["phase"], "downloading")
|
||||
self.assertIsNone(cache["transfer"]["etaSeconds"])
|
||||
|
||||
def fetch(method, path, data=None, extra_headers=None):
|
||||
if path == "/manifest":
|
||||
@@ -405,6 +532,139 @@ class AgentTests(unittest.TestCase):
|
||||
self.assertEqual(caches[0]["status"], "downloading")
|
||||
self.assertEqual(caches[0]["bytesDownloaded"], 3)
|
||||
|
||||
def test_interrupted_verification_restarts_from_disk_without_redownloading_and_keeps_elapsed(self):
|
||||
content = b"complete-game-file"
|
||||
manifest = self.manifest(content)
|
||||
payload = {"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files"}
|
||||
self.agent.artifact_open = lambda method, path, **kwargs: Response(json.dumps(manifest).encode() if path == "/manifest" else content)
|
||||
|
||||
def interrupted(files, targets, transfer, repair=False):
|
||||
transfer.advance("bytesVerified", 5, 5)
|
||||
transfer.persist(force=True)
|
||||
raise KeyboardInterrupt("Simulated process termination")
|
||||
|
||||
with patch.object(self.agent, "verify_downloaded_files", side_effect=interrupted), self.assertRaises(KeyboardInterrupt):
|
||||
self.agent.library_download(payload)
|
||||
previous = self.agent.library_transfers["downloaded"].snapshot()
|
||||
self.assertFalse(self.agent.library_dir("t6", "downloaded").exists())
|
||||
self.agent.journal.close()
|
||||
self.agent = Agent(self.config, self.docker)
|
||||
interrupted_cache = next(item for item in self.agent.inventory()["libraryCaches"] if item["libraryVersionId"] == "downloaded")
|
||||
self.assertEqual(interrupted_cache["status"], "missing")
|
||||
self.assertEqual(interrupted_cache["bytesDownloaded"], len(content))
|
||||
self.assertEqual(interrupted_cache["bytesVerified"], 5)
|
||||
requests = []
|
||||
|
||||
def fetch(method, path, **kwargs):
|
||||
requests.append(path)
|
||||
return Response(json.dumps(manifest).encode())
|
||||
|
||||
original_verify = self.agent.verify_downloaded_files
|
||||
|
||||
def verifying(files, targets, transfer, repair=False):
|
||||
self.assertEqual(transfer.snapshot()["bytesVerified"], 0)
|
||||
self.assertIsNone(transfer.snapshot()["transfer"]["etaSeconds"])
|
||||
return original_verify(files, targets, transfer, repair)
|
||||
|
||||
self.agent.artifact_open = fetch
|
||||
with patch.object(self.agent, "verify_downloaded_files", side_effect=verifying):
|
||||
self.assertEqual(self.agent.library_download(payload)["state"], "ready")
|
||||
self.assertEqual(requests, ["/manifest"])
|
||||
cache = next(item for item in self.agent.inventory()["libraryCaches"] if item["libraryVersionId"] == "downloaded")
|
||||
self.assertEqual(cache["bytesVerified"], len(content))
|
||||
self.assertEqual(cache["transfer"]["startedAt"], previous["transfer"]["startedAt"])
|
||||
self.assertGreaterEqual(cache["transfer"]["elapsedSeconds"], interrupted_cache["transfer"]["elapsedSeconds"])
|
||||
self.assertGreaterEqual(cache["transfer"]["phaseElapsedSeconds"], interrupted_cache["transfer"]["phaseElapsedSeconds"])
|
||||
self.agent.journal.close()
|
||||
self.agent = Agent(self.config, self.docker)
|
||||
self.assertEqual(next(item for item in self.agent.inventory()["libraryCaches"] if item["libraryVersionId"] == "downloaded"), cache)
|
||||
|
||||
def test_crash_before_atomic_promotion_reverifies_read_only_staging_without_network_transfers(self):
|
||||
content = b"complete-game-file"
|
||||
manifest = self.manifest(content)
|
||||
payload = {"libraryVersionId": "downloaded", "manifestUrl": "/manifest", "filesBaseUrl": "/files"}
|
||||
self.agent.artifact_open = lambda method, path, **kwargs: Response(json.dumps(manifest).encode() if path == "/manifest" else content)
|
||||
original_replace = module.os.replace
|
||||
|
||||
def replace(source, destination):
|
||||
if Path(destination) == self.agent.library_dir("t6", "downloaded"):
|
||||
raise KeyboardInterrupt("Simulated process termination before promotion")
|
||||
return original_replace(source, destination)
|
||||
|
||||
with patch.object(module.os, "replace", side_effect=replace), self.assertRaises(KeyboardInterrupt):
|
||||
self.agent.library_download(payload)
|
||||
staging = self.agent.root / "libraries/t6/.downloaded.partial/content"
|
||||
self.assertEqual(staging.stat().st_mode & 0o777, 0o555)
|
||||
self.agent.journal.close()
|
||||
self.agent = Agent(self.config, self.docker)
|
||||
requests = []
|
||||
|
||||
def fetch(method, path, **kwargs):
|
||||
requests.append(path)
|
||||
return Response(json.dumps(manifest).encode())
|
||||
|
||||
self.agent.artifact_open = fetch
|
||||
self.assertEqual(self.agent.library_download(payload)["state"], "ready")
|
||||
self.assertEqual(requests, ["/manifest"])
|
||||
self.assertEqual((self.agent.library_dir("t6", "downloaded") / "content/zone/test.ff").read_bytes(), content)
|
||||
|
||||
def test_existing_cache_reverification_is_visible_and_failure_revokes_readiness_after_restart(self):
|
||||
saved = json.loads((self.library / "manifest.json").read_text())
|
||||
manifest = {**saved, "id": self.library_id}
|
||||
self.agent.artifact_open = lambda *args, **kwargs: Response(json.dumps(manifest).encode())
|
||||
original_verify = self.agent.verify_downloaded_files
|
||||
|
||||
def verifying(files, targets, transfer, repair=False):
|
||||
inventory = self.agent.inventory()
|
||||
cache = next(item for item in inventory["libraryCaches"] if item["libraryVersionId"] == self.library_id)
|
||||
self.assertEqual(cache["status"], "verifying")
|
||||
self.assertEqual(cache["bytesVerified"], 0)
|
||||
self.assertNotIn({"id": self.library_id, "state": "ready"}, inventory["libraries"])
|
||||
return original_verify(files, targets, transfer, repair)
|
||||
|
||||
(self.library / "content/game.ff").write_bytes(b"corrupt!!")
|
||||
with patch.object(self.agent, "verify_downloaded_files", side_effect=verifying), self.assertRaisesRegex(AgentError, "integrity"):
|
||||
self.agent.library_download({"libraryVersionId": self.library_id, "manifestUrl": "/manifest", "filesBaseUrl": "/files"})
|
||||
self.agent.journal.close()
|
||||
self.agent = Agent(self.config, self.docker)
|
||||
inventory = self.agent.inventory()
|
||||
self.assertNotIn({"id": self.library_id, "state": "ready"}, inventory["libraries"])
|
||||
self.assertEqual(next(item for item in inventory["libraryCaches"] if item["libraryVersionId"] == self.library_id)["status"], "failed")
|
||||
|
||||
def test_elapsed_and_phase_eta_use_new_work_only_and_freeze_at_completion_or_failure(self):
|
||||
for terminal_status in ("ready", "failed"):
|
||||
with self.subTest(terminal_status=terminal_status):
|
||||
now = [100.0]
|
||||
progress = {"libraryVersionId": "downloaded", "status": "downloading", "bytesDownloaded": 80, "bytesVerified": 0, "bytesTotal": 100}
|
||||
previous = {"transfer": {"phase": "downloading", "startedAt": "2026-10-04T00:00:00Z", "elapsedSeconds": 20, "phaseElapsedSeconds": 20}}
|
||||
with patch.object(module.time, "monotonic", side_effect=lambda: now[0]):
|
||||
transfer = module.LibraryTransfer(progress, self.agent.root / "agent/timing.json", previous)
|
||||
self.assertIsNone(transfer.snapshot()["transfer"]["etaSeconds"])
|
||||
now[0] += 0.5
|
||||
transfer.advance("bytesDownloaded", 5, 5)
|
||||
self.assertIsNone(transfer.snapshot()["transfer"]["etaSeconds"])
|
||||
now[0] += 1.5
|
||||
snapshot = transfer.snapshot()["transfer"]
|
||||
self.assertEqual(snapshot["bytesPerSecond"], 2.5)
|
||||
self.assertEqual(snapshot["etaSeconds"], 6)
|
||||
self.assertEqual(snapshot["elapsedSeconds"], 22)
|
||||
now[0] += 1
|
||||
transfer.advance("bytesDownloaded", 15, 15)
|
||||
transfer.begin_verification()
|
||||
self.assertEqual(transfer.snapshot()["transfer"]["phaseElapsedSeconds"], 0)
|
||||
self.assertIsNone(transfer.snapshot()["transfer"]["etaSeconds"])
|
||||
now[0] += 2
|
||||
transfer.advance("bytesVerified", 40, 40)
|
||||
snapshot = transfer.snapshot()["transfer"]
|
||||
self.assertEqual(snapshot["bytesPerSecond"], 20)
|
||||
self.assertEqual(snapshot["etaSeconds"], 3)
|
||||
self.assertEqual(snapshot["elapsedSeconds"], 25)
|
||||
transfer.finish(terminal_status)
|
||||
final = transfer.snapshot()
|
||||
now[0] += 100
|
||||
self.assertEqual(transfer.snapshot(), final)
|
||||
self.assertEqual(final["transfer"]["etaSeconds"], 0 if terminal_status == "ready" else None)
|
||||
|
||||
def test_partial_inventory_rejects_changed_manifest_and_symlinked_content(self):
|
||||
content = b"complete-game-file"
|
||||
manifest = self.manifest(content)
|
||||
|
||||
Reference in New Issue
Block a user