food-market/docs/performance-baseline.md
nns 72d0a71307
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
docs(s24): docs cross-check + auto-gen + onboarding + test gap-fill (8/8 ✓)
1. Docs cross-check — обновил performance-baseline.md (Sprint 18/20/23
   фиксы), secrets.md (16 новых env-vars из Sprint 20+ — Authentication
   Google/Microsoft, Monitoring, Cleanup, Hangfire:Cron, Telegram,
   Maintenance, App, Storage, PUBLIC_GA_ID/YM_ID).

2. Auto-gen api-reference — ApiReferenceDocsJob (Hangfire weekly вс
   05:30 UTC) + Python-эквивалент `/tmp/gen-api-ref.py` для commit
   actual snapshot. docs/api-reference.md = 195 endpoints, 57 controllers.

3. Coverage gap-fill — Sprint18To23FeaturesTests.cs (16 Facts):
   - bulk-update + cross-tenant isolation
   - UserPresets CRUD
   - inline-edit price PATCH
   - CSV import 2 строки транзакцией
   - OrgExport create + list isolation
   - 1C-CSV import с русскими заголовками
   - audit-log export CSV streaming + BOM check
   - MoySklad sync-status stub
   - SSO providers + 503 unconfigured + 400 unknown provider
   - bug-001 NUL byte → 400
   - bug-004 tiny price → 400
   - export CSV BOM
   Покрывает все новые контроллеры Sprint 18-23 + regression-protect
   для критичных багов.

4. Contract tests — deploy/swagger-diff.sh: pull /swagger/v1/swagger.json
   с двух URL, diff endpoints+schemas через python3. Exit 0/1/2 для
   blue-green safety gate. Multi-path auto-detect.

5. docs/error-codes.md — каталог HTTP-кодов API (200-503) + humanizeError
   pattern для фронта + retry-policy таблица.

6. docs/glossary.md — 50+ доменных терминов (Tenant/Organization/Stock/
   StockMovement/RetailSale/Counterparty/Owner/Employee/Role/Permission/
   advisory lock/Serializable/…) с ссылками на code-сущности.

7. docs/ONBOARDING.md — first 3 days для нового разработчика
   (install → запуск → структура → первый PR + FAQ).

8. README.md — обновил под текущее состояние: React 19, Sprint-history
   1-24, ссылки на ключевые docs, корректный 5-min quick start.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 02:15:56 +05:00

179 lines
10 KiB
Markdown
Raw Permalink 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: 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
```