tressi
Version:
A lightweight, declarative stress testing CLI for modern developers.
804 lines (705 loc) • 25.6 kB
text/typescript
import { EventEmitter } from 'events';
import { build, Histogram } from 'hdr-histogram-js';
import { performance } from 'perf_hooks';
import { request } from 'undici';
import { CircularBuffer } from './circular-buffer';
import { RequestConfig } from './config';
import { Distribution } from './distribution';
import { globalAgentManager } from './http-agent';
import { RunOptions } from './index';
import { RequestResult } from './stats';
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* The main load test runner. It orchestrates the test execution, manages workers,
* and collects results. It extends EventEmitter to provide hooks into its lifecycle.
*/
export class Runner extends EventEmitter {
private options: RunOptions;
private requests: RequestConfig[];
private headers: Record<string, string>;
private sampledResults: RequestResult[] = [];
private histogram: Histogram;
private distribution: Distribution;
private endpointHistograms: Map<string, Histogram> = new Map();
private recentLatenciesForSpinner: CircularBuffer<number>;
private recentRequestTimestamps: CircularBuffer<number>;
private statusCodeMap: Record<number, number> = {};
private successfulRequestsByEndpoint: Map<string, number> = new Map();
private failedRequestsByEndpoint: Map<string, number> = new Map();
private stopped = false;
private startTime: number = 0;
private currentTargetRps: number;
private successfulRequests = 0;
private failedRequests = 0;
private activeWorkers: { promise: Promise<void>; stop: () => void }[] = [];
private testTimeout?: NodeJS.Timeout;
private rampUpInterval?: NodeJS.Timeout;
private autoscaleInterval?: NodeJS.Timeout;
// Object allocation optimizations
private headersPool: Record<string, string>[] = [];
private resultPool: RequestResult[] = [];
private endpointKeyCache = new Map<string, string>();
private responseSamplingSets = new Map<string, Set<number>>();
private maxPoolSize = 1000;
/**
* Creates a new Runner instance.
* @param options The run options.
* @param requests An array of request configurations to be used in the test.
* @param headers A record of global headers to be sent with each request.
*/
constructor(
options: RunOptions,
requests: RequestConfig[],
headers: Record<string, string>,
) {
super();
// Validate early exit options
const validatedOptions = this.validateEarlyExitOptions(options);
this.options = validatedOptions;
this.requests = requests;
this.headers = headers;
this.histogram = build();
this.distribution = new Distribution();
this.currentTargetRps =
options.rampUpTimeSec && options.rps ? 0 : options.rps || 0;
// Estimate buffer size: 2 seconds of requests at max RPS, or 10k, whichever is larger
const maxRps = options.rps || 1000;
const bufferSize = Math.max(10000, maxRps * 2);
this.recentRequestTimestamps = new CircularBuffer<number>(bufferSize);
this.recentLatenciesForSpinner = new CircularBuffer<number>(1000);
// Initialize optimization pools with reasonable starting capacity
this.headersPool = [];
this.resultPool = [];
this.endpointKeyCache = new Map();
this.responseSamplingSets = new Map();
}
/**
* Validates early exit configuration options with proper defaults and constraints.
* @param options The raw RunOptions to validate
* @returns Validated RunOptions with defaults applied
*/
private validateEarlyExitOptions(options: RunOptions): RunOptions {
const validated = { ...options };
// Set defaults for early exit options
validated.earlyExitOnError = options.earlyExitOnError ?? false;
validated.errorRateThreshold = options.errorRateThreshold;
validated.errorCountThreshold = options.errorCountThreshold;
validated.errorStatusCodes = options.errorStatusCodes;
// Validate constraints when early exit is enabled
if (validated.earlyExitOnError) {
// Validate error rate threshold (must be between 0.0 and 1.0)
if (validated.errorRateThreshold !== undefined) {
if (
typeof validated.errorRateThreshold !== 'number' ||
validated.errorRateThreshold < 0 ||
validated.errorRateThreshold > 1
) {
throw new Error(
'errorRateThreshold must be a number between 0.0 and 1.0',
);
}
}
// Validate error count threshold (must be positive integer)
if (validated.errorCountThreshold !== undefined) {
if (
!Number.isInteger(validated.errorCountThreshold) ||
validated.errorCountThreshold < 0
) {
throw new Error('errorCountThreshold must be a non-negative integer');
}
}
// Validate error status codes (must be array of valid HTTP status codes)
if (validated.errorStatusCodes !== undefined) {
if (!Array.isArray(validated.errorStatusCodes)) {
throw new Error('errorStatusCodes must be an array of numbers');
}
for (const code of validated.errorStatusCodes) {
if (!Number.isInteger(code) || code < 100 || code > 599) {
throw new Error(
`Invalid HTTP status code: ${code}. Must be between 100-599`,
);
}
}
}
// Ensure at least one threshold is provided when early exit is enabled
if (
validated.errorRateThreshold === undefined &&
validated.errorCountThreshold === undefined &&
validated.errorStatusCodes === undefined
) {
throw new Error(
'When earlyExitOnError is enabled, at least one of errorRateThreshold, errorCountThreshold, or errorStatusCodes must be provided',
);
}
}
return validated;
}
/**
* Records a new request result, updating all relevant statistics.
* @param result The `RequestResult` to record.
*/
public onResult(result: RequestResult): void {
if (this.sampledResults.length < 1000) {
// Create a shallow copy for sampled results to avoid pool contamination
this.sampledResults.push({ ...result });
}
this.histogram.recordValue(result.latencyMs);
this.distribution.add(result.latencyMs);
const endpointKey = this.getEndpointKey(result.method, result.url);
if (!this.endpointHistograms.has(endpointKey)) {
this.endpointHistograms.set(endpointKey, build());
}
this.endpointHistograms.get(endpointKey)!.recordValue(result.latencyMs);
// Keep a small, rotating log of recent latencies for the non-UI spinner
if (!this.options.useUI) {
this.recentLatenciesForSpinner.add(result.latencyMs);
}
this.recentRequestTimestamps.add(performance.now());
this.statusCodeMap[result.status] =
(this.statusCodeMap[result.status] || 0) + 1;
if (result.success) {
this.successfulRequests++;
this.successfulRequestsByEndpoint.set(
endpointKey,
(this.successfulRequestsByEndpoint.get(endpointKey) || 0) + 1,
);
} else {
this.failedRequests++;
this.failedRequestsByEndpoint.set(
endpointKey,
(this.failedRequestsByEndpoint.get(endpointKey) || 0) + 1,
);
}
// Release result object back to pool
this.releaseResultObject(result);
}
/**
* Gets a sample of the results collected during the test run.
* @returns An array of `RequestResult` objects.
*/
public getSampledResults(): RequestResult[] {
return this.sampledResults;
}
/**
* Gets the histogram containing all latency values.
* @returns The HDR histogram instance.
*/
public getHistogram(): Histogram {
return this.histogram;
}
/**
* Generates a latency distribution report.
* @param options - The options for generating the distribution.
* @param options.count - The number of buckets to group latencies into.
* @returns An array of objects representing each bucket in the distribution.
*/
public getLatencyDistribution(options: {
count: number;
chartWidth?: number;
}): {
latency: string;
count: string;
percent: string;
cumulative: string;
chart: string;
}[] {
return this.distribution.getLatencyDistribution(options);
}
/**
* Gets the full Distribution instance.
* @returns The `Distribution` instance containing all latency data.
*/
public getDistribution(): Distribution {
return this.distribution;
}
/**
* Gets the map of histograms for each endpoint.
* @returns A map where keys are endpoint identifiers and values are HDR histograms.
*/
public getEndpointHistograms(): Map<string, Histogram> {
return this.endpointHistograms;
}
/**
* Gets an array of recent latency values for the non-UI spinner.
* @returns An array of latency numbers in milliseconds.
*/
public getRecentLatencies(): number[] {
return this.recentLatenciesForSpinner.getAll();
}
/**
* Gets a map of status codes and their counts.
* @returns A record where keys are status codes and values are their counts.
*/
public getStatusCodeMap(): Record<number, number> {
return this.statusCodeMap;
}
/**
* Gets the total count of successful requests.
* @returns The number of successful requests.
*/
public getSuccessfulRequestsCount(): number {
return this.successfulRequests;
}
/**
* Gets the total count of failed requests.
* @returns The number of failed requests.
*/
public getFailedRequestsCount(): number {
return this.failedRequests;
}
public getSuccessfulRequestsByEndpoint(): Map<string, number> {
return this.successfulRequestsByEndpoint;
}
public getFailedRequestsByEndpoint(): Map<string, number> {
return this.failedRequestsByEndpoint;
}
/**
* Calculates and returns the average latency for all requests.
* @returns The average latency in milliseconds.
*/
public getAverageLatency(): number {
return this.histogram.mean;
}
/**
* Gets the timestamp when the test run started.
* @returns The start time as a Unix timestamp.
*/
public getStartTime(): number {
return this.startTime;
}
/**
* Gets the current target requests per second (Req/s).
* This value changes during ramp-up.
* @returns The current target Req/s.
*/
public getCurrentTargetRps(): number {
return Math.round(this.currentTargetRps);
}
/**
* Calculates the actual requests per second (Req/s) over the last second.
* @returns The current actual Req/s.
*/
public getCurrentRps(): number {
const now = performance.now();
const oneSecondAgo = now - 1000;
const timestamps = this.recentRequestTimestamps.getAll();
let count = 0;
// Iterate backwards since recent timestamps are at the end
for (let i = timestamps.length - 1; i >= 0; i--) {
if (timestamps[i] >= oneSecondAgo) {
count++;
} else {
// The timestamps are ordered, so we can stop.
break;
}
}
return count;
}
/**
* Gets the current number of active workers.
* @returns The number of workers.
*/
public getWorkerCount(): number {
if (this.options.autoscale) {
return this.activeWorkers.length;
}
return this.options.workers ?? 10;
}
/**
* Starts the load test. This is the main entry point for the runner.
* It sets up workers, timers, and ramp-up/autoscaling logic.
*/
public async run(): Promise<void> {
this.startTime = performance.now();
const {
workers = 10,
durationSec = 10,
rampUpTimeSec = 0,
rps = 0,
autoscale = false,
} = this.options;
// The main timer for the total test duration starts now
const durationMs = durationSec * 1000;
this.testTimeout = setTimeout(() => this.stop(), durationMs);
// If ramp-up is enabled, start the governor
if (rampUpTimeSec > 0) {
this.rampUpInterval = setInterval(() => {
const elapsedTimeSec = (performance.now() - this.startTime) / 1000;
if (this.stopped) {
clearInterval(this.rampUpInterval as NodeJS.Timeout);
return;
}
const rampUpProgress = Math.min(elapsedTimeSec / rampUpTimeSec, 1);
if (rps > 0) {
// If a target RPS is set, ramp up to that value
this.currentTargetRps = Math.round(rps * rampUpProgress);
} else {
// If no target RPS, ramp up to a theoretical max (e.g., 1k Req/s per worker)
// This creates a steady increase with no upper bound.
const arbitraryMaxRps = (this.options.workers || 10) * 1000;
this.currentTargetRps = Math.round(arbitraryMaxRps * rampUpProgress);
}
}, 1000); // Update every second
}
if (autoscale) {
// Start with one worker
this.addWorker();
this.autoscaleInterval = setInterval(() => {
if (this.stopped) {
clearInterval(this.autoscaleInterval as NodeJS.Timeout);
return;
}
const currentRps = this.getCurrentRps();
const currentWorkers = this.activeWorkers.length;
if (currentWorkers === 0) {
this.addWorker();
return;
}
const targetRps = this.options.rps;
if (!targetRps) return;
const scaleUpThreshold = targetRps * 0.9;
const scaleDownThreshold = targetRps * 1.1;
if (currentRps < scaleUpThreshold && currentWorkers < workers) {
const rpsDeficit = targetRps - currentRps;
const avgRpsPerWorker =
currentWorkers > 0 ? currentRps / currentWorkers : 10;
const workersNeeded = rpsDeficit / avgRpsPerWorker;
let workersToAdd = Math.ceil(workersNeeded * 0.25);
workersToAdd = Math.max(1, workersToAdd);
workersToAdd = Math.min(workersToAdd, workers - currentWorkers);
for (let i = 0; i < workersToAdd; i++) {
this.addWorker();
}
} else if (currentRps > scaleDownThreshold && currentWorkers > 1) {
const rpsSurplus = currentRps - targetRps;
const avgRpsPerWorker = currentRps / currentWorkers;
const workersToCut = rpsSurplus / avgRpsPerWorker;
let workersToRemove = Math.ceil(workersToCut * 0.25);
workersToRemove = Math.max(1, workersToRemove);
workersToRemove = Math.min(workersToRemove, currentWorkers - 1);
for (let i = 0; i < workersToRemove; i++) {
this.removeWorker();
}
}
}, 2000); // Check every 2 seconds
await Promise.all(this.activeWorkers.map((w) => w.promise));
} else {
const workerPromises = Array.from({ length: workers }, () =>
this.runWorker(),
);
await Promise.all(workerPromises);
}
this.cleanup();
}
public stop(): void {
if (this.stopped) return;
this.stopped = true;
this.activeWorkers.forEach((w) => w.stop());
this.cleanup();
this.emit('stop');
}
/**
* Cleans up all active timers and intervals.
*/
private cleanup(): void {
if (this.testTimeout) {
clearTimeout(this.testTimeout);
this.testTimeout = undefined;
}
if (this.rampUpInterval) {
clearInterval(this.rampUpInterval);
this.rampUpInterval = undefined;
}
if (this.autoscaleInterval) {
clearInterval(this.autoscaleInterval);
this.autoscaleInterval = undefined;
}
// Clear object pools and caches
this.headersPool.length = 0;
this.resultPool.length = 0;
this.endpointKeyCache.clear();
this.responseSamplingSets.clear();
// Close all HTTP agents to free up connections
globalAgentManager.closeAll();
}
/**
* Adds a new worker to the pool. Used by the autoscaler.
*/
private addWorker(): void {
let workerStopped = false;
const stop = (): void => {
workerStopped = true;
};
const promise = this.runWorker(() => workerStopped);
this.activeWorkers.push({ promise, stop });
}
/**
* Removes a worker from the pool and stops it. Used by the autoscaler.
*/
private removeWorker(): void {
const worker = this.activeWorkers.pop();
if (worker) {
worker.stop();
}
}
/**
* Gets a reusable headers object from the pool or creates a new one
*/
private getHeadersObject(): Record<string, string> {
return this.headersPool.pop() || {};
}
/**
* Returns a headers object to the pool for reuse
*/
private releaseHeadersObject(headers: Record<string, string>): void {
if (this.headersPool.length < this.maxPoolSize) {
// Clear the object for reuse
for (const key in headers) {
delete headers[key];
}
this.headersPool.push(headers);
}
}
/**
* Gets a RequestResult object from the pool or creates a new one
*/
private getResultObject(): RequestResult {
return this.resultPool.pop() || ({} as RequestResult);
}
/**
* Returns a RequestResult object to the pool for reuse
*/
private releaseResultObject(result: RequestResult): void {
if (this.resultPool.length < this.maxPoolSize) {
// Clear the object for reuse
result.method = '';
result.url = '';
result.status = 0;
result.latencyMs = 0;
result.success = false;
result.body = undefined;
result.error = undefined;
result.timestamp = 0;
this.resultPool.push(result);
}
}
/**
* Gets a cached endpoint key to avoid string concatenation
*/
private getEndpointKey(method: string, url: string): string {
const cacheKey = `${method}|${url}`;
let endpointKey = this.endpointKeyCache.get(cacheKey);
if (!endpointKey) {
endpointKey = `${method} ${url}`;
this.endpointKeyCache.set(cacheKey, endpointKey);
}
return endpointKey;
}
/**
* Gets a reusable Set for response sampling
*/
private getResponseSamplingSet(endpointKey: string): Set<number> {
let set = this.responseSamplingSets.get(endpointKey);
if (!set) {
set = new Set();
this.responseSamplingSets.set(endpointKey, set);
}
return set;
}
/**
* Checks if early exit conditions are met based on configured thresholds.
* This method is thread-safe as it only reads atomic counters and maps.
* @returns true if early exit conditions are met, false otherwise
*/
private shouldEarlyExit(): boolean {
if (!this.options.earlyExitOnError) {
return false;
}
const totalRequests = this.successfulRequests + this.failedRequests;
if (totalRequests === 0) {
return false;
}
// Check error rate threshold
if (this.options.errorRateThreshold !== undefined) {
const errorRate = this.failedRequests / totalRequests;
if (errorRate >= this.options.errorRateThreshold) {
return true;
}
}
// Check error count threshold
if (this.options.errorCountThreshold !== undefined) {
if (this.failedRequests >= this.options.errorCountThreshold) {
return true;
}
}
// Check specific status codes threshold
if (this.options.errorStatusCodes !== undefined) {
for (const code of this.options.errorStatusCodes) {
if (this.statusCodeMap[code] > 0) {
return true;
}
}
}
return false;
}
/**
* Makes a single HTTP request and returns the result
* @param req The request configuration
* @returns Promise<RequestResult> The request result
*/
private async makeSingleRequest(req: RequestConfig): Promise<RequestResult> {
const start = performance.now();
const headers = this.getHeadersObject();
const result = this.getResultObject();
try {
// Reuse headers object instead of creating new one
Object.assign(headers, this.headers, req.headers);
// Use per-endpoint agents in production, global dispatcher in tests
const dispatcher =
process.env.NODE_ENV !== 'test'
? globalAgentManager.getAgent(req.url)
: undefined;
const { statusCode, body: responseBody } = await request(req.url, {
method: req.method || 'GET',
headers,
body:
req.payload === undefined ? undefined : JSON.stringify(req.payload),
dispatcher,
});
let body: string | undefined;
const method = req.method || 'GET';
const endpointKey = this.getEndpointKey(method, req.url);
const sampledCodesForEndpoint = this.getResponseSamplingSet(endpointKey);
// Check if we should sample this status code for this endpoint
if (!sampledCodesForEndpoint.has(statusCode)) {
try {
body = await responseBody.text();
sampledCodesForEndpoint.add(statusCode);
} catch (e) {
// Ignore body read errors, it might be empty.
body = `(Could not read body: ${(e as Error).message}`;
}
}
const latencyMs = Math.max(0, performance.now() - start);
// Populate result object from pool
result.method = method;
result.url = req.url;
result.status = statusCode;
result.latencyMs = latencyMs;
result.success = statusCode >= 200 && statusCode < 300;
result.body = body; // Will be undefined if not sampled
result.timestamp = performance.now();
return result;
} catch (err) {
const latencyMs = Math.max(0, performance.now() - start);
// Populate result object for error case
result.method = req.method || 'GET';
result.url = req.url;
result.status = 0;
result.latencyMs = latencyMs;
result.success = false;
result.error = (err as Error).message;
result.timestamp = performance.now();
return result;
} finally {
// Always release headers object back to pool
this.releaseHeadersObject(headers);
// Note: result object is released by onResult after it's processed
}
}
/**
* Calculates the optimal concurrency level for this worker based on target RPS and worker count
* @returns The number of concurrent requests this worker should make
*/
private calculateOptimalConcurrency(): number {
const workerCount = this.getWorkerCount();
const targetRps = this.currentTargetRps;
if (targetRps <= 0 || workerCount <= 0) {
// Default concurrency when no RPS target or no workers
return this.options.concurrentRequestsPerWorker ?? 10;
}
// Calculate optimal concurrency based on target RPS per worker
const targetRpsPerWorker = targetRps / workerCount;
// Use configured value if provided, otherwise calculate dynamically
if (this.options.concurrentRequestsPerWorker !== undefined) {
return Math.min(
this.options.concurrentRequestsPerWorker,
Math.max(1, Math.ceil(targetRpsPerWorker)),
);
}
// Dynamic calculation: ensure we can meet target RPS with reasonable concurrency
// Allow up to 50 concurrent requests per worker, but at least 1
const dynamicConcurrency = Math.min(
50,
Math.max(1, Math.ceil(targetRpsPerWorker)),
);
return dynamicConcurrency;
}
/**
* The core worker function. It runs in a loop, making concurrent requests and respecting
* the rate limit (RPS) until instructed to stop.
* @param isStopped A function that returns true if the worker should stop.
* Defaults to checking the main runner's stopped flag.
*/
private async runWorker(
isStopped: () => boolean = () => this.stopped,
): Promise<void> {
while (!isStopped()) {
// Check for early exit conditions before making requests
if (this.shouldEarlyExit()) {
this.stop();
return;
}
// Calculate optimal concurrency for this worker
const requestsToMake = this.calculateOptimalConcurrency();
// Select random requests for this batch
const batchRequests = Array.from(
{ length: requestsToMake },
() => this.requests[Math.floor(Math.random() * this.requests.length)],
);
// Execute all requests concurrently
const requestPromises = batchRequests.map((req) =>
this.makeSingleRequest(req),
);
const results = await Promise.allSettled(requestPromises);
// Process all results
for (const result of results) {
if (result.status === 'fulfilled') {
this.onResult(result.value);
} else {
// Handle rejected promises (shouldn't normally happen with our implementation)
const errorResult = this.getResultObject();
errorResult.method = 'GET';
errorResult.url = 'unknown';
errorResult.status = 0;
errorResult.latencyMs = 0;
errorResult.success = false;
errorResult.error = result.reason?.toString() || 'Unknown error';
errorResult.timestamp = performance.now();
this.onResult(errorResult);
}
}
// Check for early exit conditions after processing all results
if (this.shouldEarlyExit()) {
this.stop();
return;
}
// Rate limiting for the batch
if (this.currentTargetRps > 0) {
const workerCount = this.getWorkerCount();
if (workerCount > 0) {
// Calculate delay for the entire batch
const batchDelay = (1000 * workerCount) / this.currentTargetRps;
await sleep(batchDelay);
} else {
await sleep(10);
}
} else {
// If no RPS is set, yield to the event loop
await sleep(0);
}
}
}
}