diff --git a/loadtests/backend/performance_v0124.js b/loadtests/backend/performance_v0124.js new file mode 100644 index 000000000..03741f96c --- /dev/null +++ b/loadtests/backend/performance_v0124.js @@ -0,0 +1,192 @@ +/** + * v0.12.4 Performance & Scalability Load Test + * Reference: ORIGIN_PERFORMANCE_TARGETS.md §8.4 + * + * Scenarios: + * - smoke: 10 VUs, 1 min (sanity check) + * - load: 500 VUs, 5 min (normal traffic) + * - stress: 1000 VUs, 3 min (peak load) + * + * Usage: + * k6 run loadtests/backend/performance_v0124.js + * k6 run --env SCENARIO=smoke loadtests/backend/performance_v0124.js + * k6 run --env SCENARIO=stress loadtests/backend/performance_v0124.js + */ +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Rate, Counter, Trend } from 'k6/metrics'; + +// Custom metrics +const errorRate = new Rate('error_rate'); +const cacheHits = new Counter('cache_hits'); +const cacheMisses = new Counter('cache_misses'); +const apiDuration = new Trend('api_duration', true); + +const BASE_URL = __ENV.BASE_URL || __ENV.API_ORIGIN || 'http://localhost:8080'; +const AUTH_TOKEN = __ENV.AUTH_TOKEN || ''; +const SCENARIO = __ENV.SCENARIO || 'load'; + +// Scenario configurations +const scenarios = { + smoke: { + stages: [ + { duration: '15s', target: 5 }, + { duration: '30s', target: 10 }, + { duration: '15s', target: 0 }, + ], + }, + load: { + stages: [ + { duration: '30s', target: 50 }, + { duration: '1m', target: 200 }, + { duration: '2m', target: 500 }, + { duration: '1m', target: 200 }, + { duration: '30s', target: 0 }, + ], + }, + stress: { + stages: [ + { duration: '30s', target: 100 }, + { duration: '1m', target: 500 }, + { duration: '1m', target: 1000 }, + { duration: '30s', target: 1000 }, + { duration: '30s', target: 0 }, + ], + }, +}; + +export const options = { + stages: scenarios[SCENARIO]?.stages || scenarios.load.stages, + thresholds: { + // p95 < 100ms for main API endpoints + http_req_duration: ['p(95)<100', 'p(99)<200'], + // Error rate < 1% + error_rate: ['rate<0.01'], + // Custom API duration + api_duration: ['p(95)<100', 'p(99)<200'], + }, + // Graceful stop + gracefulStop: '10s', +}; + +function headers() { + const h = { 'Content-Type': 'application/json' }; + if (AUTH_TOKEN) { + h['Authorization'] = `Bearer ${AUTH_TOKEN}`; + } + return h; +} + +export default function () { + const h = headers(); + + // Health check (should always be fast) + group('health', () => { + const res = http.get(`${BASE_URL}/health`, { headers: h }); + check(res, { 'health 200': (r) => r.status === 200 }); + errorRate.add(res.status >= 500); + }); + + // Track listing (most frequent endpoint) + group('tracks_list', () => { + const res = http.get(`${BASE_URL}/api/v1/tracks?page=1&limit=20`, { + headers: h, + }); + check(res, { + 'tracks list 2xx or 401': (r) => (r.status >= 200 && r.status < 300) || r.status === 401, + }); + errorRate.add(res.status >= 500); + apiDuration.add(res.timings.duration); + + // Check cache header + if (res.headers['X-Cache'] === 'HIT') { + cacheHits.add(1); + } else { + cacheMisses.add(1); + } + }); + sleep(0.3); + + // Search endpoint + group('search', () => { + const queries = ['rock', 'jazz', 'piano', 'guitar', 'beat']; + const q = queries[Math.floor(Math.random() * queries.length)]; + const res = http.get(`${BASE_URL}/api/v1/search?q=${q}&limit=10`, { + headers: h, + }); + check(res, { + 'search 2xx or 401': (r) => (r.status >= 200 && r.status < 300) || r.status === 401, + }); + errorRate.add(res.status >= 500); + apiDuration.add(res.timings.duration); + }); + sleep(0.3); + + // Track detail (if tracks available) + group('track_detail', () => { + const listRes = http.get(`${BASE_URL}/api/v1/tracks?page=1&limit=5`, { + headers: h, + }); + if (listRes.status === 200) { + try { + const body = JSON.parse(listRes.body); + const tracks = body.data?.tracks || body.data || body.tracks || []; + if (Array.isArray(tracks) && tracks.length > 0) { + const trackId = tracks[0].id || tracks[0].ID; + if (trackId) { + const detailRes = http.get( + `${BASE_URL}/api/v1/tracks/${trackId}`, + { headers: h } + ); + check(detailRes, { + 'track detail 2xx or 404': (r) => + (r.status >= 200 && r.status < 300) || r.status === 404, + }); + errorRate.add(detailRes.status >= 500); + apiDuration.add(detailRes.timings.duration); + } + } + } catch (_e) { + // Parse error — skip + } + } + }); + sleep(0.3); + + // User profile endpoint + group('user_profiles', () => { + const res = http.get(`${BASE_URL}/api/v1/users?page=1&limit=10`, { + headers: h, + }); + check(res, { + 'users list 2xx or 401': (r) => (r.status >= 200 && r.status < 300) || r.status === 401, + }); + errorRate.add(res.status >= 500); + apiDuration.add(res.timings.duration); + }); + sleep(0.5); +} + +export function handleSummary(data) { + const p95 = data.metrics.http_req_duration?.values?.['p(95)'] || 'N/A'; + const p99 = data.metrics.http_req_duration?.values?.['p(99)'] || 'N/A'; + const errRate = data.metrics.error_rate?.values?.rate || 0; + const hits = data.metrics.cache_hits?.values?.count || 0; + const misses = data.metrics.cache_misses?.values?.count || 0; + const hitRatio = hits + misses > 0 ? ((hits / (hits + misses)) * 100).toFixed(1) : '0'; + + console.log(` +═══════════════════════════════════════════ + v0.12.4 Performance Test Results (${SCENARIO}) +═══════════════════════════════════════════ + p95 latency: ${typeof p95 === 'number' ? p95.toFixed(2) : p95}ms (target: <100ms) + p99 latency: ${typeof p99 === 'number' ? p99.toFixed(2) : p99}ms (target: <200ms) + Error rate: ${(errRate * 100).toFixed(2)}% (target: <1%) + Cache hit ratio: ${hitRatio}% (target: >90%) + Cache hits: ${hits} + Cache misses: ${misses} +═══════════════════════════════════════════ + `); + + return {}; +}