test(load): enforce 500 acknowledged requests within five seconds
This commit is contained in:
@@ -1,29 +1,72 @@
|
|||||||
import http from 'k6/http';
|
import http from 'k6/http';
|
||||||
import { check } from 'k6';
|
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 = {
|
export const options = {
|
||||||
|
discardResponseBodies: true,
|
||||||
scenarios: {
|
scenarios: {
|
||||||
// Ramp up from 10 to 50 virtual users pushing sustained load
|
ingestion: PROFILE === 'stress' ? stressScenario : assessmentScenario,
|
||||||
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
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
thresholds: {
|
thresholds: {
|
||||||
http_req_duration: ['p(95)<50'], // 95% of API responses under 50ms
|
// 429 is a failure for the assessment burst. k6 also treats 4xx as failed HTTP requests.
|
||||||
http_req_failed: ['rate<0.01'], // Less than 1% failure
|
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 () {
|
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({
|
const payload = JSON.stringify({
|
||||||
raw_id: `load-${__VU}-${__ITER}-${Date.now()}`,
|
raw_id: rawId,
|
||||||
src_ip: '10.240.1.1',
|
src_ip: '10.240.1.1',
|
||||||
device_id: '310410998877665',
|
device_id: '310410998877665',
|
||||||
caller_num: '+19875551234',
|
caller_num: '+19875551234',
|
||||||
@@ -33,16 +76,78 @@ export default function () {
|
|||||||
rat: 6,
|
rat: 6,
|
||||||
lac: '4021',
|
lac: '4021',
|
||||||
ci: '8812',
|
ci: '8812',
|
||||||
epoch_start: 1726070000,
|
epoch_start: Math.floor(now / 1000) - 300,
|
||||||
epoch_end: 1726070300,
|
epoch_end: Math.floor(now / 1000),
|
||||||
});
|
});
|
||||||
|
|
||||||
const params = {
|
const res = http.post(API_URL, payload, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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, {
|
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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user