gabriel / musehub public
test_fetch_mpack_cleanup.py python
149 lines 7.3 KB
Raw
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor ⚠ breaking 19 days ago
1 """TDD — fetch-mpack cache cleanup via GC (issue #47, redesigned by fetch-mpack-cache).
2
3 Issue #47 originally modeled the presigned mpack as a *transient* artifact that
4 ``wire_fetch_mpack`` deleted itself after ``ttl_seconds``. The fetch-mpack-cache
5 feature inverted that contract: the mpack is now a *cached, reusable* artifact —
6 one row per ``(repo, tip)`` in ``musehub_fetch_mpack_cache``, kept until
7 ``expires_at`` — so fresh-clone requests (``want=[tip], have=[]``) hit a prebuilt
8 mpack instead of rebuilding it synchronously (which caused Cloudflare 524s on large
9 repos). A per-request delete would 404 every subsequent cache hit, so cleanup moved
10 to :func:`gc_fetch_mpack_cache`, which deletes expired rows and their R2 objects
11 after every GC run.
12
13 These tests verify that GC cleanup contract — the same guarantees the original
14 per-request tests protected, now at their real home:
15
16 BC0 An expired cache entry → ``backend.delete(mpack_id)`` is called and the row removed.
17 BC2 ``backend.delete`` raising does not propagate — GC is best-effort and still
18 removes the expired row (no orphaned cache rows).
19 BC3 Only expired entries are deleted (exactly once each); fresh entries survive.
20 """
21 from __future__ import annotations
22
23 from datetime import datetime, timedelta, timezone
24 from unittest.mock import AsyncMock
25
26 import pytest
27 from sqlalchemy import select
28 from sqlalchemy.ext.asyncio import AsyncSession
29
30 from muse.core.types import fake_id
31 from musehub.db.musehub_repo_models import MusehubFetchMPackCache
32 from tests.factories import create_repo
33
34
35 # ── helpers ───────────────────────────────────────────────────────────────────
36
37
38 def _stub_backend(monkeypatch: pytest.MonkeyPatch, *, delete_raises: bool = False) -> AsyncMock:
39 """Patch the storage backend that gc_fetch_mpack_cache resolves; return its mock."""
40 backend = AsyncMock()
41 backend.delete = AsyncMock(
42 side_effect=RuntimeError("MinIO unavailable") if delete_raises else None
43 )
44 monkeypatch.setattr("musehub.services.musehub_gc.get_backend", lambda: backend)
45 return backend
46
47
48 async def _insert_cache_row(
49 session: AsyncSession,
50 repo_id: str,
51 *,
52 mpack_id: str,
53 expired: bool,
54 seed: str,
55 ) -> None:
56 """Insert one musehub_fetch_mpack_cache row, expired (past) or fresh (future)."""
57 now = datetime.now(tz=timezone.utc)
58 session.add(
59 MusehubFetchMPackCache(
60 cache_id=fake_id(f"cache-{seed}"),
61 repo_id=repo_id,
62 tip_commit_id=fake_id(f"tip-{seed}"),
63 mpack_id=mpack_id,
64 created_at=now,
65 expires_at=now - timedelta(minutes=5) if expired else now + timedelta(days=7),
66 )
67 )
68 await session.commit()
69
70
71 async def _remaining_mpack_ids(session: AsyncSession, repo_id: str) -> list[str]:
72 rows = await session.execute(
73 select(MusehubFetchMPackCache.mpack_id).where(
74 MusehubFetchMPackCache.repo_id == repo_id
75 )
76 )
77 return list(rows.scalars().all())
78
79
80 # ══════════════════════════════════════════════════════════════════════════════
81 # BC0 — expired cache entry: backend.delete(mpack_id) called, row removed
82 # ══════════════════════════════════════════════════════════════════════════════
83
84 @pytest.mark.asyncio
85 async def test_bc0_expired_mpack_deleted_by_gc(
86 db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
87 ) -> None:
88 """gc_fetch_mpack_cache deletes the R2 object for an expired entry and drops the row."""
89 from musehub.services.musehub_gc import gc_fetch_mpack_cache
90
91 backend = _stub_backend(monkeypatch)
92 repo = await create_repo(db_session, owner="gabriel", visibility="public")
93 mpack_id = fake_id("mpack-bc0")
94 await _insert_cache_row(db_session, repo.repo_id, mpack_id=mpack_id, expired=True, seed="bc0")
95
96 deleted = await gc_fetch_mpack_cache(db_session, repo.repo_id)
97
98 assert deleted == 1
99 backend.delete.assert_awaited_once_with(mpack_id)
100 assert await _remaining_mpack_ids(db_session, repo.repo_id) == []
101
102
103 # ══════════════════════════════════════════════════════════════════════════════
104 # BC2 — backend.delete failure does not propagate (best-effort cleanup)
105 # ══════════════════════════════════════════════════════════════════════════════
106
107 @pytest.mark.asyncio
108 async def test_bc2_gc_delete_failure_is_swallowed(
109 db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
110 ) -> None:
111 """A backend.delete error must not crash GC, and the expired row is still removed."""
112 from musehub.services.musehub_gc import gc_fetch_mpack_cache
113
114 backend = _stub_backend(monkeypatch, delete_raises=True)
115 repo = await create_repo(db_session, owner="gabriel", visibility="public")
116 mpack_id = fake_id("mpack-bc2")
117 await _insert_cache_row(db_session, repo.repo_id, mpack_id=mpack_id, expired=True, seed="bc2")
118
119 # Must not raise even though backend.delete raises.
120 deleted = await gc_fetch_mpack_cache(db_session, repo.repo_id)
121
122 assert deleted == 1, "expired row removed even when the R2 delete fails"
123 backend.delete.assert_awaited_once()
124 assert await _remaining_mpack_ids(db_session, repo.repo_id) == []
125
126
127 # ══════════════════════════════════════════════════════════════════════════════
128 # BC3 — only expired entries deleted (once each); fresh entries survive
129 # ══════════════════════════════════════════════════════════════════════════════
130
131 @pytest.mark.asyncio
132 async def test_bc3_gc_deletes_only_expired_once(
133 db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
134 ) -> None:
135 """Exactly one delete for the expired entry; the fresh entry is left untouched."""
136 from musehub.services.musehub_gc import gc_fetch_mpack_cache
137
138 backend = _stub_backend(monkeypatch)
139 repo = await create_repo(db_session, owner="gabriel", visibility="public")
140 expired_mpack = fake_id("mpack-bc3-expired")
141 fresh_mpack = fake_id("mpack-bc3-fresh")
142 await _insert_cache_row(db_session, repo.repo_id, mpack_id=expired_mpack, expired=True, seed="bc3-exp")
143 await _insert_cache_row(db_session, repo.repo_id, mpack_id=fresh_mpack, expired=False, seed="bc3-fresh")
144
145 deleted = await gc_fetch_mpack_cache(db_session, repo.repo_id)
146
147 assert deleted == 1, "only the expired entry is GC'd"
148 backend.delete.assert_awaited_once_with(expired_mpack)
149 assert await _remaining_mpack_ids(db_session, repo.repo_id) == [fresh_mpack]
File History 1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 19 days ago