From 2fb56632c9e4fc789b367e3c27f9801695c57524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sun, 30 Aug 2026 23:17:02 +0800 Subject: [PATCH] =?UTF-8?q?task-99:=20=E5=85=A8=E9=93=BE=E8=B7=AF=E5=8E=8B?= =?UTF-8?q?=E6=B5=8B=E8=AE=B0=E5=BD=95=20CPU=E3=80=81=E5=86=85=E5=AD=98?= =?UTF-8?q?=E3=80=81GC=E3=80=81DB=E3=80=81Redis=E3=80=81RustFS=E3=80=81?= =?UTF-8?q?=E7=BD=91=E7=BB=9C=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-vue/src/shared/load-test-recorder.ts | 180 ++++++++++++++++++ frontend-vue/tests/load-test-recorder.test.ts | 175 +++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 frontend-vue/src/shared/load-test-recorder.ts create mode 100644 frontend-vue/tests/load-test-recorder.test.ts diff --git a/frontend-vue/src/shared/load-test-recorder.ts b/frontend-vue/src/shared/load-test-recorder.ts new file mode 100644 index 00000000..bc78e1e0 --- /dev/null +++ b/frontend-vue/src/shared/load-test-recorder.ts @@ -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 + /** 系统指标采样器;抛错计入 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 } +} diff --git a/frontend-vue/tests/load-test-recorder.test.ts b/frontend-vue/tests/load-test-recorder.test.ts new file mode 100644 index 00000000..9cf96541 --- /dev/null +++ b/frontend-vue/tests/load-test-recorder.test.ts @@ -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 => ({ + 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) +})