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