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
+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: