Files
dmenu-api/load-tests/hot-endpoints.js
T
2026-06-06 12:49:19 +03:30

205 lines
6.4 KiB
JavaScript

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),
};
}