forked from gongxuegit/tiku-backend.net
perf: add reproducible concurrency test suite
This commit is contained in:
@@ -1,59 +1,125 @@
|
||||
import http from 'k6/http';
|
||||
import { check, sleep } from 'k6';
|
||||
import { check, fail } from 'k6';
|
||||
|
||||
const baseUrl = __ENV.TIKU_BASE_URL || 'http://localhost:5000';
|
||||
const tenantCode = __ENV.TIKU_TENANT_CODE || '';
|
||||
const accessToken = __ENV.TIKU_ACCESS_TOKEN || '';
|
||||
const baseUrl = (__ENV.TIKU_BASE_URL || 'http://localhost:5091').replace(/\/$/, '');
|
||||
const tenantCode = __ENV.TIKU_TENANT_CODE || 'demo-crm-school';
|
||||
const hostHeader = __ENV.TIKU_HOST_HEADER || '';
|
||||
const mode = (__ENV.TIKU_MODE || 'mixed').toLowerCase();
|
||||
const rate = positiveInteger('TIKU_RATE', 100);
|
||||
const duration = __ENV.TIKU_DURATION || '30s';
|
||||
const preAllocatedVUs = positiveInteger('TIKU_PRE_ALLOCATED_VUS', Math.max(20, Math.ceil(rate / 20)));
|
||||
const maxVUs = positiveInteger('TIKU_MAX_VUS', Math.max(100, preAllocatedVUs * 4));
|
||||
|
||||
if (!['mixed', 'hot', 'cold', 'ready'].includes(mode)) {
|
||||
throw new Error(`Unsupported TIKU_MODE '${mode}'. Use mixed, hot, cold, or ready.`);
|
||||
}
|
||||
|
||||
export const options = {
|
||||
discardResponseBodies: true,
|
||||
summaryTrendStats: ['avg', 'min', 'med', 'p(90)', 'p(95)', 'p(99)', 'max'],
|
||||
scenarios: {
|
||||
public_catalog: {
|
||||
executor: 'constant-vus',
|
||||
vus: Number(__ENV.TIKU_PUBLIC_VUS || 10),
|
||||
duration: __ENV.TIKU_DURATION || '30s',
|
||||
exec: 'publicCatalog',
|
||||
},
|
||||
scoreline_page: {
|
||||
executor: 'constant-vus',
|
||||
vus: Number(__ENV.TIKU_SCORELINE_VUS || 10),
|
||||
duration: __ENV.TIKU_DURATION || '30s',
|
||||
exec: 'scorelinePage',
|
||||
},
|
||||
tenant_bootstrap: {
|
||||
executor: 'constant-vus',
|
||||
vus: Number(__ENV.TIKU_BACKOFFICE_VUS || 5),
|
||||
duration: __ENV.TIKU_DURATION || '30s',
|
||||
exec: 'tenantBootstrap',
|
||||
startTime: '1s',
|
||||
api: {
|
||||
executor: 'constant-arrival-rate',
|
||||
exec: 'apiTraffic',
|
||||
rate,
|
||||
timeUnit: '1s',
|
||||
duration,
|
||||
preAllocatedVUs,
|
||||
maxVUs,
|
||||
gracefulStop: '5s',
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
checks: ['rate>0.99'],
|
||||
http_req_failed: ['rate<0.01'],
|
||||
http_req_duration: ['p(95)<1000'],
|
||||
http_req_duration: ['p(95)<500', 'p(99)<1000'],
|
||||
dropped_iterations: ['count==0'],
|
||||
},
|
||||
};
|
||||
|
||||
function tenantParams(authenticated = false) {
|
||||
const headers = tenantCode ? { 'x-tenant-code': tenantCode } : {};
|
||||
if (authenticated && accessToken) headers.Authorization = `Bearer ${accessToken}`;
|
||||
return { headers };
|
||||
const tenantHeaders = {
|
||||
'x-tenant-code': tenantCode,
|
||||
Accept: 'application/json',
|
||||
...(hostHeader ? { Host: hostHeader } : {}),
|
||||
};
|
||||
|
||||
const platformHeaders = hostHeader ? { Host: hostHeader } : {};
|
||||
|
||||
export function setup() {
|
||||
const ready = http.get(`${baseUrl}/api/health/ready`, {
|
||||
headers: platformHeaders,
|
||||
responseType: 'text',
|
||||
tags: { endpoint: 'ready', phase: 'setup' },
|
||||
});
|
||||
if (ready.status !== 200) {
|
||||
fail(`API dependencies are not ready: status=${ready.status} body=${ready.body}`);
|
||||
}
|
||||
|
||||
let readiness;
|
||||
try {
|
||||
readiness = ready.json();
|
||||
} catch (error) {
|
||||
fail(`Readiness endpoint did not return JSON: ${error}`);
|
||||
}
|
||||
if (!readiness.database || !readiness.redis?.configured || !readiness.redis?.ready) {
|
||||
fail(`PostgreSQL and Redis must both be configured and ready: ${JSON.stringify(readiness)}`);
|
||||
}
|
||||
|
||||
const catalog = http.get(`${baseUrl}/api/catalog/regions`, {
|
||||
headers: tenantHeaders,
|
||||
tags: { endpoint: 'catalog_hot', phase: 'setup' },
|
||||
});
|
||||
if (catalog.status !== 200) {
|
||||
fail(`Tenant '${tenantCode}' is not usable: status=${catalog.status} body=${catalog.body}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function publicCatalog() {
|
||||
const response = http.get(`${baseUrl}/api/catalog/regions`, tenantParams());
|
||||
check(response, { 'catalog is 200': (result) => result.status === 200 });
|
||||
sleep(0.1);
|
||||
export function apiTraffic() {
|
||||
const selected = selectEndpoint();
|
||||
let response;
|
||||
|
||||
if (selected === 'ready') {
|
||||
response = http.get(`${baseUrl}/api/health/ready`, {
|
||||
headers: platformHeaders,
|
||||
tags: { endpoint: 'ready' },
|
||||
});
|
||||
} else if (selected === 'cold') {
|
||||
// A unique query string bypasses output-cache reuse while exercising the same
|
||||
// tenant resolution and PostgreSQL-backed catalog query as the hot request.
|
||||
const cacheBuster = `${__VU}-${__ITER}`;
|
||||
response = http.get(`${baseUrl}/api/catalog/regions?loadProbe=${cacheBuster}`, {
|
||||
headers: tenantHeaders,
|
||||
tags: { endpoint: 'catalog_cold' },
|
||||
});
|
||||
} else {
|
||||
response = http.get(`${baseUrl}/api/catalog/regions`, {
|
||||
headers: tenantHeaders,
|
||||
tags: { endpoint: 'catalog_hot' },
|
||||
});
|
||||
}
|
||||
|
||||
check(response, {
|
||||
[`${selected} returned 200`]: (result) => result.status === 200,
|
||||
});
|
||||
}
|
||||
|
||||
export function scorelinePage() {
|
||||
const response = http.get(`${baseUrl}/api/scoreline/records?page=1&pageSize=20`, tenantParams());
|
||||
check(response, { 'scoreline is 200': (result) => result.status === 200 });
|
||||
sleep(0.1);
|
||||
function selectEndpoint() {
|
||||
if (mode !== 'mixed') return mode;
|
||||
|
||||
// Stable 70/20/10 traffic mix: cached catalog / uncached catalog / dependency readiness.
|
||||
const bucket = (__ITER + __VU) % 10;
|
||||
if (bucket === 0) return 'ready';
|
||||
if (bucket <= 2) return 'cold';
|
||||
return 'hot';
|
||||
}
|
||||
|
||||
export function tenantBootstrap() {
|
||||
if (!accessToken) return;
|
||||
const response = http.get(`${baseUrl}/api/tenant-backoffice/ui-bootstrap`, tenantParams(true));
|
||||
check(response, { 'bootstrap is 200': (result) => result.status === 200 });
|
||||
sleep(0.1);
|
||||
function positiveInteger(name, fallback) {
|
||||
const raw = __ENV[name];
|
||||
if (raw === undefined || raw === '') return fallback;
|
||||
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, got '${raw}'.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user