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
188 lines
6.8 KiB
Python
188 lines
6.8 KiB
Python
"""Failure triage (#125): probe errored jobs' files, flag verdicts, recover.
|
|
|
|
The probe is the arbiter: reason strings only bucket the overview. A file that
|
|
passes checksum+decode is 'file_ok' (operational failure); anything else is a
|
|
'defect' — surfaced for recovery and excluded from /retry_errors.
|
|
"""
|
|
import hashlib
|
|
|
|
import pytest
|
|
from PIL import Image as PILImage
|
|
from sqlalchemy import select
|
|
|
|
from backend.app.models import (
|
|
Artist,
|
|
ExternalLink,
|
|
GpuJob,
|
|
ImageProvenance,
|
|
ImageRecord,
|
|
Post,
|
|
Source,
|
|
)
|
|
from backend.app.services.ml.gpu_triage import (
|
|
classify_reason,
|
|
recover_defective_image,
|
|
triage_errored_jobs,
|
|
)
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
def test_classify_reason_buckets():
|
|
assert classify_reason(
|
|
"no frames sampled from video — moov atom not found"
|
|
) == "truncated_or_corrupt"
|
|
assert classify_reason("ffmpeg timed out after 1200s") == "timeout"
|
|
assert classify_reason(
|
|
"gave up after repeated transient failures: HTTPConnectionPool read timed out"
|
|
) == "transient"
|
|
assert classify_reason(
|
|
"poisoned: 10+ lease attempts without ever completing"
|
|
) == "poisoned"
|
|
assert classify_reason("cannot identify image file") == "decode"
|
|
assert classify_reason("something novel") == "other"
|
|
assert classify_reason(None) == "other"
|
|
|
|
|
|
async def _errored_image(db, tmp_path, *, name, sha, content: bytes | None,
|
|
error="no frames sampled from video — moov atom not found"):
|
|
"""An ImageRecord (file written iff content is not None) + an errored job."""
|
|
path = tmp_path / name
|
|
if content is not None:
|
|
path.write_bytes(content)
|
|
img = ImageRecord(
|
|
path=str(path), sha256=sha, size_bytes=1, mime="image/png",
|
|
width=1, height=1, origin="imported_filesystem",
|
|
integrity_status="unknown",
|
|
)
|
|
db.add(img)
|
|
await db.flush()
|
|
db.add(GpuJob(image_record_id=img.id, task="ccip", status="error",
|
|
error=error, attempts=3))
|
|
await db.flush()
|
|
return img
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_triage_probes_and_splits_defect_vs_file_ok(db, tmp_path):
|
|
# Healthy: a real PNG whose recorded sha matches its bytes → file_ok.
|
|
ok_path = tmp_path / "fine.png"
|
|
PILImage.new("RGB", (4, 4), (200, 30, 30)).save(ok_path)
|
|
ok_sha = hashlib.sha256(ok_path.read_bytes()).hexdigest()
|
|
ok = await _errored_image(db, tmp_path, name="fine.png", sha=ok_sha,
|
|
content=None, error="ffmpeg timed out after 1200s")
|
|
# Corrupt: bytes don't match the recorded sha → defect.
|
|
bad = await _errored_image(db, tmp_path, name="bad.png", sha="0" * 64,
|
|
content=b"not a real png")
|
|
# Missing: no file on disk at all → defect (failed_verification).
|
|
gone = await _errored_image(db, tmp_path, name="gone.png", sha="1" * 64,
|
|
content=None)
|
|
await db.commit()
|
|
|
|
summary = await db.run_sync(lambda s: triage_errored_jobs(s))
|
|
assert summary["probed"] == 3
|
|
assert summary["defect"] == 2
|
|
assert summary["file_ok"] == 1
|
|
assert summary["partial"] is False
|
|
|
|
# Column selects, not ORM refresh — the sweep wrote via Core DML.
|
|
rows = dict((await db.execute(
|
|
select(GpuJob.image_record_id, GpuJob.triage_status)
|
|
.where(GpuJob.status == "error")
|
|
)).all())
|
|
assert rows[ok.id] == "file_ok"
|
|
assert rows[bad.id] == "defect"
|
|
assert rows[gone.id] == "defect"
|
|
verdicts = dict((await db.execute(
|
|
select(ImageRecord.id, ImageRecord.integrity_status)
|
|
.where(ImageRecord.id.in_([ok.id, bad.id, gone.id]))
|
|
)).all())
|
|
assert verdicts[ok.id] == "ok"
|
|
assert verdicts[bad.id] == "corrupt"
|
|
assert verdicts[gone.id] == "failed_verification"
|
|
|
|
# Idempotent: everything already triaged → no re-probe.
|
|
again = await db.run_sync(lambda s: triage_errored_jobs(s))
|
|
assert again["probed"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_recover_without_pollable_source_reports_no_source(db, tmp_path):
|
|
img = await _errored_image(db, tmp_path, name="orphan.png", sha="2" * 64,
|
|
content=b"x")
|
|
await db.commit()
|
|
res = await db.run_sync(
|
|
lambda s: recover_defective_image(s, img.id, images_root=tmp_path)
|
|
)
|
|
assert res["status"] == "no_source"
|
|
still_there = (await db.execute(
|
|
select(ImageRecord.id).where(ImageRecord.id == img.id)
|
|
)).scalar_one_or_none()
|
|
assert still_there == img.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_recover_deletes_record_and_requeues_source(
|
|
client, db, tmp_path, monkeypatch,
|
|
):
|
|
img = await _errored_image(db, tmp_path, name="fixme.png", sha="3" * 64,
|
|
content=b"x")
|
|
artist = Artist(name="Recov", slug="recov")
|
|
db.add(artist)
|
|
await db.flush()
|
|
src = Source(artist_id=artist.id, platform="patreon",
|
|
url="https://www.patreon.com/recov", enabled=True)
|
|
db.add(src)
|
|
await db.flush()
|
|
post = Post(artist_id=artist.id, source_id=src.id, external_post_id="p1")
|
|
db.add(post)
|
|
await db.flush()
|
|
db.add(ImageProvenance(image_record_id=img.id, post_id=post.id,
|
|
source_id=src.id))
|
|
# A settled external-host link on the same post: recovery must reset it
|
|
# (the SURGICAL path — deep posts are never re-walked by the cadence).
|
|
link = ExternalLink(post_id=post.id, artist_id=artist.id, host="mega",
|
|
url="https://mega.nz/file/x#key", status="downloaded",
|
|
attempts=2)
|
|
db.add(link)
|
|
await db.flush()
|
|
img_id, src_id, link_id = img.id, src.id, link.id
|
|
await db.commit()
|
|
|
|
queued = []
|
|
monkeypatch.setattr(
|
|
"backend.app.tasks.download.download_source.delay",
|
|
lambda sid: queued.append(sid),
|
|
)
|
|
fetches = []
|
|
monkeypatch.setattr(
|
|
"backend.app.tasks.external.fetch_external_link.delay",
|
|
lambda lid: fetches.append(lid),
|
|
)
|
|
|
|
resp = await client.post(f"/api/gpu/errors/{img_id}/recover")
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["status"] == "refetch_queued"
|
|
assert body["source_id"] == src_id
|
|
assert body["links_reset"] == 1
|
|
assert queued == [src_id]
|
|
assert fetches == [link_id]
|
|
|
|
# The link is armed for a fresh attempt.
|
|
row = (await db.execute(
|
|
select(ExternalLink.status, ExternalLink.attempts)
|
|
.where(ExternalLink.id == link_id)
|
|
)).one()
|
|
assert tuple(row) == ("pending", 0)
|
|
|
|
# Record gone — the error tombstones cascade away with it.
|
|
remaining = (await db.execute(
|
|
select(ImageRecord.id).where(ImageRecord.id == img_id)
|
|
)).scalar_one_or_none()
|
|
assert remaining is None
|
|
jobs_left = (await db.execute(
|
|
select(GpuJob.id).where(GpuJob.image_record_id == img_id)
|
|
)).scalars().all()
|
|
assert jobs_left == []
|