diff --git a/backend/imagefind/openlist_native.py b/backend/imagefind/openlist_native.py index 01a3842..74b4af9 100644 --- a/backend/imagefind/openlist_native.py +++ b/backend/imagefind/openlist_native.py @@ -484,6 +484,28 @@ class OpenListNativeService: def _verified_object_size(client: AlistClient, path: str) -> int | None: return _object_size(client.object_info(path)) + @classmethod + def _moved_object_converged( + cls, + client: AlistClient, + original: str, + target: str, + expected_size: int, + *, + attempts: int = 12, + delay_seconds: float = 0.25, + ) -> bool: + """Wait briefly for an eventually-consistent provider move to become visible.""" + + for attempt in range(attempts): + original_missing = client.object_info(original) is None + target_size = cls._verified_object_size(client, target) + if original_missing and target_size == expected_size: + return True + if attempt + 1 < attempts: + time.sleep(delay_seconds) + return False + def trash_object( self, source_id: str, @@ -508,15 +530,17 @@ class OpenListNativeService: # OpenList can finish a provider-side move after the control-plane # request times out. Resolve that ambiguous response from object # state before deciding whether compensation is required. - if client.object_info(original) is not None or self._verified_object_size(client, target) != expected_size: + if not self._moved_object_converged(client, original, target, expected_size): raise + if self._moved_object_converged(client, original, target, expected_size): + if config.encrypted: + self.sources.rclone.stop(source_id) + return target if client.object_info(original) is not None: raise RuntimeError("OpenList 原生回收站移动后源对象仍然存在") if self._verified_object_size(client, target) != expected_size: raise RuntimeError("OpenList 原生回收站对象长度不一致") - if config.encrypted: - self.sources.rclone.stop(source_id) - return target + raise RuntimeError("OpenList 原生回收站移动状态未收敛") def restore_object( self, diff --git a/tests/test_openlist_client.py b/tests/test_openlist_client.py index fd9f5bd..2a206e4 100644 --- a/tests/test_openlist_client.py +++ b/tests/test_openlist_client.py @@ -198,6 +198,29 @@ def test_native_trash_and_restore_verify_provider_state(ambiguous_timeout: bool) assert client.files == {"cloud/library/opaque.bin": 123} +def test_native_trash_waits_for_eventually_consistent_provider(monkeypatch): + class EventuallyConsistentClient(_MovingClient): + stale_reads = 4 + + def object_info(self, path: str): + trash_path = "cloud/library/.imagefind-native-trash/trash-id/opaque.bin" + if self.stale_reads and path in {"cloud/library/opaque.bin", trash_path}: + self.stale_reads -= 1 + if path == "cloud/library/opaque.bin": + return {"name": "opaque.bin", "size": 123, "is_dir": False} + return None + return super().object_info(path) + + monkeypatch.setattr("imagefind.openlist_native.time.sleep", lambda _seconds: None) + client = EventuallyConsistentClient({"cloud/library/opaque.bin": 123}) + service = _native_service(client) + + trash_path = service.trash_object("source", "trash-id", "cloud/library/opaque.bin", 123) + + assert trash_path == "cloud/library/.imagefind-native-trash/trash-id/opaque.bin" + assert client.files == {trash_path: 123} + + def test_native_trash_restore_conflicts_are_non_destructive_and_purge_is_idempotent(): trash_path = "cloud/library/.imagefind-native-trash/trash-id/opaque.bin" client = _MovingClient({"cloud/library/opaque.bin": 123, trash_path: 123})