diff --git a/scripts/load_test.js b/scripts/load_test.js index 1500bf9..03bb486 100644 --- a/scripts/load_test.js +++ b/scripts/load_test.js @@ -1,29 +1,72 @@ import http from 'k6/http'; import { check } from 'k6'; +import { Counter } from 'k6/metrics'; +import { Trend } from 'k6/metrics'; +import exec from 'k6/execution'; + +const API_URL = __ENV.API_URL || 'http://localhost:8000/api/v1/cdr/raw'; +const PROFILE = (__ENV.PROFILE || 'assessment').toLowerCase(); +const P95_MS = Number(__ENV.P95_MS || 50); + +// Assessment profile directly models the required scenario: +// 500 records in <5s. 125 iterations/s for 4 seconds schedules exactly 500 iterations. +const assessmentScenario = { + executor: 'constant-arrival-rate', + rate: 125, + timeUnit: '1s', + duration: '4s', + preAllocatedVUs: 50, + maxVUs: 250, + gracefulStop: '5s', +}; + +// Optional heavier capacity test. Run with PROFILE=stress. +const stressScenario = { + executor: 'ramping-arrival-rate', + startRate: 250, + timeUnit: '1s', + preAllocatedVUs: 250, + maxVUs: 1000, + stages: [ + { duration: '10s', target: 500 }, + { duration: '10s', target: 1000 }, + { duration: '30s', target: 1000 }, + { duration: '5s', target: 0 }, + ], +}; export const options = { + discardResponseBodies: true, scenarios: { - // Ramp up from 10 to 50 virtual users pushing sustained load - sustained_load: { - executor: 'ramping-vus', - startVUs: 10, - stages: [ - { duration: '10s', target: 50 }, // Ramp up to 50 VUs - { duration: '30s', target: 50 }, // Stay at 50 VUs (~1,000+ req/s) - { duration: '5s', target: 0 }, // Ramp down - ], - }, + ingestion: PROFILE === 'stress' ? stressScenario : assessmentScenario, }, thresholds: { - http_req_duration: ['p(95)<50'], // 95% of API responses under 50ms - http_req_failed: ['rate<0.01'], // Less than 1% failure + // 429 is a failure for the assessment burst. k6 also treats 4xx as failed HTTP requests. + http_req_failed: ['rate==0'], + checks: ['rate==1'], + dropped_iterations: ['count==0'], + ...(PROFILE === 'assessment' ? { + accepted_202: ['count==500'], + assessment_completion_ms: ['max<5000'], + } : {}), + + // This is a project performance guardrail, not an assessment requirement. + // Override it with P95_MS=100 (etc.) if you want a different SLA. + http_req_duration: [`p(95)<${P95_MS}`], }, }; +const accepted202 = new Counter('accepted_202'); +const rejected429 = new Counter('rejected_429'); +const unexpectedStatus = new Counter('unexpected_status'); +const completionTime = new Trend('assessment_completion_ms'); + export default function () { - const url = __ENV.API_URL || 'http://localhost:8000/api/v1/cdr/raw'; + const now = Date.now(); + const rawId = `k6-${PROFILE}-${__VU}-${__ITER}-${now}`; + const payload = JSON.stringify({ - raw_id: `load-${__VU}-${__ITER}-${Date.now()}`, + raw_id: rawId, src_ip: '10.240.1.1', device_id: '310410998877665', caller_num: '+19875551234', @@ -33,16 +76,78 @@ export default function () { rat: 6, lac: '4021', ci: '8812', - epoch_start: 1726070000, - epoch_end: 1726070300, + epoch_start: Math.floor(now / 1000) - 300, + epoch_end: Math.floor(now / 1000), }); - const params = { + const res = http.post(API_URL, payload, { headers: { 'Content-Type': 'application/json' }, - }; + tags: { test_profile: PROFILE }, + }); + completionTime.add(Date.now() - exec.scenario.startTime); + + if (res.status === 202) { + accepted202.add(1); + } else if (res.status === 429) { + rejected429.add(1); + } else { + unexpectedStatus.add(1); + } - const res = http.post(url, payload, params); check(res, { - 'status is 202 or 429': (r) => r.status === 202 || r.status === 429, + 'accepted with 202': (r) => r.status === 202, + 'not backpressured with 429': (r) => r.status !== 429, }); } + +export function handleSummary(data) { + const reqs = data.metrics.http_reqs?.values?.count || 0; + const failedRate = data.metrics.http_req_failed?.values?.rate || 0; + const p95 = data.metrics.http_req_duration?.values?.['p(95)']; + const dropped = data.metrics.dropped_iterations?.values?.count || 0; + const accepted = data.metrics.accepted_202?.values?.count || 0; + const rejected = data.metrics.rejected_429?.values?.count || 0; + const unexpected = data.metrics.unexpected_status?.values?.count || 0; + const completionMs = data.metrics.assessment_completion_ms?.values?.max; + + const lines = [ + '', + '=== CDR Ingestion Load-Test Summary ===', + `Profile: ${PROFILE}`, + `Requests attempted: ${reqs}`, + `202 Accepted: ${accepted}`, + `429 Backpressure: ${rejected}`, + `Unexpected statuses: ${unexpected}`, + `Dropped iterations: ${dropped}`, + `HTTP failure rate: ${(failedRate * 100).toFixed(2)}%`, + `p95 latency: ${p95 !== undefined ? p95.toFixed(2) + ' ms' : 'n/a'}`, + ]; + + if (PROFILE === 'assessment') { + const assessmentPass = + reqs === 500 && + accepted === 500 && + rejected === 0 && + unexpected === 0 && + dropped === 0 && + completionMs < 5000; + + lines.push( + 'Assessment target: 500 acknowledged records in <5s, no 429, no dropped iterations', + `Last completion: ${completionMs !== undefined ? completionMs.toFixed(2) + ' ms' : 'unavailable'}`, + `Assessment result: ${assessmentPass ? 'PASS' : 'FAIL'}`, + 'Note: k6 proves API-side burst handling; run generate_traffic.py', + ' to verify end-to-end ClickHouse/DLQ/deduplication behavior.' + ); + } else { + lines.push( + 'Stress target: sustain the configured arrival rate without errors/drops.' + ); + } + + lines.push('=======================================', ''); + + return { + stdout: `${lines.join('\n')}\n`, + }; +}