gabriel / musehub public
repair_objects.py python
136 lines 5.0 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago
1 #!/usr/bin/env python3
2 """Repair orphaned musehub_objects DB records whose bytes are absent from storage.
3
4 Run this when DB and storage have diverged (e.g. after a volume wipe or a failed
5 migration). The script:
6
7 1. Reads all object_ids from musehub_objects.
8 2. Checks each one against the configured storage backend in parallel.
9 3. Deletes DB rows whose bytes are missing from storage.
10
11 After a successful run, a normal ``muse push`` from any client that holds the
12 missing objects will re-upload them — the filter endpoint will correctly report
13 them as missing because their DB records are gone.
14
15 Run inside Docker on the target environment:
16
17 docker exec musehub-green python3 /app/deploy/repair_objects.py [--dry-run] [--batch 500]
18
19 Options:
20 --dry-run Print what would be deleted without touching the DB.
21 --batch N Process N objects per DB page (default 500).
22 --concurrency N Parallel storage checks per batch (default 64).
23 """
24 from __future__ import annotations
25
26 import argparse
27 import asyncio
28 import sys
29
30 import sqlalchemy as sa
31 from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
32 from sqlalchemy.orm import sessionmaker
33
34 from musehub.config import settings
35 from musehub.db import musehub_models as db
36 from musehub.storage import get_backend
37
38
39 async def repair(dry_run: bool, batch_size: int, concurrency: int) -> int:
40 engine = create_async_engine(settings.database_url, echo=False)
41 async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
42 backend = get_backend()
43
44 total_checked = 0
45 total_missing = 0
46 total_deleted = 0
47 errors = 0
48
49 async with async_session() as session:
50 # Page through all objects to avoid loading millions of rows at once.
51 offset = 0
52 while True:
53 rows = (await session.execute(
54 sa.select(db.MusehubObject.object_id)
55 .where(db.MusehubObject.deleted_at.is_(None))
56 .order_by(db.MusehubObject.object_id)
57 .offset(offset)
58 .limit(batch_size)
59 )).scalars().all()
60
61 if not rows:
62 break
63
64 offset += len(rows)
65 total_checked += len(rows)
66
67 # Check storage existence in parallel, bounded by concurrency.
68 sem = asyncio.Semaphore(concurrency)
69
70 async def _check(oid: str) -> tuple[str, bool]:
71 async with sem:
72 try:
73 exists = await backend.exists(oid)
74 except Exception as exc:
75 print(f" ERROR checking {oid}: {exc}", file=sys.stderr)
76 return oid, True # assume present to avoid false deletion
77 return oid, exists
78
79 results = await asyncio.gather(*(_check(oid) for oid in rows))
80 orphaned = [oid for oid, exists in results if not exists]
81 total_missing += len(orphaned)
82
83 if not orphaned:
84 print(f" batch offset={offset - len(rows)}: {len(rows)} checked, 0 orphaned")
85 continue
86
87 print(f" batch offset={offset - len(rows)}: {len(rows)} checked, {len(orphaned)} orphaned")
88 for oid in orphaned:
89 print(f" orphaned: {oid}")
90
91 if dry_run:
92 continue
93
94 try:
95 await session.execute(
96 sa.delete(db.MusehubObject).where(
97 db.MusehubObject.object_id.in_(orphaned)
98 )
99 )
100 await session.commit()
101 total_deleted += len(orphaned)
102 except Exception as exc:
103 print(f" ERROR deleting batch: {exc}", file=sys.stderr)
104 await session.rollback()
105 errors += len(orphaned)
106
107 prefix = "[dry-run] " if dry_run else ""
108 print(
109 f"\n{prefix}Repair complete: "
110 f"{total_checked} checked, "
111 f"{total_missing} orphaned, "
112 f"{total_deleted} deleted, "
113 f"{errors} errors"
114 )
115 if not dry_run and total_deleted > 0:
116 print(
117 f"\nNext step: muse push <remote> <branch> from any client that holds "
118 f"the missing objects. The filter endpoint will now report all "
119 f"{total_deleted} deleted objects as missing and the client will re-upload them."
120 )
121 return errors
122
123
124 def main() -> None:
125 parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
126 parser.add_argument("--dry-run", action="store_true", help="Print orphaned records without deleting")
127 parser.add_argument("--batch", type=int, default=500, metavar="N", help="DB page size (default 500)")
128 parser.add_argument("--concurrency", type=int, default=64, metavar="N", help="Parallel storage checks (default 64)")
129 args = parser.parse_args()
130
131 errors = asyncio.run(repair(dry_run=args.dry_run, batch_size=args.batch, concurrency=args.concurrency))
132 sys.exit(1 if errors else 0)
133
134
135 if __name__ == "__main__":
136 main()
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago