perf(api): sortowanie scen po studiu i tytule bez sortowania calego katalogu
Obie listy chodzily na produkcji w dziesiatkach sekund, bo klucz sortu nie mial nic wspolnego z filtrami widocznosci (zywy playback, odsiew wertykalow, odsiew stubow). sort=studio: nazwa studia siedzi w dolaczonej tabeli, wiec zaden indeks scen jej nie zna i planer materializowal caly przefiltrowany katalog (ponad 2 mln wierszy), po czym sortowal go w calosci. Odwracamy zapytanie: idziemy po studiach w kolejnosci nazwy i dla kazdego dobieramy jego sceny przez LATERAL po ix_scenes_studio_effective_date_desc. Zewnetrzny skan jest posortowany, wiec LIMIT urywa go po kilkunastu studiach. Sceny bez studia dobiera osobny ogon, tak jak wczesniej robilo NULLS LAST. Przy filtrze po studiach zawezamy tez sam skan studiow, inaczej szedlby przez wszystkie 33 tys. sort=title: nowy indeks na cale ORDER BY (title_normalized, created_at DESC, id DESC). Sam title_normalized zostawial Incremental Sort, a ten przed oddaniem pierwszego wiersza musi zebrac caly kubelek scen o tym samym tytule. Pomiary na produkcji (2026-09-16, ten sam zestaw filtrow, po rozgrzewce): studio strona 1 483 s -> 41 ms studio strona 5 ponad 5 min -> 38 ms studio + tag blowjob 71 s -> 226 ms studio + min_duration 48 s -> 42 ms studio + studio_slugs 255 ms -> 44 ms tytul strona 1 709 ms -> 12 ms (81 wpisow indeksu zamiast 3064) tytul + tag blowjob ponad 90 s -> 1,5 s Wynik jest identyczny co do wiersza: pierwsze 251 pozycji pelnego katalogu porownane pozycja po pozycji ze starym zapytaniem daje zero roznic, tak samo dwa okna alfabetyczne studiow (w tym Brazzers, zeby sprawdzic wewnetrzny LIMIT). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
cf9741b319
commit
470beb4aa6
2 changed files with 190 additions and 14 deletions
|
|
@ -0,0 +1,48 @@
|
|||
"""Indeks pod cale ORDER BY listy scen sortowanej po tytule.
|
||||
|
||||
`sort=title` konczy sie na `title_normalized ASC, created_at DESC, id DESC` (PK jako
|
||||
ostateczny tie-break, zeby strony sie nie nakladaly). Stary `ix_scenes_title_normalized`
|
||||
pokrywal tylko pierwszy czlon, wiec Postgres dokladal Incremental Sort, a ten przed
|
||||
oddaniem PIERWSZEGO wiersza musi zebrac caly kubelek scen o tym samym tytule. Na stronie 1
|
||||
glownego feedu ciagnal przez to 1084 wiersze zamiast 51 (709 ms zimno), a z filtrem taga
|
||||
„blowjob" nie zmiescil sie w 90 s.
|
||||
|
||||
Pomiar na produkcji 2026-09-16, ten sam zestaw filtrow, po rozgrzewce:
|
||||
strona 1 709 ms -> 12 ms (skan czyta 81 wpisow indeksu zamiast 3064)
|
||||
strona 5 27 ms -> 9 ms
|
||||
+ tag >90 s -> 1,1 s
|
||||
|
||||
Kierunki w indeksie musza byc dokladnie takie jak w ORDER BY, bo sort jest mieszany
|
||||
(ASC, DESC, DESC) i inaczej skan nie przejdzie po kolei.
|
||||
|
||||
`ix_scenes_title_normalized` zostaje: sluzy tez zwyklym lookupom po tytule (2,2 mln
|
||||
skanow), a odpiecie go od tego indeksu to osobna decyzja, nie skutek uboczny tej migracji.
|
||||
|
||||
CONCURRENTLY w autocommit_block, jak w 0035 i 0038. Nieudany CONCURRENTLY zostawia indeks
|
||||
INVALID; wtedy DROP INDEX i ponownie.
|
||||
|
||||
Revision ID: 0039_scenes_title_created_id_idx
|
||||
Revises: 0038_scenes_studio_effdate_idx
|
||||
Create Date: 2026-09-16
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0039_scenes_title_created_id_idx"
|
||||
down_revision: str | None = "0038_scenes_studio_effdate_idx"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_scenes_title_created_id_desc "
|
||||
"ON scenes (title_normalized, created_at DESC, id DESC)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_scenes_title_created_id_desc")
|
||||
|
|
@ -9,9 +9,20 @@ from typing import Annotated
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import Date, and_, distinct, exists, false, func, literal_column, select, union
|
||||
from sqlalchemy import (
|
||||
Date,
|
||||
and_,
|
||||
distinct,
|
||||
exists,
|
||||
false,
|
||||
func,
|
||||
literal_column,
|
||||
select,
|
||||
true,
|
||||
union,
|
||||
)
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.auth import require_api_key
|
||||
|
||||
|
|
@ -210,6 +221,105 @@ def blacklist_clauses(session: Session, device_id: str) -> list:
|
|||
]
|
||||
|
||||
|
||||
def _studio_sorted_select(base, inner_limit: int, studio_ids=None):
|
||||
"""`sort=studio` bez globalnego sortu calego przefiltrowanego katalogu.
|
||||
|
||||
Klucz sortu (`studios.name_normalized`) siedzi w DOLACZONEJ tabeli, wiec zaden indeks
|
||||
scen go nie zna. Planer robil wiec jedyne, co mogl: materializowal caly przefiltrowany
|
||||
katalog (ponad 2 mln wierszy) i sortowal go w calosci. Pomiar na produkcji 2026-09-16:
|
||||
strona 1 nie zdazyla sie policzyc w 5 minut, EXPLAIN pokazywal `Sort` nad `Hash Anti
|
||||
Join` bez szansy na urwanie po LIMIT.
|
||||
|
||||
Odwracamy zapytanie: idziemy po studiach w kolejnosci nazwy (`ix_studios_name_normalized`)
|
||||
i dla kazdego dobieramy jego sceny z `ix_scenes_studio_effective_date_desc` (migracja
|
||||
0038). Zewnetrzny skan jest posortowany, wiec LIMIT urywa go po kilkunastu studiach:
|
||||
85 ms zamiast minut, i tak samo z waskim filtrem (tag „blowjob": 132 ms zamiast ponad
|
||||
minuty).
|
||||
|
||||
`inner_limit` to offset+limit strony. Z JEDNEGO studia nigdy nie trzeba wiecej wierszy,
|
||||
niz liczy cala strona razem z offsetem, wiec ten LIMIT nie zmienia wyniku, a trzyma
|
||||
wewnetrzny skan przy ziemi dla gigantow w rodzaju Brazzers.
|
||||
|
||||
`studio_ids` zawezaja SAM zewnetrzny skan, gdy user filtruje po studiach. Bez tego
|
||||
przy `?studio_slugs=vixen` skan szedl przez wszystkie 33 tys. studiow, dla kazdego
|
||||
pytajac o sceny, ktorych tam z definicji nie ma: 100 s na strone 1 (pomiar 2026-09-16).
|
||||
Warunek jest juz w `base` po stronie scen, tu tylko powtorzony po stronie studiow, wiec
|
||||
wynik sie nie zmienia.
|
||||
|
||||
Sceny BEZ studia tu nie wchodza, ma je dobrac `_studio_sorted_rows`.
|
||||
"""
|
||||
st = aliased(Studio, name="st")
|
||||
inner = (
|
||||
base.where(Scene.studio_id == st.id)
|
||||
.order_by(_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc())
|
||||
.limit(literal_column(str(inner_limit)))
|
||||
.subquery()
|
||||
.lateral("sc")
|
||||
)
|
||||
scene = aliased(Scene, inner)
|
||||
outer = select(scene).select_from(st).join(inner, true())
|
||||
if studio_ids is not None:
|
||||
outer = outer.where(st.id.in_(studio_ids))
|
||||
# Dokladnie stary ORDER BY, tylko nazwa studia bierze sie teraz z zewnetrznego skanu.
|
||||
# NULLS LAST jest i tak domyslne dla ASC, wiec `ix_studios_name_normalized` wystarcza.
|
||||
return outer.order_by(
|
||||
st.name_normalized.asc().nullslast(),
|
||||
func.coalesce(
|
||||
inner.c.release_date,
|
||||
func.cast(func.timezone("UTC", inner.c.created_at), Date),
|
||||
).desc(),
|
||||
inner.c.created_at.desc(),
|
||||
inner.c.id.desc(),
|
||||
)
|
||||
|
||||
|
||||
def _studio_sorted_rows(
|
||||
session: Session, base, off: int, want: int, studio_ids=None
|
||||
) -> list[Scene]:
|
||||
"""Strona `sort=studio`: sceny studiow po nazwie, a za nimi sceny bez studia."""
|
||||
rows = list(
|
||||
session.execute(
|
||||
_studio_sorted_select(base, off + want, studio_ids)
|
||||
.offset(literal_column(str(off)))
|
||||
.limit(literal_column(str(want)))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if len(rows) >= want or studio_ids is not None:
|
||||
# Filtr po studiach: scena bez studia i tak by go nie przeszla, wiec ogon odpada.
|
||||
return rows
|
||||
|
||||
# Lista studiow sie skonczyla, wiec dobieramy ogon: sceny bez studia. W starym ORDER BY
|
||||
# siedzialy na koncu przez NULLS LAST i tak zostaje. W glownym feedzie zaczynaja sie
|
||||
# dopiero po ponad 400 tys. wierszy, czyli praktycznie nikt tam nie dojdzie, ale przy
|
||||
# waskim filtrze studia koncza sie szybko i bez tego ogona sort=studio chowalby wszystko,
|
||||
# co studia nie ma (to 62% katalogu).
|
||||
if off == 0 or rows:
|
||||
with_studio = off + len(rows)
|
||||
else:
|
||||
# Offset przeskoczyl cala czesc studiowa, wiec nie wiemy, ile jej bylo. Liczymy,
|
||||
# ale nie wiecej niz do konca zadanej strony.
|
||||
with_studio = session.execute(
|
||||
select(func.count()).select_from(
|
||||
_studio_sorted_select(base, off + want, studio_ids)
|
||||
.limit(literal_column(str(off + want)))
|
||||
.subquery()
|
||||
)
|
||||
).scalar_one()
|
||||
rows.extend(
|
||||
session.execute(
|
||||
base.where(Scene.studio_id.is_(None))
|
||||
.order_by(_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc())
|
||||
.offset(literal_column(str(max(0, off - with_studio))))
|
||||
.limit(literal_column(str(want - len(rows))))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def stub_exclusion_clause():
|
||||
"""Odsiew stub-scen: tube-only bez release_date AND bez canonical (TPDB/StashDB) AND
|
||||
bez performera. NOT stub gdy ma release_date OR canonical OR performera."""
|
||||
|
|
@ -471,6 +581,12 @@ def list_scenes(
|
|||
total_capped = False
|
||||
total: int | None = _default_scene_count(session) if _is_pure_default else None
|
||||
|
||||
# Offset i rozmiar pobrania sa potrzebne juz przy budowie sortu: `sort=studio` musi
|
||||
# wiedziec, ile wierszy moze potrzebowac z jednego studia (patrz _studio_sorted_select).
|
||||
_off = (page - 1) * per_page
|
||||
_want = per_page + 1
|
||||
studio_rows: list[Scene] | None = None
|
||||
|
||||
# Sort: KAŻDY tryb kończy się `Scene.id.desc()`, unikalny PK jako ostateczny
|
||||
# tie-break. Bez tego przy OFFSET-paginacji sceny z IDENTYCZNYM created_at (masowy
|
||||
# ingest = ten sam timestamp co do sekundy) i release_date mają NIEustalony porządek,
|
||||
|
|
@ -491,19 +607,30 @@ def list_scenes(
|
|||
_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc()
|
||||
)
|
||||
elif sort == "title":
|
||||
# Cale ORDER BY (razem z tie-breakami) pokrywa ix_scenes_title_created_id_desc
|
||||
# (migracja 0039). Sam `ix_scenes_title_normalized` zostawial Postgresowi
|
||||
# Incremental Sort, ktory przed oddaniem czegokolwiek MUSI zebrac caly kubelek
|
||||
# o tym samym tytule: na stronie 1 glownego feedu ciagnal 1084 wiersze zamiast
|
||||
# 51 (709 ms), a z filtrem taga szedl w minuty. Z pelnym indeksem sortu nie ma
|
||||
# wcale, skan urywa sie na 51. wierszu (5-12 ms).
|
||||
ordered = base.order_by(
|
||||
Scene.title_normalized.asc(), Scene.created_at.desc(), Scene.id.desc()
|
||||
)
|
||||
elif sort == "studio":
|
||||
# Sceny bez studio na końcu; w obrębie studio, najświeższe pierwsze.
|
||||
ordered = (
|
||||
base.outerjoin(Studio, Studio.id == Scene.studio_id)
|
||||
.order_by(
|
||||
Studio.name_normalized.asc().nullslast(),
|
||||
_effective_date_desc(),
|
||||
Scene.created_at.desc(),
|
||||
Scene.id.desc(),
|
||||
)
|
||||
# Sceny bez studio na końcu; w obrębie studio, najświeższe pierwsze. Fetch idzie
|
||||
# osobna sciezka (LATERAL po studiach), bo jeden SELECT z LEFT JOIN nie da sie tu
|
||||
# urwac po LIMIT: uzasadnienie i pomiary w _studio_sorted_select.
|
||||
ordered = None
|
||||
studio_rows = _studio_sorted_rows(
|
||||
session,
|
||||
base,
|
||||
_off,
|
||||
_want,
|
||||
studio_ids=(
|
||||
select(Studio.id).where(Studio.slug.in_(studio_slug_list))
|
||||
if studio_slug_list
|
||||
else None
|
||||
),
|
||||
)
|
||||
elif sort in ("duration_desc", "duration_asc"):
|
||||
# Najdluzsze / najkrotsze (zgloszenie ae6a045b). Sceny bez dlugosci odpadaja,
|
||||
|
|
@ -550,10 +677,11 @@ def list_scenes(
|
|||
# psuje early-termination i przy filtrach EXISTS planer robi gather-all+sort (sekundy)
|
||||
# zamiast limit-aware index-walk po `ix_scenes_created_at_desc`. page/per_page to
|
||||
# walidowane inty (Query ge=1, le=200), więc literal_column jest bezpieczne.
|
||||
_off = (page - 1) * per_page
|
||||
rows = (
|
||||
session.execute(
|
||||
ordered.offset(literal_column(str(_off))).limit(literal_column(str(per_page + 1)))
|
||||
studio_rows
|
||||
if studio_rows is not None
|
||||
else session.execute(
|
||||
ordered.offset(literal_column(str(_off))).limit(literal_column(str(_want)))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue