Load an album's index off the event loop - #377
Merged
Merged
Conversation
`Embeddings.indexes` and `open_cached_embeddings` are a full `np.load` of
the index whenever the three-entry `lru_cache` misses — which is any request
touching a fourth album, and the first request after the index is rewritten.
The load unpickles the per-image metadata object array, copies four arrays
and sorts one. Measured at 0.34s for a 50,000-image index with modest
metadata and a warm page cache; a real library with InvokeAI generation
metadata and a cold cache is several times that.
Six async endpoints did that inside the coroutine, so the whole server
stopped for the duration — the slideshow, thumbnails, and the
indexing-progress polling the user is watching while they wait:
umap.py /umap_data umap_embeddings + open_cached_embeddings
search.py /image_info .indexes
/get_metadata .indexes
/lookup_image_indices, /get_image_by_name
invoke.py /recall_parameters, /use_ref_image (via the sync helpers)
index.py /index_metadata open_cached_embeddings
`load_indexes()` and `load_cached_embeddings()` do it in a thread; the two
`invoke.py` helpers stay synchronous and hop at their call sites.
Moving reads into threads makes one existing race reachable, so it is fixed
here rather than left behind. The delete path clears the index cache, writes,
then re-primes "to verify the write" — but a reader that missed the cache
before the clear can finish loading at any point after it and store the
pre-delete snapshot, in which case the re-prime is a cache hit that verifies
nothing and every later request keeps serving a deleted image. Clearing again
immediately before the re-prime makes it a real load, matching what
`update_image_path` already does.
Not included: the delete endpoints themselves still run their load-and-rewrite
on the loop. That one cannot simply be threaded — the event loop is currently
what serializes concurrent deletes, and moving it without a per-index lock
would let two deletes race and lose one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lstein
force-pushed
the
lstein/fix/blocking-index-loads
branch
from
August 20, 2026 00:06
cc59a9a to
b781527
Compare
…weep Follow-up to the previous commit, from an adversarial review of it. Three problems, all consequences of the same change: once index reads run in worker threads, the lru_cache underneath them is no longer protected by the event loop's serialization. 1. The delete race was not actually closed. Clearing the cache again before the re-prime narrows the window but does not remove it, because the re-prime is itself a full load: a reader that lands *during* it installs the pre-delete snapshot, and CPython's lru_cache then refuses to overwrite a key that appeared while the wrapped call ran, so the re-prime's fresh result is silently discarded. The cache goes on serving a deleted image indefinitely while the delete reports success. Reproduced against the real code before fixing. 2. Concurrent misses were no longer deduped. lru_cache does not collapse in-flight calls; the event loop used to, by never letting two overlap. Eight concurrent requests for an uncached 50,000-image index measured eight full loads and 1.7 GB peak RSS, against one load and 0.26 GB for a single request. Both are fixed by replacing the lru_cache with _NpzIndexCache: one load per path at a time, and a generation counter so a load stores its result only if the generation it started under is still current. Measured after: exactly one load per burst at 1, 2, 4, 8 and 16 concurrent readers, with peak RSS flat. 3. The sweep missed the endpoints that matter most. /retrieve_image/ is the slideshow's own endpoint and fires once per slide, so it is the one most likely to *take* the cold miss the previous commit set out to move off the loop; /thumbnails/ fires in a burst per grid. Those two, plus /image_path/, /video_frame/, /download_images_zip/, /search_with_text_and_image/ and /curate_sync, all still ran the same blocking load inside their coroutine. Search goes behind a semaphore as well as a thread, so that threading it does not newly allow two concurrent CLIP encodes on one GPU. Regression tests, each confirmed to fail without its fix: the narrow delete race, one-load-per-burst under eight concurrent readers, the six newly threaded endpoints, and an empty-index zip download (the priming call added to /download_images_zip/ would otherwise turn a missing index into a 500 where both loops below already tolerate it). 741 backend tests pass; ruff clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lstein
added a commit
that referenced
this pull request
Aug 22, 2026
…383) PR #382 (the iPad fullscreen-panel fix) carried stale copies of files it never meant to touch, and squash-merging it rolled two already-merged PRs all the way back to their pre-merge state: #378 Resolve InvokeAI board media through their recorded subfolder photomap/backend/invokeai_client.py photomap/backend/routers/index.py tests/backend/test_invokeai_client.py tests/backend/test_invokeai_board_index.py #376 Pausing mid-edit no longer discards the Cluster Strength photomap/frontend/static/javascript/umap.js photomap/frontend/static/css/umap-floating-window.css tests/backend/test_cluster_eps.py tests/frontend/umap-eps-debounce.test.js (deleted outright) tests/frontend/umap-reindex-refresh.test.js Every one of those files was byte-identical to its pre-merge content on master, tests included, which is why nothing failed: the tests that would have caught it went back with the code they covered. The user-visible symptom is #378's: board albums went back to joining the bare filename to <invokeai_root>/outputs/{images,videos}, so on a backend whose subfolder strategy is not `flat` almost nothing resolved — indexing the reporter's board skipped 386 of 387 files. This restores both commits verbatim (cherry-picked, no conflicts) on top of current master. #377's `asyncio.to_thread` hunk in index.py, which landed after the revert in an untouched region, is preserved. Verified live against the reporter's InvokeAI at localhost:9090: the same album now resolves 386 of 387 files, the inverse of the reported failure. The one remaining miss is a genuine gap — InvokeAI lists a video whose subfolder resolves correctly and whose directory exists, but the file itself is not on disk. Backend 844 passed, frontend 656 passed (633 before, the difference being umap-eps-debounce.test.js coming back), ruff clean. Checked the seam between restored #376 and #375, which was authored against the reverted tree: both floor the Cluster Strength at MIN_CLUSTER_EPS (0.01, the spinner's own `min`), so they agree. Claude-Session: https://claude.ai/code/session_01KtGdMfK3k6z2tazjDjMFtE Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The cost
Embeddings.indexesandopen_cached_embeddingsare a fullnp.loadof the index whenever the three-entrylru_cachemisses — any request touching a fourth album, and the first request after the index is rewritten. The load unpickles the per-image metadata object array, copies four arrays, and sorts one.Measured on this branch: 0.34s for a 50,000-image index (159 MB) with modest synthetic metadata and a warm page cache. A real library with InvokeAI generation metadata and a cold cache is several times that.
Six
async defendpoints did that inside the coroutine, which stops every other request for the duration — the slideshow, thumbnail fetches, and the indexing-progress polling the user is staring at while they wait:umap.py/umap_data/umap_embeddings+open_cached_embeddingssearch.py/image_info/.indexessearch.py/get_metadata/.indexessearch.py/lookup_image_indices/,/get_image_by_name/.indexesinvoke.py/recall_parameters/,/use_ref_image/_load_raw_metadata/_load_image_pathindex.py/index_metadata/open_cached_embeddingsWhat changed
Two async accessors on
Embeddings—load_indexes()andload_cached_embeddings()— that do the work in a thread, plusasyncio.to_threadat the twoinvoke.pycall sites (the helpers there are plain sync utilities used from three places, so hopping at the call site beats making them async).The blocking property keeps a docstring saying it is blocking and pointing at the async form.
The race this exposes, fixed here
Moving reads into worker threads makes one existing interleaving reachable, so it is fixed rather than left behind.
remove_images_from_embeddingsclears the index cache, writes, then re-primes it "to verify the write". A reader that missed the cache before the clear can finish its load at any point after it and store the pre-delete snapshot — and then the re-prime is a cache hit that verifies nothing, so every later request goes on serving an image that is no longer in the index.Clearing again immediately before the re-prime makes it a real load. This is what
update_image_pathalready does a few hundred lines below, for exactly this reason.Deliberately not included
The delete endpoints still run their load-and-rewrite on the loop. That one cannot simply be threaded: the event loop is currently what serializes concurrent deletes, and moving it without a per-index lock would let two deletes race and lose one. Worth doing, but it needs the lock, and that is a different change.
Tests
tests/backend/test_event_loop_blocking.py— 5 tests, all failing on master:asyncio.get_running_loop()(which succeeds only on the loop thread) rather than thread names, and asserting a load happened so they cannot pass vacuously;696 backend tests pass; ruff clean.
🤖 Generated with Claude Code