fix(transfer): continue overwrite after target cleanup

## 问题

覆盖裁决确认旧媒体库目标存在后,洗版流程可能在实际备份前删除旧目标。恢复整理时备份步骤把旧目标和备份都不存在视为未知冲突,任务进入人工复核,暂存的新版本无法入库。

## 修复

- 备份步骤在确认旧目标已被外部删除、且备份目标也不存在时记录为无副作用跳过。
- 继续提升已完整暂存的新版本;移动整理仍在提升成功后清理源文件。
- 查询失败、两端同时存在或存在备份但状态矛盾时继续保持人工复核保护。
- 增加持久整理回归测试,覆盖覆盖裁决后旧目标被删除的场景。

## 验证

- `tests/test_transfer_safe_overwrite.py`:94 passed
- 整理执行、持久化、人工复核、提交、worker、计划测试:202 passed
- changed-file Pylint:10.00/10

Fixes #7120
This commit is contained in:
jxxghp
2026-10-21 12:54:06 +08:00
committed by GitHub
parent 85ccba699f
commit f090d22df2
2 changed files with 58 additions and 7 deletions

View File

@@ -1211,11 +1211,13 @@ class TransHandler:
target: Path,
kind: str,
expected_source: Optional[FileItem] = None,
) -> FileItem:
allow_missing_source: bool = False,
) -> Optional[FileItem]:
"""
执行同目录无覆盖重命名,并用两端严格查询恢复中断操作。
两端同时存在或同时消失视为冲突;查询或身份无法确认时不自动重放。
两端同时存在或遗留步骤中同时消失视为冲突;只有覆盖备份在执行前明确
发现旧目标缺失时,才记录为无副作用的跳过。查询或身份无法确认时不自动重放。
:param step_runner: 步骤执行器;None 时同步执行
:param storage_oper: 负责查询和重命名的存储适配器
@@ -1224,14 +1226,18 @@ class TransHandler:
:param target: 新路径,必须不存在且由调用方保持在同一目录
:param kind: 步骤操作类别
:param expected_source: 可选的冻结源身份;提供时在操作前后核对
:return: 重命名后的文件项
:param allow_missing_source: 旧目标已被外部删除时是否将重命名记录为跳过
:return: 重命名后的文件项;允许旧目标缺失并跳过时返回 None
"""
def execute() -> TransferStepResult:
"""
目标必须不存在,避免适配器的 rename 意外替换其它文件。
"""
source_item = storage_oper.get_item_strict(source)
if source_item is None or storage_oper.get_item_strict(target) is not None:
target_item = storage_oper.get_item_strict(target)
if allow_missing_source and source_item is None and target_item is None:
return TransferStepResult(payload={"item": None, "skipped": True})
if source_item is None or target_item is not None:
raise RuntimeError(f"重命名路径状态冲突:{source} -> {target}")
if expected_source is not None:
cls.__assert_file_identity(expected_source, source_item)
@@ -1285,7 +1291,9 @@ class TransHandler:
execute=execute, observe=observe,
)
item = cls.__restore_step_item(renamed)
if item is None:
if item is None and not (
allow_missing_source and renamed.payload.get("skipped") is True
):
raise RuntimeError("重命名成功证据缺少目标文件")
return item
@@ -1339,12 +1347,13 @@ class TransHandler:
step_runner=step_runner, storage_oper=target_oper,
storage=target_storage, source=target_file, target=backup,
kind="backup_overwrite_target",
allow_missing_source=True,
)
new_item = cls.__rename_with_step(
new_item = cast(FileItem, cls.__rename_with_step(
step_runner=step_runner, storage_oper=target_oper,
storage=target_storage, source=stage, target=target_file,
kind="promote_overwrite_target", expected_source=new_item,
)
))
def confirm_replacement() -> None:
"""

View File

@@ -498,6 +498,48 @@ def test_safe_overwrite_preserves_transfer_modes(tmp_path, monkeypatch, transfer
assert target.is_symlink() is (transfer_type == "softlink")
def test_overwrite_promotes_new_target_when_old_target_disappears_before_backup(
tmp_path, monkeypatch, repository_factory,
):
"""
持久覆盖裁决后旧目标被洗版清理时,跳过无效备份并继续提升新文件。
"""
source = tmp_path / "download.mkv"
target = tmp_path / "Movie.2026.mkv"
source.write_bytes(b"new-version")
target.write_bytes(b"old-version")
checkpoint = _checkpoint(source, target, transfer_type="move")
repository = repository_factory(checkpoint)
real_get_item_strict = LocalStorage.get_item_strict
deleted = False
def remove_target_after_decision(self, path):
"""
模拟覆盖裁决完成后洗版流程删除媒体库旧目标。
"""
nonlocal deleted
item = real_get_item_strict(self, path)
if path == target and item is not None and not deleted:
target.unlink()
deleted = True
return item
monkeypatch.setattr(LocalStorage, "get_item_strict", remove_target_after_decision)
result = _execute(checkpoint, monkeypatch, _durable_runner(repository, checkpoint))
assert result.success
assert target.read_bytes() == b"new-version"
assert not source.exists()
assert not list(tmp_path.glob(".mp-*"))
backup_step = next(
step for step in repository.get_snapshot(task_id="safe-overwrite").steps
if step.kind == "backup_overwrite_target"
)
assert backup_step.state is TransferStepState.SUCCEEDED
assert backup_step.result is not None
assert backup_step.result.payload["skipped"] is True
@pytest.fixture
def repository_factory():
"""