From da837fc085d14b7a0e8c6ca99fa67c078dcb4bcf Mon Sep 17 00:00:00 2001 From: senke Date: Mon, 2 Mar 2026 19:22:38 +0100 Subject: [PATCH] =?UTF-8?q?chore(release):=20v0.951=20=E2=80=94=20Loadtest?= =?UTF-8?q?=20(500=20req/s,=201000=20WS,=2050=20uploads,=20perf=20indexes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 15 ++ VERSION | 2 +- docs/PERFORMANCE_BASELINE.md | 18 ++- loadtests/README.md | 20 ++- loadtests/backend/stress_500rps.js | 111 +++++++++++++ loadtests/backend/uploads.js | 79 +++++++-- loadtests/chat/stress_1000ws.js | 151 ++++++++++++++++++ .../940_performance_indexes_v0951.sql | 8 + .../940_performance_indexes_v0951_down.sql | 3 + 9 files changed, 383 insertions(+), 24 deletions(-) create mode 100644 loadtests/backend/stress_500rps.js create mode 100644 loadtests/chat/stress_1000ws.js create mode 100644 veza-backend-api/migrations/940_performance_indexes_v0951.sql create mode 100644 veza-backend-api/migrations/940_performance_indexes_v0951_down.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 890e77880..040104aa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # 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 ### Added diff --git a/VERSION b/VERSION index 8e5c86d18..233eed873 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.962 +0.951 diff --git a/docs/PERFORMANCE_BASELINE.md b/docs/PERFORMANCE_BASELINE.md index baca017d1..093827a53 100644 --- a/docs/PERFORMANCE_BASELINE.md +++ b/docs/PERFORMANCE_BASELINE.md @@ -1,6 +1,6 @@ # 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. ## Méthodologie @@ -24,11 +24,23 @@ | `/api/v1/analytics/me` | GET | Analytics | | `/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 +## 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 ```bash diff --git a/loadtests/README.md b/loadtests/README.md index 0a0c4fd02..4daddb184 100644 --- a/loadtests/README.md +++ b/loadtests/README.md @@ -36,8 +36,10 @@ k6 run loadtests/backend/auth.js # Tracks (liste, search, détail) 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 +# v0.951: 50 uploads concurrents pendant 2 min +k6 run loadtests/backend/uploads.js # Playlists k6 run loadtests/backend/playlists.js @@ -47,6 +49,12 @@ k6 run loadtests/backend/marketplace.js # Scénario combiné (health, auth, tracks, playlists, marketplace) 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 @@ -61,6 +69,10 @@ k6 run loadtests/stream/http.js ```bash # Backend API + Chat server requis 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 @@ -94,9 +106,11 @@ loadtests/ │ ├── uploads.js │ ├── playlists.js │ ├── marketplace.js -│ └── full.js +│ ├── full.js +│ └── stress_500rps.js ├── stream/ │ └── http.js └── chat/ - └── websocket.js + ├── websocket.js + └── stress_1000ws.js ``` diff --git a/loadtests/backend/stress_500rps.js b/loadtests/backend/stress_500rps.js new file mode 100644 index 000000000..a29c4e3e6 --- /dev/null +++ b/loadtests/backend/stress_500rps.js @@ -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); +} diff --git a/loadtests/backend/uploads.js b/loadtests/backend/uploads.js index b6b0bbb5f..41ec01929 100644 --- a/loadtests/backend/uploads.js +++ b/loadtests/backend/uploads.js @@ -1,11 +1,13 @@ /** * Load test: upload simple, chunked, batch * 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 { 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'; const errorRate = new Rate('errors'); 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 CHUNK_SIZE = parseInt(__ENV.CHUNK_SIZE || '1048576'); 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 = { stages: [ - { duration: '30s', target: 5 }, - { duration: '2m', target: 10 }, - { duration: '2m', target: 10 }, + { duration: '1m', target: 50 }, + { duration: '2m', target: 50 }, { duration: '30s', target: 0 }, ], 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) { const buffer = new Uint8Array(size); 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 fileSize = Math.min(CHUNK_SIZE * 2, 1024 * 1024); const fileData = generateTestFile(fileSize); @@ -73,7 +117,7 @@ function testSimpleUpload() { const startTime = Date.now(); const res = http.post(`${BASE_URL}/api/v1/tracks`, multipart.body, { headers: { - Authorization: `Bearer ${AUTH_TOKEN}`, + Authorization: `Bearer ${token}`, 'Content-Type': multipart.contentType, }, }); @@ -86,7 +130,7 @@ function testSimpleUpload() { return success; } -function testChunkedUpload() { +function testChunkedUpload(token) { const filename = `test_chunked_${Date.now()}_${Math.random().toString(36).substring(7)}.mp3`; const totalSize = CHUNK_SIZE * TOTAL_CHUNKS; const startTime = Date.now(); @@ -96,7 +140,7 @@ function testChunkedUpload() { { headers: { '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 chunkRes = http.post(`${BASE_URL}/api/v1/tracks/chunk`, multipart.body, { headers: { - Authorization: `Bearer ${AUTH_TOKEN}`, + Authorization: `Bearer ${token}`, 'Content-Type': multipart.contentType, }, }); @@ -144,7 +188,7 @@ function testChunkedUpload() { { headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${AUTH_TOKEN}`, + Authorization: `Bearer ${token}`, }, } ); @@ -157,27 +201,28 @@ function testChunkedUpload() { return success; } -export default function () { - if (!AUTH_TOKEN) { - console.error('AUTH_TOKEN is required for upload tests'); +export default function (data) { + const token = getToken(data); + if (!token) { + if (!AUTH_TOKEN) console.error('AUTH_TOKEN or setup users required for upload tests'); return; } const rand = Math.random(); if (rand < 0.5) { - testSimpleUpload(); + testSimpleUpload(token); } else if (rand < 0.9) { - testChunkedUpload(); + testChunkedUpload(token); } else { const filename = `test_batch_${Date.now()}.mp3`; const fileData = generateTestFile(Math.min(CHUNK_SIZE, 1024 * 1024)); const multipart = createMultipartBody({}, 'files', fileData, filename, 'audio/mpeg'); const res = http.post(`${BASE_URL}/api/v1/uploads/batch`, multipart.body, { headers: { - Authorization: `Bearer ${AUTH_TOKEN}`, + Authorization: `Bearer ${token}`, 'Content-Type': multipart.contentType, }, }); check(res, { 'batch status 200 or 201': (r) => r.status === 200 || r.status === 201 }); } - sleep(2); + sleep(1); } diff --git a/loadtests/chat/stress_1000ws.js b/loadtests/chat/stress_1000ws.js new file mode 100644 index 000000000..ee90444b3 --- /dev/null +++ b/loadtests/chat/stress_1000ws.js @@ -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); + } +} diff --git a/veza-backend-api/migrations/940_performance_indexes_v0951.sql b/veza-backend-api/migrations/940_performance_indexes_v0951.sql new file mode 100644 index 000000000..a8bdeea00 --- /dev/null +++ b/veza-backend-api/migrations/940_performance_indexes_v0951.sql @@ -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); diff --git a/veza-backend-api/migrations/940_performance_indexes_v0951_down.sql b/veza-backend-api/migrations/940_performance_indexes_v0951_down.sql new file mode 100644 index 000000000..195d8dada --- /dev/null +++ b/veza-backend-api/migrations/940_performance_indexes_v0951_down.sql @@ -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;