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