Compare commits

..

No commits in common. "2cdc3d69da123afa29d3711bb17fc105222add54" and "cf9741b3192f571cce40bd72b6bce36865bbbc44" have entirely different histories.

4 changed files with 19 additions and 320 deletions

View file

@ -1,49 +0,0 @@
"""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.
Ten indeks zaczyna sie od `title_normalized`, wiec obsluguje tez zwykle lookupy po tytule
(2,2 mln skanow), przez ktore stary `ix_scenes_title_normalized` staje sie zbedny. Usuwa go
migracja 0040, osobno, zeby dalo sie cofnac samo usuniecie.
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")

View file

@ -1,46 +0,0 @@
"""Usuwa ix_scenes_title_normalized, zastapiony prefiksem indeksu z 0039.
`ix_scenes_title_created_id_desc (title_normalized, created_at DESC, id DESC)` zaczyna sie
od tej samej kolumny, wiec obsluguje wszystko, co obslugiwal stary indeks: lookupy
`title_normalized = X` z resolvera (`scene_match.blocking_candidates`,
`scene_resolver` przy trybie agregatora) i skanowanie zakresu przy sortowaniu po tytule.
Trzymanie obu to 367 MB na dysku i jeden indeks wiecej do utrzymania przy kazdym zapisie
do `scenes`, a ingest pisze do tej tabeli caly czas.
Sprawdzone na produkcji 2026-09-16 przez `BEGIN; DROP INDEX ...; EXPLAIN ANALYZE ...;
ROLLBACK;`, zeby zobaczyc wybor planera bez starego indeksu:
exact title lookup Index Scan ix_scenes_title_normalized 1,76 ms
-> Index Scan ix_scenes_title_created_id_desc 0,05 ms
OR studio + exact title Bitmap Index Scan, ten sam ksztalt planu
W wariancie z OR planer i tak siegal juz po nowy indeks, jeszcze zanim stary znikl.
CONCURRENTLY w autocommit_block: DROP bez CONCURRENTLY bierze ACCESS EXCLUSIVE i zatrzymuje
ingest na czas operacji.
Revision ID: 0040_drop_scenes_title_norm_idx
Revises: 0039_scenes_title_created_id_idx
Create Date: 2026-09-16
"""
from __future__ import annotations
from alembic import op
revision = "0040_drop_scenes_title_norm_idx"
down_revision: str | None = "0039_scenes_title_created_id_idx"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_scenes_title_normalized")
def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_scenes_title_normalized "
"ON scenes (title_normalized)"
)

View file

@ -1,52 +0,0 @@
"""Indeks pod czlon "ma kanon" w odsiewie stub-scen.
`stub_exclusion_clause` pyta, czy scena ma wpis z TPDB albo StashDB. To podzapytanie leci
dla kazdego kandydata KAZDEJ listy scen, a `ix_scene_external_refs_scene_id` zna tylko
`scene_id`, wiec `source_id` trzeba bylo dociagnac z heapu: jeden losowy odczyt na wpis,
przy 5,1 mln wierszy i 2,2 GB tabeli.
Z `(scene_id, source_id)` to samo podzapytanie idzie Index Only Scanem. Pomiar na
produkcji 2026-09-16 (sort=title + origin=hqporner, strona 5, 16 tys. wywolan tego
podzapytania): Heap Fetches 16 191 -> 1241.
Razem ze zmiana kolejnosci OR w `stub_exclusion_clause` (tanszy czlon "ma performera"
przed drozszym "ma kanon") to zapytanie czyta 251 tys. blokow zamiast 294 tys., a na
rozgrzanym cache trwa 305 ms zamiast 371 ms (mediana z 3 rund z rotowana kolejnoscia).
Zysk jest skromny i ograniczony do list, gdzie kandydatow sa dziesiatki tysiecy; na
glownym feedzie to podzapytanie leci kilka razy na strone i zmiany nie widac.
Uwaga do pomiarow: pierwsze wykonanie tego zapytania na zimnym cache trwa 3-5 s i tak
zostaje, bo trzeba dotknac ~2 GB. Wariant puszczany jako pierwszy wychodzi przez to
kilkanascie razy wolniej, co przy pierwszym podejsciu dalo falszywe "10x szybciej".
`ix_scene_external_refs_scene_id` zostaje na razie, mimo ze jest prefiksem tego indeksu.
Osobna decyzja, tak jak przy 0040.
CONCURRENTLY w autocommit_block, jak w 0035, 0038 i 0039. Nieudany CONCURRENTLY zostawia
indeks INVALID; wtedy DROP INDEX i ponownie.
Revision ID: 0041_scene_ext_refs_scene_source
Revises: 0040_drop_scenes_title_norm_idx
Create Date: 2026-09-16
"""
from __future__ import annotations
from alembic import op
revision = "0041_scene_ext_refs_scene_source"
down_revision: str | None = "0040_drop_scenes_title_norm_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_scene_external_refs_scene_source "
"ON scene_external_refs (scene_id, source_id)"
)
def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_scene_external_refs_scene_source")

View file

@ -9,20 +9,9 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import ( from sqlalchemy import Date, and_, distinct, exists, false, func, literal_column, select, union
Date,
and_,
distinct,
exists,
false,
func,
literal_column,
select,
true,
union,
)
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session
from app.auth import require_api_key from app.auth import require_api_key
@ -221,125 +210,9 @@ 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(): def stub_exclusion_clause():
"""Odsiew stub-scen: tube-only bez release_date AND bez canonical (TPDB/StashDB) AND """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. bez performera. NOT stub gdy ma release_date OR canonical OR performera."""
KOLEJNOSC CZLONOW OR MA ZNACZENIE i jest tu celowa, od najtanszego do najdrozszego.
Postgres liczy OR po kolei i przerywa na pierwszym trafieniu, a sam czlonow nie
przestawia. `release_date IS NOT NULL` idzie pierwszy, bo czyta tylko wiersz sceny.
Kanon jest ostatni, bo to jedyny czlon z JOIN-em (`sources`).
Pomiar na produkcji 2026-09-16, sort=title + origin=hqporner, strona 5 (najgorszy
przypadek, 33 590 kandydatow): czlon "ma kanon" liczony 5146 razy zamiast 16 191, bo
"ma performera" odsiewa go wczesniej. Z indeksem z migracji 0041 spadly przy tym
odczyty z heapu `scene_external_refs`, 16 191 -> 1241.
Zysk jest skromny i widac go tylko tam, gdzie kandydatow sa dziesiatki tysiecy: cale
zapytanie czyta 251 tys. blokow zamiast 294 tys. (-15%), a czas na rozgrzanym cache to
305 ms zamiast 371 ms (mediana z 3 rund z rotowana kolejnoscia). Na glownym feedzie
czlon "ma kanon" wykonuje sie kilka razy na strone, wiec tam zmiana nie robi nic.
"""
has_performer = exists(select(1).where(ScenePerformer.scene_id == Scene.id))
canonical_exists = exists( canonical_exists = exists(
select(1) select(1)
.select_from(SceneExternalRef) .select_from(SceneExternalRef)
@ -347,7 +220,8 @@ def stub_exclusion_clause():
.where(SceneExternalRef.scene_id == Scene.id) .where(SceneExternalRef.scene_id == Scene.id)
.where(Source.kind.in_([SourceKind.tpdb, SourceKind.stashdb])) .where(Source.kind.in_([SourceKind.tpdb, SourceKind.stashdb]))
) )
return Scene.release_date.is_not(None) | has_performer | canonical_exists has_performer = exists(select(1).where(ScenePerformer.scene_id == Scene.id))
return Scene.release_date.is_not(None) | canonical_exists | has_performer
@router.get("", response_model=SceneListOut) @router.get("", response_model=SceneListOut)
@ -504,18 +378,9 @@ def list_scenes(
) )
if has_playback is True: if has_playback is True:
# Przy filtrze originu ta klauzula jest nadmiarowa: `origin` nizej sam wymaga
# zrodla z `dead_at IS NULL`, wiec scena, ktora go przechodzi, ma z definicji zywy
# playback. Kosztowala natomiast kolejna sonde `playback_sources` na KAZDYM
# kandydacie, a przy waskim originie kandydatow sa dziesiatki tysiecy.
if not origin:
base = base.where(live_playback_exists()) base = base.where(live_playback_exists())
elif has_playback is False: elif has_playback is False:
# `has_playback=false` RAZEM z filtrem originu to warunek sprzeczny: origin wymaga base = base.where(~live_playback_exists())
# zrodla z `dead_at IS NULL`, a `has_playback=false` zabrania takiego zrodla. Wynik
# jest pusty z definicji, ale Postgres i tak przemielal caly katalog, zeby to
# stwierdzic: 148 s na produkcji (pomiar 2026-09-16). Zwieramy od razu.
base = base.where(false() if origin else ~live_playback_exists())
if origin: if origin:
# Pelny origin z ekranu strony ('tube:hdporngg') porownujemy wprost: rownosc idzie # Pelny origin z ekranu strony ('tube:hdporngg') porownujemy wprost: rownosc idzie
@ -606,12 +471,6 @@ def list_scenes(
total_capped = False total_capped = False
total: int | None = _default_scene_count(session) if _is_pure_default else None 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 # 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 # 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, # ingest = ten sam timestamp co do sekundy) i release_date mają NIEustalony porządek,
@ -632,31 +491,19 @@ def list_scenes(
_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc() _effective_date_desc(), Scene.created_at.desc(), Scene.id.desc()
) )
elif sort == "title": elif sort == "title":
# Cale ORDER BY (razem z tie-breakami) pokrywa ix_scenes_title_created_id_desc
# (migracja 0039). Wczesniejszy indeks na samym `title_normalized` zostawial
# Postgresowi Incremental Sort, a ten przed oddaniem czegokolwiek MUSI zebrac caly
# kubelek scen 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 wezla
# sortu nie ma wcale, skan urywa sie na 51. wierszu (5-12 ms). Stary indeks byl
# prefiksem nowego, wiec 0040 go kasuje.
ordered = base.order_by( ordered = base.order_by(
Scene.title_normalized.asc(), Scene.created_at.desc(), Scene.id.desc() Scene.title_normalized.asc(), Scene.created_at.desc(), Scene.id.desc()
) )
elif sort == "studio": elif sort == "studio":
# Sceny bez studio na końcu; w obrębie studio, najświeższe pierwsze. Fetch idzie # Sceny bez studio na końcu; w obrębie studio, najświeższe pierwsze.
# osobna sciezka (LATERAL po studiach), bo jeden SELECT z LEFT JOIN nie da sie tu ordered = (
# urwac po LIMIT: uzasadnienie i pomiary w _studio_sorted_select. base.outerjoin(Studio, Studio.id == Scene.studio_id)
ordered = None .order_by(
studio_rows = _studio_sorted_rows( Studio.name_normalized.asc().nullslast(),
session, _effective_date_desc(),
base, Scene.created_at.desc(),
_off, Scene.id.desc(),
_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"): elif sort in ("duration_desc", "duration_asc"):
# Najdluzsze / najkrotsze (zgloszenie ae6a045b). Sceny bez dlugosci odpadaja, # Najdluzsze / najkrotsze (zgloszenie ae6a045b). Sceny bez dlugosci odpadaja,
@ -703,11 +550,10 @@ def list_scenes(
# psuje early-termination i przy filtrach EXISTS planer robi gather-all+sort (sekundy) # 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 # 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. # walidowane inty (Query ge=1, le=200), więc literal_column jest bezpieczne.
_off = (page - 1) * per_page
rows = ( rows = (
studio_rows session.execute(
if studio_rows is not None ordered.offset(literal_column(str(_off))).limit(literal_column(str(per_page + 1)))
else session.execute(
ordered.offset(literal_column(str(_off))).limit(literal_column(str(_want)))
) )
.scalars() .scalars()
.all() .all()