perf(images): start a stale listing's refresh walk a second after the read (#348)

After the faster walk, a read that found the listing stale still took
0.5-1.0 s against 0.07 s: it was served from the old listing but ran beside
the refresh walk it had just started, sharing the GIL. The refresh now starts
REFRESH_DELAY (1 s) after that read through a per-root rewalk_handle, so the
page's own requests finish first. One pending refresh per root and none while
a walk runs; any walk start (a cold read, Rescan, the warm-up) cancels a
pending one; on_cleanup cancels them before its first await.
This commit is contained in:
Vito Sansevero
2026-10-26 10:34:05 -07:00
parent b7b99004c7
commit a320acc41b
6 changed files with 111 additions and 5 deletions
+5
View File
@@ -548,6 +548,11 @@ class PromptManagerAPI(
async def cleanup_gallery_listings(self, app=None):
"""Stop the background listing walks (aiohttp on_cleanup)."""
self._workspace_warm_enabled = False
# Before any await, so no delayed refresh can start a walk meanwhile.
for record in self._root_listings.values():
handle, record.rewalk_handle = record.rewalk_handle, None
if handle is not None:
handle.cancel()
warm, self._workspace_warm_task = self._workspace_warm_task, None
if warm is not None:
warm.cancel()
+7
View File
@@ -21,6 +21,11 @@ from typing import Callable, NamedTuple
# patch it.
REFRESH_AFTER = 30
# The stale read is served at once; its refresh walk starts this many seconds
# later, so the walk does not share the GIL with the page's own requests
# (#348 live test: reads during the walk took 2.2 s instead of 0.07 s).
REFRESH_DELAY = 1.0
# 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
@@ -75,6 +80,8 @@ class RootListing:
flight: asyncio.Future | None = None
# True while walks keep failing: one warning per streak.
failing: bool = False
# The delayed refresh not started yet (loop.call_later), or None.
rewalk_handle: asyncio.TimerHandle | None = None
def drop(self) -> None:
self.files = None
+18 -2
View File
@@ -1422,9 +1422,22 @@ class ImageRoutesMixin:
)
def _refresh_if_stale(self, output_path, record):
"""Walk a stale listing again, REFRESH_DELAY after this read (on the loop).
At most one pending refresh per root, none while a walk runs.
"""
age = _time.monotonic() - (record.walked_at or 0.0)
if age >= gallery_listing.REFRESH_AFTER:
self._schedule_walk(output_path)
if age < gallery_listing.REFRESH_AFTER:
return
if record.flight is not None or record.rewalk_handle is not None:
return
record.rewalk_handle = asyncio.get_running_loop().call_later(
gallery_listing.REFRESH_DELAY, self._delayed_walk, output_path, record
)
def _delayed_walk(self, output_path, record):
record.rewalk_handle = None
self._schedule_walk(output_path)
def _schedule_walk(self, output_path):
"""Start a background walk of *output_path* unless one is running."""
@@ -1436,6 +1449,9 @@ class ImageRoutesMixin:
"""Start the root's one walk; returns its flight future (on the loop)."""
loop = asyncio.get_running_loop()
self._listing_loop = loop
handle, record.rewalk_handle = record.rewalk_handle, None
if handle is not None:
handle.cancel() # this walk is the refresh it was waiting to start
record.walk_seq += 1
flight = loop.create_future()
record.flight = flight