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>
10 KiB
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 |
❌ Не реализовано. |
Воспроизведение
# 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