Skip to content

Commit d874cc1

Browse files
authored
Merge pull request #4071 from Spinnich/fix/random-pick-constant-time
perf(roms): pick a random rom without paging to a random offset
2 parents 042a362 + b6d43ce commit d874cc1

7 files changed

Lines changed: 582 additions & 33 deletions

File tree

backend/endpoints/roms/__init__.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -898,6 +898,69 @@ def get_rom_identifiers(
898898
return [r.id for r in db_roms]
899899

900900

901+
@protected_route(router.get, "/random", [Scope.ROMS_READ])
902+
def get_random_rom(
903+
request: Request,
904+
platform_ids: Annotated[
905+
list[int] | None,
906+
Query(
907+
description=(
908+
"Platform internal ids. Multiple values are allowed by repeating the"
909+
" parameter, and the pick will match any of the values."
910+
),
911+
),
912+
] = None,
913+
collection_id: Annotated[
914+
int | None,
915+
Query(description="Collection internal id.", ge=1),
916+
] = None,
917+
virtual_collection_id: Annotated[
918+
str | None,
919+
Query(description="Virtual collection internal id."),
920+
] = None,
921+
smart_collection_id: Annotated[
922+
int | None,
923+
Query(description="Smart collection internal id.", ge=1),
924+
] = None,
925+
) -> SimpleRomSchema | None:
926+
"""Retrieve one rom picked at random, or null when the scope holds none.
927+
928+
Sampled on the primary key instead of paged to, so the pick doesn't get
929+
slower as the library grows.
930+
"""
931+
perms = get_permissions(request)
932+
933+
base_query, _ = db_rom_handler.get_roms_query(user_id=request.user.id)
934+
query = db_rom_handler.filter_roms(
935+
query=base_query,
936+
user_id=request.user.id,
937+
hidden_platform_ids=perms.hidden_platform_ids, # type: ignore
938+
hidden_rom_ids=perms.hidden_rom_ids, # type: ignore
939+
platform_ids=platform_ids,
940+
collection_id=collection_id,
941+
virtual_collection_id=virtual_collection_id,
942+
smart_collection_id=smart_collection_id,
943+
include_related=False,
944+
)
945+
946+
rom_id = db_rom_handler.get_random_rom_id(query=query)
947+
if rom_id is None:
948+
return None
949+
950+
rom = db_rom_handler.get_rom_simple(rom_id)
951+
if not rom:
952+
return None
953+
954+
# The fetch is by raw id, so it re-checks the row it actually loaded rather
955+
# than trusting the filter that chose the id: a rom that moved to a hidden
956+
# platform in between was picked under its old one. Reads no database, and
957+
# null keeps a hidden rom indistinguishable from an empty scope.
958+
if not perms.can_see_rom(rom.id, rom.platform_id):
959+
return None
960+
961+
return SimpleRomSchema.from_orm_with_request(rom, request)
962+
963+
901964
@protected_route(
902965
router.get,
903966
"/download",

backend/handler/database/roms_handler.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import hashlib
33
import json
44
import re
5+
import secrets
56
from collections.abc import Iterable, Sequence
67
from datetime import datetime
78
from typing import Any, NamedTuple
@@ -140,6 +141,11 @@
140141
# stored lowercase, which keeps the lookup an indexed equality.
141142
HEX_DIGEST_REGEX = re.compile(r"[0-9a-fA-F]+")
142143

144+
# Primary keys offered per round trip when picking a random rom. Sixteen
145+
# lands a hit ~99% of the time on a library occupying a quarter of its id
146+
# range, which is what deletions leave behind on a long-lived instance.
147+
RANDOM_ID_SAMPLE_SIZE = 16
148+
143149
# CRC32 (8), MD5 and RetroAchievements (32), SHA-1 (40).
144150
ROM_HASH_COLUMNS_BY_DIGEST_LENGTH: dict[int, tuple[QueryableAttribute, ...]] = {
145151
8: (Rom.crc_hash,),
@@ -1642,6 +1648,51 @@ def get_rom_count(
16421648
or 0
16431649
)
16441650

1651+
@begin_session
1652+
def get_random_rom_id(
1653+
self,
1654+
query: Query,
1655+
*,
1656+
session: Session = None, # type: ignore
1657+
) -> int | None:
1658+
"""Pick one rom id at random, uniformly, from a filtered query.
1659+
1660+
Two mechanisms, both uniform. First a batch of random primary keys is
1661+
offered to the query: every id in the table is equally likely to be
1662+
offered, so any hit is an unbiased pick, and the whole batch costs one
1663+
index lookup per candidate no matter how large the library is.
1664+
1665+
A batch misses when the query matches too little of the id space (a
1666+
scoped gallery, an id range left full of gaps by deletions). It then
1667+
falls back to counting the set and taking the row at a random position
1668+
in it. That reads no rows, only index entries, since the statement
1669+
selects nothing but the id.
1670+
"""
1671+
id_query = query.order_by(None).with_only_columns(Rom.id) # type: ignore
1672+
1673+
# Bounds come from the table rather than the filtered set: they only
1674+
# need to cover it, and MIN/MAX over an untouched primary key are two
1675+
# index seeks, where the same pair over a joined and filtered set is a
1676+
# scan of it.
1677+
lowest, highest = session.execute(
1678+
select(func.min(Rom.id), func.max(Rom.id))
1679+
).one()
1680+
if lowest is None or highest is None:
1681+
return None
1682+
1683+
span = highest - lowest + 1
1684+
candidates = {
1685+
lowest + secrets.randbelow(span) for _ in range(RANDOM_ID_SAMPLE_SIZE)
1686+
}
1687+
hits = session.scalars(id_query.where(Rom.id.in_(candidates))).all()
1688+
if hits:
1689+
return secrets.choice(hits)
1690+
1691+
total = self.get_rom_count(query=query, session=session)
1692+
if total == 0:
1693+
return None
1694+
return session.scalar(id_query.limit(1).offset(secrets.randbelow(total)))
1695+
16451696
@begin_session
16461697
def get_roms_by_fs_name(
16471698
self,

0 commit comments

Comments
 (0)