From 94a3233ce248866b4daf51c084fcd14b3e935da6 Mon Sep 17 00:00:00 2001 From: Cabbagec Date: Fri, 24 Jul 2026 17:00:10 +0000 Subject: [PATCH] fix: clean interrupted staging temporaries --- deploy/production/lithium/compose.yaml | 2 +- deploy/production/x1/compose.yaml | 2 +- deploy/production/x2/compose.yaml | 2 +- docs/deployment-and-usage.md | 2 +- pyproject.toml | 2 +- src/archive_clients/jobs.py | 6 ++++++ src/archive_clients/transfer.py | 30 ++++++++++++++++++++++++++ tests/test_transfer.py | 28 ++++++++++++++++++++++++ 8 files changed, 69 insertions(+), 5 deletions(-) diff --git a/deploy/production/lithium/compose.yaml b/deploy/production/lithium/compose.yaml index 5f29e5e..3cb219e 100644 --- a/deploy/production/lithium/compose.yaml +++ b/deploy/production/lithium/compose.yaml @@ -2,7 +2,7 @@ name: archive-control-archive services: archive-client: - image: sodium/archive-clients:v0.1.7 + image: sodium/archive-clients:v0.1.8 user: "1000:1000" restart: unless-stopped command: ["--config", "/etc/archive-control/client.toml"] diff --git a/deploy/production/x1/compose.yaml b/deploy/production/x1/compose.yaml index 10e8b0b..c2872f4 100644 --- a/deploy/production/x1/compose.yaml +++ b/deploy/production/x1/compose.yaml @@ -2,7 +2,7 @@ name: archive-control-cache services: archive-client: - image: sodium/archive-clients:v0.1.7 + image: sodium/archive-clients:v0.1.8 user: "1001:1001" restart: unless-stopped network_mode: host diff --git a/deploy/production/x2/compose.yaml b/deploy/production/x2/compose.yaml index e42aef9..c66ef03 100644 --- a/deploy/production/x2/compose.yaml +++ b/deploy/production/x2/compose.yaml @@ -2,7 +2,7 @@ name: archive-control-cache services: archive-client: - image: sodium/archive-clients:v0.1.7 + image: sodium/archive-clients:v0.1.8 user: "1001:1001" restart: unless-stopped network_mode: host diff --git a/docs/deployment-and-usage.md b/docs/deployment-and-usage.md index 1c0593d..8d4b195 100644 --- a/docs/deployment-and-usage.md +++ b/docs/deployment-and-usage.md @@ -218,7 +218,7 @@ cache/archive routes according to policy. ```yaml services: archive-client: - image: sodium/archive-clients:v0.1.7 + image: sodium/archive-clients:v0.1.8 user: "1001:1001" restart: unless-stopped command: ["archive-client", "--config", "/etc/archive-control/client.toml"] diff --git a/pyproject.toml b/pyproject.toml index c73f796..a463242 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "archive-clients" -version = "0.1.7" +version = "0.1.8" requires-python = ">=3.11" dependencies = ["protobuf==7.35.1", "websockets==16.0"] diff --git a/src/archive_clients/jobs.py b/src/archive_clients/jobs.py index 7350bc2..d42b5bf 100644 --- a/src/archive_clients/jobs.py +++ b/src/archive_clients/jobs.py @@ -32,6 +32,7 @@ from archive_clients.syncthing import RouteSetupError, SyncthingTransferObserver from archive_clients.transfer import ( TransferError, TransferIntegrityError, + cleanup_partial_transfer, cleanup_transfer, load_published_transfer, materialize_transfer, @@ -833,6 +834,11 @@ class ClientJobExecutor: qb_root=job_directory, store=self.store, ) + cleanup_partial_transfer( + job_directory, + job_id=definition.job_id, + store=self.store, + ) for name in ("ready.json", "manifest.json"): candidate = job_directory / name if candidate.is_file() and not candidate.is_symlink(): diff --git a/src/archive_clients/transfer.py b/src/archive_clients/transfer.py index 3678265..53ee499 100644 --- a/src/archive_clients/transfer.py +++ b/src/archive_clients/transfer.py @@ -456,6 +456,36 @@ def cleanup_transfer(job_directory: Path) -> bool: return True +def cleanup_partial_transfer( + job_directory: Path, + *, + job_id: str, + store: ClientStore, +) -> None: + """Remove copy/reflink temporaries left before a transfer is published. + + A source-stage failure before ``manifest.json``/``ready.json`` exists + cannot use :func:`cleanup_transfer`. The file-operation journal is the + authoritative list of owned destinations, so derive each temporary path + from it rather than recursively removing arbitrary content from a shared + Syncthing folder. + """ + + for row in store.file_operation_rows(job_id): + intent = json.loads(str(row["intent_json"])) + destination = job_directory / _relative_path(str(intent["destination"])) + temporary = _temporary_path(destination, str(row["operation_id"])) + try: + metadata = temporary.lstat() + except FileNotFoundError: + continue + if not stat.S_ISREG(metadata.st_mode): + raise TransferIntegrityError( + "job-owned temporary cleanup path is not a regular file" + ) + temporary.unlink() + + def canonical_message_json(message: object) -> bytes: value = json_format.MessageToDict( message, diff --git a/tests/test_transfer.py b/tests/test_transfer.py index f8ffc03..1bfa05a 100644 --- a/tests/test_transfer.py +++ b/tests/test_transfer.py @@ -11,6 +11,7 @@ from archive_clients.transfer import ( FileMaterializer, TransferIntegrityError, canonical_message_json, + cleanup_partial_transfer, load_published_transfer, materialize_transfer, stage_transfer, @@ -206,6 +207,33 @@ class TransferHappyPathTests(unittest.TestCase): os.stat(copied).st_blocks * 512, os.stat(copied).st_size ) + def test_partial_cleanup_removes_only_journalled_temporary(self): + job_id = str(uuid4()) + job_root = self.sync / ".archive-control/jobs" / job_id + destination = job_root / "payload/album/one.bin" + destination.parent.mkdir(parents=True) + operation_id = "interrupted-copy" + self.store.begin_file_operation( + operation_id, + job_id, + '{"destination":"payload/album/one.bin"}', + ) + temporary = destination.with_name( + ".one.bin.archive-control-" + + hashlib.sha256(operation_id.encode()).hexdigest()[:16] + + ".tmp" + ) + temporary.write_bytes(b"partial") + unrelated = destination.parent / "keep-me" + unrelated.write_bytes(b"unrelated") + + cleanup_partial_transfer( + job_root, job_id=job_id, store=self.store + ) + + self.assertFalse(temporary.exists()) + self.assertTrue(unrelated.is_file()) + def test_canonical_manifest_json_is_stable(self): manifest = self._manifest() first = canonical_message_json(manifest)