feat(recovery): surgical re-fetch for deep posts via ExternalLink reset
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
This commit is contained in:
@@ -12,6 +12,7 @@ from sqlalchemy import select
|
||||
|
||||
from backend.app.models import (
|
||||
Artist,
|
||||
ExternalLink,
|
||||
GpuJob,
|
||||
ImageProvenance,
|
||||
ImageRecord,
|
||||
@@ -138,7 +139,14 @@ async def test_recover_deletes_record_and_requeues_source(
|
||||
await db.flush()
|
||||
db.add(ImageProvenance(image_record_id=img.id, post_id=post.id,
|
||||
source_id=src.id))
|
||||
img_id, src_id = img.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 = []
|
||||
@@ -146,13 +154,27 @@ async def test_recover_deletes_record_and_requeues_source(
|
||||
"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(
|
||||
|
||||
Reference in New Issue
Block a user