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:
+47
@@ -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:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user