chore(release): v0.951 — Loadtest (500 req/s, 1000 WS, 50 uploads, perf indexes)
This commit is contained in:
parent
b52f209636
commit
da837fc085
9 changed files with 383 additions and 24 deletions
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -1,5 +1,20 @@
|
||||||
# Changelog - Veza
|
# Changelog - Veza
|
||||||
|
|
||||||
|
## [v0.951] - 2026-03-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Load test: stress_500rps.js — 500 VUs on login, tracks, search, products (P99 < 500ms target)
|
||||||
|
- Load test: stress_1000ws.js — 1000 WebSocket concurrent, 5 min hold
|
||||||
|
- Load test: uploads.js — 50 VUs, setup() creates users when AUTH_TOKEN absent
|
||||||
|
- Migration 940: products indexes (status+created_at, seller_id+status)
|
||||||
|
- docs/PERFORMANCE_BASELINE.md: v0.951 scripts and targets
|
||||||
|
- loadtests/README.md: stress_500rps, stress_1000ws, 50 uploads sections
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- loadtests/backend/uploads.js: stages 1m→50 VUs, 2m hold; setup() for token creation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [v0.803] - 2026-02-25
|
## [v0.803] - 2026-02-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.962
|
0.951
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Performance Baseline — Veza API
|
# Performance Baseline — Veza API
|
||||||
|
|
||||||
**Version** : v0.931
|
**Version** : v0.951
|
||||||
**Objectif** : Documenter les latences P50/P95/P99 des endpoints critiques pour détecter les régressions.
|
**Objectif** : Documenter les latences P50/P95/P99 des endpoints critiques pour détecter les régressions.
|
||||||
|
|
||||||
## Méthodologie
|
## Méthodologie
|
||||||
|
|
@ -24,11 +24,23 @@
|
||||||
| `/api/v1/analytics/me` | GET | Analytics |
|
| `/api/v1/analytics/me` | GET | Analytics |
|
||||||
| `/health` | GET | Health check |
|
| `/health` | GET | Health check |
|
||||||
|
|
||||||
## Cibles v1.0 (voir roadmap v0.951)
|
## Cibles v1.0 (v0.951)
|
||||||
|
|
||||||
- **P99 < 500ms** sur tous les endpoints critiques à 500 req/s
|
- **P99 < 500ms** sur tous les endpoints critiques à 500 req/s (stress_500rps.js)
|
||||||
|
- **1000 WebSocket** : connexions stables 5 min, taux livraison > 99% (stress_1000ws.js)
|
||||||
|
- **50 uploads concurrents** : tous réussis, backpressure respecté (uploads.js)
|
||||||
- **GET /tracks** : pagination cursor-based (v0.931) garantit des performances constantes quelle que soit la page
|
- **GET /tracks** : pagination cursor-based (v0.931) garantit des performances constantes quelle que soit la page
|
||||||
|
|
||||||
|
## Scripts k6 v0.951
|
||||||
|
|
||||||
|
| Script | Commande | Seuils |
|
||||||
|
|--------|----------|--------|
|
||||||
|
| API stress 500 VUs | `k6 run loadtests/backend/stress_500rps.js` | P99 < 500ms (login, tracks, search, products) |
|
||||||
|
| WebSocket 1000 | `k6 run loadtests/chat/stress_1000ws.js` | ws_connection_failures < 1%, ws_message_failures < 1% |
|
||||||
|
| Uploads 50 | `k6 run loadtests/backend/uploads.js` | P95 < 5s (simple), P95 < 8s (chunked) |
|
||||||
|
|
||||||
|
Voir [loadtests/README.md](../loadtests/README.md) pour l'exécution complète.
|
||||||
|
|
||||||
## Commande pprof
|
## Commande pprof
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,10 @@ k6 run loadtests/backend/auth.js
|
||||||
# Tracks (liste, search, détail)
|
# Tracks (liste, search, détail)
|
||||||
k6 run loadtests/backend/tracks.js
|
k6 run loadtests/backend/tracks.js
|
||||||
|
|
||||||
# Uploads (requiert AUTH_TOKEN)
|
# Uploads (AUTH_TOKEN optionnel — setup crée des users si absent)
|
||||||
AUTH_TOKEN=xxx k6 run loadtests/backend/uploads.js
|
AUTH_TOKEN=xxx k6 run loadtests/backend/uploads.js
|
||||||
|
# v0.951: 50 uploads concurrents pendant 2 min
|
||||||
|
k6 run loadtests/backend/uploads.js
|
||||||
|
|
||||||
# Playlists
|
# Playlists
|
||||||
k6 run loadtests/backend/playlists.js
|
k6 run loadtests/backend/playlists.js
|
||||||
|
|
@ -47,6 +49,12 @@ k6 run loadtests/backend/marketplace.js
|
||||||
|
|
||||||
# Scénario combiné (health, auth, tracks, playlists, marketplace)
|
# Scénario combiné (health, auth, tracks, playlists, marketplace)
|
||||||
k6 run loadtests/backend/full.js
|
k6 run loadtests/backend/full.js
|
||||||
|
|
||||||
|
# Stress test 500 req/s (v0.951) — login, tracks, search, products
|
||||||
|
# Seuils: P99 < 500ms, 500 VUs ramp 0→100→250→500 sur 3 min, maintien 2 min
|
||||||
|
k6 run loadtests/backend/stress_500rps.js
|
||||||
|
# Avec rapport JSON:
|
||||||
|
k6 run --out json=report.json loadtests/backend/stress_500rps.js
|
||||||
```
|
```
|
||||||
|
|
||||||
### Stream server
|
### Stream server
|
||||||
|
|
@ -61,6 +69,10 @@ k6 run loadtests/stream/http.js
|
||||||
```bash
|
```bash
|
||||||
# Backend API + Chat server requis
|
# Backend API + Chat server requis
|
||||||
k6 run loadtests/chat/websocket.js
|
k6 run loadtests/chat/websocket.js
|
||||||
|
|
||||||
|
# Stress test 1000 WebSocket concurrent (v0.951)
|
||||||
|
# Prérequis: Backend + Chat server, mémoire serveur < 2GB
|
||||||
|
k6 run loadtests/chat/stress_1000ws.js
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
@ -94,9 +106,11 @@ loadtests/
|
||||||
│ ├── uploads.js
|
│ ├── uploads.js
|
||||||
│ ├── playlists.js
|
│ ├── playlists.js
|
||||||
│ ├── marketplace.js
|
│ ├── marketplace.js
|
||||||
│ └── full.js
|
│ ├── full.js
|
||||||
|
│ └── stress_500rps.js
|
||||||
├── stream/
|
├── stream/
|
||||||
│ └── http.js
|
│ └── http.js
|
||||||
└── chat/
|
└── chat/
|
||||||
└── websocket.js
|
├── websocket.js
|
||||||
|
└── stress_1000ws.js
|
||||||
```
|
```
|
||||||
|
|
|
||||||
111
loadtests/backend/stress_500rps.js
Normal file
111
loadtests/backend/stress_500rps.js
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* Stress test: 500 req/s target on critical endpoints (v0.951)
|
||||||
|
* Endpoints: login, get tracks, search, marketplace products
|
||||||
|
* Usage: k6 run loadtests/backend/stress_500rps.js
|
||||||
|
* Output: k6 run --out json=report.json loadtests/backend/stress_500rps.js
|
||||||
|
*/
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep } from 'k6';
|
||||||
|
import { randomString } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
|
||||||
|
|
||||||
|
const BASE_URL = __ENV.BASE_URL || __ENV.API_ORIGIN || 'http://localhost:8080';
|
||||||
|
const TEST_EMAIL_PREFIX = __ENV.TEST_EMAIL_PREFIX || 'user+stress';
|
||||||
|
const TEST_EMAIL_DOMAIN = __ENV.TEST_EMAIL_DOMAIN || 'example.com';
|
||||||
|
const TEST_PASSWORD_PREFIX = __ENV.TEST_PASSWORD_PREFIX || 'V3za!stress-';
|
||||||
|
|
||||||
|
function generateTestUser() {
|
||||||
|
const rand = randomString(8);
|
||||||
|
const pwd = `${TEST_PASSWORD_PREFIX}${rand}`;
|
||||||
|
return {
|
||||||
|
email: `${TEST_EMAIL_PREFIX}${rand}@${TEST_EMAIL_DOMAIN}`,
|
||||||
|
password: pwd,
|
||||||
|
password_confirmation: pwd,
|
||||||
|
username: `st${rand}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
stress: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
stages: [
|
||||||
|
{ duration: '1m', target: 100 },
|
||||||
|
{ duration: '1m', target: 250 },
|
||||||
|
{ duration: '1m', target: 500 },
|
||||||
|
{ duration: '2m', target: 500 },
|
||||||
|
{ duration: '30s', target: 0 },
|
||||||
|
],
|
||||||
|
gracefulRampDown: '20s',
|
||||||
|
exec: 'stressScenario',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
'http_req_duration{name:login}': ['p(99)<500'],
|
||||||
|
'http_req_duration{name:tracks}': ['p(99)<500'],
|
||||||
|
'http_req_duration{name:search}': ['p(99)<500'],
|
||||||
|
'http_req_duration{name:products}': ['p(99)<500'],
|
||||||
|
http_req_duration: ['p(50)<100', 'p(95)<300', 'p(99)<500'],
|
||||||
|
http_req_failed: ['rate<0.05'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function setup() {
|
||||||
|
const users = [];
|
||||||
|
const baseURL = `${BASE_URL}/api/v1/auth`;
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const user = generateTestUser();
|
||||||
|
const registerRes = http.post(`${baseURL}/register`, JSON.stringify(user), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
if (registerRes.status === 201) {
|
||||||
|
const loginRes = http.post(`${baseURL}/login`, JSON.stringify({ email: user.email, password: user.password }), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
if (loginRes.status === 200) {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(loginRes.body);
|
||||||
|
const token = body.data?.token?.access_token;
|
||||||
|
if (token) users.push({ ...user, token });
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { users };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stressScenario(data) {
|
||||||
|
const { users } = data;
|
||||||
|
const baseURL = `${BASE_URL}/api/v1`;
|
||||||
|
const action = Math.floor(Math.random() * 4);
|
||||||
|
|
||||||
|
if (action === 0 && users.length > 0) {
|
||||||
|
const user = users[Math.floor(Math.random() * users.length)];
|
||||||
|
const res = http.post(
|
||||||
|
`${baseURL}/auth/login`,
|
||||||
|
JSON.stringify({ email: user.email, password: user.password }),
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
tags: { name: 'login' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
check(res, { 'login 200': (r) => r.status === 200 });
|
||||||
|
} else if (action === 1) {
|
||||||
|
const headers = users.length > 0
|
||||||
|
? { Authorization: `Bearer ${users[Math.floor(Math.random() * users.length)].token}` }
|
||||||
|
: {};
|
||||||
|
const res = http.get(`${baseURL}/tracks?limit=20`, { headers, tags: { name: 'tracks' } });
|
||||||
|
check(res, { 'tracks 200 or 401': (r) => r.status === 200 || r.status === 401 });
|
||||||
|
} else if (action === 2) {
|
||||||
|
const headers = users.length > 0
|
||||||
|
? { Authorization: `Bearer ${users[Math.floor(Math.random() * users.length)].token}` }
|
||||||
|
: {};
|
||||||
|
const q = ['test', 'music', 'beat', 'track', 'audio'][Math.floor(Math.random() * 5)];
|
||||||
|
const res = http.get(`${baseURL}/tracks/search?q=${q}&limit=10`, { headers, tags: { name: 'search' } });
|
||||||
|
check(res, { 'search 200 or 401': (r) => r.status === 200 || r.status === 401 });
|
||||||
|
} else {
|
||||||
|
const res = http.get(`${baseURL}/marketplace/products`, { tags: { name: 'products' } });
|
||||||
|
check(res, { 'products 200': (r) => r.status === 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(0.1);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
/**
|
/**
|
||||||
* Load test: upload simple, chunked, batch
|
* Load test: upload simple, chunked, batch
|
||||||
* Usage: k6 run loadtests/backend/uploads.js
|
* Usage: k6 run loadtests/backend/uploads.js
|
||||||
* Requires: AUTH_TOKEN (JWT)
|
* Option: AUTH_TOKEN (JWT) — if not set, setup() registers users and uses tokens
|
||||||
|
* v0.951: 50 VUs concurrent for 2 min (stress 50 uploads)
|
||||||
*/
|
*/
|
||||||
import http from 'k6/http';
|
import http from 'k6/http';
|
||||||
import { check, sleep } from 'k6';
|
import { check, sleep } from 'k6';
|
||||||
import { Rate, Trend, Counter } from 'k6/metrics';
|
import { Rate, Trend, Counter } from 'k6/metrics';
|
||||||
|
import { randomString } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
|
||||||
|
|
||||||
const errorRate = new Rate('errors');
|
const errorRate = new Rate('errors');
|
||||||
const uploadDuration = new Trend('upload_duration');
|
const uploadDuration = new Trend('upload_duration');
|
||||||
|
|
@ -16,12 +18,14 @@ const BASE_URL = __ENV.BASE_URL || __ENV.API_ORIGIN || 'http://localhost:8080';
|
||||||
const AUTH_TOKEN = __ENV.AUTH_TOKEN || '';
|
const AUTH_TOKEN = __ENV.AUTH_TOKEN || '';
|
||||||
const CHUNK_SIZE = parseInt(__ENV.CHUNK_SIZE || '1048576');
|
const CHUNK_SIZE = parseInt(__ENV.CHUNK_SIZE || '1048576');
|
||||||
const TOTAL_CHUNKS = parseInt(__ENV.TOTAL_CHUNKS || '5');
|
const TOTAL_CHUNKS = parseInt(__ENV.TOTAL_CHUNKS || '5');
|
||||||
|
const TEST_EMAIL_PREFIX = __ENV.TEST_EMAIL_PREFIX || 'user+upl';
|
||||||
|
const TEST_EMAIL_DOMAIN = __ENV.TEST_EMAIL_DOMAIN || 'example.com';
|
||||||
|
const TEST_PASSWORD_PREFIX = __ENV.TEST_PASSWORD_PREFIX || 'V3za!upl-';
|
||||||
|
|
||||||
export const options = {
|
export const options = {
|
||||||
stages: [
|
stages: [
|
||||||
{ duration: '30s', target: 5 },
|
{ duration: '1m', target: 50 },
|
||||||
{ duration: '2m', target: 10 },
|
{ duration: '2m', target: 50 },
|
||||||
{ duration: '2m', target: 10 },
|
|
||||||
{ duration: '30s', target: 0 },
|
{ duration: '30s', target: 0 },
|
||||||
],
|
],
|
||||||
thresholds: {
|
thresholds: {
|
||||||
|
|
@ -32,6 +36,39 @@ export const options = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function setup() {
|
||||||
|
if (AUTH_TOKEN) return { tokens: [AUTH_TOKEN] };
|
||||||
|
const tokens = [];
|
||||||
|
const baseURL = `${BASE_URL}/api/v1/auth`;
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
const rand = randomString(8);
|
||||||
|
const pwd = `${TEST_PASSWORD_PREFIX}${rand}`;
|
||||||
|
const user = {
|
||||||
|
email: `${TEST_EMAIL_PREFIX}${rand}@${TEST_EMAIL_DOMAIN}`,
|
||||||
|
password: pwd,
|
||||||
|
password_confirmation: pwd,
|
||||||
|
username: `upl${rand}`,
|
||||||
|
};
|
||||||
|
const registerRes = http.post(`${baseURL}/register`, JSON.stringify(user), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
if (registerRes.status === 201) {
|
||||||
|
const loginRes = http.post(`${baseURL}/login`, JSON.stringify({ email: user.email, password: user.password }), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
if (loginRes.status === 200) {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(loginRes.body);
|
||||||
|
const token = body.data?.token?.access_token;
|
||||||
|
if (token) tokens.push(token);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i % 10 === 0) sleep(0.1);
|
||||||
|
}
|
||||||
|
return { tokens };
|
||||||
|
}
|
||||||
|
|
||||||
function generateTestFile(size) {
|
function generateTestFile(size) {
|
||||||
const buffer = new Uint8Array(size);
|
const buffer = new Uint8Array(size);
|
||||||
for (let i = 0; i < size; i++) {
|
for (let i = 0; i < size; i++) {
|
||||||
|
|
@ -60,7 +97,14 @@ function createMultipartBody(fields, fileField, fileData, filename, contentType)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function testSimpleUpload() {
|
function getToken(data) {
|
||||||
|
if (AUTH_TOKEN) return AUTH_TOKEN;
|
||||||
|
const tokens = data?.tokens || [];
|
||||||
|
if (tokens.length === 0) return '';
|
||||||
|
return tokens[Math.floor(Math.random() * tokens.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function testSimpleUpload(token) {
|
||||||
const filename = `test_${Date.now()}_${Math.random().toString(36).substring(7)}.mp3`;
|
const filename = `test_${Date.now()}_${Math.random().toString(36).substring(7)}.mp3`;
|
||||||
const fileSize = Math.min(CHUNK_SIZE * 2, 1024 * 1024);
|
const fileSize = Math.min(CHUNK_SIZE * 2, 1024 * 1024);
|
||||||
const fileData = generateTestFile(fileSize);
|
const fileData = generateTestFile(fileSize);
|
||||||
|
|
@ -73,7 +117,7 @@ function testSimpleUpload() {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const res = http.post(`${BASE_URL}/api/v1/tracks`, multipart.body, {
|
const res = http.post(`${BASE_URL}/api/v1/tracks`, multipart.body, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${AUTH_TOKEN}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': multipart.contentType,
|
'Content-Type': multipart.contentType,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -86,7 +130,7 @@ function testSimpleUpload() {
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
function testChunkedUpload() {
|
function testChunkedUpload(token) {
|
||||||
const filename = `test_chunked_${Date.now()}_${Math.random().toString(36).substring(7)}.mp3`;
|
const filename = `test_chunked_${Date.now()}_${Math.random().toString(36).substring(7)}.mp3`;
|
||||||
const totalSize = CHUNK_SIZE * TOTAL_CHUNKS;
|
const totalSize = CHUNK_SIZE * TOTAL_CHUNKS;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
@ -96,7 +140,7 @@ function testChunkedUpload() {
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${AUTH_TOKEN}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -126,7 +170,7 @@ function testChunkedUpload() {
|
||||||
const multipart = createMultipartBody(fields, 'chunk', chunkData, `chunk${chunkNum}.bin`, 'application/octet-stream');
|
const multipart = createMultipartBody(fields, 'chunk', chunkData, `chunk${chunkNum}.bin`, 'application/octet-stream');
|
||||||
const chunkRes = http.post(`${BASE_URL}/api/v1/tracks/chunk`, multipart.body, {
|
const chunkRes = http.post(`${BASE_URL}/api/v1/tracks/chunk`, multipart.body, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${AUTH_TOKEN}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': multipart.contentType,
|
'Content-Type': multipart.contentType,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -144,7 +188,7 @@ function testChunkedUpload() {
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${AUTH_TOKEN}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -157,27 +201,28 @@ function testChunkedUpload() {
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function () {
|
export default function (data) {
|
||||||
if (!AUTH_TOKEN) {
|
const token = getToken(data);
|
||||||
console.error('AUTH_TOKEN is required for upload tests');
|
if (!token) {
|
||||||
|
if (!AUTH_TOKEN) console.error('AUTH_TOKEN or setup users required for upload tests');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rand = Math.random();
|
const rand = Math.random();
|
||||||
if (rand < 0.5) {
|
if (rand < 0.5) {
|
||||||
testSimpleUpload();
|
testSimpleUpload(token);
|
||||||
} else if (rand < 0.9) {
|
} else if (rand < 0.9) {
|
||||||
testChunkedUpload();
|
testChunkedUpload(token);
|
||||||
} else {
|
} else {
|
||||||
const filename = `test_batch_${Date.now()}.mp3`;
|
const filename = `test_batch_${Date.now()}.mp3`;
|
||||||
const fileData = generateTestFile(Math.min(CHUNK_SIZE, 1024 * 1024));
|
const fileData = generateTestFile(Math.min(CHUNK_SIZE, 1024 * 1024));
|
||||||
const multipart = createMultipartBody({}, 'files', fileData, filename, 'audio/mpeg');
|
const multipart = createMultipartBody({}, 'files', fileData, filename, 'audio/mpeg');
|
||||||
const res = http.post(`${BASE_URL}/api/v1/uploads/batch`, multipart.body, {
|
const res = http.post(`${BASE_URL}/api/v1/uploads/batch`, multipart.body, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${AUTH_TOKEN}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': multipart.contentType,
|
'Content-Type': multipart.contentType,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
check(res, { 'batch status 200 or 201': (r) => r.status === 200 || r.status === 201 });
|
check(res, { 'batch status 200 or 201': (r) => r.status === 200 || r.status === 201 });
|
||||||
}
|
}
|
||||||
sleep(2);
|
sleep(1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
151
loadtests/chat/stress_1000ws.js
Normal file
151
loadtests/chat/stress_1000ws.js
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
/**
|
||||||
|
* Stress test: 1000 WebSocket concurrent connections (v0.951)
|
||||||
|
* Usage: k6 run loadtests/chat/stress_1000ws.js
|
||||||
|
* Requires: Backend API + Chat server running
|
||||||
|
* Prérequis: Mémoire serveur < 2GB pendant le test
|
||||||
|
*/
|
||||||
|
import ws from 'k6/ws';
|
||||||
|
import { check, sleep } from 'k6';
|
||||||
|
import { Rate, Trend, Counter } from 'k6/metrics';
|
||||||
|
import { randomString } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
|
||||||
|
import http from 'k6/http';
|
||||||
|
|
||||||
|
const API_ORIGIN = __ENV.API_ORIGIN || __ENV.BASE_URL || 'http://localhost:8080';
|
||||||
|
const CHAT_ORIGIN = __ENV.CHAT_ORIGIN || 'ws://localhost:8081';
|
||||||
|
const WS_BASE = CHAT_ORIGIN.startsWith('ws') ? CHAT_ORIGIN : `ws://${CHAT_ORIGIN.replace(/^https?:\/\//, '')}`;
|
||||||
|
const WS_URL = WS_BASE.endsWith('/ws') ? WS_BASE : `${WS_BASE.replace(/\/?$/, '')}/ws`;
|
||||||
|
const TEST_EMAIL_PREFIX = __ENV.TEST_EMAIL_PREFIX || 'user+st1k';
|
||||||
|
const TEST_EMAIL_DOMAIN = __ENV.TEST_EMAIL_DOMAIN || 'example.com';
|
||||||
|
const TEST_PASSWORD_PREFIX = __ENV.TEST_PASSWORD_PREFIX || 'V3za!st1k-';
|
||||||
|
|
||||||
|
const wsConnectionTime = new Trend('ws_connection_time');
|
||||||
|
const wsConnectionFailures = new Rate('ws_connection_failures');
|
||||||
|
const wsMessageFailures = new Rate('ws_message_failures');
|
||||||
|
const messagesReceived = new Counter('messages_received');
|
||||||
|
const messagesSent = new Counter('messages_sent');
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
websocket_stress: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
stages: [
|
||||||
|
{ duration: '1m', target: 200 },
|
||||||
|
{ duration: '1m', target: 500 },
|
||||||
|
{ duration: '1m', target: 1000 },
|
||||||
|
{ duration: '5m', target: 1000 },
|
||||||
|
{ duration: '1m', target: 0 },
|
||||||
|
],
|
||||||
|
gracefulRampDown: '60s',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
ws_connection_time: ['p(95)<1000', 'p(99)<2000'],
|
||||||
|
ws_connection_failures: ['rate<0.01'],
|
||||||
|
ws_message_failures: ['rate<0.01'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function createAuthenticatedUser() {
|
||||||
|
const rand = randomString(8);
|
||||||
|
const pwd = `${TEST_PASSWORD_PREFIX}${rand}`;
|
||||||
|
const user = {
|
||||||
|
email: `${TEST_EMAIL_PREFIX}${rand}@${TEST_EMAIL_DOMAIN}`,
|
||||||
|
password: pwd,
|
||||||
|
password_confirmation: pwd,
|
||||||
|
username: `st1k${rand}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerRes = http.post(`${API_ORIGIN}/api/v1/auth/register`, JSON.stringify(user), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
if (registerRes.status !== 201) return null;
|
||||||
|
|
||||||
|
const loginRes = http.post(`${API_ORIGIN}/api/v1/auth/login`, JSON.stringify({ email: user.email, password: user.password }), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
if (loginRes.status !== 200) return null;
|
||||||
|
|
||||||
|
let accessToken = '';
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(loginRes.body);
|
||||||
|
accessToken = body.data?.token?.access_token || '';
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!accessToken) return null;
|
||||||
|
|
||||||
|
const chatTokenRes = http.get(`${API_ORIGIN}/api/v1/chat/token`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
if (chatTokenRes.status !== 200) return null;
|
||||||
|
|
||||||
|
let chatToken = '';
|
||||||
|
try {
|
||||||
|
const ctBody = JSON.parse(chatTokenRes.body);
|
||||||
|
chatToken = ctBody.data?.token || ctBody.token || '';
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!chatToken) return null;
|
||||||
|
|
||||||
|
return { ...user, chatToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setup() {
|
||||||
|
const users = [];
|
||||||
|
for (let i = 0; i < 1100; i++) {
|
||||||
|
const user = createAuthenticatedUser();
|
||||||
|
if (user) users.push(user);
|
||||||
|
if (i % 50 === 0) sleep(0.05);
|
||||||
|
}
|
||||||
|
return { users };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (data) {
|
||||||
|
const { users } = data;
|
||||||
|
if (users.length === 0) return;
|
||||||
|
|
||||||
|
const user = users[Math.floor(Math.random() * users.length)];
|
||||||
|
const url = `${WS_URL}?token=${user.chatToken}`;
|
||||||
|
const connectionStart = Date.now();
|
||||||
|
|
||||||
|
const res = ws.connect(url, {}, function (socket) {
|
||||||
|
socket.on('open', () => {
|
||||||
|
wsConnectionTime.add(Date.now() - connectionStart);
|
||||||
|
|
||||||
|
socket.send(JSON.stringify({ type: 'join', room: 'general' }));
|
||||||
|
|
||||||
|
socket.setInterval(() => {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
room: 'general',
|
||||||
|
content: `Stress ${Date.now()}`,
|
||||||
|
}));
|
||||||
|
messagesSent.add(1);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
socket.setTimeout(() => {
|
||||||
|
socket.close();
|
||||||
|
}, 300000);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('message', (data) => {
|
||||||
|
messagesReceived.add(1);
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data);
|
||||||
|
if (msg.error) wsMessageFailures.add(1);
|
||||||
|
} catch (e) {
|
||||||
|
wsMessageFailures.add(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', () => {
|
||||||
|
wsConnectionFailures.add(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
check(res, { 'WebSocket connection successful': (r) => r && r.status === 101 });
|
||||||
|
if (!res || res.status !== 101) {
|
||||||
|
wsConnectionFailures.add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- v0.951: Performance indexes for load test critical queries
|
||||||
|
-- Targets: ListProducts (status + created_at), auth sessions, tracks list
|
||||||
|
|
||||||
|
-- products: ListProducts default query (status=active ORDER BY created_at DESC)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_status_created_at ON public.products(status, created_at DESC);
|
||||||
|
|
||||||
|
-- products: Filter by seller (seller dashboard)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_seller_id_status ON public.products(seller_id, status);
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- v0.951: Rollback performance indexes
|
||||||
|
DROP INDEX IF EXISTS public.idx_products_status_created_at;
|
||||||
|
DROP INDEX IF EXISTS public.idx_products_seller_id_status;
|
||||||
Loading…
Reference in a new issue