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
-112
View File
@@ -185,118 +185,6 @@
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 50,
"endColumn": 56,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 50,
"endColumn": 56,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 50,
"endColumn": 56,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 50,
"endColumn": 56,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 50,
"endColumn": 56,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 50,
"endColumn": 56,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 50,
"endColumn": 56,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 50,
"endColumn": 56,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 50,
"endColumn": 56,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 50,
"endColumn": 56,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 50,
"endColumn": 56,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 50,
"endColumn": 56,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 50,
"endColumn": 56,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 50,
"endColumn": 56,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
@@ -0,0 +1,40 @@
"""videos: a random key per video, so a seeded shuffle walks an index instead of sorting the scope
Revision ID: 0118_videos_shuffle_key
Revises: 0117_videos_length_sort_indexes
Create Date: 2026-11-07
The feed's Shuffle sort and the Home surprise ordered the whole feed scope by md5(id || seed) on every
call: ~350 ms on prod for a scope of ~213k videos, and a Shuffle page deeper in the list cost more
(767 ms on page 7 on localdev, against 4 ms for Newest). Each video now carries a random number drawn
once (`Video.shuffle_key`); a seed picks a point on that ring and the draw reads the index from there
(`feedquery.shuffle_walk`).
One ADD COLUMN with a volatile default rewrites the table and fills every row with its own value. On
localdev (278k videos) that took 28 s, against 84 s for an UPDATE of a nullable column, so the rewrite
it is: the table is locked for that time, and a deploy has the app down while it migrates anyway.
The index is (shuffle_key, id): the walk's keyset cursor compares the pair.
"""
from alembic import op
revision: str = "0118_videos_shuffle_key"
down_revision: str | None = "0117_videos_length_sort_indexes"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.execute(
"ALTER TABLE videos ADD COLUMN IF NOT EXISTS shuffle_key double precision "
"NOT NULL DEFAULT random()"
)
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_shuffle_key")
op.execute("CREATE INDEX CONCURRENTLY ix_videos_shuffle_key ON videos (shuffle_key, id)")
def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_shuffle_key")
op.execute("ALTER TABLE videos DROP COLUMN IF EXISTS shuffle_key")
+4
View File
@@ -10,6 +10,7 @@ from sqlalchemy import (
Boolean,
Computed,
DateTime,
Float,
ForeignKey,
Integer,
JSON,
@@ -217,6 +218,9 @@ class Video(Base, TimestampMixin):
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
unavailable_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
# A random number in [0, 1), drawn once when the row is written: the ring a seeded shuffle walks
# (`feedquery.shuffle_walk`, index ix_videos_shuffle_key on (shuffle_key, id), migration 0118).
shuffle_key: Mapped[float] = mapped_column(Float, nullable=False, server_default=func.random())
@hybrid_property
def length_seconds(self) -> int | None:
+45 -67
View File
@@ -10,29 +10,22 @@ explore-visible and in-progress for free, and cannot drift from them. The Plex h
narrows EVERY rail by the Plex sidebar's `LibraryFilters`, which is right for a surface that HAS a
sidebar and wrong for a landing page that does not.
**One seed drives both halves.** The feed orders a shuffle by `md5(id || seed)` so a keyset cursor
stays valid across pages; the Plex surprise rail uses `ORDER BY random()`, which is fine for a single
un-paged draw but cannot be reproduced. A cross-source reroll needs both halves to move together —
otherwise "Mást kérek" re-draws the Plex picks while the YouTube ones stand still — so the Plex side
here takes the feed's seeded-md5 idiom instead of `random()`.
**One seed drives both halves.** The YouTube half starts on the ring of stored random keys at the
seed's point and shuffles that window by `md5(id || seed)`; the Plex surprise rail uses
`ORDER BY random()`, which is fine for a single un-paged draw but cannot be reproduced. A
cross-source reroll needs both halves to move together — otherwise "Mást kérek" re-draws the Plex
picks while the YouTube ones stand still — so the Plex side here orders by the same seeded md5
instead of `random()`.
"""
import json
from datetime import datetime, timezone
from sqlalchemy import (
Float,
Select,
and_,
cast,
func,
literal,
literal_column,
or_,
select,
union_all,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Session, aliased
from app import draw
@@ -43,6 +36,7 @@ from app.feedquery import (
not_blocked_cond,
order_by,
serialize,
shuffle_walk,
sort_keys,
)
from app.models import User
@@ -128,66 +122,50 @@ def yt_surprise(db: Session, user: User, budget: str, seed: int, limit: int) ->
Shorts are opened up only for the shortest band: "I have ten minutes" is the one answer a Short is
actually a good response to, and the feed's own default keeps them out otherwise."""
lo, hi = band(budget)
params = dict(_FEED_BASE, min_duration=lo, max_duration=hi, include_shorts=budget == "short")
query, rank_expr = filtered_query(db, user, **params)
keys = sort_keys("shuffle", seed, rank_expr)
# Over-fetch, then drop anything already started. The feed's `unwatched` scope keeps watched and
# hidden out but deliberately keeps in-progress rows in (they belong in the feed). Here they must
# go: the resume tier has already had its chance at them, and it may have passed precisely because
# the REMAINING time did not fit the budget — offering the same item back as a surprise, whose
# runtime does fit, would label a half-finished thing "Meglepetés" and contradict the question.
take = limit * 4
def unstarted(rows) -> list[dict]:
return [r for r in map(serialize, rows) if not r["position_seconds"]]
params: dict = {
**_FEED_BASE,
"min_duration": lo,
"max_duration": hi,
"include_shorts": budget == "short",
}
query, _rank = filtered_query(db, user, **params)
# A window of the ring, not the whole scope: the seed's SURPRISE_WINDOW videos read along the
# stored random key (`shuffle_walk`, a millisecond), then shuffled by the seed. Dealing straight
# from the ring would favour for good every video that sits after a wide gap of it (the first
# video past a random point is the one whose gap the point fell into); shuffling the window makes
# each video's chance the window's share, even to within a few percent. A scope no larger than
# the window is the whole scope, in exactly the md5 order the draw always had.
#
# Anything already started is dropped. The feed's `unwatched` scope keeps watched and hidden out
# but deliberately keeps in-progress rows in (they belong in the feed). Here they must go: the
# resume tier has already had its chance at them, and it may have passed precisely because the
# REMAINING time did not fit the budget — offering the same item back as a surprise, whose runtime
# does fit, would label a half-finished thing "Meglepetés" and contradict the question.
window = [
r
for r in map(serialize, shuffle_walk(db, query, seed, SURPRISE_WINDOW))
if not r["position_seconds"]
]
blind = draw.shuffled(window, seed, key=_video_id)
channels = _channel_affinity(db, user)
if not channels:
return unstarted(db.execute(query.order_by(*order_by(keys)).limit(take)).all())[:limit]
return blind[:limit]
# Both orders from ONE pass over the scope. The pool is the whole feed scope (~200k videos on a
# prod-sized catalog, ~390 ms a pass), so a second ordered query for the weighted draw doubled
# the cost; a materialized pool of (id, shuffle key, weighted key) is read twice instead.
weight = literal_column("1.0") + CHANNEL_WEIGHT * func.coalesce(
cast(cast(literal(json.dumps(channels)), JSONB).op("->>")(Video.channel_id), Float), 0.0
)
shuffle = func.md5(func.concat(Video.id, str(seed)))
pool = (
query.with_only_columns(
Video.id.label("vid"),
shuffle.label("plain"),
draw.weighted_key(draw.seeded_u(Video.id, seed), weight).label("leaning"),
)
.cte("surprise_pool")
.prefix_with("MATERIALIZED")
)
def weight(row: dict) -> float:
return 1.0 + CHANNEL_WEIGHT * channels.get(row["channel_id"], 0.0)
def top(side: int, *order) -> Select:
part = (
select(pool.c.vid, pool.c.plain, pool.c.leaning, literal(side).label("side"))
.order_by(*order, pool.c.vid)
.limit(take)
.subquery()
)
return select(part)
leaning = draw.leaning(window, seed, key=_video_id, weight=weight)
return draw.blend(leaning, blind, seed, limit, key=_video_id)
# One statement, so the pool is built once; each side's order is restored from its keys.
got = db.execute(union_all(top(0, pool.c.plain), top(1, pool.c.leaning, pool.c.plain))).all()
plain_ids = [
r.vid for r in sorted((r for r in got if r.side == 0), key=lambda r: (r.plain, r.vid))
]
leaning_ids = [
r.vid
for r in sorted((r for r in got if r.side == 1), key=lambda r: (r.leaning, r.plain, r.vid))
]
rows = {
r["id"]: r
for r in unstarted(db.execute(query.where(Video.id.in_({*plain_ids, *leaning_ids}))).all())
}
blind = [rows[i] for i in plain_ids if i in rows]
leaning = [rows[i] for i in leaning_ids if i in rows]
return draw.blend(leaning, blind, seed, limit, key=lambda r: r["id"])
# How many videos of the ring a surprise draws from: wide enough that a channel the user watches has
# a few in it, small enough to read in a millisecond or two.
SURPRISE_WINDOW = 500
def _video_id(row: dict) -> str:
return row["id"]
# How much a channel the user watches most outweighs one they never watch (1 + this), and how far back
+26
View File
@@ -11,8 +11,13 @@ often. Two things keep it a surprise rather than a ranking:
seeded (the Plex Discover rail) takes u from random().
- **One slot of three is blind** (`blend`): dealt from the unweighted order, so the same few
favourites cannot fill the rail.
The YouTube surprise draws over a window of the feed scope rather than all of it (`yt_surprise`):
the window is read in SQL, and `shuffled` / `leaning` order it here with the same u.
"""
import hashlib
import math
from collections.abc import Callable, Hashable
from typing import Any
@@ -44,6 +49,27 @@ def random_u() -> ColumnElement[float]:
return func.random()
def u_of(key: str, seed: int) -> float:
"""`seeded_u` in Python: the same u for the same key and seed."""
return int(hashlib.md5(f"{key}{seed}".encode()).hexdigest()[:8], 16) / _SPAN
def shuffled(items: list, seed: int, key: Callable[[Any], str]) -> list:
"""`items` in the seed's md5 order — the order `ORDER BY md5(key || seed)` gives."""
return sorted(items, key=lambda it: hashlib.md5(f"{key(it)}{seed}".encode()).hexdigest())
def leaning(
items: list, seed: int, key: Callable[[Any], str], weight: Callable[[Any], float]
) -> list:
"""`items` in the weighted draw's order (`weighted_key` in Python), ties in the md5 order."""
order = {key(it): i for i, it in enumerate(shuffled(items, seed, key))}
return sorted(
items,
key=lambda it: (-math.log(1.0 - u_of(key(it), seed)) / weight(it), order[key(it)]),
)
def blend(
weighted: list, blind: list, offset: int, n: int, key: Callable[[Any], Hashable] = lambda x: x
) -> list:
+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:
+151
View File
@@ -0,0 +1,151 @@
"""A seeded shuffle walks a stored random key instead of sorting the whole scope. DB lane.
Every video carries a random number drawn once (`Video.shuffle_key`). A seed picks a point on that
ring; the feed's Shuffle sort pages along the ring from there and wraps, and the Home surprise reads
a window of it and shuffles that window by the seed (`discover.queries.yt_surprise`). Ordering the
scope by md5(id || seed) cost ~350 ms per call on prod and grew with every Shuffle page.
"""
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from sqlalchemy import event
from app import feedquery
from app.catalog.models import Channel, Subscription, Video
from app.discover import queries
from app.discover.queries import _FEED_BASE
NOW = datetime.now(timezone.utc)
KEYS = {"k05": 0.05, "k20": 0.20, "k40": 0.40, "k60": 0.60, "k80": 0.80, "k95": 0.95}
def _seed(db, make_user, keys=KEYS):
user = make_user()
db.add(Channel(id="UCring", title="Ring"))
db.flush()
db.add(Subscription(user_id=user.id, channel_id="UCring"))
db.add_all(
[
Video(
id=vid,
channel_id="UCring",
title=vid,
duration_seconds=20 * 60,
published_at=NOW - timedelta(days=i + 1),
shuffle_key=key,
)
for i, (vid, key) in enumerate(keys.items())
]
)
db.commit()
return user
def _ring(seed, keys=KEYS):
"""The ring order from the seed's point: the keys at or after it ascending, then the rest."""
r = feedquery.shuffle_start(seed)
return sorted(keys, key=lambda v: (keys[v] < r, keys[v]))
def _seed_landing_between(lo, hi):
return next(s for s in range(1000) if lo < feedquery.shuffle_start(s) < hi)
def test_the_walk_starts_at_the_seeds_point_and_wraps(db, make_user):
user = _seed(db, make_user)
query, _ = feedquery.filtered_query(db, user, **_FEED_BASE)
seed = _seed_landing_between(0.45, 0.55) # starts at k60, wraps to k05 after k95
got = [r.id for r in feedquery.shuffle_walk(db, query, seed, 6)]
assert got == ["k60", "k80", "k95", "k05", "k20", "k40"]
assert [r.id for r in feedquery.shuffle_walk(db, query, seed, 4)] == got[:4]
def test_the_feed_shuffle_pages_along_the_ring(db, make_user, as_user):
user = _seed(db, make_user)
client = as_user(user)
for seed in (_seed_landing_between(0.1, 0.15), _seed_landing_between(0.85, 0.9)):
walked, cursor = [], None
for _ in range(5):
url = f"/api/feed?sort=shuffle&seed={seed}&limit=2" + (
f"&cursor={cursor}" if cursor else ""
)
body = client.get(url).json()
walked += [it["id"] for it in body["items"]]
cursor = body["next_cursor"]
if not body["has_more"]:
break
assert walked == _ring(seed), seed
def test_a_new_seed_opens_the_shuffle_elsewhere(db, make_user, as_user):
user = _seed(db, make_user)
client = as_user(user)
first = {
seed: client.get(f"/api/feed?sort=shuffle&seed={seed}&limit=2").json()["items"][0]["id"]
for seed in (_seed_landing_between(0.1, 0.15), _seed_landing_between(0.65, 0.7))
}
assert sorted(first.values()) == ["k20", "k80"]
@contextmanager
def _statements(db):
seen: list[str] = []
def grab(conn, cursor, statement, *args):
seen.append(statement)
engine = db.get_bind()
event.listen(engine, "before_cursor_execute", grab)
try:
yield seen
finally:
event.remove(engine, "before_cursor_execute", grab)
def _draws_of_videos(seen):
return [s for s in seen if "FROM videos" in s and "LIMIT" in s]
def test_neither_draw_orders_the_scope_by_a_hash(db, make_user):
# Each draw reads the videos as an ascending range of the key (what ix_videos_shuffle_key serves),
# never ordered by a hash or by the ring's CASE (which no index can serve).
user = _seed(db, make_user)
with _statements(db) as surprise:
queries.yt_surprise(db, user, "any", 7, 3)
with _statements(db) as page:
feedquery.feed_page(
db, user, dict(_FEED_BASE), sort="shuffle", seed=7, limit=2, cursor=None, lang="en"
)
for seen in (surprise, page):
draws = _draws_of_videos(seen)
assert draws
for s in draws:
assert "md5(" not in s.lower()
assert "ORDER BY videos.shuffle_key, videos.id" in s.replace("\n", " "), s
def test_the_surprise_reads_a_window_of_the_ring(db, make_user, monkeypatch):
user = _seed(db, make_user)
monkeypatch.setattr(queries, "SURPRISE_WINDOW", 3)
for seed in range(20):
drawn = {r["id"] for r in queries.yt_surprise(db, user, "any", seed, 3)}
assert drawn == set(_ring(seed)[:3]), seed
def test_a_wide_gap_on_the_ring_does_not_make_a_favourite(db, make_user):
# One video after an empty half of the ring, twenty packed into the other half. Taking the first
# video after the seed's point would deal the lonely one about half the time; the window is
# shuffled by the seed, so it leads about as often as any other.
keys = {"lonely": 0.5} | {f"p{i:02}": 0.51 + i * 0.02 for i in range(20)}
user = _seed(db, make_user, keys)
leads = [queries.yt_surprise(db, user, "any", s, 1)[0]["id"] for s in range(90)]
assert leads.count("lonely") < 15, leads.count("lonely")
def test_a_new_video_gets_its_own_key(db, make_user):
_seed(db, make_user, {})
db.add_all([Video(id=f"n{i}", channel_id="UCring", title="n") for i in range(3)])
db.commit()
keys = [db.get(Video, f"n{i}").shuffle_key for i in range(3)]
assert len(set(keys)) == 3 and all(0 <= k < 1 for k in keys)