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 pydantic import BaseModel
from sqlalchemy import (
Date,
and_,
distinct,
exists,
false,
func,
literal_column,
select,
true,
union,
)
from sqlalchemy import Date, and_, distinct, exists, false, func, literal_column, select, union
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, aliased
from sqlalchemy.orm import Session
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():
"""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.
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))
bez performera. NOT stub gdy ma release_date OR canonical OR performera."""
canonical_exists = exists(
select(1)
.select_from(SceneExternalRef)
@ -347,7 +220,8 @@ def stub_exclusion_clause():
.where(SceneExternalRef.scene_id == Scene.id)
.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)
@ -504,18 +378,9 @@ def list_scenes(
)
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:
# `has_playback=false` RAZEM z filtrem originu to warunek sprzeczny: origin wymaga
# 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())
base = base.where(~live_playback_exists())
if origin:
# Pelny origin z ekranu strony ('tube:hdporngg') porownujemy wprost: rownosc idzie
@ -606,12 +471,6 @@ 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,
@ -632,31 +491,19 @@ 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). 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(
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. 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
),
# 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(),
)
)
elif sort in ("duration_desc", "duration_asc"):
# 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)
# 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 = (
studio_rows
if studio_rows is not None
else session.execute(
ordered.offset(literal_column(str(_off))).limit(literal_column(str(_want)))
session.execute(
ordered.offset(literal_column(str(_off))).limit(literal_column(str(per_page + 1)))
)
.scalars()
.all()