task-99: 全链路压测记录 CPU、内存、GC、DB、Redis、RustFS、网络结果
This commit is contained in:
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* 全链路压测记录器(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<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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createLoadTestRecorder, type LoadMetric, type LoadSample } from '../src/shared/load-test-recorder.ts'
|
||||||
|
|
||||||
|
const sample = (over: Partial<LoadSample> = {}): LoadSample => ({
|
||||||
|
cpuPercent: 35,
|
||||||
|
heapBytes: 1_200_000_000,
|
||||||
|
gcCount: 12,
|
||||||
|
dbQps: 500,
|
||||||
|
redisQps: 900,
|
||||||
|
rustfsQps: 60,
|
||||||
|
networkBytesPerSec: 8_000_000,
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_099_rustfs_normal_default_path', async () => {
|
||||||
|
const recorder = createLoadTestRecorder({
|
||||||
|
metric: async () => ({ ok: true, output: '200 OK', latencyMs: 120 }),
|
||||||
|
sample: async () => sample(),
|
||||||
|
})
|
||||||
|
const result = await recorder.run()
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal(result.summary.requests, 100)
|
||||||
|
assert.equal(result.summary.failures, 0)
|
||||||
|
assert.equal(result.summary.ok, true)
|
||||||
|
assert.equal(result.metrics.length, 100)
|
||||||
|
assert.equal(result.metrics[0].latencyMs, 120)
|
||||||
|
const s = result.summary.samples
|
||||||
|
assert.equal(s.cpuPercent.avg, 35)
|
||||||
|
assert.equal(s.heapBytes.avg, 1_200_000_000)
|
||||||
|
assert.equal(s.dbQps.avg, 500)
|
||||||
|
assert.equal(s.redisQps.avg, 900)
|
||||||
|
assert.equal(s.rustfsQps.avg, 60)
|
||||||
|
assert.equal(s.networkBytesPerSec.avg, 8_000_000)
|
||||||
|
assert.equal(s.gcCount.avg, 12)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_099_rustfs_normal_multiple_items', async () => {
|
||||||
|
// 批量压测:并发 N 路,结果不丢失且顺序稳定
|
||||||
|
let seq = 0
|
||||||
|
const recorder = createLoadTestRecorder({
|
||||||
|
metric: async (i: number) => ({ ok: true, output: 'ok', latencyMs: 100 + (i % 5) }),
|
||||||
|
sample: async () => {
|
||||||
|
const cpu = 30 + (seq % 3) * 10
|
||||||
|
seq += 1
|
||||||
|
return sample({ cpuPercent: cpu })
|
||||||
|
},
|
||||||
|
concurrency: 4,
|
||||||
|
})
|
||||||
|
const result = await recorder.run()
|
||||||
|
assert.equal(result.metrics.length, 100)
|
||||||
|
const latencies = result.metrics.map((m) => m.latencyMs)
|
||||||
|
assert.ok(latencies.every((l) => l >= 100 && l <= 104))
|
||||||
|
assert.equal(result.summary.requests, 100)
|
||||||
|
assert.equal(result.summary.ok, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_099_rustfs_normal_repeated_operation_is_idempotent', async () => {
|
||||||
|
let n = 0
|
||||||
|
const recorder = createLoadTestRecorder({
|
||||||
|
metric: async () => {
|
||||||
|
n += 1
|
||||||
|
return { ok: true, output: 'ok', latencyMs: 50 }
|
||||||
|
},
|
||||||
|
sample: async () => sample(),
|
||||||
|
})
|
||||||
|
const first = await recorder.run()
|
||||||
|
const second = await recorder.run()
|
||||||
|
assert.equal(first.summary.requests, 100)
|
||||||
|
assert.equal(second.summary.requests, 100)
|
||||||
|
assert.equal(first.summary.ok, second.summary.ok)
|
||||||
|
assert.deepEqual(first.summary.samples.heapBytes, second.summary.samples.heapBytes)
|
||||||
|
assert.equal(n, 200, '重复执行线性增长,无残留状态')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_099_rustfs_boundary_empty_input', async () => {
|
||||||
|
const recorder = createLoadTestRecorder({
|
||||||
|
requests: 0,
|
||||||
|
metric: async () => ({ ok: true, output: 'ok', latencyMs: 1 }),
|
||||||
|
sample: async () => sample(),
|
||||||
|
})
|
||||||
|
const result = await recorder.run()
|
||||||
|
assert.equal(result.summary.requests, 0)
|
||||||
|
assert.equal(result.metrics.length, 0)
|
||||||
|
assert.equal(result.summary.ok, true)
|
||||||
|
assert.equal(result.summary.failures, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_099_rustfs_boundary_single_item', async () => {
|
||||||
|
const recorder = createLoadTestRecorder({
|
||||||
|
requests: 1,
|
||||||
|
metric: async () => ({ ok: true, output: 'ok', latencyMs: 77 }),
|
||||||
|
sample: async () => sample(),
|
||||||
|
})
|
||||||
|
const result = await recorder.run()
|
||||||
|
assert.equal(result.metrics.length, 1)
|
||||||
|
assert.equal(result.metrics[0].latencyMs, 77)
|
||||||
|
assert.equal(result.summary.requests, 1)
|
||||||
|
assert.equal(result.summary.samples.cpuPercent.avg, 35)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_099_rustfs_boundary_limit_and_overflow', async () => {
|
||||||
|
// 部分失败:失败率超阈值 → ok=false;失败计数准确
|
||||||
|
const recorder = createLoadTestRecorder({
|
||||||
|
requests: 10,
|
||||||
|
maxFailures: 2,
|
||||||
|
metric: async (i: number) =>
|
||||||
|
i % 3 === 0
|
||||||
|
? { ok: false, output: 'timeout', latencyMs: 1000 }
|
||||||
|
: { ok: true, output: 'ok', latencyMs: 10 },
|
||||||
|
sample: async () => sample(),
|
||||||
|
})
|
||||||
|
const result = await recorder.run()
|
||||||
|
assert.equal(result.summary.requests, 10)
|
||||||
|
assert.equal(result.summary.failures, 4, 'i%3===0 共 4 次')
|
||||||
|
assert.equal(result.summary.ok, false)
|
||||||
|
// 失败未超阈值:仍判定 ok
|
||||||
|
const okRecorder = createLoadTestRecorder({
|
||||||
|
requests: 10,
|
||||||
|
maxFailures: 5,
|
||||||
|
metric: async (i: number) =>
|
||||||
|
i % 3 === 0
|
||||||
|
? { ok: false, output: 'timeout', latencyMs: 1000 }
|
||||||
|
: { ok: true, output: 'ok', latencyMs: 10 },
|
||||||
|
sample: async () => sample(),
|
||||||
|
})
|
||||||
|
assert.equal((await okRecorder.run()).summary.ok, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_099_rustfs_invalid_input_rejected', async () => {
|
||||||
|
assert.throws(() => createLoadTestRecorder({} as never), /metric 必须是函数/)
|
||||||
|
assert.throws(
|
||||||
|
() => createLoadTestRecorder({ metric: async () => ({ ok: true, output: '', latencyMs: 1 }), sample: undefined as never }),
|
||||||
|
/sample 必须是函数/,
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() => createLoadTestRecorder({ metric: async () => ({ ok: true, output: '', latencyMs: 1 }), sample: async () => sample(), requests: -1 }),
|
||||||
|
/requests 必须是非负整数/,
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() => createLoadTestRecorder({ metric: async () => ({ ok: true, output: '', latencyMs: 1 }), sample: async () => sample(), concurrency: 0 }),
|
||||||
|
/concurrency 必须为正数/,
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() => createLoadTestRecorder({ metric: async () => ({ ok: true, output: '', latencyMs: 1 }), sample: async () => sample(), maxFailures: -1 }),
|
||||||
|
/maxFailures 必须是非负整数/,
|
||||||
|
)
|
||||||
|
const recorder = createLoadTestRecorder({
|
||||||
|
metric: async () => ({ ok: true, output: 'ok', latencyMs: NaN }),
|
||||||
|
sample: async () => sample(),
|
||||||
|
})
|
||||||
|
await assert.rejects(() => recorder.run(), /latencyMs 必须为非负数值/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_099_rustfs_dependency_failure_releases_resources', async () => {
|
||||||
|
// 采样依赖抛错:单次采样失败不中断压测,统计从有效采样计算;恢复后正常
|
||||||
|
let broken = true
|
||||||
|
const recorder = createLoadTestRecorder({
|
||||||
|
requests: 10,
|
||||||
|
metric: async () => ({ ok: true, output: 'ok', latencyMs: 10 }),
|
||||||
|
sample: async () => {
|
||||||
|
if (broken) throw new Error('prometheus down')
|
||||||
|
return sample()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const result = await recorder.run()
|
||||||
|
assert.equal(result.summary.requests, 10, '采样失败不影响请求执行')
|
||||||
|
assert.equal(result.sampleFailures, 10)
|
||||||
|
assert.equal(result.summary.samples.avgCount, 0, '无有效采样时统计为空')
|
||||||
|
broken = false
|
||||||
|
const recovered = await recorder.run()
|
||||||
|
assert.equal(recovered.sampleFailures, 0)
|
||||||
|
assert.equal(recovered.summary.samples.avgCount, 10)
|
||||||
|
assert.equal(recovered.summary.samples.cpuPercent.avg, 35)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user