perf: stabilize list queries for pilot
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s

This commit is contained in:
ik
2026-09-07 08:02:37 +07:00
parent c45b4511b7
commit 5e27a5b066
7 changed files with 123 additions and 27 deletions
+1 -1
View File
@@ -87,7 +87,7 @@ docker compose up --build
## Что реализовано
- FastAPI и SQLAlchemy 2;
- PostgreSQL 17 и миграции Alembic до `0010`;
- PostgreSQL 17 и миграции Alembic до `0011`;
- идемпотентный seed с двумя точками и свежими демо-уловами;
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
- `GET /api/v1/spots/{id}` и `/catches`;
@@ -0,0 +1,29 @@
"""Add composite indexes for pilot list and maintenance queries."""
from alembic import op
revision = "0011"
down_revision = "0010"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index("ix_catch_report_activity_lookup", "catch_report", ["moderation_status", "deleted_at", "reported_at"])
op.create_index("ix_catch_report_spot_feed", "catch_report", ["spot_id", "moderation_status", "deleted_at", "reported_at"])
op.create_index("ix_catch_report_moderation_queue", "catch_report", ["source_type", "moderation_status", "deleted_at", "reported_at"])
op.create_index("ix_catch_report_official_records", "catch_report", ["source_type", "caught_at", "weight_g"])
op.create_index("ix_official_import_source_status_started", "official_record_import", ["source_url", "status", "started_at"])
op.create_index("ix_external_observation_review_queue", "external_observation", ["status", "source_system", "last_seen_at"])
op.create_index("ix_submission_attempt_client_created", "submission_attempt", ["client_hash", "created_at"])
op.create_index("ix_moderation_event_created_at", "moderation_event", ["created_at"])
def downgrade() -> None:
op.drop_index("ix_moderation_event_created_at", table_name="moderation_event")
op.drop_index("ix_submission_attempt_client_created", table_name="submission_attempt")
op.drop_index("ix_external_observation_review_queue", table_name="external_observation")
op.drop_index("ix_official_import_source_status_started", table_name="official_record_import")
op.drop_index("ix_catch_report_official_records", table_name="catch_report")
op.drop_index("ix_catch_report_moderation_queue", table_name="catch_report")
op.drop_index("ix_catch_report_spot_feed", table_name="catch_report")
op.drop_index("ix_catch_report_activity_lookup", table_name="catch_report")
+21 -16
View File
@@ -94,18 +94,18 @@ def ready(db: Db) -> JSONResponse:
@app.get("/api/v1/fishes", response_model=list[FishOut])
def fishes(db: Db) -> list[Fish]:
return list(db.scalars(select(Fish).order_by(Fish.name_ru)))
def fishes(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Fish]:
return list(db.scalars(select(Fish).order_by(Fish.name_ru, Fish.id).offset(offset).limit(limit)))
@app.get("/api/v1/waterbodies", response_model=list[WaterbodyOut])
def waterbodies(db: Db) -> list[Waterbody]:
return list(db.scalars(select(Waterbody).order_by(Waterbody.name_ru)))
def waterbodies(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Waterbody]:
return list(db.scalars(select(Waterbody).order_by(Waterbody.name_ru, Waterbody.id).offset(offset).limit(limit)))
@app.get("/api/v1/baits", response_model=list[BaitOut])
def baits(db: Db) -> list[Bait]:
return list(db.scalars(select(Bait).order_by(Bait.name)))
def baits(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Bait]:
return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit)))
@app.get("/api/v1/activity", response_model=list[ActivityOut])
@@ -119,7 +119,11 @@ def activity(
if hours not in {6, 12, 24, 72}:
raise HTTPException(status_code=422, detail="hours must be one of: 6, 12, 24, 72")
rows = activity_rows(db, hours=hours, waterbody=waterbody, fish=fish, method=method)
keys = {"activity": lambda r: r.activity_score, "confidence": lambda r: r.confidence_score, "freshness": lambda r: r.last_confirmed_at}
keys = {
"activity": lambda r: (r.activity_score, r.confidence_score, r.last_confirmed_at, str(r.spot_id)),
"confidence": lambda r: (r.confidence_score, r.activity_score, r.last_confirmed_at, str(r.spot_id)),
"freshness": lambda r: (r.last_confirmed_at, r.activity_score, r.confidence_score, str(r.spot_id)),
}
rows.sort(key=keys[sort], reverse=True)
return rows[offset:offset + limit]
@@ -145,7 +149,7 @@ def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
@app.get("/api/v1/spots/{spot_id}/catches", response_model=list[CatchOut])
def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]:
_spot_or_404(db, spot_id)
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc()).offset(offset).limit(limit)))
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed) for r in reports]
@@ -160,9 +164,9 @@ def records(
query = query.join(CatchReport.fish).where(Fish.slug == fish)
if waterbody:
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
items = list(db.scalars(query.order_by(CatchReport.caught_at.desc(), CatchReport.weight_g.desc()).offset(offset).limit(limit)))
if category:
items = [item for item in items if (item.raw_payload or {}).get("category") == category]
query = query.where(CatchReport.raw_payload["category"].as_string() == category)
items = list(db.scalars(query.order_by(CatchReport.caught_at.desc(), CatchReport.weight_g.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
return [OfficialRecordOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, waterbody=r.waterbody.name_ru, bait=r.bait.name if r.bait else None, player_name=r.player_name, record_date=r.caught_at, category=(r.raw_payload or {}).get("category"), region=(r.raw_payload or {}).get("region"), source_url=r.source_url) for r in items]
@@ -174,8 +178,8 @@ def _admin(authorization: Annotated[str | None, Header()] = None) -> str:
@app.get("/api/v1/imports", response_model=list[ImportRunOut])
def imports(db: Db, limit: int = Query(20, ge=1, le=100)) -> list[OfficialRecordImport]:
return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc()).limit(limit)))
def imports(db: Db, limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[OfficialRecordImport]:
return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc()).offset(offset).limit(limit)))
@app.get("/api/v1/admin/imports", response_model=list[ImportRunOut])
@@ -221,7 +225,8 @@ def _external_out(item: ExternalObservation) -> ExternalObservationOut:
@app.get("/api/v1/admin/external-observations", response_model=list[ExternalObservationOut])
def admin_external_observations(
db: Db, _: Annotated[str, Depends(_admin)], status: str | None = None,
db: Db, _: Annotated[str, Depends(_admin)],
status: Literal["staged", "mapped", "ready", "published", "rejected"] | None = None,
source_system: str | None = None, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0),
) -> list[ExternalObservationOut]:
query = select(ExternalObservation).options(
@@ -231,7 +236,7 @@ def admin_external_observations(
query = query.where(ExternalObservation.status == status)
if source_system:
query = query.where(ExternalObservation.source_system == source_system)
items = db.scalars(query.order_by(ExternalObservation.last_seen_at.desc()).offset(offset).limit(limit))
items = db.scalars(query.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc()).offset(offset).limit(limit))
return [_external_out(item) for item in items]
@@ -332,8 +337,8 @@ def add_screenshot(
@app.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut])
def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100)) -> list[AdminCatchReportOut]:
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at).limit(limit)))
def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[AdminCatchReportOut]:
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at, CatchReport.id).offset(offset).limit(limit)))
return [AdminCatchReportOut(id=r.id, fish=r.fish.name_ru, waterbody=r.waterbody.name_ru, coordinates=f"{r.spot.x}:{r.spot.y}" if r.spot else "", weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, reported_at=r.reported_at, moderation_status=r.moderation_status.value, comment=(r.raw_payload or {}).get("comment"), screenshot_url=signed_screenshot_url(r.screenshot_key) if r.screenshot_key else None) for r in reports]
+24
View File
@@ -56,6 +56,14 @@ def test_invalid_period_is_rejected() -> None:
assert client.get("/api/v1/activity?sort=unknown").status_code == 422
def test_list_pagination_and_filter_validation() -> None:
assert client.get("/api/v1/fishes?limit=0").status_code == 422
assert client.get("/api/v1/fishes?limit=1&offset=0").status_code == 200
headers = {"Authorization": "Bearer change-me-in-production"}
assert client.get("/api/v1/admin/external-observations?status=unknown", headers=headers).status_code == 422
assert client.get("/api/v1/admin/catch-reports?offset=-1", headers=headers).status_code == 422
def test_liveness_does_not_probe_dependencies() -> None:
response = client.get("/health?token=must-not-be-logged")
assert response.json() == {"status": "ok"}
@@ -80,6 +88,22 @@ def test_records_list_is_empty_before_import() -> None:
assert response.json() == []
def test_record_category_filter_is_applied_before_pagination() -> None:
with Session(engine) as db:
fish = db.scalar(select(Fish).where(Fish.slug == "pike"))
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == "test-lake"))
now = datetime.now(timezone.utc) - timedelta(days=30)
db.add_all([
CatchReport(fish=fish, waterbody=waterbody, weight_g=9000, caught_at=now, reported_at=now, source_type=SourceType.official_record, source_confidence=100, moderation_status=ModerationStatus.approved, raw_payload={"category": "other"}),
CatchReport(fish=fish, waterbody=waterbody, weight_g=8000, caught_at=now - timedelta(days=1), reported_at=now, source_type=SourceType.official_record, source_confidence=100, moderation_status=ModerationStatus.approved, raw_payload={"category": "wanted"}),
])
db.commit()
response = client.get("/api/v1/records?category=wanted&limit=1")
assert response.status_code == 200
assert len(response.json()) == 1
assert response.json()[0]["category"] == "wanted"
def test_user_report_requires_moderation_before_activity() -> None:
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "player_name": "Reporter"})
assert created.status_code == 201
+3 -1
View File
@@ -26,7 +26,9 @@ curl -fsS "http://127.0.0.1:$BOOTSTRAP_API_PORT/ready" >/dev/null
curl -fsS "http://127.0.0.1:$BOOTSTRAP_WEB_PORT/" >/dev/null
curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^x-frame-options: DENY'
curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^cross-origin-opener-policy: same-origin'
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')" = "0010"
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')" = "0011"
index_count=$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c "select count(*) from pg_indexes where schemaname = 'public' and indexname in ('ix_catch_report_activity_lookup','ix_catch_report_spot_feed','ix_catch_report_moderation_queue','ix_catch_report_official_records','ix_official_import_source_status_started','ix_external_observation_review_queue','ix_submission_attempt_client_created','ix_moderation_event_created_at')")
test "$index_count" = "8"
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from fish')" = "2"
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from waterbody')" = "2"
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from catch_report')" = "0"
+7 -9
View File
@@ -61,10 +61,10 @@
- [x] Автоматизировать ежедневную цепочку backup → dry-run → retention с блокировкой параллельного запуска; ограничить Docker JSON-логи пятью файлами по 10 МБ на сервис.
- [x] Добавить структурированные JSON-логи без пользовательских секретов и персональных технических данных (whitelist полей, redaction, request ID; Uvicorn access-log отключён).
- [x] Добавить Gitea Actions CI: backend tests, Astro check/build, E2E и применение всех миграций на чистой PostgreSQL; сохранять логи Compose и Playwright-артефакты при падении (`.gitea/workflows/ci.yml`).
- [x] Добавить отдельный тест полного bootstrap: пустые production volumes → миграции `0010` → seed без демо-уловов → readiness → браузерная отправка и проверка moderation API (`deploy/test-production-bootstrap.sh`, 6 сентября 2026).
- [x] Добавить отдельный тест полного bootstrap: пустые production volumes → миграции `0011` → seed без демо-уловов → readiness → браузерная отправка и проверка moderation API (`deploy/test-production-bootstrap.sh`, 7 сентября 2026).
- [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками.
- [ ] Проверить списочные API по требованию раздела 12: пагинация, предсказуемая сортировка и валидация фильтров для справочников, импортов, модерации и внешнего staging.
- [ ] Проверить необходимые индексы PostgreSQL и планы запросов для activity, модерации, дедупликации и очистки rate limit; зафиксировать допустимый бюджет запросов пилота.
- [x] Проверить списочные API по требованию раздела 12: все выдачи имеют ограниченные `limit`/`offset`, детерминированный tie-breaker и типизированные фильтры; фильтр категории рекордов перенесён до пагинации.
- [x] Добавить составные индексы PostgreSQL для activity, модерации, официальных рекордов, staging, импорта, аудита и очистки rate limit (миграция `0011`); бюджет и процедура проверки планов зафиксированы в `docs/query-performance.md`.
- [x] Провести security-проверку admin-аутентификации, CORS, headers, загрузок, контейнерных пользователей и секретов: двойная защита admin web/API, constant-time token, no-store, non-root API/web и отдельные MinIO root/app credentials; остаточные ограничения записаны в `docs/security-review.md`.
- [x] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно.
- [x] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавлены настраиваемая dry-run-first очистка, тест и `docs/data-retention.md`.
@@ -119,12 +119,10 @@
Технический production-контур, health/readiness, backup/restore и безопасные логи готовы. Следующие пункты выполняются строго по одному:
1. защита официального импорта от конкурентных запусков;
2. пагинация/сортировка списочных API и индексы PostgreSQL;
3. UI/UX-пакеты B–D: мобильная главная, форма и рекорды;
4. accessibility/admin safety и Lighthouse;
5. мониторинг, DNS/TLS и проверка production-профиля на целевом сервере;
6. финальное обновление README, лицензия кода и политика данных.
1. UI/UX-пакеты B–D: мобильная главная, форма и рекорды;
2. accessibility/admin safety и Lighthouse;
3. мониторинг, DNS/TLS и проверка production-профиля на целевом сервере;
4. финальное обновление README, лицензия кода и политика данных.
После каждого пункта необходимо:
+38
View File
@@ -0,0 +1,38 @@
# Бюджет запросов для альфа-пилота
Дата фиксации: 7 сентября 2026 года.
## Контракт списочных API
Все списочные endpoint'ы принимают ограниченный `limit` и неотрицательный `offset`. Справочники ограничены 500 строками, публичные и административные журналы — 100–200 строками. Сортировка всегда имеет уникальный `id` последним ключом, поэтому соседние страницы не меняются местами при одинаковых датах или названиях. Значения перечислимых фильтров проверяет FastAPI; неподдерживаемое значение возвращает `422`.
Фильтры применяются в SQL до `offset` и `limit`. Это особенно важно для `GET /api/v1/records?category=...`: фильтрация JSON-поля после пагинации могла возвращать пустую страницу при наличии подходящих записей.
## Индексы
Миграция `0011` добавляет составные индексы для основных путей чтения:
- activity и лента точки — статус модерации, удаление, время и точка;
- очередь модерации — источник, статус, удаление и время;
- официальные рекорды — источник, дата и вес;
- журнал импорта — источник, статус и время запуска;
- staging — статус, система-источник и время последнего наблюдения;
- rate limit — отпечаток клиента и время попытки;
- retention аудита — время события модерации.
## Измеримый бюджет
На сервере альфа-пилота при объёме до 100 000 уловов и до 100 000 staging-наблюдений принимаются следующие server-side цели без учёта сети и браузерного рендера:
- p95 публичных списков и activity — не более 250 мс;
- p95 административных очередей — не более 500 мс;
- один запрос очистки rate limit или retention-пакет — не более 2 с;
- ни один интерактивный запрос не должен читать более 10 000 строк фактов по `Rows Removed by Filter`.
Это стартовый эксплуатационный бюджет, а не результат синтетического бенчмарка. Планы на пустой bootstrap-БД не показательны: PostgreSQL обоснованно выбирает последовательное чтение маленьких таблиц.
## Проверка после загрузки пилотных данных
После наполнения выполнить `EXPLAIN (ANALYZE, BUFFERS)` для activity, moderation queue, records, staging queue и удаления старых submission attempts. Проверять фактическое время, `Rows Removed by Filter`, объём buffers и соответствие выбранного индекса фильтрам. Если таблица превышает 10 000 строк, а план остаётся последовательным и выходит за бюджет, сохранить план в журнал релиза и скорректировать индекс или форму запроса до открытия альфы.
Bootstrap-тест отдельно проверяет, что Alembic дошёл до `0011` и все восемь составных индексов созданы на чистой PostgreSQL.