perf(api): lista stron odswiezana w tle, nikt nie czeka na przeliczenie

Agregat po 4,1 mln zywych zrodel trwa 0,6-2 s, a cache wygasal co 5 minut, wiec co
wygasniecie ktos placil pelnym czasem (Sentry Slow DB Query list_sources, 186 zdarzen,
ostatnie dzisiaj). Teraz po wygasnieciu oddajemy stary wynik i przeliczamy w watku w tle,
a TTL idzie z 5 do 15 minut. Liczby zmieniaja sie o promile na godzine.

Pomiar na prodzie: pierwsze wejscie po restarcie 2,1 s (nieuniknione), potem 11-15 ms,
a wejscie tuz po wygasnieciu 38 ms zamiast pelnego przeliczenia.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-09-17 11:05:51 +02:00
parent be0f2d7d37
commit 3c1d2c5907

View file

@ -16,6 +16,7 @@ from __future__ import annotations
import logging
import os
import re
import threading
import time
from datetime import datetime
from pathlib import Path
@ -27,7 +28,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth import require_api_key
from app.db import get_session
from app.db import get_session, session_scope
from app.models.playback_source import PlaybackSource
from app.models.source_stats import SourceStats
@ -86,8 +87,10 @@ class SourceOut(BaseModel):
_ICON_DIR = Path(__file__).resolve().parent.parent / "static" / "site-icons"
_SOURCES_TTL = 300.0
#: Po tylu sekundach wynik uznajemy za stary i przeliczamy W TLE (oddajac stary).
_SOURCES_TTL = 900.0
_SOURCES_CACHE: dict = {"at": 0.0, "rows": None}
_SOURCES_LOCK = threading.Lock()
def _icon_url(sitetag: str) -> str | None:
@ -134,6 +137,50 @@ def _sitetag_to_display(sitetag: str) -> str:
return sitetag
def _source_rows(session: Session):
return session.execute(
select(
PlaybackSource.origin,
func.count(PlaybackSource.id).label("scene_count"),
func.max(PlaybackSource.last_seen_at).label("last_scraped_at"),
)
.where(PlaybackSource.dead_at.is_(None))
.where(PlaybackSource.origin.like("tube:%"))
.group_by(PlaybackSource.origin)
).all()
def _refresh_source_rows_bg() -> None:
if not _SOURCES_LOCK.acquire(blocking=False):
return # ktos juz liczy
def run() -> None:
try:
with session_scope() as s:
rows = _source_rows(s)
_SOURCES_CACHE["rows"] = rows
_SOURCES_CACHE["at"] = time.monotonic()
except Exception: # noqa: BLE001
log.exception("sources: odswiezenie listy stron w tle nie wyszlo")
finally:
_SOURCES_LOCK.release()
threading.Thread(target=run, name="sources-refresh", daemon=True).start()
def _cached_source_rows(session: Session):
rows = _SOURCES_CACHE["rows"]
if rows is None:
with _SOURCES_LOCK:
if _SOURCES_CACHE["rows"] is None:
_SOURCES_CACHE["rows"] = _source_rows(session)
_SOURCES_CACHE["at"] = time.monotonic()
return _SOURCES_CACHE["rows"]
if time.monotonic() - _SOURCES_CACHE["at"] > _SOURCES_TTL:
_refresh_source_rows_bg()
return rows
@router.get("", response_model=SourceListOut)
def list_sources(
session: Annotated[Session, Depends(get_session)],
@ -142,23 +189,11 @@ def list_sources(
Filter: `origin LIKE 'tube:%'` (drop canonical:* TPDB trailery to inna semantyka).
"""
# Agregat idzie po 4,1 mln zywych zrodel (600 ms na kazde wejscie w Sites, Sentry:
# Slow DB Query list_sources). Liczby zmieniaja sie o promile na godzine, wiec
# wynik zyje w pamieci procesu przez _SOURCES_TTL sekund.
now = time.monotonic()
if _SOURCES_CACHE["rows"] is None or now - _SOURCES_CACHE["at"] > _SOURCES_TTL:
_SOURCES_CACHE["rows"] = session.execute(
select(
PlaybackSource.origin,
func.count(PlaybackSource.id).label("scene_count"),
func.max(PlaybackSource.last_seen_at).label("last_scraped_at"),
)
.where(PlaybackSource.dead_at.is_(None))
.where(PlaybackSource.origin.like("tube:%"))
.group_by(PlaybackSource.origin)
).all()
_SOURCES_CACHE["at"] = now
rows = _SOURCES_CACHE["rows"]
# Agregat idzie po 4,1 mln zywych zrodel (0,6-1,6 s na przeliczenie, Sentry: Slow DB
# Query list_sources, 186 zdarzen). Liczby zmieniaja sie o promile na godzine, wiec
# wynik zyje w pamieci procesu, a po wygasnieciu oddajemy STARY i przeliczamy w tle.
# Wczesniej co wygasniecie cache ktos placil pelnym czasem zapytania (2026-09-17).
rows = _cached_source_rows(session)
# Oceny z source_stats (policzone offline przez run_source_stats). Origin → row.
stats = {s.origin: s for s in session.execute(select(SourceStats)).scalars().all()}