Skip to content

perf(db): use a covering index and anti-join for the gallery image names query#9385

Open
dexhunter wants to merge 1 commit into
invoke-ai:mainfrom
dexhunter:perf/gallery-image-names-query
Open

perf(db): use a covering index and anti-join for the gallery image names query#9385
dexhunter wants to merge 1 commit into
invoke-ai:mainfrom
dexhunter:perf/gallery-image-names-query

Conversation

@dexhunter

Copy link
Copy Markdown
Contributor

Summary

Kind: performance (backend, gallery)

GET /api/v1/images/names fetches the full ordered name list that drives the virtualized gallery. The names query has no index matching its shape, so on every request SQLite scans idx_images_starred, probes board_images once per row through the LEFT 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:

  1. Query: the board filter in SqliteImageRecordStorage.get_image_names becomes a NOT EXISTS / EXISTS subquery instead of LEFT JOIN board_images + IS NULL / = ?. board_images.image_name is 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).
  2. Index: a migration adds idx_images_gallery_names ON images (image_category, is_intermediate, starred DESC, created_at DESC, image_name). Equality columns first, then the ORDER BY columns, then image_name so 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, order DESC; medians of interleaved paired rounds on an otherwise idle 32-core Linux host; fixture with 2% starred, 33% boarded, distinct millisecond timestamps):

Gallery size before after
10,000 images 8.7 ms 6.6 ms
50,000 images 55.6 ms 39.6 ms
200,000 images 239.7 ms 186.9 ms
200,000, starred_first=false 798 ms 200 ms

The order_dir=ASC toggle also improves (240 → 205 ms at 200k). No measured path regresses.

Why the two halves must land together: the index alone (old LEFT JOIN query) 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 without image_name, and a partial WHERE is_intermediate = 0 variant — 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. A NOT IN variant 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/DK5RIpsJirqAqrB8ty4dhZCMF5K6SN4H

Where 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 def over 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 DESC has no tiebreaker, so images created in the same millisecond have no defined order. Adding image_name as 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).
  • On any existing install, start the app once (the migration logs 2026_07_25_gallery_name_list_index and builds the index), then EXPLAIN QUERY PLAN on the names query shows SEARCH images USING COVERING INDEX idx_images_gallery_names with no USE TEMP B-TREE line; on main it shows the temp B-tree sort.
  • Gallery behavior is unchanged: same images, same order, same starred counts.

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 is DROP INDEX idx_images_gallery_names plus 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

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • Documentation added / updated (if applicable) — n/a, no user-facing behavior change

…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).
@github-actions github-actions Bot added python PRs that change python files services PRs that change app services python-tests PRs that change python tests labels Jul 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python PRs that change python files python-tests PRs that change python tests services PRs that change app services

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant