193 lines
6 KiB
JavaScript
193 lines
6 KiB
JavaScript
|
|
/**
|
||
|
|
* 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 {};
|
||
|
|
}
|