perf(scenes): indeks pod sortowanie serwisu po dacie premiery + fix wyscigu zapisu wyszukiwania
Zgloszenie 4cccc70f: sxyprn na liscie sites ladowal sie ok 18 sekund. Odtworzone: /scenes?origin=tube:sxyprncom&sort=release_date schodzilo 19,23 s, a to samo z sort=created_at 0,08 s. Ekran serwisu sortuje po dacie premiery, czyli release_date DESC NULLS LAST, created_at DESC, id DESC, a istniejacy ix_scenes_release_date jest ROSNACY: czytany od tylu daje NULL-e na poczatku i nie obsluguje kluczy rozstrzygajacych. Planner leial wiec pelnym skanem scenes (3,9 mln) i playback_sources (3,7 mln). Po zalozeniu pasujacego indeksu strumieniuje w docelowej kolejnosci i konczy po 25 trafieniach: sxyprn 19,23 s -> 0,11 s, hqporner 7,99 s -> 0,87 s. Drugi indeks po originie, bo playback_sources nie mial zadnego. GOON-4Y: add_saved_search sprawdzal istnienie i wstawial w dwoch krokach, wiec dwa rownolegle zapisy tego samego zapytania (podwojne tapniecie, ponowienie po timeoucie) oba widzialy pusto i drugi dostawal 500 na uq_saved_searches_device_query. Teraz IntegrityError konczy sie oddaniem istniejacego wiersza. Test: 6 rownoleglych zapisow -> 6x200 i jeden wiersz. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
65f768cf8a
commit
8f6fa8c732
2 changed files with 69 additions and 1 deletions
51
alembic/versions/20260820_0032_origin_release_sort_idx.py
Normal file
51
alembic/versions/20260820_0032_origin_release_sort_idx.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""indeksy pod listowanie scen jednego serwisu sortowane po dacie premiery
|
||||||
|
|
||||||
|
Zgłoszenie 4cccc70f: „po kliknięciu sxyprn na liście sites sceny ładują się po ok
|
||||||
|
18 sekundach". Odtworzone co do sekundy: `/scenes?origin=tube:sxyprncom&
|
||||||
|
sort=release_date` schodziło 19,23 s, a to samo z `sort=created_at` 0,08 s.
|
||||||
|
|
||||||
|
Powód: ekran serwisu sortuje po dacie premiery, czyli
|
||||||
|
`release_date DESC NULLS LAST, created_at DESC, id DESC`. Istniejący
|
||||||
|
`ix_scenes_release_date` jest ROSNĄCY, więc czytany od tyłu daje NULL-e na
|
||||||
|
początku, a nie na końcu, i nie obsługuje tego układu ani dwóch kluczy
|
||||||
|
rozstrzygających. Bez pasującego indeksu planner leciał pełnym skanem `scenes`
|
||||||
|
(3,9 mln) i `playback_sources` (3,7 mln), sortował całość i dopiero brał 25 wierszy.
|
||||||
|
|
||||||
|
Z pasującym indeksem strumieniuje sceny już w docelowej kolejności i zatrzymuje się
|
||||||
|
po 25 trafieniach. Po założeniu na produkcji: sxyprn 19,23 s → 0,11 s,
|
||||||
|
hqporner 7,99 s → 0,87 s.
|
||||||
|
|
||||||
|
Drugi indeks (`origin`, `scene_id`) obsługuje wyszukanie źródeł jednego serwisu.
|
||||||
|
Na `playback_sources` nie było ŻADNEGO indeksu po originie.
|
||||||
|
|
||||||
|
`IF NOT EXISTS`, bo na produkcji oba założono od razu (CONCURRENTLY, żeby nie
|
||||||
|
blokować ruchu). Tu chodzi o to, by świeża instalacja miała to samo.
|
||||||
|
|
||||||
|
Revision ID: 0032_origin_release_idx
|
||||||
|
Revises: 0031_duration_backfill
|
||||||
|
Create Date: 2026-08-20
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0032_origin_release_idx"
|
||||||
|
down_revision: str | None = "0031_duration_backfill"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_scenes_release_created_id_desc "
|
||||||
|
"ON scenes (release_date DESC NULLS LAST, created_at DESC, id DESC)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_playback_sources_origin_live "
|
||||||
|
"ON playback_sources (origin, scene_id) WHERE dead_at IS NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_playback_sources_origin_live")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_scenes_release_created_id_desc")
|
||||||
|
|
@ -17,6 +17,7 @@ from typing import Annotated
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel, field_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.device import get_device_id
|
from app.api.device import get_device_id
|
||||||
|
|
@ -89,7 +90,23 @@ def add_saved_search(
|
||||||
)
|
)
|
||||||
row = SavedSearch(device_id=device_id, query=payload.query)
|
row = SavedSearch(device_id=device_id, query=payload.query)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
session.commit()
|
try:
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
# Wyscig na `uq_saved_searches_device_query`: sprawdzenie wyzej i INSERT to dwie
|
||||||
|
# osobne operacje, wiec dwa rownolegle zapisy tego samego zapytania (podwojne
|
||||||
|
# tapniecie, ponowienie po timeoucie) oba widza pusto i oba wstawiaja. Drugi
|
||||||
|
# dostawal 500 zamiast idempotentnej odpowiedzi (GOON-4Y). Skoro wiersz juz
|
||||||
|
# istnieje, to dokladnie ten, ktory user chcial zapisac, wiec go oddajemy.
|
||||||
|
session.rollback()
|
||||||
|
existing = session.execute(
|
||||||
|
select(SavedSearch).where(
|
||||||
|
SavedSearch.device_id == device_id, SavedSearch.query == payload.query
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is None:
|
||||||
|
raise
|
||||||
|
return existing
|
||||||
session.refresh(row)
|
session.refresh(row)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue