gabriel / musehub public
test_gc_section32.py python
734 lines 28.7 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Section 32 — Garbage Collector: 7-layer test suite.
2
3 Covers:
4 musehub/services/musehub_gc.py — GCResult, run_gc
5 musehub/api/routes/wire.py — _run_gc_async (fire-and-forget background task)
6
7 Key behaviour:
8 - run_gc collects all branch head commit IDs then BFS through parent_ids
9 - Commits reachable from any branch head are preserved
10 - Orphaned commits (not reachable from any branch) are deleted
11 - Snapshots referenced only by orphaned commits are deleted
12 - Snapshots also referenced by a reachable commit are preserved
13 - Repos with no branch heads → GC skips (returns empty result)
14 - Repos already clean → GC is a no-op (returns 0 deletes)
15 - run_gc commits the session itself
16 """
17 from __future__ import annotations
18
19 import uuid
20 from datetime import datetime, timezone
21
22 import pytest
23 from sqlalchemy import select
24 from sqlalchemy.ext.asyncio import AsyncSession
25
26 from musehub.db.musehub_models import (
27 MusehubBranch,
28 MusehubCommit,
29 MusehubRepo,
30 MusehubSnapshot,
31 )
32 from musehub.services.musehub_gc import GCResult, run_gc
33
34
35 # ── helpers ───────────────────────────────────────────────────────────────────
36
37
38 def _uid() -> str:
39 return str(uuid.uuid4())
40
41
42 def _cid() -> str:
43 """Short commit-id (64-char hex would be realistic, but short UUIDs work for tests)."""
44 return uuid.uuid4().hex[:16]
45
46
47 def _now() -> datetime:
48 return datetime.now(tz=timezone.utc)
49
50
51 async def _db_repo(session: AsyncSession) -> MusehubRepo:
52 slug = f"gc-repo-{_uid()[:8]}"
53 repo = MusehubRepo(
54 repo_id=_uid(),
55 name=slug,
56 slug=slug,
57 owner="testuser",
58 owner_user_id="testuser",
59 visibility="private",
60 )
61 session.add(repo)
62 await session.flush()
63 return repo
64
65
66 async def _db_branch(
67 session: AsyncSession,
68 repo_id: str,
69 *,
70 name: str = "dev",
71 head_commit_id: str | None = None,
72 ) -> MusehubBranch:
73 branch = MusehubBranch(
74 branch_id=_uid(),
75 repo_id=repo_id,
76 name=name,
77 head_commit_id=head_commit_id,
78 )
79 session.add(branch)
80 await session.flush()
81 return branch
82
83
84 async def _db_commit(
85 session: AsyncSession,
86 repo_id: str,
87 *,
88 commit_id: str | None = None,
89 parent_ids: list[str] | None = None,
90 snapshot_id: str | None = None,
91 branch: str = "dev",
92 ) -> MusehubCommit:
93 commit = MusehubCommit(
94 commit_id=commit_id or _cid(),
95 repo_id=repo_id,
96 branch=branch,
97 parent_ids=parent_ids or [],
98 message="test commit",
99 author="testuser",
100 timestamp=_now(),
101 snapshot_id=snapshot_id,
102 )
103 session.add(commit)
104 await session.flush()
105 return commit
106
107
108 async def _db_snapshot(
109 session: AsyncSession,
110 repo_id: str,
111 *,
112 snapshot_id: str | None = None,
113 ) -> MusehubSnapshot:
114 snap = MusehubSnapshot(
115 snapshot_id=snapshot_id or _cid(),
116 repo_id=repo_id,
117 created_at=_now(),
118 )
119 session.add(snap)
120 await session.flush()
121 return snap
122
123
124 # ═══════════════════════════════════════════════════════════════════════════════
125 # Layer 1 — Unit
126 # ═══════════════════════════════════════════════════════════════════════════════
127
128
129 class TestUnitGC:
130 def test_gcresult_defaults(self) -> None:
131 r = GCResult(repo_id="repo-abc")
132 assert r.commits_deleted == 0
133 assert r.snapshots_deleted == 0
134 assert r.reachable_commit_count == 0
135 assert r.errors == []
136
137 def test_gcresult_is_dataclass(self) -> None:
138 import dataclasses
139 assert dataclasses.is_dataclass(GCResult)
140
141 def test_gcresult_with_values(self) -> None:
142 r = GCResult(
143 repo_id="abc",
144 commits_deleted=5,
145 snapshots_deleted=3,
146 reachable_commit_count=10,
147 )
148 assert r.commits_deleted == 5
149 assert r.snapshots_deleted == 3
150 assert r.reachable_commit_count == 10
151
152 def test_gcresult_errors_is_list(self) -> None:
153 r = GCResult(repo_id="x")
154 r.errors.append("something failed")
155 assert len(r.errors) == 1
156
157 def test_bfs_reachability_logic(self) -> None:
158 """Verify BFS logic in isolation using the same algorithm as run_gc."""
159 # Simulate a simple commit graph:
160 # head → c2 → c1 → root
161 # ↑
162 # orphan (not reachable from head)
163 all_commits = {
164 "head": ["c2"],
165 "c2": ["c1"],
166 "c1": ["root"],
167 "root": [],
168 "orphan": ["root"], # orphan points to root but no branch points to it
169 }
170 heads = ["head"]
171
172 reachable: set[str] = set()
173 queue = list(heads)
174 while queue:
175 cid = queue.pop()
176 if cid in reachable or cid not in all_commits:
177 continue
178 reachable.add(cid)
179 queue.extend(all_commits[cid])
180
181 assert "head" in reachable
182 assert "c2" in reachable
183 assert "c1" in reachable
184 assert "root" in reachable
185 assert "orphan" not in reachable
186
187 def test_bfs_handles_merge_commits(self) -> None:
188 """Merge commits have two parents — BFS must traverse both."""
189 all_commits = {
190 "merge": ["left", "right"],
191 "left": ["base"],
192 "right": ["base"],
193 "base": [],
194 }
195 heads = ["merge"]
196
197 reachable: set[str] = set()
198 queue = list(heads)
199 while queue:
200 cid = queue.pop()
201 if cid in reachable or cid not in all_commits:
202 continue
203 reachable.add(cid)
204 queue.extend(all_commits[cid])
205
206 assert reachable == {"merge", "left", "right", "base"}
207
208 def test_bfs_handles_cycle_guard(self) -> None:
209 """Circular parent references must not infinite-loop (already-visited guard)."""
210 all_commits = {"a": ["b"], "b": ["a"]}
211 heads = ["a"]
212
213 reachable: set[str] = set()
214 queue = list(heads)
215 while queue:
216 cid = queue.pop()
217 if cid in reachable or cid not in all_commits:
218 continue
219 reachable.add(cid)
220 queue.extend(all_commits[cid])
221
222 assert reachable == {"a", "b"}
223
224
225 # ═══════════════════════════════════════════════════════════════════════════════
226 # Layer 2 — Integration
227 # ═══════════════════════════════════════════════════════════════════════════════
228
229
230 class TestIntegrationGC:
231 @pytest.mark.anyio
232 async def test_gc_clean_repo_no_deletes(self, db_session: AsyncSession) -> None:
233 repo = await _db_repo(db_session)
234 c1 = await _db_commit(db_session, repo.repo_id)
235 await _db_branch(db_session, repo.repo_id, head_commit_id=c1.commit_id)
236 await db_session.commit()
237
238 result = await run_gc(db_session, repo.repo_id)
239 assert result.commits_deleted == 0
240 assert result.snapshots_deleted == 0
241 assert result.reachable_commit_count == 1
242
243 @pytest.mark.anyio
244 async def test_gc_no_branch_heads_skips(self, db_session: AsyncSession) -> None:
245 repo = await _db_repo(db_session)
246 # Branch with no head_commit_id
247 await _db_branch(db_session, repo.repo_id, head_commit_id=None)
248 await _db_commit(db_session, repo.repo_id)
249 await db_session.commit()
250
251 result = await run_gc(db_session, repo.repo_id)
252 # No heads → GC skips immediately; nothing deleted
253 assert result.commits_deleted == 0
254 assert result.reachable_commit_count == 0
255
256 @pytest.mark.anyio
257 async def test_gc_deletes_orphaned_commit(self, db_session: AsyncSession) -> None:
258 repo = await _db_repo(db_session)
259 live = await _db_commit(db_session, repo.repo_id)
260 orphan = await _db_commit(db_session, repo.repo_id)
261 await _db_branch(db_session, repo.repo_id, head_commit_id=live.commit_id)
262 await db_session.commit()
263
264 result = await run_gc(db_session, repo.repo_id)
265 assert result.commits_deleted == 1
266 assert result.reachable_commit_count == 1
267
268 # Verify orphan is gone
269 row = await db_session.get(MusehubCommit, orphan.commit_id)
270 assert row is None
271
272 @pytest.mark.anyio
273 async def test_gc_preserves_reachable_chain(self, db_session: AsyncSession) -> None:
274 repo = await _db_repo(db_session)
275 root_cid = _cid()
276 mid_cid = _cid()
277 head_cid = _cid()
278 root = await _db_commit(db_session, repo.repo_id, commit_id=root_cid)
279 mid = await _db_commit(
280 db_session, repo.repo_id, commit_id=mid_cid, parent_ids=[root_cid]
281 )
282 head = await _db_commit(
283 db_session, repo.repo_id, commit_id=head_cid, parent_ids=[mid_cid]
284 )
285 await _db_branch(db_session, repo.repo_id, head_commit_id=head_cid)
286 await db_session.commit()
287
288 result = await run_gc(db_session, repo.repo_id)
289 assert result.commits_deleted == 0
290 assert result.reachable_commit_count == 3
291
292 for cid in [root_cid, mid_cid, head_cid]:
293 row = await db_session.get(MusehubCommit, cid)
294 assert row is not None
295
296 @pytest.mark.anyio
297 async def test_gc_deletes_orphaned_snapshot(self, db_session: AsyncSession) -> None:
298 repo = await _db_repo(db_session)
299 snap = await _db_snapshot(db_session, repo.repo_id)
300 live = await _db_commit(db_session, repo.repo_id)
301 orphan = await _db_commit(
302 db_session, repo.repo_id, snapshot_id=snap.snapshot_id
303 )
304 await _db_branch(db_session, repo.repo_id, head_commit_id=live.commit_id)
305 await db_session.commit()
306
307 result = await run_gc(db_session, repo.repo_id)
308 assert result.commits_deleted == 1
309 assert result.snapshots_deleted == 1
310
311 snap_row = await db_session.get(MusehubSnapshot, snap.snapshot_id)
312 assert snap_row is None
313
314 @pytest.mark.anyio
315 async def test_gc_preserves_snapshot_referenced_by_live_commit(
316 self, db_session: AsyncSession
317 ) -> None:
318 repo = await _db_repo(db_session)
319 shared_snap = await _db_snapshot(db_session, repo.repo_id)
320 # Both reachable and orphan point to same snapshot
321 live_cid = _cid()
322 orphan_cid = _cid()
323 await _db_commit(
324 db_session, repo.repo_id,
325 commit_id=live_cid, snapshot_id=shared_snap.snapshot_id
326 )
327 await _db_commit(
328 db_session, repo.repo_id,
329 commit_id=orphan_cid, snapshot_id=shared_snap.snapshot_id
330 )
331 await _db_branch(db_session, repo.repo_id, head_commit_id=live_cid)
332 await db_session.commit()
333
334 result = await run_gc(db_session, repo.repo_id)
335 assert result.commits_deleted == 1 # orphan commit removed
336 assert result.snapshots_deleted == 0 # snapshot still used by live commit
337
338 snap_row = await db_session.get(MusehubSnapshot, shared_snap.snapshot_id)
339 assert snap_row is not None
340
341 @pytest.mark.anyio
342 async def test_gc_multiple_branches_union_of_reachable(
343 self, db_session: AsyncSession
344 ) -> None:
345 repo = await _db_repo(db_session)
346 c1 = await _db_commit(db_session, repo.repo_id)
347 c2 = await _db_commit(db_session, repo.repo_id)
348 await _db_branch(db_session, repo.repo_id, name="dev", head_commit_id=c1.commit_id)
349 await _db_branch(db_session, repo.repo_id, name="main", head_commit_id=c2.commit_id)
350 await db_session.commit()
351
352 result = await run_gc(db_session, repo.repo_id)
353 assert result.commits_deleted == 0
354 assert result.reachable_commit_count == 2
355
356 @pytest.mark.anyio
357 async def test_gc_returns_gcresult(self, db_session: AsyncSession) -> None:
358 repo = await _db_repo(db_session)
359 c = await _db_commit(db_session, repo.repo_id)
360 await _db_branch(db_session, repo.repo_id, head_commit_id=c.commit_id)
361 await db_session.commit()
362
363 result = await run_gc(db_session, repo.repo_id)
364 assert isinstance(result, GCResult)
365 assert result.repo_id == repo.repo_id
366
367 @pytest.mark.anyio
368 async def test_gc_only_affects_target_repo(self, db_session: AsyncSession) -> None:
369 repo1 = await _db_repo(db_session)
370 repo2 = await _db_repo(db_session)
371 live = await _db_commit(db_session, repo1.repo_id)
372 r2_orphan = await _db_commit(db_session, repo2.repo_id)
373 await _db_branch(db_session, repo1.repo_id, head_commit_id=live.commit_id)
374 await _db_branch(db_session, repo2.repo_id, head_commit_id=None)
375 await db_session.commit()
376
377 # Run GC on repo1 only
378 result = await run_gc(db_session, repo1.repo_id)
379 assert result.commits_deleted == 0
380
381 # repo2's commit must still exist
382 row = await db_session.get(MusehubCommit, r2_orphan.commit_id)
383 assert row is not None
384
385
386 # ═══════════════════════════════════════════════════════════════════════════════
387 # Layer 3 — End-to-End
388 # ═══════════════════════════════════════════════════════════════════════════════
389
390
391 class TestE2EGC:
392 """GC has no direct HTTP endpoint; _run_gc_async is fire-and-forget after push.
393 We test GC end-to-end by calling run_gc directly after setting up realistic
394 repo state and verifying full database consistency.
395 """
396
397 @pytest.mark.anyio
398 async def test_e2e_gc_linear_history(self, db_session: AsyncSession) -> None:
399 """Full pipeline: 5-commit linear chain, 2 orphans, run GC, verify state."""
400 repo = await _db_repo(db_session)
401 ids = [_cid() for _ in range(7)]
402 # Chain: 0←1←2←3←4 (reachable); 5, 6 (orphans)
403 for i, cid in enumerate(ids[:5]):
404 parents = [ids[i - 1]] if i > 0 else []
405 await _db_commit(
406 db_session, repo.repo_id, commit_id=cid, parent_ids=parents
407 )
408 for cid in ids[5:]:
409 await _db_commit(db_session, repo.repo_id, commit_id=cid)
410
411 await _db_branch(db_session, repo.repo_id, head_commit_id=ids[4])
412 await db_session.commit()
413
414 result = await run_gc(db_session, repo.repo_id)
415
416 assert result.reachable_commit_count == 5
417 assert result.commits_deleted == 2
418
419 for cid in ids[:5]:
420 assert await db_session.get(MusehubCommit, cid) is not None
421 for cid in ids[5:]:
422 assert await db_session.get(MusehubCommit, cid) is None
423
424 @pytest.mark.anyio
425 async def test_e2e_gc_empty_repo(self, db_session: AsyncSession) -> None:
426 repo = await _db_repo(db_session)
427 await db_session.commit()
428
429 result = await run_gc(db_session, repo.repo_id)
430 assert result.commits_deleted == 0
431 assert result.snapshots_deleted == 0
432
433 @pytest.mark.anyio
434 async def test_e2e_gc_snapshot_lifecycle(self, db_session: AsyncSession) -> None:
435 repo = await _db_repo(db_session)
436 live_snap = await _db_snapshot(db_session, repo.repo_id)
437 dead_snap = await _db_snapshot(db_session, repo.repo_id)
438
439 live_cid = _cid()
440 dead_cid = _cid()
441 await _db_commit(
442 db_session, repo.repo_id, commit_id=live_cid, snapshot_id=live_snap.snapshot_id
443 )
444 await _db_commit(
445 db_session, repo.repo_id, commit_id=dead_cid, snapshot_id=dead_snap.snapshot_id
446 )
447 await _db_branch(db_session, repo.repo_id, head_commit_id=live_cid)
448 await db_session.commit()
449
450 result = await run_gc(db_session, repo.repo_id)
451 assert result.commits_deleted == 1
452 assert result.snapshots_deleted == 1
453
454 assert await db_session.get(MusehubSnapshot, live_snap.snapshot_id) is not None
455 assert await db_session.get(MusehubSnapshot, dead_snap.snapshot_id) is None
456
457
458 # ═══════════════════════════════════════════════════════════════════════════════
459 # Layer 4 — Stress
460 # ═══════════════════════════════════════════════════════════════════════════════
461
462
463 class TestStressGC:
464 @pytest.mark.anyio
465 async def test_gc_large_orphan_set(self, db_session: AsyncSession) -> None:
466 """GC must handle a repo with 200 orphaned commits."""
467 repo = await _db_repo(db_session)
468 live = await _db_commit(db_session, repo.repo_id)
469 await _db_branch(db_session, repo.repo_id, head_commit_id=live.commit_id)
470
471 for _ in range(200):
472 await _db_commit(db_session, repo.repo_id)
473
474 await db_session.commit()
475
476 result = await run_gc(db_session, repo.repo_id)
477 assert result.commits_deleted == 200
478 assert result.reachable_commit_count == 1
479
480 @pytest.mark.anyio
481 async def test_gc_deep_chain(self, db_session: AsyncSession) -> None:
482 """GC must traverse a 100-commit linear chain without stack overflow."""
483 repo = await _db_repo(db_session)
484 ids = [_cid() for _ in range(100)]
485 for i, cid in enumerate(ids):
486 parents = [ids[i - 1]] if i > 0 else []
487 await _db_commit(
488 db_session, repo.repo_id, commit_id=cid, parent_ids=parents
489 )
490 await _db_branch(db_session, repo.repo_id, head_commit_id=ids[-1])
491 await db_session.commit()
492
493 result = await run_gc(db_session, repo.repo_id)
494 assert result.commits_deleted == 0
495 assert result.reachable_commit_count == 100
496
497 @pytest.mark.anyio
498 async def test_gc_many_orphaned_snapshots(self, db_session: AsyncSession) -> None:
499 repo = await _db_repo(db_session)
500 live = await _db_commit(db_session, repo.repo_id)
501 await _db_branch(db_session, repo.repo_id, head_commit_id=live.commit_id)
502
503 for _ in range(50):
504 snap = await _db_snapshot(db_session, repo.repo_id)
505 await _db_commit(db_session, repo.repo_id, snapshot_id=snap.snapshot_id)
506
507 await db_session.commit()
508
509 result = await run_gc(db_session, repo.repo_id)
510 assert result.commits_deleted == 50
511 assert result.snapshots_deleted == 50
512
513 @pytest.mark.anyio
514 async def test_gc_idempotent_on_clean_repo(self, db_session: AsyncSession) -> None:
515 """Running GC twice on an already-clean repo must be a no-op both times."""
516 repo = await _db_repo(db_session)
517 c = await _db_commit(db_session, repo.repo_id)
518 await _db_branch(db_session, repo.repo_id, head_commit_id=c.commit_id)
519 await db_session.commit()
520
521 r1 = await run_gc(db_session, repo.repo_id)
522 r2 = await run_gc(db_session, repo.repo_id)
523
524 assert r1.commits_deleted == 0
525 assert r2.commits_deleted == 0
526
527
528 # ═══════════════════════════════════════════════════════════════════════════════
529 # Layer 5 — Data Integrity
530 # ═══════════════════════════════════════════════════════════════════════════════
531
532
533 class TestDataIntegrityGC:
534 @pytest.mark.anyio
535 async def test_gc_does_not_delete_head_commit(self, db_session: AsyncSession) -> None:
536 repo = await _db_repo(db_session)
537 head = await _db_commit(db_session, repo.repo_id)
538 await _db_branch(db_session, repo.repo_id, head_commit_id=head.commit_id)
539 await db_session.commit()
540
541 await run_gc(db_session, repo.repo_id)
542 row = await db_session.get(MusehubCommit, head.commit_id)
543 assert row is not None
544
545 @pytest.mark.anyio
546 async def test_gc_does_not_delete_live_snapshot(self, db_session: AsyncSession) -> None:
547 repo = await _db_repo(db_session)
548 snap = await _db_snapshot(db_session, repo.repo_id)
549 c = await _db_commit(db_session, repo.repo_id, snapshot_id=snap.snapshot_id)
550 await _db_branch(db_session, repo.repo_id, head_commit_id=c.commit_id)
551 await db_session.commit()
552
553 await run_gc(db_session, repo.repo_id)
554 snap_row = await db_session.get(MusehubSnapshot, snap.snapshot_id)
555 assert snap_row is not None
556
557 @pytest.mark.anyio
558 async def test_gc_counts_match_actual_deletes(self, db_session: AsyncSession) -> None:
559 repo = await _db_repo(db_session)
560 live = await _db_commit(db_session, repo.repo_id)
561 snaps = [await _db_snapshot(db_session, repo.repo_id) for _ in range(3)]
562 for snap in snaps:
563 await _db_commit(db_session, repo.repo_id, snapshot_id=snap.snapshot_id)
564 await _db_branch(db_session, repo.repo_id, head_commit_id=live.commit_id)
565 await db_session.commit()
566
567 result = await run_gc(db_session, repo.repo_id)
568 assert result.commits_deleted == 3
569 assert result.snapshots_deleted == 3
570
571 # Verify actual DB state matches reported counts
572 remaining_commits = await db_session.execute(
573 select(MusehubCommit).where(MusehubCommit.repo_id == repo.repo_id)
574 )
575 assert len(remaining_commits.scalars().all()) == 1
576
577 remaining_snaps = await db_session.execute(
578 select(MusehubSnapshot).where(MusehubSnapshot.repo_id == repo.repo_id)
579 )
580 assert len(remaining_snaps.scalars().all()) == 0
581
582 @pytest.mark.anyio
583 async def test_gc_merge_commit_both_parents_preserved(
584 self, db_session: AsyncSession
585 ) -> None:
586 repo = await _db_repo(db_session)
587 base_cid = _cid()
588 left_cid = _cid()
589 right_cid = _cid()
590 merge_cid = _cid()
591
592 await _db_commit(db_session, repo.repo_id, commit_id=base_cid)
593 await _db_commit(
594 db_session, repo.repo_id, commit_id=left_cid, parent_ids=[base_cid]
595 )
596 await _db_commit(
597 db_session, repo.repo_id, commit_id=right_cid, parent_ids=[base_cid]
598 )
599 await _db_commit(
600 db_session, repo.repo_id,
601 commit_id=merge_cid, parent_ids=[left_cid, right_cid]
602 )
603 await _db_branch(db_session, repo.repo_id, head_commit_id=merge_cid)
604 await db_session.commit()
605
606 result = await run_gc(db_session, repo.repo_id)
607 assert result.commits_deleted == 0
608 assert result.reachable_commit_count == 4
609
610 for cid in [base_cid, left_cid, right_cid, merge_cid]:
611 assert await db_session.get(MusehubCommit, cid) is not None
612
613 @pytest.mark.anyio
614 async def test_gc_commit_with_no_snapshot_skips_snapshot_delete(
615 self, db_session: AsyncSession
616 ) -> None:
617 repo = await _db_repo(db_session)
618 live = await _db_commit(db_session, repo.repo_id)
619 # orphan has no snapshot
620 await _db_commit(db_session, repo.repo_id, snapshot_id=None)
621 await _db_branch(db_session, repo.repo_id, head_commit_id=live.commit_id)
622 await db_session.commit()
623
624 result = await run_gc(db_session, repo.repo_id)
625 assert result.commits_deleted == 1
626 assert result.snapshots_deleted == 0
627
628
629 # ═══════════════════════════════════════════════════════════════════════════════
630 # Layer 6 — Security
631 # ═══════════════════════════════════════════════════════════════════════════════
632
633
634 class TestSecurityGC:
635 @pytest.mark.anyio
636 async def test_gc_does_not_cross_repo_boundaries(self, db_session: AsyncSession) -> None:
637 """GC for repo1 must never delete commits belonging to repo2."""
638 repo1 = await _db_repo(db_session)
639 repo2 = await _db_repo(db_session)
640
641 # repo2 has a commit not referenced by any branch
642 r2_commit = await _db_commit(db_session, repo2.repo_id)
643
644 # repo1 has a live commit
645 r1_live = await _db_commit(db_session, repo1.repo_id)
646 await _db_branch(db_session, repo1.repo_id, head_commit_id=r1_live.commit_id)
647 await _db_branch(db_session, repo2.repo_id, head_commit_id=None)
648 await db_session.commit()
649
650 await run_gc(db_session, repo1.repo_id)
651
652 # repo2's commit must still be there
653 row = await db_session.get(MusehubCommit, r2_commit.commit_id)
654 assert row is not None
655
656 @pytest.mark.anyio
657 async def test_gc_nonexistent_repo_returns_empty_result(
658 self, db_session: AsyncSession
659 ) -> None:
660 """Calling GC on a non-existent repo_id must not raise and return empty result."""
661 result = await run_gc(db_session, "nonexistent-repo-id")
662 assert result.commits_deleted == 0
663 assert result.reachable_commit_count == 0
664
665 @pytest.mark.anyio
666 async def test_gc_with_unknown_parent_ids_does_not_crash(
667 self, db_session: AsyncSession
668 ) -> None:
669 """Commits that reference parent IDs not in the DB (dangling refs) are handled."""
670 repo = await _db_repo(db_session)
671 head_cid = _cid()
672 # parent_ids references a commit that doesn't exist in DB
673 await _db_commit(
674 db_session, repo.repo_id,
675 commit_id=head_cid, parent_ids=["phantom-commit-id-not-in-db"]
676 )
677 await _db_branch(db_session, repo.repo_id, head_commit_id=head_cid)
678 await db_session.commit()
679
680 # BFS encounters unknown parent, skips it — must not raise
681 result = await run_gc(db_session, repo.repo_id)
682 assert result.commits_deleted == 0
683 assert result.reachable_commit_count == 1
684
685
686 # ═══════════════════════════════════════════════════════════════════════════════
687 # Layer 7 — Performance
688 # ═══════════════════════════════════════════════════════════════════════════════
689
690
691 class TestPerformanceGC:
692 @pytest.mark.anyio
693 async def test_gc_completes_quickly_small_repo(self, db_session: AsyncSession) -> None:
694 import time
695
696 repo = await _db_repo(db_session)
697 ids = [_cid() for _ in range(20)]
698 for i, cid in enumerate(ids):
699 parents = [ids[i - 1]] if i > 0 else []
700 await _db_commit(db_session, repo.repo_id, commit_id=cid, parent_ids=parents)
701 await _db_branch(db_session, repo.repo_id, head_commit_id=ids[-1])
702 await db_session.commit()
703
704 start = time.perf_counter()
705 result = await run_gc(db_session, repo.repo_id)
706 elapsed = time.perf_counter() - start
707
708 assert result.commits_deleted == 0
709 assert elapsed < 1.0
710
711 @pytest.mark.anyio
712 async def test_gc_with_mixed_load(self, db_session: AsyncSession) -> None:
713 import time
714
715 repo = await _db_repo(db_session)
716 live_ids = [_cid() for _ in range(30)]
717 for i, cid in enumerate(live_ids):
718 parents = [live_ids[i - 1]] if i > 0 else []
719 await _db_commit(db_session, repo.repo_id, commit_id=cid, parent_ids=parents)
720
721 for _ in range(50):
722 snap = await _db_snapshot(db_session, repo.repo_id)
723 await _db_commit(db_session, repo.repo_id, snapshot_id=snap.snapshot_id)
724
725 await _db_branch(db_session, repo.repo_id, head_commit_id=live_ids[-1])
726 await db_session.commit()
727
728 start = time.perf_counter()
729 result = await run_gc(db_session, repo.repo_id)
730 elapsed = time.perf_counter() - start
731
732 assert result.commits_deleted == 50
733 assert result.snapshots_deleted == 50
734 assert elapsed < 2.0
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago