aa12a57f97
Operator-flagged: the recovered defective files live DEEP in their artists'
back-catalogues — the normal download cadence (by design, via the seen-gates)
will never re-walk them, so recovery's source re-check alone can't bring them
back. The durable per-post handle is the ExternalLink row, which survives the
image delete:
- services/external_links.refetch_links_for_post: reset settled links to
pending (fresh attempt budget, in-flight left alone) + dispatch their
fetches; sha-dedupe at import discards payload files that still exist, so
only the missing file lands.
- recover_defective_image now captures the image's post ids BEFORE the delete
cascades provenance away and resets those posts' links — future recoveries
are surgical automatically (response gains links_reset; source re-check
stays for gallery-dl-native files within walk reach).
- POST /api/admin/posts/refetch-external {external_post_id, source_id?} — the
manual tool for the three files recovered before this fix existed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
180 lines
7.6 KiB
Python
180 lines
7.6 KiB
Python
"""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,
|
|
}
|