perf(api): tanszy odsiew stub-scen i zwarcie sprzecznego filtra playbacku
Some checks failed
Backend tests / test (push) Has been cancelled

Trzy zmiany wokol listy scen z filtrem originu, gdzie kandydatow sa dziesiatki tysiecy.

1. Kolejnosc czlonow OR w stub_exclusion_clause. Postgres liczy OR po kolei i przerywa na
pierwszym trafieniu, a sam czlonow nie przestawia. "Ma kanon" (jedyny czlon z JOIN-em do
sources) stal przed tanszym "ma performera", wiec placilismy za niego takze za sceny,
ktore performera mialy. Po zamianie ten czlon liczy sie 5146 razy zamiast 16 191.

2. Indeks scene_external_refs (scene_id, source_id), migracja 0041. Podzapytanie "ma
kanon" dociagalo source_id z heapu, bo stary indeks zna tylko scene_id. Teraz idzie
Index Only Scanem: Heap Fetches 16 191 -> 1241.

Razem: sort=title + origin=hqporner, strona 5 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. Pierwsze wykonanie na zimnym cache trwa 3-5 s i tak
zostaje, bo trzeba dotknac ~2 GB; sprawdzilem tez wariant z materializacja scen originu
i CTE, czyta wiecej blokow (302 tys.), wiec obecny plan zostaje.

3. has_playback=false RAZEM z filtrem originu to warunek sprzeczny, bo origin wymaga
zrodla z dead_at IS NULL. Wynik jest pusty z definicji, ale Postgres przemielal caly
katalog, zeby to stwierdzic: 148 s. Teraz zwarte na false(), 4,8 ms, dalej zero wynikow.
Przy has_playback=true z originem pomijamy z kolei nadmiarowe EXISTS o zywym playbacku,
bo origin sam go wymaga, a kosztowalo sonde na kazdym kandydacie.

Wyniki bez zmian: strony 1-5 dla origin=hqporner i dla glownego feedu porownane pozycja
po pozycji ze stara wersja daja zero roznic, a sam predykat stub zgadza sie na probce
200 tys. scen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-09-16 11:11:17 +02:00
parent b6a075b28a
commit 2cdc3d69da
2 changed files with 82 additions and 5 deletions

View file

@ -0,0 +1,52 @@
"""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

@ -322,7 +322,24 @@ def _studio_sorted_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)
@ -330,8 +347,7 @@ 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]))
) )
has_performer = exists(select(1).where(ScenePerformer.scene_id == Scene.id)) return Scene.release_date.is_not(None) | has_performer | canonical_exists
return Scene.release_date.is_not(None) | canonical_exists | has_performer
@router.get("", response_model=SceneListOut) @router.get("", response_model=SceneListOut)
@ -488,9 +504,18 @@ 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:
base = base.where(~live_playback_exists()) # `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())
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