gabriel / musehub public
orphan_scan.py python
71 lines 2.4 KB
Raw
sha256:1dd27e6d5c24550177b01b0a171e8375c07cf046b549e7683364706b6522d3bc docs(#139): mark all 11 checklist items resolved, document … Sonnet 5 12 days ago
1 """Periodic orphan-object scanner for MuseHub.
2
3 An orphaned object is a row in ``musehub_objects`` with zero rows in
4 ``musehub_object_refs``. This can occur when:
5 - A bug skips the ref-upsert step after writing an object.
6 - A failed GC pass deletes ref rows but not the object row.
7 - A future migration error leaves unreferenced objects.
8
9 GC (musehub_gc.run_gc) handles the normal case. This scanner is a
10 belt-and-suspenders sweep for anything GC missed.
11
12 ``scan_orphan_objects(session)`` → ``OrphanScanResult``
13 Read-only scan returning object_ids with no ref rows.
14
15 ``delete_orphan_objects(session)`` → int
16 Deletes orphaned objects and returns the count. Call only during a
17 maintenance window — not on the hot request path.
18 """
19
20 from dataclasses import dataclass, field
21
22 from sqlalchemy import delete, select
23 from sqlalchemy.engine import CursorResult
24 from sqlalchemy.ext.asyncio import AsyncSession
25 from sqlalchemy.sql.elements import ColumnElement
26
27 from musehub.db.musehub_repo_models import MusehubObject, MusehubObjectRef
28
29 @dataclass
30 class OrphanScanResult:
31 """Summary of one orphan-object scan pass."""
32 orphaned_object_ids: list[str] = field(default_factory=list)
33
34 @property
35 def count(self) -> int:
36 return len(self.orphaned_object_ids)
37
38 @property
39 def ok(self) -> bool:
40 return self.count == 0
41
42 def _orphan_predicate() -> ColumnElement[bool]:
43 """SQLAlchemy predicate: object has no rows in musehub_object_refs."""
44 return ~MusehubObject.object_id.in_(
45 select(MusehubObjectRef.object_id)
46 )
47
48 async def scan_orphan_objects(session: AsyncSession) -> OrphanScanResult:
49 """Return all object rows with no entry in musehub_object_refs.
50
51 Read-only — does not modify the database.
52 """
53 rows = (
54 await session.execute(
55 select(MusehubObject.object_id).where(_orphan_predicate())
56 )
57 ).scalars().all()
58 return OrphanScanResult(orphaned_object_ids=list(rows))
59
60 async def delete_orphan_objects(session: AsyncSession) -> int:
61 """Delete all orphaned object rows and return the count deleted.
62
63 Commits the transaction before returning.
64 Call only during a maintenance window — not on a hot request path.
65 """
66 result = await session.execute(
67 delete(MusehubObject).where(_orphan_predicate())
68 )
69 assert isinstance(result, CursorResult)
70 await session.commit()
71 return result.rowcount or 0
File History 2 commits
sha256:1dd27e6d5c24550177b01b0a171e8375c07cf046b549e7683364706b6522d3bc docs(#139): mark all 11 checklist items resolved, document … Sonnet 5 12 days ago
sha256:649011bedd713e22f7dca4c4be94bdefbf3b10950d9fc80235928d0b0b823be8 docs: track issue #139 (two-column app-shell refactor) plan… Sonnet 5 12 days ago