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:
parent
ed3a7b8f4e
commit
7f2fdfaed0
5 changed files with 235 additions and 4 deletions
89
alembic/versions/20260918_0042_scene_tags_sort_date.py
Normal file
89
alembic/versions/20260918_0042_scene_tags_sort_date.py
Normal 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")
|
||||
40
alembic/versions/20260918_0043_scene_tags_tag_sort_idx.py
Normal file
40
alembic/versions/20260918_0043_scene_tags_tag_sort_idx.py
Normal 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")
|
||||
|
|
@ -466,6 +466,9 @@ def list_scenes(
|
|||
# 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
|
||||
# (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:
|
||||
id_by_slug = dict(
|
||||
session.execute(
|
||||
|
|
@ -477,6 +480,9 @@ def list_scenes(
|
|||
if tag_id is None:
|
||||
base = base.where(false()) # nieznany slug → brak wyników
|
||||
break
|
||||
if len(tag_slug_list) == 1 and sort == "release_date":
|
||||
tag_sort_id = tag_id
|
||||
continue
|
||||
base = base.where(
|
||||
exists(
|
||||
select(1)
|
||||
|
|
@ -644,6 +650,18 @@ def list_scenes(
|
|||
# Wyrazenie musi byc IMMUTABLE, zeby weszlo do indeksu: `created_at::date` zalezy
|
||||
# od strefy sesji, stad jawne AT TIME ZONE 'UTC'. Indeks:
|
||||
# ix_scenes_effective_date_desc (migracja 0033).
|
||||
if tag_sort_id is not None:
|
||||
# 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()
|
||||
)
|
||||
|
|
@ -716,7 +734,8 @@ def list_scenes(
|
|||
# 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
|
||||
# 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
|
||||
window = max(base_window, page * per_page * 4)
|
||||
cand = (
|
||||
|
|
|
|||
|
|
@ -134,3 +134,7 @@ class SceneTag(Base):
|
|||
source_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
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)
|
||||
|
|
|
|||
79
scripts/backfill_scene_tags_sort_date.py
Normal file
79
scripts/backfill_scene_tags_sort_date.py
Normal 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)
|
||||
Loading…
Add table
Reference in a new issue