From 7980ee77d08438f5bc016ab4320be7b24a2b5b52 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Fri, 7 Aug 2026 15:31:13 +0800 Subject: [PATCH] perf(recipes): batch preview dimension reads via asyncio.gather --- py/routes/handlers/recipe_handlers.py | 50 ++++++++++++++++++++------ tests/routes/test_recipe_routes.py | 52 +++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 15de553a..68480ce7 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -10,7 +10,7 @@ import asyncio import tempfile from dataclasses import dataclass from pathlib import Path -from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional +from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Tuple from aiohttp import web @@ -44,6 +44,22 @@ EnsureDependenciesCallable = Callable[[], Awaitable[None]] RecipeScannerGetter = Callable[[], Any] CivitaiClientGetter = Callable[[], Any] +# Cap concurrent preview-dimension reads across requests. With a cold LRU +# cache one page can touch up to page_size image files; 16 balances SSD and +# HDD throughput without starving the event loop. +_DIMS_READ_SEMAPHORE = asyncio.Semaphore(16) + + +async def _read_preview_dims(path: str) -> Optional[Tuple[int, int]]: + """Read preview dimensions off the event loop under the concurrency cap. + + PIL I/O runs in a worker thread so it never blocks the event loop, and the + semaphore bounds how many files are opened at once even when many list + requests land together. + """ + async with _DIMS_READ_SEMAPHORE: + return await asyncio.to_thread(ExifUtils.get_image_dimensions, path) + @dataclass(frozen=True) class RecipeHandlerSet: @@ -246,24 +262,36 @@ class RecipeListingHandler: recursive=recursive, ) - for item in result.get("items", []): + items = result.get("items", []) + for item in items: file_path = item.get("file_path") if file_path: item["file_url"] = self.format_recipe_file_url(file_path) - # Offload synchronous PIL I/O: with a cold LRU cache this - # reads up to page_size images and would block the event - # loop otherwise. Fields are omitted (not null) when the - # preview has no readable dimensions (video, missing file). - dims = await asyncio.to_thread( - ExifUtils.get_image_dimensions, file_path - ) - if dims: - item["width"], item["height"] = dims else: item.setdefault("file_url", "/loras_static/images/no-preview.png") item.setdefault("loras", []) item.setdefault("base_model", "") + # Batch preview dimension reads with asyncio.gather. The previous + # loop awaited asyncio.to_thread once per item, so a page_size=100 + # request submitted 100 sequential thread calls (50-300ms cold-page + # latency). gather runs them concurrently while the semaphore caps + # disk opens; dimensions stay omitted (not null) when a preview has + # no readable size (video, missing file). + to_read = [ + (i, item.get("file_path")) + for i, item in enumerate(items) + if item.get("file_path") + ] + if to_read: + dims_list = await asyncio.gather( + *(_read_preview_dims(path) for _, path in to_read) + ) + for (idx, _), dims in zip(to_read, dims_list): + if dims: + item = items[idx] + item["width"], item["height"] = dims + return web.json_response(result) except Exception as exc: self._logger.error("Error retrieving recipes: %s", exc, exc_info=True) diff --git a/tests/routes/test_recipe_routes.py b/tests/routes/test_recipe_routes.py index 6143c215..78bbad27 100644 --- a/tests/routes/test_recipe_routes.py +++ b/tests/routes/test_recipe_routes.py @@ -475,6 +475,58 @@ async def test_list_recipes_offloads_dimensions_to_thread( assert "height" not in payload["items"][1] +async def test_list_recipes_batches_dimensions_for_mixed_items( + monkeypatch, tmp_path: Path +) -> None: + """(d) Mixed file_path presence: dims align per-item with their own files.""" + async with recipe_harness(monkeypatch, tmp_path) as harness: + wide_path = harness.tmp_dir / "recipes" / "wide.png" + tall_path = harness.tmp_dir / "recipes" / "tall.png" + wide_path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (120, 40), color="green").save(wide_path) + Image.new("RGB", (30, 90), color="blue").save(tall_path) + + harness.scanner.listing_items = [ + { + "id": "recipe-wide", + "file_path": str(wide_path), + "title": "Wide", + "loras": [], + }, + {"id": "recipe-none", "title": "No Preview", "loras": []}, + { + "id": "recipe-tall", + "file_path": str(tall_path), + "title": "Tall", + "loras": [], + }, + {"id": "recipe-none-2", "title": "No Preview 2", "loras": []}, + ] + harness.scanner.cached_raw = list(harness.scanner.listing_items) + + response = await harness.client.get("/api/lm/recipes") + payload = await response.json() + + assert response.status == 200 + items = payload["items"] + + # Items with a file_path carry integer dims read from their own file; + # the two images differ in both dimensions so a shifted pair would + # fail these assertions. + assert items[0]["width"] == 120 + assert items[0]["height"] == 40 + assert isinstance(items[0]["width"], int) + assert isinstance(items[0]["height"], int) + assert items[2]["width"] == 30 + assert items[2]["height"] == 90 + + # Items without a file_path get the no-preview fallback and omit dims. + for item in (items[1], items[3]): + assert "width" not in item + assert "height" not in item + assert item["file_url"] == "/loras_static/images/no-preview.png" + + async def test_list_recipes_passes_checkpoint_hash_filter( monkeypatch, tmp_path: Path ) -> None: