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

9.3 KiB
Raw Blame History

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

Воспроизведение

# 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