perf(roms): pick a random rom without paging to a random offset - #4071
perf(roms): pick a random rom without paging to a random offset#4071Spinnich wants to merge 2 commits into
Conversation
The Home "Random Pick" widget asked the API for the library total, then for the single row at a random offset. Paging to offset N makes the database walk and discard the N rows before it, so the pick got slower the bigger the library: measured on an 83,000-game instance, 1.7s at the median offset and 6.9s deep into the list, on every home page visit. Adds GET /api/roms/random. It offers the query a batch of random primary keys and takes any that exist, which is one index lookup per candidate and unbiased, since every id in the table is equally likely to be offered. A batch only misses when the query matches too little of the id space, and it then falls back to counting the set and taking the row at a random position, reading index entries rather than rows because the statement selects nothing but the id. Both paths are uniform. On the same 83,000-game library: 2.9ms median for an unscoped pick (0.6% of picks take the fallback, 236ms), 19-25ms scoped to a platform. The widget now resolves a pick in one request. The endpoint takes the platform / collection scope parameters so the gallery "Random ROM" buttons (rommapp#4068) can move onto it too. Fixes rommapp#4066 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Greptile SummaryThe PR replaces the Home widget's count-and-random-offset workflow with a dedicated random-ROM endpoint.
Confidence Score: 4/5The visibility-changing race should be fixed before merging because it can return a ROM after it becomes hidden from the requester. The random selection logic and frontend integration are otherwise coherent, but the endpoint crosses two transactions and returns the second lookup without enforcing the visibility boundary on the object actually serialized. Files Needing Attention: backend/endpoints/roms/init.py
|
| Filename | Overview |
|---|---|
| backend/endpoints/roms/init.py | Adds the protected random-ROM route and scope filtering, but the second lookup does not revalidate visibility. |
| backend/handler/database/roms_handler.py | Adds primary-key batch sampling with a uniform-offset fallback; the checked query transformations preserve one row per ROM. |
| backend/tests/endpoints/roms/test_random.py | Covers authentication, filtering, hidden ROMs, empty scopes, reachability, and both selection paths, but not visibility changes between selection and fetch. |
| frontend/src/services/api/rom.ts | Adds a correctly shaped wrapper for the new nullable random-ROM endpoint and optional scope parameters. |
| frontend/src/v2/components/Home/Widgets/RandomPickWidget.vue | Replaces the two-request offset workflow with one endpoint call while preserving loading, error, and focus behavior. |
| frontend/src/v2/components/Home/Widgets/RandomPickWidget.test.ts | Exercises initial success, empty and error states, and failed rerolls retaining the previous pick. |
| docs/BACKEND_ARCHITECTURE.md | Documents the new protected random-ROM endpoint. |
Prompt To Fix All With AI
### Issue 1
backend/endpoints/roms/__init__.py:949
**Visibility Lost Between Lookups**
If a ROM becomes hidden or moves to a hidden platform between ID selection and `get_rom_simple`, the unfiltered second lookup returns it without revalidating visibility, exposing ROM metadata the requester is no longer permitted to see.
**How this was verified:** The permission-filtered ID selection is followed by a separate raw-ID lookup, while the existing simple-ROM endpoint explicitly validates visibility after fetching.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (1): Last reviewed commit: "perf(roms): pick a random rom without pa..." | Re-trigger Greptile
| rom_id = db_rom_handler.get_random_rom_id(query=query) | ||
| if rom_id is None: | ||
| return None | ||
|
|
There was a problem hiding this comment.
Visibility Lost Between Lookups
If a ROM becomes hidden or moves to a hidden platform between ID selection and get_rom_simple, the unfiltered second lookup returns it without revalidating visibility, exposing ROM metadata the requester is no longer permitted to see.
How this was verified: The permission-filtered ID selection is followed by a separate raw-ID lookup, while the existing simple-ROM endpoint explicitly validates visibility after fetching.
Knowledge Base Used: Backend API Endpoints
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/endpoints/roms/__init__.py
Line: 949
Comment:
**Visibility Lost Between Lookups**
If a ROM becomes hidden or moves to a hidden platform between ID selection and `get_rom_simple`, the unfiltered second lookup returns it without revalidating visibility, exposing ROM metadata the requester is no longer permitted to see.
**How this was verified:** The permission-filtered ID selection is followed by a separate raw-ID lookup, while the existing simple-ROM endpoint explicitly validates visibility after fetching.
**Knowledge Base Used:** [Backend API Endpoints](https://app.greptile.com/romm/-/custom-context/knowledge-base/rommapp/romm/-/docs/backend-api-endpoints.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.The id comes from a permission-filtered query but the row is fetched by raw id, so a rom that moved to a hidden platform in between was picked under its old one and would serialize under its new one. Re-check the loaded row against the caller's permissions, as every other raw-id rom lookup does. Returns null rather than raising 404: it keeps a hidden rom indistinguishable from an empty scope, and keeps an error path out of a contract that otherwise has none.
|
Thanks @greptileai, the second half of this is a real gap. Splitting it because only one of the two mechanisms holds: The permission-change race isn't reachable. The platform-move case is real. The stronger argument for fixing it is consistency rather than the race: every other endpoint that resolves a ROM by raw ID calls Fixed in 7bbf94c. I used Added This reply was drafted by Claude Code, as was the PR itself. |
|
The analysis is correct on all counts. Confirmed from def get_permissions(request: Request) -> ResolvedPermissions:
cached = getattr(request.state, "permissions", None)
if cached is not None:
return cached
perms = resolve_permissions(request.user)
request.state.permissions = perms
return permsBoth the filter query and the post-fetch check resolve through the same cached The test rewrite is the right call. The original passed either way because the query returned empty before the fetch — patching
The commit looks good. |
Description
Fixes #4066.
The v2 Home "Random Pick" widget resolved a pick by paging to it: one request to
learn the library total, a second at
offset: <random>. An offset makes thedatabase walk and discard every preceding row, so the cost grew with the library.
On an 83k-rom instance a pick took 1.7s median and up to 6.9s, and the widget
runs one on every Home mount.
This adds
GET /api/roms/random, which samples on the primary key instead.Two mechanisms, both uniform:
the filtered query in a single
IN (...). Every id in the table is equallylikely to be offered, so any hit is an unbiased pick, and the batch costs one
index lookup per candidate no matter how large the library is. Bounds come
from
MIN/MAXover the untouched primary key (two index seeks) rather thanover the filtered set, which would be a scan of it.
of the id space (a narrow scope, or an id range left sparse by deletions). It
then counts the set and takes the row at a random position. The statement
selects nothing but the id and carries no
ORDER BY, so it reads indexentries rather than rows.
Measured on a real 83,132-rom library (read-only, production data):
Distribution over 200k draws against that same id space: 75,495 of 83,132 roms
drawn (exactly what uniform predicts for that many draws), stdev 1.55 against a
Poisson expectation of 1.55.
The endpoint accepts the platform/collection/virtual/smart scope params, so the
gallery "Random ROM" buttons tracked in #4068 can move onto it without a
signature change. Rewiring those call sites is deliberately left out of this PR.
Files modified
backend/handler/database/roms_handler.py- newget_random_rom_id(query)implementing both mechanisms, plus the
RANDOM_ID_SAMPLE_SIZEconstant. Usessecretsrather thanrandom.backend/endpoints/roms/__init__.py- newGET /randomroute returningSimpleRomSchema | None. Declared before/{id}so the int path param doesn'tswallow it. Scope params and permission filtering mirror
GET /api/roms, andthe picked row is re-checked against the caller's permissions after the fetch
(see reviewer notes).
backend/tests/endpoints/roms/test_random.py- new, 11 tests covering bothmechanisms (see testing notes).
frontend/src/services/api/rom.ts- newgetRandomRom()wrapper.frontend/src/v2/components/Home/Widgets/RandomPickWidget.vue-reroll()nowmakes one request. Drops
pickOnce(), thePICK_QUERYopt-outs and theretry-on-drift branch, all of which existed only to work around the two-call
approach.
frontend/src/v2/components/Home/Widgets/RandomPickWidget.test.ts- new, 4tests.
docs/BACKEND_ARCHITECTURE.md- endpoint table entry.No migration, no schema change, and no regenerated types: the route reuses
SimpleRomSchema.Testing notes
covering auth, an empty library, platform/collection scoping, admin-hidden roms
staying out of the pick, post-fetch visibility, and both code paths reaching
every rom in the set. Two mechanism guards: one asserts
get_rom_countandget_rom_id_indexare never called and that no statement carries anOFFSET;the other patches the sample size to 0 to force the fallback.
on first load, reroll keeping the previous pick on failure). Full vitest run
green.
place without navigating, keyboard focus survives the reroll.
trunk fmt && trunk checkclean.database over a read-only account,
SELECT/EXPLAINonly.What reviewers should look at
procedure treats every existing id identically, so conditional on a non-empty
hit set each is equally likely to be returned. Worth a second opinion.
narrow scope, so [Bug] "Random ROM" button in platform and collection galleries scans the entire library on every click #4068's call sites will pay the count-plus-offset path. That
measured 19-25 ms on large platforms, but it is not the constant-time path and
shouldn't be described as one.
ORDER BY. AddingORDER BY roms.idbacklooks harmless and is not: a PK-ordered scan is the clustered index, so it
drags whole rows off disk. That measured 4.6 s against 0.067 s for the same
query without it. Uniformity doesn't depend on the ordering, only on each row
appearing exactly once.
RANDOM_ID_SAMPLE_SIZE = 16is tuned for an id space roughly a quarteroccupied, which is what deletions leave behind on a long-lived instance
(mine is 27% dense). Happy to change it if that assumption seems wrong.
query but the row is fetched by raw id, so the loaded row is re-tested with
perms.can_see_rom(...), matching what every other raw-id ROM lookup does viaassert_rom_visible. It returnsnullrather than raising 404, to keep ahidden ROM indistinguishable from an empty scope and to keep an error path out
of a contract that otherwise has none.
endpoint returns
nulland the widget briefly shows its empty-library copy.I judged a third round trip not worth avoiding that.
Checklist
AI assistance
This PR was written primarily by Claude Code, including this description and the
replies on the review thread. I directed the work, required that the approach be
measured against my own production database before it was accepted, and reviewed
the result.
Two things it got wrong that the process caught, noted so reviewers can calibrate
how much scrutiny to apply. The first implementation sampled a random point in
the id range and took the nearest row at or above it, which is only uniform on a
dense id space; on my 27%-dense library it skewed 10,840x toward one ROM and
could reach only 2,773 of 83,132 ROMs. It was replaced with the batch-sampling
mechanism above and re-validated the same way. Separately, the first version of
the visibility regression test passed with the fix reverted, because the query
returned empty before control flow ever reached the guard; it was rewritten to
actually exercise it.