feat(v0.12.4): k6 load test for 1000 concurrent users
Three scenarios: smoke (10 VUs), load (500 VUs), stress (1000 VUs). Tests tracks listing, search, track detail, and user profiles. Thresholds: p95 < 100ms, p99 < 200ms, error rate < 1%. Custom metrics for cache hit ratio tracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ade46fc70f
commit
df8ce52a1e
1 changed files with 192 additions and 0 deletions
192
loadtests/backend/performance_v0124.js
Normal file
192
loadtests/backend/performance_v0124.js
Normal file
|
|
@ -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 {};
|
||||
}
|
||||
Loading…
Reference in a new issue