* feat(lora): cache LoraManager's layout behind file signatures (#362, phase 1) A new py/lora_layout_cache.py keeps one (install, layout) entry and reuses it while the configured path, the plugin settings file's and the settings file's signatures (st_mtime_ns, st_size), the install and every media root's real path are unchanged and the entry is younger than 30 s. A load whose watched file changed within 3 s (git's racy rule), could not be checked, or whose settings were unreadable serves its request only. clear() bumps a generation so a load that started earlier stores nothing. Hits take no lock. Not wired yet; phase 2 puts it behind the image hot paths. * fix(lora): treat an offline Windows share as unvalidated and make cache tests portable (#362) Review findings on phase 1: Windows raises FileNotFoundError with winerror 53/67 for an offline share, which is a temporary failure, not a missing file. Test times are whole milliseconds (NTFS keeps 100 ns steps) and a file system that rounds them skips; cleanup no longer ignores errors; tests added for ValueError, NotADirectoryError, a failed reload, resolve failures and the watched-file set. * perf(lora): serve the image hot paths from the layout cache (#362, phase 2) _lora_image_dirs takes (install, layout) from lora_utils.cached_lora_manager_layout instead of resolving the install and reading LoraManager's settings files on every Library, inspect, reveal and serve request. serve_image builds the allow-list in the executor instead of on the event loop. A finished import (System job or legacy scan, both through replace_lora_imports) clears the cache, so files linked under a folder LoraManager just created are served at once. The integration-off check and the configured-path rule still run before the cache. * fix(lora): fingerprint every scanned sidecar, kept or skipped (#362, phase 3) Discovery keeps every metadata file it saw (scanned_files, resolved and sorted; scanned is now its length). The fingerprint hashes each one's path, size, modification time and whether it was kept, so a skipped sidecar added, removed or edited between the review and Start refuses Start with plan-changed, as the review's counts promised. * fix(lora): warn about refused default LoRA folders and log what failed (#362, phase 4) ComfyUI's default LoRA folders already pass the folder guard; a refused one that exists is now listed in the layout's refused_defaults, and the layout cache warns once per newly refused folder. The cache writes one INFO line when a stored layout differs from the last one logged. BAD_SIDECAR_ROOT and the module docstring name a folder that contains the home folder. A relative preview_url writes a DEBUG line before the suffix search; a non-OSError failure while loading trigger words logs its traceback. Review follow-ups harden two serve tests. * fix(lora): skip a sidecar that is not UTF-8 instead of failing the load (#362) Review follow-up on phase 4: read_lora_metadata caught JSONDecodeError but not UnicodeDecodeError, so one non-UTF-8 sidecar failed the whole trigger-word load (now logged with a traceback every retry) and the import review. It is skipped like any unreadable sidecar. The relative preview_url DEBUG line uses lazy formatting. * docs(testing): the LoRA linked-only gate, layout cache and fingerprint scope (#362, phase 5) TESTING.md's LoRA reference import contract now states what #358 and #362 shipped: the linked-only gate and _media_roots, linked_only media, the cover link order, the guard on ComfyUI's default LoRA folders and refused_defaults, the folder-unreadable code, the trigger cache's catch and 60 s retry, the fingerprint over every scanned sidecar, and the layout cache's key, signatures, racy window, maximum age, root re-check and clears. The targeted command includes tests.test_lora_layout_cache and tests.test_api_images. * fix(lora): a cached layout needs the same home folder; give the rebuild poll its time (#362) Final review follow-ups: the folder guard compares against the home folder, so a cache hit now also needs Path.home() unchanged. TESTING.md says the configured-path rule runs on the cache's result and that a non-UTF-8 sidecar is skipped. CI: 'Rebuild all is explicit...' (webkit) polled the job for the default 5 s while it regenerated 24 previews; the failure screenshot shows 12 of 24 done at the deadline on the busy runner (0.17 s locally). The poll now allows 30 s for that real work.
218 lines
8.6 KiB
Python
218 lines
8.6 KiB
Python
"""A per-process cache of LoraManager's layout for the image hot paths (#362).
|
|
|
|
Every Library list, prompt detail, inspect, reveal and serve request needs the
|
|
LoRA allow-list (``ImagesMixin._lora_image_dirs``). Building it means finding
|
|
the LoraManager install, reading its plugin settings file and its settings
|
|
file, and resolving every folder. ``LayoutCache`` keeps the last result and
|
|
reuses it while all of these hold:
|
|
|
|
- the key (the configured install path, stripped) and the home folder the
|
|
folder guard compares against are the same;
|
|
- the entry is younger than ``MAX_AGE_SECONDS`` (monotonic clock);
|
|
- the plugin settings file and the settings file have the same signature
|
|
(``st_mtime_ns``, ``st_size``, or ``MISSING``) as when it was stored;
|
|
- the install and every stored media root still resolve to themselves (a
|
|
folder swapped for a symlink reloads, so the folder guard runs again).
|
|
|
|
A result is stored only when no watched file is "racy" (modified within
|
|
``RACY_SECONDS`` of the load, git's rule for racily clean index entries),
|
|
every watched file could be checked, the settings file was readable, and no
|
|
``clear()`` ran during the load. What each rule covers: signatures catch a
|
|
settings change on the next request; the racy rule catches a write during the
|
|
load, a half-written file and coarse timestamps; the maximum age bounds the
|
|
inputs no signature sees (``extra_model_paths.yaml``, environment, temporary
|
|
failures); ``clear()`` makes a finished import's new folders servable at once.
|
|
|
|
Hits and loads take no lock: a slow ``os.stat`` on a network share holds up
|
|
only its own request, and two threads that miss together both load (both
|
|
loads only read). A short lock guards the compare-and-store and the logging
|
|
state; no file I/O runs under it. Standard library only, so it is tested
|
|
without ComfyUI or LoraManager.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Callable, Optional, Tuple
|
|
|
|
from .lora_manager_layout import UNREADABLE, LoraManagerLayout, _canonical
|
|
|
|
# A watched file modified this recently is not trusted: LoraManager writes its
|
|
# settings in place, and FAT/exFAT store modification times in 2 s steps.
|
|
RACY_SECONDS = 3
|
|
# Bounds what no signature sees (unwatched inputs, temporary failures).
|
|
MAX_AGE_SECONDS = 30
|
|
|
|
# The signature of a watched file that does not exist (a valid state).
|
|
MISSING = object()
|
|
# ERROR_BAD_NETPATH and ERROR_BAD_NET_NAME, which Windows raises as
|
|
# FileNotFoundError.
|
|
_OFFLINE_SHARE_ERRORS = frozenset({53, 67})
|
|
|
|
logger = logging.getLogger("prompt_manager.lora_layout_cache")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _Entry:
|
|
key: str
|
|
home: str
|
|
install: Optional[str]
|
|
layout: LoraManagerLayout
|
|
signatures: Tuple[Tuple[str, object], ...]
|
|
stored_at: float
|
|
|
|
|
|
class _CannotValidate(Exception):
|
|
"""A watched file could not be checked (not merely missing)."""
|
|
|
|
|
|
class LayoutCache:
|
|
"""At most one ``(install, layout)`` for the active LoraManager config."""
|
|
|
|
def __init__(
|
|
self,
|
|
resolve: Callable[[str], Optional[str]],
|
|
load: Callable[[Optional[str]], LoraManagerLayout],
|
|
*,
|
|
wall_ns: Callable[[], int] = time.time_ns,
|
|
monotonic: Callable[[], float] = time.monotonic,
|
|
stat=os.stat,
|
|
pathmod=os.path,
|
|
home: Callable[[], str] = lambda: str(Path.home()),
|
|
):
|
|
self._resolve = resolve
|
|
self._load = load
|
|
self._wall_ns = wall_ns
|
|
self._monotonic = monotonic
|
|
self._stat = stat
|
|
self._pathmod = pathmod
|
|
self._home = home
|
|
self._entry: Optional[_Entry] = None
|
|
self._generation = 0
|
|
self._lock = threading.Lock()
|
|
# What was last logged; survives clear() so a reload of the same
|
|
# layout stays quiet.
|
|
self._logged_layout: Optional[LoraManagerLayout] = None
|
|
self._logged_refused: Tuple[str, ...] = ()
|
|
|
|
def get(self, configured: str) -> Tuple[Optional[str], LoraManagerLayout]:
|
|
"""``(install, layout)`` for the configured path ("" auto-detects)."""
|
|
key = (configured or "").strip()
|
|
home = self._home()
|
|
entry = self._entry
|
|
if entry is not None and self._valid(entry, key, home):
|
|
return entry.install, entry.layout
|
|
with self._lock:
|
|
if entry is not None and self._entry is entry:
|
|
self._entry = None
|
|
generation = self._generation
|
|
install = self._resolve(key)
|
|
layout = self._load(install)
|
|
self._log_refused(layout)
|
|
try:
|
|
signatures = tuple(
|
|
(path, self._signature(path)) for path in self._watched(install, layout)
|
|
)
|
|
except _CannotValidate:
|
|
return install, layout
|
|
if layout.error == UNREADABLE or self._racy(signatures, self._wall_ns()):
|
|
return install, layout
|
|
stored = _Entry(key, home, install, layout, signatures, self._monotonic())
|
|
changed = False
|
|
with self._lock:
|
|
if self._generation == generation:
|
|
self._entry = stored
|
|
changed = layout != self._logged_layout
|
|
if changed:
|
|
self._logged_layout = layout
|
|
if changed:
|
|
logger.info(
|
|
f"LoraManager layout changed: {layout.source or 'no'} settings, "
|
|
f"{layout.mode} storage, {len(layout.media_roots)} media folders"
|
|
)
|
|
return install, layout
|
|
|
|
def clear(self) -> None:
|
|
"""Drop the entry; a load that started before this stores nothing."""
|
|
with self._lock:
|
|
self._entry = None
|
|
self._generation += 1
|
|
|
|
def _log_refused(self, layout: LoraManagerLayout) -> None:
|
|
"""One warning per default LoRA folder newly refused by the guard."""
|
|
refused = tuple(layout.refused_defaults)
|
|
with self._lock:
|
|
if refused == self._logged_refused:
|
|
return
|
|
new = [folder for folder in refused if folder not in self._logged_refused]
|
|
self._logged_refused = refused
|
|
for folder in new:
|
|
logger.warning(
|
|
f"ComfyUI LoRA folder '{folder}' is neither scanned nor served: "
|
|
"it is a filesystem root, the home folder or a folder that "
|
|
"contains it"
|
|
)
|
|
|
|
# ── validation ──────────────────────────────────────────────────
|
|
|
|
def _valid(self, entry: _Entry, key: str, home: str) -> bool:
|
|
if entry.key != key or entry.home != home:
|
|
return False
|
|
if self._monotonic() - entry.stored_at >= MAX_AGE_SECONDS:
|
|
return False
|
|
try:
|
|
for path, signature in entry.signatures:
|
|
if self._signature(path) != signature:
|
|
return False
|
|
except _CannotValidate:
|
|
return False
|
|
folders = entry.layout.media_roots
|
|
if entry.install is not None:
|
|
folders = (entry.install,) + tuple(folders)
|
|
for folder in folders:
|
|
try:
|
|
if _canonical(folder, self._pathmod) != folder:
|
|
return False
|
|
except (OSError, ValueError):
|
|
return False
|
|
return True
|
|
|
|
def _watched(self, install, layout) -> Tuple[str, ...]:
|
|
"""The plugin settings file, then the settings file, each once."""
|
|
if install is None:
|
|
return ()
|
|
paths = [self._pathmod.join(install, "settings.json")]
|
|
if layout.settings_path is not None:
|
|
paths.append(str(layout.settings_path))
|
|
watched, seen = [], set()
|
|
for path in paths:
|
|
identity = self._pathmod.normcase(self._pathmod.abspath(path))
|
|
if identity not in seen:
|
|
seen.add(identity)
|
|
watched.append(path)
|
|
return tuple(watched)
|
|
|
|
def _signature(self, path: str):
|
|
try:
|
|
result = self._stat(path)
|
|
except (FileNotFoundError, NotADirectoryError) as error:
|
|
# Windows reports an offline share as "not found": that is a
|
|
# temporary failure, not a missing file.
|
|
if getattr(error, "winerror", None) in _OFFLINE_SHARE_ERRORS:
|
|
raise _CannotValidate(path) from error
|
|
return MISSING
|
|
except (OSError, ValueError) as error:
|
|
raise _CannotValidate(path) from error
|
|
return (result.st_mtime_ns, result.st_size)
|
|
|
|
@staticmethod
|
|
def _racy(signatures, now_ns: int) -> bool:
|
|
threshold = now_ns - RACY_SECONDS * 10**9
|
|
return any(
|
|
signature is not MISSING and signature[0] > threshold
|
|
for _path, signature in signatures
|
|
)
|