gabriel / musehub public
object_integrity.py python
173 lines 5.5 KB
Raw
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