From 8f349c5c62dc8857a203b17cd97430a2e27c52a8 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Thu, 16 Jul 2026 05:12:08 -0400 Subject: [PATCH] fix: make frozen product backup unique and atomic on --force --- app/harness/build_split.py | 44 ++++++++++++++++++++++--- tests/unit/test_video_split_cli.py | 53 ++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/app/harness/build_split.py b/app/harness/build_split.py index 197d4e1..e1d80be 100644 --- a/app/harness/build_split.py +++ b/app/harness/build_split.py @@ -105,17 +105,41 @@ class SplitBuildResult: return getattr(self, key) +def _unique_backup_path(path: Path, suffix: str) -> Path: + """求 path 的唯一 .bak. 备份路径,已存在则追加递增序号避免覆盖。 + + 首选 ``.bak.``;若已存在,退化为 ``.bak..2``、 + ``.3`` … 直到找到不存在的名字。保证连续 forced freeze(同 suffix 或都缺 + manifest 用 'prev')不会静默覆盖此前保留的备份。 + + 参数: + path: 待备份的原文件路径。 + suffix: 备份后缀(旧 pools_sha256 前 8 位或 'prev')。 + + 返回: + 目录内唯一、尚不存在的备份路径。 + """ + candidate = path.with_name(f"{path.name}.bak.{suffix}") + counter = 2 + while candidate.exists(): + candidate = path.with_name(f"{path.name}.bak.{suffix}.{counter}") + counter += 1 + return candidate + + def _guard_frozen_products(out_path: Path, manifest_path: Path, *, force: bool) -> None: """冻结前的覆盖保护:产物已存在时按 force 决定报错或备份。 参数: out_path: 目标 pools.json 路径。 manifest_path: 目标 split_manifest.json 路径。 - force: False 时已存在即 FileExistsError;True 时把旧产物重命名为 - .bak.<旧 pools_sha256 前 8 位或 'prev'> 再放行。 + force: False 时已存在即 FileExistsError;True 时把旧产物重命名为唯一的 + .bak.<旧 pools_sha256 前 8 位或 'prev'>(同名已存在则追加递增序号)再放行。 异常: FileExistsError: force=False 且产物已存在(防静默覆盖冻结锚点)。 + OSError: 备份 rename 失败;已备份的文件先 rollback 回原名再抛出,保证 + 要么两文件都备份、要么都不动(原子性,不留半备份的不一致目录)。 """ if not out_path.exists() and not manifest_path.exists(): return @@ -132,9 +156,19 @@ def _guard_frozen_products(out_path: Path, manifest_path: Path, *, force: bool) suffix = str(old.get("pools_sha256", "prev"))[:8] or "prev" except (json.JSONDecodeError, OSError): suffix = "prev" - for p in (out_path, manifest_path): - if p.exists(): - p.rename(p.with_name(f"{p.name}.bak.{suffix}")) + # 先为每个存在的文件求唯一备份路径(互不冲突),再逐个 rename; + # 中途失败则把已备份的 rollback 回原名,保证原子性。 + to_backup = [p for p in (out_path, manifest_path) if p.exists()] + done: list[tuple[Path, Path]] = [] # (备份路径, 原路径),供 rollback + try: + for p in to_backup: + dst = _unique_backup_path(p, suffix) + p.rename(dst) + done.append((dst, p)) + except OSError: + for backup_path, original in reversed(done): + backup_path.rename(original) + raise def build_split( diff --git a/tests/unit/test_video_split_cli.py b/tests/unit/test_video_split_cli.py index 60cb702..2bc7536 100644 --- a/tests/unit/test_video_split_cli.py +++ b/tests/unit/test_video_split_cli.py @@ -329,3 +329,56 @@ def test_build_split_force_backs_up_old(tmp_path): _guard_frozen_products(out_path, manifest_path, force=True) baks = list(tmp_path.glob("pools.json.bak.*")) assert len(baks) == 1, f"未备份旧产物: {list(tmp_path.iterdir())}" + + +def test_build_split_force_twice_same_suffix_keeps_both(tmp_path): + """连续两次 force 命中同后缀时,第二次备份不覆盖第一次(追加递增序号)。""" + from app.harness.build_split import _guard_frozen_products + + out_path = tmp_path / "pools.json" + manifest_path = tmp_path / "split_manifest.json" + + # 第一次 force:两文件都无 manifest 里的 pools_sha256 之外的差异,后缀恒为同值。 + out_path.write_text('{"gen":1}', encoding="utf-8") + manifest_path.write_text('{"pools_sha256":"deadbeef00000000"}', encoding="utf-8") + _guard_frozen_products(out_path, manifest_path, force=True) + + # 第二次 force:写入相同 sha 的新产物,触发同后缀备份。 + out_path.write_text('{"gen":2}', encoding="utf-8") + manifest_path.write_text('{"pools_sha256":"deadbeef00000000"}', encoding="utf-8") + _guard_frozen_products(out_path, manifest_path, force=True) + + pools_baks = sorted(p.name for p in tmp_path.glob("pools.json.bak.*")) + assert len(pools_baks) == 2, f"同后缀第二次备份覆盖了第一次: {pools_baks}" + + +def test_build_split_force_rollback_on_partial_failure(tmp_path, monkeypatch): + """备份第二个文件失败时,第一个已备份文件被 rollback 回原名(原子性)。""" + from pathlib import Path + + from app.harness import build_split as bs + + out_path = tmp_path / "pools.json" + manifest_path = tmp_path / "split_manifest.json" + out_path.write_text('{"gen":1}', encoding="utf-8") + manifest_path.write_text('{"pools_sha256":"deadbeef00000000"}', encoding="utf-8") + + real_rename = Path.rename + calls = {"n": 0} + + def flaky_rename(self, target): + # 第一次 rename(备份 pools.json)成功,第二次(备份 manifest)抛错。 + calls["n"] += 1 + if calls["n"] == 2: + raise OSError("模拟第二个备份 rename 失败") + return real_rename(self, target) + + monkeypatch.setattr(Path, "rename", flaky_rename) + + with pytest.raises(OSError, match="模拟第二个备份"): + bs._guard_frozen_products(out_path, manifest_path, force=True) + + # rollback 后:两原文件仍在原位,无残留 .bak.* + assert out_path.exists(), "第一个文件未被 rollback 回原名" + assert manifest_path.exists(), "第二个文件不应被移动" + assert not list(tmp_path.glob("*.bak.*")), f"残留半备份: {list(tmp_path.iterdir())}"