Files
crawler-plugin/frontend-vue/src/shared/load-test-recorder.ts
T

181 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 全链路压测记录器(Task 99)。
*
* 执行压测请求并记录 CPU、内存、GC、DB、Redis、RustFS、网络结果:
* metric(i) 产生单次请求结果(ok/延迟),sample() 周期采集系统指标,
* 输出请求统计 + 各指标的平均/最大/min/95 分位汇总。
*
* 语义:
* - 并发有界:concurrency 个并发槽执行 requests 个请求,不无界扩张;
* - 失败有界:maxFailures 设定失败容忍上限,超过后 ok=false,但仍完成
* 全部请求并输出完整报告;
* - 采样失败(依赖故障)不中断压测:计入 sampleFailures,统计从有效
* 采样计算;依赖恢复后同一 recorder 重跑即全量恢复;
* - 空请求数安全返回;单请求不依赖批量路径;同一输入重复执行幂等,
* 无残留状态;
* - 校验失败 fail-fastmetric/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<LoadMetric>
/** 系统指标采样器;抛错计入 sampleFailures 不中断压测 */
sample: () => Promise<LoadSample>
/** 请求总数,必须为非负整数,默认 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<string, MetricSummary> & { avgCount: number }
}
export interface LoadTestResult {
ok: boolean
summary: LoadTestSummary
metrics: Array<LoadMetric & { index: number }>
sampleFailures: number
}
export interface LoadTestRecorder {
run: () => Promise<LoadTestResult>
}
const SAMPLE_KEYS: Array<keyof LoadSample> = [
'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<LoadTestResult> {
const metrics: Array<LoadMetric & { index: number }> = []
const samplesByKey: Record<string, number[]> = {}
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<string, MetricSummary>)[key] = summarize(samplesByKey[key])
}
return { ok: failures <= maxFailures, summary, metrics, sampleFailures }
}
return { run }
}