# 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: race на номере (23505) и serialization conflict (40001) | `GenerateNumberAsync` race + Serializable | ✅ Sprint 18: advisory lock убил 23505. Sprint 23: 40001 теперь корректные 409 (было 500). | ## Прогон 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` | ✅ Зафиксен в Sprint 18 через PostgreSQL advisory lock (`DocumentNumberRetry.WithOrgAdvisoryLockAsync` per (orgHash, docTypeHash)). Воспроизводится: 23505 ошибки 53% → 0. | | 🟡 P1 | Тот же подход в `SuppliesController/DemandsController/...` | Везде где есть `GenerateNumberAsync` | ⚠️ Helper `DocumentNumberRetry` готов, но к Supplies/Demands ещё не применён. TODO для будущего спринта. | | 🟡 P1 | 40001 Serializable conflict при concurrent /post → 500 | `RetailSalesController.Post` | ✅ Зафиксен в Sprint 23: `SerializationConflictMiddleware` мапит 40001 → 409 + `SerializableRetry` helper (exp backoff) применён к `RetailSale.PostCoreAsync`. После: 20 параллельных продаж → 0 × 500, 6 ok + 14 × 409, stock invariant сохраняется. | | 🟢 P2 | Tune autovacuum для `stock_movements` | PG config / `ALTER TABLE` | ✅ Sprint 20: `DatabaseMaintenanceJobs.VacuumTopTablesAsync` weekly воскр 04:00 UTC. | | 🟢 P2 | Прометей-алерт на p95 отчёта | observability | ⚠️ Sprint 20 добавил `~/nightly-perf-check.sh` (sliding baseline + Telegram). Реальные Prometheus alert-rules — не настроены. | | 🟢 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 ```