load test
This commit is contained in:
@@ -0,0 +1 @@
|
||||
results/
|
||||
@@ -0,0 +1,204 @@
|
||||
import http from 'k6/http';
|
||||
import { check, group } from 'k6';
|
||||
import { Rate, Trend } from 'k6/metrics';
|
||||
|
||||
/**
|
||||
* Load test for hot public menu endpoints.
|
||||
*
|
||||
* Usage:
|
||||
* k6 run load-tests/hot-endpoints.js
|
||||
* k6 run -e BASE_URL=http://localhost:2000 -e RESTAURANT_SLUG=zhivan load-tests/hot-endpoints.js
|
||||
* k6 run --scenario concurrent_burst load-tests/hot-endpoints.js
|
||||
* k6 run --scenario sustained_load -e VUS=100 load-tests/hot-endpoints.js
|
||||
*
|
||||
* Scenarios:
|
||||
* smoke - quick sanity check (default when TEST_PROFILE=smoke)
|
||||
* concurrent_burst - many VUs hit all 3 endpoints at once (reproduces hang)
|
||||
* sustained_load - ramp up and hold steady traffic
|
||||
* thundering_herd - same slug, no think time, maximum cache-miss pressure
|
||||
*/
|
||||
|
||||
const BASE_URL = __ENV.BASE_URL || 'http://localhost:2000';
|
||||
const SLUG = __ENV.RESTAURANT_SLUG || 'zhivan';
|
||||
const REQUEST_TIMEOUT = __ENV.REQUEST_TIMEOUT || '30s';
|
||||
|
||||
const restaurantLatency = new Trend('restaurant_latency', true);
|
||||
const foodsLatency = new Trend('foods_latency', true);
|
||||
const categoriesLatency = new Trend('categories_latency', true);
|
||||
const menuPageLatency = new Trend('menu_page_latency', true);
|
||||
const errors = new Rate('errors');
|
||||
|
||||
const ENDPOINTS = {
|
||||
restaurant: `/public/restaurants/${SLUG}`,
|
||||
foods: `/public/foods/restaurant/${SLUG}`,
|
||||
categories: `/public/categories/restaurant/${SLUG}`,
|
||||
};
|
||||
|
||||
const defaultVus = Number(__ENV.VUS || 50);
|
||||
const defaultDuration = __ENV.DURATION || '1m';
|
||||
|
||||
const scenarios = {
|
||||
smoke: {
|
||||
executor: 'shared-iterations',
|
||||
vus: 2,
|
||||
iterations: 6,
|
||||
maxDuration: '1m',
|
||||
tags: { profile: 'smoke' },
|
||||
},
|
||||
concurrent_burst: {
|
||||
executor: 'shared-iterations',
|
||||
vus: defaultVus,
|
||||
iterations: Number(__ENV.ITERATIONS || defaultVus * 10),
|
||||
maxDuration: '5m',
|
||||
tags: { profile: 'concurrent_burst' },
|
||||
},
|
||||
sustained_load: {
|
||||
executor: 'ramping-vus',
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: '30s', target: Math.floor(defaultVus / 2) },
|
||||
{ duration: defaultDuration, target: defaultVus },
|
||||
{ duration: '30s', target: 0 },
|
||||
],
|
||||
gracefulRampDown: '10s',
|
||||
tags: { profile: 'sustained_load' },
|
||||
},
|
||||
thundering_herd: {
|
||||
executor: 'constant-vus',
|
||||
vus: defaultVus,
|
||||
duration: defaultDuration,
|
||||
tags: { profile: 'thundering_herd' },
|
||||
},
|
||||
};
|
||||
|
||||
const profile = __ENV.TEST_PROFILE;
|
||||
const activeScenarios =
|
||||
profile && scenarios[profile]
|
||||
? { [profile]: scenarios[profile] }
|
||||
: {
|
||||
smoke: scenarios.smoke,
|
||||
concurrent_burst: scenarios.concurrent_burst,
|
||||
sustained_load: scenarios.sustained_load,
|
||||
};
|
||||
|
||||
export const options = {
|
||||
scenarios: activeScenarios,
|
||||
thresholds: {
|
||||
http_req_duration: ['p(95)<5000', 'p(99)<10000'],
|
||||
http_req_failed: ['rate<0.1'],
|
||||
errors: ['rate<0.1'],
|
||||
restaurant_latency: ['p(95)<3000'],
|
||||
foods_latency: ['p(95)<5000'],
|
||||
categories_latency: ['p(95)<3000'],
|
||||
menu_page_latency: ['p(95)<8000'],
|
||||
},
|
||||
summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'],
|
||||
};
|
||||
|
||||
function headers() {
|
||||
return { 'X-Slug': SLUG, Accept: 'application/json' };
|
||||
}
|
||||
|
||||
function parseBody(res) {
|
||||
try {
|
||||
return JSON.parse(res.body);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function assertSuccess(res, name) {
|
||||
const body = parseBody(res);
|
||||
return check(res, {
|
||||
[`${name} status 200`]: (r) => r.status === 200,
|
||||
[`${name} responds in time`]: (r) => r.timings.duration < 10000,
|
||||
[`${name} success true`]: () => body?.success === true,
|
||||
[`${name} has data`]: () => body?.data !== undefined && body?.data !== null,
|
||||
});
|
||||
}
|
||||
|
||||
function getEndpoint(path, metric, tag) {
|
||||
const res = http.get(`${BASE_URL}${path}`, {
|
||||
headers: headers(),
|
||||
tags: { endpoint: tag },
|
||||
timeout: REQUEST_TIMEOUT,
|
||||
});
|
||||
|
||||
metric.add(res.timings.duration);
|
||||
const ok = assertSuccess(res, tag);
|
||||
errors.add(!ok);
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Simulates a real menu page: all 3 endpoints requested in parallel. */
|
||||
function loadMenuPage() {
|
||||
const started = Date.now();
|
||||
|
||||
const responses = http.batch([
|
||||
['GET', `${BASE_URL}${ENDPOINTS.restaurant}`, null, { headers: headers(), tags: { endpoint: 'restaurant' }, timeout: REQUEST_TIMEOUT }],
|
||||
['GET', `${BASE_URL}${ENDPOINTS.foods}`, null, { headers: headers(), tags: { endpoint: 'foods' }, timeout: REQUEST_TIMEOUT }],
|
||||
['GET', `${BASE_URL}${ENDPOINTS.categories}`, null, { headers: headers(), tags: { endpoint: 'categories' }, timeout: REQUEST_TIMEOUT }],
|
||||
]);
|
||||
|
||||
restaurantLatency.add(responses[0].timings.duration);
|
||||
foodsLatency.add(responses[1].timings.duration);
|
||||
categoriesLatency.add(responses[2].timings.duration);
|
||||
menuPageLatency.add(Date.now() - started);
|
||||
|
||||
const ok =
|
||||
assertSuccess(responses[0], 'restaurant') &&
|
||||
assertSuccess(responses[1], 'foods') &&
|
||||
assertSuccess(responses[2], 'categories');
|
||||
|
||||
errors.add(!ok);
|
||||
}
|
||||
|
||||
export function setup() {
|
||||
const res = http.get(`${BASE_URL}${ENDPOINTS.restaurant}`, {
|
||||
headers: headers(),
|
||||
timeout: REQUEST_TIMEOUT,
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(
|
||||
`Setup failed: ${BASE_URL}${ENDPOINTS.restaurant} returned ${res.status}. ` +
|
||||
`Is the API running? Try: npm run start:dev`,
|
||||
);
|
||||
}
|
||||
|
||||
return { baseUrl: BASE_URL, slug: SLUG };
|
||||
}
|
||||
|
||||
export default function () {
|
||||
const profileTag = __ENV.TEST_PROFILE || 'mixed';
|
||||
|
||||
if (profileTag === 'thundering_herd') {
|
||||
group('thundering_herd_single_endpoint', () => {
|
||||
getEndpoint(ENDPOINTS.foods, foodsLatency, 'foods');
|
||||
getEndpoint(ENDPOINTS.categories, categoriesLatency, 'categories');
|
||||
getEndpoint(ENDPOINTS.restaurant, restaurantLatency, 'restaurant');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
group('menu_page_parallel', loadMenuPage);
|
||||
}
|
||||
|
||||
export function handleSummary(data) {
|
||||
const p95 = (name) => data.metrics[name]?.values?.['p(95)']?.toFixed(2) ?? 'n/a';
|
||||
const failed = data.metrics.http_req_failed?.values?.rate ?? 0;
|
||||
|
||||
console.log('\n--- Hot endpoints load test summary ---');
|
||||
console.log(`Target: ${BASE_URL} | slug: ${SLUG}`);
|
||||
console.log(`Failed requests: ${(failed * 100).toFixed(2)}%`);
|
||||
console.log(`Restaurant p95: ${p95('restaurant_latency')} ms`);
|
||||
console.log(`Foods p95: ${p95('foods_latency')} ms`);
|
||||
console.log(`Categories p95: ${p95('categories_latency')} ms`);
|
||||
console.log(`Menu page p95: ${p95('menu_page_latency')} ms`);
|
||||
console.log('---------------------------------------\n');
|
||||
|
||||
return {
|
||||
stdout: JSON.stringify(data, null, 2),
|
||||
'load-tests/results/hot-endpoints-summary.json': JSON.stringify(data, null, 2),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user