Photo list query has no index for its sort shape (436x) #84

Closed
opened 2026-07-28 06:00:29 +00:00 by claude-bot · 1 comment

Severity: HIGH (measured)

The problem

PhotoRepository.list_photos filters on collection_id and orders by created_at, but no index
contains created_at for the photo table. Every page pays a full scan plus a temporary B-tree
sort.

Measurements

Built a 50k-row SQLite database mirroring migration 001 exactly, then ran EXPLAIN QUERY PLAN
and timings:

Query Plan Time
page 1, no filter SCAN photo + temp B-tree 61.0 ms
deep cursor page SCAN photo + temp B-tree 24.8 ms
status=pending (55% of rows) index for filter, still sorts 26.4 ms
status=disputed (2% of rows) index for filter, still sorts 3.9 ms
with (collection_id, created_at) index order, no temp sort 0.14 ms
with (collection_id, status, created_at) index order, no temp sort 0.12-0.14 ms

436x on the hottest query. The cost is linear in collection size, and keyset pagination does
not amortize it without the index.

Fix

One Alembic migration:

  • add ix_photo_collection_created (collection_id, created_at)
  • replace ix_photo_collection_status with (collection_id, status, created_at)

Both plans were verified to flip to index-order SEARCH with no temp B-tree.

Done when

  • The migration is applied and EXPLAIN QUERY PLAN shows no temp B-tree for either shape
  • A note records the measured before/after so the change is evaluable

References

  • backend/app/repositories/photos.py:40-54
  • backend/app/models/models.py:262-267
  • backend/alembic/versions/001_initial_schema.py:137-140

Related: #48 asks generically to verify indexes; this is the concrete answer.

## Severity: HIGH (measured) ## The problem `PhotoRepository.list_photos` filters on `collection_id` and orders by `created_at`, but no index contains `created_at` for the photo table. Every page pays a full scan plus a temporary B-tree sort. ## Measurements Built a 50k-row SQLite database mirroring migration `001` exactly, then ran `EXPLAIN QUERY PLAN` and timings: | Query | Plan | Time | |---|---|---| | page 1, no filter | `SCAN photo` + temp B-tree | **61.0 ms** | | deep cursor page | `SCAN photo` + temp B-tree | 24.8 ms | | status=pending (55% of rows) | index for filter, still sorts | 26.4 ms | | status=disputed (2% of rows) | index for filter, still sorts | 3.9 ms | | with `(collection_id, created_at)` | index order, no temp sort | **0.14 ms** | | with `(collection_id, status, created_at)` | index order, no temp sort | 0.12-0.14 ms | **436x** on the hottest query. The cost is linear in collection size, and keyset pagination does not amortize it without the index. ## Fix One Alembic migration: - add `ix_photo_collection_created (collection_id, created_at)` - replace `ix_photo_collection_status` with `(collection_id, status, created_at)` Both plans were verified to flip to index-order `SEARCH` with no temp B-tree. ## Done when - [ ] The migration is applied and `EXPLAIN QUERY PLAN` shows no temp B-tree for either shape - [ ] A note records the measured before/after so the change is evaluable ## References - `backend/app/repositories/photos.py:40-54` - `backend/app/models/models.py:262-267` - `backend/alembic/versions/001_initial_schema.py:137-140` Related: #48 asks generically to verify indexes; this is the concrete answer.
claude-bot added this to the v0.2.0 milestone 2026-07-28 06:00:29 +00:00
Author

Done in c225acb. Migration 010, plus backend/tests/test_query_plans.py.

Re-measured, because the issue's numbers predate #88

#88 changed the ordering to (created_at, id) with a row-value cursor comparison, so I rebuilt the 50k-row database and measured the current query rather than trusting the earlier figures. Best of five:

Query Before After Plan
page 1, no filter 15.2 ms 0.150 ms SCAN + temp B-tree → index order
deep cursor page 17.4 ms 0.150 ms SCAN + temp B-tree → index order
status=pending (55%) 11.8 ms 0.142 ms index + temp B-tree → index order
status=disputed (2%) 1.9 ms 0.149 ms index + temp B-tree → index order

(The absolute numbers are lower than the issue's — different machine — but the plan change is identical, and the deep page is the interesting one: it was the slowest of the four, because keyset pagination does not amortize a missing index. The cursor predicate still had to be applied to every row.)

Two corrections to the proposed shape, both from measuring

1. id has to be in the index. With (collection_id, created_at) the plan still reports USE TEMP B-TREE FOR LAST TERM OF ORDER BY — the sort is smaller, not gone, because #88 made the sort key (created_at, id). So the index is (collection_id, created_at, id).

2. The status index needs the sort key too. Given only the created index, SQLite drops ix_photo_collection_status entirely and filters:

  • status=pending (55% of rows) → 0.140 ms, fine
  • status=disputed (2% of rows) → 0.375 ms, about 2.5× the indexed answer

disputed is the queue a reviewer opens most often, so the status index is widened to (collection_id, status, created_at, id) rather than left to be ignored. The narrow (collection_id, status) is dropped rather than kept alongside — it is a prefix of the new one, so it would cost writes and disk to answer nothing.

A test that asserts the plan, not the timing

An index is not a behaviour. Dropped, every other test in the suite would keep passing — a little more slowly — until a collection grew large enough for someone to complain. test_query_plans.py asserts EXPLAIN QUERY PLAN has no TEMP B-TREE and names the expected index, that the deep page seeks on (created_at,id)> rather than filtering, and that the indexes exist on the migrated schema.

That last one matters for the same reason #67's trigger test does: on SQLite batch_alter_table is create-copy-drop-rename, which takes a table's indexes with it — and an index would go that way with even less noise than a trigger.

Verified load-bearing: against the pre-010 schema both plans come back USE TEMP B-TREE FOR ORDER BY, so the assertions fail.

Done when

  • The migration is applied and EXPLAIN QUERY PLAN shows no temp B-tree for either shape
  • A note records the measured before/after so the change is evaluable — in the migration docstring, the test module docstring, and the changelog

1028 passed, 8 skipped; migrations up and down from empty verified; ruff clean.

Done in c225acb. Migration `010`, plus `backend/tests/test_query_plans.py`. ## Re-measured, because the issue's numbers predate #88 #88 changed the ordering to `(created_at, id)` with a row-value cursor comparison, so I rebuilt the 50k-row database and measured the *current* query rather than trusting the earlier figures. Best of five: | Query | Before | After | Plan | |---|---|---|---| | page 1, no filter | 15.2 ms | **0.150 ms** | `SCAN` + temp B-tree → index order | | deep cursor page | 17.4 ms | **0.150 ms** | `SCAN` + temp B-tree → index order | | `status=pending` (55%) | 11.8 ms | **0.142 ms** | index + temp B-tree → index order | | `status=disputed` (2%) | 1.9 ms | **0.149 ms** | index + temp B-tree → index order | (The absolute numbers are lower than the issue's — different machine — but the plan change is identical, and the deep page is the interesting one: it was the *slowest* of the four, because keyset pagination does not amortize a missing index. The cursor predicate still had to be applied to every row.) ## Two corrections to the proposed shape, both from measuring **1. `id` has to be in the index.** With `(collection_id, created_at)` the plan still reports `USE TEMP B-TREE FOR LAST TERM OF ORDER BY` — the sort is smaller, not gone, because #88 made the sort key `(created_at, id)`. So the index is `(collection_id, created_at, id)`. **2. The status index needs the sort key too.** Given only the created index, SQLite drops `ix_photo_collection_status` entirely and filters: - `status=pending` (55% of rows) → 0.140 ms, fine - `status=disputed` (2% of rows) → **0.375 ms**, about 2.5× the indexed answer `disputed` is the queue a reviewer opens most often, so the status index is widened to `(collection_id, status, created_at, id)` rather than left to be ignored. The narrow `(collection_id, status)` is dropped rather than kept alongside — it is a prefix of the new one, so it would cost writes and disk to answer nothing. ## A test that asserts the plan, not the timing An index is not a behaviour. Dropped, every other test in the suite would keep passing — a little more slowly — until a collection grew large enough for someone to complain. `test_query_plans.py` asserts `EXPLAIN QUERY PLAN` has no `TEMP B-TREE` and names the expected index, that the deep page *seeks* on `(created_at,id)>` rather than filtering, and that the indexes exist on the migrated schema. That last one matters for the same reason #67's trigger test does: on SQLite `batch_alter_table` is create-copy-drop-rename, which takes a table's indexes with it — and an index would go that way with even less noise than a trigger. Verified load-bearing: against the pre-`010` schema both plans come back `USE TEMP B-TREE FOR ORDER BY`, so the assertions fail. ## Done when - [x] The migration is applied and `EXPLAIN QUERY PLAN` shows no temp B-tree for either shape - [x] A note records the measured before/after so the change is evaluable — in the migration docstring, the test module docstring, and the changelog **1028 passed, 8 skipped**; migrations up and down from empty verified; ruff clean.
Sign in to join this conversation.
No description provided.