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