fix(api): szybkie szukanie po tytule dla popularnych i krotkich fraz
Some checks are pending
Backend tests / test (push) Waiting to run

Planer szacowal kazda fraze na 427 trafien (histogram 100 probek, dolny limit), wiec
zawsze szedl indeksem trigramow i 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
(GOON-73/75).

- 0046: histogram title_normalized 1000 probek, szacunki bliskie prawdy.
- list_scenes: przy q i sorcie po dacie najpierw przejscie po indeksie sortu z
  limitem 300 ms (bez bitmap, w SAVEPOINT wycofywanym zawsze), gdy probka tytulow
  z pg_stats mowi, ze wystarczy przejsc do 30 tys. scen; inaczej zwykly plan.
  Frazy bez trigramu zawsze przejsciem (limit 3 s, potem pusto).

115 prawdziwych wyszukiwan z logow: wyniki identyczne, najgorszy 44 s -> 0,8 s,
srednie frazy dotykaja 3-7x mniej stron ("bad" 122 tys. -> 16 tys.).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-09-22 11:52:14 +02:00
parent 7accd4692f
commit 45ee7a81e1
3 changed files with 228 additions and 9 deletions

View file

@ -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)")

View file

@ -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,10 +951,30 @@ 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 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 = (
studio_rows
if studio_rows is not None
else session.execute(
session.execute(
ordered.offset(literal_column(str(_off))).limit(literal_column(str(_want)))
)
.scalars()

52
tests/test_search_walk.py Normal file
View file

@ -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