Документация для следующего разработчика (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>
79 lines
3.4 KiB
JavaScript
79 lines
3.4 KiB
JavaScript
// sales-report-heavy.js — нагрузка чтения отчётов.
|
||
//
|
||
// Сценарий: один tenant с уже-нагенерированными чеками (1500 через
|
||
// YearDemoSeeder или 10000+ через несколько подряд запусков
|
||
// retail-sales-parallel.js). 30 секунд VU дёргают
|
||
// GET /api/reports/sales и GET /api/reports/abc, мерим p95/p99.
|
||
//
|
||
// Это «чтение тяжёлого агрегата», самый показательный bench для
|
||
// PG-индексов и плана запроса. Если p95 > 2с при 1500 чеках —
|
||
// нужен EXPLAIN.
|
||
|
||
import http from 'k6/http';
|
||
import { check } from 'k6';
|
||
import { Trend, Rate } from 'k6/metrics';
|
||
|
||
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5081';
|
||
const DURATION_S = Number(__ENV.DURATION_S || 30);
|
||
const VUS = Number(__ENV.VUS || 10);
|
||
|
||
// Email/пароль уже-существующего tenant'а — нужно подсунуть. По дефолту
|
||
// — admin@food-market.local (SuperAdmin без своей орги, отчёты будут
|
||
// пустыми, но мы мерим только время ответа эндпоинтов).
|
||
const EMAIL = __ENV.EMAIL || 'admin@food-market.local';
|
||
const PASSWORD = __ENV.PASSWORD || 'Admin12345!';
|
||
|
||
const salesTrend = new Trend('reports_sales_ms', true);
|
||
const abcTrend = new Trend('reports_abc_ms', true);
|
||
const dashboardTrend = new Trend('dashboard_top_ms', true);
|
||
const non2xx = new Rate('reports_non_2xx');
|
||
|
||
export const options = {
|
||
scenarios: {
|
||
reports: {
|
||
executor: 'constant-vus',
|
||
vus: VUS,
|
||
duration: `${DURATION_S}s`,
|
||
},
|
||
},
|
||
thresholds: {
|
||
reports_sales_ms: ['p(95)<2500', 'p(99)<5000'],
|
||
reports_abc_ms: ['p(95)<3000', 'p(99)<6000'],
|
||
reports_non_2xx: ['rate<0.01'],
|
||
},
|
||
};
|
||
|
||
export function setup() {
|
||
const r = http.post(`${BASE_URL}/connect/token`,
|
||
`grant_type=password&username=${encodeURIComponent(EMAIL)}&password=${encodeURIComponent(PASSWORD)}&client_id=food-market-web&scope=openid profile email roles api offline_access`,
|
||
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
|
||
if (r.status !== 200) throw new Error(`token failed for ${EMAIL}: ${r.status} ${r.body}`);
|
||
return { token: r.json('access_token') };
|
||
}
|
||
|
||
export default function (ctx) {
|
||
const auth = { headers: { Authorization: `Bearer ${ctx.token}` }, tags: {} };
|
||
|
||
// Период «весь год» — стандартный запрос дашборда «за год».
|
||
const dateFrom = '2026-01-01';
|
||
const dateTo = '2026-12-31';
|
||
|
||
let t0 = Date.now();
|
||
let r = http.get(`${BASE_URL}/api/reports/sales?dateFrom=${dateFrom}&dateTo=${dateTo}&groupBy=day`, auth);
|
||
salesTrend.add(Date.now() - t0);
|
||
non2xx.add(r.status >= 400);
|
||
check(r, { 'sales 2xx': (x) => x.status >= 200 && x.status < 300 });
|
||
|
||
t0 = Date.now();
|
||
r = http.get(`${BASE_URL}/api/reports/abc?dateFrom=${dateFrom}&dateTo=${dateTo}`, auth);
|
||
abcTrend.add(Date.now() - t0);
|
||
non2xx.add(r.status >= 400);
|
||
check(r, { 'abc 2xx': (x) => x.status >= 200 && x.status < 300 });
|
||
|
||
t0 = Date.now();
|
||
r = http.get(`${BASE_URL}/api/dashboard/top-products?limit=5`, auth);
|
||
dashboardTrend.add(Date.now() - t0);
|
||
non2xx.add(r.status >= 400);
|
||
check(r, { 'dashboard 2xx': (x) => x.status >= 200 && x.status < 300 });
|
||
}
|