From 8f6fa8c732351c29b047a684559e8b330a39bf92 Mon Sep 17 00:00:00 2001 From: goon-foss Date: Thu, 20 Aug 2026 16:00:58 +0200 Subject: [PATCH] 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 --- .../20260820_0032_origin_release_sort_idx.py | 51 +++++++++++++++++++ app/api/saved_searches.py | 19 ++++++- 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/20260820_0032_origin_release_sort_idx.py diff --git a/alembic/versions/20260820_0032_origin_release_sort_idx.py b/alembic/versions/20260820_0032_origin_release_sort_idx.py new file mode 100644 index 0000000..cdd5394 --- /dev/null +++ b/alembic/versions/20260820_0032_origin_release_sort_idx.py @@ -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") diff --git a/app/api/saved_searches.py b/app/api/saved_searches.py index b12c462..ced5a6d 100644 --- a/app/api/saved_searches.py +++ b/app/api/saved_searches.py @@ -17,6 +17,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, field_validator from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session 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) 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) return row