Files
FabledCurator/backend/app/tasks/library_audit.py
T
bvandeusen f2e9ae07dc fix(audit): chunk + self-resume library scans (stop the 2h queue-hog timeouts)
scan_library_for_rule ran one 2-hour pass that timed out on large libraries and
held the concurrency-1 maintenance queue the whole time, starving vacuum/backup/
normalize (operator-flagged — it was the dominant entry in the 24h failures).

It now runs ~10-min chunks and re-enqueues itself until the library is
exhausted, matching the operator's preferred pattern (reasonable timeout → retry
queued → other things process between). New columns (alembic 0039):
resume_after_id persists the keyset cursor so a chunk continues where the last
left off; last_progress_at lets the recovery sweep tell a progressing multi-
chunk audit from a dead one (it now measures staleness from last_progress_at,
not started_at). Matches accumulate across chunks. soft/hard limits dropped
2h→15/16.7 min so the in-chunk budget fires first; a soft-limit backstop
re-enqueues to resume instead of erroring the whole run.

Tests: time-box → re-enqueue (status stays running); resume carries prior
matches and appends new ones. Existing full-scan tests unchanged (small sets
finish in one chunk).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:08:19 -04:00

218 lines
8.9 KiB
Python

"""scan_library_for_rule Celery task — iterates image_record in keyset-
paginated batches, evaluates the audit rule per image, populates
LibraryAuditRun.matched_ids. Runs on the maintenance queue with a 2h soft
time limit (plenty of margin for 100k+ image libraries at ~100ms PIL
decode + histogram per image).
State machine:
start: status='running'
end success: status='ready'
end error: status='error', error=traceback
oversize: status='error', error='matched too many images; tighten threshold'
external cancel: scan sees status='cancelled' between batches, exits.
"""
import logging
import time
import traceback
from datetime import UTC, datetime
from celery.exceptions import SoftTimeLimitExceeded
from PIL import Image
from sqlalchemy import select, update
from sqlalchemy.exc import DBAPIError, OperationalError
from ..celery_app import celery
from ..models import ImageRecord, LibraryAuditRun
from ..services.audits import single_color, transparency
from ._sync_engine import sync_session_factory as _sync_session_factory
log = logging.getLogger(__name__)
_BATCH = 500
_PROGRESS_TICK = 100
_MAX_MATCHED = 50_000
# One chunk's wall-clock budget. Was a single 2h pass that timed out on large
# libraries and held the concurrency-1 maintenance queue the whole time
# (operator-flagged 2026-06-07). Now: scan ~10 min, persist the keyset cursor +
# matches, re-enqueue to continue — so backups/vacuum/normalize chunks can
# interleave. soft/hard limits sit just above so the budget fires first.
_CHUNK_SECONDS = 600
_RULES = {
"transparency": transparency.evaluate,
"single_color": single_color.evaluate,
}
@celery.task(
name="backend.app.tasks.library_audit.scan_library_for_rule",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=5,
retry_backoff_max=60,
retry_jitter=True,
max_retries=3,
soft_time_limit=900,
time_limit=1000,
)
def scan_library_for_rule(self, audit_id: int) -> dict:
"""See module docstring. Time-boxed + self-resuming: one call scans a
~10-min chunk, persists the resume cursor + matches, and re-enqueues itself
until the library is exhausted. Returns a small summary dict for eager-mode
test assertions (real workers ignore the return value)."""
SessionLocal = _sync_session_factory()
start = time.monotonic()
try:
with SessionLocal() as session:
audit = session.get(LibraryAuditRun, audit_id)
if audit is None:
return {"audit_id": audit_id, "status": "missing"}
evaluate = _RULES.get(audit.rule)
if evaluate is None:
_mark_error(session, audit_id, f"unknown rule {audit.rule!r}")
return {"audit_id": audit_id, "status": "error"}
params = dict(audit.params or {})
# Resume from the previous chunk's persisted state.
matched: list[int] = list(audit.matched_ids or [])
scanned = audit.scanned_count or 0
last_id = audit.resume_after_id or 0
while True:
# Cancellation check between batches.
current_status = session.execute(
select(LibraryAuditRun.status)
.where(LibraryAuditRun.id == audit_id)
).scalar_one()
if current_status == "cancelled":
return {"audit_id": audit_id, "status": "cancelled"}
# Time-box: persist the cursor + matches and re-enqueue so the
# queue is freed between chunks. The next call resumes here.
if time.monotonic() - start >= _CHUNK_SECONDS:
_persist_chunk(session, audit_id, scanned, matched, last_id)
scan_library_for_rule.delay(audit_id)
return {
"audit_id": audit_id, "status": "running",
"partial": True, "scanned": scanned,
}
rows = session.execute(
select(ImageRecord.id, ImageRecord.path)
.where(ImageRecord.id > last_id)
.where(ImageRecord.mime.like("image/%"))
.order_by(ImageRecord.id.asc())
.limit(_BATCH)
).all()
if not rows:
break
for image_id, image_path in rows:
last_id = image_id
scanned += 1
try:
with Image.open(image_path) as im:
try:
if evaluate(im, **params):
matched.append(image_id)
except Exception as exc: # noqa: BLE001
log.warning(
"audit %s: rule evaluate failed on %s: %s",
audit_id, image_path, exc,
)
except FileNotFoundError:
log.warning(
"audit %s: image_record %s file missing at %s; skipping",
audit_id, image_id, image_path,
)
except OSError as exc:
log.warning(
"audit %s: PIL load failed for %s: %s",
audit_id, image_path, exc,
)
if len(matched) > _MAX_MATCHED:
_mark_error(
session, audit_id,
f"matched > {_MAX_MATCHED} images; "
"tighten threshold and re-run",
)
return {"audit_id": audit_id, "status": "error"}
if scanned % _PROGRESS_TICK == 0:
# Cheap heartbeat: scanned_count + last_progress_at so the
# recovery sweep sees the multi-chunk audit is alive. The
# cursor + matches are persisted at chunk boundaries.
session.execute(
update(LibraryAuditRun)
.where(LibraryAuditRun.id == audit_id)
.values(
scanned_count=scanned,
last_progress_at=datetime.now(UTC),
)
)
session.commit()
# Final state.
session.execute(
update(LibraryAuditRun)
.where(LibraryAuditRun.id == audit_id)
.values(
scanned_count=scanned,
matched_count=len(matched),
matched_ids=matched,
resume_after_id=last_id,
status="ready",
finished_at=datetime.now(UTC),
last_progress_at=datetime.now(UTC),
)
)
session.commit()
return {
"audit_id": audit_id,
"status": "ready",
"scanned": scanned,
"matched": len(matched),
}
except SoftTimeLimitExceeded:
# Backstop (the in-chunk budget should fire first): the audit stays
# 'running' with its last committed cursor; re-enqueue to continue from
# there rather than marking the whole run an error.
log.warning(
"audit %s: soft time limit hit — re-enqueuing to resume", audit_id,
)
scan_library_for_rule.delay(audit_id)
return {"audit_id": audit_id, "status": "running", "partial": True}
except (OperationalError, DBAPIError):
# Retryable per the decorator; leave row in 'running' and let
# autoretry try again. Recovery sweep catches if all retries fail.
raise
except Exception: # noqa: BLE001
tb = traceback.format_exc()
with SessionLocal() as session:
_mark_error(session, audit_id, tb)
raise
def _persist_chunk(session, audit_id, scanned, matched, last_id) -> None:
"""Persist a chunk boundary: scanned count, matches so far, and the keyset
cursor the next chunk resumes from. Keeps status='running'."""
session.execute(
update(LibraryAuditRun)
.where(LibraryAuditRun.id == audit_id)
.values(
scanned_count=scanned,
matched_count=len(matched),
matched_ids=list(matched),
resume_after_id=last_id,
last_progress_at=datetime.now(UTC),
)
)
session.commit()
def _mark_error(session, audit_id: int, error_msg: str) -> None:
session.execute(
update(LibraryAuditRun)
.where(LibraryAuditRun.id == audit_id)
.values(
status="error",
error=error_msg,
finished_at=datetime.now(UTC),
)
)
session.commit()