"""Phase 6: Storage tier formalisation — TDD (RED → GREEN). Seven tiers: Tier 1 — prune_packed removes loose objects that are covered by packs Tier 2 — prune_packed preserves loose objects NOT in any pack Tier 3 — classify_object returns correct StorageTier (HOT / WARM) Tier 4 — classify_object returns None for unknown objects Tier 5 — storage_stats returns accurate counts per tier Tier 6 — auto_pack packs + prunes when loose count meets threshold Tier 7 — full promotion workflow: write → pack → prune → all still readable """ from __future__ import annotations import secrets from pathlib import Path import pytest from muse.core.types import blob_id # ── helpers ─────────────────────────────────────────────────────────────────── def _oid(content: bytes | None = None) -> str: if content is not None: return blob_id(content) return blob_id(secrets.token_bytes(16)) def _repo_root(tmp_path: Path, owner: str = "gabriel", slug: str = "tier-test") -> Path: root = tmp_path / owner / slug (root / "refs" / "heads").mkdir(parents=True, exist_ok=True) (root / "objects").mkdir(parents=True, exist_ok=True) return root def _write_loose(repo_root: Path, object_id: str, data: bytes) -> Path: from muse.core.object_store import object_path from muse.core.paths import server_objects_dir p = object_path(repo_root, object_id, objects_base=server_objects_dir(repo_root)) p.parent.mkdir(parents=True, exist_ok=True) p.write_bytes(data) return p def _loose_path(repo_root: Path, object_id: str) -> Path: from muse.core.object_store import object_path from muse.core.paths import server_objects_dir return object_path(repo_root, object_id, objects_base=server_objects_dir(repo_root)) def _count_loose(repo_root: Path) -> int: from muse.core.paths import server_objects_dir base = server_objects_dir(repo_root) / "sha256" if not base.exists(): return 0 return sum(1 for p in base.rglob("*") if p.is_file()) def _pack_dir(repo_root: Path) -> Path: return repo_root / "objects" / "pack" # ── Tier 1: prune_packed removes covered loose objects ──────────────────────── class TestPrunePacked: def test_removes_loose_after_packing(self, tmp_path: Path) -> None: from musehub.storage.tiers import prune_packed from musehub.storage.pack import pack_loose_objects repo_root = _repo_root(tmp_path) data = b"i will be pruned" oid = _oid(data) _write_loose(repo_root, oid, data) pack_loose_objects(repo_root) assert _loose_path(repo_root, oid).exists(), "loose must exist before prune" result = prune_packed(repo_root) assert not _loose_path(repo_root, oid).exists(), "loose must be removed after prune" assert result.pruned == 1 def test_removes_all_covered_loose_objects(self, tmp_path: Path) -> None: from musehub.storage.tiers import prune_packed from musehub.storage.pack import pack_loose_objects repo_root = _repo_root(tmp_path) oids = [] for i in range(8): data = f"object-{i}".encode() oid = _oid(data) _write_loose(repo_root, oid, data) oids.append(oid) pack_loose_objects(repo_root) result = prune_packed(repo_root) assert result.pruned == 8 assert _count_loose(repo_root) == 0 def test_noop_when_no_packs_exist(self, tmp_path: Path) -> None: from musehub.storage.tiers import prune_packed repo_root = _repo_root(tmp_path) data = b"unpacked" oid = _oid(data) _write_loose(repo_root, oid, data) result = prune_packed(repo_root) assert result.pruned == 0 assert _loose_path(repo_root, oid).exists(), "loose must survive when no packs exist" def test_noop_when_no_loose_exists(self, tmp_path: Path) -> None: from musehub.storage.tiers import prune_packed from musehub.storage.pack import pack_loose_objects repo_root = _repo_root(tmp_path) data = b"pack then prune" oid = _oid(data) _write_loose(repo_root, oid, data) pack_loose_objects(repo_root) _loose_path(repo_root, oid).unlink() # already gone result = prune_packed(repo_root) assert result.pruned == 0 # ── Tier 2: prune_packed preserves unpackaged loose objects ─────────────────── class TestPrunePackedPreservesUnpacked: def test_preserves_loose_objects_not_in_any_pack(self, tmp_path: Path) -> None: from musehub.storage.tiers import prune_packed from musehub.storage.pack import pack_loose_objects repo_root = _repo_root(tmp_path) # Pack object A data_a = b"packed-object-a" oid_a = _oid(data_a) _write_loose(repo_root, oid_a, data_a) pack_loose_objects(repo_root) # Write object B after packing — it is NOT in any pack data_b = b"unpacked-object-b" oid_b = _oid(data_b) _write_loose(repo_root, oid_b, data_b) result = prune_packed(repo_root) assert result.pruned == 1 assert not _loose_path(repo_root, oid_a).exists(), "A must be pruned" assert _loose_path(repo_root, oid_b).exists(), "B must survive" def test_mixed_prune_removes_only_covered(self, tmp_path: Path) -> None: from musehub.storage.tiers import prune_packed from musehub.storage.pack import pack_loose_objects repo_root = _repo_root(tmp_path) # Pack first batch packed_oids = [] for i in range(5): data = f"packed-{i}".encode() oid = _oid(data) _write_loose(repo_root, oid, data) packed_oids.append(oid) pack_loose_objects(repo_root) # New loose objects written after packing new_oids = [] for i in range(3): data = f"new-{i}".encode() oid = _oid(data) _write_loose(repo_root, oid, data) new_oids.append(oid) result = prune_packed(repo_root) assert result.pruned == 5 for oid in packed_oids: assert not _loose_path(repo_root, oid).exists() for oid in new_oids: assert _loose_path(repo_root, oid).exists() # ── Tier 3: classify_object returns correct tier ────────────────────────────── class TestClassifyObject: def test_hot_for_loose_object(self, tmp_path: Path) -> None: from musehub.storage.tiers import classify_object, StorageTier repo_root = _repo_root(tmp_path) data = b"hot object" oid = _oid(data) _write_loose(repo_root, oid, data) assert classify_object(repo_root, oid) is StorageTier.HOT def test_warm_for_packed_object_after_prune(self, tmp_path: Path) -> None: from musehub.storage.tiers import classify_object, StorageTier, prune_packed from musehub.storage.pack import pack_loose_objects repo_root = _repo_root(tmp_path) data = b"warm object" oid = _oid(data) _write_loose(repo_root, oid, data) pack_loose_objects(repo_root) prune_packed(repo_root) assert classify_object(repo_root, oid) is StorageTier.WARM def test_hot_before_prune_loose_and_pack_both_present(self, tmp_path: Path) -> None: """Loose takes precedence — object is HOT even if also in a pack.""" from musehub.storage.tiers import classify_object, StorageTier from musehub.storage.pack import pack_loose_objects repo_root = _repo_root(tmp_path) data = b"still hot" oid = _oid(data) _write_loose(repo_root, oid, data) pack_loose_objects(repo_root) # loose still exists after packing assert classify_object(repo_root, oid) is StorageTier.HOT def test_warm_for_object_in_pack_loose_removed(self, tmp_path: Path) -> None: from musehub.storage.tiers import classify_object, StorageTier from musehub.storage.pack import pack_loose_objects repo_root = _repo_root(tmp_path) data = b"pack me" oid = _oid(data) loose = _write_loose(repo_root, oid, data) pack_loose_objects(repo_root) loose.unlink() assert classify_object(repo_root, oid) is StorageTier.WARM # ── Tier 4: classify_object returns None for unknown object ─────────────────── class TestClassifyObjectUnknown: def test_returns_none_for_missing_object(self, tmp_path: Path) -> None: from musehub.storage.tiers import classify_object repo_root = _repo_root(tmp_path) assert classify_object(repo_root, _oid()) is None def test_returns_none_in_empty_repo(self, tmp_path: Path) -> None: from musehub.storage.tiers import classify_object repo_root = _repo_root(tmp_path) for _ in range(3): assert classify_object(repo_root, _oid()) is None def test_returns_none_after_object_deleted_from_all_tiers(self, tmp_path: Path) -> None: from musehub.storage.tiers import classify_object, prune_packed from musehub.storage.pack import pack_loose_objects, gc_packs repo_root = _repo_root(tmp_path) data = b"temporary" oid = _oid(data) _write_loose(repo_root, oid, data) pack_loose_objects(repo_root) prune_packed(repo_root) # Nuke the pack for f in _pack_dir(repo_root).glob("*"): f.unlink() assert classify_object(repo_root, oid) is None # ── Tier 5: storage_stats returns accurate counts per tier ──────────────────── class TestStorageStats: def test_all_loose_before_packing(self, tmp_path: Path) -> None: from musehub.storage.tiers import storage_stats repo_root = _repo_root(tmp_path) for i in range(4): data = f"loose-{i}".encode() _write_loose(repo_root, _oid(data), data) stats = storage_stats(repo_root) assert stats.hot == 4 assert stats.warm == 0 def test_warm_count_after_pack_and_prune(self, tmp_path: Path) -> None: from musehub.storage.tiers import storage_stats, prune_packed from musehub.storage.pack import pack_loose_objects repo_root = _repo_root(tmp_path) for i in range(6): data = f"to-warm-{i}".encode() _write_loose(repo_root, _oid(data), data) pack_loose_objects(repo_root) prune_packed(repo_root) stats = storage_stats(repo_root) assert stats.hot == 0 assert stats.warm == 6 def test_mixed_hot_and_warm(self, tmp_path: Path) -> None: from musehub.storage.tiers import storage_stats, prune_packed from musehub.storage.pack import pack_loose_objects repo_root = _repo_root(tmp_path) # 3 objects packed + pruned → warm for i in range(3): data = f"warm-obj-{i}".encode() oid = _oid(data) loose = _write_loose(repo_root, oid, data) pack_loose_objects(repo_root) prune_packed(repo_root) # 2 new objects loose → hot for i in range(2): data = f"hot-obj-{i}".encode() _write_loose(repo_root, _oid(data), data) stats = storage_stats(repo_root) assert stats.hot == 2 assert stats.warm == 3 def test_empty_repo_all_zeros(self, tmp_path: Path) -> None: from musehub.storage.tiers import storage_stats repo_root = _repo_root(tmp_path) stats = storage_stats(repo_root) assert stats.hot == 0 assert stats.warm == 0 def test_total_equals_hot_plus_warm(self, tmp_path: Path) -> None: from musehub.storage.tiers import storage_stats, prune_packed from musehub.storage.pack import pack_loose_objects repo_root = _repo_root(tmp_path) for i in range(5): data = f"obj-{i}".encode() oid = _oid(data) loose = _write_loose(repo_root, oid, data) pack_loose_objects(repo_root) prune_packed(repo_root) for i in range(3): data = f"new-obj-{i}".encode() _write_loose(repo_root, _oid(data), data) stats = storage_stats(repo_root) assert stats.total == stats.hot + stats.warm # ── Tier 6: auto_pack packs + prunes at threshold ──────────────────────────── class TestAutoPack: def test_packs_and_prunes_when_loose_meets_threshold(self, tmp_path: Path) -> None: from musehub.storage.tiers import auto_pack, storage_stats repo_root = _repo_root(tmp_path) n = 10 for i in range(n): data = f"auto-pack-{i}".encode() _write_loose(repo_root, _oid(data), data) result = auto_pack(repo_root, min_loose=5) assert result.triggered is True assert result.packed == n stats = storage_stats(repo_root) assert stats.hot == 0 assert stats.warm == n def test_skips_when_loose_below_threshold(self, tmp_path: Path) -> None: from musehub.storage.tiers import auto_pack repo_root = _repo_root(tmp_path) for i in range(3): data = f"few-{i}".encode() _write_loose(repo_root, _oid(data), data) result = auto_pack(repo_root, min_loose=10) assert result.triggered is False assert result.packed == 0 assert _count_loose(repo_root) == 3 # unchanged def test_skips_when_no_loose_objects(self, tmp_path: Path) -> None: from musehub.storage.tiers import auto_pack repo_root = _repo_root(tmp_path) result = auto_pack(repo_root, min_loose=1) assert result.triggered is False assert result.packed == 0 def test_exact_threshold_triggers(self, tmp_path: Path) -> None: from musehub.storage.tiers import auto_pack repo_root = _repo_root(tmp_path) n = 5 for i in range(n): data = f"exact-{i}".encode() _write_loose(repo_root, _oid(data), data) result = auto_pack(repo_root, min_loose=n) assert result.triggered is True # ── Tier 7: full promotion workflow ────────────────────────────────────────── class TestFullPromotion: @pytest.mark.asyncio async def test_objects_readable_through_all_lifecycle_stages( self, tmp_path: Path ) -> None: """Write → pack → prune → all objects still readable via backend.""" from musehub.storage.backends import LocalBackend from musehub.storage.pack import pack_loose_objects from musehub.storage.tiers import prune_packed, classify_object, StorageTier repo_root = _repo_root(tmp_path) backend = LocalBackend() # Stage 1: write 15 objects as loose (HOT) objects: dict[str, bytes] = {} for i in range(15): content = f"lifecycle-{i}-{'x' * 40}".encode() oid = _oid(content) await backend.put(oid, content, repo_root=repo_root) objects[oid] = content assert classify_object(repo_root, oid) is StorageTier.HOT # Stage 2: pack — objects still loose (HOT), also in pack (WARM) pack_loose_objects(repo_root) for oid in objects: assert classify_object(repo_root, oid) is StorageTier.HOT # loose wins # Stage 3: prune — objects move to WARM prune_packed(repo_root) for oid in objects: assert classify_object(repo_root, oid) is StorageTier.WARM # Stage 4: all still readable through backend after prune for oid, content in objects.items(): result = await backend.get(oid, repo_root=repo_root) assert result == content, f"object {oid[:20]}... lost after promotion" @pytest.mark.asyncio async def test_new_objects_written_after_prune_are_hot( self, tmp_path: Path ) -> None: """Objects written after a prune cycle start in the HOT tier.""" from musehub.storage.backends import LocalBackend from musehub.storage.pack import pack_loose_objects from musehub.storage.tiers import prune_packed, classify_object, StorageTier repo_root = _repo_root(tmp_path) backend = LocalBackend() # Full cycle on first batch old_content = b"old-batch" old_oid = _oid(old_content) await backend.put(old_oid, old_content, repo_root=repo_root) pack_loose_objects(repo_root) prune_packed(repo_root) assert classify_object(repo_root, old_oid) is StorageTier.WARM # New object — starts HOT new_content = b"fresh-after-prune" new_oid = _oid(new_content) await backend.put(new_oid, new_content, repo_root=repo_root) assert classify_object(repo_root, new_oid) is StorageTier.HOT assert await backend.get(new_oid, repo_root=repo_root) == new_content assert await backend.get(old_oid, repo_root=repo_root) == old_content