perf(roms): sort metadata fields on the indexed roms column - #4078
Merged
Conversation
Ordering the gallery by a `roms_metadata` field joined the view back in and sorted through it. `roms_metadata` is a thin view over STORED generated columns on `roms` (migration 0098), so that join is `roms` to itself and it leaves the sort key on a joined table, which no index can serve: the engine filesorts the whole library for every page. The generated columns are already indexed, so the three that are offered as sorts now resolve straight to them and the join goes away. On a 120k-row table this turns a full scan plus filesort into a 72-row index walk. The remaining view columns are JSON arrays with no index and no sort that offers them, so they keep reading the view. Fixes rommapp#4067 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Greptile SummaryThis PR optimizes metadata sorting by resolving three public sort keys directly to indexed generated columns on
Confidence Score: 5/5The PR appears safe to merge with no actionable correctness or security issues identified. The new sort expressions reference the same generated values and database types previously projected by the metadata view, while normal startup and test paths ensure the underlying migration is applied. Important Files Changed
Reviews (1): Last reviewed commit: "perf(roms): sort metadata fields on the ..." | Re-trigger Greptile |
gantoine
requested changes
Aug 4, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gantoine
approved these changes
Aug 4, 2026
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.
Description
Fixes #4067
Sorting a gallery by
first_release_datetook 20.3s against 0.27s for thedefault sort on an 83k-game library. The issue guessed the cause was a missing
index, but the indexes were already there (
idx_roms_generated_first_release_dateand friends, created by migration
0098). The real cause is where the sort keysits.
roms_metadatais not a table. It is a thin view over the STORED generatedcolumns on
roms. Resolving one of its columns as the sort key did:That joins
romsback to itself and leaves the sort key on the joined table.MariaDB/MySQL can only use an index for
ORDER BYwhen the sort column belongsto the first table in the join order, and the right side of a
LEFT JOINnever can be. So the planner fell back to a full scan of
romsplus a filesortof the entire library, on every page, to return 72 rows.
The three view columns that are actually offered as sorts now resolve straight
to the indexed
romscolumn and the join disappears. Measured on a 120k-rowcopy of the real
romsschema (169 MB), withRom.metadatum'slazy="joined"eager load present in both shapes so the comparison is honest:
first_release_dateASC, LIMIT 72average_ratingDESC, LIMIT 72EXPLAINbefore:type: ALL, 113,534 rows,Using temporary; Using filesort.After:
type: index, keyidx_roms_generated_first_release_date, 72 rows, nofilesort — the same plan shape as the default
name_sort_keysort.No migration, no new index, no schema change: the columns and their indexes
already existed, they just weren't being read.
Scope note: the remaining
roms_metadatacolumns (genres,franchises,collections,companies,game_modes,age_ratings) are JSON arrays with noindex to sort on, and nothing in the UI offers them as a sort. Since
/api/romshas no
order_byallowlist and accepts any string, the view-join fallback isdeliberately kept so those keep behaving exactly as they do today.
Files changed
backend/handler/database/roms_handler.pyROM_METADATA_ORDER_COLUMNSmap, plus a branch inget_roms_query()that resolves those three keys to the indexedromscolumn instead of joining the view.backend/models/rom.pygenerated_first_release_date,generated_average_ratingandgenerated_player_countonRomas read-only (FetchedValue()), so the sort has an attribute to reference.backend/tests/handler/database/test_roms_metadata_sort.pyTesting notes
trunk fmt && trunk checkclean.ordering (
first_release_date,average_rating,player_count, eachdirection).
romstable, since a normaldev library is too small to show the difference.
What a reviewer should look at
generated_*are STORED generated columns owned bythe engine, mapped with
server_default/server_onupdate=FetchedValue()soSQLAlchemy never tries to write them. Worth confirming that reasoning holds:
add_romgoes throughsession.merge(),update_romuses a Coreupdate()with caller-supplied keys, and
_nullable_columns()is only ever called withRomFile/TrackMeta, so nothing enumeratesRom's columns to build a write.endpoints/responses/rom.pydeclare their fields explicitly, so the new attributes don't leak into
OpenAPI and the frontend types are unchanged.
env.pyalready excludes anygenerated_*column fromautogenerate, so adding them to the model shouldn't produce a spurious
revision. I could not run
alembic revision --autogenerateto prove this —it currently fails on
masterfor an unrelated reason(
NoReferencedTableErroronsaves.origin_device_id).player_countordering is lexicographic (VARCHAR(100), so"10" < "2").That is unchanged: the view projects the same
VARCHARcolumn, so this PRpreserves the existing behavior rather than introducing it.
Out of scope, but found while in here: the
RomUserbranch of the samefunction applies
query.filter(RomUser.user_id == user_id)on top of an outerjoin whose
ONclause already carries that condition, which collapses it to aneffective inner join. Sorting by
last_playedtherefore silently drops every ROMwith no
rom_userrow — 12,000 rows returned instead of 120,000 in my test data.It is fast only because it discards 90% of the library. Left alone to keep this
PR focused; happy to open a separate issue.
Checklist
AI assistance disclosure
Per
CONTRIBUTING.md: this change was written with AI assistance (Claude Code).The AI performed the root-cause investigation, wrote the tests first, implemented
the fix, and ran the benchmarks and the full test suite. I reviewed the diff, the
benchmark methodology and the test coverage before opening this PR.