"""GPU-failure triage (#125): classify errored jobs, PROBE the file, recover. An errored GPU job is a tombstone with a stored reason, but the reason alone is a suspicion, not a verdict — a timeout can hit a perfectly fine file, and "moov atom not found" can mean a truncated download OR a one-off transfer fault. So triage EVALUATES: it runs the real integrity probe (sha256 recompute + PIL/ffprobe — verify_integrity's own machinery) on each errored image ONCE and records both verdicts: ImageRecord.integrity_status <- file-level verdict (ok / corrupt / ...) GpuJob.triage_status <- 'defect' (file is bad: recovery material, excluded from /retry_errors) 'file_ok' (file passes: the failure was operational, safe to retry) Recovery reuses established primitives: delete the defective copy + record (cleanup_service.delete_images — full cascade) and re-poll the image's subscription Source (the Layer-2 refetch pattern: gallery-dl re-fetches the now-absent file on the next source check). Images without a pollable Source report 'no_source' — manual remediation. Every classification is logged at WARNING so the operator notices in Logs / System Activity. """ import logging import time from datetime import UTC, datetime from pathlib import Path from sqlalchemy import select, update from sqlalchemy.orm import Session from ...models import GpuJob, ImageProvenance, ImageRecord, Source from ..cleanup_service import delete_images log = logging.getLogger(__name__) # Reason buckets for the triage overview (reporting only — the PROBE decides # 'defect', never the string). Ordered: first match wins. _REASON_BUCKETS = ( ("poisoned", ("poisoned:",)), ("transient", ("gave up after repeated transient", "curator unreachable", "connection", "read timed out")), ("timeout", ("timed out", "timeout")), ("truncated_or_corrupt", ("moov atom", "invalid data", "end of file", "header missing", "error reading header", "truncated", "premature", "corrupt", "no frames sampled")), ("decode", ("cannot identify", "decompression", "broken data stream", "unrecognized data")), ) def classify_reason(error: str | None) -> str: """Bucket a stored job-error string for the overview table.""" text = (error or "").lower() if not text: return "other" for bucket, needles in _REASON_BUCKETS: if any(n in text for n in needles): return bucket return "other" def triage_errored_jobs( session: Session, *, time_budget_seconds: float = 300.0, ) -> dict: """Probe every not-yet-triaged errored image and write both verdicts. Time-boxed (sha256 of a large original over NFS can take tens of seconds) and inherently resumable: rows are selected by `triage_status IS NULL`, so the next sweep continues exactly where a budget cut stopped. Commits per image so a mid-run crash keeps completed verdicts.""" image_ids = session.execute( select(GpuJob.image_record_id) .where(GpuJob.status == "error", GpuJob.triage_status.is_(None)) .group_by(GpuJob.image_record_id) .order_by(GpuJob.image_record_id) ).scalars().all() counts = {"probed": 0, "defect": 0, "file_ok": 0, "partial": False} if not image_ids: return counts # Lazy imports: the probe helper lives in the maintenance task module and # the hasher in the importer — importing either at module load would pull # celery into every service consumer. from ...tasks.maintenance import _verify_one from ..importer import _sha256_of started = time.monotonic() for image_id in image_ids: if time.monotonic() - started > time_budget_seconds: counts["partial"] = True break rec = session.get(ImageRecord, image_id) if rec is None: # record deleted since the job errored continue verdict = _verify_one(Path(rec.path), rec.sha256, rec.mime, _sha256_of) # 'ok' means the failure was operational; anything else (corrupt / # failed_verification = missing/unreadable) makes the file itself the # problem — recovery material. triage = "file_ok" if verdict == "ok" else "defect" reason = session.execute( select(GpuJob.error) .where(GpuJob.image_record_id == image_id, GpuJob.status == "error") .limit(1) ).scalar_one_or_none() rec.integrity_status = verdict session.execute( update(GpuJob) .where(GpuJob.image_record_id == image_id, GpuJob.status == "error") .values(triage_status=triage, updated_at=datetime.now(UTC)) ) session.commit() counts["probed"] += 1 counts[triage] += 1 log.warning( "gpu triage: image %s (%s) job error %r -> integrity probe %r -> %s", image_id, rec.path, (reason or "")[:120], verdict, triage, ) return counts def recover_defective_image( session: Session, image_id: int, *, images_root: Path, ) -> dict: """Delete the defective copy + record and queue its re-fetch — surgically where possible. Two re-fetch layers (operator 2026-07-03: deep back-catalogue items are NEVER re-walked by the normal cadence, so recovery can't rely on it): 1. SURGICAL: any ExternalLink rows on the image's post(s) are reset + re-dispatched — this is how external-host files (the common defect case: big videos) come back regardless of post age. Sha-dedupe at import discards payload files that still exist. 2. BROAD: a source re-check, which re-fetches gallery-dl-NATIVE files the walk still reaches (recent posts). A native file deeper than the walk needs a per-source backfill/deep scan — reported via links_reset=0 so the caller can say so. The record delete cascades the error tombstones with it. 'no_source' when no enabled, real-URL Source resolves via provenance — manual there.""" rec = session.get(ImageRecord, image_id) if rec is None: return {"status": "not_found"} src_id = session.execute( select(Source.id) .join(ImageProvenance, ImageProvenance.source_id == Source.id) .where( ImageProvenance.image_record_id == image_id, Source.enabled.is_(True), ~Source.url.like("sidecar:%"), # synthetic anchor — not pollable ) .order_by(Source.id.asc()) ).scalars().first() if src_id is None: return {"status": "no_source"} # Capture the post linkage BEFORE the delete cascades provenance away. post_ids = session.execute( select(ImageProvenance.post_id) .where(ImageProvenance.image_record_id == image_id) ).scalars().all() path = rec.path summary = delete_images(session, image_ids=[image_id], images_root=images_root) from ..external_links import refetch_links_for_post links_reset = 0 for pid in post_ids: links_reset += refetch_links_for_post(session, pid)["links_reset"] # Lazy import (services -> tasks would cycle at module load). from ...tasks.download import download_source download_source.delay(src_id) log.warning( "gpu triage recovery: deleted defective image %s (%s); reset %d " "external link(s) and queued a re-check of source %s", image_id, path, links_reset, src_id, ) return { "status": "refetch_queued", "source_id": src_id, "links_reset": links_reset, **summary, }