chore(release): v0.951 — Loadtest (500 req/s, 1000 WS, 50 uploads, perf indexes)
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s

This commit is contained in:
senke 2026-03-02 19:22:38 +01:00
parent b52f209636
commit da837fc085
9 changed files with 383 additions and 24 deletions

View file

@ -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

View file

@ -1 +1 @@
0.962 0.951

View file

@ -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

View file

@ -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 0100250500 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
``` ```

View 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);
}

View file

@ -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);
} }

View 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);
}
}

View file

@ -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);

View file

@ -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;