perf(api): lista scen jednego tagu od najnowszych po indeksie na scene_tags (GOON-64)

Ekran tagu (jeden tag, sort=release_date, 200 scen) sortowal po dacie z `scenes`, a filtrowal
po `scene_tags`. Postgres szedl od najnowszej sceny calego katalogu i kazda sprawdzal; przy
tagach rzedu 40-60 tys. scen (`rough-sex`) to 46 tys. scen na strone, 7-15 s na zimnym cache.
Dwie przebudowy samego zapytania nie pomogly (2x albo 30 s na duzych tagach), cache tez nie,
bo wejscia rozkladaja sie na setki tagow.

`scene_tags.sort_date` (0042) trzyma ta sama date co `_effective_date_desc()`, pilnowana przez
wyzwalacze w bazie, indeks (tag_id, sort_date DESC, scene_id DESC) w 0043. Wypelnianie 23 mln
wierszy: scripts/backfill_scene_tags_sort_date.py, partiami z pauza.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-09-18 13:24:03 +02:00
parent ed3a7b8f4e
commit 7f2fdfaed0
5 changed files with 235 additions and 4 deletions

View file

@ -0,0 +1,89 @@
"""Data sortowania sceny powielona w scene_tags (GOON-64).
Lista "scen z tagiem X od najnowszych" (ekran tagu w apce, 200 scen) sortuje po dacie z
`scenes`, a filtruje po `scene_tags`. Zaden indeks nie laczy tych dwoch rzeczy, wiec
Postgres idzie po `ix_scenes_effective_date_desc` od najnowszej sceny i kazda sprawdza, czy
ma tag. Dla tagow sredniej wielkosci to dziesiatki tysiecy sprawdzen: `rough-sex` (41,5 tys.
scen) przegladal 46 tys. scen, zeby znalezc 201, czyli ~460 tys. blokow. 0,5 s na rozgrzanym
cache, 7-15 s na zimnym (Sentry GOON-64, pomiar 2026-09-18). Male tagi planer odwraca (od
tagu), duze znajduja 201 scen po kilku tysiacach, wiec bola te posrodku.
Dwie przebudowy zapytania nie pomogly: kandydaci po tagu bez ciezkich filtrow dali ~2x na
`rough-sex`, a wymuszenie "najpierw tag" robilo z duzych tagow 22-30 s (hash join po calym
`scenes`). Cache nie ma sensu: 237 wejsc w 48 h na 168 roznych tagow.
Dlatego `sort_date` = COALESCE(release_date, created_at w UTC jako data), to samo wyrazenie
co `_effective_date_desc()` w app/api/scenes.py, trzymane tez przy tagu. Indeks (tag_id,
sort_date DESC, scene_id DESC) z migracji 0043 daje od razu najnowsze sceny danego tagu.
Utrzymanie w bazie, nie w aplikacji, bo do scene_tags pisze wiele sciezek (ingest, merge,
porzadki tagow, skrypty):
- BEFORE INSERT / UPDATE OF scene_id na scene_tags: data z wiersza sceny,
- AFTER UPDATE OF release_date, created_at na scenes: poprawia tagi tej sceny (scena
dostaje date premiery z TPDB/StashDB, merge bierze wczesniejsze created_at).
Kolumna nullable i bez DEFAULT, wiec ALTER jest natychmiastowy. Istniejace 23 mln wierszy
wypelnia skrypt partiami w tle (scripts/backfill_scene_tags_sort_date.py), dopiero potem
indeks (0043) i przelaczenie zapytania. lock_timeout, bo ALTER czeka za kazdym dlugim
zapytaniem na scene_tags i w tym czasie blokuje wszystkie kolejne.
Revision ID: 0042_scene_tags_sort_date
Revises: 0041_scene_ext_refs_scene_source
Create Date: 2026-09-18
"""
from __future__ import annotations
from alembic import op
revision = "0042_scene_tags_sort_date"
down_revision: str | None = "0041_scene_ext_refs_scene_source"
branch_labels = None
depends_on = None
_SORT_DATE = "COALESCE({t}.release_date, ({t}.created_at AT TIME ZONE 'UTC')::date)"
def upgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("ALTER TABLE scene_tags ADD COLUMN IF NOT EXISTS sort_date date")
op.execute(f"""
CREATE OR REPLACE FUNCTION scene_tags_fill_sort_date() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
SELECT {_SORT_DATE.format(t='s')} INTO NEW.sort_date
FROM scenes s WHERE s.id = NEW.scene_id;
RETURN NEW;
END $$
""")
op.execute("""
CREATE TRIGGER trg_scene_tags_fill_sort_date
BEFORE INSERT OR UPDATE OF scene_id ON scene_tags
FOR EACH ROW EXECUTE FUNCTION scene_tags_fill_sort_date()
""")
op.execute(f"""
CREATE OR REPLACE FUNCTION scenes_propagate_sort_date() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE d date := {_SORT_DATE.format(t='NEW')};
BEGIN
UPDATE scene_tags SET sort_date = d
WHERE scene_id = NEW.id AND sort_date IS DISTINCT FROM d;
RETURN NULL;
END $$
""")
op.execute("""
CREATE TRIGGER trg_scenes_propagate_sort_date
AFTER UPDATE OF release_date, created_at ON scenes
FOR EACH ROW
WHEN (OLD.release_date IS DISTINCT FROM NEW.release_date
OR OLD.created_at IS DISTINCT FROM NEW.created_at)
EXECUTE FUNCTION scenes_propagate_sort_date()
""")
def downgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("DROP TRIGGER IF EXISTS trg_scenes_propagate_sort_date ON scenes")
op.execute("DROP TRIGGER IF EXISTS trg_scene_tags_fill_sort_date ON scene_tags")
op.execute("DROP FUNCTION IF EXISTS scenes_propagate_sort_date()")
op.execute("DROP FUNCTION IF EXISTS scene_tags_fill_sort_date()")
op.execute("ALTER TABLE scene_tags DROP COLUMN IF EXISTS sort_date")

View file

@ -0,0 +1,40 @@
"""Indeks "najnowsze sceny danego tagu" (GOON-64, kolumna z 0042).
(tag_id, sort_date DESC, scene_id DESC): lista scen z jednym tagiem, sort=release_date,
idzie po tym indeksie od najnowszej i konczy po LIMIT, niezaleznie od wielkosci tagu.
Uzasadnienie i pomiary w 0042.
Zakladac DOPIERO po wypelnieniu `sort_date` (scripts/backfill_scene_tags_sort_date.py).
Z indeksem w trakcie wypelniania kazda z 23 mln aktualizacji dopisywalaby tez wpis tutaj.
`ix_scene_tags_tag_id_scene_id` zostaje na razie, choc nowy indeks ma ten sam prefiks.
Osobna decyzja, jak przy 0040.
CONCURRENTLY w autocommit_block, jak w 0035, 0038, 0039 i 0041. Nieudany CONCURRENTLY
zostawia indeks INVALID; wtedy DROP INDEX i ponownie.
Revision ID: 0043_scene_tags_tag_sort_idx
Revises: 0042_scene_tags_sort_date
Create Date: 2026-09-18
"""
from __future__ import annotations
from alembic import op
revision = "0043_scene_tags_tag_sort_idx"
down_revision: str | None = "0042_scene_tags_sort_date"
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_tags_tag_sort "
"ON scene_tags (tag_id, sort_date DESC, scene_id DESC)"
)
def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_scene_tags_tag_sort")

View file

@ -466,6 +466,9 @@ def list_scenes(
# ix_scenes_created_at_desc zamiast materializować wszystkie scene_tags. Slug-JOIN # ix_scenes_created_at_desc zamiast materializować wszystkie scene_tags. Slug-JOIN
# ukrywał tag_id przed plannerem → używał średniej (8.4M/11541≈726) → zły plan # ukrywał tag_id przed plannerem → używał średniej (8.4M/11541≈726) → zły plan
# (4-12s). Z literałem: ~20ms. Zob. też _build... light mode. # (4-12s). Z literałem: ~20ms. Zob. też _build... light mode.
# Jeden tag + sort od najnowszych (ekran tagu w apce): zamiast EXISTS dolaczamy wiersz
# scene_tags i sortujemy po jego `sort_date`, patrz galaz sort == "release_date".
tag_sort_id: uuid.UUID | None = None
if tag_slug_list: if tag_slug_list:
id_by_slug = dict( id_by_slug = dict(
session.execute( session.execute(
@ -477,6 +480,9 @@ def list_scenes(
if tag_id is None: if tag_id is None:
base = base.where(false()) # nieznany slug → brak wyników base = base.where(false()) # nieznany slug → brak wyników
break break
if len(tag_slug_list) == 1 and sort == "release_date":
tag_sort_id = tag_id
continue
base = base.where( base = base.where(
exists( exists(
select(1) select(1)
@ -644,9 +650,21 @@ def list_scenes(
# Wyrazenie musi byc IMMUTABLE, zeby weszlo do indeksu: `created_at::date` zalezy # Wyrazenie musi byc IMMUTABLE, zeby weszlo do indeksu: `created_at::date` zalezy
# od strefy sesji, stad jawne AT TIME ZONE 'UTC'. Indeks: # od strefy sesji, stad jawne AT TIME ZONE 'UTC'. Indeks:
# ix_scenes_effective_date_desc (migracja 0033). # ix_scenes_effective_date_desc (migracja 0033).
ordered = base.order_by( if tag_sort_id is not None:
_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc() # Z filtrem jednego tagu ta sama data siedzi w scene_tags.sort_date (wyzwalacz,
) # migracja 0042) i idzie po ix_scene_tags_tag_sort (0043): od najnowszej sceny
# tagu, koniec po LIMIT. Po samym `scenes` Postgres sprawdzal tag scena po scenie
# od najnowszej w calym katalogu i przy tagach rzedu 40-60 tys. scen (`rough-sex`)
# przegladal 46 tys. scen na jedna strone: 7-15 s na zimnym cache (GOON-64).
# W obrebie jednego dnia kolejnosc po id, nie po created_at: indeks jest
# wezszy, a stronicowanie i tak stabilne.
ordered = base.join(
SceneTag, and_(SceneTag.scene_id == Scene.id, SceneTag.tag_id == tag_sort_id)
).order_by(SceneTag.sort_date.desc(), SceneTag.scene_id.desc())
else:
ordered = base.order_by(
_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc()
)
elif sort == "title": elif sort == "title":
# Cale ORDER BY (razem z tie-breakami) pokrywa ix_scenes_title_created_id_desc # Cale ORDER BY (razem z tie-breakami) pokrywa ix_scenes_title_created_id_desc
# (migracja 0039). Wczesniejszy indeks na samym `title_normalized` zostawial # (migracja 0039). Wczesniejszy indeks na samym `title_normalized` zostawial
@ -716,7 +734,8 @@ def list_scenes(
# Z oknem 2000 id to nested loop po kilkudziesieciu wierszach (pomiar 2026-09-14). # Z oknem 2000 id to nested loop po kilkudziesieciu wierszach (pomiar 2026-09-14).
# Okno rosnie ze strona, zeby glebsze strony mialy z czego wybierac. Sekcje # Okno rosnie ze strona, zeby glebsze strony mialy z czego wybierac. Sekcje
# wertykalne (JAV, Anime) to ~2% katalogu, wiec dostaja 4x szersze okno; z 2000 # wertykalne (JAV, Anime) to ~2% katalogu, wiec dostaja 4x szersze okno; z 2000
# id JAV wyciagal 27 scen i druga strona bylaby pusta. # id JAV wyciagal 27 scen i druga strona bylaby pusta. Szersze okno dla wszystkich
# nie wchodzi w gre: z filtrem tagu 6000 id to 2,7 s (probe scene_tags per scena).
base_window = 8000 if (vertical or jav) else 2000 base_window = 8000 if (vertical or jav) else 2000
window = max(base_window, page * per_page * 4) window = max(base_window, page * per_page * 4)
cand = ( cand = (

View file

@ -134,3 +134,7 @@ class SceneTag(Base):
source_id: Mapped[uuid.UUID | None] = mapped_column( source_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("sources.id", ondelete="SET NULL") UUID(as_uuid=True), ForeignKey("sources.id", ondelete="SET NULL")
) )
# Data sortowania sceny (premiera albo dzien dodania), powielona tu pod indeks
# "najnowsze sceny tagu" (GOON-64, migracje 0042 i 0043). Pisze ja WYZWALACZ w bazie,
# nie aplikacja: wartosc podana przy INSERT i tak zostanie nadpisana.
sort_date: Mapped[date | None] = mapped_column(Date)

View file

@ -0,0 +1,79 @@
"""Wypelnia scene_tags.sort_date w istniejacych wierszach (migracja 0042, GOON-64).
Partiami po zakresach stron heapu (TID range scan, Postgres 14+), commit po kazdej partii,
VACUUM co kilka partii: martwe wersje zwalniaja miejsce dla nastepnych partii, zamiast
podwajac 1,7 GB tabeli i jej dwa indeksy. Nowe wiersze w trakcie wypelnia juz wyzwalacz
z 0042. Wznawialny: rusza tylko wiersze z sort_date IS NULL, na koncu dobiera resztki.
Odpalac w tle, nie z restartowanego workera:
docker compose run -d --rm --name backfill_sort_date worker \
python3 -u /srv/scripts/backfill_scene_tags_sort_date.py [stron_na_partie] [pauza_s]
Pauza miedzy partiami oddaje dysk apce: bez niej (2000 stron, 25-45 s na partie) zapytania
list scen czekaly na odczyt po 30 s (2026-09-18). Z 1000 stron i pauza 10 s bylo spokojnie.
Po nim: alembic upgrade (0043, indeks) i dopiero wtedy kod, ktory z indeksu korzysta.
"""
from __future__ import annotations
import sys
import time
from sqlalchemy import text
from app.db import engine
PAGES = int(sys.argv[1]) if len(sys.argv) > 1 else 1000
PAUSE = float(sys.argv[2]) if len(sys.argv) > 2 else 10.0
VACUUM_EVERY = 8
SORT_DATE = "COALESCE(s.release_date, (s.created_at AT TIME ZONE 'UTC')::date)"
def rel_pages() -> int:
with engine.connect() as c:
return int(c.execute(text(
"SELECT pg_relation_size('scene_tags') / current_setting('block_size')::int"
)).scalar())
def vacuum(analyze: bool = False) -> None:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as c:
c.execute(text("VACUUM (ANALYZE) scene_tags" if analyze else "VACUUM scene_tags"))
t0 = time.time()
total = 0
page = 0
batch = 0
pages = rel_pages()
print(f"scene_tags: {pages} stron, partia {PAGES} stron, pauza {PAUSE} s", flush=True)
while page < pages:
tb = time.time()
with engine.begin() as c:
c.execute(text("SET LOCAL statement_timeout = '600s'"))
n = c.execute(text(f"""
UPDATE scene_tags st SET sort_date = {SORT_DATE}
FROM scenes s
WHERE s.id = st.scene_id AND st.sort_date IS NULL
AND st.ctid >= format('(%s,0)', CAST(:p0 AS int))::tid
AND st.ctid < format('(%s,0)', CAST(:p1 AS int))::tid
"""), {"p0": page, "p1": page + PAGES}).rowcount
total += n
page += PAGES
batch += 1
if batch % VACUUM_EVERY == 0:
vacuum()
pages = rel_pages() # tabela moze urosnac, zanim VACUUM zwolni miejsce
print(f"partia {batch}: strony do {page}/{pages}, {n} wierszy w {time.time() - tb:.1f} s, "
f"razem {total}, {time.time() - t0:.0f} s", flush=True)
if n:
time.sleep(PAUSE)
with engine.begin() as c:
rest = c.execute(text(f"""
UPDATE scene_tags st SET sort_date = {SORT_DATE}
FROM scenes s WHERE s.id = st.scene_id AND st.sort_date IS NULL
""")).rowcount
print(f"resztki: {rest}", flush=True)
vacuum(analyze=True)
with engine.connect() as c:
left = c.execute(text("SELECT count(*) FROM scene_tags WHERE sort_date IS NULL")).scalar()
print(f"KONIEC: {total + rest} wierszy, puste po wszystkim: {left}, {time.time() - t0:.0f} s", flush=True)