perf(images): share and warm one listing per output root (#348)

The Images page, its sidebar counts, the subfolder list and the legacy
gallery each rebuilt the output listing when the 30 s cache expired, all
at once, and the subfolder list computed relative paths on the event
loop. Thumbnail writes also threw the listing away.

Keep one RootListing per output root (py/api/gallery_listing.py):
- one walk per root at a time; concurrent readers wait for it (shielded)
- a listing older than 30 s is served at once and refreshed in the
  background
- the walk, the comparison with the previous listing and both folder
  sets run in the executor; an unchanged walk keeps the same list object
- os.scandir probe: a missing root fails the walk and keeps the previous
  listing (one warning per failure streak)
- Rescan waits for a walk started after the click and is a 500 when it
  fails, even with a previous listing
- deletes keep the blocking invalidation; a generation counter that
  survives drops discards walks that raced it
- thumbnail jobs, conversion and the legacy generate/clear routes no
  longer drop the listing
- warm-up on the host's on_startup, cancelled on on_cleanup

Adds tools/vendor/perf/images_load_probe.mjs (maintainer tool, not CI)
and the TESTING.md section "Images listing cache (#348)".
This commit is contained in:
Vito Sansevero
2026-10-25 20:59:14 -07:00
parent 3be0ada6a1
commit 2dbf79c9ae
14 changed files with 1055 additions and 48 deletions
+47
View File
@@ -235,6 +235,53 @@ change the budget in the spec with a reason) in a follow-up PR. Never rerun it t
green. It is not a required check, and it cannot be one while it skips pull
requests (`.github/branch-protection.json`).
## Images listing cache (#348)
`/prompt_manager/images/output`, `/prompt_manager/gallery/subfolders` and
`POST /prompt_manager/gallery/rescan` read one shared listing per output root
(`RootListing` in `py/api/gallery_listing.py`, `_listing` in
`py/api/images.py`). The tests are `tests/test_gallery_listing.py` (pure
helpers) and `TestSharedListing` in `tests/test_api_images.py` (routes); they
hold a walk open with a gated scan instead of sleeping.
- **Single flight.** At most one walk per root runs at a time; every reader
that needs that root while it runs waits for it (shielded, so a client that
disconnects never cancels it). The walk, the comparison with the previous
listing and both folder sets run in the executor (`walk_root`). A walk that
finds the same `(path, mtime)` list keeps the previous list object, so the
favorites lookup and the sort orders are not rebuilt.
- **30 s background refresh.** A listing older than `REFRESH_AFTER` (30 s) is
served at once and one background walk replaces it. Files added or removed
by hand therefore show up one open later than before; Rescan shows them at
once.
- **Blocking invalidation after deletes.** Duplicate cleanup and the legacy
duplicate delete still call `invalidate_gallery_cache()`: the listing is
dropped and the next read waits for a walk, so a deleted file is never
listed. A walk running during the drop is discarded (the record's
`generation` outlives every drop); a cold reader gives up with a 500 after 3
discarded walks in a row. Thumbnail writes (jobs, conversion, the legacy
generate and clear routes) no longer drop the listing: thumbnail URLs are
computed per request.
- **Startup warm-up.** The root `__init__.py` registers
`api.warm_gallery_listings` on the host's `on_startup`, which starts one
background walk per root, and `api.cleanup_gallery_listings` on
`on_cleanup`, which cancels running walks. A warm-up failure never stops
startup; the first read retries.
- **Failures and Rescan.** `walk_root` opens the root with `os.scandir` first,
so a deleted or unreadable root fails the walk instead of listing nothing.
A failed walk keeps the previous listing, logs one warning per streak of
failures and is retried at most once per `REFRESH_AFTER`; a reader with no
listing gets a 500. Rescan waits for a walk that started after the click
(by walk number, not by clock), Rescans clicked during one walk share the
next, and a failed walk is a 500 even when a previous listing exists.
- **Measuring it.** `tools/vendor/perf/images_load_probe.mjs` is a maintainer
tool, never run in CI. After `npm ci --prefix tools/vendor` and
`npx --prefix tools/vendor playwright install chromium`, run
`node tools/vendor/perf/images_load_probe.mjs http://127.0.0.1:8188` against
a running ComfyUI. It prints the time to the first decoded thumbnail, the
time to network idle and the 10 slowest requests, from a fresh browser
context each run.
## Windows portability preflight
Before changing paths, SQLite, streams, restore or cleanup:
+5
View File
@@ -100,6 +100,11 @@ try:
if host_app is not None and hasattr(host_app, "on_cleanup"):
host_app.on_cleanup.append(api.cleanup_history_reads)
host_app.on_cleanup.append(api.cleanup_annotation_captures)
host_app.on_cleanup.append(api.cleanup_gallery_listings)
if host_app is not None and hasattr(host_app, "on_startup"):
# Build each output root's Images listing once the server's loop runs,
# so the first open does not wait for the walk (#348).
host_app.on_startup.append(api.warm_gallery_listings)
if host_app is not None and hasattr(host_app, "on_shutdown"):
# Open Needs review change streams end before the server waits on them.
host_app.on_shutdown.append(api.close_review_events)
+48 -11
View File
@@ -47,6 +47,7 @@ from .review_events import ReviewEventsMixin
from .saved_views import SavedViewRoutesMixin
from .history import HistoryRoutesMixin
from .images import ImageRoutesMixin
from .gallery_listing import WALK_STOPPED, RootListing, WalkOutcome
from .admin import AdminRoutesMixin
from .settings_routes import SettingsRoutesMixin
from .job_routes import JobRoutesMixin
@@ -344,8 +345,10 @@ class PromptManagerAPI(
self._captures_closed = False
self._cached_output_dir = None # Lazy-cached by _find_comfyui_output_dir()
self._html_cache = {} # Cached HTML file contents keyed by path
self._gallery_cache = {} # dict: path_str -> (files, timestamp)
self._gallery_cache_ttl = 30 # Cache TTL in seconds
# One shared listing per output root, keyed by str(root), and the
# background walks refreshing them (py/api/gallery_listing.py, #348).
self._root_listings: dict[str, RootListing] = {}
self._listing_tasks: set = set()
# Workspace listing values and sort orders derived from a cached scan
# (py/api/images.py _workspace_listed/_workspace_orders).
self._workspace_listed_cache = {}
@@ -465,20 +468,54 @@ class PromptManagerAPI(
return await loop.run_in_executor(None, call)
return await loop.run_in_executor(None, func, *args)
def invalidate_gallery_cache(self):
"""Invalidate the cached gallery file listing.
def _root_listing(self, root) -> RootListing:
"""The shared listing record of one output root (created empty)."""
return self._root_listings.setdefault(str(root), RootListing())
Called by the image monitor when new files are detected. The workspace
keeps its per-file values and card facts, so the next request rebuilds
only new or changed files (see ImageRoutesMixin._workspace_listed and
_card_facts); its sort orders
are dropped, as the rescan replaces them anyway, and so is the
favorites lookup, which holds the old scan list.
def invalidate_gallery_cache(self):
"""Drop every root's listing, so the next read waits for a new walk.
Called after files are deleted through the app (duplicate cleanup and
the legacy duplicate delete), never for thumbnail writes. A walk
running now is not stored (its generation moved on). The workspace
keeps its per-file values and card facts, so the next request
rebuilds only new or changed files (see ImageRoutesMixin.
_workspace_listed and _card_facts); its sort orders are dropped, as
the walk replaces them anyway, and so is the favorites lookup, which
holds the old list.
"""
self._gallery_cache = {}
for record in self._root_listings.values():
record.drop()
self._workspace_order_cache = {}
self._favorite_listing_cache = {}
async def warm_gallery_listings(self, app=None):
"""Start one background walk per output root (aiohttp on_startup).
Registered by the root ``__init__.py``, so the first Images open after
ComfyUI starts finds the listing built. It never fails startup: a
root that cannot be walked is retried by the first read.
"""
try:
for root in self._get_all_output_dirs():
self._schedule_walk(root)
except Exception as e:
self.logger.warning(f"Could not warm the Images listing: {e}")
async def cleanup_gallery_listings(self, app=None):
"""Stop the background listing walks (aiohttp on_cleanup)."""
tasks = list(self._listing_tasks)
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self._listing_tasks.clear()
# A walk cancelled before its first step never ran its own cleanup.
for record in self._root_listings.values():
flight, record.flight = record.flight, None
if flight is not None and not flight.done():
flight.set_result(WalkOutcome.failed(RuntimeError(WALK_STOPPED)))
def reset_gallery_roots_cache(self):
"""Drop every cached listing and derived workspace value (roots changed)."""
self.invalidate_gallery_cache()
+129
View File
@@ -0,0 +1,129 @@
"""One shared listing per output root, and the pure helpers behind it (#348).
The API keeps one ``RootListing`` per output root (``_root_listings`` in
``py/api/__init__.py``). The record is read and written only on the event
loop; the walk itself, the comparison with the previous listing and both
folder sets run in the executor through ``walk_root``. Routes live in
``py/api/images.py`` (``_listing``); this module has no aiohttp import so
tests can exercise it without a server.
"""
from __future__ import annotations
import asyncio
import os
from dataclasses import dataclass
from pathlib import PurePath
from typing import Callable, NamedTuple
# A listing older than this (seconds) is served as it is, and one background
# walk replaces it. Read as ``gallery_listing.REFRESH_AFTER`` so tests can
# patch it.
REFRESH_AFTER = 30
# A reader with no listing gives up (500) after this many walks in a row were
# discarded by invalidations made while they ran.
MAX_DISCARDED_WALKS = 3
WALK_STOPPED = "The listing walk was stopped"
STORED = "stored"
DISCARDED = "discarded"
FAILED = "failed"
class WalkOutcome(NamedTuple):
"""What one walk ended with; a walk's flight future resolves to this."""
kind: str
error: BaseException | None = None
@classmethod
def stored(cls) -> "WalkOutcome":
return cls(STORED)
@classmethod
def discarded(cls) -> "WalkOutcome":
return cls(DISCARDED)
@classmethod
def failed(cls, error: BaseException) -> "WalkOutcome":
return cls(FAILED, error)
@dataclass
class RootListing:
"""The shared listing of one output root.
A record is never deleted: ``drop()`` clears the listing and bumps
``generation``, so a walk that started before a drop can never be stored
after it.
"""
# (path, mtime) newest first, as _scan_gallery_files_sync returns it.
files: list | None = None
folders: frozenset | None = None
folders_with_ancestors: frozenset | None = None
# Monotonic time the last walk finished, stored or failed.
walked_at: float | None = None
# Number of the last walk started; a running walk is always that one.
walk_seq: int = 0
# Number of the walk whose files are stored (Rescan waits on it).
stored_seq: int = 0
generation: int = 0
# The running walk's future (resolves to a WalkOutcome), or None.
flight: asyncio.Future | None = None
# True while walks keep failing: one warning per streak.
failing: bool = False
def drop(self) -> None:
self.files = None
self.folders = None
self.folders_with_ancestors = None
self.walked_at = None
self.generation += 1
def folder_sets(root, files) -> tuple[frozenset, frozenset]:
"""``(folders, folders_with_ancestors)`` of the files listed under *root*.
Folders keep the spelling ``/gallery/subfolders`` has always answered:
``str(relative.parent)`` (native separators) for the folders holding
files, plus every ancestor joined by ``/``.
"""
folders = set()
for path, _mtime in files:
rel_dir = str(path.relative_to(root).parent)
if rel_dir and rel_dir != ".":
folders.add(rel_dir)
with_ancestors = set(folders)
for folder in folders:
parts = folder.replace("\\", "/").split("/")
for i in range(1, len(parts)):
with_ancestors.add("/".join(parts[:i]))
return frozenset(folders), frozenset(with_ancestors)
def same_listing(old, new) -> bool:
"""True when two listings hold the same ``(path, mtime)`` pairs in order."""
if old is None or new is None:
return False
return old is new or old == new
def walk_root(
scan_fn: Callable[[PurePath], list], root, previous_files
) -> tuple[list, frozenset, frozenset]:
"""The single executor job of a walk: ``(files, folders, with_ancestors)``.
Opens the root first, so a missing or unreadable root raises ``OSError``
instead of listing nothing. Returns *previous_files* itself when nothing
changed, which keeps the identity-keyed caches built on it warm.
"""
with os.scandir(root):
pass
files = scan_fn(root)
if same_listing(previous_files, files):
files = previous_files
folders, with_ancestors = folder_sets(root, files)
return files, folders, with_ancestors
+122 -29
View File
@@ -22,6 +22,15 @@ from aiohttp import web
from PIL import Image, UnidentifiedImageError
from .jobs import MAX_OUTCOME_ROWS, THUMBNAILS_JOB_KIND
from . import gallery_listing
from .gallery_listing import (
DISCARDED,
FAILED,
MAX_DISCARDED_WALKS,
WALK_STOPPED,
WalkOutcome,
walk_root,
)
from . import thumbnail_settings
from .thumbnail_encode import (
PREVIEW_HELD,
@@ -1296,16 +1305,107 @@ class ImageRoutesMixin:
return found
async def _get_gallery_files(self, output_path):
"""Get gallery files with per-directory TTL cache."""
now = _time.monotonic()
key = str(output_path)
cached = self._gallery_cache.get(key)
if cached and (now - cached[1]) < self._gallery_cache_ttl:
return cached[0]
"""The shared listing of one output root: ``[(path, mtime)]``, newest first."""
return (await self._listing(output_path)).files
files = await self._run_in_executor(self._scan_gallery_files_sync, output_path)
self._gallery_cache[key] = (files, now)
return files
async def _listing(self, output_path, *, fresh=False):
"""The shared ``RootListing`` of *output_path*, with a listing (#348).
A listing that exists is returned at once; when it is older than
``REFRESH_AFTER`` one background walk replaces it. With no listing,
the caller waits for the root's single walk. ``fresh`` (Rescan) waits
for a walk that started after this call, and raises when it fails
even if a previous listing exists.
"""
record = self._root_listing(output_path)
needed = record.walk_seq + 1 if fresh else 0
discarded = 0
while True:
if record.files is not None and record.stored_seq >= needed:
if not fresh:
self._refresh_if_stale(output_path, record)
return record
flight = record.flight
if flight is None:
flight = self._start_walk(output_path, record)
joined = record.walk_seq
# Shielded: a client that goes away never cancels a shared walk.
outcome = await asyncio.shield(flight)
if joined < needed:
continue # an older walk ended; Rescan needs a newer one
if outcome.kind == FAILED:
raise outcome.error
if outcome.kind == DISCARDED:
discarded += 1
if discarded >= MAX_DISCARDED_WALKS:
raise RuntimeError(
"The output listing kept changing while it was read"
)
def _refresh_if_stale(self, output_path, record):
age = _time.monotonic() - (record.walked_at or 0.0)
if age >= gallery_listing.REFRESH_AFTER:
self._schedule_walk(output_path)
def _schedule_walk(self, output_path):
"""Start a background walk of *output_path* unless one is running."""
record = self._root_listing(output_path)
if record.flight is None:
self._start_walk(output_path, record)
def _start_walk(self, output_path, record):
"""Start the root's one walk; returns its flight future (on the loop)."""
loop = asyncio.get_running_loop()
record.walk_seq += 1
flight = loop.create_future()
record.flight = flight
task = loop.create_task(
self._run_walk(
output_path, record, flight, record.generation, record.walk_seq
)
)
self._listing_tasks.add(task)
task.add_done_callback(self._listing_tasks.discard)
return flight
async def _run_walk(self, output_path, record, flight, generation, seq):
"""Walk in the executor, then store, discard or fail (on the loop).
The flight is always resolved with a WalkOutcome, never failed, so an
error never reaches a future nobody awaits.
"""
outcome = None
try:
files, folders, with_ancestors = await self._run_in_executor(
walk_root, self._scan_gallery_files_sync, output_path, record.files
)
except asyncio.CancelledError:
outcome = WalkOutcome.failed(RuntimeError(WALK_STOPPED))
raise
except Exception as exc:
record.walked_at = _time.monotonic()
if not record.failing:
self.logger.warning(
f"Could not list output folder {output_path}: {exc}"
)
record.failing = True
outcome = WalkOutcome.failed(exc)
else:
record.failing = False
if record.generation == generation:
record.files = files
record.folders = folders
record.folders_with_ancestors = with_ancestors
record.walked_at = _time.monotonic()
record.stored_seq = seq
outcome = WalkOutcome.stored()
else:
outcome = WalkOutcome.discarded()
finally:
if record.flight is flight:
record.flight = None
if not flight.done():
flight.set_result(outcome)
async def get_output_images(self, request):
"""Get all images from ComfyUI output folder(s)."""
@@ -1832,17 +1932,18 @@ class ImageRoutesMixin:
)
async def rescan_gallery(self, request):
"""Forget the cached listing and rewalk every output root now.
"""Rewalk every output root now and answer with the new listing.
The listing is cached for ``_gallery_cache_ttl`` seconds; the gallery's
Rescan button needs files added or removed on disk to show up at once.
Readers are served the shared listing and refresh it in the
background (``gallery_listing.REFRESH_AFTER``); the gallery's Rescan
button needs files added or removed on disk to show up at once, so it
waits for a walk that started after the click and fails when it does.
"""
try:
self.invalidate_gallery_cache()
output_dirs = list(self._get_all_output_dirs())
total = 0
for output_path in output_dirs:
total += len(await self._get_gallery_files(output_path))
total += len((await self._listing(output_path, fresh=True)).files)
return web.json_response(
{"success": True, "total": total, "roots": len(output_dirs)}
)
@@ -1853,20 +1954,14 @@ class ImageRoutesMixin:
)
async def _root_subfolders(self, output_path, include_ancestors):
"""The distinct folders holding listed files under one output root."""
subfolders = set()
for f, _mtime in await self._get_gallery_files(output_path):
rel_dir = str(f.relative_to(output_path).parent)
if rel_dir and rel_dir != ".":
subfolders.add(rel_dir)
"""The distinct folders holding listed files under one output root.
Built with the walk, in the executor (gallery_listing.folder_sets).
"""
listing = await self._listing(output_path)
if include_ancestors:
ancestors = set()
for folder in subfolders:
parts = folder.replace("\\", "/").split("/")
for i in range(1, len(parts)):
ancestors.add("/".join(parts[:i]))
subfolders.update(ancestors)
return subfolders
return listing.folders_with_ancestors
return listing.folders
async def get_gallery_subfolders(self, request):
"""Get distinct subfolders from gallery output directories.
@@ -1940,7 +2035,6 @@ class ImageRoutesMixin:
result = await self._run_in_executor(
self._generate_gallery_thumbnails_sync, output_dirs
)
self.invalidate_gallery_cache()
return web.json_response(result)
except Exception as e:
@@ -2416,7 +2510,6 @@ class ImageRoutesMixin:
cleared_count, cleared_size = await self._run_in_executor(
self._clear_gallery_thumbnails_sync, output_dirs
)
self.invalidate_gallery_cache()
def format_size(bytes_size: int) -> str:
size = float(bytes_size)
-1
View File
@@ -175,7 +175,6 @@ class ThumbnailConvertMixin:
job.log_line(f"Conversion finished · {NOTHING_OUTDATED.lower()}")
return "finished"
totals, left = await self._convert_in_batches(job, options, plan, output_dirs)
self.invalidate_gallery_cache()
if totals["stopped"]:
job.update(status_line=FORMAT_CHANGED.format(converted=totals["converted"]))
job.log_line(
-1
View File
@@ -261,7 +261,6 @@ class ThumbnailJobsMixin:
stats, start, left, attempted = await self._generate_in_batches(
job, options, plan, roots, output_dirs
)
self.invalidate_gallery_cache()
generated = _plural(stats["generated"], "preview")
if job.cancelled:
return self._end_cancelled(
+16 -2
View File
@@ -687,9 +687,23 @@ class TestInstanceHelpers(FolderPathsFixture):
self.assertEqual(self._run(self.api._run_in_executor(add, 1, 2)), 3)
def test_invalidate_gallery_cache(self):
self.api._gallery_cache = {"x": 1}
record = self.api._root_listing(self.output_dir)
record.files = [(self.output_dir / "a.png", 1.0)]
record.folders = record.folders_with_ancestors = frozenset()
record.walked_at = 1.0
self.api._favorite_listing_cache = {"x": 1}
self.api._workspace_order_cache = {"x": 1}
self.api.invalidate_gallery_cache()
self.assertEqual(self.api._gallery_cache, {})
# The record stays: its generation outlives every drop (#348).
self.assertIs(self.api._root_listings[str(self.output_dir)], record)
self.assertIsNone(record.files)
self.assertIsNone(record.folders)
self.assertIsNone(record.walked_at)
self.assertEqual(record.generation, 1)
self.assertEqual(self.api._favorite_listing_cache, {})
self.assertEqual(self.api._workspace_order_cache, {})
self.api.reset_gallery_roots_cache()
self.assertEqual(record.generation, 2)
def test_enrich_images_adds_urls_and_thumbnails(self):
GalleryConfig.MONITORING_DIRECTORIES = [str(self.output_dir)]
+389 -4
View File
@@ -34,6 +34,7 @@ from PIL import Image # noqa: E402
from database.operations import PromptDatabase # noqa: E402
import py.api.images as images_module
from py.api import gallery_listing # noqa: E402
from py.api.thumbnail_settings import ThumbnailSettings # noqa: E402
from py.api import PromptManagerAPI # noqa: E402
from py.api.prompts import MAX_PAGE_LIMIT # noqa: E402
@@ -1805,10 +1806,13 @@ class TestClearThumbnails(ImageRouteCoverageCase):
self.assertFalse(data["success"])
self.assertTrue(original.is_file())
async def test_cleanup_runs_off_loop_and_invalidates_gallery_cache(self):
async def test_cleanup_runs_off_loop_and_keeps_the_listing(self):
import threading
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
make_png(self.output_dir / "a.png")
await self._json("GET", "/prompt_manager/images/output")
listing = self.api._root_listings[str(self.output_dir)].files
loop_thread = threading.get_ident()
deletion_threads = []
original_unlink = Path.unlink
@@ -1817,14 +1821,14 @@ class TestClearThumbnails(ImageRouteCoverageCase):
deletion_threads.append(threading.get_ident())
return original_unlink(path, *args, **kwargs)
self.api._gallery_cache = {"previous": object()}
with patch.object(Path, "unlink", unlink):
status, data = await self._json("POST", self.URL)
self.assertEqual(status, 200)
self.assertEqual(data["cleared_files"], 1)
self.assertTrue(deletion_threads)
self.assertNotIn(loop_thread, deletion_threads)
self.assertEqual(self.api._gallery_cache, {})
# Previews are not part of the listing (#348): it stays as it was.
self.assertIs(self.api._root_listings[str(self.output_dir)].files, listing)
class TestImagePromptLookup(ImageRouteCoverageCase):
@@ -1979,7 +1983,10 @@ class TestGalleryRescan(ImageAPITestCase):
return resp.status, await resp.json()
async def test_rescan_makes_new_files_visible_before_the_cache_expires(self):
self.api._gallery_cache_ttl = 3600 # the cache alone would hide the new file
# The listing alone would hide the new file for an hour.
refresh = patch.object(gallery_listing, "REFRESH_AFTER", 3600)
refresh.start()
self.addCleanup(refresh.stop)
make_png(self.output_dir / "a.png")
_, first = await self._json("GET", self.URL)
self.assertEqual(first["total"], 1)
@@ -2342,3 +2349,381 @@ class TestRootScopeServing(ImageAPITestCase):
self._make_symlink(roots[1], link)
for path in ("image.png", "thumbnails/image_thumb.png"):
await self.assert_no_access(path, scope=token)
class _GatedScan:
"""Wraps the real scan, counts calls and can hold one call after it listed.
``hold_next()`` makes the next call list the files, then wait on the
returned gate, so a test can change the disk or the cache while a walk
is in flight and decide when it ends.
"""
def __init__(self, real):
self.real = real
self.calls = 0
self._gate = None
self.entered = threading.Event()
self._gates = []
def hold_next(self):
self._gate = threading.Event()
self._gates.append(self._gate)
self.entered.clear()
return self._gate
def release_all(self):
for gate in self._gates:
gate.set()
def __call__(self, root):
self.calls += 1
files = self.real(root)
gate, self._gate = self._gate, None
if gate is not None:
self.entered.set()
gate.wait(30)
return files
async def wait_entered(self):
loop = asyncio.get_running_loop()
entered = await loop.run_in_executor(None, self.entered.wait, 30)
if not entered:
raise AssertionError("the walk never reached the gate")
class TestSharedListing(ImageAPITestCase):
"""One listing per output root, shared and refreshed in the background (#348)."""
URL = "/prompt_manager/images/output"
async def asyncSetUp(self):
await super().asyncSetUp()
self.scan = _GatedScan(self.api._scan_gallery_files_sync)
self.api._scan_gallery_files_sync = self.scan
self.addCleanup(self.scan.release_all)
async def _json(self, method, path, **kwargs):
resp = await self.client.request(method, path, **kwargs)
return resp.status, await resp.json()
def _record(self):
return self.api._root_listings[str(self.output_dir)]
def _age(self, seconds=None):
from py.api import gallery_listing
record = self._record()
record.walked_at -= (seconds or gallery_listing.REFRESH_AFTER) + 1
async def _settle(self):
"""Wait for every background walk this API started."""
while self.api._listing_tasks:
await asyncio.gather(*list(self.api._listing_tasks))
async def test_concurrent_cold_readers_share_one_walk(self):
make_png(self.output_dir / "sub" / "a.png")
results = await asyncio.gather(
self._json("GET", self.URL),
self._json("GET", self.URL + "?limit=1"),
self._json("GET", self.URL + "?mode=workspace&limit=1"),
self._json("GET", "/prompt_manager/gallery/subfolders"),
)
self.assertEqual([status for status, _ in results], [200] * 4)
self.assertEqual(self.scan.calls, 1)
self.assertEqual(results[0][1]["total"], 1)
self.assertEqual(results[3][1]["subfolders"], ["sub"])
async def test_a_stale_listing_is_served_at_once_and_refreshed_behind(self):
make_png(self.output_dir / "a.png")
_, first = await self._json("GET", self.URL)
make_png(self.output_dir / "b.png")
self._age()
gate = self.scan.hold_next()
_, stale = await self._json("GET", self.URL)
# Served from the old listing while the walk is still held.
self.assertEqual((first["total"], stale["total"]), (1, 1))
await self.scan.wait_entered()
# A second stale read joins the running walk instead of starting one.
_, again = await self._json("GET", self.URL)
self.assertEqual(again["total"], 1)
gate.set()
await self._settle()
_, fresh = await self._json("GET", self.URL)
self.assertEqual(fresh["total"], 2)
self.assertEqual(self.scan.calls, 2)
async def test_a_fresh_listing_is_not_walked_again(self):
make_png(self.output_dir / "a.png")
for _ in range(3):
await self._json("GET", self.URL)
await self._json("GET", "/prompt_manager/gallery/subfolders")
self.assertEqual(self.scan.calls, 1)
self.assertEqual(self.api._listing_tasks, set())
async def test_rescan_waits_for_a_walk_started_after_the_click(self):
make_png(self.output_dir / "a.png")
await self._json("GET", self.URL)
self._age()
gate = self.scan.hold_next()
await self._json("GET", self.URL) # starts a background walk
await self.scan.wait_entered() # it has listed: it cannot see c.png
make_png(self.output_dir / "c.png")
rescan = asyncio.ensure_future(
self._json("POST", "/prompt_manager/gallery/rescan")
)
await asyncio.sleep(0)
self.assertFalse(rescan.done())
gate.set()
status, data = await rescan
self.assertEqual(status, 200)
self.assertEqual((data["total"], data["roots"]), (2, 1))
self.assertEqual(self.scan.calls, 3)
async def test_rescans_clicked_during_a_walk_share_the_next_one(self):
make_png(self.output_dir / "a.png")
await self._json("GET", self.URL)
gate = self.scan.hold_next()
first = asyncio.ensure_future(
self._json("POST", "/prompt_manager/gallery/rescan")
)
await self.scan.wait_entered()
# Clicked while the first Rescan's walk runs: neither may use it,
# and both wait for the same next walk.
later = [
asyncio.ensure_future(self._json("POST", "/prompt_manager/gallery/rescan"))
for _ in range(2)
]
await asyncio.sleep(0.05)
gate.set()
results = await asyncio.gather(first, *later)
self.assertEqual([status for status, _ in results], [200] * 3)
self.assertEqual(self.scan.calls, 3)
async def test_rescan_failure_with_a_previous_listing_is_500(self):
make_png(self.output_dir / "a.png")
await self._json("GET", self.URL)
self.api._scan_gallery_files_sync = _raise
with self.assertLogs(self.api.logger, "WARNING"):
status, data = await self._json("POST", "/prompt_manager/gallery/rescan")
self.assertEqual(status, 500)
self.assertFalse(data["success"])
self.assertNotIn("boom", json.dumps(data))
# Plain readers keep the previous listing, and do not retry at once.
status, listed = await self._json("GET", self.URL)
self.assertEqual((status, listed["total"]), (200, 1))
self.assertEqual(self.api._listing_tasks, set())
async def test_a_cold_read_whose_walk_fails_is_500(self):
self.api._scan_gallery_files_sync = _raise
with self.assertLogs(self.api.logger, "WARNING"):
status, data = await self._json("GET", self.URL)
self.assertEqual(status, 500)
self.assertFalse(data["success"])
async def test_a_deleted_root_keeps_the_previous_listing_and_warns_once(self):
import shutil
make_png(self.output_dir / "a.png")
await self._json("GET", self.URL)
shutil.rmtree(self.output_dir)
with self.assertLogs(self.api.logger, "WARNING") as logs:
for _ in range(2):
self._age()
status, data = await self._json("GET", self.URL)
self.assertEqual((status, data["total"]), (200, 1))
await self._settle()
warnings = [r for r in logs.records if r.levelname == "WARNING"]
self.assertEqual(len(warnings), 1, [r.getMessage() for r in warnings])
self.assertEqual(self.scan.calls, 1) # the probe failed before the scan
async def test_a_root_that_recovers_warns_again_on_its_next_failure(self):
make_png(self.output_dir / "a.png")
await self._json("GET", self.URL)
real = self.api._scan_gallery_files_sync
with self.assertLogs(self.api.logger, "WARNING") as logs:
for scan in (_raise, real, _raise):
self.api._scan_gallery_files_sync = scan
self._age()
await self._json("GET", self.URL)
await self._settle()
warnings = [r for r in logs.records if r.levelname == "WARNING"]
self.assertEqual(len(warnings), 2)
async def test_an_invalidation_during_a_walk_discards_it(self):
make_png(self.output_dir / "a.png")
gate = self.scan.hold_next()
reader = asyncio.ensure_future(self._json("GET", self.URL))
await self.scan.wait_entered()
make_png(self.output_dir / "b.png")
self.api.invalidate_gallery_cache()
gate.set()
status, data = await reader
self.assertEqual((status, data["total"]), (200, 2))
self.assertEqual(self.scan.calls, 2)
async def test_three_discarded_walks_are_a_500(self):
loop = asyncio.get_running_loop()
real = self.scan.real
async def invalidate():
self.api.invalidate_gallery_cache()
def scan(root):
asyncio.run_coroutine_threadsafe(invalidate(), loop).result(30)
return real(root)
self.scan.real = scan
status, data = await self._json("GET", self.URL)
self.assertEqual(status, 500)
self.assertFalse(data["success"])
self.assertEqual(self.scan.calls, 3)
async def test_a_disconnected_waiter_does_not_cancel_the_walk(self):
make_png(self.output_dir / "a.png")
gate = self.scan.hold_next()
waiter = asyncio.ensure_future(self.api._listing(self.output_dir))
await self.scan.wait_entered()
waiter.cancel()
with self.assertRaises(asyncio.CancelledError):
await waiter
flight = self._record().flight
self.assertIsNotNone(flight)
gate.set()
await asyncio.shield(flight)
self.assertEqual(len(self._record().files), 1)
self.assertIsNone(self._record().flight)
async def test_a_duplicate_delete_is_gone_on_the_very_next_read(self):
make_png(self.output_dir / "keep.png")
make_png(self.output_dir / "dupe.png")
_, before = await self._json("GET", self.URL)
self.assertEqual(before["total"], 2)
status, data = await self._json(
"POST",
"/prompt_manager/delete_duplicate_images",
json={"image_paths": ["dupe.png"]},
)
self.assertEqual((status, data["deleted_count"]), (200, 1))
_, after = await self._json("GET", self.URL)
self.assertEqual([image["filename"] for image in after["images"]], ["keep.png"])
async def test_response_keys_are_unchanged(self):
make_png(self.output_dir / "sub" / "a.png")
_, legacy = await self._json("GET", self.URL)
self.assertEqual(
set(legacy),
{"success", "images", "total", "offset", "limit", "has_more"},
)
_, workspace = await self._json("GET", self.URL + "?mode=workspace")
self.assertEqual(
set(workspace),
{
"success",
"images",
"total",
"positive_count",
"limit",
"has_more",
"next_cursor",
},
)
_, folders = await self._json("GET", "/prompt_manager/gallery/subfolders")
self.assertEqual(set(folders), {"success", "subfolders"})
_, scoped = await self._json(
"GET", "/prompt_manager/gallery/subfolders?mode=workspace"
)
self.assertEqual(set(scoped), {"success", "subfolders", "roots"})
self.assertEqual(set(scoped["roots"][0]), {"index", "label", "subfolders"})
_, rescan = await self._json("POST", "/prompt_manager/gallery/rescan")
self.assertEqual(set(rescan), {"success", "total", "roots"})
async def test_subfolders_with_ancestors_come_from_the_listing(self):
make_png(self.output_dir / "a" / "b" / "c.png")
_, plain = await self._json("GET", "/prompt_manager/gallery/subfolders")
_, nested = await self._json(
"GET", "/prompt_manager/gallery/subfolders?include_ancestors=true"
)
self.assertEqual(plain["subfolders"], [str(Path("a") / "b")])
self.assertEqual(
sorted(nested["subfolders"]), sorted(["a", str(Path("a") / "b")])
)
self.assertEqual(self.scan.calls, 1)
async def test_warm_up_walks_every_root_before_the_first_read(self):
second = self.comfy_dir / "second"
make_png(second / "x.png")
make_png(self.output_dir / "a.png")
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
await self.api.warm_gallery_listings(web.Application())
self.assertEqual(len(self.api._listing_tasks), 2)
await self._settle()
self.assertEqual(self.scan.calls, 2)
_, data = await self._json("GET", self.URL)
self.assertEqual(data["total"], 2)
self.assertEqual(self.scan.calls, 2)
async def test_warm_up_never_fails_startup(self):
def broken():
raise RuntimeError("no roots")
self.api._get_all_output_dirs = broken
with self.assertLogs(self.api.logger, "WARNING"):
await self.api.warm_gallery_listings(web.Application())
self.assertEqual(self.api._listing_tasks, set())
async def test_warm_up_failure_is_retried_by_the_first_read(self):
make_png(self.output_dir / "a.png")
self.api._scan_gallery_files_sync = _raise
with self.assertLogs(self.api.logger, "WARNING"):
await self.api.warm_gallery_listings(web.Application())
await self._settle()
self.api._scan_gallery_files_sync = self.scan
status, data = await self._json("GET", self.URL)
self.assertEqual((status, data["total"]), (200, 1))
async def test_cleanup_cancels_running_walks(self):
make_png(self.output_dir / "a.png")
gate = self.scan.hold_next()
await self.api.warm_gallery_listings(web.Application())
await self.scan.wait_entered()
tasks = set(self.api._listing_tasks)
flight = self._record().flight
await self.api.cleanup_gallery_listings(web.Application())
gate.set()
self.assertTrue(all(task.cancelled() for task in tasks))
self.assertEqual(self.api._listing_tasks, set())
self.assertTrue(flight.done())
self.assertIsNone(self._record().flight)
self.assertIsNone(self._record().files)
async def test_cleanup_resolves_a_walk_cancelled_before_it_ran(self):
await self.api.warm_gallery_listings(web.Application())
flight = self._record().flight
waiter = asyncio.ensure_future(asyncio.shield(flight))
# No await between the warm-up and the cleanup: the task never ran.
await self.api.cleanup_gallery_listings(web.Application())
outcome = await waiter
self.assertEqual(outcome.kind, gallery_listing.FAILED)
self.assertIsNone(self._record().flight)
self.assertEqual(self.scan.calls, 0)
class TestGalleryListingHostHooks(unittest.IsolatedAsyncioTestCase):
async def test_root_registers_the_warm_up_and_its_cleanup(self):
try:
from test_init_package import fake_server_module, load_package
except ImportError:
from tests.test_init_package import fake_server_module, load_package
with tempfile.TemporaryDirectory() as directory:
server = fake_server_module()
server.PromptServer.instance.app = web.Application()
with load_package(
"gallery_listing_hooks_pkg",
str(Path(directory) / "db.sqlite"),
directory,
server,
) as (package, host):
self.assertIn(package.api.warm_gallery_listings, host.app.on_startup)
self.assertIn(package.api.cleanup_gallery_listings, host.app.on_cleanup)
+32
View File
@@ -171,6 +171,38 @@ class TestWorkspaceImages(ImageAPITestCase):
self.assertIsNone(second["next_cursor"])
self.assertEqual(second["total"], 4)
async def test_an_unchanged_refresh_keeps_the_listing_and_derived_caches(self):
from py.api import gallery_listing
self.media("a.png", 300)
self.media("b.png", 200)
_, first = await self.get()
response = await self.client.put(
"/prompt_manager/images/favorite",
json={"root_index": 0, "path": "a.png"},
)
self.assertEqual(response.status, 200)
record = self.api._root_listings[str(self.output_dir)]
files = record.files
key = ((0, str(self.output_dir)),)
newest = self.api._workspace_order_cache[key][1]["newest"]
favorites = self.api._favorite_listing_cache[str(self.output_dir)]
record.walked_at -= gallery_listing.REFRESH_AFTER + 1
with patch.object(
self.api,
"_scan_gallery_files_sync",
wraps=self.api._scan_gallery_files_sync,
) as scan:
await self.get() # served at once; one background walk runs
while self.api._listing_tasks:
await asyncio.gather(*list(self.api._listing_tasks))
_, again = await self.get()
self.assertEqual(scan.call_count, 1)
self.assertIs(record.files, files)
self.assertIs(self.api._workspace_order_cache[key][1]["newest"], newest)
self.assertIs(self.api._favorite_listing_cache[str(self.output_dir)], favorites)
self.assertEqual(again["total"], first["total"])
async def test_filters_apply_before_limit_and_sorts_are_complete(self):
self.media("folder/Zebra.png", 200)
self.media("folder/apple.png", 300)
+164
View File
@@ -0,0 +1,164 @@
"""Pure helpers of the shared Images listing (py/api/gallery_listing.py, #348)."""
import os
import sys
import tempfile
import unittest
from pathlib import Path, PureWindowsPath
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from py.api import gallery_listing # noqa: E402
from py.api.gallery_listing import ( # noqa: E402
DISCARDED,
FAILED,
STORED,
RootListing,
WalkOutcome,
folder_sets,
same_listing,
walk_root,
)
def _legacy_subfolders(root, files, include_ancestors):
"""``_root_subfolders`` as it was before #348, the reference for folder_sets."""
subfolders = set()
for f, _mtime in files:
rel_dir = str(f.relative_to(root).parent)
if rel_dir and rel_dir != ".":
subfolders.add(rel_dir)
if include_ancestors:
ancestors = set()
for folder in subfolders:
parts = folder.replace("\\", "/").split("/")
for i in range(1, len(parts)):
ancestors.add("/".join(parts[:i]))
subfolders.update(ancestors)
return subfolders
class FolderSetsTests(unittest.TestCase):
def test_nested_folders_match_the_previous_subfolder_listing(self):
root = Path("/out")
files = [
(root / "top.png", 1.0),
(root / "a" / "one.png", 2.0),
(root / "a" / "b" / "c" / "two.png", 3.0),
(root / "d" / "three.png", 4.0),
]
folders, with_ancestors = folder_sets(root, files)
self.assertEqual(folders, _legacy_subfolders(root, files, False))
self.assertEqual(with_ancestors, _legacy_subfolders(root, files, True))
self.assertIn("a/b", with_ancestors)
self.assertNotIn("a/b", folders)
self.assertIsInstance(folders, frozenset)
self.assertIsInstance(with_ancestors, frozenset)
def test_windows_separators_keep_the_previous_spelling(self):
root = PureWindowsPath("C:/out")
files = [
(root / "x" / "y" / "z.png", 1.0),
(root / "w.png", 2.0),
]
folders, with_ancestors = folder_sets(root, files)
self.assertEqual(folders, {"x\\y"})
self.assertEqual(with_ancestors, {"x\\y", "x"})
self.assertEqual(folders, _legacy_subfolders(root, files, False))
self.assertEqual(with_ancestors, _legacy_subfolders(root, files, True))
def test_an_empty_listing_has_no_folders(self):
self.assertEqual(folder_sets(Path("/out"), []), (frozenset(), frozenset()))
class SameListingTests(unittest.TestCase):
def test_equal_path_and_mtime_lists_are_the_same(self):
old = [(Path("/o/a.png"), 1.0), (Path("/o/b.png"), 2.0)]
self.assertTrue(same_listing(old, list(old)))
self.assertTrue(same_listing(old, old))
def test_a_changed_mtime_path_or_length_differs(self):
old = [(Path("/o/a.png"), 1.0), (Path("/o/b.png"), 2.0)]
self.assertFalse(same_listing(old, [(Path("/o/a.png"), 1.5), old[1]]))
self.assertFalse(same_listing(old, [(Path("/o/c.png"), 1.0), old[1]]))
self.assertFalse(same_listing(old, old[:1]))
self.assertFalse(same_listing(None, old))
class WalkRootTests(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.root = Path(self._tmp.name) / "output"
(self.root / "sub").mkdir(parents=True)
def _scan(self, files):
calls = []
def scan(root):
calls.append(root)
return list(files)
return scan, calls
def test_returns_the_listing_and_both_folder_sets(self):
files = [(self.root / "sub" / "a.png", 2.0), (self.root / "b.png", 1.0)]
scan, calls = self._scan(files)
listed, folders, with_ancestors = walk_root(scan, self.root, None)
self.assertEqual(calls, [self.root])
self.assertEqual(listed, files)
self.assertEqual(folders, frozenset({"sub"}))
self.assertEqual(with_ancestors, frozenset({"sub"}))
def test_an_unchanged_walk_returns_the_previous_list_object(self):
files = [(self.root / "sub" / "a.png", 2.0)]
previous = list(files)
scan, _calls = self._scan(files)
listed, _folders, _ancestors = walk_root(scan, self.root, previous)
self.assertIs(listed, previous)
def test_a_changed_walk_returns_the_new_list(self):
previous = [(self.root / "sub" / "a.png", 2.0)]
files = [(self.root / "sub" / "a.png", 3.0)]
scan, _calls = self._scan(files)
listed, _folders, _ancestors = walk_root(scan, self.root, previous)
self.assertIsNot(listed, previous)
self.assertEqual(listed, files)
def test_a_deleted_root_raises_instead_of_listing_nothing(self):
scan, calls = self._scan([])
missing = self.root / "gone"
with self.assertRaises(OSError):
walk_root(scan, missing, None)
self.assertEqual(calls, [])
class RecordTests(unittest.TestCase):
def test_drop_clears_the_listing_and_bumps_the_generation(self):
record = RootListing()
record.files = [(Path("/o/a.png"), 1.0)]
record.folders = frozenset({"x"})
record.folders_with_ancestors = frozenset({"x"})
record.walked_at = 5.0
record.drop()
record.drop()
self.assertIsNone(record.files)
self.assertIsNone(record.folders)
self.assertIsNone(record.folders_with_ancestors)
self.assertIsNone(record.walked_at)
self.assertEqual(record.generation, 2)
def test_outcomes(self):
error = OSError("gone")
self.assertEqual(WalkOutcome.stored().kind, STORED)
self.assertEqual(WalkOutcome.discarded().kind, DISCARDED)
failed = WalkOutcome.failed(error)
self.assertEqual(failed.kind, FAILED)
self.assertIs(failed.error, error)
def test_refresh_constant(self):
self.assertEqual(gallery_listing.REFRESH_AFTER, 30)
if __name__ == "__main__":
unittest.main()
+8
View File
@@ -85,6 +85,14 @@ class TestConvertOutdated(ConvertCase):
)
self.assertEqual(self.api._jobs.list(), [])
async def test_conversion_keeps_the_listing(self):
self._legacy("a")
await self.client.get("/prompt_manager/images/output?mode=workspace")
files = self.api._root_listings[str(self.output_dir)].files
_summary, job = await self._convert()
self.assertEqual(job.state, "finished")
self.assertIs(self.api._root_listings[str(self.output_dir)].files, files)
async def test_converts_from_the_source_and_removes_the_old_preview(self):
old = self._legacy("a")
original = (self.output_dir / "a.png").read_bytes()
+14
View File
@@ -345,6 +345,20 @@ class TestThumbnailJob(ThumbnailJobCase):
sizes[quality] = thumb.stat().st_size
self.assertLess(sizes["low"], sizes["high"])
async def test_a_job_keeps_the_listing_and_the_next_page_shows_the_preview(self):
make_png(self.output_dir / "a.png", size=(64, 64))
url = "/prompt_manager/images/output?mode=workspace"
before = (await (await self.client.get(url)).json())["images"][0]
files = self.api._root_listings[str(self.output_dir)].files
status, body = await self._start(mode="missing")
self.assertEqual(status, 202)
await self._finished(body["job"]["id"])
# Previews are not part of the listing (#348): no rewalk is forced.
self.assertIs(self.api._root_listings[str(self.output_dir)].files, files)
after = (await (await self.client.get(url)).json())["images"][0]
self.assertNotIn("v=", before["thumbnail_url"])
self.assertIn("v=", after["thumbnail_url"])
async def test_no_output_folder_fails_the_job(self):
self.api._get_all_output_dirs = lambda: []
_, body = await self._start()
+81
View File
@@ -0,0 +1,81 @@
// Maintainer tool, run by hand (never in CI): how long the workspace Images
// page takes to show its first thumbnail (#348, TESTING.md "Images listing
// cache (#348)").
//
// Once: npm ci --prefix tools/vendor
// npx --prefix tools/vendor playwright install chromium
// Then: node tools/vendor/perf/images_load_probe.mjs http://127.0.0.1:8188
//
// Prints the time to the first loaded thumbnail, the time to network idle and
// the 10 slowest requests. Each run opens a fresh browser context (no cache).
import { chromium } from '@playwright/test';
const SLOWEST = 10;
const TIMEOUT_MS = 120_000;
function usage() {
console.error('usage: node tools/vendor/perf/images_load_probe.mjs <base-url>');
console.error('example: node tools/vendor/perf/images_load_probe.mjs http://127.0.0.1:8188');
process.exit(2);
}
function parseBase(argument) {
if (!argument) usage();
let url;
try {
url = new URL(argument);
} catch {
usage();
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') usage();
return url.origin + url.pathname.replace(/\/+$/, '');
}
const ms = value => `${Math.round(value)} ms`;
async function probe(base) {
const browser = await chromium.launch();
try {
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await context.newPage();
const requests = [];
page.on('requestfinished', request => {
const timing = request.timing();
requests.push({
url: request.url().replace(base, ''),
status: null,
ms: timing.responseEnd >= 0 ? timing.responseEnd : NaN,
response: request.response(),
});
});
const started = Date.now();
await page.goto(`${base}/prompt_manager/workspace#/images`, { timeout: TIMEOUT_MS });
// The first Images thumbnail the browser has decoded.
await page.waitForFunction(() => [...document.images].some(image =>
image.complete && image.naturalWidth > 0
&& image.src.includes('/prompt_manager/images/serve/')), null,
{ timeout: TIMEOUT_MS, polling: 'raf' });
const firstThumbnail = Date.now() - started;
await page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS });
const networkIdle = Date.now() - started;
for (const entry of requests) {
entry.status = (await entry.response)?.status() ?? 'n/a';
}
const slowest = requests.filter(entry => Number.isFinite(entry.ms))
.sort((a, b) => b.ms - a.ms).slice(0, SLOWEST);
console.log(`first thumbnail: ${ms(firstThumbnail)}`);
console.log(`network idle: ${ms(networkIdle)}`);
console.log(`requests: ${requests.length}`);
console.log(`slowest ${slowest.length}:`);
for (const entry of slowest) {
console.log(` ${ms(entry.ms).padStart(9)} ${String(entry.status).padEnd(3)} ${entry.url}`);
}
} finally {
await browser.close();
}
}
probe(parseBase(process.argv[2])).catch(error => {
console.error(`probe failed: ${error.message}`);
process.exit(1);
});