diff --git a/alembic/versions/20260922_0046_scenes_title_stats.py b/alembic/versions/20260922_0046_scenes_title_stats.py new file mode 100644 index 0000000..e4a5848 --- /dev/null +++ b/alembic/versions/20260922_0046_scenes_title_stats.py @@ -0,0 +1,36 @@ +"""Wiekszy histogram statystyk dla scenes.title_normalized (szukanie po tytule). + +Postgres szacuje `ILIKE '%fraza%'` z histogramu kolumny. Przy domyslnych 100 probkach +popularne frazy nie trafialy w zadna i szacunek spadal na dolny limit 0,01% (427 scen), +wiec planista zawsze szedl przez indeks trigramow i potem sortowal wszystkie trafienia: +"vo" 44 s (dwie litery nie maja trigramu, indeks oddaje 4,3 mln wierszy), "mis" 6,5 s, +"free" 1,9 s (Sentry GOON-73/75, 2026-09-22). Z 1000 probkami szacunki sa bliskie +prawdy i dla popularnych fraz wygrywa przejscie po ix_scenes_created_at_desc do 51. +trafienia: 9 ms, 15 ms, 60 ms. Rzadkie frazy dalej ida przez trigramy. + +ANALYZE samej kolumny czyta probke 300 tys. wierszy, kilkanascie sekund. + +Revision ID: 0046_scenes_title_stats +Revises: 0045_autovacuum_big_tables +Create Date: 2026-09-22 +""" +from __future__ import annotations + +from alembic import op + +revision = "0046_scenes_title_stats" +down_revision: str | None = "0045_autovacuum_big_tables" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("SET LOCAL lock_timeout = '5s'") + op.execute("ALTER TABLE scenes ALTER COLUMN title_normalized SET STATISTICS 1000") + op.execute("ANALYZE scenes (title_normalized)") + + +def downgrade() -> None: + op.execute("SET LOCAL lock_timeout = '5s'") + op.execute("ALTER TABLE scenes ALTER COLUMN title_normalized SET STATISTICS -1") + op.execute("ANALYZE scenes (title_normalized)") diff --git a/app/api/scenes.py b/app/api/scenes.py index 55b556c..f248387 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -20,10 +20,12 @@ from sqlalchemy import ( func, literal_column, select, + text, true, union, ) -from sqlalchemy.exc import IntegrityError +from psycopg.errors import QueryCanceled +from sqlalchemy.exc import IntegrityError, OperationalError from sqlalchemy.orm import Session, aliased from app.auth import require_api_key @@ -208,6 +210,115 @@ def _blacklists_empty(session: Session, device_id: str) -> bool: return not has_any +# Szukanie po tytule (GOON-73/75, pomiary 2026-09-22). Sa dwa dobre plany i planer nie +# umie wybrac miedzy nimi, bo nie wie, ile tytulow pasuje: +# - przejscie po indeksie sortu od najnowszych do 51. trafienia: popularne i srednie frazy +# ("vo", "bad", "veronica") 10-110 ms, rzadkie ("jackerman") ponad 20 s, bo idzie przez +# caly katalog; +# - indeks trigramow, potem sort wszystkich trafien: rzadkie 30-500 ms, popularne sekundy +# ("mis" 6,5 s), a dwuliterowe ("vo") 44 s, bo bez trigramu indeks oddaje 4,3 mln wierszy. +# Dlatego najpierw przejscie z limitem czasu; nie zdazylo, to fraza jest rzadka i zwykly +# plan (trigramy) jest wlasciwy. Wiekszy histogram tytulow (migracja 0046) poprawil +# szacunki, ale srednie frazy dalej szly trigramami (0,2-1,3 s zamiast 10-110 ms). +# Zeby rzadkie frazy (nazwiska) i dalekie strony nie placily limitu na probe, szacujemy z +# probki tytulow ze statystyk Postgresa (1000 wartosci, ta sama, z ktorej planer liczy +# szacunek), ile scen trzeba przejsc: (offset + strona) / udzial trafien w probce. Na 115 +# prawdziwych wyszukiwaniach przejscie wygrywalo zawsze do ~30 tys., wyzej i przy +# zawezeniu (tag, obsada, studio, dlugie minimum) zwykle nie miescilo sie w limicie. +_SEARCH_WALK_BUDGET_MS = 300 +_SEARCH_WALK_MAX_SCAN = 30_000 +# Fraza bez ciagu 3 liter/cyfr nie ma trigramu, wiec awaryjnego planu nie ma. Dluzszy +# limit, a po nim pusta lista: to zwykle stan w trakcie pisania ("q", "xz"). +_SEARCH_WALK_BUDGET_NO_TRGM_MS = 3000 +_TRGM_RUN_RE = re.compile(r"[^\W_]{3}") +_TITLE_SAMPLE_TTL = 3600.0 +_TITLE_SAMPLE: dict = {"ts": 0.0, "vals": ()} + + +def _title_sample(session: Session) -> tuple[str, ...]: + """Histogram + najczestsze wartosci `scenes.title_normalized` z pg_stats (cache 1 h).""" + import time as _time + + now = _time.monotonic() + if _TITLE_SAMPLE["vals"] and now - _TITLE_SAMPLE["ts"] < _TITLE_SAMPLE_TTL: + return _TITLE_SAMPLE["vals"] + row = session.execute( + text( + "SELECT histogram_bounds::text::text[], most_common_vals::text::text[] " + "FROM pg_stats WHERE schemaname = 'public' AND tablename = 'scenes' " + "AND attname = 'title_normalized'" + ) + ).first() + vals = tuple((row[0] or []) + (row[1] or [])) if row else () + _TITLE_SAMPLE.update(ts=now, vals=vals) + return vals + + +def _search_walk_ids(session: Session, ordered, off: int, want: int, budget_ms: int): + """Id strony z `ordered` bez skanow bitmapowych i z limitem czasu; None po limicie. + + Bez bitmap indeks trigramow (GIN) odpada, wiec planer idzie po indeksie sortu i konczy + na LIMIT. SAVEPOINT wycofujemy zawsze: SET LOCAL przezylby jego zwolnienie i reszta + requestu (obsada, tagi, zrodla) szlaby bez bitmap i z limitem czasu. Nic nie tracimy, + to sam odczyt, a id sa juz w Pythonie. + """ + sp = session.begin_nested() + try: + session.execute( + select( + func.set_config("enable_bitmapscan", "off", True), + func.set_config("statement_timeout", str(budget_ms), True), + ) + ) + return list( + session.execute( + ordered.with_only_columns(Scene.id) + .offset(literal_column(str(off))) + .limit(literal_column(str(want))) + ).scalars() + ) + except OperationalError as e: + if isinstance(e.orig, QueryCanceled): + return None + raise + finally: + sp.rollback() + + +def _search_walk_budget(q: str, off: int, want: int, narrowed: bool, sample) -> int | None: + """Limit czasu (ms) na przejscie od najnowszych albo None, gdy od razu zwykly plan. + + `sample` to funkcja zwracajaca probke tytulow, wolana tylko gdy potrzebna. + """ + if not _TRGM_RUN_RE.search(q): + return _SEARCH_WALK_BUDGET_NO_TRGM_MS + if narrowed: + return None + vals = sample() + needle = q.lower() + hits = sum(needle in v for v in vals) + if not hits or (off + want) * len(vals) / hits > _SEARCH_WALK_MAX_SCAN: + return None + return _SEARCH_WALK_BUDGET_MS + + +def _search_walk_rows( + session: Session, ordered, q: str, off: int, want: int, narrowed: bool +) -> list[Scene] | None: + """Strona wynikow szukania przejsciem od najnowszych; None = uzyj zwyklego planu.""" + budget = _search_walk_budget(q, off, want, narrowed, lambda: _title_sample(session)) + if budget is None: + return None + ids = _search_walk_ids(session, ordered, off, want, budget) + if ids is None: + # Bez trigramu zwykly plan czyta caly indeks (44 s), wiec lepiej pusto. + return None if budget == _SEARCH_WALK_BUDGET_MS else [] + if not ids: + return [] + by_id = {s.id: s for s in session.execute(select(Scene).where(Scene.id.in_(ids))).scalars()} + return [by_id[i] for i in ids if i in by_id] + + def _split_csv(raw: str | None) -> list[str]: if not raw: return [] @@ -840,15 +951,35 @@ def list_scenes( rows = [by_id[i] for i in page_ids if i in by_id] has_more = page < len(feed_pages) or len(cand) >= cand_n else: - rows = ( - studio_rows - if studio_rows is not None - else session.execute( - ordered.offset(literal_column(str(_off))).limit(literal_column(str(_want))) + rows = studio_rows + if rows is None and q and ( + sort == "created_at" or (sort == "release_date" and tag_sort_id is None) + ): + rows = _search_walk_rows( + session, + ordered, + q, + _off, + _want, + narrowed=bool( + tag_slug_list + or perf_id_strings + or studio_slug_list + or origin + or min_quality_p is not None + or released_within_days is not None + or max_duration_sec is not None + or (min_duration_sec or 0) > 600 + ), + ) + if rows is None: + rows = ( + session.execute( + ordered.offset(literal_column(str(_off))).limit(literal_column(str(_want))) + ) + .scalars() + .all() ) - .scalars() - .all() - ) has_more = len(rows) > per_page rows = rows[:per_page] diff --git a/tests/test_search_walk.py b/tests/test_search_walk.py new file mode 100644 index 0000000..a6fd5a3 --- /dev/null +++ b/tests/test_search_walk.py @@ -0,0 +1,52 @@ +from app.api.scenes import ( + _SEARCH_WALK_BUDGET_MS, + _SEARCH_WALK_BUDGET_NO_TRGM_MS, + _search_walk_budget, +) + +# 1000 tytulow jak histogram z pg_stats: 20 z "anal", 1 z "hailey rose", reszta obojetna. +SAMPLE = ["anal scene %d" % i for i in range(20)] + ["hailey rose pov"] + [ + "other title %d" % i for i in range(979) +] + + +def _budget(q, off=0, want=51, narrowed=False): + return _search_walk_budget(q, off, want, narrowed, lambda: SAMPLE) + + +def test_common_phrase_walks(): + # 20/1000 = 2%: na strone 1 trzeba przejsc ~2,5 tys. scen + assert _budget("anal") == _SEARCH_WALK_BUDGET_MS + + +def test_phrase_absent_from_sample_goes_to_trigrams(): + assert _budget("jackerman") is None + + +def test_single_hit_phrase_goes_to_trigrams(): + # 1/1000 = 0,1%: ~51 tys. scen do przejscia, powyzej progu + assert _budget("hailey rose") is None + + +def test_deep_page_of_common_phrase_goes_to_trigrams(): + # strona 13 "anal": (600 + 51) / 2% = ~32 tys. + assert _budget("anal", off=600) is None + + +def test_narrowed_search_goes_to_trigrams(): + assert _budget("anal", narrowed=True) is None + + +def test_short_phrase_always_walks(): + # bez trigramu zwykly plan czyta caly indeks; tez przy zawezeniu i poza probka + for q in ("vo", "q", "xz", " a "): + assert _budget(q, narrowed=True) == _SEARCH_WALK_BUDGET_NO_TRGM_MS + assert _budget("zq", off=5000) == _SEARCH_WALK_BUDGET_NO_TRGM_MS + + +def test_sample_not_read_when_not_needed(): + def boom(): + raise AssertionError("probka niepotrzebna") + + assert _search_walk_budget("vo", 0, 51, False, boom) == _SEARCH_WALK_BUDGET_NO_TRGM_MS + assert _search_walk_budget("anal", 0, 51, True, boom) is None