perf(db): use a covering index and anti-join for the gallery image names query#9385
Open
dexhunter wants to merge 1 commit into
Open
perf(db): use a covering index and anti-join for the gallery image names query#9385dexhunter wants to merge 1 commit into
dexhunter wants to merge 1 commit into
Conversation
…mes query GET /api/v1/images/names fetches the full ordered name list for the virtualized gallery. The names query had no index matching its shape, so SQLite scanned idx_images_starred, probed board_images per row, and sorted every returned row in a temp B-tree on each request. Measured on the production method (SqliteImageRecordStorage .get_image_names, both statements, median of interleaved rounds, the client's default parameters): - 10k-image gallery: 8.7 ms -> 6.6 ms - 50k-image gallery: 55.6 ms -> 39.6 ms - 200k-image gallery: 239.7 ms -> 186.9 ms - 200k with starred_first=false: 798 ms -> 200 ms The two changes only work together: the covering index with the old LEFT JOIN query regresses 1.55x at 200k rows, and the anti-join without the index is neutral at 50k. Per-image insert overhead from the index is +76 us on a 3.2 ms insert transaction; the index adds ~3.1 MB per 50k images and builds once in 68 ms (50k) / 265 ms (200k).
dexhunter
requested review from
JPPhoto,
Pfannkuchensack,
blessedcoolant,
dunkeroni and
lstein
as code owners
July 25, 2026 22:34
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.
Summary
Kind: performance (backend, gallery)
GET /api/v1/images/namesfetches the full ordered name list that drives the virtualized gallery. The names query has no index matching its shape, so on every request SQLite scansidx_images_starred, probesboard_imagesonce per row through theLEFT JOIN, and then sorts every returned row in a temp B-tree (USE TEMP B-TREE FOR LAST TERM OF ORDER BY).This PR makes two changes that only work as a pair:
SqliteImageRecordStorage.get_image_namesbecomes aNOT EXISTS/EXISTSsubquery instead ofLEFT JOIN board_images+IS NULL/= ?.board_images.image_nameis that table's primary key, so an image has at most one board row and the two forms return identical results (verified exhaustively in tests and with a differential harness across 2,880 parameter combinations — zero mismatches).idx_images_gallery_names ON images (image_category, is_intermediate, starred DESC, created_at DESC, image_name). Equality columns first, then theORDER BYcolumns, thenimage_nameso the scan is covering. The plan becomes a covering index scan in output order: no temp B-tree, no per-row table lookups. Index-only migrations to fix a query shape have precedent here ([feat] Round robin job scheduling in multiuser mode #9086's round-robin indexes).Measured on the production method (both statements + result assembly, client-default parameters
board_id='none',categories=['general'],is_intermediate=false,starred_first=true, orderDESC; medians of interleaved paired rounds on an otherwise idle 32-core Linux host; fixture with 2% starred, 33% boarded, distinct millisecond timestamps):starred_first=falseThe
order_dir=ASCtoggle also improves (240 → 205 ms at 200k). No measured path regresses.Why the two halves must land together: the index alone (old
LEFT JOINquery) measured a 1.55× regression at 200k rows (343 ms vs 220 ms), because the planner keeps the join probe; and the rewrite alone is neutral at ≤50k. Several simpler index shapes —(starred DESC, created_at DESC)with and withoutimage_name, and a partialWHERE is_intermediate = 0variant — measured 2.5–3.7× slower than baseline (non-covering indexes turn the sequential scan into random row lookups), which is why this PR doesn't take the "just add an index on the ORDER BY columns" route. ANOT INvariant and an all-ASC index variant measured equivalent to the chosen form;NOT EXISTS+ DESC columns were kept as the more robust/idiomatic pair. As a record of the alternatives we explored and measured (including the regressions), see this external autoresearch log: https://dashboard.weco.ai/share/DK5RIpsJirqAqrB8ty4dhZCMF5K6SN4HWhere this matters and where it doesn't: the name list is refetched on app start, board switch, view toggle, star/unstar, delete, move, and search-term change — and the handler is
async defover synchronous SQLite, so while this query runs the event loop is blocked for every other request. At 5k images the query is ~4 ms and the win is ~1–2 ms — imperceptible; the change is aimed at the "10k+ is not uncommon" galleries discussed in #6632, where it removes 25–50+ ms of event-loop blocking per refetch. Costs measured for the index: +76 µs per image insert (on a ~3.2 ms insert transaction), ~3.1 MB of DB per 50k images, one-time build of 68 ms (50k) / 265 ms (200k) during the migration.One pre-existing quirk noticed while measuring (unchanged by this PR, mentioning for completeness):
ORDER BY starred DESC, created_at DESChas no tiebreaker, so images created in the same millisecond have no defined order. Addingimage_nameas a tiebreaker reorders such rows and costs ~10 ms at 50k, so it was deliberately left out.Related Issues / Discussions
QA Instructions
uv run pytest tests/app/services/image_records/ tests/app/services/shared/sqlite_migrator/— covers board filtering ("none" / explicit board / no filter, per-user isolation), starred-first ordering in both directions,starred_first=false, and the migration (columns, idempotence, missing-table tolerance).2026_07_25_gallery_name_list_indexand builds the index), thenEXPLAIN QUERY PLANon the names query showsSEARCH images USING COVERING INDEX idx_images_gallery_nameswith noUSE TEMP B-TREEline; onmainit shows the temp B-tree sort.Merge Plan
DB-schema-adjacent but additive-only: the migration creates one index (
CREATE INDEX IF NOT EXISTS), rewrites no data, and runs once at startup (sub-second even for very large galleries). Rollback isDROP INDEX idx_images_gallery_namesplus a revert of the query change — but please don't ship the index without the query rewrite (or vice versa); the halves are only correct together, per the regression numbers above.Checklist