test_fetch_mpack_cleanup.py
python
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