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