object_integrity.py
python
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2
chore: bump version to 0.2.0rc15 for musehub#113 fix release
Sonnet 4.6
patch
15 days ago
| 1 | """Periodic object-store integrity scanner and soft-delete reaper for MuseHub. |
| 2 | |
| 3 | Object integrity |
| 4 | ---------------- |
| 5 | Content-addressed objects are immutable once written. This module provides |
| 6 | ``scan_object_integrity(session, backend, sample_size)`` which: |
| 7 | |
| 8 | 1. Draws a random sample of ``MusehubObject`` rows (default 200). |
| 9 | 2. Re-reads each object from the storage backend. |
| 10 | 3. Re-computes SHA-256(content) and compares it with the declared object_id. |
| 11 | 4. Returns an ``ObjectIntegrityScanResult`` listing any mismatches. |
| 12 | |
| 13 | Run this on a schedule (e.g. nightly cron or health-check endpoint) — not on |
| 14 | the hot request path. |
| 15 | |
| 16 | Soft-delete reaper |
| 17 | ------------------ |
| 18 | ``reap_deleted_objects(session, backend, retention_days)`` hard-deletes objects |
| 19 | whose ``deleted_at`` is older than ``retention_days`` (default ``object_retention_days`` |
| 20 | from settings). It removes the backing file/S3 object *and* the DB row. |
| 21 | |
| 22 | Call only during maintenance windows. |
| 23 | """ |
| 24 | |
| 25 | import logging |
| 26 | from dataclasses import dataclass, field |
| 27 | from datetime import datetime, timedelta, timezone |
| 28 | |
| 29 | from sqlalchemy import delete, func, select |
| 30 | from sqlalchemy.ext.asyncio import AsyncSession |
| 31 | |
| 32 | from muse.core.types import blob_id, split_id |
| 33 | from musehub.config import settings |
| 34 | from musehub.db.musehub_repo_models import MusehubObject |
| 35 | from musehub.storage.backends import StorageBackend |
| 36 | |
| 37 | logger = logging.getLogger(__name__) |
| 38 | |
| 39 | _DEFAULT_SAMPLE_SIZE = 200 |
| 40 | |
| 41 | @dataclass |
| 42 | class ObjectIntegrityMismatch: |
| 43 | """One corrupted or missing object found during a scan pass.""" |
| 44 | object_id: str |
| 45 | repo_id: str |
| 46 | reason: str # "missing" | "hash_mismatch" |
| 47 | |
| 48 | @dataclass |
| 49 | class ObjectIntegrityScanResult: |
| 50 | """Summary of one integrity scan pass.""" |
| 51 | sampled: int = 0 |
| 52 | mismatches: list[ObjectIntegrityMismatch] = field(default_factory=list) |
| 53 | |
| 54 | @property |
| 55 | def ok(self) -> bool: |
| 56 | return len(self.mismatches) == 0 |
| 57 | |
| 58 | @property |
| 59 | def mismatch_count(self) -> int: |
| 60 | return len(self.mismatches) |
| 61 | |
| 62 | async def scan_object_integrity( |
| 63 | session: AsyncSession, |
| 64 | backend: StorageBackend, |
| 65 | sample_size: int = _DEFAULT_SAMPLE_SIZE, |
| 66 | ) -> ObjectIntegrityScanResult: |
| 67 | """Sample up to ``sample_size`` live objects and verify their SHA-256 hashes. |
| 68 | |
| 69 | Read-only — does not modify the database or storage. |
| 70 | """ |
| 71 | rows = ( |
| 72 | await session.execute( |
| 73 | select(MusehubObject.object_id) |
| 74 | .where(MusehubObject.deleted_at.is_(None)) |
| 75 | .order_by(func.random()) |
| 76 | .limit(sample_size) |
| 77 | ) |
| 78 | ).scalars().all() |
| 79 | |
| 80 | result = ObjectIntegrityScanResult(sampled=len(rows)) |
| 81 | |
| 82 | for object_id in rows: |
| 83 | content = await backend.get(object_id) |
| 84 | if content is None: |
| 85 | result.mismatches.append( |
| 86 | ObjectIntegrityMismatch( |
| 87 | object_id=object_id, |
| 88 | repo_id="", |
| 89 | reason="missing", |
| 90 | ) |
| 91 | ) |
| 92 | logger.warning("integrity: object missing from storage: %s", object_id) |
| 93 | continue |
| 94 | |
| 95 | if object_id.startswith("sha256:"): |
| 96 | _, declared = split_id(object_id) |
| 97 | _, actual = split_id(blob_id(content)) |
| 98 | if actual != declared: |
| 99 | result.mismatches.append( |
| 100 | ObjectIntegrityMismatch( |
| 101 | object_id=object_id, |
| 102 | repo_id="", |
| 103 | reason="hash_mismatch", |
| 104 | ) |
| 105 | ) |
| 106 | logger.error( |
| 107 | "integrity: hash mismatch for %s: declared=%s actual=%s", |
| 108 | object_id, declared, actual, |
| 109 | ) |
| 110 | |
| 111 | return result |
| 112 | |
| 113 | async def soft_delete_object( |
| 114 | session: AsyncSession, |
| 115 | object_id: str, |
| 116 | ) -> bool: |
| 117 | """Mark an object as logically deleted by setting deleted_at to now. |
| 118 | |
| 119 | Returns True if the row was found and updated, False if not found. |
| 120 | Does NOT remove the backing file — that is deferred to ``reap_deleted_objects``. |
| 121 | """ |
| 122 | row = await session.get(MusehubObject, object_id) |
| 123 | if row is None: |
| 124 | return False |
| 125 | if row.deleted_at is None: |
| 126 | row.deleted_at = datetime.now(tz=timezone.utc) |
| 127 | await session.flush() |
| 128 | return True |
| 129 | |
| 130 | async def reap_deleted_objects( |
| 131 | session: AsyncSession, |
| 132 | backend: StorageBackend, |
| 133 | retention_days: int | None = None, |
| 134 | ) -> int: |
| 135 | """Hard-delete objects past the retention window. |
| 136 | |
| 137 | For each object whose ``deleted_at`` is older than ``retention_days``: |
| 138 | 1. Remove the backing file / S3 object from storage. |
| 139 | 2. Delete the DB row. |
| 140 | |
| 141 | Returns the number of objects permanently removed. |
| 142 | Commits the transaction before returning. |
| 143 | |
| 144 | Call only during a maintenance window — not on the hot request path. |
| 145 | """ |
| 146 | days = retention_days if retention_days is not None else settings.object_retention_days |
| 147 | cutoff = datetime.now(tz=timezone.utc) - timedelta(days=days) |
| 148 | |
| 149 | rows = ( |
| 150 | await session.execute( |
| 151 | select(MusehubObject.object_id) |
| 152 | .where( |
| 153 | MusehubObject.deleted_at.isnot(None), |
| 154 | MusehubObject.deleted_at < cutoff, |
| 155 | ) |
| 156 | ) |
| 157 | ).scalars().all() |
| 158 | |
| 159 | reaped = 0 |
| 160 | for object_id in rows: |
| 161 | try: |
| 162 | await backend.delete(object_id) |
| 163 | except Exception as exc: |
| 164 | logger.warning("reap: failed to delete storage for %s: %s", object_id, exc) |
| 165 | |
| 166 | await session.execute( |
| 167 | delete(MusehubObject).where(MusehubObject.object_id == object_id) |
| 168 | ) |
| 169 | reaped += 1 |
| 170 | |
| 171 | await session.commit() |
| 172 | logger.info("reap: hard-deleted %d object(s) past %d-day retention window", reaped, days) |
| 173 | return reaped |
File History
11 commits
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2
chore: bump version to 0.2.0rc15 for musehub#113 fix release
Sonnet 4.6
patch
15 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352
Merge branch 'task/version-tags-phase3-server' into dev
Human
17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53
merge: rescue snapshot-recovery hardening (c00aa21d) into d…
Opus 4.8
minor
⚠
30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a
fix: remove false-positive proposal_comments index drop fro…
Sonnet 4.6
patch
34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3
feat: render markdown mists as HTML with heading anchor links
Sonnet 4.6
patch
34 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6
Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump …
Human
36 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee
Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump …
Human
36 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c
Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo…
Human
36 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd
Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop…
Human
36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f
fix: use wire_bytes not mpack_bytes_raw in compute_object_b…
Sonnet 4.6
patch
48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583
rename: delta_add → delta_upsert across wire format, models…
Sonnet 4.6
patch
50 days ago