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 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.orm import Session
@ -247,39 +247,47 @@ def _visible_clauses(device_id: str, bl: dict[str, set[str]]) -> list:
return clauses
def _candidates(session: Session, ids: list[uuid.UUID], clauses: list) -> list[cf.Candidate]:
if not ids:
return []
rows = session.execute(select(Clip).where(Clip.id.in_(ids), *clauses)).scalars().all()
tags = _tags_for(session, [r.id for r in rows])
_CAND_COLS = (Clip.id, Clip.creator, Clip.likes, Clip.published_at, Clip.niches)
def _rows_to_candidates(session: Session, rows, *, with_tags: bool) -> list[cf.Candidate]:
"""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 [
cf.Candidate(
id=r.id, creator=r.creator, likes=r.likes, published_at=r.published_at,
tags=tags.get(r.id, []), niches=list(r.niches or []),
)
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 []))
for r in rows
]
def _explore_window(session: Session, pk: uuid.UUID) -> tuple[list[uuid.UUID], bool]:
"""Kolejne `_EXPLORE_WINDOW` id po `pk`; gdy okno się kończy, zawija do zera."""
ids = [
r[0]
for r in session.execute(
select(Clip.id).where(Clip.id > pk, Clip.dead_at.is_(None)).order_by(Clip.id).limit(_EXPLORE_WINDOW)
)
]
wrapped = False
if len(ids) < _EXPLORE_WINDOW and pk != _UUID_ZERO:
wrapped = True
more = [
r[0]
for r in session.execute(
select(Clip.id).where(Clip.dead_at.is_(None)).order_by(Clip.id).limit(_EXPLORE_WINDOW - len(ids))
)
]
ids.extend(more)
return ids, wrapped
def _candidates(session: Session, ids: list[uuid.UUID], clauses: list) -> list[cf.Candidate]:
if not ids:
return []
rows = session.execute(select(*_CAND_COLS).where(Clip.id.in_(ids), *clauses)).all()
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)
).subquery()
# Postgres nie ma max(uuid); tekstowo porządek jest ten sam (małe hex, stały format).
end_txt, n = session.execute(
select(func.max(cast(window.c.id, String)), func.count()).select_from(window)
).one()
if not n:
return [], _UUID_ZERO, True
end_id = uuid.UUID(end_txt)
rows = session.execute(
select(*_CAND_COLS).where(Clip.id.in_(select(window.c.id)), *clauses).order_by(Clip.id).limit(limit)
).all()
last = n < _EXPLORE_WINDOW
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]:
@ -344,19 +352,19 @@ def clip_feed(
taken = {c.id for c in picked_aff}
# 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] = []
wrapped = False
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):
ids, w = _explore_window(session, next_pk)
wrapped = wrapped or w
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]
pool, next_pk, last = _explore_candidates(session, next_pk, clauses, limit=max(60, want * 15))
pool = [c for c in pool if c.id not in taken]
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
page_items = cf.compose_page(picked_aff, picked_explore, rng)