diff --git a/alembic/versions/20260916_0039_scenes_title_created_id_idx.py b/alembic/versions/20260916_0039_scenes_title_created_id_idx.py new file mode 100644 index 0000000..e60c9fa --- /dev/null +++ b/alembic/versions/20260916_0039_scenes_title_created_id_idx.py @@ -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") diff --git a/app/api/scenes.py b/app/api/scenes.py index 3b12823..66dbb47 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -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()