feat(integrity): verify_integrity task (keyset, fail-soft) + weekly beat

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 17:51:33 -04:00
parent c33741837b
commit e28de547c7
3 changed files with 224 additions and 0 deletions
+4
View File
@@ -66,6 +66,10 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.ml.apply_allowlist_tags",
"schedule": 86400.0,
},
"integrity-verify-weekly": {
"task": "backend.app.tasks.maintenance.verify_integrity",
"schedule": 604800.0, # weekly
},
},
timezone="UTC",
)
+81
View File
@@ -1,7 +1,9 @@
"""Periodic maintenance: recover stuck import tasks, garbage-collect old finished tasks."""
import logging
import subprocess
from datetime import UTC, datetime, timedelta
from pathlib import Path
from PIL import Image
from sqlalchemy import create_engine, delete, select, update
@@ -17,6 +19,8 @@ log = logging.getLogger(__name__)
STUCK_THRESHOLD_MINUTES = 30
OLD_TASK_DAYS = 7
PHASH_PAGE = 500
VERIFY_PAGE = 200
FFPROBE_TIMEOUT_SECONDS = 10
def _sync_session_factory():
@@ -115,3 +119,80 @@ def backfill_phash() -> int:
session.commit()
last_id = rows[-1].id
return updated
def _verify_one(path: Path, expected_sha: str, mime: str, sha_fn) -> str:
"""Compute the integrity verdict for one file. Status precedence:
failed_verification (can't run) > corrupt (sha mismatch / decode
fails) > ok (passes both). Never raises."""
try:
if not path.is_file():
return "failed_verification"
try:
actual_sha = sha_fn(path)
except (OSError, PermissionError):
return "failed_verification"
if actual_sha != expected_sha:
return "corrupt"
if mime and mime.startswith("image/"):
try:
with Image.open(path) as im:
im.verify()
except Exception:
return "corrupt"
return "ok"
if mime and mime.startswith("video/"):
try:
proc = subprocess.run(
["ffprobe", "-v", "error", "-i", str(path)],
capture_output=True,
timeout=FFPROBE_TIMEOUT_SECONDS,
)
except FileNotFoundError:
# ffprobe binary missing — environment problem, not file.
return "failed_verification"
except subprocess.TimeoutExpired:
return "corrupt"
return "ok" if proc.returncode == 0 else "corrupt"
# Unknown mime — sha matched already; trust that.
return "ok"
except Exception as exc:
log.warning("verify_integrity unexpected error for %s: %s", path, exc)
return "failed_verification"
@celery.task(name="backend.app.tasks.maintenance.verify_integrity")
def verify_integrity() -> int:
"""Verify every ImageRecord file: sha256 recompute + decode/probe
(PIL for images; ffprobe for videos). Writes integrity_status
(always — the column reflects the most recent verdict).
Keyset-paginated, fail-soft per row, idempotent. Returns the total
count verified."""
from ..services.importer import _sha256_of # reuse the importer's helper
SessionLocal = _sync_session_factory()
total = 0
counts = {"ok": 0, "corrupt": 0, "failed_verification": 0}
last_id = 0
with SessionLocal() as session:
while True:
rows = session.execute(
select(ImageRecord)
.where(ImageRecord.id > last_id)
.order_by(ImageRecord.id.asc())
.limit(VERIFY_PAGE)
).scalars().all()
if not rows:
break
for rec in rows:
rec.integrity_status = _verify_one(
Path(rec.path), rec.sha256, rec.mime, _sha256_of
)
counts[rec.integrity_status] = (
counts.get(rec.integrity_status, 0) + 1
)
total += 1
session.commit()
last_id = rows[-1].id
log.info("verify_integrity verdicts: %s (total %d)", counts, total)
return total