fix: make frozen product backup unique and atomic on --force

This commit is contained in:
2026-07-16 05:12:08 -04:00
parent a0c7e043e8
commit 8f349c5c62
2 changed files with 92 additions and 5 deletions
+39 -5
View File
@@ -105,17 +105,41 @@ class SplitBuildResult:
return getattr(self, key)
def _unique_backup_path(path: Path, suffix: str) -> Path:
"""求 path 的唯一 .bak.<suffix> 备份路径,已存在则追加递增序号避免覆盖。
首选 ``<name>.bak.<suffix>``;若已存在,退化为 ``<name>.bak.<suffix>.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 时已存在即 FileExistsErrorTrue 时把旧产物重命名为
.bak.<旧 pools_sha256 前 8 位或 'prev'> 再放行。
force: False 时已存在即 FileExistsErrorTrue 时把旧产物重命名为唯一的
.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(
+53
View File
@@ -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())}"