diff --git a/src/perf/index.ts b/src/perf/index.ts new file mode 100644 index 0000000..9a2218a --- /dev/null +++ b/src/perf/index.ts @@ -0,0 +1 @@ +export * from './loadTester'; diff --git a/src/perf/loadTester.ts b/src/perf/loadTester.ts new file mode 100644 index 0000000..cf6187b --- /dev/null +++ b/src/perf/loadTester.ts @@ -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; +} + +export class LoadTester { + private config: Required; + + 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(workload: (index: number) => Promise): Promise { + const latencies: number[] = []; + const errorMap = new Map(); + 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((_, 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), + }; + } +} diff --git a/test/loadTesting.test.ts b/test/loadTesting.test.ts new file mode 100644 index 0000000..b4ad57c --- /dev/null +++ b/test/loadTesting.test.ts @@ -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); + }); +});