perf(clips): explore po podzapytaniu okna, kandydaci bez ORM i bez tagow, max(uuid) przez tekst

Strona feedu spadla z 310-470 ms do 50-130 ms (srednio 85 ms na 30 stronach,
0 powtorek). Postgres nie ma max(uuid): koniec okna liczony po rzutowaniu na
tekst (ten sam porzadek dla malych hex w stalym formacie).
This commit is contained in:
goon-foss 2026-09-14 20:34:19 +02:00
parent 0df2236b37
commit 538a72107e

View file

@ -23,7 +23,7 @@ from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import func, select, text from sqlalchemy import String, cast, func, select, text
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -247,39 +247,47 @@ def _visible_clauses(device_id: str, bl: dict[str, set[str]]) -> list:
return clauses return clauses
def _candidates(session: Session, ids: list[uuid.UUID], clauses: list) -> list[cf.Candidate]: _CAND_COLS = (Clip.id, Clip.creator, Clip.likes, Clip.published_at, Clip.niches)
if not ids:
return []
rows = session.execute(select(Clip).where(Clip.id.in_(ids), *clauses)).scalars().all() def _rows_to_candidates(session: Session, rows, *, with_tags: bool) -> list[cf.Candidate]:
tags = _tags_for(session, [r.id for r in rows]) """Wiersze `_CAND_COLS` → Candidate. Tagi tylko na życzenie: pula explore punktuje
wyłącznie lajkami, więc ciągnięcie tagów dla 300 kandydatów było czystym kosztem."""
tags = _tags_for(session, [r[0] for r in rows]) if with_tags else {}
return [ return [
cf.Candidate( cf.Candidate(id=r[0], creator=r[1], likes=r[2], published_at=r[3], tags=tags.get(r[0], []), niches=list(r[4] or []))
id=r.id, creator=r.creator, likes=r.likes, published_at=r.published_at,
tags=tags.get(r.id, []), niches=list(r.niches or []),
)
for r in rows for r in rows
] ]
def _explore_window(session: Session, pk: uuid.UUID) -> tuple[list[uuid.UUID], bool]: def _candidates(session: Session, ids: list[uuid.UUID], clauses: list) -> list[cf.Candidate]:
"""Kolejne `_EXPLORE_WINDOW` id po `pk`; gdy okno się kończy, zawija do zera.""" if not ids:
ids = [ return []
r[0] rows = session.execute(select(*_CAND_COLS).where(Clip.id.in_(ids), *clauses)).all()
for r in session.execute( return _rows_to_candidates(session, rows, with_tags=True)
def _explore_candidates(
session: Session, pk: uuid.UUID, clauses: list, limit: int
) -> tuple[list[cf.Candidate], uuid.UUID, bool]:
"""Widoczni kandydaci z okna `_EXPLORE_WINDOW` id po `pk` (podzapytanie, bez
przenoszenia 2000 uuid przez SQL). Zwraca (kandydaci, koniec okna, czy okno było
ostatnie). Ostatnie okno = katalog wyczerpany od `pk`, następna strona zawija do zera."""
window = (
select(Clip.id).where(Clip.id > pk, Clip.dead_at.is_(None)).order_by(Clip.id).limit(_EXPLORE_WINDOW) select(Clip.id).where(Clip.id > pk, Clip.dead_at.is_(None)).order_by(Clip.id).limit(_EXPLORE_WINDOW)
) ).subquery()
] # Postgres nie ma max(uuid); tekstowo porządek jest ten sam (małe hex, stały format).
wrapped = False end_txt, n = session.execute(
if len(ids) < _EXPLORE_WINDOW and pk != _UUID_ZERO: select(func.max(cast(window.c.id, String)), func.count()).select_from(window)
wrapped = True ).one()
more = [ if not n:
r[0] return [], _UUID_ZERO, True
for r in session.execute( end_id = uuid.UUID(end_txt)
select(Clip.id).where(Clip.dead_at.is_(None)).order_by(Clip.id).limit(_EXPLORE_WINDOW - len(ids)) rows = session.execute(
) select(*_CAND_COLS).where(Clip.id.in_(select(window.c.id)), *clauses).order_by(Clip.id).limit(limit)
] ).all()
ids.extend(more) last = n < _EXPLORE_WINDOW
return ids, wrapped return _rows_to_candidates(session, rows, with_tags=False), (end_id if not last else _UUID_ZERO), last
def _affinity_candidate_ids(session: Session, aff: cf.Affinity, rng) -> set[uuid.UUID]: def _affinity_candidate_ids(session: Session, aff: cf.Affinity, rng) -> set[uuid.UUID]:
@ -344,19 +352,19 @@ def clip_feed(
taken = {c.id for c in picked_aff} taken = {c.id for c in picked_aff}
# Explore: okno po PK; gdy w oknie brak widocznych, przesuwamy sie dalej (max 3 okna). # Explore: okno po PK; gdy w oknie brak widocznych, przesuwamy sie dalej (max 3 okna).
# Braki z afinicznosci (za malo kandydatow po filtrach) dopelnia explore.
picked_explore: list[cf.Candidate] = [] picked_explore: list[cf.Candidate] = []
wrapped = False wrapped = False
next_pk = pk next_pk = pk
want = n_explore + max(0, n_aff - len(picked_aff)) # braki z afinicznosci dopelnia explore want = n_explore + max(0, n_aff - len(picked_aff))
for _ in range(3): for _ in range(3):
ids, w = _explore_window(session, next_pk) pool, next_pk, last = _explore_candidates(session, next_pk, clauses, limit=max(60, want * 15))
wrapped = wrapped or w pool = [c for c in pool if c.id not in taken]
if not ids:
break
next_pk = ids[-1] if not w else _UUID_ZERO
pool = [c for c in _candidates(session, ids, clauses) if c.id not in taken]
picked_explore += cf.pick_explore(pool, want - len(picked_explore), rng) picked_explore += cf.pick_explore(pool, want - len(picked_explore), rng)
if len(picked_explore) >= want or len(ids) < _EXPLORE_WINDOW: taken.update(c.id for c in picked_explore)
if last:
wrapped = True
if len(picked_explore) >= want or last:
break break
page_items = cf.compose_page(picked_aff, picked_explore, rng) page_items = cf.compose_page(picked_aff, picked_explore, rng)