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 <noreply@anthropic.com>
This commit is contained in:
1 parent
aacf55679a
commit
86c3e4e477
2 files changed
+140
-1
No files matched your search
+25
-1
@@ -49,4 +49,28 @@
|
|||||||
- Покрытие: Go и Python ≥ 70% по строкам для `internal/*/service` и `pipu_rec/*`; для `auth/policy` — 100% веток.
|
- Покрытие: Go и Python ≥ 70% по строкам для `internal/*/service` и `pipu_rec/*`; для `auth/policy` — 100% веток.
|
||||||
- Тест на каждый исправленный баг.
|
- Тест на каждый исправленный баг.
|
||||||
- Flaky-тесты помечаются и чинятся в течение недели, а не перезапускаются бесконечно.
|
- 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`. Запускать с машины в той же сети, что и сервер, —
|
||||||
|
иначе в цифрах будет задержка канала.
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
Reference in new issue
Block a user