Move retention, orphan pruning, status and clear operations into a topical sibling and route callers and tests directly to their defining module. Keep the shared store paths and git execution in checkpoint_manager; shorten the local child-env WHAT docstring.
167 lines
7.5 KiB
Python
167 lines
7.5 KiB
Python
"""Real history retention and failure propagation at the public pruning callers."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import pytest
|
|
|
|
import tools.checkpoint_manager as checkpoints
|
|
from tools import checkpoint_maintenance
|
|
|
|
|
|
@pytest.fixture
|
|
def history(tmp_path, monkeypatch):
|
|
base = tmp_path / "checkpoints"
|
|
monkeypatch.setattr(checkpoints, "CHECKPOINT_BASE", base)
|
|
project = min((tmp_path / name for name in ("project-a", "project-b")),
|
|
key=lambda path: checkpoints._project_hash(str(path)))
|
|
project.mkdir()
|
|
manager = checkpoints.CheckpointManager(enabled=True, max_total_size_mb=0)
|
|
blob = project / "old.bin"
|
|
blob.write_bytes(os.urandom(1_300_000))
|
|
assert manager.ensure_checkpoint(str(project), "large-oldest")
|
|
blob.unlink()
|
|
for index in range(5):
|
|
(project / "small.txt").write_text(str(index), encoding="utf-8")
|
|
manager.new_turn()
|
|
assert manager.ensure_checkpoint(str(project), f"small-{index}")
|
|
return base, project, manager
|
|
|
|
|
|
def test_maintenance_size_cap_retains_every_snapshot_after_the_only_large_one(history):
|
|
base, project, manager = history
|
|
store = checkpoints._store_path(base)
|
|
assert checkpoints._dir_size_bytes(store) > 1024 * 1024
|
|
result = checkpoint_maintenance.prune_checkpoints(retention_days=0, checkpoint_base=base, max_total_size_mb=1)
|
|
assert result["errors"] == 0
|
|
assert [row["reason"] for row in manager.list_checkpoints(str(project))] == [f"small-{i}" for i in reversed(range(5))]
|
|
assert checkpoints._dir_size_bytes(store) <= 1024 * 1024
|
|
assert (project / "small.txt").read_text(encoding="utf-8") == "4"
|
|
|
|
|
|
def test_snapshot_over_cap_drops_one_round_and_leaves_the_gc_to_the_prune(history):
|
|
"""A checkpoint never waits on ``git gc``: over the cap it rewrites the ref once and marks
|
|
the store gc-pending; the periodic prune reclaims the objects and clears the marker."""
|
|
from tools.checkpoint_pruning import GC_PENDING_NAME
|
|
|
|
base, project, manager = history
|
|
store = checkpoints._store_path(base)
|
|
manager.max_total_size_mb = 1
|
|
gc_calls = []
|
|
original = checkpoints._run_git
|
|
|
|
def count_gc(args, *rest, **kwargs):
|
|
if args[0] == "gc":
|
|
gc_calls.append(args)
|
|
return original(args, *rest, **kwargs)
|
|
|
|
(project / "new.txt").write_text("new snapshot", encoding="utf-8")
|
|
manager.new_turn()
|
|
with pytest.MonkeyPatch.context() as mp:
|
|
mp.setattr(checkpoints, "_run_git", count_gc)
|
|
assert manager.ensure_checkpoint(str(project), "newest")
|
|
assert gc_calls == []
|
|
assert (store / GC_PENDING_NAME).exists()
|
|
assert [row["reason"] for row in manager.list_checkpoints(str(project))] == \
|
|
["newest"] + [f"small-{i}" for i in reversed(range(5))]
|
|
# Objects are still in the pack until the prune runs.
|
|
assert checkpoints._dir_size_bytes(store) > 1024 * 1024
|
|
|
|
result = checkpoint_maintenance.prune_checkpoints(retention_days=0, checkpoint_base=base, max_total_size_mb=1)
|
|
assert result["errors"] == 0
|
|
assert not (store / GC_PENDING_NAME).exists()
|
|
assert checkpoints._dir_size_bytes(store) <= 1024 * 1024
|
|
assert (project / "small.txt").read_text(encoding="utf-8") == "4"
|
|
|
|
|
|
def test_reclaim_stops_before_touching_another_projects_history(history):
|
|
base, first, manager = history
|
|
# Real ref sorting chooses the first victim. Pick the other project so
|
|
# this is a discriminating reclaim-before-next-project assertion.
|
|
other = next(first.parent / name for name in ("project-a", "project-b") if first.name != name)
|
|
other.mkdir()
|
|
for index in range(3):
|
|
(other / "small.txt").write_text(str(index), encoding="utf-8")
|
|
manager.new_turn()
|
|
assert manager.ensure_checkpoint(str(other), f"other-{index}")
|
|
before = manager.list_checkpoints(str(other))
|
|
result = checkpoint_maintenance.prune_checkpoints(retention_days=0, checkpoint_base=base, max_total_size_mb=1)
|
|
assert result["errors"] == 0
|
|
assert manager.list_checkpoints(str(other)) == before
|
|
assert len(manager.list_checkpoints(str(first))) == 5
|
|
|
|
|
|
@pytest.mark.parametrize("failure", ["gc", "reflog", "for-each-ref", "rev-list", "log", "update-ref", "delete-ref"])
|
|
def test_maintenance_stops_on_failed_git_without_losing_a_second_snapshot(history, monkeypatch, failure):
|
|
import json
|
|
|
|
base, project, manager = history
|
|
original = checkpoint_maintenance._run_git
|
|
rewrites = []
|
|
retention = 0
|
|
if failure == "delete-ref":
|
|
meta = checkpoints._project_meta_path(checkpoints._store_path(base), checkpoints._project_hash(str(project)))
|
|
state = json.loads(meta.read_text(encoding="utf-8"))
|
|
state["last_touch"] = 1
|
|
meta.write_text(json.dumps(state), encoding="utf-8")
|
|
retention = 1
|
|
|
|
def fail_one(args, *rest, **kwargs):
|
|
if args[0] == failure or (failure == "delete-ref" and args[:2] == ["update-ref", "-d"]):
|
|
return False, "", "injected Git failure"
|
|
result = original(args, *rest, **kwargs)
|
|
if args[0] == "update-ref" and result[0]:
|
|
rewrites.append(args)
|
|
return result
|
|
|
|
with monkeypatch.context() as patcher:
|
|
patcher.setattr(checkpoint_maintenance, "_run_git", fail_one)
|
|
result = checkpoint_maintenance.prune_checkpoints(retention_days=retention, checkpoint_base=base, max_total_size_mb=1)
|
|
assert result["errors"] > 0
|
|
assert len(rewrites) <= 1
|
|
assert len(manager.list_checkpoints(str(project))) >= 5
|
|
if failure == "delete-ref":
|
|
assert meta.is_file()
|
|
assert result["deleted_stale"] == 0
|
|
|
|
|
|
def test_snapshot_pruning_stops_at_a_failed_count_rewrite(history, monkeypatch, caplog):
|
|
"""A count trim that cannot rewrite its ref aborts the take-path pruning before the size
|
|
round runs — never a partial history, never a second rewrite on top of a failed one."""
|
|
_, project, manager = history
|
|
manager.max_snapshots = 5
|
|
manager.max_total_size_mb = 1
|
|
(project / "later.txt").write_text("newest", encoding="utf-8")
|
|
manager.new_turn()
|
|
original = checkpoints._run_git
|
|
changed_refs = []
|
|
|
|
def refuse_rewrite(args, *rest, **kwargs):
|
|
# The rewrite re-creates commits under their original dates; the snapshot's own
|
|
# commit-tree carries no extra_env.
|
|
if args[0] == "commit-tree" and kwargs.get("extra_env"):
|
|
return False, "", "injected rewrite failure"
|
|
result = original(args, *rest, **kwargs)
|
|
if args[0] == "update-ref" and result[0]:
|
|
changed_refs.append(args)
|
|
return result
|
|
|
|
monkeypatch.setattr(checkpoints, "_run_git", refuse_rewrite)
|
|
assert manager.ensure_checkpoint(str(project), "latest")
|
|
assert len(changed_refs) == 1 # The new checkpoint only; the trim failed before update-ref.
|
|
assert "injected rewrite failure" in caplog.text
|
|
store = checkpoints._store_path(checkpoints.CHECKPOINT_BASE)
|
|
ref = checkpoints._ref_name(checkpoints._project_hash(str(project)))
|
|
ok, out, _ = original(["rev-list", "--count", ref], store, str(project))
|
|
assert ok and int(out) == 7 # Nothing trimmed: the failed rewrite left history whole.
|
|
|
|
|
|
def test_restore_keeps_its_target_alive_while_taking_the_safety_snapshot(history):
|
|
_, project, manager = history
|
|
target = manager.list_checkpoints(str(project))[-1]["hash"]
|
|
manager.max_snapshots = 1
|
|
(project / "small.txt").write_text("changed before restore", encoding="utf-8")
|
|
result = manager.restore(str(project), target)
|
|
assert result["success"], result
|
|
assert (project / "old.bin").stat().st_size == 1_300_000
|