diff --git a/.gitignore b/.gitignore index 84f3c30..3d5c684 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,7 @@ go.work.sum # Air (hot reload) tmp/ -build-errors.log \ No newline at end of file +build-errors.log + + +loadtest/scripts/tokens.json diff --git a/loadtest/scripts/01_baseline.js b/loadtest/scripts/01_baseline.js new file mode 100644 index 0000000..7f2daf3 --- /dev/null +++ b/loadtest/scripts/01_baseline.js @@ -0,0 +1,31 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { BASE_URL, DEFAULT_HEADERS } from './lib/config.js'; +import { setupEvent } from './lib/setup.js'; +import { getToken } from './lib/tokens.js'; + +export const options = { + vus: 50, + duration: '2m', + thresholds: { + http_req_failed: ['rate<0.01'], + 'http_req_duration{name:join_queue}': ['p(99)<500'], + }, +}; + +export function setup() { + return setupEvent({ name: 'Baseline Queue Test' }); +} + +export default function (data) { + const token = getToken(__VU); + + const res = http.post(`${BASE_URL}/events/${data.eventID}/queue`, null, { + headers: DEFAULT_HEADERS(token), + tags: { name: 'join_queue' }, + }); + + check(res, { + 'joined queue': (r) => r.status === 201, + }); +} \ No newline at end of file diff --git a/loadtest/scripts/01_thundering_herd.js b/loadtest/scripts/01_thundering_herd.js deleted file mode 100644 index 8b4dbbb..0000000 --- a/loadtest/scripts/01_thundering_herd.js +++ /dev/null @@ -1,100 +0,0 @@ -import http from 'k6/http'; -import { check, sleep } from 'k6'; -import { SharedArray } from 'k6/data'; - -// Test configuration -export const options = { - // Ramp up to 50,000 VUs over 60 seconds, hold briefly, ramp down - stages: [ - { duration: '60s', target: 50000 }, - { duration: '30s', target: 50000 }, - { duration: '30s', target: 0 }, - ], - thresholds: { - // 99% of queue join requests must complete under 500ms - 'http_req_duration{name:join_queue}': ['p(99)<500'], - // Less than 1% of requests should fail - 'http_req_failed': ['rate<0.01'], - }, -}; - -const BASE_URL = __ENV.BASE_URL || 'https://loadtest-api.tulcanvcm.com'; -const EVENT_ID = __ENV.EVENT_ID; - -export function setup() { - // Create organizer and event once before all VUs start - // Returns data that every VU receives - const orgResp = http.post(`${BASE_URL}/auth/organizer/login`, JSON.stringify({ - email: __ENV.ORGANIZER_EMAIL, - password: __ENV.ORGANIZER_PASSWORD, - }), { headers: { 'Content-Type': 'application/json' } }); - - const orgToken = orgResp.json('token'); - - const eventResp = http.post(`${BASE_URL}/events`, JSON.stringify({ - name: 'Load Test Event', - total_inventory: 5000, - price: 25000, - sale_start: new Date(Date.now() - 3600000).toISOString(), - sale_end: new Date(Date.now() + 86400000).toISOString(), - }), { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${orgToken}`, - }, - }); - - const eventID = eventResp.json('data.id'); - - // Activate the event - http.put(`${BASE_URL}/events/${eventID}/activate`, null, { - headers: { 'Authorization': `Bearer ${orgToken}` }, - }); - - return { eventID }; -} - -export default function (data) { - const { eventID } = data; - - // Each VU is a unique customer — use VU ID to generate unique email - const email = `loadtest-user-${__VU}-${__ITER}@test.com`; - - // Step 1: Request OTP - http.post(`${BASE_URL}/auth/customer/otp/request`, - JSON.stringify({ email }), - { headers: { 'Content-Type': 'application/json' } } - ); - - // Step 2: Get OTP from load test helper endpoint - // We add a special endpoint to the load test server for this - const otpResp = http.get(`${BASE_URL}/loadtest/otp?email=${email}`); - const otp = otpResp.json('otp'); - - // Step 3: Verify OTP - const authResp = http.post(`${BASE_URL}/auth/customer/otp/verify`, - JSON.stringify({ email, otp }), - { headers: { 'Content-Type': 'application/json' } } - ); - const token = authResp.json('token'); - - // Step 4: Join queue — this is what we're stress testing - const joinResp = http.post( - `${BASE_URL}/events/${eventID}/queue`, - null, - { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - tags: { name: 'join_queue' }, // tag for threshold targeting - } - ); - - check(joinResp, { - 'joined queue successfully': r => r.status === 201, - 'has position': r => r.json('position') > 0, - }); - - // No sleep — we want maximum pressure on the join endpoint -} \ No newline at end of file diff --git a/loadtest/scripts/02_sustained.js b/loadtest/scripts/02_sustained.js new file mode 100644 index 0000000..edb5cbd --- /dev/null +++ b/loadtest/scripts/02_sustained.js @@ -0,0 +1,32 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; +import { BASE_URL, DEFAULT_HEADERS } from './lib/config.js'; +import { setupEvent } from './lib/setup.js'; +import { getToken } from './lib/tokens.js'; + +export const options = { + stages: [ + { duration: '5m', target: 500 }, + { duration: '30m', target: 500 }, + { duration: '5m', target: 0 }, + ], + thresholds: { + http_req_failed: ['rate<0.01'], + 'http_req_duration{name:join_queue}': ['p(99)<500'], + }, +}; + +export function setup() { + return setupEvent({ name: 'Sustained Queue Test' }); +} + +export default function (data) { + const token = getToken(__VU); + + http.post(`${BASE_URL}/events/${data.eventID}/queue`, null, { + headers: DEFAULT_HEADERS(token), + tags: { name: 'join_queue' }, + }); + + sleep(Math.random() * 3 + 1); +} \ No newline at end of file diff --git a/loadtest/scripts/02_sustained_load.js b/loadtest/scripts/02_sustained_load.js deleted file mode 100644 index 268ed71..0000000 --- a/loadtest/scripts/02_sustained_load.js +++ /dev/null @@ -1,130 +0,0 @@ -import http from 'k6/http'; -import { check, sleep } from 'k6'; - -export const options = { - stages: [ - { duration: '2m', target: 1000 }, // ramp up - { duration: '25m', target: 1000 }, // hold — watching for leaks - { duration: '3m', target: 0 }, // ramp down - ], - thresholds: { - 'http_req_duration{name:join_queue}': ['p(99)<500'], - 'http_req_duration{name:claim_ticket}': ['p(99)<500'], - 'http_req_failed': ['rate<0.01'], - }, -}; - -const BASE_URL = __ENV.BASE_URL || 'https://loadtest-api.tulcanvcm.com'; - -export function setup() { - // Same setup as scenario 1 — create organizer and event - // Using 2000 tickets so claims don't exhaust inventory - const orgResp = http.post(`${BASE_URL}/auth/organizer/login`, - JSON.stringify({ - email: __ENV.ORGANIZER_EMAIL, - password: __ENV.ORGANIZER_PASSWORD, - }), - { headers: { 'Content-Type': 'application/json' } } - ); - const orgToken = orgResp.json('token'); - - const eventResp = http.post(`${BASE_URL}/events`, JSON.stringify({ - name: 'Sustained Load Test Event', - total_inventory: 999999, // effectively unlimited for stability test - price: 25000, - sale_start: new Date(Date.now() - 3600000).toISOString(), - sale_end: new Date(Date.now() + 86400000).toISOString(), - }), { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${orgToken}`, - }, - }); - - const eventID = eventResp.json('data.id'); - http.put(`${BASE_URL}/events/${eventID}/activate`, null, { - headers: { 'Authorization': `Bearer ${orgToken}` }, - }); - - return { eventID }; -} - -export default function (data) { - const { eventID } = data; - const email = `sustained-${__VU}-${__ITER}@test.com`; - - // Full flow — every VU completes the entire journey - http.post(`${BASE_URL}/auth/customer/otp/request`, - JSON.stringify({ email }), - { headers: { 'Content-Type': 'application/json' } } - ); - - const otpResp = http.get(`${BASE_URL}/loadtest/otp?email=${email}`); - const otp = otpResp.json('otp'); - - const authResp = http.post(`${BASE_URL}/auth/customer/otp/verify`, - JSON.stringify({ email, otp }), - { headers: { 'Content-Type': 'application/json' } } - ); - const token = authResp.json('token'); - - const headers = { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }; - - // Join queue - const joinResp = http.post( - `${BASE_URL}/events/${eventID}/queue`, - null, - { headers, tags: { name: 'join_queue' } } - ); - check(joinResp, { 'joined queue': r => r.status === 201 }); - - // Poll until admitted — max 60 attempts with 5s sleep - let admitted = false; - let admissionToken = null; - - for (let i = 0; i < 60; i++) { - sleep(5); - const posResp = http.get( - `${BASE_URL}/events/${eventID}/queue/position`, - { headers, tags: { name: 'poll_position' } } - ); - - if (posResp.json('status') === 'ADMITTED') { - admitted = true; - admissionToken = posResp.json('admission_token'); - break; - } - } - - if (!admitted) return; // timed out waiting — skip claim - - // Claim ticket - const claimResp = http.post( - `${BASE_URL}/events/${eventID}/claims`, - JSON.stringify({ admission_token: admissionToken }), - { headers, tags: { name: 'claim_ticket' } } - ); - - const claimOK = check(claimResp, { - 'claimed ticket': r => r.status === 201, - }); - - if (!claimOK) return; - - const claimID = claimResp.json('claim_id'); - - // Initialize payment (hits mock gateway — instant response) - const payResp = http.post( - `${BASE_URL}/claims/${claimID}/payments`, - null, - { headers, tags: { name: 'init_payment' } } - ); - - check(payResp, { 'payment initialized': r => r.status === 201 }); - - // Think time — real users don't hammer continuously - sleep(Math.random() * 3 + 1); -} \ No newline at end of file diff --git a/loadtest/scripts/03_correctness_check.js b/loadtest/scripts/03_correctness_check.js deleted file mode 100644 index 787ffb6..0000000 --- a/loadtest/scripts/03_correctness_check.js +++ /dev/null @@ -1,30 +0,0 @@ -import http from 'k6/http'; -import { check } from 'k6'; - -// Single VU, single iteration — just check correctness -export const options = { - vus: 1, - iterations: 1, -}; - -const BASE_URL = __ENV.BASE_URL || 'https://loadtest-api.tulcanvcm.com'; - -export default function () { - // This hits a stats endpoint you expose on the load test server - // showing claimed count vs total inventory - const resp = http.get(`${BASE_URL}/loadtest/stats?event_id=${__ENV.EVENT_ID}`); - - const stats = resp.json(); - - check(stats, { - 'no overselling': s => s.claimed_count <= s.total_inventory, - 'has claims': s => s.claimed_count > 0, - 'confirmed plus claimed equals total activity': - s => s.confirmed_count + s.claimed_count + s.released_count <= s.total_inventory, - }); - - console.log(`Total inventory: ${stats.total_inventory}`); - console.log(`Claimed: ${stats.claimed_count}`); - console.log(`Confirmed: ${stats.confirmed_count}`); - console.log(`Released: ${stats.released_count}`); -} \ No newline at end of file diff --git a/loadtest/scripts/03_spike.js b/loadtest/scripts/03_spike.js new file mode 100644 index 0000000..a57d272 --- /dev/null +++ b/loadtest/scripts/03_spike.js @@ -0,0 +1,39 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { BASE_URL, DEFAULT_HEADERS } from './lib/config.js'; +import { setupEvent } from './lib/setup.js'; +import { getToken } from './lib/tokens.js'; + +export const options = { + scenarios: { + spike: { + executor: 'constant-arrival-rate', + rate: 20000, + timeUnit: '1s', + duration: '30s', + preAllocatedVUs: 10000, + maxVUs: 60000, + }, + }, + thresholds: { + http_req_failed: ['rate<0.01'], + 'http_req_duration{name:join_queue}': ['p(99)<500'], + }, +}; + +export function setup() { + return setupEvent({ name: 'Spike Queue Test' }); +} + +export default function (data) { + const token = getToken(__VU); + + const res = http.post(`${BASE_URL}/events/${data.eventID}/queue`, null, { + headers: DEFAULT_HEADERS(token), + tags: { name: 'join_queue' }, + }); + + check(res, { + 'joined queue': (r) => r.status === 201, + }); +} \ No newline at end of file diff --git a/loadtest/scripts/04_breakpoint.js b/loadtest/scripts/04_breakpoint.js new file mode 100644 index 0000000..b268ec1 --- /dev/null +++ b/loadtest/scripts/04_breakpoint.js @@ -0,0 +1,35 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { BASE_URL, DEFAULT_HEADERS } from './lib/config.js'; +import { setupEvent } from './lib/setup.js'; +import { getToken } from './lib/tokens.js'; + +export const options = { + stages: [ + { duration: '2m', target: 1000 }, + { duration: '2m', target: 5000 }, + { duration: '2m', target: 10000 }, + { duration: '2m', target: 20000 }, + { duration: '2m', target: 40000 }, + ], + thresholds: { + http_req_failed: ['rate<0.05'], + }, +}; + +export function setup() { + return setupEvent({ name: 'Breakpoint Queue Test' }); +} + +export default function (data) { + const token = getToken(__VU); + + const res = http.post(`${BASE_URL}/events/${data.eventID}/queue`, null, { + headers: DEFAULT_HEADERS(token), + tags: { name: 'join_queue' }, + }); + + check(res, { + 'joined queue': (r) => r.status === 201, + }); +} \ No newline at end of file diff --git a/loadtest/scripts/05_contention.js b/loadtest/scripts/05_contention.js new file mode 100644 index 0000000..7a0e71d --- /dev/null +++ b/loadtest/scripts/05_contention.js @@ -0,0 +1,48 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { BASE_URL, DEFAULT_HEADERS } from './lib/config.js'; +import { setupEvent } from './lib/setup.js'; +import { getToken } from './lib/tokens.js'; + +export const options = { + vus: 20000, + duration: '5m', +}; + +export function setup() { + return setupEvent({ name: 'Contention Queue Test', totalInventory: 20000 }); +} + +export default function (data) { + const token = getToken(__VU); + const headers = DEFAULT_HEADERS(token); + + http.post(`${BASE_URL}/events/${data.eventID}/queue`, null, { headers }); + + let admitted = false; + let admissionToken; + + for (let i = 0; i < 30; i++) { + const res = http.get(`${BASE_URL}/events/${data.eventID}/queue/position`, { headers }); + + if (res.json('status') === 'ADMITTED') { + admitted = true; + admissionToken = res.json('admission_token'); + break; + } + + sleep(1); + } + + if (!admitted) return; + + const claimRes = http.post( + `${BASE_URL}/events/${data.eventID}/claims`, + JSON.stringify({ admission_token: admissionToken }), + { headers } + ); + + check(claimRes, { + 'claimed ticket': (r) => r.status === 201, + }); +} \ No newline at end of file diff --git a/loadtest/scripts/06_polling.js b/loadtest/scripts/06_polling.js new file mode 100644 index 0000000..fe44ba7 --- /dev/null +++ b/loadtest/scripts/06_polling.js @@ -0,0 +1,22 @@ +import http from 'k6/http'; +import { BASE_URL, DEFAULT_HEADERS } from './lib/config.js'; +import { setupEvent } from './lib/setup.js'; +import { getToken } from './lib/tokens.js'; + +export const options = { + vus: 5000, + duration: '5m', +}; + +export function setup() { + return setupEvent({ name: 'Polling Bottleneck Test' }); +} + +export default function (data) { + const token = getToken(__VU); + + http.get(`${BASE_URL}/events/${data.eventID}/queue/position`, { + headers: DEFAULT_HEADERS(token), + tags: { name: 'poll_position' }, + }); +} \ No newline at end of file diff --git a/loadtest/scripts/generate_tokens.js b/loadtest/scripts/generate_tokens.js new file mode 100644 index 0000000..61685a9 --- /dev/null +++ b/loadtest/scripts/generate_tokens.js @@ -0,0 +1,50 @@ +import fs from 'node:fs'; + +const BASE_URL = process.env.BASE_URL; +const TOTAL_USERS = Number(process.env.TOTAL_USERS || '20000'); + +if (!BASE_URL) { + throw new Error('BASE_URL is required'); +} + +async function generate() { + const tokens = []; + + for (let i = 0; i < TOTAL_USERS; i += 1) { + const email = `loadtest-${i}@test.com`; + + await fetch(`${BASE_URL}/auth/customer/otp/request`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + + const otpRes = await fetch(`${BASE_URL}/loadtest/otp?email=${encodeURIComponent(email)}`); + const otpData = await otpRes.json(); + + const authRes = await fetch(`${BASE_URL}/auth/customer/otp/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, otp: otpData.otp }), + }); + + const authData = await authRes.json(); + if (!authData.token) { + throw new Error(`No token returned for ${email}`); + } + + tokens.push(authData.token); + + if (i > 0 && i % 1000 === 0) { + console.log(`Generated ${i} tokens`); + } + } + + fs.writeFileSync('tokens.json', JSON.stringify(tokens, null, 2)); + console.log(`Wrote ${tokens.length} tokens to tokens.json`); +} + +generate().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/loadtest/scripts/lib/config.js b/loadtest/scripts/lib/config.js new file mode 100644 index 0000000..bff879f --- /dev/null +++ b/loadtest/scripts/lib/config.js @@ -0,0 +1,14 @@ +export const BASE_URL = __ENV.BASE_URL || 'http://loadtest-app:8082'; + +export const DEFAULT_HEADERS = (token) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, +}); + +export function requireEnv(name) { + const value = __ENV[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} \ No newline at end of file diff --git a/loadtest/scripts/lib/setup.js b/loadtest/scripts/lib/setup.js new file mode 100644 index 0000000..08bcb28 --- /dev/null +++ b/loadtest/scripts/lib/setup.js @@ -0,0 +1,73 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { BASE_URL, requireEnv } from './config.js'; + +function isoFromNow(ms) { + return new Date(Date.now() + ms).toISOString(); +} + +export function setupEvent({ + name = 'Load Test Event', + totalInventory = 1000000, + price = 1000, +} = {}) { + if (__ENV.EVENT_ID) { + return { eventID: __ENV.EVENT_ID }; + } + + const organizerEmail = requireEnv('ORGANIZER_EMAIL'); + const organizerPassword = requireEnv('ORGANIZER_PASSWORD'); + + const loginRes = http.post( + `${BASE_URL}/auth/organizer/login`, + JSON.stringify({ email: organizerEmail, password: organizerPassword }), + { headers: { 'Content-Type': 'application/json' }, tags: { name: 'organizer_login' } } + ); + + check(loginRes, { + 'organizer login succeeded': (r) => r.status === 200, + }); + + const organizerToken = loginRes.json('token'); + if (!organizerToken) { + throw new Error('Organizer login did not return a token'); + } + + const eventRes = http.post( + `${BASE_URL}/events`, + JSON.stringify({ + name, + total_inventory: totalInventory, + price, + sale_start: isoFromNow(-3600000), + sale_end: isoFromNow(86400000), + }), + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${organizerToken}`, + }, + tags: { name: 'create_event' }, + } + ); + + check(eventRes, { + 'event created': (r) => r.status === 201, + }); + + const eventID = eventRes.json('data.id'); + if (!eventID) { + throw new Error('Event creation did not return data.id'); + } + + const activateRes = http.put(`${BASE_URL}/events/${eventID}/activate`, null, { + headers: { Authorization: `Bearer ${organizerToken}` }, + tags: { name: 'activate_event' }, + }); + + check(activateRes, { + 'event activated': (r) => r.status === 200, + }); + + return { eventID }; +} \ No newline at end of file diff --git a/loadtest/scripts/lib/tokens.js b/loadtest/scripts/lib/tokens.js new file mode 100644 index 0000000..abab7f5 --- /dev/null +++ b/loadtest/scripts/lib/tokens.js @@ -0,0 +1,12 @@ +import { SharedArray } from 'k6/data'; + +export const tokens = new SharedArray('tokens', function () { + return JSON.parse(open('./tokens.json')); +}); + +export function getToken(vu) { + if (!tokens.length) { + throw new Error('tokens.json is empty; generate tokens before running load tests'); + } + return tokens[vu % tokens.length]; +} \ No newline at end of file