Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/perf/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './loadTester';
143 changes: 143 additions & 0 deletions src/perf/loadTester.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* SDK Performance & Concurrent Load Testing Suite
*
* Implements high-throughput request generation, concurrency control,
* percentile latency metrics (p50, p95, p99), error rate accounting,
* and throughput (TPS) measurement.
*/

export interface LoadTestConfig {
/** Total number of simulated operations to execute */
totalOperations: number;
/** Maximum concurrent in-flight executions */
concurrencyLimit: number;
/** Optional timeout per operation in milliseconds */
timeoutMs?: number;
/** Ramp-up duration in ms before full load */
rampUpMs?: number;
}

export interface LatencyPercentiles {
min: number;
max: number;
mean: number;
p50: number;
p90: number;
p95: number;
p99: number;
}

export interface LoadTestResult {
totalOperations: number;
successfulOperations: number;
failedOperations: number;
errorRate: number;
totalDurationMs: number;
throughputTps: number;
latencies: LatencyPercentiles;
errors: Map<string, number>;
}

export class LoadTester {
private config: Required<LoadTestConfig>;

constructor(config: LoadTestConfig) {
this.config = {
totalOperations: Math.max(1, config.totalOperations),
concurrencyLimit: Math.max(1, config.concurrencyLimit),
timeoutMs: config.timeoutMs ?? 10_000,
rampUpMs: config.rampUpMs ?? 0,
};
}

/**
* Executes a load test workload with the configured concurrency limits.
*/
async execute<T>(workload: (index: number) => Promise<T>): Promise<LoadTestResult> {
const latencies: number[] = [];
const errorMap = new Map<string, number>();
let successfulOperations = 0;
let failedOperations = 0;

const startTime = Date.now();
let currentOpIndex = 0;

const worker = async () => {
while (true) {
const opIndex = currentOpIndex++;
if (opIndex >= this.config.totalOperations) {
break;
}

// Apply progressive ramp-up delay if specified
if (this.config.rampUpMs > 0 && opIndex < this.config.concurrencyLimit) {
const delay = (this.config.rampUpMs / this.config.concurrencyLimit) * opIndex;
await new Promise((r) => setTimeout(r, delay));
}

const opStart = performance.now();
try {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Operation timed out')), this.config.timeoutMs);
});

await Promise.race([workload(opIndex), timeoutPromise]);
const duration = performance.now() - opStart;
latencies.push(duration);
successfulOperations++;
} catch (err: unknown) {
const duration = performance.now() - opStart;
latencies.push(duration);
failedOperations++;
const errKey = err instanceof Error ? err.message : String(err);
errorMap.set(errKey, (errorMap.get(errKey) ?? 0) + 1);
}
}
};

const workerCount = Math.min(this.config.concurrencyLimit, this.config.totalOperations);
const workers = Array.from({ length: workerCount }, () => worker());
await Promise.all(workers);

const totalDurationMs = Date.now() - startTime;
const throughputTps =
totalDurationMs > 0 ? (this.config.totalOperations / totalDurationMs) * 1000 : 0;
const errorRate =
this.config.totalOperations > 0 ? failedOperations / this.config.totalOperations : 0;

return {
totalOperations: this.config.totalOperations,
successfulOperations,
failedOperations,
errorRate,
totalDurationMs,
throughputTps,
latencies: this.calculatePercentiles(latencies),
errors: errorMap,
};
}

private calculatePercentiles(latencies: number[]): LatencyPercentiles {
if (latencies.length === 0) {
return { min: 0, max: 0, mean: 0, p50: 0, p90: 0, p95: 0, p99: 0 };
}

const sorted = [...latencies].sort((a, b) => a - b);
const sum = sorted.reduce((acc, val) => acc + val, 0);

const getP = (p: number) => {
const idx = Math.min(Math.floor((p / 100) * sorted.length), sorted.length - 1);
return sorted[idx];
};

return {
min: sorted[0],
max: sorted[sorted.length - 1],
mean: sum / sorted.length,
p50: getP(50),
p90: getP(90),
p95: getP(95),
p99: getP(99),
};
}
}
70 changes: 70 additions & 0 deletions test/loadTesting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import { LoadTester } from '../src/perf/loadTester';

describe('LoadTester Performance & Concurrency Suite', () => {
it('executes concurrent operations within target concurrency bounds', async () => {
let activeConcurrency = 0;
let peakConcurrency = 0;

const tester = new LoadTester({
totalOperations: 50,
concurrencyLimit: 5,
timeoutMs: 2000,
});

const result = await tester.execute(async (idx) => {
activeConcurrency++;
peakConcurrency = Math.max(peakConcurrency, activeConcurrency);
await new Promise((r) => setTimeout(r, 10));
activeConcurrency--;
return { index: idx, status: 'ok' };
});

expect(result.totalOperations).toBe(50);
expect(result.successfulOperations).toBe(50);
expect(result.failedOperations).toBe(0);
expect(result.errorRate).toBe(0);
expect(peakConcurrency).toBeLessThanOrEqual(5);
expect(result.throughputTps).toBeGreaterThan(0);
expect(result.latencies.p50).toBeGreaterThan(0);
expect(result.latencies.p95).toBeGreaterThanOrEqual(result.latencies.p50);
expect(result.latencies.max).toBeGreaterThanOrEqual(result.latencies.min);
});

it('captures failures, calculates error rates, and isolates errors', async () => {
const tester = new LoadTester({
totalOperations: 20,
concurrencyLimit: 4,
timeoutMs: 1000,
});

const result = await tester.execute(async (idx) => {
if (idx % 4 === 0) {
throw new Error('Simulated network congestion');
}
return 'success';
});

expect(result.totalOperations).toBe(20);
expect(result.failedOperations).toBe(5);
expect(result.successfulOperations).toBe(15);
expect(result.errorRate).toBe(0.25);
expect(result.errors.get('Simulated network congestion')).toBe(5);
});

it('handles timeouts accurately when operations exceed budget', async () => {
const tester = new LoadTester({
totalOperations: 5,
concurrencyLimit: 2,
timeoutMs: 50,
});

const result = await tester.execute(async () => {
await new Promise((r) => setTimeout(r, 200));
});

expect(result.failedOperations).toBe(5);
expect(result.errorRate).toBe(1);
expect(result.errors.get('Operation timed out')).toBe(5);
});
});