gabriel / musehub public
test_gc_background_tasks_section41.py python
652 lines 29.8 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Section 41 — GC & Background Tasks: 7-layer test suite.
2
3 Covers:
4 - musehub/services/musehub_gc.py::GCResult, run_gc
5 - musehub/api/routes/wire.py::_build_symbol_index_async, _run_gc_async
6 """
7 from __future__ import annotations
8
9 import asyncio
10 import secrets
11 import time
12 import uuid
13 from datetime import UTC, datetime
14 from unittest.mock import AsyncMock, MagicMock, patch
15
16 import pytest
17 from sqlalchemy import select
18 from sqlalchemy.ext.asyncio import AsyncSession
19
20 from musehub.api.routes.wire import (
21 _build_symbol_index_async,
22 _run_gc_async,
23 )
24 from musehub.db.musehub_models import (
25 MusehubBranch,
26 MusehubCommit,
27 MusehubRepo,
28 MusehubSnapshot,
29 )
30 from musehub.services.musehub_gc import GCResult, run_gc
31
32
33 # ─────────────────────────────────────────────────────────────────────────────
34 # Helpers
35 # ─────────────────────────────────────────────────────────────────────────────
36
37 def _uid() -> str:
38 return str(uuid.uuid4())
39
40
41 def _hex(n: int = 32) -> str:
42 return secrets.token_hex(n)
43
44
45 async def _mk_repo(session: AsyncSession, suffix: str = "") -> MusehubRepo:
46 repo = MusehubRepo(
47 repo_id=_uid(),
48 name=f"gc-test{suffix}",
49 owner="testuser",
50 slug=f"gc-test{suffix}-{_uid()[:8]}",
51 owner_user_id="test-owner-id",
52 )
53 session.add(repo)
54 await session.flush()
55 return repo
56
57
58 async def _mk_branch(
59 session: AsyncSession,
60 repo_id: str,
61 head_commit_id: str | None = None,
62 name: str = "main",
63 ) -> MusehubBranch:
64 branch = MusehubBranch(
65 branch_id=_uid(),
66 repo_id=repo_id,
67 name=name,
68 head_commit_id=head_commit_id,
69 )
70 session.add(branch)
71 await session.flush()
72 return branch
73
74
75 async def _mk_commit(
76 session: AsyncSession,
77 repo_id: str,
78 commit_id: str | None = None,
79 parent_ids: list[str] | None = None,
80 snapshot_id: str | None = None,
81 ) -> MusehubCommit:
82 commit = MusehubCommit(
83 commit_id=commit_id or _hex(),
84 repo_id=repo_id,
85 branch="main",
86 parent_ids=parent_ids or [],
87 message="test commit",
88 author="testuser",
89 timestamp=datetime.now(UTC),
90 snapshot_id=snapshot_id,
91 )
92 session.add(commit)
93 await session.flush()
94 return commit
95
96
97 async def _mk_snapshot(
98 session: AsyncSession, repo_id: str, snapshot_id: str | None = None
99 ) -> MusehubSnapshot:
100 snap = MusehubSnapshot(
101 snapshot_id=snapshot_id or _hex(),
102 repo_id=repo_id,
103 )
104 session.add(snap)
105 await session.flush()
106 return snap
107
108
109 async def _count_commits(session: AsyncSession, repo_id: str) -> int:
110 result = await session.execute(
111 select(MusehubCommit).where(MusehubCommit.repo_id == repo_id)
112 )
113 return len(result.scalars().all())
114
115
116 # ─────────────────────────────────────────────────────────────────────────────
117 # LAYER 1 — UNIT
118 # ─────────────────────────────────────────────────────────────────────────────
119
120
121 class TestGCResultUnit:
122 """Unit: GCResult dataclass shape and defaults."""
123
124 def test_default_counts_are_zero(self) -> None:
125 r = GCResult(repo_id="abc")
126 assert r.commits_deleted == 0
127 assert r.snapshots_deleted == 0
128 assert r.reachable_commit_count == 0
129
130 def test_default_errors_is_empty_list(self) -> None:
131 r = GCResult(repo_id="abc")
132 assert r.errors == []
133
134 def test_errors_is_independent_per_instance(self) -> None:
135 a = GCResult(repo_id="a")
136 b = GCResult(repo_id="b")
137 a.errors.append("x")
138 assert b.errors == []
139
140 def test_fields_set_correctly(self) -> None:
141 r = GCResult(repo_id="x", commits_deleted=3, snapshots_deleted=1, reachable_commit_count=5)
142 assert r.repo_id == "x"
143 assert r.commits_deleted == 3
144 assert r.snapshots_deleted == 1
145 assert r.reachable_commit_count == 5
146
147
148 class TestRunGcNoBranches:
149 """Unit: run_gc early-exit when repo has no branches."""
150
151 async def test_no_branches_returns_zero_deletions(self, db_session: AsyncSession) -> None:
152 repo = await _mk_repo(db_session)
153 result = await run_gc(db_session, repo.repo_id)
154 assert result.commits_deleted == 0
155 assert result.reachable_commit_count == 0
156
157 async def test_branch_with_null_head_is_ignored(self, db_session: AsyncSession) -> None:
158 repo = await _mk_repo(db_session)
159 await _mk_branch(db_session, repo.repo_id, head_commit_id=None)
160 result = await run_gc(db_session, repo.repo_id)
161 assert result.commits_deleted == 0
162
163 async def test_unknown_repo_id_returns_empty_result(self, db_session: AsyncSession) -> None:
164 result = await run_gc(db_session, "nonexistent-repo-id")
165 assert result.commits_deleted == 0
166 assert result.reachable_commit_count == 0
167
168
169 class TestBuildSymbolIndexAsyncUnit:
170 """Unit: _build_symbol_index_async swallows all exceptions."""
171
172 async def test_swallows_db_connection_error(self) -> None:
173 with patch("musehub.db.database.AsyncSessionLocal") as mock_sl:
174 mock_sl.return_value.__aenter__ = AsyncMock(side_effect=RuntimeError("db down"))
175 mock_sl.return_value.__aexit__ = AsyncMock(return_value=False)
176 await _build_symbol_index_async("repo-id", "commit-id") # must not raise
177
178 async def test_swallows_indexer_exception(self) -> None:
179 with patch("musehub.db.database.AsyncSessionLocal") as mock_sl:
180 mock_session = AsyncMock()
181 mock_sl.return_value.__aenter__ = AsyncMock(return_value=mock_session)
182 mock_sl.return_value.__aexit__ = AsyncMock(return_value=False)
183 with patch(
184 "musehub.services.musehub_symbol_indexer.build_symbol_index",
185 new=AsyncMock(side_effect=ValueError("bad symbols")),
186 ):
187 await _build_symbol_index_async("repo-id", "commit-id") # must not raise
188
189 async def test_logs_warning_on_failure(self, caplog) -> None:
190 import logging
191 with patch("musehub.db.database.AsyncSessionLocal") as mock_sl:
192 mock_sl.return_value.__aenter__ = AsyncMock(side_effect=RuntimeError("boom"))
193 mock_sl.return_value.__aexit__ = AsyncMock(return_value=False)
194 with caplog.at_level(logging.WARNING, logger="musehub.api.routes.wire"):
195 await _build_symbol_index_async("repo-xyz", "c123")
196 assert any("repo-xyz" in r.message for r in caplog.records)
197
198
199 class TestRunGcAsyncUnit:
200 """Unit: _run_gc_async swallows all exceptions."""
201
202 async def test_swallows_db_error(self) -> None:
203 with patch("musehub.db.database.AsyncSessionLocal") as mock_sl:
204 mock_sl.return_value.__aenter__ = AsyncMock(side_effect=RuntimeError("db down"))
205 mock_sl.return_value.__aexit__ = AsyncMock(return_value=False)
206 await _run_gc_async("repo-id") # must not raise
207
208 async def test_swallows_gc_service_error(self) -> None:
209 with patch("musehub.db.database.AsyncSessionLocal") as mock_sl:
210 mock_session = AsyncMock()
211 mock_sl.return_value.__aenter__ = AsyncMock(return_value=mock_session)
212 mock_sl.return_value.__aexit__ = AsyncMock(return_value=False)
213 with patch(
214 "musehub.services.musehub_gc.run_gc",
215 new=AsyncMock(side_effect=RuntimeError("gc failed")),
216 ):
217 await _run_gc_async("repo-id") # must not raise
218
219 async def test_logs_warning_on_failure(self, caplog) -> None:
220 import logging
221 with patch("musehub.db.database.AsyncSessionLocal") as mock_sl:
222 mock_sl.return_value.__aenter__ = AsyncMock(side_effect=Exception("explode"))
223 mock_sl.return_value.__aexit__ = AsyncMock(return_value=False)
224 with caplog.at_level(logging.WARNING, logger="musehub.api.routes.wire"):
225 await _run_gc_async("repo-abc")
226 assert any("repo-abc" in r.message for r in caplog.records)
227
228
229 # ─────────────────────────────────────────────────────────────────────────────
230 # LAYER 2 — INTEGRATION
231 # ─────────────────────────────────────────────────────────────────────────────
232
233
234 class TestRunGcIntegration:
235 """Integration: run_gc with real in-memory DB."""
236
237 async def test_clean_repo_deletes_nothing(self, db_session: AsyncSession) -> None:
238 repo = await _mk_repo(db_session)
239 commit = await _mk_commit(db_session, repo.repo_id)
240 await _mk_branch(db_session, repo.repo_id, head_commit_id=commit.commit_id)
241 result = await run_gc(db_session, repo.repo_id)
242 assert result.commits_deleted == 0
243 assert result.reachable_commit_count == 1
244
245 async def test_orphaned_commit_is_deleted(self, db_session: AsyncSession) -> None:
246 repo = await _mk_repo(db_session)
247 # reachable commit
248 head = await _mk_commit(db_session, repo.repo_id)
249 await _mk_branch(db_session, repo.repo_id, head_commit_id=head.commit_id)
250 # orphaned commit — not on any branch
251 await _mk_commit(db_session, repo.repo_id)
252 await db_session.commit()
253
254 result = await run_gc(db_session, repo.repo_id)
255 assert result.commits_deleted == 1
256 assert result.reachable_commit_count == 1
257
258 async def test_orphaned_chain_all_deleted(self, db_session: AsyncSession) -> None:
259 repo = await _mk_repo(db_session)
260 head = await _mk_commit(db_session, repo.repo_id)
261 await _mk_branch(db_session, repo.repo_id, head_commit_id=head.commit_id)
262 # chain of 3 orphaned commits
263 c1 = await _mk_commit(db_session, repo.repo_id)
264 c2 = await _mk_commit(db_session, repo.repo_id, parent_ids=[c1.commit_id])
265 c3 = await _mk_commit(db_session, repo.repo_id, parent_ids=[c2.commit_id])
266 await db_session.commit()
267
268 result = await run_gc(db_session, repo.repo_id)
269 assert result.commits_deleted == 3
270 assert result.reachable_commit_count == 1
271
272 async def test_reachable_commit_chain_untouched(self, db_session: AsyncSession) -> None:
273 repo = await _mk_repo(db_session)
274 c1 = await _mk_commit(db_session, repo.repo_id)
275 c2 = await _mk_commit(db_session, repo.repo_id, parent_ids=[c1.commit_id])
276 c3 = await _mk_commit(db_session, repo.repo_id, parent_ids=[c2.commit_id])
277 await _mk_branch(db_session, repo.repo_id, head_commit_id=c3.commit_id)
278 await db_session.commit()
279
280 result = await run_gc(db_session, repo.repo_id)
281 assert result.commits_deleted == 0
282 assert result.reachable_commit_count == 3
283
284 async def test_gc_scoped_to_repo(self, db_session: AsyncSession) -> None:
285 """Orphaned commits in repo_a are not touched when GC runs on repo_b."""
286 repo_a = await _mk_repo(db_session, "-a")
287 repo_b = await _mk_repo(db_session, "-b")
288 # repo_b: clean
289 head_b = await _mk_commit(db_session, repo_b.repo_id)
290 await _mk_branch(db_session, repo_b.repo_id, head_commit_id=head_b.commit_id)
291 # repo_a: orphaned commit
292 await _mk_commit(db_session, repo_a.repo_id)
293 await db_session.commit()
294
295 result = await run_gc(db_session, repo_b.repo_id)
296 assert result.commits_deleted == 0
297 # repo_a's orphan still exists
298 assert await _count_commits(db_session, repo_a.repo_id) == 1
299
300 async def test_orphaned_snapshot_deleted_with_commit(self, db_session: AsyncSession) -> None:
301 repo = await _mk_repo(db_session)
302 snap = await _mk_snapshot(db_session, repo.repo_id)
303 head = await _mk_commit(db_session, repo.repo_id)
304 await _mk_branch(db_session, repo.repo_id, head_commit_id=head.commit_id)
305 # orphaned commit references a snapshot
306 await _mk_commit(db_session, repo.repo_id, snapshot_id=snap.snapshot_id)
307 await db_session.commit()
308
309 result = await run_gc(db_session, repo.repo_id)
310 assert result.commits_deleted == 1
311 assert result.snapshots_deleted == 1
312
313
314 # ─────────────────────────────────────────────────────────────────────────────
315 # LAYER 3 — E2E
316 # ─────────────────────────────────────────────────────────────────────────────
317
318
319 class TestBackgroundTaskE2E:
320 """E2E: background task functions run to completion against the test DB."""
321
322 async def test_run_gc_async_completes_on_real_db(self, db_session: AsyncSession) -> None:
323 """_run_gc_async runs to completion using the patched test DB session factory."""
324 repo = await _mk_repo(db_session)
325 head = await _mk_commit(db_session, repo.repo_id)
326 await _mk_branch(db_session, repo.repo_id, head_commit_id=head.commit_id)
327 await db_session.commit()
328
329 # Run fire-and-forget task synchronously inside test event loop
330 await _run_gc_async(repo.repo_id)
331 # No assertion needed — if it raises the test fails
332
333 async def test_run_gc_async_with_orphan_completes(self, db_session: AsyncSession) -> None:
334 repo = await _mk_repo(db_session)
335 head = await _mk_commit(db_session, repo.repo_id)
336 await _mk_branch(db_session, repo.repo_id, head_commit_id=head.commit_id)
337 await _mk_commit(db_session, repo.repo_id) # orphan
338 await db_session.commit()
339 await _run_gc_async(repo.repo_id)
340
341 async def test_build_symbol_index_async_swallows_service_error(self) -> None:
342 """_build_symbol_index_async doesn't crash callers even if indexer fails."""
343 with patch(
344 "musehub.services.musehub_symbol_indexer.build_symbol_index",
345 new=AsyncMock(side_effect=Exception("index unavailable")),
346 ):
347 await _build_symbol_index_async("any-repo", "any-commit")
348
349 async def test_create_task_swallows_gc_failure(self) -> None:
350 """asyncio.create_task wrapping _run_gc_async doesn't propagate exceptions."""
351 with patch("musehub.db.database.AsyncSessionLocal") as mock_sl:
352 mock_sl.return_value.__aenter__ = AsyncMock(side_effect=RuntimeError("gone"))
353 mock_sl.return_value.__aexit__ = AsyncMock(return_value=False)
354 task = asyncio.create_task(_run_gc_async("bad-repo"))
355 await asyncio.sleep(0) # yield to let task run
356 await task # must not raise
357
358 async def test_multiple_background_tasks_fire_and_forget(self, db_session: AsyncSession) -> None:
359 """Multiple tasks scheduled together all complete without interference."""
360 repos = [await _mk_repo(db_session, f"-{i}") for i in range(3)]
361 for repo in repos:
362 head = await _mk_commit(db_session, repo.repo_id)
363 await _mk_branch(db_session, repo.repo_id, head_commit_id=head.commit_id)
364 await db_session.commit()
365
366 tasks = [asyncio.create_task(_run_gc_async(r.repo_id)) for r in repos]
367 await asyncio.gather(*tasks)
368
369
370 # ─────────────────────────────────────────────────────────────────────────────
371 # LAYER 4 — STRESS
372 # ─────────────────────────────────────────────────────────────────────────────
373
374
375 class TestGCStress:
376 """Stress: GC under large commit volumes."""
377
378 async def test_gc_100_commit_linear_chain_all_reachable(self, db_session: AsyncSession) -> None:
379 repo = await _mk_repo(db_session)
380 parent_id: str | None = None
381 commits = []
382 for _ in range(100):
383 c = await _mk_commit(db_session, repo.repo_id, parent_ids=[parent_id] if parent_id else [])
384 commits.append(c)
385 parent_id = c.commit_id
386 await _mk_branch(db_session, repo.repo_id, head_commit_id=commits[-1].commit_id)
387 await db_session.commit()
388
389 result = await run_gc(db_session, repo.repo_id)
390 assert result.commits_deleted == 0
391 assert result.reachable_commit_count == 100
392
393 async def test_gc_50_orphaned_commits_all_deleted(self, db_session: AsyncSession) -> None:
394 repo = await _mk_repo(db_session)
395 head = await _mk_commit(db_session, repo.repo_id)
396 await _mk_branch(db_session, repo.repo_id, head_commit_id=head.commit_id)
397 for _ in range(50):
398 await _mk_commit(db_session, repo.repo_id)
399 await db_session.commit()
400
401 result = await run_gc(db_session, repo.repo_id)
402 assert result.commits_deleted == 50
403
404 async def test_gc_diamond_merge_topology(self, db_session: AsyncSession) -> None:
405 """Diamond: base → left + right → merge; all 4 commits are reachable."""
406 repo = await _mk_repo(db_session)
407 base = await _mk_commit(db_session, repo.repo_id)
408 left = await _mk_commit(db_session, repo.repo_id, parent_ids=[base.commit_id])
409 right = await _mk_commit(db_session, repo.repo_id, parent_ids=[base.commit_id])
410 merge = await _mk_commit(
411 db_session, repo.repo_id, parent_ids=[left.commit_id, right.commit_id]
412 )
413 await _mk_branch(db_session, repo.repo_id, head_commit_id=merge.commit_id)
414 await db_session.commit()
415
416 result = await run_gc(db_session, repo.repo_id)
417 assert result.commits_deleted == 0
418 assert result.reachable_commit_count == 4
419
420 async def test_concurrent_gc_async_calls_idempotent(self, db_session: AsyncSession) -> None:
421 """Two simultaneous GC calls on the same repo both complete without error."""
422 repo = await _mk_repo(db_session)
423 head = await _mk_commit(db_session, repo.repo_id)
424 await _mk_branch(db_session, repo.repo_id, head_commit_id=head.commit_id)
425 await db_session.commit()
426
427 await asyncio.gather(
428 _run_gc_async(repo.repo_id),
429 _run_gc_async(repo.repo_id),
430 )
431
432
433 # ─────────────────────────────────────────────────────────────────────────────
434 # LAYER 5 — DATA INTEGRITY
435 # ─────────────────────────────────────────────────────────────────────────────
436
437
438 class TestGCDataIntegrity:
439 """Data Integrity: GC preserves reachable objects and correctly tracks counts."""
440
441 async def test_reachable_commits_still_in_db_after_gc(self, db_session: AsyncSession) -> None:
442 repo = await _mk_repo(db_session)
443 c1 = await _mk_commit(db_session, repo.repo_id)
444 c2 = await _mk_commit(db_session, repo.repo_id, parent_ids=[c1.commit_id])
445 await _mk_branch(db_session, repo.repo_id, head_commit_id=c2.commit_id)
446 await _mk_commit(db_session, repo.repo_id) # orphan
447 await db_session.commit()
448
449 await run_gc(db_session, repo.repo_id)
450 remaining = await _count_commits(db_session, repo.repo_id)
451 assert remaining == 2
452
453 async def test_orphaned_commits_gone_after_gc(self, db_session: AsyncSession) -> None:
454 repo = await _mk_repo(db_session)
455 head = await _mk_commit(db_session, repo.repo_id)
456 await _mk_branch(db_session, repo.repo_id, head_commit_id=head.commit_id)
457 orphan = await _mk_commit(db_session, repo.repo_id)
458 orphan_id = orphan.commit_id
459 await db_session.commit()
460
461 await run_gc(db_session, repo.repo_id)
462 row = await db_session.get(MusehubCommit, orphan_id)
463 assert row is None
464
465 async def test_reachable_count_plus_deleted_equals_total(self, db_session: AsyncSession) -> None:
466 repo = await _mk_repo(db_session)
467 head = await _mk_commit(db_session, repo.repo_id)
468 await _mk_branch(db_session, repo.repo_id, head_commit_id=head.commit_id)
469 for _ in range(5):
470 await _mk_commit(db_session, repo.repo_id)
471 await db_session.commit()
472
473 result = await run_gc(db_session, repo.repo_id)
474 assert result.reachable_commit_count + result.commits_deleted == 6
475
476 async def test_shared_snapshot_not_deleted_when_reachable(self, db_session: AsyncSession) -> None:
477 """Snapshot referenced by both a reachable and orphaned commit must not be deleted."""
478 repo = await _mk_repo(db_session)
479 snap = await _mk_snapshot(db_session, repo.repo_id)
480 head = await _mk_commit(db_session, repo.repo_id, snapshot_id=snap.snapshot_id)
481 await _mk_branch(db_session, repo.repo_id, head_commit_id=head.commit_id)
482 await _mk_commit(db_session, repo.repo_id, snapshot_id=snap.snapshot_id) # orphan
483 await db_session.commit()
484
485 result = await run_gc(db_session, repo.repo_id)
486 assert result.commits_deleted == 1
487 assert result.snapshots_deleted == 0
488 # snapshot still present
489 snap_row = await db_session.get(MusehubSnapshot, snap.snapshot_id)
490 assert snap_row is not None
491
492 async def test_gc_idempotent_second_run_deletes_nothing(self, db_session: AsyncSession) -> None:
493 repo = await _mk_repo(db_session)
494 head = await _mk_commit(db_session, repo.repo_id)
495 await _mk_branch(db_session, repo.repo_id, head_commit_id=head.commit_id)
496 await _mk_commit(db_session, repo.repo_id) # orphan
497 await db_session.commit()
498
499 first = await run_gc(db_session, repo.repo_id)
500 assert first.commits_deleted == 1
501
502 second = await run_gc(db_session, repo.repo_id)
503 assert second.commits_deleted == 0
504
505 async def test_multi_branch_all_heads_reachable(self, db_session: AsyncSession) -> None:
506 """Commits pointed to by multiple branches are all preserved."""
507 repo = await _mk_repo(db_session)
508 c_main = await _mk_commit(db_session, repo.repo_id)
509 c_dev = await _mk_commit(db_session, repo.repo_id)
510 c_feat = await _mk_commit(db_session, repo.repo_id)
511 await _mk_branch(db_session, repo.repo_id, head_commit_id=c_main.commit_id, name="main")
512 await _mk_branch(db_session, repo.repo_id, head_commit_id=c_dev.commit_id, name="dev")
513 await _mk_branch(db_session, repo.repo_id, head_commit_id=c_feat.commit_id, name="feat")
514 await db_session.commit()
515
516 result = await run_gc(db_session, repo.repo_id)
517 assert result.commits_deleted == 0
518 assert result.reachable_commit_count == 3
519
520
521 # ─────────────────────────────────────────────────────────────────────────────
522 # LAYER 6 — SECURITY
523 # ─────────────────────────────────────────────────────────────────────────────
524
525
526 class TestGCBackgroundSecurity:
527 """Security: isolation, error suppression, no cross-repo contamination."""
528
529 async def test_gc_cannot_delete_commits_in_other_repo(self, db_session: AsyncSession) -> None:
530 repo_a = await _mk_repo(db_session, "-sec-a")
531 repo_b = await _mk_repo(db_session, "-sec-b")
532 # repo_b: clean
533 head_b = await _mk_commit(db_session, repo_b.repo_id)
534 await _mk_branch(db_session, repo_b.repo_id, head_commit_id=head_b.commit_id)
535 # repo_a: orphaned commit
536 orphan = await _mk_commit(db_session, repo_a.repo_id)
537 orphan_id = orphan.commit_id
538 await db_session.commit()
539
540 await run_gc(db_session, repo_b.repo_id)
541
542 # repo_a's commit untouched
543 still_there = await db_session.get(MusehubCommit, orphan_id)
544 assert still_there is not None
545
546 async def test_gc_async_exception_not_propagated_to_caller(self) -> None:
547 """A crash inside _run_gc_async must not escape to the push handler."""
548 with patch("musehub.db.database.AsyncSessionLocal") as mock_sl:
549 mock_sl.return_value.__aenter__ = AsyncMock(side_effect=Exception("catastrophe"))
550 mock_sl.return_value.__aexit__ = AsyncMock(return_value=False)
551 try:
552 await _run_gc_async("repo")
553 except Exception: # noqa: BLE001
554 pytest.fail("_run_gc_async propagated an exception")
555
556 async def test_build_symbol_async_exception_not_propagated(self) -> None:
557 with patch("musehub.db.database.AsyncSessionLocal") as mock_sl:
558 mock_sl.return_value.__aenter__ = AsyncMock(side_effect=Exception("boom"))
559 mock_sl.return_value.__aexit__ = AsyncMock(return_value=False)
560 try:
561 await _build_symbol_index_async("repo", "commit")
562 except Exception: # noqa: BLE001
563 pytest.fail("_build_symbol_index_async propagated an exception")
564
565 async def test_gc_invalid_repo_id_no_exception(self, db_session: AsyncSession) -> None:
566 result = await run_gc(db_session, "totally-invalid-uuid")
567 assert result.commits_deleted == 0
568
569 async def test_gc_large_repo_id_no_exception(self, db_session: AsyncSession) -> None:
570 result = await run_gc(db_session, "x" * 1000)
571 assert result.commits_deleted == 0
572
573 async def test_gc_does_not_expose_internal_state_via_result(self, db_session: AsyncSession) -> None:
574 """GCResult contains no raw SQL or stack traces."""
575 repo = await _mk_repo(db_session)
576 await db_session.commit()
577 result = await run_gc(db_session, repo.repo_id)
578 result_str = str(result)
579 assert "SELECT" not in result_str
580 assert "Traceback" not in result_str
581
582
583 # ─────────────────────────────────────────────────────────────────────────────
584 # LAYER 7 — PERFORMANCE
585 # ─────────────────────────────────────────────────────────────────────────────
586
587
588 class TestGCPerformance:
589 """Performance: GC latency budgets."""
590
591 async def test_gc_clean_repo_under_100ms(self, db_session: AsyncSession) -> None:
592 repo = await _mk_repo(db_session)
593 head = await _mk_commit(db_session, repo.repo_id)
594 await _mk_branch(db_session, repo.repo_id, head_commit_id=head.commit_id)
595 await db_session.commit()
596
597 t0 = time.perf_counter()
598 await run_gc(db_session, repo.repo_id)
599 elapsed = time.perf_counter() - t0
600 assert elapsed < 0.1, f"GC on clean repo took {elapsed:.3f}s"
601
602 async def test_gc_100_commit_chain_under_2s(self, db_session: AsyncSession) -> None:
603 repo = await _mk_repo(db_session)
604 parent_id: str | None = None
605 commits = []
606 for _ in range(100):
607 c = await _mk_commit(
608 db_session, repo.repo_id, parent_ids=[parent_id] if parent_id else []
609 )
610 commits.append(c)
611 parent_id = c.commit_id
612 await _mk_branch(db_session, repo.repo_id, head_commit_id=commits[-1].commit_id)
613 await db_session.commit()
614
615 t0 = time.perf_counter()
616 await run_gc(db_session, repo.repo_id)
617 elapsed = time.perf_counter() - t0
618 assert elapsed < 2.0, f"GC on 100-commit chain took {elapsed:.3f}s"
619
620 async def test_gc_50_orphans_under_1s(self, db_session: AsyncSession) -> None:
621 repo = await _mk_repo(db_session)
622 head = await _mk_commit(db_session, repo.repo_id)
623 await _mk_branch(db_session, repo.repo_id, head_commit_id=head.commit_id)
624 for _ in range(50):
625 await _mk_commit(db_session, repo.repo_id)
626 await db_session.commit()
627
628 t0 = time.perf_counter()
629 await run_gc(db_session, repo.repo_id)
630 elapsed = time.perf_counter() - t0
631 assert elapsed < 1.0, f"GC on 50 orphans took {elapsed:.3f}s"
632
633 async def test_gc_result_construction_is_negligible(self) -> None:
634 t0 = time.perf_counter()
635 for _ in range(10_000):
636 GCResult(repo_id="x")
637 elapsed = time.perf_counter() - t0
638 assert elapsed < 0.1, f"10K GCResult() took {elapsed:.3f}s"
639
640 async def test_run_gc_async_mocked_under_50ms(self) -> None:
641 mock_result = GCResult(repo_id="r", reachable_commit_count=5)
642 with patch("musehub.db.database.AsyncSessionLocal") as mock_sl:
643 mock_session = AsyncMock()
644 mock_sl.return_value.__aenter__ = AsyncMock(return_value=mock_session)
645 mock_sl.return_value.__aexit__ = AsyncMock(return_value=False)
646 with patch(
647 "musehub.services.musehub_gc.run_gc", new=AsyncMock(return_value=mock_result)
648 ):
649 t0 = time.perf_counter()
650 await _run_gc_async("repo-id")
651 elapsed = time.perf_counter() - t0
652 assert elapsed < 0.05, f"_run_gc_async overhead: {elapsed:.3f}s"
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago