Skip to content

Load an album's index off the event loop - #377

Open
lstein wants to merge 1 commit into
masterfrom
lstein/fix/blocking-index-loads
Open

Load an album's index off the event loop#377
lstein wants to merge 1 commit into
masterfrom
lstein/fix/blocking-index-loads

Conversation

@lstein

@lstein lstein commented Aug 20, 2026

Copy link
Copy Markdown
Owner

The cost

Embeddings.indexes and open_cached_embeddings are a full np.load of the index whenever the three-entry lru_cache misses — 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 def endpoints 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:

file endpoint call
umap.py /umap_data/ umap_embeddings + open_cached_embeddings
search.py /image_info/ .indexes
search.py /get_metadata/ .indexes
search.py /lookup_image_indices/, /get_image_by_name/ .indexes
invoke.py /recall_parameters/, /use_ref_image/ via _load_raw_metadata / _load_image_path
index.py /index_metadata/ open_cached_embeddings

What changed

Two async accessors on Embeddingsload_indexes() and load_cached_embeddings() — that do the work in a thread, plus asyncio.to_thread at the two invoke.py call 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_embeddings clears 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_path already 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:

  • four endpoints asserted to load the index only on a worker thread, using 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;
  • the delete race, driven deterministically by priming the cache from the still-unmodified file during the write — precisely what a reader that loaded just before the rename leaves behind.

696 backend tests pass; ruff clean.

🤖 Generated with Claude Code

`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
lstein force-pushed the lstein/fix/blocking-index-loads branch from cc59a9a to b781527 Compare August 20, 2026 00:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant