perf(feed): a shuffle walks a stored random key instead of sorting the whole scope

Every video carries a random number drawn once (videos.shuffle_key, migration 0118, indexed with
id). A seed picks a point on that ring: the feed's Shuffle sort pages along it and wraps, and the
Home surprise reads a 500-video window of it and shuffles that window by the seed, so no video is
favoured for sitting after a wide gap. The md5(id || seed) order read and sorted the whole feed
scope on every call: ~350 ms on prod per surprise, 381-767 ms per Shuffle page on localdev, now
14 ms and 4 ms.
This commit is contained in:
2026-11-07 04:00:00 +01:00
parent 9310ff85bf
commit 25aed6635f
7 changed files with 328 additions and 185 deletions
+62 -6
View File
@@ -13,7 +13,19 @@ import binascii
import json
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import BigInteger, Select, and_, case, cast, false, func, or_, select
from sqlalchemy import (
BigInteger,
Select,
and_,
case,
cast,
false,
func,
literal,
or_,
select,
tuple_,
)
from sqlalchemy.orm import Session, aliased
from app.collation import collated
@@ -536,15 +548,17 @@ def sort_keys(sort: str, seed: int, rank_expr=None, lang: str | None = None) ->
},
pub,
],
# Deterministic per-seed shuffle: the same seed yields the same total order, so
# the cursor stays valid across pages of one shuffled run.
# The ring of stored random keys from the seed's point (`shuffle_walk`): the keys at or after
# it, then the ones before. The same seed yields the same total order, so the cursor stays
# valid across pages; `feed_page` reads it as two index ranges, never as this sort.
"shuffle": [
{
"expr": func.md5(func.concat(Video.id, str(seed))),
"expr": case((Video.shuffle_key < shuffle_start(seed), 1), else_=0),
"asc": True,
"nullable": False,
"kind": "str",
}
"kind": "num",
},
{"expr": Video.shuffle_key, "asc": True, "nullable": False, "kind": "num"},
],
}
# Relevance (search-result ranking) only exists when there's a query to rank against;
@@ -700,6 +714,40 @@ class BadCursor(ValueError):
"""A `cursor` this module did not write (or one from another sort): the caller's bad input."""
# The golden ratio's fraction: consecutive seeds land far apart on the ring, never next to each other.
_GOLDEN = 0.6180339887498949
def shuffle_start(seed: int) -> float:
"""The point in [0, 1) where a seed's shuffle starts on the ring of `Video.shuffle_key`."""
return (seed * _GOLDEN) % 1.0
def shuffle_walk(
db: Session, query: Select, seed: int, n: int, after: tuple[int, float, str] | None = None
) -> list:
"""Up to `n` rows of `query` in the seed's ring order: shuffle_key from the seed's point upwards,
then from 0 up to that point. `after` is a cursor's (phase, key, id): the walk resumes past it.
Each arc is an ascending range read along ix_videos_shuffle_key with the scope's filters checked
row by row, so a draw costs the rows it reads, not a sort of the scope — the md5(id || seed) order
it replaced read and sorted every row the filters let through (~350 ms on prod for ~213k)."""
start = shuffle_start(seed)
phase_from, key, last_id = after if after is not None else (0, None, None)
rows: list = []
for phase in (0, 1):
if phase < phase_from or len(rows) >= n:
continue
arc = Video.shuffle_key >= start if phase == 0 else Video.shuffle_key < start
q = query.where(arc)
if key is not None and phase == phase_from:
q = q.where(
tuple_(Video.shuffle_key, Video.id) > tuple_(literal(key), literal(last_id))
)
rows += db.execute(q.order_by(Video.shuffle_key, Video.id).limit(n - len(rows))).all()
return rows
def feed_page(
db: Session,
user: User,
@@ -734,6 +782,14 @@ def feed_page(
k["value"] = v
if ranked and window is not None:
rows = windowed_relevance_rows(db, query, keys, window, cursor_id, limit + 1)
elif sort == "shuffle":
after = None
if vals is not None and cursor_id is not None:
try:
after = (int(vals[0]), float(vals[1]), str(cursor_id))
except (TypeError, ValueError) as e:
raise BadCursor("Invalid feed cursor") from e
rows = shuffle_walk(db, query, seed, limit + 1, after)
else:
query = query.order_by(*order_by(keys))
if cursor_id is not None: