Документация для следующего разработчика (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>
179 lines
9.3 KiB
Markdown
179 lines
9.3 KiB
Markdown
# 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
|
||
```
|