gabriel / musehub public
test_mwp8_cache_invalidation.py python
1,125 lines 45.6 KB
Raw
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 9 days ago
1 """MWP-8 — fetch-mpack cache invalidation (musehub#111).
2
3 AUDIT VERDICT (Phase 0 — AU_01..AU_04):
4 ========================================
5 All three repair endpoints (wire_repair_object, wire_repair_snapshot,
6 wire_repair_commit) and the force-push path (wire_push_unpack_mpack with
7 force=True) share the same gap:
8
9 1. repair-object (AU_01) — wire_repair_object performs upsert + session.commit()
10 but NEVER touches musehub_fetch_mpack_cache. A fresh cache row for the tip
11 that reaches the repaired object survives unchanged. A subsequent fresh-clone
12 request (want=[tip], have=[]) returns a HIT on the pre-repair mpack_id.
13
14 2. repair-snapshot (AU_02) — wire_repair_snapshot resets manifest_blob +
15 session.commit() but NEVER touches musehub_fetch_mpack_cache. Same HIT
16 on stale bytes.
17
18 3. repair-commit (AU_03) — wire_repair_commit force-overwrites identity fields +
19 session.commit() but NEVER touches musehub_fetch_mpack_cache. Same HIT.
20 This is the exact mechanism behind the gabriel/muse rc10 incident: the
21 repair fixed the DB row but the cache kept serving the corrupted mpack for
22 7 more days.
23
24 4. force-push (AU_04) — wire_push_unpack_mpack(force=True) advances the
25 branch pointer and enqueues intel jobs but NEVER deletes the old-tip cache
26 row. A client that still knows the old tip gets a HIT on content that is
27 no longer on any branch.
28
29 Expected post-fix behavior (implemented in Phases 2–3):
30 • AU_01..AU_03 → after repair, wire_fetch_mpack(want=[tip], have=[]) raises
31 MPackNotReadyError (cache row deleted in same transaction as repair).
32 • AU_04 → after force-push, (repo_id, old_tip) row is gone; the same
33 fetch raises MPackNotReadyError.
34
35 Phase 2 (RP_04..RP_06) and Phase 3 (FP_02) replace these assertions with the
36 corrected contracts.
37
38 Run:
39 python3 -m pytest tests/test_mwp8_cache_invalidation.py -q --tb=short
40 """
41 from __future__ import annotations
42
43 import hashlib
44 from datetime import datetime, timedelta, timezone
45 from typing import Any
46 from unittest.mock import AsyncMock, patch
47
48 import pytest
49 from sqlalchemy import select
50 from sqlalchemy.ext.asyncio import AsyncSession
51
52 from muse.core.ids import hash_commit, long_id
53 from muse.core.mpack import build_wire_mpack
54 from muse.core.types import blob_id, fake_id
55 from musehub.db.musehub_repo_models import (
56 MusehubBranch,
57 MusehubCommit,
58 MusehubCommitRef,
59 MusehubFetchMPackCache,
60 MusehubObject,
61 MusehubObjectRef,
62 )
63 from musehub.db.musehub_jobs_models import MusehubBackgroundJob
64 from musehub.muse_cli.snapshot import compute_snapshot_id
65 from musehub.services.musehub_wire_fetch import wire_fetch_mpack
66 from musehub.services.musehub_wire_push import (
67 wire_push_unpack_mpack,
68 wire_repair_commit,
69 wire_repair_object,
70 wire_repair_snapshot,
71 )
72 from musehub.services.musehub_wire_shared import MPackNotReadyError
73 from tests.factories import create_branch, create_repo
74
75
76 # ── helpers ───────────────────────────────────────────────────────────────────
77
78
79 def _now() -> datetime:
80 return datetime.now(tz=timezone.utc)
81
82
83 async def _insert_fresh_cache_row(
84 session: AsyncSession,
85 repo_id: str,
86 tip_commit_id: str,
87 mpack_id: str,
88 seed: str,
89 ) -> MusehubFetchMPackCache:
90 """Seed a non-expired cache row (tip → mpack_id) into musehub_fetch_mpack_cache."""
91 now = _now()
92 row = MusehubFetchMPackCache(
93 cache_id=fake_id(f"cache-{seed}"),
94 repo_id=repo_id,
95 tip_commit_id=tip_commit_id,
96 mpack_id=mpack_id,
97 created_at=now,
98 expires_at=now + timedelta(days=7),
99 )
100 session.add(row)
101 await session.commit()
102 return row
103
104
105 async def _cache_rows_for_repo(
106 session: AsyncSession,
107 repo_id: str,
108 ) -> list[MusehubFetchMPackCache]:
109 """Return all cache rows for a given repo_id."""
110 rows = await session.execute(
111 select(MusehubFetchMPackCache).where(
112 MusehubFetchMPackCache.repo_id == repo_id
113 )
114 )
115 return list(rows.scalars().all())
116
117
118 def _stub_push_backend(monkeypatch: pytest.MonkeyPatch) -> AsyncMock:
119 """Patch the storage backend used by musehub_wire_push (repair calls backend.put)."""
120 backend = AsyncMock()
121 backend.put = AsyncMock(return_value="s3://test-bucket/object")
122 monkeypatch.setattr("musehub.services.musehub_wire_push.get_backend", lambda: backend)
123 return backend
124
125
126 def _stub_fetch_backend(monkeypatch: pytest.MonkeyPatch, presign_url: str = "https://r2.example/mpack") -> AsyncMock:
127 """Patch the storage backend used by musehub_wire_fetch (HIT calls backend.presign_mpack_get)."""
128 backend = AsyncMock()
129 backend.presign_mpack_get = AsyncMock(return_value=presign_url)
130 monkeypatch.setattr("musehub.services.musehub_wire_fetch.get_backend", lambda: backend)
131 return backend
132
133
134 def _stub_gc_backend(monkeypatch: pytest.MonkeyPatch) -> AsyncMock:
135 """Patch the storage backend used by musehub_gc (invalidate calls backend.delete)."""
136 backend = AsyncMock()
137 backend.delete = AsyncMock()
138 monkeypatch.setattr("musehub.services.musehub_gc.get_backend", lambda: backend)
139 return backend
140
141
142 def _build_force_push_backend(
143 monkeypatch: pytest.MonkeyPatch,
144 mpack_bytes: bytes,
145 ) -> AsyncMock:
146 """Patch the storage backend used inside wire_push_unpack_mpack.
147
148 wire_push_unpack_mpack imports get_backend locally from musehub.storage.backends,
149 so we patch that site (not the module-level musehub.services.musehub_wire_push one).
150 The returned mock has get_mpack pre-configured to return mpack_bytes.
151 """
152 backend = AsyncMock()
153 backend.get_mpack = AsyncMock(return_value=mpack_bytes)
154 backend.quarantine_mpack = AsyncMock()
155 backend.delete = AsyncMock()
156 monkeypatch.setattr("musehub.storage.backends.get_backend", lambda: backend)
157 return backend
158
159
160 async def _insert_object_row(
161 session: AsyncSession,
162 repo_id: str,
163 object_id: str,
164 ) -> None:
165 """Insert a minimal MusehubObject + ref so wire_repair_object can upsert it."""
166 from sqlalchemy.dialects.postgresql import insert as _pg_insert
167 await session.execute(
168 _pg_insert(MusehubObject)
169 .values(object_id=object_id, path="test.bin", size_bytes=0, storage_uri="s3://bucket/x", content_cache=None)
170 .on_conflict_do_nothing(index_elements=["object_id"])
171 )
172 await session.execute(
173 _pg_insert(MusehubObjectRef)
174 .values(repo_id=repo_id, object_id=object_id)
175 .on_conflict_do_nothing(index_elements=["repo_id", "object_id"])
176 )
177 await session.commit()
178
179
180 def _build_valid_commit_dict(
181 *,
182 snapshot_id: str,
183 message: str,
184 committed_at: str,
185 author: str = "gabriel",
186 ) -> tuple[str, dict[str, Any]]:
187 """Compute a canonical (commit_id, wire_dict) pair that passes wire_repair_commit's hash check."""
188 commit_id = hash_commit(
189 parent_ids=[],
190 snapshot_id=snapshot_id,
191 message=message,
192 committed_at_iso=committed_at,
193 author=author,
194 signer_public_key="",
195 )
196 wire_dict = {
197 "commit_id": commit_id,
198 "branch": "main",
199 "snapshot_id": snapshot_id,
200 "message": message,
201 "committed_at": committed_at,
202 "parent_commit_id": None,
203 "parent2_commit_id": None,
204 "author": author,
205 "signer_public_key": "",
206 "signature": "",
207 "signer_key_id": "",
208 }
209 return commit_id, wire_dict
210
211
212 async def _insert_commit_row(
213 session: AsyncSession,
214 repo_id: str,
215 commit_id: str,
216 *,
217 snapshot_id: str,
218 message: str,
219 committed_at: str,
220 author: str = "gabriel",
221 ) -> MusehubCommit:
222 """Insert a MusehubCommit + MusehubCommitRef row that wire_repair_commit can look up."""
223 ts = datetime.fromisoformat(committed_at.replace("Z", "+00:00"))
224 row = MusehubCommit(
225 commit_id=commit_id,
226 message=message,
227 author=author,
228 branch="main",
229 parent_ids=[],
230 snapshot_id=snapshot_id,
231 timestamp=ts,
232 signer_public_key="",
233 signature="",
234 signer_key_id="",
235 )
236 session.add(row)
237 session.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
238 await session.commit()
239 return row
240
241
242 # ══════════════════════════════════════════════════════════════════════════════
243 # RP_04 — repair-object busts cache (post-fix contract, converts AU_01)
244 # ══════════════════════════════════════════════════════════════════════════════
245
246
247 @pytest.mark.asyncio
248 @pytest.mark.tier2
249 async def test_RP_04_repair_object_busts_cache(
250 db_session: AsyncSession,
251 monkeypatch: pytest.MonkeyPatch,
252 ) -> None:
253 """RP_04 — wire_repair_object invalidates the cache in the same transaction.
254
255 After repair, wire_fetch_mpack(want=[T], have=[]) raises MPackNotReadyError
256 (the cache row is gone — the next clone will MISS-rebuild from repaired rows).
257 Row count for repo_id is 0.
258
259 Converts AU_01 (red-as-bug) to the post-fix correctness contract.
260 """
261 content = b"rp04-test-object-mwp8"
262 object_id = blob_id(content)
263 tip_T = fake_id("tip-rp04")
264 mpack_M = fake_id("mpack-rp04")
265
266 repo = await create_repo(db_session, owner="gabriel", visibility="public")
267 await _insert_object_row(db_session, repo.repo_id, object_id)
268 await _insert_fresh_cache_row(db_session, repo.repo_id, tip_T, mpack_M, seed="rp04")
269
270 _stub_push_backend(monkeypatch)
271 _stub_gc_backend(monkeypatch)
272
273 result = await wire_repair_object(
274 db_session, repo.repo_id, object_id, content, caller_id="gabriel"
275 )
276 assert result["repaired"] is True
277
278 # Post-fix: cache row is gone — fetch raises MPackNotReadyError (MISS)
279 with pytest.raises(MPackNotReadyError):
280 await wire_fetch_mpack(
281 db_session, repo.repo_id, want=[tip_T], have=[], force_build=False
282 )
283
284 remaining = await _cache_rows_for_repo(db_session, repo.repo_id)
285 assert len(remaining) == 0, "cache must be fully busted after repair-object"
286
287
288 # ══════════════════════════════════════════════════════════════════════════════
289 # RP_05 — repair-snapshot busts cache (post-fix contract, converts AU_02)
290 # ══════════════════════════════════════════════════════════════════════════════
291
292
293 @pytest.mark.asyncio
294 @pytest.mark.tier2
295 async def test_RP_05_repair_snapshot_busts_cache(
296 db_session: AsyncSession,
297 monkeypatch: pytest.MonkeyPatch,
298 ) -> None:
299 """RP_05 — wire_repair_snapshot invalidates the cache in the same transaction.
300
301 Converts AU_02 (red-as-bug) to the post-fix correctness contract.
302 """
303 manifest: dict[str, str] = {"file.txt": fake_id("obj-rp05")}
304 directories: list[str] = []
305 snapshot_id = compute_snapshot_id(manifest, directories)
306 tip_T = fake_id("tip-rp05")
307 mpack_M = fake_id("mpack-rp05")
308
309 repo = await create_repo(db_session, owner="gabriel", visibility="public")
310 await _insert_fresh_cache_row(db_session, repo.repo_id, tip_T, mpack_M, seed="rp05")
311
312 _stub_gc_backend(monkeypatch)
313
314 result = await wire_repair_snapshot(
315 db_session, repo.repo_id, snapshot_id, manifest, directories,
316 caller_id="gabriel",
317 )
318 assert result["repaired"] is True
319
320 with pytest.raises(MPackNotReadyError):
321 await wire_fetch_mpack(
322 db_session, repo.repo_id, want=[tip_T], have=[], force_build=False
323 )
324
325 remaining = await _cache_rows_for_repo(db_session, repo.repo_id)
326 assert len(remaining) == 0, "cache must be fully busted after repair-snapshot"
327
328
329 # ══════════════════════════════════════════════════════════════════════════════
330 # RP_06 — repair-commit busts cache (post-fix contract, converts AU_03)
331 # ══════════════════════════════════════════════════════════════════════════════
332
333
334 @pytest.mark.asyncio
335 @pytest.mark.tier2
336 async def test_RP_06_repair_commit_busts_cache(
337 db_session: AsyncSession,
338 monkeypatch: pytest.MonkeyPatch,
339 ) -> None:
340 """RP_06 — wire_repair_commit invalidates the cache in the same transaction.
341
342 This is the direct fix for the gabriel/muse rc10 incident: repair-commit now
343 busts the cache so the next clone rebuilds from the corrected commit row.
344
345 Converts AU_03 (red-as-bug) to the post-fix correctness contract.
346 """
347 committed_at = "2026-01-01T00:00:00+00:00"
348 snapshot_id = fake_id("snap-rp06")
349 message = "rp06 post-fix repair-commit test"
350 commit_id, wire_dict = _build_valid_commit_dict(
351 snapshot_id=snapshot_id,
352 message=message,
353 committed_at=committed_at,
354 )
355
356 tip_T = commit_id
357 mpack_M = fake_id("mpack-rp06")
358
359 repo = await create_repo(db_session, owner="gabriel", visibility="public")
360 await _insert_commit_row(
361 db_session, repo.repo_id, commit_id,
362 snapshot_id=snapshot_id,
363 message=message,
364 committed_at=committed_at,
365 )
366 await _insert_fresh_cache_row(db_session, repo.repo_id, tip_T, mpack_M, seed="rp06")
367
368 _stub_gc_backend(monkeypatch)
369
370 result = await wire_repair_commit(
371 db_session, repo.repo_id, wire_dict, caller_id="gabriel"
372 )
373 assert result["repaired"] is True
374
375 with pytest.raises(MPackNotReadyError):
376 await wire_fetch_mpack(
377 db_session, repo.repo_id, want=[tip_T], have=[], force_build=False
378 )
379
380 remaining = await _cache_rows_for_repo(db_session, repo.repo_id)
381 assert len(remaining) == 0, "cache must be fully busted after repair-commit"
382
383
384 # ══════════════════════════════════════════════════════════════════════════════
385 # RP_07 — atomicity guard: failed repair commit rolls back the invalidation
386 # ══════════════════════════════════════════════════════════════════════════════
387
388
389 @pytest.mark.asyncio
390 @pytest.mark.tier2
391 async def test_RP_07_repair_rollback_keeps_cache(
392 db_session: AsyncSession,
393 monkeypatch: pytest.MonkeyPatch,
394 ) -> None:
395 """RP_07 — if the repair's session.commit() fails, the invalidation rolls back too.
396
397 No half-state: the cache row must survive a repair that fails to commit,
398 because both the repair write and the cache deletion are in the same transaction
399 (Design Decision D2).
400
401 This prevents a scenario where:
402 - invalidate_fetch_mpack_cache deletes the row (no commit)
403 - session.commit() fails (e.g. constraint violation, DB error)
404 - the repair is rejected — but the cache is nuked → next clone is a MISS
405 on an un-repaired repo, hiding the failure from the operator.
406
407 Uses wire_repair_object as the representative trigger.
408 """
409 content = b"rp07-atomicity-guard-mwp8"
410 object_id = blob_id(content)
411 tip_T = fake_id("tip-rp07")
412 mpack_M = fake_id("mpack-rp07")
413
414 repo = await create_repo(db_session, owner="gabriel", visibility="public")
415 repo_id = repo.repo_id # capture before any potential rollback
416 await _insert_object_row(db_session, repo_id, object_id)
417 await _insert_fresh_cache_row(db_session, repo_id, tip_T, mpack_M, seed="rp07")
418
419 _stub_push_backend(monkeypatch)
420 _stub_gc_backend(monkeypatch)
421
422 # Patch session.commit to raise after setup is complete — simulates a DB
423 # commit failure (e.g. a concurrent constraint violation).
424 original_commit = db_session.commit
425
426 commit_calls = 0
427
428 async def failing_commit() -> None:
429 nonlocal commit_calls
430 commit_calls += 1
431 raise RuntimeError("RP_07: simulated commit failure")
432
433 monkeypatch.setattr(db_session, "commit", failing_commit)
434
435 with pytest.raises(RuntimeError, match="RP_07"):
436 await wire_repair_object(
437 db_session, repo_id, object_id, content, caller_id="gabriel"
438 )
439
440 # Restore commit so we can rollback and then query
441 monkeypatch.setattr(db_session, "commit", original_commit)
442 await db_session.rollback()
443
444 # The cache row must still exist — invalidation was not committed
445 remaining = await _cache_rows_for_repo(db_session, repo_id)
446 assert len(remaining) == 1, (
447 "cache row must survive a repair whose commit failed — "
448 "invalidation must be in the same transaction (D2)"
449 )
450
451
452 # ══════════════════════════════════════════════════════════════════════════════
453 # FP_02 — force-push busts old + new tip rows (post-fix, converts AU_04)
454 # ══════════════════════════════════════════════════════════════════════════════
455
456
457 @pytest.mark.asyncio
458 @pytest.mark.tier2
459 async def test_FP_02_force_push_busts_old_and_new_tip(
460 db_session: AsyncSession,
461 monkeypatch: pytest.MonkeyPatch,
462 ) -> None:
463 """FP_02 — force-push deletes cache rows for both the old and new tip (FP_01 wiring).
464
465 Scenario:
466 - Branch main currently points to T_old; a fresh cache row exists for T_old.
467 - A cache row for T_new also exists (e.g. T_new was once a prior branch head —
468 the hazard described in Background 'Trigger 2, hazard 2').
469 - Force-push main → T_new.
470
471 Post-fix contract:
472 - Both (repo_id, T_old) and (repo_id, T_new) rows are deleted in the same
473 transaction as the branch advance (Design Decision D1 tip-scoped, D2 same-tx).
474 - A subsequent clone of T_old raises MPackNotReadyError (MISS).
475
476 Converts AU_04 (red-as-bug) to the post-fix correctness contract.
477 """
478 from musehub.core.genesis import compute_branch_id
479
480 mpack_bytes = build_wire_mpack({"objects": [], "commits": [], "snapshots": []})
481 mpack_key = blob_id(mpack_bytes)
482
483 T_old = fake_id("tip-old-fp02")
484 T_new = fake_id("tip-new-fp02")
485 mpack_old = fake_id("mpack-fp02-old")
486 mpack_new_cached = fake_id("mpack-fp02-new-cached")
487
488 repo = await create_repo(db_session, owner="gabriel", visibility="public")
489 repo_id = repo.repo_id
490
491 db_session.add(MusehubBranch(
492 branch_id=compute_branch_id(repo_id, "main"),
493 repo_id=repo_id,
494 name="main",
495 head_commit_id=T_old,
496 ))
497 await db_session.commit()
498
499 # Seed cache rows for both tips (old + new)
500 await _insert_fresh_cache_row(db_session, repo_id, T_old, mpack_old, seed="fp02-old")
501 await _insert_fresh_cache_row(db_session, repo_id, T_new, mpack_new_cached, seed="fp02-new")
502
503 _build_force_push_backend(monkeypatch, mpack_bytes)
504 _stub_gc_backend(monkeypatch)
505
506 await wire_push_unpack_mpack(
507 db_session,
508 repo_id,
509 mpack_key=mpack_key,
510 pusher_id="gabriel",
511 branch="main",
512 head_commit_id=T_new,
513 force=True,
514 )
515
516 remaining = await _cache_rows_for_repo(db_session, repo_id)
517 remaining_tips = {r.tip_commit_id for r in remaining}
518 assert T_old not in remaining_tips, "(repo_id, T_old) must be gone after force-push"
519 assert T_new not in remaining_tips, "(repo_id, T_new) must be gone after force-push"
520
521 # A clone of T_old is now a MISS
522 with pytest.raises(MPackNotReadyError):
523 await wire_fetch_mpack(db_session, repo_id, want=[T_old], have=[], force_build=False)
524
525
526 # ══════════════════════════════════════════════════════════════════════════════
527 # FP_03 — normal (non-force) push does NOT invalidate sibling cache rows
528 # ══════════════════════════════════════════════════════════════════════════════
529
530
531 @pytest.mark.asyncio
532 @pytest.mark.tier2
533 async def test_FP_03_normal_push_does_not_invalidate(
534 db_session: AsyncSession,
535 monkeypatch: pytest.MonkeyPatch,
536 ) -> None:
537 """FP_03 — a non-force push must not invalidate sibling branch cache rows (Goal #3 / D3).
538
539 Scenario:
540 - Repo has a 'dev' branch tip (T_dev) with a fresh cache row — the sibling.
541 - 'main' branch does not yet exist (first push to it).
542 - Push main → T_main (force=False, new branch creation).
543
544 With current_head = None (new branch), the invalidation guard
545 `force and current_head and current_head != tip` is False on two counts
546 (force=False, current_head=None). The sibling T_dev row must survive intact.
547
548 This asserts Design Decision D3: only force-push triggers invalidation; the
549 common fast-forward path pays zero invalidation cost.
550 """
551 mpack_bytes = build_wire_mpack({"objects": [], "commits": [], "snapshots": []})
552 mpack_key = blob_id(mpack_bytes)
553
554 T_dev = fake_id("tip-dev-fp03")
555 T_main = fake_id("tip-main-fp03")
556 mpack_dev = fake_id("mpack-dev-fp03")
557
558 repo = await create_repo(db_session, owner="gabriel", visibility="public")
559 repo_id = repo.repo_id
560
561 # Seed the sibling dev branch cache row (no branch row needed — the cache
562 # row tests whether the sibling survives the push to main)
563 await _insert_fresh_cache_row(db_session, repo_id, T_dev, mpack_dev, seed="fp03-dev")
564
565 _build_force_push_backend(monkeypatch, mpack_bytes)
566 _stub_gc_backend(monkeypatch)
567
568 # Non-force push creating a new 'main' branch — current_head is None
569 await wire_push_unpack_mpack(
570 db_session,
571 repo_id,
572 mpack_key=mpack_key,
573 pusher_id="gabriel",
574 branch="main",
575 head_commit_id=T_main,
576 force=False,
577 )
578
579 remaining = await _cache_rows_for_repo(db_session, repo_id)
580 remaining_tips = {r.tip_commit_id for r in remaining}
581 assert T_dev in remaining_tips, "sibling dev cache row must survive a non-force push"
582
583
584 # ══════════════════════════════════════════════════════════════════════════════
585 # FP_04 — force-push still enqueues a prebuild for T_new (loop closed)
586 # ══════════════════════════════════════════════════════════════════════════════
587
588
589 @pytest.mark.asyncio
590 @pytest.mark.tier2
591 async def test_FP_04_force_push_enqueues_prebuild_for_new_tip(
592 db_session: AsyncSession,
593 monkeypatch: pytest.MonkeyPatch,
594 ) -> None:
595 """FP_04 — after force-push, a fetch.mpack.prebuild job is enqueued for T_new.
596
597 The invalidation (FP_01) deletes old cache rows; the existing enqueue_push_intel
598 path (unchanged by this ticket) then repopulates T_new with a fresh mpack.
599
600 This test verifies the loop is still closed — force-push = delete stale rows +
601 enqueue rebuild — so the next clone is a fast HIT on correct content, not a slow
602 synchronous MISS-build or a stale HIT.
603
604 Asserts Design Decision D4: correctness via deletion; speed via prebuild.
605 """
606 from musehub.db.musehub_jobs_models import MusehubBackgroundJob
607 from musehub.core.genesis import compute_branch_id
608
609 mpack_bytes = build_wire_mpack({"objects": [], "commits": [], "snapshots": []})
610 mpack_key = blob_id(mpack_bytes)
611
612 T_old = fake_id("tip-old-fp04")
613 T_new = fake_id("tip-new-fp04")
614 mpack_old = fake_id("mpack-fp04-old")
615
616 repo = await create_repo(db_session, owner="gabriel", visibility="public")
617 repo_id = repo.repo_id
618
619 db_session.add(MusehubBranch(
620 branch_id=compute_branch_id(repo_id, "main"),
621 repo_id=repo_id,
622 name="main",
623 head_commit_id=T_old,
624 ))
625 await db_session.commit()
626
627 await _insert_fresh_cache_row(db_session, repo_id, T_old, mpack_old, seed="fp04-old")
628
629 _build_force_push_backend(monkeypatch, mpack_bytes)
630 _stub_gc_backend(monkeypatch)
631
632 await wire_push_unpack_mpack(
633 db_session,
634 repo_id,
635 mpack_key=mpack_key,
636 pusher_id="gabriel",
637 branch="main",
638 head_commit_id=T_new,
639 force=True,
640 )
641
642 # A fetch.mpack.prebuild job must exist for the repo (enqueued by enqueue_push_intel)
643 job_rows = (await db_session.execute(
644 select(MusehubBackgroundJob).where(
645 MusehubBackgroundJob.job_type == "fetch.mpack.prebuild",
646 MusehubBackgroundJob.repo_id == repo_id,
647 )
648 )).scalars().all()
649 assert len(job_rows) >= 1, (
650 "force-push must enqueue a fetch.mpack.prebuild job so T_new gets a fresh "
651 "cache row after invalidation (D4 — correctness via deletion, speed via prebuild)"
652 )
653
654
655 # ══════════════════════════════════════════════════════════════════════════════
656 # Phase 1 — The invalidation primitive (IV_01..IV_06)
657 # ══════════════════════════════════════════════════════════════════════════════
658
659
660 # ── IV_01 ─────────────────────────────────────────────────────────────────────
661
662
663 @pytest.mark.asyncio
664 @pytest.mark.tier2
665 async def test_IV_01_invalidate_all_deletes_every_row_for_repo(
666 db_session: AsyncSession,
667 monkeypatch: pytest.MonkeyPatch,
668 ) -> None:
669 """IV_01 — repo-wide invalidation deletes all rows for target repo; sibling repo untouched.
670
671 Seed 3 rows for repo A (mix of fresh + expired) and 2 rows for repo B.
672 invalidate_fetch_mpack_cache(session, A) must return 3, leave A with 0 rows,
673 and leave B's 2 rows intact (cross-repo isolation).
674 """
675 from musehub.services.musehub_gc import invalidate_fetch_mpack_cache
676
677 _stub_push_backend(monkeypatch)
678
679 repo_a = await create_repo(db_session, owner="gabriel", visibility="public")
680 repo_b = await create_repo(db_session, owner="gabriel", visibility="public")
681
682 # 3 rows for A — mix of fresh and expired
683 for seed in ("iv01-a1", "iv01-a2", "iv01-a3"):
684 expired = seed.endswith("a3")
685 now = _now()
686 db_session.add(MusehubFetchMPackCache(
687 cache_id=fake_id(f"cache-{seed}"),
688 repo_id=repo_a.repo_id,
689 tip_commit_id=fake_id(f"tip-{seed}"),
690 mpack_id=fake_id(f"mpack-{seed}"),
691 created_at=now,
692 expires_at=now - timedelta(minutes=5) if expired else now + timedelta(days=7),
693 ))
694 await db_session.commit()
695
696 # 2 rows for B — all fresh
697 for seed in ("iv01-b1", "iv01-b2"):
698 await _insert_fresh_cache_row(db_session, repo_b.repo_id, fake_id(f"tip-{seed}"), fake_id(f"mpack-{seed}"), seed=seed)
699
700 deleted = await invalidate_fetch_mpack_cache(db_session, repo_a.repo_id)
701 await db_session.commit()
702
703 assert deleted == 3, f"expected 3 deleted, got {deleted}"
704 assert len(await _cache_rows_for_repo(db_session, repo_a.repo_id)) == 0, "repo A must have 0 rows"
705 assert len(await _cache_rows_for_repo(db_session, repo_b.repo_id)) == 2, "repo B must be untouched"
706
707
708 # ── IV_02 ─────────────────────────────────────────────────────────────────────
709
710
711 @pytest.mark.asyncio
712 @pytest.mark.tier2
713 async def test_IV_02_invalidate_deletes_regardless_of_expiry(
714 db_session: AsyncSession,
715 monkeypatch: pytest.MonkeyPatch,
716 ) -> None:
717 """IV_02 — invalidate deletes fresh (non-expired) rows; gc_fetch_mpack_cache does not.
718
719 This is the key behavioral difference: gc only removes expired rows, invalidate
720 removes regardless of expires_at.
721 """
722 from musehub.services.musehub_gc import gc_fetch_mpack_cache, invalidate_fetch_mpack_cache
723
724 _stub_push_backend(monkeypatch)
725
726 repo = await create_repo(db_session, owner="gabriel", visibility="public")
727 tip = fake_id("tip-iv02")
728 mpack_id = fake_id("mpack-iv02")
729
730 # Insert a FRESH (non-expired) row
731 now = _now()
732 db_session.add(MusehubFetchMPackCache(
733 cache_id=fake_id("cache-iv02"),
734 repo_id=repo.repo_id,
735 tip_commit_id=tip,
736 mpack_id=mpack_id,
737 created_at=now,
738 expires_at=now + timedelta(days=7),
739 ))
740 await db_session.commit()
741
742 # gc_fetch_mpack_cache must NOT delete the fresh row
743 gc_deleted = await gc_fetch_mpack_cache(db_session, repo.repo_id)
744 assert gc_deleted == 0, "gc must not delete fresh rows"
745 assert len(await _cache_rows_for_repo(db_session, repo.repo_id)) == 1, "row must survive gc"
746
747 # invalidate_fetch_mpack_cache MUST delete the fresh row
748 inv_deleted = await invalidate_fetch_mpack_cache(db_session, repo.repo_id)
749 await db_session.commit()
750
751 assert inv_deleted == 1, "invalidate must delete regardless of expiry"
752 assert len(await _cache_rows_for_repo(db_session, repo.repo_id)) == 0, "row must be gone after invalidate"
753
754
755 # ── IV_03 ─────────────────────────────────────────────────────────────────────
756
757
758 @pytest.mark.asyncio
759 @pytest.mark.tier2
760 async def test_IV_03_invalidate_tip_scoped_leaves_siblings(
761 db_session: AsyncSession,
762 monkeypatch: pytest.MonkeyPatch,
763 ) -> None:
764 """IV_03 — tip-scoped invalidation deletes only the named tips; siblings survive.
765
766 Seed rows for tips [X, Y, Z]. invalidate(..., tips=[X]) must delete only X;
767 Y and Z must be untouched.
768 """
769 from musehub.services.musehub_gc import invalidate_fetch_mpack_cache
770
771 _stub_push_backend(monkeypatch)
772
773 repo = await create_repo(db_session, owner="gabriel", visibility="public")
774 tip_x = fake_id("tip-x-iv03")
775 tip_y = fake_id("tip-y-iv03")
776 tip_z = fake_id("tip-z-iv03")
777
778 for tip, seed in ((tip_x, "iv03-x"), (tip_y, "iv03-y"), (tip_z, "iv03-z")):
779 await _insert_fresh_cache_row(db_session, repo.repo_id, tip, fake_id(f"mpack-{seed}"), seed=seed)
780
781 deleted = await invalidate_fetch_mpack_cache(db_session, repo.repo_id, tips=[tip_x])
782 await db_session.commit()
783
784 assert deleted == 1, "only 1 row (X) must be deleted"
785 remaining = await _cache_rows_for_repo(db_session, repo.repo_id)
786 remaining_tips = {r.tip_commit_id for r in remaining}
787 assert tip_x not in remaining_tips, "X must be gone"
788 assert tip_y in remaining_tips, "Y must survive"
789 assert tip_z in remaining_tips, "Z must survive"
790
791
792 # ── IV_04 ─────────────────────────────────────────────────────────────────────
793
794
795 @pytest.mark.asyncio
796 @pytest.mark.tier2
797 async def test_IV_04_invalidate_best_effort_r2_delete_swallows_failure(
798 db_session: AsyncSession,
799 monkeypatch: pytest.MonkeyPatch,
800 ) -> None:
801 """IV_04 — backend.delete raising must not propagate; DB row still deleted.
802
803 Mirrors the best-effort pattern from test_bc2_gc_delete_failure_is_swallowed.
804 R2 unavailability must not block cache invalidation — the DB row is the source
805 of truth for the HIT gate, not the R2 object.
806 """
807 from musehub.services.musehub_gc import invalidate_fetch_mpack_cache
808
809 backend = AsyncMock()
810 backend.delete = AsyncMock(side_effect=RuntimeError("R2 unavailable"))
811 monkeypatch.setattr("musehub.services.musehub_gc.get_backend", lambda: backend)
812
813 repo = await create_repo(db_session, owner="gabriel", visibility="public")
814 await _insert_fresh_cache_row(db_session, repo.repo_id, fake_id("tip-iv04"), fake_id("mpack-iv04"), seed="iv04")
815
816 # Must not raise even though backend.delete raises
817 deleted = await invalidate_fetch_mpack_cache(db_session, repo.repo_id)
818 await db_session.commit()
819
820 assert deleted == 1, "row must be deleted from DB even when R2 delete fails"
821 backend.delete.assert_awaited_once()
822 assert len(await _cache_rows_for_repo(db_session, repo.repo_id)) == 0, "DB row must be gone"
823
824
825 # ── IV_05 ─────────────────────────────────────────────────────────────────────
826
827
828 @pytest.mark.asyncio
829 @pytest.mark.tier2
830 async def test_IV_05_invalidate_does_not_commit(
831 db_session: AsyncSession,
832 monkeypatch: pytest.MonkeyPatch,
833 ) -> None:
834 """IV_05 — invalidate_fetch_mpack_cache issues no COMMIT (Design Decision D2).
835
836 The caller owns the transaction. Rows deleted within the open transaction
837 must be invisible after a session.rollback(), proving the deletion was not
838 committed by the primitive itself.
839 """
840 from musehub.services.musehub_gc import invalidate_fetch_mpack_cache
841
842 _stub_push_backend(monkeypatch)
843
844 repo = await create_repo(db_session, owner="gabriel", visibility="public")
845 repo_id = repo.repo_id # capture before rollback; ORM objects expire on rollback
846 await _insert_fresh_cache_row(db_session, repo_id, fake_id("tip-iv05"), fake_id("mpack-iv05"), seed="iv05")
847
848 # Rows are visible before invalidation
849 assert len(await _cache_rows_for_repo(db_session, repo_id)) == 1
850
851 deleted = await invalidate_fetch_mpack_cache(db_session, repo_id)
852 assert deleted == 1
853
854 # Within the open transaction, the row appears deleted
855 assert len(await _cache_rows_for_repo(db_session, repo_id)) == 0, "row deleted within transaction"
856
857 # Roll back — if the primitive had committed, this would be a no-op and the row would stay gone.
858 # If the primitive did NOT commit (correct), the row is restored.
859 await db_session.rollback()
860
861 assert len(await _cache_rows_for_repo(db_session, repo_id)) == 1, (
862 "row must be restored after rollback — invalidate must not commit (D2)"
863 )
864
865
866 # ── IV_06 ─────────────────────────────────────────────────────────────────────
867
868
869 @pytest.mark.asyncio
870 @pytest.mark.tier2
871 async def test_IV_06_invalidate_empty_repo_is_noop_returns_zero(
872 db_session: AsyncSession,
873 monkeypatch: pytest.MonkeyPatch,
874 ) -> None:
875 """IV_06 — invalidating a repo with no cache rows is a no-op returning 0."""
876 from musehub.services.musehub_gc import invalidate_fetch_mpack_cache
877
878 _stub_push_backend(monkeypatch)
879
880 repo = await create_repo(db_session, owner="gabriel", visibility="public")
881
882 deleted = await invalidate_fetch_mpack_cache(db_session, repo.repo_id)
883 await db_session.commit()
884
885 assert deleted == 0
886 assert len(await _cache_rows_for_repo(db_session, repo.repo_id)) == 0
887
888
889 # ══════════════════════════════════════════════════════════════════════════════
890 # Phase 4 — Optional prebuild-on-repair + E2E clone-after-repair (EX_01..EX_03)
891 # ══════════════════════════════════════════════════════════════════════════════
892
893
894 # ── EX_01 ─────────────────────────────────────────────────────────────────────
895
896
897 @pytest.mark.asyncio
898 @pytest.mark.tier2
899 async def test_EX_01_repair_enqueues_prebuild(
900 db_session: AsyncSession,
901 monkeypatch: pytest.MonkeyPatch,
902 ) -> None:
903 """EX_01 — each repair function enqueues a fetch.mpack.prebuild job after invalidation.
904
905 Closing the loop: after repair busts the cache, the worker rebuilds a fresh mpack
906 from the corrected rows so the NEXT clone is a fast HIT, not a slow synchronous
907 MISS-build (Design Decision D4 — correctness via deletion, speed via prebuild).
908
909 Uses wire_repair_commit as the representative repair trigger. The branch must exist
910 so _enqueue_repair_prebuild has branch heads to cover.
911 """
912 from musehub.core.genesis import compute_branch_id
913
914 committed_at = "2026-01-01T00:00:00+00:00"
915 snapshot_id = fake_id("snap-ex01")
916 message = "ex01 prebuild-on-repair test"
917 commit_id, wire_dict = _build_valid_commit_dict(
918 snapshot_id=snapshot_id,
919 message=message,
920 committed_at=committed_at,
921 )
922
923 repo = await create_repo(db_session, owner="gabriel", visibility="public")
924 repo_id = repo.repo_id
925
926 # Branch must exist so _enqueue_repair_prebuild finds a tip to cover
927 db_session.add(MusehubBranch(
928 branch_id=compute_branch_id(repo_id, "main"),
929 repo_id=repo_id,
930 name="main",
931 head_commit_id=commit_id,
932 ))
933 await _insert_commit_row(
934 db_session, repo_id, commit_id,
935 snapshot_id=snapshot_id,
936 message=message,
937 committed_at=committed_at,
938 )
939
940 _stub_gc_backend(monkeypatch)
941
942 result = await wire_repair_commit(db_session, repo_id, wire_dict, caller_id="gabriel")
943 assert result["repaired"] is True
944
945 # A fetch.mpack.prebuild job must be enqueued for the repo
946 job_rows = (await db_session.execute(
947 select(MusehubBackgroundJob).where(
948 MusehubBackgroundJob.job_type == "fetch.mpack.prebuild",
949 MusehubBackgroundJob.repo_id == repo_id,
950 )
951 )).scalars().all()
952 assert len(job_rows) >= 1, (
953 "repair must enqueue a fetch.mpack.prebuild job so the next clone "
954 "is a fast HIT on corrected content (EX_01 — D4)"
955 )
956
957 # The job payload must cover the branch tip
958 job = job_rows[0]
959 assert commit_id in job.payload.get("tip_commit_ids", []), (
960 "prebuild job payload must include the branch tip so the worker "
961 "rebuilds the mpack for the corrected content"
962 )
963
964
965 # ── EX_02 ─────────────────────────────────────────────────────────────────────
966
967
968 @pytest.mark.asyncio
969 @pytest.mark.tier2
970 @pytest.mark.wire
971 async def test_EX_02_clone_after_repair_reflects_repair(
972 db_session: AsyncSession,
973 monkeypatch: pytest.MonkeyPatch,
974 ) -> None:
975 """EX_02 — load-bearing E2E: repair busts cache; second clone sees corrected rows.
976
977 This is the regression test for the gabriel/muse rc10 incident:
978 - A push populates musehub_fetch_mpack_cache (simulated by seeding a fresh row).
979 - A first clone is a fast HIT returning mpack_M_stale.
980 - An operator calls repair-commit to fix a signer identity field.
981 - A second clone attempt raises MPackNotReadyError (MISS) — the stale mpack_M
982 is gone and the cache row was deleted in the same transaction as the repair.
983 - The corrected commit row is in the DB and will be served by the next rebuild.
984 - A fetch.mpack.prebuild job is enqueued so the rebuild happens before the
985 next real client request.
986
987 Combines RP_06 (repair-commit invalidates) + EX_01 (prebuild enqueued) into a
988 single narrative to prove the full chain is wired correctly.
989 """
990 from musehub.core.genesis import compute_branch_id
991
992 committed_at = "2026-01-15T12:00:00+00:00"
993 snapshot_id = fake_id("snap-ex02")
994 message = "ex02 e2e repair test"
995 commit_id, wire_dict = _build_valid_commit_dict(
996 snapshot_id=snapshot_id,
997 message=message,
998 committed_at=committed_at,
999 )
1000
1001 repo = await create_repo(db_session, owner="gabriel", visibility="public")
1002 repo_id = repo.repo_id
1003
1004 # Branch pointing to this commit (main tip = commit_id)
1005 db_session.add(MusehubBranch(
1006 branch_id=compute_branch_id(repo_id, "main"),
1007 repo_id=repo_id,
1008 name="main",
1009 head_commit_id=commit_id,
1010 ))
1011 await _insert_commit_row(
1012 db_session, repo_id, commit_id,
1013 snapshot_id=snapshot_id,
1014 message=message,
1015 committed_at=committed_at,
1016 )
1017
1018 # Seed the pre-repair mpack cache row (simulates state after a push + prebuild)
1019 mpack_stale = fake_id("mpack-stale-ex02")
1020 await _insert_fresh_cache_row(db_session, repo_id, commit_id, mpack_stale, seed="ex02")
1021
1022 _stub_gc_backend(monkeypatch)
1023 _stub_fetch_backend(monkeypatch, presign_url="https://r2.example/stale-mpack")
1024
1025 # ── First clone: cache HIT on pre-repair mpack ────────────────────────────
1026 first_clone = await wire_fetch_mpack(db_session, repo_id, want=[commit_id], have=[])
1027 assert first_clone["mpack_id"] == mpack_stale, "first clone must HIT the pre-repair mpack"
1028
1029 # ── Repair: fix the commit's signer field ─────────────────────────────────
1030 result = await wire_repair_commit(db_session, repo_id, wire_dict, caller_id="gabriel")
1031 assert result["repaired"] is True
1032
1033 # ── Second clone: MISS — cache was busted by the repair in the same tx ────
1034 with pytest.raises(MPackNotReadyError):
1035 await wire_fetch_mpack(db_session, repo_id, want=[commit_id], have=[], force_build=False)
1036
1037 # The stale cache row is gone
1038 remaining = await _cache_rows_for_repo(db_session, repo_id)
1039 assert len(remaining) == 0, "cache must be empty after repair"
1040
1041 # The corrected commit row is in the DB — next rebuild will serve correct bytes
1042 from sqlalchemy import select as _select
1043 from musehub.db.musehub_repo_models import MusehubCommit as _MC
1044 commit_row = (await db_session.execute(
1045 _select(_MC).where(_MC.commit_id == commit_id)
1046 )).scalar_one()
1047 assert commit_row.signer_public_key == wire_dict["signer_public_key"], (
1048 "the commit row must have the repaired signer_public_key — "
1049 "the next rebuild will serve this corrected content"
1050 )
1051
1052 # A prebuild job was enqueued to close the loop
1053 job_rows = (await db_session.execute(
1054 select(MusehubBackgroundJob).where(
1055 MusehubBackgroundJob.job_type == "fetch.mpack.prebuild",
1056 MusehubBackgroundJob.repo_id == repo_id,
1057 )
1058 )).scalars().all()
1059 assert len(job_rows) >= 1, "repair must enqueue a prebuild so the next clone is a fast HIT"
1060
1061
1062 # ── EX_03 ─────────────────────────────────────────────────────────────────────
1063
1064
1065 @pytest.mark.asyncio
1066 @pytest.mark.tier2
1067 async def test_EX_03_invalidation_is_logged(
1068 db_session: AsyncSession,
1069 monkeypatch: pytest.MonkeyPatch,
1070 caplog: pytest.LogCaptureFixture,
1071 ) -> None:
1072 """EX_03 — invalidation emits a log line naming repo, scope, and rows deleted.
1073
1074 Operators need visibility into when the cache was busted — the gabriel/muse rc10
1075 incident was invisible because there was no invalidation AND no log. This test
1076 asserts that after a repair trigger, a log record exists that an operator can
1077 grep for to confirm the bust happened.
1078
1079 Checks the logger.info in invalidate_fetch_mpack_cache:
1080 "invalidate_fetch_mpack_cache: repo=... tips=all deleted=N rows"
1081 """
1082 import logging
1083
1084 committed_at = "2026-02-01T00:00:00+00:00"
1085 snapshot_id = fake_id("snap-ex03")
1086 message = "ex03 logging test"
1087 commit_id, wire_dict = _build_valid_commit_dict(
1088 snapshot_id=snapshot_id,
1089 message=message,
1090 committed_at=committed_at,
1091 )
1092
1093 repo = await create_repo(db_session, owner="gabriel", visibility="public")
1094 repo_id = repo.repo_id
1095
1096 await _insert_commit_row(
1097 db_session, repo_id, commit_id,
1098 snapshot_id=snapshot_id,
1099 message=message,
1100 committed_at=committed_at,
1101 )
1102
1103 tip_T = commit_id
1104 await _insert_fresh_cache_row(db_session, repo_id, tip_T, fake_id("mpack-ex03"), seed="ex03")
1105
1106 _stub_gc_backend(monkeypatch)
1107
1108 with caplog.at_level(logging.INFO, logger="musehub.services.musehub_gc"):
1109 result = await wire_repair_commit(db_session, repo_id, wire_dict, caller_id="gabriel")
1110
1111 assert result["repaired"] is True
1112
1113 # The invalidation log line must appear, naming repo + deletion count
1114 gc_logs = [r.message for r in caplog.records if "invalidate_fetch_mpack_cache" in r.message]
1115 assert len(gc_logs) >= 1, (
1116 "invalidate_fetch_mpack_cache must emit a log line so operators can confirm "
1117 "the bust happened (the rc10 incident was invisible — this log line is the fix)"
1118 )
1119 log_line = gc_logs[0]
1120 assert "deleted=1" in log_line or "deleted" in log_line, (
1121 f"log line must name the rows deleted; got: {log_line!r}"
1122 )
1123 assert repo_id[:8] in log_line or "repo=" in log_line, (
1124 f"log line must name the repo; got: {log_line!r}"
1125 )
File History 3 commits
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 9 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 12 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 15 days ago