perf: bound public activity aggregation
This commit is contained in:
@@ -10,6 +10,8 @@ from sqlalchemy.orm import Session, joinedload
|
|||||||
from .models import CatchReport, ModerationStatus
|
from .models import CatchReport, ModerationStatus
|
||||||
from .schemas import ActivityOut
|
from .schemas import ActivityOut
|
||||||
|
|
||||||
|
MAX_ACTIVITY_FACTS = 10_000
|
||||||
|
|
||||||
|
|
||||||
def activity_rows(
|
def activity_rows(
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -33,6 +35,8 @@ def activity_rows(
|
|||||||
CatchReport.spot_id.is_not(None),
|
CatchReport.spot_id.is_not(None),
|
||||||
CatchReport.reported_at >= now - timedelta(hours=hours),
|
CatchReport.reported_at >= now - timedelta(hours=hours),
|
||||||
)
|
)
|
||||||
|
.order_by(CatchReport.reported_at.desc(), CatchReport.id.desc())
|
||||||
|
.limit(MAX_ACTIVITY_FACTS)
|
||||||
)
|
)
|
||||||
if waterbody:
|
if waterbody:
|
||||||
query = query.where(CatchReport.waterbody.has(slug=waterbody))
|
query = query.where(CatchReport.waterbody.has(slug=waterbody))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ TACKLE_MEDIA_ROLES = {"tackle_card", "tackle_detail", "rig_diagram", "tackle_scr
|
|||||||
KNOWN_MEDIA_ROLES = WATERBODY_MEDIA_ROLES | TACKLE_MEDIA_ROLES
|
KNOWN_MEDIA_ROLES = WATERBODY_MEDIA_ROLES | TACKLE_MEDIA_ROLES
|
||||||
MEDIA_ROLES_BY_ENTITY = {"waterbody": WATERBODY_MEDIA_ROLES, "tackle": TACKLE_MEDIA_ROLES}
|
MEDIA_ROLES_BY_ENTITY = {"waterbody": WATERBODY_MEDIA_ROLES, "tackle": TACKLE_MEDIA_ROLES}
|
||||||
_manifest_cache: tuple[Path, int, int, dict] | None = None
|
_manifest_cache: tuple[Path, int, int, dict] | None = None
|
||||||
|
_asset_index_cache: tuple[int, dict[str, tuple[str, str | None]]] | None = None
|
||||||
|
|
||||||
|
|
||||||
def _read_manifest() -> dict:
|
def _read_manifest() -> dict:
|
||||||
@@ -107,21 +108,23 @@ def published_file(digest: str) -> tuple[Path, str] | None:
|
|||||||
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
|
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
|
||||||
return None
|
return None
|
||||||
manifest = _read_manifest()
|
manifest = _read_manifest()
|
||||||
item = next((row for row in manifest.get("assets", []) if row.get("status") == "approved" and row.get("sha256") == digest), None)
|
global _asset_index_cache
|
||||||
media_type = None
|
cache_key = id(manifest)
|
||||||
local_path = None
|
if _asset_index_cache is None or _asset_index_cache[0] != cache_key:
|
||||||
if item:
|
index: dict[str, tuple[str, str | None]] = {}
|
||||||
media_type = item.get("content_type")
|
|
||||||
local_path = item.get("local_path")
|
|
||||||
else:
|
|
||||||
for row in manifest.get("assets", []):
|
for row in manifest.get("assets", []):
|
||||||
if row.get("status") != "approved":
|
if row.get("status") != "approved":
|
||||||
continue
|
continue
|
||||||
variant = next((candidate for candidate in row.get("derivatives", []) if candidate.get("sha256") == digest), None)
|
if row.get("sha256") and row.get("local_path"):
|
||||||
if variant:
|
index[str(row["sha256"])] = (str(row["local_path"]), row.get("content_type"))
|
||||||
media_type = variant.get("content_type")
|
for variant in row.get("derivatives", []):
|
||||||
local_path = variant.get("local_path")
|
if variant.get("sha256") and variant.get("local_path"):
|
||||||
break
|
index.setdefault(str(variant["sha256"]), (str(variant["local_path"]), variant.get("content_type")))
|
||||||
|
_asset_index_cache = (cache_key, index)
|
||||||
|
asset = _asset_index_cache[1].get(digest)
|
||||||
|
if asset is None:
|
||||||
|
return None
|
||||||
|
local_path, media_type = asset
|
||||||
if not local_path:
|
if not local_path:
|
||||||
return None
|
return None
|
||||||
target = (MEDIA_ROOT / local_path).resolve()
|
target = (MEDIA_ROOT / local_path).resolve()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from ..schemas import TackleCombinationOut
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
FRESHNESS_HALF_LIFE_HOURS = 12.5
|
FRESHNESS_HALF_LIFE_HOURS = 12.5
|
||||||
|
MAX_ANALYTICS_FACTS = 10_000
|
||||||
|
|
||||||
|
|
||||||
def _age_hours(value: datetime, now: datetime) -> float:
|
def _age_hours(value: datetime, now: datetime) -> float:
|
||||||
@@ -53,6 +54,7 @@ def tackle_combinations(
|
|||||||
query = query.join(Fish, CatchReport.fish_id == Fish.id).where(Fish.slug == fish)
|
query = query.join(Fish, CatchReport.fish_id == Fish.id).where(Fish.slug == fish)
|
||||||
if method:
|
if method:
|
||||||
query = query.where(CatchReport.fishing_method == method)
|
query = query.where(CatchReport.fishing_method == method)
|
||||||
|
query = query.order_by(CatchReport.caught_at.desc(), CatchReport.reported_at.desc(), CatchReport.id.desc()).limit(MAX_ANALYTICS_FACTS)
|
||||||
|
|
||||||
groups: dict[tuple[str, str], list[CatchReport]] = defaultdict(list)
|
groups: dict[tuple[str, str], list[CatchReport]] = defaultdict(list)
|
||||||
for report in db.scalars(query):
|
for report in db.scalars(query):
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ R-пункты уточняют критерии существующих B/G/U/
|
|||||||
- [x] **R11 · P2 · Честное качество и происхождение media (B25).** API использует точный hostname allowlist для `rf4db`/`rf4map`/`rf4-stat`, неизвестные и malformed URL получают `unknown`, а не ложный `rf4-official`. UI различает низкое разрешение и наличие проверенной card-версии, не утверждая отсутствие альтернатив без данных manifest. Cache/revocation и production visual acceptance остаются отдельными gates.
|
- [x] **R11 · P2 · Честное качество и происхождение media (B25).** API использует точный hostname allowlist для `rf4db`/`rf4map`/`rf4-stat`, неизвестные и malformed URL получают `unknown`, а не ложный `rf4-official`. UI различает низкое разрешение и наличие проверенной card-версии, не утверждая отсутствие альтернатив без данных manifest. Cache/revocation и production visual acceptance остаются отдельными gates.
|
||||||
- [x] **R14 · P2 · Подтверждённый каталог и demo seed.** `SEED_DEMO_DATA=false` больше не создаёт редакционные описания тестовых точек; demo-уловы имеют отдельный `seed:` namespace и catalog audit считает их отдельно от подтверждённых reports. Loaded canonical counts и external staging остаются раздельными, media count не используется как catalog count.
|
- [x] **R14 · P2 · Подтверждённый каталог и demo seed.** `SEED_DEMO_DATA=false` больше не создаёт редакционные описания тестовых точек; demo-уловы имеют отдельный `seed:` namespace и catalog audit считает их отдельно от подтверждённых reports. Loaded canonical counts и external staging остаются раздельными, media count не используется как catalog count.
|
||||||
- [x] **R15 · P2 · Восстановление пользовательских действий.** Report UI теперь различает idempotency-конфликт и обычную ошибку, восстанавливает draft и предлагает повтор с новым ключом; plan получил восьмисекундный undo после удаления с обработкой storage errors. Draft сохраняется при 422/429/timeout, повтор не требует заново заполнять весь улов.
|
- [x] **R15 · P2 · Восстановление пользовательских действий.** Report UI теперь различает idempotency-конфликт и обычную ошибку, восстанавливает draft и предлагает повтор с новым ключом; plan получил восьмисекундный undo после удаления с обработкой storage errors. Draft сохраняется при 422/429/timeout, повтор не требует заново заполнять весь улов.
|
||||||
- [ ] **R16 · P2 · Производительность реальных endpoints.** Первый шаг выполнен: public media catalog использует process-local digest/mtime cache manifest с инвалидированием при изменении файла, API отдаёт короткий `Cache-Control` и manifest version. Осталось измерить ORM/RSS/SQL на крупных fixtures, завершить bounded aggregation и зафиксировать воспроизводимый endpoint budget; Redis заранее не добавлять.
|
- [ ] **R16 · P2 · Производительность реальных endpoints.** Первый шаг выполнен: public media catalog использует process-local digest/mtime cache manifest с инвалидированием при изменении файла, API отдаёт короткий `Cache-Control` и manifest version. Теперь `published_file` использует revision-local digest index без повторного сканирования assets, а activity/tackle analytics ограничены воспроизводимым окном 10 000 свежих фактов. Осталось измерить ORM/RSS/SQL на крупных fixtures и зафиксировать endpoint budget; Redis заранее не добавлять.
|
||||||
- [x] **R17 · P2 · Навигация и SEO снастей.** Sitemap теперь включает `/tackle`, все пагинированные canonical item/rig detail URL и не добавляет фильтры/analytics; detail layout оставляет missing/unavailable/404 страницы noindex, а каталог ведёт в canonical карточки. При недоступном API сохраняется последний корректный sitemap.
|
- [x] **R17 · P2 · Навигация и SEO снастей.** Sitemap теперь включает `/tackle`, все пагинированные canonical item/rig detail URL и не добавляет фильтры/analytics; detail layout оставляет missing/unavailable/404 страницы noindex, а каталог ведёт в canonical карточки. При недоступном API сохраняется последний корректный sitemap.
|
||||||
- [ ] **R12 · P2 · Визуальная приёмка наполненных состояний (D07/A06/U06/G09).** Изолированные данные: длинные карточки, компоненты, конфликты, ошибки, authenticated admin; light/dark mobile/desktop, keyboard/contrast и screenshots с revision. Критерий: подтверждены действия и читаемость, а не только DOM/overflow.
|
- [ ] **R12 · P2 · Визуальная приёмка наполненных состояний (D07/A06/U06/G09).** Изолированные данные: длинные карточки, компоненты, конфликты, ошибки, authenticated admin; light/dark mobile/desktop, keyboard/contrast и screenshots с revision. Критерий: подтверждены действия и читаемость, а не только DOM/overflow.
|
||||||
- [ ] **R13 · P2 · Зелёные проверки и CI artifacts.** Обновить два устаревших контракта, адресные regressions исправленных дефектов, успешные visual artifacts, Node/Python baseline и отдельный dependency audit. Критерий: suites зелёные, skip объяснён, отчёт связан с commit. Известные падения учитывать при каждом feature-коммите.
|
- [ ] **R13 · P2 · Зелёные проверки и CI artifacts.** Обновить два устаревших контракта, адресные regressions исправленных дефектов, успешные visual artifacts, Node/Python baseline и отдельный dependency audit. Критерий: suites зелёные, skip объяснён, отчёт связан с commit. Известные падения учитывать при каждом feature-коммите.
|
||||||
|
|||||||
@@ -22,6 +22,13 @@
|
|||||||
|
|
||||||
## Измеримый бюджет
|
## Измеримый бюджет
|
||||||
|
|
||||||
|
Activity и tackle analytics намеренно обрабатывают не более 10 000 самых
|
||||||
|
свежих approved-фактов за окно запроса. Это bounded budget для Python-агрегации:
|
||||||
|
сортировка и уникальный `id` делают срез воспроизводимым, а публичные ответы не
|
||||||
|
могут внезапно потребить память пропорционально всей истории. При превышении
|
||||||
|
лимита production measurement должен отдельно показать необходимость SQL/RSS
|
||||||
|
агрегации, а не расширять этот предел вслепую.
|
||||||
|
|
||||||
На сервере альфа-пилота при объёме до 100 000 уловов и до 100 000 staging-наблюдений принимаются следующие server-side цели без учёта сети и браузерного рендера:
|
На сервере альфа-пилота при объёме до 100 000 уловов и до 100 000 staging-наблюдений принимаются следующие server-side цели без учёта сети и браузерного рендера:
|
||||||
|
|
||||||
- p95 публичных списков и activity — не более 250 мс;
|
- p95 публичных списков и activity — не более 250 мс;
|
||||||
|
|||||||
Reference in New Issue
Block a user