From 2cdc3d69da123afa29d3711bb17fc105222add54 Mon Sep 17 00:00:00 2001 From: goon-foss Date: Wed, 16 Sep 2026 11:11:17 +0200 Subject: [PATCH] perf(api): tanszy odsiew stub-scen i zwarcie sprzecznego filtra playbacku 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 --- ...41_scene_external_refs_scene_source_idx.py | 52 +++++++++++++++++++ app/api/scenes.py | 35 +++++++++++-- 2 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/20260916_0041_scene_external_refs_scene_source_idx.py diff --git a/alembic/versions/20260916_0041_scene_external_refs_scene_source_idx.py b/alembic/versions/20260916_0041_scene_external_refs_scene_source_idx.py new file mode 100644 index 0000000..f2c51e4 --- /dev/null +++ b/alembic/versions/20260916_0041_scene_external_refs_scene_source_idx.py @@ -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") diff --git a/app/api/scenes.py b/app/api/scenes.py index 03bb09c..6f83299 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -322,7 +322,24 @@ def _studio_sorted_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.""" + 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( select(1) .select_from(SceneExternalRef) @@ -330,8 +347,7 @@ def stub_exclusion_clause(): .where(SceneExternalRef.scene_id == Scene.id) .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) | canonical_exists | has_performer + return Scene.release_date.is_not(None) | has_performer | canonical_exists @router.get("", response_model=SceneListOut) @@ -488,9 +504,18 @@ def list_scenes( ) if has_playback is True: - base = base.where(live_playback_exists()) + # 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()) 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: # Pelny origin z ekranu strony ('tube:hdporngg') porownujemy wprost: rownosc idzie