fix(api): szukanie w serwisie i sortowanie po studiu z rzadka fraza
Some checks are pending
Backend tests / test (push) Waiting to run

- Szukanie w jednym serwisie do 60 tys. scen zaczyna od jego scen (scenes.id = ANY),
  gdy fraza jest popularna albo bez trigramu: "sel" w hqporner 3,4 s -> 0,1 s, "brat"
  w fullmoviesxxx 2,9 s -> 0,15 s. Rzadkie frazy dalej trigramami (GOON-75).
- Poprawka regresji z c08abf6: fraza bez trigramu przy zawezeniu po 3 s oddawala
  pusta liste ("ai" w fullmoviesxxx: 0 wynikow). Teraz od serwisu albo limit 10 s.
- sort=studio z rzadka fraza: JOIN ze studiami i sort samych trafien zamiast
  przejscia po 33 tys. studiow ("sunny leone" 928 s -> 0,26 s, "savanna" 20 s -> 0,17 s).

Porownanie ze starym kodem na 100 wyszukiwaniach w serwisach i 12 z sortem po studiu:
wyniki identyczne poza naprawionymi pustymi listami.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-09-22 17:13:39 +02:00
parent 0da36d5c1e
commit 3349700f72
2 changed files with 119 additions and 4 deletions

View file

@ -14,6 +14,8 @@ from pydantic import BaseModel
from sqlalchemy import (
Date,
and_,
any_,
bindparam,
distinct,
exists,
false,
@ -25,6 +27,8 @@ from sqlalchemy import (
union,
)
from psycopg.errors import QueryCanceled
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.orm import Session, aliased
@ -228,8 +232,25 @@ def _blacklists_empty(session: Session, device_id: str) -> bool:
_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").
# limit, a po nim pusta lista: to zwykle stan w trakcie pisania ("q", "xz"). Przy
# zawezeniu (tag itp.) trafienia sa rzadsze, wiec limit wiekszy, zeby nie oddac pustej
# listy tam, gdzie wyniki sa.
_SEARCH_WALK_BUDGET_NO_TRGM_MS = 3000
_SEARCH_WALK_BUDGET_NO_TRGM_NARROWED_MS = 10_000
# Szukanie w jednym serwisie (ekran serwisu w apce, GOON-75): dla serwisu do tylu scen
# zaczynamy od jego scen (id z playback_sources, potem scenes.id = ANY), zamiast od
# calego katalogu. Czas zalezy od wielkosci serwisu, nie od frazy, a z trigramem
# Postgres laczy oba indeksy (BitmapAnd). Pomiar 2026-09-22: "sel" w hqporner 3,4 s ->
# 0,11 s, "brat" w fullmoviesxxx 2,9 s -> 0,15 s, "ai" 8,6 s -> 2,2 s. W duzym serwisie
# (sxyprn 108 tys., xvideos 444 tys.) przejscie od najnowszych trafia szybko, a samo
# pobranie i przeslanie tablicy id kosztuje 0,5-0,9 s (sxyprn "sislovesme" 0,12 -> 1,65 s).
_ORIGIN_DRIVEN_MAX_SCENES = 60_000
# Rzadka fraza (nazwisko): mniej niz tyle trafien w probce tytulow, czyli ponizej ~0,4%
# katalogu. Jej sceny najszybciej znajduja trigramy, bo jest ich malo. W serwisie
# (0,02-0,36 s) sciezka od serwisu placilaby tylko staly narzut tablicy id (~0,5 s),
# a przy sort=studio przejscie po studiach szuka trafien w kazdym z 33 tys. studiow
# ("sunny leone" 15 min), podczas gdy trigramy + sort trafien to 0,37 s.
_RARE_PHRASE_HITS = 4
_TRGM_RUN_RE = re.compile(r"[^\W_]{3}")
_TITLE_SAMPLE_TTL = 3600.0
_TITLE_SAMPLE: dict = {"ts": 0.0, "vals": ()}
@ -291,7 +312,7 @@ def _search_walk_budget(q: str, off: int, want: int, narrowed: bool, sample) ->
`sample` to funkcja zwracajaca probke tytulow, wolana tylko gdy potrzebna.
"""
if not _TRGM_RUN_RE.search(q):
return _SEARCH_WALK_BUDGET_NO_TRGM_MS
return _SEARCH_WALK_BUDGET_NO_TRGM_NARROWED_MS if narrowed else _SEARCH_WALK_BUDGET_NO_TRGM_MS
if narrowed:
return None
vals = sample()
@ -319,6 +340,44 @@ def _search_walk_rows(
return [by_id[i] for i in ids if i in by_id]
def _rare_title_phrase(q: str, sample) -> bool:
"""Fraza z trigramem, ktora w probce tytulow trafia rzadko (patrz _RARE_PHRASE_HITS).
`sample` to funkcja zwracajaca probke tytulow, wolana tylko gdy potrzebna.
"""
if not _TRGM_RUN_RE.search(q):
return False
needle = q.lower()
return sum(needle in v for v in sample()) < _RARE_PHRASE_HITS
def _origin_driven_worth(q: str, sample) -> bool:
"""Czy szukanie w serwisie zaczac od jego scen: fraza bez trigramu albo popularna."""
return not _rare_title_phrase(q, sample)
def _origin_scene_ids(session: Session, origin: str) -> list[uuid.UUID] | None:
"""Id scen z zywym zrodlem serwisu albo None, gdy serwis jest za duzy na te sciezke.
Wielkosc bierzemy z source_stats (liczone co 6 h), zeby dla xvideos nie ciagnac
setek tysiecy id tylko po to, zeby je wyrzucic.
"""
from app.models.source_stats import SourceStats
size = session.execute(select(SourceStats.scenes).where(SourceStats.origin == origin)).scalar()
if size is not None and size > _ORIGIN_DRIVEN_MAX_SCENES:
return None
ids = list(
session.execute(
select(PlaybackSource.scene_id)
.where(PlaybackSource.origin == origin, PlaybackSource.dead_at.is_(None))
.distinct()
.limit(_ORIGIN_DRIVEN_MAX_SCENES + 1)
).scalars()
)
return ids if len(ids) <= _ORIGIN_DRIVEN_MAX_SCENES else None
def _split_csv(raw: str | None) -> list[str]:
if not raw:
return []
@ -850,6 +909,18 @@ def list_scenes(
ordered = base.order_by(
Scene.title_normalized.asc(), Scene.created_at.desc(), Scene.id.desc()
)
elif sort == "studio" and q and _rare_title_phrase(q, lambda: _title_sample(session)):
# Rzadka fraza: trafien jest malo, wiec zwykly JOIN ze studiami i sort samych
# trafien (trigramy) zamiast przejscia po studiach, ktore szukaloby ich w kazdym
# studiu z osobna ("sunny leone" 15 min -> 0,37 s, "savannah" 20 s -> 0,88 s).
# Ten sam porzadek: studia po nazwie, w studiu najswiezsze, bez studia na koncu.
sst = aliased(Studio, name="sort_studio")
ordered = base.outerjoin(sst, sst.id == Scene.studio_id).order_by(
sst.name_normalized.asc().nullslast(),
_effective_date_desc(),
Scene.created_at.desc(),
Scene.id.desc(),
)
elif sort == "studio":
# Sceny bez studio na końcu; w obrębie studio, najświeższe pierwsze. Fetch idzie
# osobna sciezka (LATERAL po studiach), bo jeden SELECT z LEFT JOIN nie da sie tu
@ -952,7 +1023,21 @@ def list_scenes(
has_more = page < len(feed_pages) or len(cand) >= cand_n
else:
rows = studio_rows
if rows is None and q and (
origin_ids = None
if (
rows is None
and q
and origin
and sort in ("created_at", "release_date")
and _origin_driven_worth(q, lambda: _title_sample(session))
):
origin_ids = _origin_scene_ids(session, origin)
if origin_ids is not None:
ordered = ordered.where(
Scene.id
== any_(bindparam("origin_scene_ids", origin_ids, type_=ARRAY(PG_UUID(as_uuid=True))))
)
if rows is None and origin_ids is None and q and (
sort == "created_at" or (sort == "release_date" and tag_sort_id is None)
):
rows = _search_walk_rows(

View file

@ -1,6 +1,9 @@
from app.api.scenes import (
_SEARCH_WALK_BUDGET_MS,
_SEARCH_WALK_BUDGET_NO_TRGM_MS,
_SEARCH_WALK_BUDGET_NO_TRGM_NARROWED_MS,
_origin_driven_worth,
_rare_title_phrase,
_search_walk_budget,
)
@ -40,13 +43,40 @@ def test_narrowed_search_goes_to_trigrams():
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(q) == _SEARCH_WALK_BUDGET_NO_TRGM_MS
assert _budget("zq", off=5000) == _SEARCH_WALK_BUDGET_NO_TRGM_MS
def test_short_phrase_narrowed_gets_longer_budget():
# z tagiem trafienia sa rzadsze; po 3 s oddawalismy pusta liste mimo wynikow
assert _budget("ai", narrowed=True) == _SEARCH_WALK_BUDGET_NO_TRGM_NARROWED_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("vo", 0, 51, True, boom) == _SEARCH_WALK_BUDGET_NO_TRGM_NARROWED_MS
assert _search_walk_budget("anal", 0, 51, True, boom) is None
def test_origin_driven_for_common_and_short_phrases():
# "anal" 20/1000 w probce: popularna, w malym serwisie zaczynamy od jego scen
assert _origin_driven_worth("anal", lambda: SAMPLE)
# bez trigramu zawsze od serwisu (inaczej przejscie po calym katalogu)
assert _origin_driven_worth("ai", lambda: SAMPLE)
def test_origin_driven_skipped_for_rare_phrases():
# nazwisko: trigramy znajda kilka scen szybciej niz tablica id serwisu
assert not _origin_driven_worth("hailey rose", lambda: SAMPLE)
assert not _origin_driven_worth("jackerman", lambda: SAMPLE)
def test_rare_phrase_detection():
assert _rare_title_phrase("hailey rose", lambda: SAMPLE)
assert _rare_title_phrase("sunny leone", lambda: SAMPLE)
assert not _rare_title_phrase("anal", lambda: SAMPLE)
# bez trigramu nie jest "rzadka": trigramy i tak by jej nie znalazly
assert not _rare_title_phrase("ai", lambda: SAMPLE)