fix(webp): backfill cover conversion on startup when enabled

The frontend rewrites every cover URL to .webp as soon as the heartbeat
reports ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP, but existing covers have
no .webp sibling until the scheduled cron eventually runs (the inline
conversion only covers art fetched after enabling). This produced 404s on
all existing covers until the cron fired.

Enqueue a one-off backfill run of the conversion task on startup when the
feature is enabled, mirroring the recompute-save-hashes pattern. A fixed
job_id + Job.exists guard prevents duplicate jobs across restarts, and the
task already skips covers that have a .webp sibling so repeated runs are
cheap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Georges-Antoine Assi
2026-06-25 14:46:08 -04:00
parent 64b42d2206
commit 1ae49b6420
2 changed files with 80 additions and 0 deletions

View File

@@ -49,6 +49,7 @@ from utils.context import initialize_context
tracer = trace.get_tracer(__name__)
RECOMPUTE_SAVE_HASHES_JOB_ID = "recompute_save_content_hashes_bootstrap"
CONVERT_IMAGES_TO_WEBP_JOB_ID = "convert_images_to_webp_bootstrap"
def _enqueue_recompute_save_hashes_if_needed() -> None:
@@ -98,6 +99,40 @@ def _enqueue_recompute_save_hashes_if_needed() -> None:
)
def _enqueue_convert_images_to_webp() -> None:
"""Backfill .webp covers when WebP conversion is enabled.
The frontend rewrites cover URLs to .webp as soon as the feature flag is
on, but the scheduled task only runs at its next cron time and the inline
conversion in the resources handler only fires for covers fetched after
enabling. Without a backfill, existing covers have no .webp sibling and
every request 404s until the cron eventually runs. Enqueue a one-off run
so existing covers are converted on startup. The task skips covers that
already have a .webp sibling, so repeated restarts are cheap."""
try:
if Job.exists(CONVERT_IMAGES_TO_WEBP_JOB_ID, low_prio_queue.connection):
log.info(
"convert_images_to_webp already queued or running from a "
"previous restart; skipping enqueue"
)
return
low_prio_queue.enqueue(
convert_images_to_webp_task.run,
job_id=CONVERT_IMAGES_TO_WEBP_JOB_ID,
job_timeout=TASK_TIMEOUT,
meta={
"task_name": convert_images_to_webp_task.title,
"task_type": convert_images_to_webp_task.task_type.value,
},
)
log.info("Enqueued convert_images_to_webp backfill on low-priority worker")
except Exception:
log.exception(
"Failed to enqueue convert_images_to_webp; admins can run it manually"
)
@tracer.start_as_current_span("main")
async def main() -> None:
"""Run startup tasks."""
@@ -121,6 +156,7 @@ async def main() -> None:
if ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP:
log.info("Starting scheduled convert images to webp")
convert_images_to_webp_task.init()
_enqueue_convert_images_to_webp()
if ENABLE_SCHEDULED_RETROACHIEVEMENTS_PROGRESS_SYNC:
log.info("Starting scheduled RetroAchievements progress sync")
sync_retroachievements_progress_task.init()

View File

@@ -88,3 +88,47 @@ def test_enqueue_recompute_swallows_enqueue_error(mocker):
)
startup._enqueue_recompute_save_hashes_if_needed()
def test_enqueue_convert_webp_fires_when_not_queued(mocker):
"""No in-flight bootstrap job -> enqueue the backfill exactly once."""
mocker.patch.object(startup.Job, "exists", return_value=False)
enqueue = mocker.patch.object(startup.low_prio_queue, "enqueue")
startup._enqueue_convert_images_to_webp()
enqueue.assert_called_once()
args, kwargs = enqueue.call_args
assert args[0].__self__ is startup.convert_images_to_webp_task
assert kwargs["meta"]["task_name"] == startup.convert_images_to_webp_task.title
assert kwargs["meta"]["task_type"] == (
startup.convert_images_to_webp_task.task_type.value
)
assert kwargs["job_timeout"] == startup.TASK_TIMEOUT
assert kwargs["job_id"] == startup.CONVERT_IMAGES_TO_WEBP_JOB_ID
def test_convert_webp_job_id_is_valid_rq_id():
"""An invalid job_id raises in set_id, which the broad except swallows ->
backfill silently never enqueues. Assert the id matches RQ's contract."""
assert JOB_ID_PATTERN.fullmatch(startup.CONVERT_IMAGES_TO_WEBP_JOB_ID)
def test_enqueue_convert_webp_skips_when_already_queued(mocker):
"""An in-flight job from a previous restart -> skip enqueue, don't double up."""
mocker.patch.object(startup.Job, "exists", return_value=True)
enqueue = mocker.patch.object(startup.low_prio_queue, "enqueue")
startup._enqueue_convert_images_to_webp()
enqueue.assert_not_called()
def test_enqueue_convert_webp_swallows_enqueue_error(mocker):
"""A failed enqueue must not crash startup."""
mocker.patch.object(startup.Job, "exists", return_value=False)
mocker.patch.object(
startup.low_prio_queue, "enqueue", side_effect=RuntimeError("redis gone")
)
startup._enqueue_convert_images_to_webp()