From 86c3e4e477b7bc6feddd1a9fe3375334e383c7fb Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:01:10 +0300 Subject: [PATCH] test(load): k6 smoke for 100 wave listeners and 20 rps suggest (M7) Three minutes of wave sessions with stream starts, play events and next batches alongside a steady suggest load; thresholds follow the documented p95 targets. Co-Authored-By: Claude Opus 5.5 --- docs/testing.md | 26 +++++++++- tests/load/smoke.js | 115 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 tests/load/smoke.js diff --git a/docs/testing.md b/docs/testing.md index b7fbd64..4cf9758 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -49,4 +49,28 @@ - Покрытие: Go и Python ≥ 70% по строкам для `internal/*/service` и `pipu_rec/*`; для `auth/policy` — 100% веток. - Тест на каждый исправленный баг. - Flaky-тесты помечаются и чинятся в течение недели, а не перезапускаются бесконечно. -- Нагрузочный smoke (k6, вручную перед релизом): 100 одновременных слушателей, 20 rps на suggest — p95 в рамках целей. +- Нагрузочный smoke (k6, вручную перед релизом) — см. ниже. + +## Нагрузочный smoke + +`tests/load/smoke.js` (k6 ≥ 0.50), 3 минуты: 100 одновременных слушателей волны (старт волны, начало потока +каждого трека — первые 64 КБ, события прослушивания, следующая пачка с отзывами) и 20 запросов подсказок поиска +в секунду. Пороги — цели из [api.md](api.md) и [recommendations.md](recommendations.md#10-требования-к-производительности): + +| Запрос | p95 | +|---|---| +| `GET /search/suggest` | < 50 мс | +| `POST /wave/sessions/{id}/next` | < 150 мс | +| `POST /wave/sessions` | < 300 мс | +| начало потока (`Range: bytes=0-65535`) | < 300 мс | +| `POST /events` | < 100 мс | + +Ошибок < 1%. k6 завершается с ненулевым кодом, если порог не выполнен. + +```bash +k6 run -e BASE_URL=https://music.example.com -e PIPU_USER=loadtest -e PIPU_PASSWORD=… tests/load/smoke.js +``` + +Пользователь — отдельный обычный (`role=user`): тест наполняет его историю и лайки. Нагрузку можно менять: +`-e LISTENERS=20 -e SUGGEST_RPS=5 -e DURATION=1m`. Запускать с машины в той же сети, что и сервер, — +иначе в цифрах будет задержка канала. diff --git a/tests/load/smoke.js b/tests/load/smoke.js new file mode 100644 index 0000000..6a74528 --- /dev/null +++ b/tests/load/smoke.js @@ -0,0 +1,115 @@ +// Нагрузочный smoke перед релизом (docs/testing.md#нагрузочный-smoke): 100 одновременных слушателей волны +// и 20 запросов подсказок поиска в секунду в течение 3 минут; пороги — цели из docs/api.md и +// docs/recommendations.md. Запуск (k6 ≥ 0.50): +// k6 run -e BASE_URL=https://music.example.com -e PIPU_USER=loadtest -e PIPU_PASSWORD=… tests/load/smoke.js +// Пользователь — обычный (role=user), заведённый для теста: его история и лайки засоряются. +import { check, sleep } from 'k6'; +import http from 'k6/http'; + +const BASE = (__ENV.BASE_URL || 'http://localhost:8080').replace(/\/$/, ''); +const DURATION = __ENV.DURATION || '3m'; +const LISTENERS = Number(__ENV.LISTENERS || 100); +const SUGGEST_RPS = Number(__ENV.SUGGEST_RPS || 20); + +export const options = { + scenarios: { + listeners: { executor: 'constant-vus', vus: LISTENERS, duration: DURATION, exec: 'listen' }, + suggest: { + executor: 'constant-arrival-rate', + rate: SUGGEST_RPS, + timeUnit: '1s', + duration: DURATION, + preAllocatedVUs: 10, + maxVUs: 40, + exec: 'suggest', + }, + }, + thresholds: { + 'http_req_duration{name:suggest}': ['p(95)<50'], + 'http_req_duration{name:wave_next}': ['p(95)<150'], + 'http_req_duration{name:wave_start}': ['p(95)<300'], + 'http_req_duration{name:stream}': ['p(95)<300'], // первые 64 КБ — время до начала звука + 'http_req_duration{name:events}': ['p(95)<100'], + http_req_failed: ['rate<0.01'], + checks: ['rate>0.99'], + }, +}; + +const json = (token) => ({ + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, +}); + +export function setup() { + const res = http.post( + `${BASE}/api/v1/auth/login`, + JSON.stringify({ + username: __ENV.PIPU_USER, + password: __ENV.PIPU_PASSWORD, + client_type: 'android', + device_name: 'k6 load smoke', + }), + { headers: { 'Content-Type': 'application/json' } }, + ); + if (res.status !== 200) throw new Error(`login: ${res.status} ${res.body}`); + return { token: res.json('access_token') }; +} + +function uuid() { + // v4 — для id событий (дубли отбрасываются сервером, поэтому уникальность важна) + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); + }); +} + +// Слушатель: волна → по каждому треку начало потока и события → следующая пачка с отзывами. +export function listen(data) { + const p = json(data.token); + const start = http.post(`${BASE}/api/v1/wave/sessions`, JSON.stringify({}), { ...p, tags: { name: 'wave_start' } }); + if (!check(start, { 'wave started': (r) => r.status === 200 || r.status === 201 })) { + sleep(5); + return; + } + const session = start.json('session_id'); + let tracks = start.json('tracks') || []; + for (let batch = 0; batch < 3 && tracks.length > 0; batch++) { + const feedback = []; + for (const t of tracks) { + const s = http.get(`${BASE}${t.stream_url}`, { headers: { Range: 'bytes=0-65535' }, tags: { name: 'stream' } }); + check(s, { 'stream starts': (r) => r.status === 206 || r.status === 200 }); + const now = new Date(); + const outcome = Math.random() < 0.3 ? 'skip_early' : 'complete'; + const played = outcome === 'complete' ? 180000 : 8000; + const ev = http.post( + `${BASE}/api/v1/events`, + JSON.stringify({ + events: [ + { id: uuid(), type: 'play_start', track_id: t.id, occurred_at: new Date(now - played).toISOString(), position_ms: 0, context_type: 'wave', context_id: session }, + { id: uuid(), type: 'play_end', track_id: t.id, occurred_at: now.toISOString(), position_ms: played, played_ms: played, context_type: 'wave', context_id: session }, + ], + }), + { ...p, tags: { name: 'events' } }, + ); + check(ev, { 'events accepted': (r) => r.status === 200 }); + feedback.push({ track_id: t.id, outcome }); + sleep(1 + Math.random() * 2); // «слушает» — запросы реального клиента редкие + } + const next = http.post(`${BASE}/api/v1/wave/sessions/${session}/next`, JSON.stringify({ feedback }), { + ...p, + tags: { name: 'wave_next' }, + }); + if (!check(next, { 'wave next': (r) => r.status === 200 })) break; + tracks = next.json('tracks') || []; + } +} + +const QUERIES = ['ки', 'кино', 'mo', 'mozart', 'the', 'love', 'ghbdtn', 'a', 'нау', 'rock', 'би', 'queen', 'ле', 'jazz']; + +export function suggest(data) { + const q = QUERIES[Math.floor(Math.random() * QUERIES.length)]; + const r = http.get(`${BASE}/api/v1/search/suggest?q=${encodeURIComponent(q)}`, { + headers: { Authorization: `Bearer ${data.token}` }, + tags: { name: 'suggest' }, + }); + check(r, { 'suggest ok': (x) => x.status === 200 }); +}