From d4e76a2ab4633dab973ff1f9f7ffd9da5d27dfd5 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Sat, 6 Jun 2026 12:49:19 +0330 Subject: [PATCH] load test --- load-tests/.gitignore | 1 + load-tests/hot-endpoints.js | 204 ++++++++++++++++++++++++++++++++++++ package.json | 4 + 3 files changed, 209 insertions(+) create mode 100644 load-tests/.gitignore create mode 100644 load-tests/hot-endpoints.js diff --git a/load-tests/.gitignore b/load-tests/.gitignore new file mode 100644 index 0000000..fbca225 --- /dev/null +++ b/load-tests/.gitignore @@ -0,0 +1 @@ +results/ diff --git a/load-tests/hot-endpoints.js b/load-tests/hot-endpoints.js new file mode 100644 index 0000000..499f577 --- /dev/null +++ b/load-tests/hot-endpoints.js @@ -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), + }; +} diff --git a/package.json b/package.json index b1a9d4b..fdbe1d4 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", + "load:hot": "k6 run -e BASE_URL=https://dmenu-api.danakcorp.com load-tests/hot-endpoints.js", + "load:hot:burst": "k6 run -e BASE_URL=https://dmenu-api.danakcorp.com -e TEST_PROFILE=concurrent_burst load-tests/hot-endpoints.js", + "load:hot:herd": "k6 run -e BASE_URL=https://dmenu-api.danakcorp.com -e TEST_PROFILE=thundering_herd load-tests/hot-endpoints.js", + "load:hot:smoke": "k6 run -e BASE_URL=https://dmenu-api.danakcorp.com -e TEST_PROFILE=smoke load-tests/hot-endpoints.js", "migration:create": "npx mikro-orm migration:create --config ./src/config/mikro-orm.config.dev.ts", "migration:up": "npx mikro-orm migration:up --config ./src/config/mikro-orm.config.dev.ts", "migration:down": "npx mikro-orm migration:down --config ./src/config/mikro-orm.config.dev.ts",