orphan_scan.py
python
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