From f34c02756d53f69e9c58a92e3dcb160a3b27bf0a Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sat, 25 Jul 2026 17:26:53 +0800 Subject: [PATCH] fix(recipes): eliminate O(n) fuzzy search fallback over 42k+ recipes Drop the SequenceMatcher-based fuzzy_match fallback that froze the server when FTS returned empty results. FTS now returns empty set for zero results (no fallback), and when the index is not yet ready, search returns empty rather than scanning all items in Python. --- py/services/recipe_scanner.py | 64 +++++++-------------------- tests/services/test_recipe_scanner.py | 10 ++++- 2 files changed, 25 insertions(+), 49 deletions(-) diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index b0eb3be6..a4545a4f 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -21,7 +21,7 @@ from .checkpoint_scanner import CheckpointScanner from .settings_manager import get_settings_manager from .recipes.errors import RecipeNotFoundError from ..utils.civitai_utils import extract_civitai_image_id -from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match +from ..utils.utils import calculate_recipe_fingerprint from natsort import natsorted import sys import re @@ -1020,13 +1020,16 @@ class RecipeScanner: try: result = self._fts_index.search(search, fields) - # Return None if empty to trigger fuzzy fallback - # Empty FTS results may indicate query syntax issues or need for fuzzy matching + # Return empty set for empty FTS results — do NOT fall back to + # Python fuzzy matching, which freezes the server with 10k+ recipes. + # FTS5 prefix matching with unicode61 tokenizer correctly handles + # compound tokens (e.g. "illustrious" matches "path/illustrious/model"). + # If FTS returns nothing, there are genuinely no matching recipes. if not result: - return None + return set() return result except Exception as exc: - logger.debug("FTS search failed, falling back to fuzzy search: %s", exc) + logger.debug("FTS search failed, falling back to title-only search: %s", exc) return None def _update_fts_index_for_recipe( @@ -2079,49 +2082,14 @@ class RecipeScanner: if str(item.get("id", "")) in fts_matching_ids ] else: - # Fallback to fuzzy_match (slower but always available) - # Build the search predicate based on search options - def matches_search(item): - # Search in title if enabled - if search_options.get("title", True): - if fuzzy_match(str(item.get("title", "")), search): - return True - - # Search in tags if enabled - if search_options.get("tags", True) and "tags" in item: - for tag in item["tags"]: - if fuzzy_match(tag, search): - return True - - # Search in lora file names if enabled - if search_options.get("lora_name", True) and "loras" in item: - for lora in item["loras"]: - if fuzzy_match(str(lora.get("file_name", "")), search): - return True - - # Search in lora model names if enabled - if search_options.get("lora_model", True) and "loras" in item: - for lora in item["loras"]: - if fuzzy_match(str(lora.get("modelName", "")), search): - return True - - # Search in prompt and negative_prompt if enabled - if search_options.get("prompt", True) and "gen_params" in item: - gen_params = item["gen_params"] - if fuzzy_match(str(gen_params.get("prompt", "")), search): - return True - if fuzzy_match( - str(gen_params.get("negative_prompt", "")), search - ): - return True - - # No match found - return False - - # Filter the data using the search predicate - filtered_data = [ - item for item in filtered_data if matches_search(item) - ] + # FTS index not yet built — return empty rather than + # scanning 42k+ items in Python. The FTS background build + # finishes in seconds; by the time a user navigates here + # and types a search, it is already available. + logger.debug( + "FTS index not ready — search '%s' returning empty", search + ) + filtered_data = [] # Apply additional filters if filters: diff --git a/tests/services/test_recipe_scanner.py b/tests/services/test_recipe_scanner.py index 23fb3140..27978f82 100644 --- a/tests/services/test_recipe_scanner.py +++ b/tests/services/test_recipe_scanner.py @@ -77,7 +77,15 @@ def recipe_scanner(tmp_path: Path, monkeypatch): monkeypatch.setattr(config, "loras_roots", [str(tmp_path)]) stub = StubLoraScanner() scanner = RecipeScanner(lora_scanner=stub) - asyncio.run(scanner.refresh_cache(force=True)) + + async def _init(): + await scanner.refresh_cache(force=True) + # Wait for FTS index build to finish — asyncio.run() + # cancels background tasks on return, so we must await it here. + if scanner._fts_index_task: + await scanner._fts_index_task + + asyncio.run(_init()) yield scanner, stub RecipeScanner._instance = None settings_manager_module.reset_settings_manager()