// Sprint 27: 4-часовой soak test. // // Сценарий: 50 RPS на смесь read+write эндпоинтов 4 часа подряд. // Цель — обнаружить: // - утечки памяти heap dotnet (растёт линейно?) // - leak'и PG connection pool (через `pg_stat_activity`) // - дисковое заполнение логами // - latency degradation (p95 растёт?) // // Запуск: // E2E_ADMIN_URL=https://test.admin.food-market.kz \ // DURATION=4h RPS=50 k6 run tests/load/soak-4h.js // // Запуск-lite (для разработки/CI): // DURATION=10m RPS=10 k6 run tests/load/soak-4h.js // // Артефакты: // - stdout: k6 summary stats // - JSON: --summary-export=reports/soak-summary.json // - Чтобы записать metrics-snapshot каждые 5мин, запусти параллельно // ./monitor-soak.sh который дёргает /metrics и пишет CSV. import http from 'k6/http' import { check, sleep } from 'k6' import { Trend, Rate, Counter } from 'k6/metrics' const BASE_URL = __ENV.E2E_ADMIN_URL || __ENV.BASE_URL || 'https://test.admin.food-market.kz' const DURATION = __ENV.DURATION || '30m' const RPS = Number(__ENV.RPS || 50) const ADMIN = __ENV.ADMIN_USER || 'admin@food-market.local' const PASS = __ENV.ADMIN_PASS || 'Admin12345!' // Pre-existing seeded org/token (из setup() — выдаётся один на весь soak). const meTrend = new Trend('soak_me_ms', true) const productsTrend = new Trend('soak_products_ms', true) const statsTrend = new Trend('soak_stats_ms', true) const errors = new Counter('soak_errors') const err4xx = new Rate('soak_4xx_rate') const err5xx = new Rate('soak_5xx_rate') export const options = { scenarios: { soak: { executor: 'constant-arrival-rate', rate: RPS, timeUnit: '1s', duration: DURATION, preAllocatedVUs: Math.max(20, RPS), maxVUs: Math.max(50, RPS * 2), }, }, thresholds: { // Soak: latency не должен ухудшаться. p95 от 95-го перцентиля // GET-запросов на свежем стенде ~250мс; allow 1500мс под нагрузкой. soak_me_ms: ['p(95)<1500'], soak_products_ms: ['p(95)<2000'], soak_stats_ms: ['p(95)<3000'], soak_4xx_rate: ['rate<0.01'], // <1% 4xx — норма soak_5xx_rate: ['rate<0.005'], // <0.5% 5xx — допустимый peak }, } export function setup() { // Получаем токен админа stage (он SuperAdmin без orgId, что не ОК для // products — SuperAdmin видит все orgs). Поэтому регаем свежую org. const ts = Date.now() const email = `soak-${ts}@test-fm.local` const password = 'Soak12345!' const signupRes = http.post(`${BASE_URL}/api/auth/signup`, JSON.stringify({ email, password, organizationName: `soak-${ts}`, phone: '+77001234567', }), { headers: { 'Content-Type': 'application/json' } }) if (signupRes.status !== 200) { throw new Error(`signup failed: ${signupRes.status} ${signupRes.body}`) } const tokRes = http.post(`${BASE_URL}/connect/token`, { grant_type: 'password', username: email, password, client_id: 'food-market-web', scope: 'openid profile email roles api', }) if (tokRes.status !== 200) { throw new Error(`token failed: ${tokRes.status}`) } const tok = tokRes.json('access_token') return { token: tok, email } } export default function (data) { const headers = { Authorization: `Bearer ${data.token}` } // Rotate через 3 endpoint'a (50/30/20%). const r = Math.random() let resp, trend if (r < 0.5) { resp = http.get(`${BASE_URL}/api/me`, { headers, tags: { endpoint: 'me' } }) trend = meTrend } else if (r < 0.8) { resp = http.get(`${BASE_URL}/api/catalog/products?page=1&pageSize=20`, { headers, tags: { endpoint: 'products' } }) trend = productsTrend } else { resp = http.get(`${BASE_URL}/api/sales/retail/stats?days=7`, { headers, tags: { endpoint: 'stats' } }) trend = statsTrend } trend.add(resp.timings.duration) if (resp.status >= 400 && resp.status < 500) err4xx.add(1) else err4xx.add(0) if (resp.status >= 500) { err5xx.add(1) errors.add(1) } else { err5xx.add(0) } check(resp, { 'status 200/304': r => r.status === 200 || r.status === 304, }) }