|
2 | 2 | import hashlib |
3 | 3 | import json |
4 | 4 | import re |
| 5 | +import secrets |
5 | 6 | from collections.abc import Iterable, Sequence |
6 | 7 | from datetime import datetime |
7 | 8 | from typing import Any, NamedTuple |
|
140 | 141 | # stored lowercase, which keeps the lookup an indexed equality. |
141 | 142 | HEX_DIGEST_REGEX = re.compile(r"[0-9a-fA-F]+") |
142 | 143 |
|
| 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 | + |
143 | 149 | # CRC32 (8), MD5 and RetroAchievements (32), SHA-1 (40). |
144 | 150 | ROM_HASH_COLUMNS_BY_DIGEST_LENGTH: dict[int, tuple[QueryableAttribute, ...]] = { |
145 | 151 | 8: (Rom.crc_hash,), |
@@ -1642,6 +1648,51 @@ def get_rom_count( |
1642 | 1648 | or 0 |
1643 | 1649 | ) |
1644 | 1650 |
|
| 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 | + |
1645 | 1696 | @begin_session |
1646 | 1697 | def get_roms_by_fs_name( |
1647 | 1698 | self, |
|
0 commit comments