/** * 全链路压测记录器(Task 99)。 * * 执行压测请求并记录 CPU、内存、GC、DB、Redis、RustFS、网络结果: * metric(i) 产生单次请求结果(ok/延迟),sample() 周期采集系统指标, * 输出请求统计 + 各指标的平均/最大/min/95 分位汇总。 * * 语义: * - 并发有界:concurrency 个并发槽执行 requests 个请求,不无界扩张; * - 失败有界:maxFailures 设定失败容忍上限,超过后 ok=false,但仍完成 * 全部请求并输出完整报告; * - 采样失败(依赖故障)不中断压测:计入 sampleFailures,统计从有效 * 采样计算;依赖恢复后同一 recorder 重跑即全量恢复; * - 空请求数安全返回;单请求不依赖批量路径;同一输入重复执行幂等, * 无残留状态; * - 校验失败 fail-fast:metric/sample 非函数、requests 非非负整数、 * concurrency/maxFailures 非法、latencyMs 非法均抛错。 */ export interface LoadMetric { ok: boolean output: string latencyMs: number } export interface LoadSample { cpuPercent: number heapBytes: number gcCount: number dbQps: number redisQps: number rustfsQps: number networkBytesPerSec: number } export interface LoadTestRecorderOptions { /** 单次请求执行器;抛错按失败计入 */ metric: (i: number) => Promise /** 系统指标采样器;抛错计入 sampleFailures 不中断压测 */ sample: () => Promise /** 请求总数,必须为非负整数,默认 100 */ requests?: number /** 并发槽数量,必须为正数,默认 1 */ concurrency?: number /** 失败容忍上限,必须为非负整数,默认 0 */ maxFailures?: number /** 采样间隔(每 N 个请求采样一次),必须为正数,默认 1 */ sampleEvery?: number } export interface MetricSummary { avg: number max: number min: number p95: number } export interface LoadTestSummary { ok: boolean requests: number failures: number samples: Record & { avgCount: number } } export interface LoadTestResult { ok: boolean summary: LoadTestSummary metrics: Array sampleFailures: number } export interface LoadTestRecorder { run: () => Promise } const SAMPLE_KEYS: Array = [ 'cpuPercent', 'heapBytes', 'gcCount', 'dbQps', 'redisQps', 'rustfsQps', 'networkBytesPerSec', ] function p95(values: number[]): number { if (values.length === 0) return 0 const sorted = [...values].sort((a, b) => a - b) const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1) return sorted[index] } function summarize(values: number[]): MetricSummary { return { avg: values.length ? values.reduce((sum, v) => sum + v, 0) / values.length : 0, max: values.length ? Math.max(...values) : 0, min: values.length ? Math.min(...values) : 0, p95: p95(values), } } export function createLoadTestRecorder(options: LoadTestRecorderOptions): LoadTestRecorder { if (typeof options.metric !== 'function') { throw new Error('metric 必须是函数') } if (typeof options.sample !== 'function') { throw new Error('sample 必须是函数') } const requests = options.requests ?? 100 const concurrency = options.concurrency ?? 1 const maxFailures = options.maxFailures ?? 0 const sampleEvery = options.sampleEvery ?? 1 if (!Number.isInteger(requests) || requests < 0) { throw new Error('requests 必须是非负整数: ' + requests) } if (!(concurrency > 0)) { throw new Error('concurrency 必须为正数: ' + concurrency) } if (!Number.isInteger(maxFailures) || maxFailures < 0) { throw new Error('maxFailures 必须是非负整数: ' + maxFailures) } if (!(sampleEvery > 0)) { throw new Error('sampleEvery 必须为正数: ' + sampleEvery) } async function run(): Promise { const metrics: Array = [] const samplesByKey: Record = {} for (const key of SAMPLE_KEYS) samplesByKey[key] = [] let sampleFailures = 0 const runRequest = async (i: number) => { let metric: LoadMetric try { metric = await options.metric(i) } catch (error) { metric = { ok: false, output: error instanceof Error ? error.message : String(error), latencyMs: 0 } } if (typeof metric.latencyMs !== 'number' || !Number.isFinite(metric.latencyMs) || metric.latencyMs < 0) { throw new Error('latencyMs 必须为非负数值: ' + metric.latencyMs) } metrics.push({ ...metric, index: i }) if (i % sampleEvery === 0) { try { const sample = await options.sample() for (const key of SAMPLE_KEYS) { samplesByKey[key].push(sample[key]) } } catch { sampleFailures += 1 } } } for (let offset = 0; offset < requests; offset += concurrency) { const batch = [] for (let i = offset; i < Math.min(offset + concurrency, requests); i++) { batch.push(runRequest(i)) } await Promise.all(batch) } metrics.sort((a, b) => a.index - b.index) const failures = metrics.filter((m) => !m.ok).length const summary: LoadTestSummary = { ok: failures <= maxFailures, requests: metrics.length, failures, samples: { avgCount: samplesByKey.cpuPercent.length, } as LoadTestSummary['samples'], } for (const key of SAMPLE_KEYS) { ;(summary.samples as Record)[key] = summarize(samplesByKey[key]) } return { ok: failures <= maxFailures, summary, metrics, sampleFailures } } return { run } }