Skip to content

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

Merged
lstein merged 3 commits into
masterfrom
lstein/fix/blocking-index-loads
Aug 22, 2026
Merged

Load an album's index off the event loop#377
lstein merged 3 commits 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
lstein and others added 2 commits August 21, 2026 19:08
…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
lstein merged commit 17903d2 into master Aug 22, 2026
9 checks passed
@lstein
lstein deleted the lstein/fix/blocking-index-loads branch August 22, 2026 00:51
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>
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