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