perf(rename): make bulk filename-template apply O(n) instead of O(n^2)

Applying a filename template to a large library re-did O(library) work for
every renamed file: a full natsort resort plus whole-table SQLite rewrite and
download-history resync after each rename, and a full scan plus resort of the
entire recipe collection per renamed LoRA. On a 20k-model library with 300k
recipes on a HDD this pushed "Apply to Library" into multi-day runs.

- ModelScanner.defer_cache_persist(): bulk loops update the in-memory entry
  and indexes only; resort + persist + download-history sync run once at
  context exit, forced even on cancellation/error since files are already
  renamed on disk. Single-rename callers keep immediate per-call behavior.
- RecipeScanner.build_lora_hash_index(): one-shot hash -> recipes index so
  per-file lookups are O(1); update_lora_filename_by_hash gains hash_index /
  defer_maintenance params, with a single finalize_bulk_filename_updates()
  resort at the end of a bulk session.
- ModelLifecycleService.bulk_rename_session() / BulkRenameContext wire the
  deferred path through rename_model (hash index built lazily on first
  recipe-touching rename).
- Blocking os.rename sequence offloaded via asyncio.to_thread so one file's
  HDD I/O no longer stalls the event loop (no cross-file parallelism).
- Skip logic, per-batch WebSocket progress, cancellation, and result
  counters unchanged.
This commit is contained in:
Will Miao
2026-10-03 14:31:10 +08:00
parent 515469054c
commit 896ce5eddb
8 changed files with 1080 additions and 63 deletions
+6 -1
View File
@@ -3,9 +3,10 @@ import importlib.util
import inspect
import sys
import types
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
from typing import Any, AsyncIterator, Dict, List, Optional, Sequence
from unittest import mock
import pytest
@@ -177,6 +178,10 @@ class MockScanner:
def reset_cancellation(self) -> None:
self._cancelled = False
@asynccontextmanager
async def defer_cache_persist(self) -> AsyncIterator[None]:
yield None
async def get_cached_data(self, force_refresh: bool = False):
return self._cache
+639
View File
@@ -0,0 +1,639 @@
"""Performance-regression tests for the bulk filename-template rename path.
The bulk "Apply to Library" rename must be O(1)-per-file: the heavyweight
cache persist/resort chain and the recipe maintenance run exactly once per
bulk operation (even on cancellation or mid-loop errors), while single-shot
renames keep their immediate per-call behavior.
"""
from __future__ import annotations
import asyncio
import json
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, AsyncIterator, Dict, List, Optional
import pytest
from py.services import model_scanner as model_scanner_module
from py.services.model_cache import ModelCache
from py.services.model_lifecycle_service import ModelLifecycleService
from py.services.model_scanner import ModelScanner
from py.services.settings_manager import get_settings_manager
from py.services.use_cases.filename_template_use_case import FilenameTemplateUseCase
from py.utils.models import BaseModelMetadata
class BulkDummyScanner(ModelScanner):
"""Minimal concrete scanner for cache-behavior tests."""
def __init__(self, root: Path):
self._root = str(root)
super().__init__(
model_type="dummy",
model_class=BaseModelMetadata,
file_extensions={".txt"},
)
def get_model_roots(self) -> List[str]:
return [self._root]
@pytest.fixture(autouse=True)
def _reset_model_scanner_singletons():
ModelScanner._instances.clear()
ModelScanner._locks.clear()
yield
ModelScanner._instances.clear()
ModelScanner._locks.clear()
@pytest.fixture(autouse=True)
def _disable_persistent_cache_env(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("LORA_MANAGER_DISABLE_PERSISTENT_CACHE", "1")
@pytest.fixture(autouse=True)
def _stub_register_service(monkeypatch: pytest.MonkeyPatch):
async def noop(*_args: Any, **_kwargs: Any) -> None:
return None
monkeypatch.setattr(
model_scanner_module.ServiceRegistry, "register_service", noop
)
def _metadata(stem: str, path: str, sha256: str, civitai_id: int = 1) -> Dict[str, Any]:
return {
"file_name": stem,
"file_path": path,
"model_name": stem,
"sha256": sha256,
"folder": "",
"tags": [],
"size": 1,
"modified": 1.0,
"civitai": {"id": civitai_id, "modelId": 2},
}
class _SpiedCache:
"""Scanner cache with call counters for resort/persist/sync."""
def __init__(self, scanner: BulkDummyScanner, entries: List[Dict[str, Any]]):
self.cache = ModelCache(raw_data=[dict(e) for e in entries], folders=[])
scanner._cache = self.cache
self.resort_calls = 0
self.save_calls: List[bool] = []
self.sync_calls = 0
self._original_resort = self.cache.resort
async def resort(self) -> None:
self.resort_calls += 1
await self._original_resort()
async def fake_save(self, scan_result: Any, *, force: bool = False) -> None:
self.save_calls.append(force)
async def fake_sync(self, raw_data: Any, *, source: str) -> None:
self.sync_calls += 1
async def _make_spied_scanner(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, entries: List[Dict[str, Any]]
) -> tuple[BulkDummyScanner, _SpiedCache]:
scanner = BulkDummyScanner(tmp_path)
spied = _SpiedCache(scanner, entries)
# Truthy stand-in so _persist_current_cache() does not early-return.
scanner._persistent_cache = object()
monkeypatch.setattr(scanner, "_save_persistent_cache", spied.fake_save)
monkeypatch.setattr(scanner, "_sync_download_history", spied.fake_sync)
# Flush the resort task scheduled by ModelCache.__post_init__ before
# installing the counting wrapper.
await asyncio.sleep(0)
await asyncio.sleep(0)
monkeypatch.setattr(spied.cache, "resort", spied.resort)
return scanner, spied
# ---------------------------------------------------------------------------
# ModelScanner deferred persist
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_update_single_model_cache_persists_immediately_by_default(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
path_a = (tmp_path / "a.txt").as_posix()
scanner, spied = await _make_spied_scanner(
tmp_path, monkeypatch, [_metadata("a", path_a, "hash-a")]
)
await scanner.update_single_model_cache(
path_a, path_a, _metadata("a", path_a, "hash-a")
)
assert spied.save_calls == [False]
assert spied.sync_calls == 1
assert spied.resort_calls == 1
@pytest.mark.asyncio
async def test_deferred_cache_persist_persists_once_for_many_updates(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
entries = []
for index, stem in enumerate(("a", "b", "c")):
path = (tmp_path / f"{stem}.txt").as_posix()
entries.append(_metadata(stem, path, f"hash-{stem}", civitai_id=index + 1))
scanner, spied = await _make_spied_scanner(tmp_path, monkeypatch, entries)
async with scanner.defer_cache_persist():
for index, stem in enumerate(("a", "b", "c")):
old_path = (tmp_path / f"{stem}.txt").as_posix()
new_path = (tmp_path / f"{stem}-renamed.txt").as_posix()
await scanner.update_single_model_cache(
old_path,
new_path,
_metadata(
f"{stem}-renamed", new_path, f"hash-{stem}", civitai_id=index + 1
),
)
# Nothing heavy may run mid-loop.
assert spied.save_calls == []
assert spied.sync_calls == 0
assert spied.resort_calls == 0
# Exactly one heavyweight finalize for the whole bulk operation.
assert spied.save_calls == [True]
assert spied.sync_calls == 1
assert spied.resort_calls == 1
# In-memory state is correct, including the incremental version index.
cache = await scanner.get_cached_data()
cached_paths = {item["file_path"] for item in cache.raw_data}
for stem in ("a", "b", "c"):
assert (tmp_path / f"{stem}.txt").as_posix() not in cached_paths
assert (tmp_path / f"{stem}-renamed.txt").as_posix() in cached_paths
assert cache.version_index[1]["file_path"].endswith("a-renamed.txt")
assert scanner._hash_index.get_path("hash-a").endswith("a-renamed.txt")
@pytest.mark.asyncio
async def test_deferred_cache_persist_finalizes_despite_cancellation(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
path_a = (tmp_path / "a.txt").as_posix()
scanner, spied = await _make_spied_scanner(
tmp_path, monkeypatch, [_metadata("a", path_a, "hash-a")]
)
scanner.cancel_task()
async with scanner.defer_cache_persist():
new_path = (tmp_path / "a-renamed.txt").as_posix()
await scanner.update_single_model_cache(
path_a, new_path, _metadata("a-renamed", new_path, "hash-a")
)
# Files are already renamed on disk, so the persist must be forced even
# though the cancellation flag is set.
assert spied.save_calls == [True]
assert spied.sync_calls == 1
assert spied.resort_calls == 1
@pytest.mark.asyncio
async def test_deferred_cache_persist_finalizes_despite_mid_loop_error(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
path_a = (tmp_path / "a.txt").as_posix()
scanner, spied = await _make_spied_scanner(
tmp_path, monkeypatch, [_metadata("a", path_a, "hash-a")]
)
with pytest.raises(RuntimeError, match="boom"):
async with scanner.defer_cache_persist():
new_path = (tmp_path / "a-renamed.txt").as_posix()
await scanner.update_single_model_cache(
path_a, new_path, _metadata("a-renamed", new_path, "hash-a")
)
raise RuntimeError("boom")
assert spied.save_calls == [True]
assert spied.sync_calls == 1
@pytest.mark.asyncio
async def test_deferred_cache_persist_nested_contexts_finalize_once(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
path_a = (tmp_path / "a.txt").as_posix()
scanner, spied = await _make_spied_scanner(
tmp_path, monkeypatch, [_metadata("a", path_a, "hash-a")]
)
async with scanner.defer_cache_persist():
async with scanner.defer_cache_persist():
new_path = (tmp_path / "a-renamed.txt").as_posix()
await scanner.update_single_model_cache(
path_a, new_path, _metadata("a-renamed", new_path, "hash-a")
)
assert spied.save_calls == [True]
assert spied.resort_calls == 1
@pytest.mark.asyncio
async def test_deferred_cache_persist_without_updates_persists_nothing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
scanner, spied = await _make_spied_scanner(tmp_path, monkeypatch, [])
async with scanner.defer_cache_persist():
pass
assert spied.save_calls == []
assert spied.resort_calls == 0
# ---------------------------------------------------------------------------
# ModelLifecycleService bulk rename session
# ---------------------------------------------------------------------------
class _SessionScanner:
model_type = "lora"
def __init__(self, root: Path):
self._root = str(root)
self.cache_updates: List[tuple[str, str]] = []
self.defer_enters = 0
self.defer_exits = 0
def get_model_roots(self) -> List[str]:
return [self._root]
@asynccontextmanager
async def defer_cache_persist(self) -> AsyncIterator[None]:
self.defer_enters += 1
try:
yield
finally:
self.defer_exits += 1
async def update_single_model_cache(
self, old_path: str, new_path: str, metadata: Dict[str, Any]
) -> bool:
self.cache_updates.append((old_path, new_path))
return True
class _PassthroughMetadataManager:
def __init__(self) -> None:
self.saved: List[str] = []
async def save_metadata(self, path: str, metadata: Dict[str, Any]) -> bool:
self.saved.append(path)
return True
class RecordingRecipeScanner:
"""Records bulk-mode recipe calls and applies filename changes."""
def __init__(self) -> None:
self.index_builds = 0
self.updates: List[Dict[str, Any]] = []
self.finalizes = 0
self._recipes: Dict[str, Dict[str, Any]] = {}
def add_recipe(self, recipe_id: str, lora_hash: str, file_name: str) -> None:
self._recipes[recipe_id] = {
"id": recipe_id,
"loras": [{"hash": lora_hash, "file_name": file_name}],
}
async def build_lora_hash_index(self) -> Dict[str, List[Dict[str, Any]]]:
self.index_builds += 1
index: Dict[str, List[Dict[str, Any]]] = {}
for recipe in self._recipes.values():
for lora in recipe["loras"]:
index.setdefault(lora["hash"].lower(), []).append(recipe)
return index
async def update_lora_filename_by_hash(
self,
hash_value: str,
new_file_name: str,
*,
hash_index: Optional[Dict[str, List[Dict[str, Any]]]] = None,
defer_maintenance: bool = False,
) -> tuple[int, int]:
self.updates.append(
{
"hash_value": hash_value,
"new_file_name": new_file_name,
"hash_index": hash_index,
"defer_maintenance": defer_maintenance,
}
)
matched = 0
for recipe in (self._recipes.values() if hash_index is None else hash_index.get(hash_value.lower(), [])):
for lora in recipe["loras"]:
if lora["hash"].lower() == hash_value.lower():
lora["file_name"] = new_file_name
matched += 1
return (matched, matched)
async def finalize_bulk_filename_updates(self) -> None:
self.finalizes += 1
def _write_model_with_sidecar(
root: Path, stem: str, sha256: str, model_name: Optional[str] = None
) -> str:
model_path = root / f"{stem}.safetensors"
model_path.write_bytes(b"model")
(root / f"{stem}.metadata.json").write_text(
json.dumps(
{
"file_name": stem,
"file_path": model_path.as_posix(),
"model_name": model_name or stem,
"sha256": sha256,
}
)
)
return model_path.as_posix()
def _make_lifecycle_service(
scanner: _SessionScanner, recipe_scanner: RecordingRecipeScanner
) -> ModelLifecycleService:
async def metadata_loader(path: str) -> Dict[str, Any]:
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
async def recipe_scanner_factory() -> RecordingRecipeScanner:
return recipe_scanner
return ModelLifecycleService(
scanner=scanner, # pyright: ignore[reportArgumentType]
metadata_manager=_PassthroughMetadataManager(), # pyright: ignore[reportArgumentType]
metadata_loader=metadata_loader,
recipe_scanner_factory=recipe_scanner_factory,
)
@pytest.mark.asyncio
async def test_bulk_rename_session_defers_and_finalizes_once(tmp_path: Path):
scanner = _SessionScanner(tmp_path)
recipe_scanner = RecordingRecipeScanner()
recipe_scanner.add_recipe("r1", "aa" * 32, "old_a")
recipe_scanner.add_recipe("r2", "bb" * 32, "old_b")
service = _make_lifecycle_service(scanner, recipe_scanner)
path_a = _write_model_with_sidecar(tmp_path, "model-a", "aa" * 32)
path_b = _write_model_with_sidecar(tmp_path, "model-b", "bb" * 32)
async with service.bulk_rename_session() as bulk_context:
await service.rename_model(
file_path=path_a, new_file_name="renamed-a", bulk_context=bulk_context
)
await service.rename_model(
file_path=path_b, new_file_name="renamed-b", bulk_context=bulk_context
)
# No finalize may run before the session ends.
assert recipe_scanner.finalizes == 0
assert scanner.defer_enters == 1
assert scanner.defer_exits == 0
assert scanner.defer_exits == 1
# Hash index built at most once for the whole session.
assert recipe_scanner.index_builds == 1
assert recipe_scanner.finalizes == 1
assert len(recipe_scanner.updates) == 2
for update in recipe_scanner.updates:
assert update["hash_index"] is not None
assert update["defer_maintenance"] is True
# Recipes were re-pointed.
assert recipe_scanner._recipes["r1"]["loras"][0]["file_name"] == "renamed-a"
assert recipe_scanner._recipes["r2"]["loras"][0]["file_name"] == "renamed-b"
@pytest.mark.asyncio
async def test_bulk_rename_session_finalizes_recipes_despite_error(tmp_path: Path):
scanner = _SessionScanner(tmp_path)
recipe_scanner = RecordingRecipeScanner()
recipe_scanner.add_recipe("r1", "aa" * 32, "old_a")
service = _make_lifecycle_service(scanner, recipe_scanner)
path_a = _write_model_with_sidecar(tmp_path, "model-a", "aa" * 32)
with pytest.raises(RuntimeError, match="mid-loop"):
async with service.bulk_rename_session() as bulk_context:
await service.rename_model(
file_path=path_a, new_file_name="renamed-a", bulk_context=bulk_context
)
raise RuntimeError("mid-loop")
assert recipe_scanner.finalizes == 1
assert scanner.defer_exits == 1
@pytest.mark.asyncio
async def test_bulk_rename_session_skips_recipe_finalize_when_untouched(tmp_path: Path):
scanner = _SessionScanner(tmp_path)
recipe_scanner = RecordingRecipeScanner()
recipe_scanner.add_recipe("r1", "cc" * 32, "old_c")
service = _make_lifecycle_service(scanner, recipe_scanner)
# Model whose hash matches no recipe: the lookup runs but no recipe is
# touched, so no recipe maintenance is needed at finalize.
path_a = _write_model_with_sidecar(tmp_path, "model-a", "aa" * 32)
async with service.bulk_rename_session() as bulk_context:
await service.rename_model(
file_path=path_a, new_file_name="renamed-a", bulk_context=bulk_context
)
assert len(recipe_scanner.updates) == 1
assert recipe_scanner.updates[0]["defer_maintenance"] is True
assert recipe_scanner.finalizes == 0
assert recipe_scanner._recipes["r1"]["loras"][0]["file_name"] == "old_c"
@pytest.mark.asyncio
async def test_single_rename_keeps_immediate_recipe_behavior(tmp_path: Path):
scanner = _SessionScanner(tmp_path)
recipe_scanner = RecordingRecipeScanner()
recipe_scanner.add_recipe("r1", "aa" * 32, "old_a")
service = _make_lifecycle_service(scanner, recipe_scanner)
path_a = _write_model_with_sidecar(tmp_path, "model-a", "aa" * 32)
await service.rename_model(file_path=path_a, new_file_name="renamed-a")
assert len(recipe_scanner.updates) == 1
update = recipe_scanner.updates[0]
assert update["hash_index"] is None
assert update["defer_maintenance"] is False
assert recipe_scanner.finalizes == 0
assert recipe_scanner._recipes["r1"]["loras"][0]["file_name"] == "renamed-a"
# ---------------------------------------------------------------------------
# Use case end-to-end: exactly one heavyweight persist for the whole loop
# ---------------------------------------------------------------------------
class _UseCaseScanner(ModelScanner):
def __init__(self, root: Path):
self._root = str(root)
super().__init__(
model_type="lora",
model_class=BaseModelMetadata,
file_extensions={".safetensors"},
)
def get_model_roots(self) -> List[str]:
return [self._root]
class _UseCaseLockProvider:
def __init__(self) -> None:
self._lock = asyncio.Lock()
def is_auto_organize_running(self) -> bool:
return False
async def get_auto_organize_lock(self) -> asyncio.Lock:
return self._lock
def _set_filename_template(template: str, model_type: str = "lora") -> None:
manager = get_settings_manager()
templates = dict(manager.settings.get("download_filename_templates") or {})
templates[model_type] = template
manager.settings["download_filename_templates"] = templates
@pytest.mark.asyncio
async def test_filename_template_bulk_apply_persists_and_repoints_once(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
_set_filename_template("{model_name}")
entries = []
for index, (stem, model_name, sha) in enumerate(
(("model-a", "alpha", "aa" * 32), ("model-b", "beta", "bb" * 32))
):
path = _write_model_with_sidecar(tmp_path, stem, sha, model_name=model_name)
entries.append(_metadata(model_name, path, sha, civitai_id=index + 1))
scanner = _UseCaseScanner(tmp_path)
spied = _SpiedCache(scanner, entries)
scanner._persistent_cache = object()
monkeypatch.setattr(scanner, "_save_persistent_cache", spied.fake_save)
monkeypatch.setattr(scanner, "_sync_download_history", spied.fake_sync)
await asyncio.sleep(0)
await asyncio.sleep(0)
monkeypatch.setattr(spied.cache, "resort", spied.resort)
recipe_scanner = RecordingRecipeScanner()
recipe_scanner.add_recipe("r1", "aa" * 32, "alpha")
recipe_scanner.add_recipe("r2", "bb" * 32, "beta")
service = _make_lifecycle_service( # type: ignore[arg-type]
scanner, recipe_scanner # pyright: ignore[reportArgumentType]
)
use_case = FilenameTemplateUseCase(
scanner=scanner,
lifecycle_service=service,
lock_provider=_UseCaseLockProvider(),
model_type="lora",
)
result = await use_case.execute(progress_callback=None)
assert result.status == "success"
assert result.success_count == 2
# One heavyweight persist/resort for the entire bulk operation, not two.
assert spied.save_calls == [True]
assert spied.sync_calls == 1
assert spied.resort_calls == 1
# Recipes re-pointed via a single lazily built hash index, maintenance
# finalized once.
assert recipe_scanner.index_builds == 1
assert recipe_scanner.finalizes == 1
assert recipe_scanner._recipes["r1"]["loras"][0]["file_name"] == "alpha"
assert recipe_scanner._recipes["r2"]["loras"][0]["file_name"] == "beta"
# Cache reflects the new paths.
cache = await scanner.get_cached_data()
cached_paths = {item["file_path"] for item in cache.raw_data}
assert (tmp_path / "alpha.safetensors").as_posix() in cached_paths
assert (tmp_path / "beta.safetensors").as_posix() in cached_paths
# Sidecars moved alongside the model files.
assert (tmp_path / "alpha.metadata.json").exists()
assert (tmp_path / "beta.metadata.json").exists()
@pytest.mark.asyncio
async def test_filename_template_bulk_apply_finalizes_persist_on_cancellation(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
_set_filename_template("{model_name}")
entries = []
paths = []
for index, (stem, model_name, sha) in enumerate(
(("model-a", "alpha", "aa" * 32), ("model-b", "beta", "bb" * 32))
):
path = _write_model_with_sidecar(tmp_path, stem, sha, model_name=model_name)
paths.append(path)
entries.append(_metadata(model_name, path, sha, civitai_id=index + 1))
scanner = _UseCaseScanner(tmp_path)
spied = _SpiedCache(scanner, entries)
scanner._persistent_cache = object()
monkeypatch.setattr(scanner, "_save_persistent_cache", spied.fake_save)
monkeypatch.setattr(scanner, "_sync_download_history", spied.fake_sync)
await asyncio.sleep(0)
await asyncio.sleep(0)
monkeypatch.setattr(spied.cache, "resort", spied.resort)
service = _make_lifecycle_service(
scanner, # pyright: ignore[reportArgumentType]
RecordingRecipeScanner(),
)
original_rename = service.rename_model
async def cancelling_rename(**kwargs: Any) -> Dict[str, object]:
result = await original_rename(**kwargs)
scanner.cancel_task()
return result
monkeypatch.setattr(service, "rename_model", cancelling_rename)
use_case = FilenameTemplateUseCase(
scanner=scanner,
lifecycle_service=service,
lock_provider=_UseCaseLockProvider(),
model_type="lora",
)
result = await use_case.execute(progress_callback=None)
assert result.status == "cancelled"
# The one file renamed before cancellation is on disk; the cache must
# still be persisted exactly once.
assert spied.save_calls == [True]
assert spied.sync_calls == 1
assert spied.resort_calls == 1
+109
View File
@@ -1903,6 +1903,115 @@ async def test_update_lora_filename_by_hash_updates_affected_recipes(
assert cached1["loras"][0]["file_name"] == new_name
@pytest.mark.asyncio
async def test_update_lora_filename_by_hash_bulk_mode_skips_scan_and_defers_resort(
tmp_path: Path, recipe_scanner, monkeypatch: pytest.MonkeyPatch
):
"""Bulk rename path: prebuilt hash index, no per-call cache walk/resort."""
scanner, _ = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True)
recipe1_id = "recipe-bulk-1"
recipe1_path = recipes_dir / f"{recipe1_id}.recipe.json"
recipe1_data = {
"id": recipe1_id,
"file_path": str(tmp_path / "bulk1.png"),
"title": "Bulk 1",
"modified": 0.0,
"created_date": 0.0,
"loras": [{"file_name": "old_name", "hash": "hash1"}],
}
recipe1_path.write_text(json.dumps(recipe1_data))
await scanner.add_recipe(dict(recipe1_data))
# Build the index once (O(recipes)), as a bulk rename session does.
hash_index = await scanner.build_lora_hash_index()
assert "hash1" in hash_index
# Spies: the bulk call must not walk the recipe cache again, and must not
# schedule a resort per call.
get_cached_calls = 0
original_get_cached = scanner.get_cached_data
async def counting_get_cached_data(*args, **kwargs):
nonlocal get_cached_calls
get_cached_calls += 1
return await original_get_cached(*args, **kwargs)
monkeypatch.setattr(scanner, "get_cached_data", counting_get_cached_data)
resort_calls = 0
original_schedule = scanner._schedule_resort
def counting_schedule_resort(**kwargs):
nonlocal resort_calls
resort_calls += 1
original_schedule(**kwargs)
monkeypatch.setattr(scanner, "_schedule_resort", counting_schedule_resort)
file_count, cache_count = await scanner.update_lora_filename_by_hash(
"HASH1", "new_name", hash_index=hash_index, defer_maintenance=True
)
assert (file_count, cache_count) == (1, 1)
assert get_cached_calls == 0
assert resort_calls == 0
# The per-match recipe JSON rewrite must still happen.
persisted1 = json.loads(recipe1_path.read_text())
assert persisted1["loras"][0]["file_name"] == "new_name"
cached1 = next(r for r in hash_index["hash1"] if r["id"] == recipe1_id)
assert cached1["loras"][0]["file_name"] == "new_name"
# Deferred maintenance runs exactly once at finalize.
await scanner.finalize_bulk_filename_updates()
await asyncio.sleep(0)
assert resort_calls == 1
@pytest.mark.asyncio
async def test_update_lora_filename_by_hash_with_index_still_resorts_when_not_deferred(
tmp_path: Path, recipe_scanner, monkeypatch: pytest.MonkeyPatch
):
"""hash_index without defer_maintenance: O(1) lookup, immediate resort."""
scanner, _ = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True)
recipe1_id = "recipe-idx-1"
recipe1_path = recipes_dir / f"{recipe1_id}.recipe.json"
recipe1_data = {
"id": recipe1_id,
"file_path": str(tmp_path / "idx1.png"),
"title": "Index 1",
"modified": 0.0,
"created_date": 0.0,
"loras": [{"file_name": "old_name", "hash": "hash1"}],
}
recipe1_path.write_text(json.dumps(recipe1_data))
await scanner.add_recipe(dict(recipe1_data))
hash_index = await scanner.build_lora_hash_index()
resort_calls = 0
original_schedule = scanner._schedule_resort
def counting_schedule_resort(**kwargs):
nonlocal resort_calls
resort_calls += 1
original_schedule(**kwargs)
monkeypatch.setattr(scanner, "_schedule_resort", counting_schedule_resort)
file_count, cache_count = await scanner.update_lora_filename_by_hash(
"hash1", "new_name", hash_index=hash_index
)
assert (file_count, cache_count) == (1, 1)
assert resort_calls == 1
@pytest.mark.asyncio
async def test_get_paginated_data_filters_by_favorite(recipe_scanner):
scanner, _ = recipe_scanner
+9 -2
View File
@@ -1,8 +1,9 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any, Dict, List, Optional
from typing import Any, AsyncIterator, Dict, List, Optional
import pytest
@@ -547,7 +548,13 @@ class StubLifecycleService:
self.cancel_on_rename = False
self._scanner = scanner
async def rename_model(self, *, file_path: str, new_file_name: str) -> Dict[str, Any]:
@asynccontextmanager
async def bulk_rename_session(self) -> AsyncIterator[None]:
yield None
async def rename_model(
self, *, file_path: str, new_file_name: str, bulk_context: Any = None
) -> Dict[str, Any]:
if self.error is not None:
raise self.error
self.renames.append({"file_path": file_path, "new_file_name": new_file_name})