diff --git a/.gitignore b/.gitignore index c1ccd56..6eb9d66 100644 --- a/.gitignore +++ b/.gitignore @@ -331,3 +331,13 @@ my_project_env .superpowers/ .bounded .claude/worktrees/ + +# perf-стенд +perf/results/** +perf/**/*trace*.json +*.heapsnapshot +perf/.build/ +perf/.venv/ +perf/e2e/node_modules/ +perf/.run/ +!perf/docker-compose.yml diff --git a/openspec/changes/perf-profiling/design.md b/openspec/changes/perf-profiling/design.md index f577fd6..85d6c9a 100644 --- a/openspec/changes/perf-profiling/design.md +++ b/openspec/changes/perf-profiling/design.md @@ -103,3 +103,9 @@ | 2026-10-25 | Добавлен сценарий «скролл при одном фоновом соавторе» (1 и 5 правок/с), но не нагрузочный тест | Совместная работа — основной режим системы, входящие WS-события перерисовывают таблицу | +1 сценарий; если не влияет — так и будет написано | | 2026-10-25 | Своды 1, 2 и сводные проектов — в скоупе вторым приоритетом | Пользователь может называть свод «формой»; это тяжёлые таблицы | Больше работы по данным и сценариям | | 2026-10-25 | Postgres 16 | Версия прод-БД неизвестна; 16 — актуальная стабильная | Мелкие расхождения планов запросов с продом | +| 2026-10-25 | R-1 (design #11): Task 3 заполняет `svods`/`modal_pages` манифеста по маршрутам из Routes.jsx | Иначе сценарии T5/T7 не получат список страниц | Доработка T3 при T5/T7 | +| 2026-10-25 | Email админа стенда `admin@perf-stand.com` вместо `admin@perf.local` | `/api/v1/users/me` падает 500: email-validator отклоняет `.local` в response_model | Другой email у админа стенда; на замеры не влияет | +| 2026-10-25 | В `perf/requirements.txt` добавлен `greenlet` | `api/requirements-dev.txt` его не содержит, alembic на asyncpg падает без него | Лишняя зависимость в venv стенда | +| 2026-10-25 | `perf/web/rsbuild.perf.config.ts` импортирует `@rsbuild/core` по относительному пути `../../web/node_modules/@rsbuild/core` | Конфиг в `perf/` не видит `web/node_modules` через резолв пакетов | Путь ломается при смене раскладки каталогов | +| 2026-10-25 | В `.gitignore` шаблон для трейсов `perf/**/*trace*.json` | Формулировка «*.json trace» неоднозначна | Лишний или пропущенный файл в игноре | +| 2026-10-25 | В `.gitignore` добавлено исключение `!perf/docker-compose.yml` | Корневой `.gitignore` игнорирует `docker-compose.yml` | Файл стенда не попадёт в коммит | diff --git a/perf/README.md b/perf/README.md new file mode 100644 index 0000000..e9c3879 --- /dev/null +++ b/perf/README.md @@ -0,0 +1,22 @@ +# Профилирование: perf/ + +## Стенд + +Требования: Docker, python3.12, Node 24, npm. + +``` +perf/stand.sh all # up, migrate, bootstrap, web-build, api, web, smoke +perf/stand.sh down # остановить бэк, фронт и Postgres +``` + +Подкоманды: `up | migrate | bootstrap | api | web-build | web | smoke | down | all`. + +- Postgres 16 (`pg_stat_statements`) на `localhost:55432`, `perf/perf/perf`. +- Бэк (`DEBUG=true`) на `http://localhost:8000`, логи и PID в `perf/.run/`. +- Фронт (prod-сборка, `rsbuild preview`) на `http://localhost:8080`; `/api` (включая WebSocket) проксируется на бэк. +- Профилирующая сборка (`PERF_PROFILING=1`, `react-dom/profiling`) лежит в `perf/.build/profiling`, обычная в `perf/.build/prod`. + Для запуска preview с профилирующей сборкой: `PERF_PROFILING=1 perf/stand.sh web` (после остановки обычного `web`). +- Админ: `admin` / `admin`. +- Переменные: `PERF_API`, `PERF_WEB`, `PERF_DB_DSN`. +- Зависимости Python ставятся в `perf/.venv` (`api/requirements-dev.txt` + `perf/requirements.txt`). +- npm берёт пакеты с `https://registry.npmjs.org` (`npm_config_registry`), `web/.npmrc` не нужен. diff --git a/perf/docker-compose.yml b/perf/docker-compose.yml new file mode 100644 index 0000000..ca8d591 --- /dev/null +++ b/perf/docker-compose.yml @@ -0,0 +1,15 @@ +services: + pg: + image: postgres:16 + container_name: perf-pg + ports: + - "55432:5432" + environment: + POSTGRES_USER: perf + POSTGRES_PASSWORD: perf + POSTGRES_DB: perf + command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all + volumes: + - perf-pg:/var/lib/postgresql/data +volumes: + perf-pg: diff --git a/perf/requirements.txt b/perf/requirements.txt new file mode 100644 index 0000000..9c52c6e --- /dev/null +++ b/perf/requirements.txt @@ -0,0 +1,6 @@ +httpx +asyncpg +websockets +argon2-cffi +pytest +greenlet diff --git a/perf/stand.sh b/perf/stand.sh new file mode 100755 index 0000000..826d944 --- /dev/null +++ b/perf/stand.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Стенд профилирования: up | migrate | bootstrap | api | web-build | web | smoke | down | all +set -euo pipefail +cd "$(dirname "$0")/.." +ROOT="$(pwd)" +PERF="$ROOT/perf" +RUN="$PERF/.run" +VENV="$PERF/.venv" +mkdir -p "$RUN" + +export PERF_API="${PERF_API:-http://localhost:8000}" +export PERF_WEB="${PERF_WEB:-http://localhost:8080}" +export PERF_DB_DSN="${PERF_DB_DSN:-postgresql://perf:perf@localhost:55432/perf}" +export DATABASE_URL="postgresql+asyncpg://perf:perf@localhost:55432/perf" +export DEBUG=true +export npm_config_registry="${npm_config_registry:-https://registry.npmjs.org}" + +venv() { + if [ ! -x "$VENV/bin/python" ]; then + python3.12 -m venv "$VENV" + "$VENV/bin/pip" install -q -r "$ROOT/api/requirements-dev.txt" -r "$PERF/requirements.txt" + fi +} + +start_bg() { # name, workdir, cmd... + local name="$1" dir="$2"; shift 2 + if [ -f "$RUN/$name.pid" ] && kill -0 "$(cat "$RUN/$name.pid")" 2>/dev/null; then + echo "$name уже запущен (pid $(cat "$RUN/$name.pid"))"; return + fi + (cd "$dir" && nohup "$@" >"$RUN/$name.log" 2>&1 & echo $! >"$RUN/$name.pid") +} + +wait_http() { # url, tries + for _ in $(seq 1 "${2:-60}"); do + curl -fsS -o /dev/null "$1" 2>/dev/null && return 0 + sleep 1 + done + echo "не дождались $1" >&2; return 1 +} + +cmd="${1:-}" +case "$cmd" in + up) + docker compose -f "$PERF/docker-compose.yml" up -d pg + for _ in $(seq 1 60); do + docker exec perf-pg pg_isready -U perf -d perf >/dev/null 2>&1 && break + sleep 1 + done + docker exec perf-pg pg_isready -U perf -d perf + ;; + migrate) + venv + (cd "$ROOT/api" && "$VENV/bin/alembic" upgrade head) + ;; + bootstrap) + venv + "$VENV/bin/python" "$PERF/stand/bootstrap.py" + ;; + api) + venv + start_bg api "$ROOT/api" "$VENV/bin/python" run_server.py + wait_http "$PERF_API/healthz" 60 + ;; + web-build) + (cd "$ROOT/web" && npm ci && npx rsbuild build -c ../perf/web/rsbuild.perf.config.ts \ + && PERF_PROFILING=1 npx rsbuild build -c ../perf/web/rsbuild.perf.config.ts) + ;; + web) + start_bg web "$ROOT/web" npx rsbuild preview -c ../perf/web/rsbuild.perf.config.ts + wait_http "$PERF_WEB/" 60 + ;; + smoke) + venv + "$VENV/bin/python" "$PERF/stand/smoke.py" + ;; + down) + for n in web api; do + if [ -f "$RUN/$n.pid" ]; then + pkill -P "$(cat "$RUN/$n.pid")" 2>/dev/null || true + kill "$(cat "$RUN/$n.pid")" 2>/dev/null || true + rm -f "$RUN/$n.pid" + fi + done + docker compose -f "$PERF/docker-compose.yml" down + ;; + all) + "$0" up; "$0" migrate; "$0" bootstrap; "$0" web-build; "$0" api; "$0" web; "$0" smoke + ;; + *) + echo "usage: $0 up|migrate|bootstrap|api|web-build|web|smoke|down|all" >&2; exit 2 + ;; +esac diff --git a/perf/stand/bootstrap.py b/perf/stand/bootstrap.py new file mode 100644 index 0000000..49cae5f --- /dev/null +++ b/perf/stand/bootstrap.py @@ -0,0 +1,36 @@ +"""Идемпотентная подготовка БД стенда: расширение pg_stat_statements и админ admin/admin.""" +import asyncio +import os + +import asyncpg +from argon2 import PasswordHasher + +# .local отклоняется email-validator в ответе /users/me (500), поэтому обычный домен +EMAIL = "admin@perf-stand.com" +DSN = os.environ.get("PERF_DB_DSN", "postgresql://perf:perf@localhost:55432/perf") + + +async def main() -> None: + conn = await asyncpg.connect(DSN) + try: + await conn.execute("CREATE EXTENSION IF NOT EXISTS pg_stat_statements") + # Хеш в формате src/core/security.py (argon2-cffi PasswordHasher() по умолчанию) + hashed = PasswordHasher().hash("admin") + exists = await conn.fetchval("SELECT id FROM v3.app_user WHERE username = 'admin'") + if exists: + await conn.execute( + "UPDATE v3.app_user SET hashed_password=$1, email=$2, is_active=true, role_id=1 WHERE id=$3", + hashed, EMAIL, exists, + ) + else: + await conn.execute( + "INSERT INTO v3.app_user (email, username, hashed_password, full_name, role_id, is_active) " + "VALUES ($2, 'admin', $1, 'Perf Admin', 1, true)", + hashed, EMAIL, + ) + print("bootstrap OK") + finally: + await conn.close() + + +asyncio.run(main()) diff --git a/perf/stand/smoke.py b/perf/stand/smoke.py new file mode 100644 index 0000000..801e0cd --- /dev/null +++ b/perf/stand/smoke.py @@ -0,0 +1,48 @@ +"""Smoke стенда: бэк, логин, фронт, прокси /api и прокси WebSocket.""" +import asyncio +import json +import os +import sys + +import httpx +import websockets + +API = os.environ.get("PERF_API", "http://localhost:8000") +WEB = os.environ.get("PERF_WEB", "http://localhost:8080") + + +def check(cond: bool, msg: str) -> None: + if not cond: + raise AssertionError(msg) + + +async def ws_check(token: str) -> None: + url = WEB.replace("http", "ws", 1) + "/api/v1/ws/export" + async with websockets.connect(url, open_timeout=10) as ws: + await ws.send(json.dumps({"token": token})) + msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=10)) + check(msg.get("event") == "exports", f"ws: неожиданное сообщение {str(msg)[:200]}") + + +def main() -> None: + with httpx.Client(timeout=15) as c: + r = c.get(f"{API}/healthz") + check(r.status_code == 200, f"healthz: {r.status_code}") + r = c.post(f"{API}/api/v1/auth/login", json={"username": "admin", "password": "admin"}) + check(r.status_code == 200, f"login: {r.status_code} {r.text[:200]}") + token = r.json().get("access_token") + check(bool(token), "login: нет access_token") + r = c.get(f"{WEB}/") + check(r.status_code == 200 and "