perf: стенд для профилирования (postgres, api, prod-сборка фронта)
Refs #202 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b4b2d1bc8b
commit
63f464dfae
10
.gitignore
vendored
10
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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
22
perf/README.md
Normal 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
15
perf/docker-compose.yml
Normal 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
6
perf/requirements.txt
Normal file
@ -0,0 +1,6 @@
|
||||
httpx
|
||||
asyncpg
|
||||
websockets
|
||||
argon2-cffi
|
||||
pytest
|
||||
greenlet
|
||||
92
perf/stand.sh
Executable file
92
perf/stand.sh
Executable 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
36
perf/stand/bootstrap.py
Normal 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
48
perf/stand/smoke.py
Normal 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)
|
||||
23
perf/web/rsbuild.perf.config.ts
Normal file
23
perf/web/rsbuild.perf.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user