food-market/docs/performance-baseline.md
nns 97e26a65d5 docs(s12): ARCHITECTURE/MULTI-TENANCY/RUNBOOK/DEVELOPER-GUIDE + k6 baseline + stage-verify CI
Документация для следующего разработчика (4 файла, ~1500 строк по
существу), реальный нагрузочный baseline на stage, и автоматический
smoke на каждый push.

Доки:
- docs/ARCHITECTURE.md — карта слоёв, модулей, Program.cs composition
  root, полный поток signup→post с трассировщиком ASP.NET pipeline.
- docs/MULTI-TENANCY.md — ITenantEntity + reflection query-filter,
  stamping в SaveChanges, SuperAdmin override (read-only + edit-mode
  с reason), 8 подводных камней, чеклист «как добавить tenant-сущность».
- docs/RUNBOOK.md — health-чеки, backup/restore с примером, смена SDK,
  disaster-recovery на новый сервер, 6 описанных инцидентов
  (включая docker-compose project name), БД-troubleshooting.
- docs/DEVELOPER-GUIDE.md — локальный setup, гочи integration-тестов,
  полные паттерны (controller с permission + tenant-сущность с
  RowVersion + 5 шагов миграции), валидация, structured-логирование,
  «НЕ делать» список.

k6 baseline:
- tests/load/ — 3 скрипта (signup-burst, retail-sales-parallel,
  sales-report-heavy) + README с инструкциями.
- docs/performance-baseline.md — реальные цифры на stage:
  * signup p95 446ms @ 50 RPM (IP-лимит 60/мин держит);
  * retail-sale sequential — 17/sec, p95 71ms;
  * retail-sale @ VU>1 — 53% failure из-за race в
    GenerateNumberAsync (unique-violation 23505 не ловится в
    SaveOrFkErrorAsync) — P0 для следующего рефакторинга;
  * reports на 1500 чеков — p95 50-114ms до VU=5.

CI:
- .forgejo/workflows/stage-verify.yml — on workflow_run после Docker
  API/Web, wait-for-ready → tests/stage-smoke.sh → Telegram пинг.
- tests/stage-smoke.sh — 7-секундный bash-смок (curl+jq+python3),
  5 этапов: health, signup, token, multi-tenant изоляция (B → 404
  на product A, B → пустой список), полный документ-цикл
  (supplier+supply.post → stock=100 → sale.post → stock=99).
  Локальный прогон против stage — все этапы зелёные.

Build чистый, локальный прогон smoke зелёный. Sprint 12 закрывает
автономно-безопасный цикл — дальше нужен вход от user'а.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 03:19:25 +05:00

179 lines
9.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Performance baseline — food-market API
Дата прогона: **2026-06-07**. Прогон против stage:
`https://test.admin.food-market.kz`. Инструмент — k6 v0.55.0.
Сетап stage'а на момент замеров:
- 1 контейнер `food-market-stage-api` (Kestrel, .NET 8).
- 1 контейнер `food-market-stage-postgres` (Postgres 16, дефолтные настройки).
- Nginx-фронт на dev-vm `192.168.1.190`, dev-машина k6-генератора в той
же локалке (RTT ~5-20мс).
Все цифры — **с одного клиента**, без воспроизведения пиковой нагрузки
из ЦА продакшна. Это baseline для регрессий, не SLA.
## TL;DR — что работает, что нет
| Операция | Здоровый сценарий | Предел до деградации | Узкое место |
|---|---|---|---|
| **GET /api/reports/sales** (1500 чеков в орге) | p95 50-115ms до 5 VU | После 5 VU непредсказуемо (см. ниже) | PG aggregation / connection pool |
| **POST /api/auth/signup** | p95 446ms при 50 RPM | 100 RPM с одного IP → 39% 429 | IP rate-limit (60/мин, by design) |
| **POST /api/sales/retail + /post** (sequential) | p95 71ms, 17 sales/sec | VU > 1 на одном tenant'е → unique-violation race | `GenerateNumberAsync` race condition |
## Прогон 1: signup-burst
`tests/load/signup-burst.js` — 50/100 регистраций/мин с одного IP, 60с.
### 50 RPM (под IP-лимитом 60/мин)
| Метрика | Значение |
|---|---|
| Iterations | 51 (за 60с) |
| http_req_duration p50 | 391ms |
| http_req_duration p90 | 425ms |
| http_req_duration p95 | 446ms |
| http_req_duration p99 | ~1.37s (один outlier) |
| signup_rate_limited | 0% |
| Failures | 0 |
Прогон чистый. Signup на stage'е укладывается в ~400-450ms p95.
### 100 RPM (превышение IP-лимита)
| Метрика | Значение |
|---|---|
| Iterations | 101 |
| 2xx (успешные) | 62 |
| 429 (rate-limited) | 39 (38.6%) |
| http_req_duration p95 (2xx-only) | 437ms |
429-ответы возвращаются за единицы миллисекунд (`http_req_duration`
total p95 показывает 436ms потому что включает и 429 — лимитер очень
быстрый). Поведение by design (см. `AuthRateLimiterExtensions`),
указывает что защита работает.
## Прогон 2: retail-sales-parallel
`tests/load/retail-sales-parallel.js` — на одном tenant'е N параллельных
кассиров (VU) проводят чеки. Тест создаёт draft (`POST /api/sales/retail`)
и сразу проводит (`POST /api/sales/retail/{id}/post`).
### VU=1 (sequential baseline) — 200 итераций
| Метрика | Значение |
|---|---|
| Iterations | 200/200 (100%) |
| Throughput | **17 sales/sec** |
| sale_draft_ms p50 | 25ms |
| sale_draft_ms p95 | 37ms |
| sale_post_ms p50 | 26ms |
| sale_post_ms p95 | 35ms |
| sale_total_ms p95 | **71ms** |
| sale_total_ms p99 | ~90ms |
| post_4xx | 0% |
Идеальная картинка: 17 sales/sec на single-thread, латенция стабильная.
### VU=5 (параллельные кассиры) — 200 итераций
| Метрика | Значение |
|---|---|
| Iterations | 200/200 (driver), но успешных только 94 |
| post_4xx | **53% 🔴** |
| sale_draft_ms p95 (включая failed) | 151ms |
| sale_total_ms p95 (только успешные) | 185ms |
**Узкое место найдено: race в `GenerateNumberAsync`.**
`RetailSalesController.GenerateNumberAsync` строит next-number чтением
последней `Number` для tenant'а и +1. Под параллельными VU несколько
запросов читают одно и то же `lastNumber`, генерируют одинаковый
`ПР-2026-000XXX`, на INSERT падают на unique-index
`IX_retail_sales_OrganizationId_Number`. `SaveOrFkErrorAsync` ловит
только 23503 (FK violation), не 23505 (unique violation) — поэтому
до клиента долетает 500 (или 400 от EF middleware).
**Что делать (отдельная задача, не в Sprint 12)**: завести
`organization_counters` (singleton-row per tenant), увеличивать счётчик
через `UPDATE … RETURNING value` в той же транзакции. Альтернатива —
ловить 23505 и ретраить с +1 в цикле. Третий вариант — использовать
PG sequence per tenant (более сложно, но самое чистое).
## Прогон 3: sales-report-heavy
`tests/load/sales-report-heavy.js` — за 20-30 секунд VU дёргают
`GET /api/reports/sales?dateFrom=...&dateTo=...&groupBy=day`,
`/api/reports/abc?...` и `/api/dashboard/top-products?limit=5`.
Tenant с **1500 проведённых чеков, 5535 stock_movements, 200 товаров**
(посеян через `POST /api/admin/seed-demo?years=1` — это YearDemoSeeder).
| VU | Throughput (iter/s) | sales p95 | abc p95 | non_2xx | Заметки |
|---|---|---|---|---|---|
| 1 | 6.7 | 54ms | 51ms | 0% | Чистый baseline. |
| 2 | 17.5 | 60ms | 54ms | 0% | Linear, ОК. |
| 3 | 23.5 | 67ms | 63ms | 0% | Linear, ОК. |
| 4 | 25.4 | 81ms | 78ms | 0% | Незначительная деградация. |
| 5 | 24.0 | 114ms | 108ms | 0% | Деградация заметнее, throughput плато. |
| 5* | 8.7 | 3 800ms 🔴 | 3 500ms 🔴 | 0% | Аномалия одного прогона (см. ниже). |
`*` Первый прогон VU=5 показал p95 ~3.8с на отчёт. Повторный прогон —
обычные 114ms p95. Скорее всего совпало с autovacuum'ом
`stock_movements` (5535 строк, частые обновления при seed'е). Это
напоминает: в production нужны:
- Мониторинг p95 отчётов в Prometheus + алерт на отклонение от baseline.
- Тюнинг `autovacuum_*` для `stock_movements` (или явный
`VACUUM ANALYZE` после массовых seed'ов).
### Что НЕ протестировано (требует входа от user)
- **10 000 чеков на одного tenant'а** — пользователь просил «отчёт Sales
с 10 000 продажами». YearDemoSeeder делает максимум 1500 (это сезонный
год для одного магазина); чтобы получить 10к — нужно либо допилить
seeder на «10 лет» / «10 магазинов», либо запустить несколько
параллельных seed'ов под отдельными tenant'ами и тестировать
cross-tenant. Пока обозначено как TODO для будущего спринта.
- **Реальная нагрузка из ЦА** — k6 запускается из локалки, RTT
5-20мс. Реальный пользователь из Алматы добавит 30-80мс к
каждому запросу. Считать SLA с учётом этого.
- **POS-синк** (`POST /api/pos/v1/batch`) — отдельный сценарий, потому
что требует серии чеков с идемпотентным ключом и подходящих refs.
TODO: `pos-sync.js`.
## Сводка: что нужно поправить
| Приоритет | Что | Где |
|---|---|---|
| 🔴 P0 | Race в `GenerateNumberAsync` | `RetailSalesController.cs:957` |
| 🟡 P1 | Тот же подход в `SuppliesController/DemandsController/...` | Везде где есть `GenerateNumberAsync` |
| 🟡 P1 | `SaveOrFkErrorAsync` не ловит 23505 (unique violation) | `RetailSalesController.cs:418` |
| 🟢 P2 | Tune autovacuum для `stock_movements` | PG config / `ALTER TABLE` |
| 🟢 P2 | Прометей-алерт на p95 отчёта | observability |
| 🟢 P2 | k6 для POS-синка с idempotency | `tests/load/pos-sync.js` |
## Воспроизведение
```bash
# k6 v0.55+ должен быть в PATH (см. tests/load/README.md)
cd tests/load
# 1. Signup-burst (60с, 50 RPM)
BASE_URL=https://test.admin.food-market.kz TARGET_RPM=50 \
k6 run signup-burst.js
# 2. Sales sequential baseline
BASE_URL=https://test.admin.food-market.kz \
DURATION_S=120 TARGET_ITERS=200 VUS=1 \
k6 run retail-sales-parallel.js
# 3. Reports на свежем tenant'е (нужны creds от signup + year-demo seed)
SLUG="loadbase-$(date +%s)"
EMAIL="$SLUG@example.kz"
curl -sX POST $BASE_URL/api/auth/signup -H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"password\":\"Passw0rd!\",\"organizationName\":\"LoadOrg\",\"phone\":\"+77001234567\"}"
TOKEN=$(curl -sX POST $BASE_URL/connect/token -d "grant_type=password&username=$EMAIL&password=Passw0rd!&client_id=food-market-web&scope=openid profile email roles api offline_access" | jq -r .access_token)
curl -sX POST -H "Authorization: Bearer $TOKEN" "$BASE_URL/api/admin/seed-demo?years=1"
EMAIL=$EMAIL PASSWORD=Passw0rd! VUS=3 k6 run sales-report-heavy.js
```