perf: стенд для профилирования (postgres, api, prod-сборка фронта)

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-25 21:06:41 +04:00
parent b4b2d1bc8b
commit 63f464dfae
9 changed files with 258 additions and 0 deletions

10
.gitignore vendored
View File

@ -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

View File

@ -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` | Файл стенда не попадёт в коммит |

22
perf/README.md Normal file
View File

@ -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` не нужен.

15
perf/docker-compose.yml Normal file
View File

@ -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:

6
perf/requirements.txt Normal file
View File

@ -0,0 +1,6 @@
httpx
asyncpg
websockets
argon2-cffi
pytest
greenlet

92
perf/stand.sh Executable file
View File

@ -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

36
perf/stand/bootstrap.py Normal file
View File

@ -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())

48
perf/stand/smoke.py Normal file
View File

@ -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 "<script" in r.text, f"web /: {r.status_code}")
r = c.get(f"{WEB}/api/v1/users/me", headers={"Authorization": f"Bearer {token}"})
check(r.status_code == 200, f"proxy users/me: {r.status_code} {r.text[:200]}")
asyncio.run(ws_check(token))
print("SMOKE OK")
if __name__ == "__main__":
try:
main()
except Exception as e: # noqa: BLE001
print(f"SMOKE FAIL: {type(e).__name__}: {e}")
sys.exit(1)

View File

@ -0,0 +1,23 @@
import path from 'node:path';
import { mergeRsbuildConfig } from '../../web/node_modules/@rsbuild/core';
import baseConfig from '../../web/rsbuild.config';
const profiling = process.env.PERF_PROFILING === '1';
const root = path.resolve(__dirname, '..', '.build', profiling ? 'profiling' : 'prod');
export default mergeRsbuildConfig(baseConfig, {
output: {
distPath: { root },
},
resolve: profiling ? { alias: { 'react-dom$': 'react-dom/profiling' } } : {},
server: {
port: 8080,
proxy: {
'/api': {
target: 'http://localhost:8000',
ws: true,
changeOrigin: true,
},
},
},
});