From 538a72107e64c1e2d22736ef630872810a3c6ea7 Mon Sep 17 00:00:00 2001 From: goon-foss Date: Mon, 14 Sep 2026 20:34:19 +0200 Subject: [PATCH] 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). --- app/api/clips.py | 82 ++++++++++++++++++++++++++---------------------- 1 file changed, 45 insertions(+), 37 deletions(-) diff --git a/app/api/clips.py b/app/api/clips.py index a5f17f3..70c8237 100644 --- a/app/api/clips.py +++ b/app/api/clips.py @@ -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)