Load an album's index off the event loop - #377
Open
lstein wants to merge 1 commit into
Open
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
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