fix(thumbnails): surface backfill results + tighten validity check
Two coupled problems, operator-flagged 2026-06-01: "missing thumbnails
but triggering backfill found nothing."
1. **Backfill UI was a black box.** `POST /api/thumbnails/backfill`
returned just `{celery_task_id}` and the admin card said
"Enqueued." with no counts. There was no way to tell whether the
scan found 0 candidates, 5000 candidates, or whether the worker
even picked up the task. "Found nothing" was indistinguishable
from a broken queue.
Fix: refactor the scan into a sync helper (`_run_backfill_scan`)
shared by the Celery task and the API endpoint. The API now runs
the scan in an executor and returns `{scanned, enqueued, ok,
regenerated}`. The actual thumbnail generation work still goes
to the thumbnail Celery queue per row via
`generate_thumbnail.delay()` — the scan itself is fast
(SELECT id+thumbnail_path + a file.stat() per row).
2. **`_thumb_is_valid` accepted header-only corrupt files.** The
magic-byte check passed for any 8-byte file starting with a JPEG
or PNG header, including empty/truncated/zero-pad files that
browsers render as broken. Backfill counted these as `ok` and
never regenerated.
Fix: also require file size ≥ MIN_THUMB_BYTES (256). Real
thumbnails are minimum ~2KB even on solid-color sources; header-
only corrupt files top out around 12 bytes. 256 is well above
the corrupt floor and well below any legitimate thumbnail.
Plus the admin card now shows the per-run counts instead of
"Enqueued.":
Scanned 5,432 · enqueued 3 (2 regenerated) · 5,429 ok
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
"""Thumbnail admin API: backfill trigger."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from quart import Blueprint, jsonify
|
||||
|
||||
thumbnails_bp = Blueprint("thumbnails", __name__, url_prefix="/api/thumbnails")
|
||||
@@ -7,7 +9,20 @@ thumbnails_bp = Blueprint("thumbnails", __name__, url_prefix="/api/thumbnails")
|
||||
|
||||
@thumbnails_bp.route("/backfill", methods=["POST"])
|
||||
async def trigger_backfill():
|
||||
from ..tasks.thumbnail import backfill_thumbnails
|
||||
"""Run the backfill scan synchronously, return the counts. The actual
|
||||
thumbnail generation work is still off-loaded to the thumbnail Celery
|
||||
queue via `generate_thumbnail.delay()` per missing row — so this
|
||||
handler is fast even on a 100k-image library (a scan is just SELECT
|
||||
id, thumbnail_path + a file.stat() per row, no heavy work).
|
||||
|
||||
r = backfill_thumbnails.delay()
|
||||
return jsonify({"celery_task_id": r.id}), 202
|
||||
Operator-flagged 2026-06-01: the previous fire-and-forget shape
|
||||
returned `{celery_task_id}` only, so the admin UI had no idea whether
|
||||
backfill found 0 or 5000 candidates — \"found nothing\" was
|
||||
indistinguishable from \"the worker isn't picking up the task.\""""
|
||||
from ..tasks.thumbnail import _run_backfill_scan
|
||||
|
||||
# Sync scan inside an executor so we don't block the event loop.
|
||||
counts = await asyncio.get_running_loop().run_in_executor(
|
||||
None, _run_backfill_scan,
|
||||
)
|
||||
return jsonify(counts), 200
|
||||
|
||||
@@ -20,14 +20,32 @@ IMAGES_ROOT = Path("/images")
|
||||
THUMB_MAGIC_JPEG = b"\xff\xd8\xff"
|
||||
THUMB_MAGIC_PNG = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
# Minimum file size for a thumbnail to count as valid. Anything smaller
|
||||
# is almost certainly truncated/corrupt — a legitimate 400×400 JPEG@85
|
||||
# bottoms out around 2KB even on a solid-color image; 400×400 PNG starts
|
||||
# around 1KB. 256 bytes is well below any real thumbnail and well above
|
||||
# header-only corrupt files (~8-12 bytes). Operator-flagged 2026-06-01:
|
||||
# header-only corrupt files were silently passing the magic-byte check
|
||||
# and backfill counted them as "ok" — so broken-image tiles in the UI
|
||||
# never got regenerated even after running backfill.
|
||||
MIN_THUMB_BYTES = 256
|
||||
|
||||
|
||||
def _thumb_is_valid(path: Path) -> bool:
|
||||
"""Return True iff `path` exists and starts with a JPEG or PNG magic header.
|
||||
"""Return True iff `path` exists, starts with a JPEG or PNG magic
|
||||
header, AND is at least MIN_THUMB_BYTES on disk.
|
||||
|
||||
The on-disk thumbnail format is set by services/thumbnailer.py — JPEG for
|
||||
opaque sources, PNG for alpha sources. Anything else (missing file, OSError,
|
||||
truncated, wrong magic) is invalid.
|
||||
The on-disk thumbnail format is set by services/thumbnailer.py — JPEG
|
||||
for opaque sources, PNG for alpha sources. Anything else (missing
|
||||
file, OSError, truncated below the size floor, wrong magic) is
|
||||
invalid and gets re-enqueued.
|
||||
"""
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
except OSError:
|
||||
return False
|
||||
if size < MIN_THUMB_BYTES:
|
||||
return False
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
head = f.read(12)
|
||||
@@ -42,6 +60,59 @@ def _thumb_is_valid(path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _run_backfill_scan() -> dict:
|
||||
"""Synchronous scan logic shared by the Celery task and the API
|
||||
endpoint. Returns {enqueued, ok, regenerated, scanned}.
|
||||
|
||||
Operator-flagged 2026-06-01: the original task was fire-and-forget,
|
||||
so the admin UI couldn't show what backfill actually found —
|
||||
operator saw \"Enqueued.\" with no counts and assumed nothing was
|
||||
happening. Now the API runs this synchronously and returns the
|
||||
real numbers; the periodic Celery task wraps it too."""
|
||||
from sqlalchemy import select, update
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
enqueued = 0
|
||||
ok = 0
|
||||
regenerated = 0
|
||||
scanned = 0
|
||||
last_id = 0
|
||||
with SessionLocal() as session:
|
||||
while True:
|
||||
rows = session.execute(
|
||||
select(ImageRecord.id, ImageRecord.thumbnail_path)
|
||||
.where(ImageRecord.id > last_id)
|
||||
.order_by(ImageRecord.id.asc())
|
||||
.limit(500)
|
||||
).all()
|
||||
if not rows:
|
||||
break
|
||||
scanned += len(rows)
|
||||
for image_id, thumb_path in rows:
|
||||
if thumb_path is None:
|
||||
generate_thumbnail.delay(image_id)
|
||||
enqueued += 1
|
||||
elif _thumb_is_valid(Path(thumb_path)):
|
||||
ok += 1
|
||||
else:
|
||||
session.execute(
|
||||
update(ImageRecord)
|
||||
.where(ImageRecord.id == image_id)
|
||||
.values(thumbnail_path=None)
|
||||
)
|
||||
generate_thumbnail.delay(image_id)
|
||||
enqueued += 1
|
||||
regenerated += 1
|
||||
session.commit()
|
||||
last_id = rows[-1][0]
|
||||
return {
|
||||
"scanned": scanned,
|
||||
"enqueued": enqueued,
|
||||
"ok": ok,
|
||||
"regenerated": regenerated,
|
||||
}
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.thumbnail.generate_thumbnail",
|
||||
bind=True,
|
||||
@@ -84,48 +155,15 @@ def backfill_thumbnails(self) -> dict:
|
||||
"""Scan ImageRecord and enqueue generate_thumbnail for rows whose
|
||||
thumbnail is missing, gone from disk, or has wrong magic bytes.
|
||||
|
||||
Keyset paginates by id ASC, page size 500. NULLs out thumbnail_path for
|
||||
rows that point at a missing or corrupt file before enqueueing — keeps
|
||||
the DB self-consistent on partial runs and makes re-runs safe.
|
||||
Keyset paginates by id ASC, page size 500. NULLs out thumbnail_path
|
||||
for rows that point at a missing or corrupt file before enqueueing —
|
||||
keeps the DB self-consistent on partial runs and makes re-runs safe.
|
||||
|
||||
Returns {"enqueued": N, "ok": M, "regenerated": K} where:
|
||||
- enqueued = total generate_thumbnail.delay() calls
|
||||
- ok = rows whose existing thumbnail file is valid (skipped)
|
||||
- regenerated = subset of enqueued that had a non-NULL thumbnail_path
|
||||
cleared (i.e. missing + corrupt)
|
||||
Returns {scanned, enqueued, ok, regenerated} where:
|
||||
- scanned = total rows examined
|
||||
- enqueued = total generate_thumbnail.delay() calls
|
||||
- ok = rows whose existing thumbnail file is valid (skipped)
|
||||
- regenerated = subset of enqueued that had a non-NULL
|
||||
thumbnail_path cleared (i.e. missing + corrupt)
|
||||
"""
|
||||
from sqlalchemy import select, update
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
enqueued = 0
|
||||
ok = 0
|
||||
regenerated = 0
|
||||
last_id = 0
|
||||
with SessionLocal() as session:
|
||||
while True:
|
||||
rows = session.execute(
|
||||
select(ImageRecord.id, ImageRecord.thumbnail_path)
|
||||
.where(ImageRecord.id > last_id)
|
||||
.order_by(ImageRecord.id.asc())
|
||||
.limit(500)
|
||||
).all()
|
||||
if not rows:
|
||||
break
|
||||
for image_id, thumb_path in rows:
|
||||
if thumb_path is None:
|
||||
generate_thumbnail.delay(image_id)
|
||||
enqueued += 1
|
||||
elif _thumb_is_valid(Path(thumb_path)):
|
||||
ok += 1
|
||||
else:
|
||||
session.execute(
|
||||
update(ImageRecord)
|
||||
.where(ImageRecord.id == image_id)
|
||||
.values(thumbnail_path=None)
|
||||
)
|
||||
generate_thumbnail.delay(image_id)
|
||||
enqueued += 1
|
||||
regenerated += 1
|
||||
session.commit()
|
||||
last_id = rows[-1][0]
|
||||
return {"enqueued": enqueued, "ok": ok, "regenerated": regenerated}
|
||||
return _run_backfill_scan()
|
||||
|
||||
Reference in New Issue
Block a user