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:
@@ -185,118 +185,6 @@
|
|||||||
"lineCount": 1
|
"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",
|
"code": "reportArgumentType",
|
||||||
"range": {
|
"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")
|
||||||
@@ -10,6 +10,7 @@ from sqlalchemy import (
|
|||||||
Boolean,
|
Boolean,
|
||||||
Computed,
|
Computed,
|
||||||
DateTime,
|
DateTime,
|
||||||
|
Float,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
JSON,
|
JSON,
|
||||||
@@ -217,6 +218,9 @@ class Video(Base, TimestampMixin):
|
|||||||
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
unavailable_since: 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))
|
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
|
@hybrid_property
|
||||||
def length_seconds(self) -> int | None:
|
def length_seconds(self) -> int | None:
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
**One seed drives both halves.** The YouTube half starts on the ring of stored random keys at the
|
||||||
stays valid across pages; the Plex surprise rail uses `ORDER BY random()`, which is fine for a single
|
seed's point and shuffles that window by `md5(id || seed)`; the Plex surprise rail uses
|
||||||
un-paged draw but cannot be reproduced. A cross-source reroll needs both halves to move together —
|
`ORDER BY random()`, which is fine for a single un-paged draw but cannot be reproduced. A
|
||||||
otherwise "Mást kérek" re-draws the Plex picks while the YouTube ones stand still — so the Plex side
|
cross-source reroll needs both halves to move together — otherwise "Mást kérek" re-draws the Plex
|
||||||
here takes the feed's seeded-md5 idiom instead of `random()`.
|
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 datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Float,
|
|
||||||
Select,
|
|
||||||
and_,
|
and_,
|
||||||
cast,
|
|
||||||
func,
|
func,
|
||||||
literal,
|
|
||||||
literal_column,
|
|
||||||
or_,
|
or_,
|
||||||
select,
|
select,
|
||||||
union_all,
|
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app import draw
|
from app import draw
|
||||||
@@ -43,6 +36,7 @@ from app.feedquery import (
|
|||||||
not_blocked_cond,
|
not_blocked_cond,
|
||||||
order_by,
|
order_by,
|
||||||
serialize,
|
serialize,
|
||||||
|
shuffle_walk,
|
||||||
sort_keys,
|
sort_keys,
|
||||||
)
|
)
|
||||||
from app.models import User
|
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
|
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."""
|
actually a good response to, and the feed's own default keeps them out otherwise."""
|
||||||
lo, hi = band(budget)
|
lo, hi = band(budget)
|
||||||
params = dict(_FEED_BASE, min_duration=lo, max_duration=hi, include_shorts=budget == "short")
|
params: dict = {
|
||||||
query, rank_expr = filtered_query(db, user, **params)
|
**_FEED_BASE,
|
||||||
keys = sort_keys("shuffle", seed, rank_expr)
|
"min_duration": lo,
|
||||||
|
"max_duration": hi,
|
||||||
# Over-fetch, then drop anything already started. The feed's `unwatched` scope keeps watched and
|
"include_shorts": budget == "short",
|
||||||
# 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
|
query, _rank = filtered_query(db, user, **params)
|
||||||
# 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"]]
|
|
||||||
|
|
||||||
|
# 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)
|
channels = _channel_affinity(db, user)
|
||||||
if not channels:
|
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
|
def weight(row: dict) -> float:
|
||||||
# prod-sized catalog, ~390 ms a pass), so a second ordered query for the weighted draw doubled
|
return 1.0 + CHANNEL_WEIGHT * channels.get(row["channel_id"], 0.0)
|
||||||
# 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 top(side: int, *order) -> Select:
|
leaning = draw.leaning(window, seed, key=_video_id, weight=weight)
|
||||||
part = (
|
return draw.blend(leaning, blind, seed, limit, key=_video_id)
|
||||||
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)
|
|
||||||
|
|
||||||
# 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()
|
# How many videos of the ring a surprise draws from: wide enough that a channel the user watches has
|
||||||
plain_ids = [
|
# a few in it, small enough to read in a millisecond or two.
|
||||||
r.vid for r in sorted((r for r in got if r.side == 0), key=lambda r: (r.plain, r.vid))
|
SURPRISE_WINDOW = 500
|
||||||
]
|
|
||||||
leaning_ids = [
|
|
||||||
r.vid
|
def _video_id(row: dict) -> str:
|
||||||
for r in sorted((r for r in got if r.side == 1), key=lambda r: (r.leaning, r.plain, r.vid))
|
return row["id"]
|
||||||
]
|
|
||||||
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 much a channel the user watches most outweighs one they never watch (1 + this), and how far back
|
# How much a channel the user watches most outweighs one they never watch (1 + this), and how far back
|
||||||
|
|||||||
@@ -11,8 +11,13 @@ often. Two things keep it a surprise rather than a ranking:
|
|||||||
seeded (the Plex Discover rail) takes u from random().
|
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
|
- **One slot of three is blind** (`blend`): dealt from the unweighted order, so the same few
|
||||||
favourites cannot fill the rail.
|
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 collections.abc import Callable, Hashable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -44,6 +49,27 @@ def random_u() -> ColumnElement[float]:
|
|||||||
return func.random()
|
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(
|
def blend(
|
||||||
weighted: list, blind: list, offset: int, n: int, key: Callable[[Any], Hashable] = lambda x: x
|
weighted: list, blind: list, offset: int, n: int, key: Callable[[Any], Hashable] = lambda x: x
|
||||||
) -> list:
|
) -> list:
|
||||||
|
|||||||
@@ -13,7 +13,19 @@ import binascii
|
|||||||
import json
|
import json
|
||||||
from datetime import date, datetime, timedelta, timezone
|
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 sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app.collation import collated
|
from app.collation import collated
|
||||||
@@ -536,15 +548,17 @@ def sort_keys(sort: str, seed: int, rank_expr=None, lang: str | None = None) ->
|
|||||||
},
|
},
|
||||||
pub,
|
pub,
|
||||||
],
|
],
|
||||||
# Deterministic per-seed shuffle: the same seed yields the same total order, so
|
# The ring of stored random keys from the seed's point (`shuffle_walk`): the keys at or after
|
||||||
# the cursor stays valid across pages of one shuffled run.
|
# 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": [
|
"shuffle": [
|
||||||
{
|
{
|
||||||
"expr": func.md5(func.concat(Video.id, str(seed))),
|
"expr": case((Video.shuffle_key < shuffle_start(seed), 1), else_=0),
|
||||||
"asc": True,
|
"asc": True,
|
||||||
"nullable": False,
|
"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;
|
# 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."""
|
"""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(
|
def feed_page(
|
||||||
db: Session,
|
db: Session,
|
||||||
user: User,
|
user: User,
|
||||||
@@ -734,6 +782,14 @@ def feed_page(
|
|||||||
k["value"] = v
|
k["value"] = v
|
||||||
if ranked and window is not None:
|
if ranked and window is not None:
|
||||||
rows = windowed_relevance_rows(db, query, keys, window, cursor_id, limit + 1)
|
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:
|
else:
|
||||||
query = query.order_by(*order_by(keys))
|
query = query.order_by(*order_by(keys))
|
||||||
if cursor_id is not None:
|
if cursor_id is not None:
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user