From 1ae49b642093c415e2574bad2c712d8ca88cde66 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Thu, 25 Jun 2026 14:46:08 -0400 Subject: [PATCH 1/2] 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) --- backend/startup.py | 36 ++++++++++++++++++++++++++++ backend/tests/test_startup.py | 44 +++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/backend/startup.py b/backend/startup.py index 96d95bc28..23832b16a 100644 --- a/backend/startup.py +++ b/backend/startup.py @@ -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() diff --git a/backend/tests/test_startup.py b/backend/tests/test_startup.py index 71a99d53c..273cd1655 100644 --- a/backend/tests/test_startup.py +++ b/backend/tests/test_startup.py @@ -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() From be8de5d0c3b5c2c1eeda865edb3e3188d61bc4c2 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Thu, 25 Jun 2026 16:09:15 -0400 Subject: [PATCH 2/2] cleanup comment --- backend/startup.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/startup.py b/backend/startup.py index 23832b16a..b8efd1c24 100644 --- a/backend/startup.py +++ b/backend/startup.py @@ -106,9 +106,7 @@ def _enqueue_convert_images_to_webp() -> None: 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.""" + every request 404s until the cron eventually runs.""" try: if Job.exists(CONVERT_IMAGES_TO_WEBP_JOB_ID, low_prio_queue.connection): log.info(