task-98: 真实启动、健康检查、核心请求和外部依赖调用验证执行器
This commit is contained in:
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* 健康检查执行器(Task 98)。
|
||||||
|
*
|
||||||
|
* 把真实启动、健康检查、核心请求、外部依赖调用四个验收探针收敛为
|
||||||
|
* 可注入探针的确定性流程:探针按序执行,全部产出结果与耗时。
|
||||||
|
*
|
||||||
|
* 语义:
|
||||||
|
* - 单个探针失败(返回非 ok 输出或抛错)计入 failed,不中断其余探针,
|
||||||
|
* 健康检查输出全量报告;ok=false 当且仅当存在 failed;
|
||||||
|
* - 空探针列表视为通过;单探针不依赖批量路径;
|
||||||
|
* - maxProbes 限制执行的探针数量(超出部分安全跳过),执行有界;
|
||||||
|
* - timeoutMs 为单个探针设置超时上限:超时按失败计入,不泄漏未完成
|
||||||
|
* 的探测任务(探针异步任务自行收尾);
|
||||||
|
* - 校验失败 fail-fast:probes 非数组、id 为空、probe 非函数、maxProbes/
|
||||||
|
* timeoutMs 非正数、探针返回非字符串均抛错;失败可恢复——修复探针后
|
||||||
|
* 同一 runner 重跑即全绿。
|
||||||
|
*/
|
||||||
|
export interface HealthProbe {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
probe: () => Promise<string> | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthCheckOptions {
|
||||||
|
probes: HealthProbe[]
|
||||||
|
/** 执行的探针数量上限,必须为正数,默认 100 */
|
||||||
|
maxProbes?: number
|
||||||
|
/** 单个探针超时(毫秒),必须为正数,默认 30_000 */
|
||||||
|
timeoutMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProbeResult {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
ok: boolean
|
||||||
|
output: string
|
||||||
|
durationMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthCheckResult {
|
||||||
|
ok: boolean
|
||||||
|
passed: ProbeResult[]
|
||||||
|
failed: ProbeResult[]
|
||||||
|
totalDurationMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthCheckRunner {
|
||||||
|
runAll: () => Promise<HealthCheckResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHealthCheckRunner(options: HealthCheckOptions): HealthCheckRunner {
|
||||||
|
if (!Array.isArray(options.probes)) {
|
||||||
|
throw new Error('probes 必须是数组')
|
||||||
|
}
|
||||||
|
for (const probe of options.probes) {
|
||||||
|
if (typeof probe.id !== 'string' || probe.id.length === 0) {
|
||||||
|
throw new Error('probe id 不能为空')
|
||||||
|
}
|
||||||
|
if (typeof probe.probe !== 'function') {
|
||||||
|
throw new Error('probe 必须是函数')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const maxProbes = options.maxProbes ?? 100
|
||||||
|
const timeoutMs = options.timeoutMs ?? 30_000
|
||||||
|
if (!(maxProbes > 0)) {
|
||||||
|
throw new Error('maxProbes 必须为正数: ' + maxProbes)
|
||||||
|
}
|
||||||
|
if (!(timeoutMs > 0)) {
|
||||||
|
throw new Error('timeoutMs 必须为正数: ' + timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOne(probe: HealthProbe): Promise<ProbeResult> {
|
||||||
|
const start = Date.now()
|
||||||
|
let output = ''
|
||||||
|
let failed = false
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
try {
|
||||||
|
const raw = await Promise.race([
|
||||||
|
Promise.resolve(probe.probe()),
|
||||||
|
new Promise<string>((resolve) => {
|
||||||
|
timer = setTimeout(() => resolve(''), timeoutMs)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
if (timer != null) clearTimeout(timer)
|
||||||
|
output = raw
|
||||||
|
} catch (error) {
|
||||||
|
if (timer != null) clearTimeout(timer)
|
||||||
|
failed = true
|
||||||
|
output = error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
const durationMs = Date.now() - start
|
||||||
|
if (typeof output !== 'string') {
|
||||||
|
throw new Error('output 必须是字符串')
|
||||||
|
}
|
||||||
|
const ok = !failed && output !== ''
|
||||||
|
return { id: probe.id, label: probe.label, ok, output, durationMs }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAll(): Promise<HealthCheckResult> {
|
||||||
|
const start = Date.now()
|
||||||
|
const passed: ProbeResult[] = []
|
||||||
|
const failed: ProbeResult[] = []
|
||||||
|
for (let i = 0; i < options.probes.length; i++) {
|
||||||
|
if (i >= maxProbes) break
|
||||||
|
const result = await runOne(options.probes[i])
|
||||||
|
if (result.ok) {
|
||||||
|
passed.push(result)
|
||||||
|
} else {
|
||||||
|
failed.push(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: failed.length === 0,
|
||||||
|
passed,
|
||||||
|
failed,
|
||||||
|
totalDurationMs: Date.now() - start,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { runAll }
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createHealthCheckRunner, type HealthProbe } from '../src/shared/health-check-runner.ts'
|
||||||
|
|
||||||
|
const PROBES: HealthProbe[] = [
|
||||||
|
{ id: 'startup', label: '真实启动', probe: async () => 'listening :18080' },
|
||||||
|
{ id: 'health', label: '健康检查', probe: async () => '{"status":"UP"}' },
|
||||||
|
{ id: 'core-request', label: '核心请求', probe: async () => '200 OK' },
|
||||||
|
{ id: 'external-deps', label: '外部依赖调用', probe: async () => 'mysql/redis/oss ok' },
|
||||||
|
]
|
||||||
|
|
||||||
|
test('test_task_098_task_normal_default_path', async () => {
|
||||||
|
const runner = createHealthCheckRunner({ probes: PROBES })
|
||||||
|
const result = await runner.runAll()
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal(result.passed.length, 4)
|
||||||
|
assert.deepEqual(result.failed, [])
|
||||||
|
assert.deepEqual(
|
||||||
|
result.passed.map((p) => p.id),
|
||||||
|
['startup', 'health', 'core-request', 'external-deps'],
|
||||||
|
'按序执行全部探针',
|
||||||
|
)
|
||||||
|
assert.equal(result.passed[0].output, 'listening :18080')
|
||||||
|
assert.ok(result.passed[0].durationMs >= 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_098_task_normal_multiple_items', async () => {
|
||||||
|
const runner = createHealthCheckRunner({ probes: PROBES })
|
||||||
|
const result = await runner.runAll()
|
||||||
|
// 批量场景:结果不丢失且顺序稳定
|
||||||
|
assert.equal(result.passed.length, 4)
|
||||||
|
assert.equal(result.passed[3].id, 'external-deps')
|
||||||
|
assert.equal(result.passed[3].output, 'mysql/redis/oss ok')
|
||||||
|
assert.equal(result.totalDurationMs >= 0, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_098_task_normal_repeated_operation_is_idempotent', async () => {
|
||||||
|
const runner = createHealthCheckRunner({ probes: PROBES })
|
||||||
|
const first = await runner.runAll()
|
||||||
|
const second = await runner.runAll()
|
||||||
|
assert.equal(first.ok, second.ok)
|
||||||
|
assert.deepEqual(first.passed.map((p) => p.id), second.passed.map((p) => p.id))
|
||||||
|
assert.deepEqual(first.failed, second.failed)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_098_task_boundary_empty_input', async () => {
|
||||||
|
const runner = createHealthCheckRunner({ probes: [] })
|
||||||
|
const result = await runner.runAll()
|
||||||
|
assert.equal(result.ok, true, '无探针视为通过')
|
||||||
|
assert.deepEqual(result.passed, [])
|
||||||
|
assert.deepEqual(result.failed, [])
|
||||||
|
assert.equal(result.totalDurationMs >= 0, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_098_task_boundary_single_item', async () => {
|
||||||
|
const runner = createHealthCheckRunner({ probes: [PROBES[0]] })
|
||||||
|
const result = await runner.runAll()
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal(result.passed.length, 1)
|
||||||
|
assert.equal(result.passed[0].id, 'startup')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_098_task_boundary_limit_and_overflow', async () => {
|
||||||
|
// 探针失败:标记 failed,其余探针继续执行(健康检查全量报告)
|
||||||
|
let failHealth = false
|
||||||
|
const probes = PROBES.map((p) =>
|
||||||
|
p.id === 'health'
|
||||||
|
? { ...p, probe: async () => { failHealth = true; throw new Error('health down') } }
|
||||||
|
: p,
|
||||||
|
)
|
||||||
|
const runner = createHealthCheckRunner({ probes })
|
||||||
|
const result = await runner.runAll()
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.failed.length, 1)
|
||||||
|
assert.equal(result.failed[0].id, 'health')
|
||||||
|
assert.ok(result.failed[0].output.includes('health down'))
|
||||||
|
assert.equal(result.passed.length, 3, '失败不中断其余探针')
|
||||||
|
assert.equal(failHealth, true)
|
||||||
|
// 探针抛错:同样计入 failed,不中断
|
||||||
|
const throwing = createHealthCheckRunner({
|
||||||
|
probes: [{ id: 'startup', label: 'x', probe: async () => { throw new Error('boom') } }, ...PROBES.slice(1)],
|
||||||
|
})
|
||||||
|
const thrown = await throwing.runAll()
|
||||||
|
assert.equal(thrown.ok, false)
|
||||||
|
assert.equal(thrown.failed[0].id, 'startup')
|
||||||
|
assert.ok(thrown.failed[0].output.includes('boom'))
|
||||||
|
assert.equal(thrown.passed.length, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_098_task_invalid_input_rejected', async () => {
|
||||||
|
assert.throws(() => createHealthCheckRunner({} as never), /probes 必须是数组/)
|
||||||
|
assert.throws(() => createHealthCheckRunner({ probes: 'x' as never }), /probes 必须是数组/)
|
||||||
|
assert.throws(
|
||||||
|
() => createHealthCheckRunner({ probes: [{ id: '', label: 'x', probe: async () => '' }] }),
|
||||||
|
/probe id 不能为空/,
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() => createHealthCheckRunner({ probes: [{ id: 'a', label: 'x', probe: undefined as never }] }),
|
||||||
|
/probe 必须是函数/,
|
||||||
|
)
|
||||||
|
assert.throws(() => createHealthCheckRunner({ probes: [], maxProbes: 0 }), /maxProbes 必须为正数/)
|
||||||
|
assert.throws(
|
||||||
|
() => createHealthCheckRunner({ probes: PROBES, timeoutMs: 0 }),
|
||||||
|
/timeoutMs 必须为正数/,
|
||||||
|
)
|
||||||
|
// probe 返回非字符串:抛错
|
||||||
|
const bad = createHealthCheckRunner({ probes: [{ id: 'a', label: 'x', probe: async () => 1 as never }] })
|
||||||
|
await assert.rejects(() => bad.runAll(), /output 必须是字符串/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_098_task_dependency_failure_releases_resources', async () => {
|
||||||
|
// 外部依赖探针失败:错误可恢复,修复后同一 runner 重跑全绿
|
||||||
|
let broken = true
|
||||||
|
let attempts = 0
|
||||||
|
const runner = createHealthCheckRunner({
|
||||||
|
probes: [
|
||||||
|
{ id: 'startup', label: '启动', probe: async () => 'ok' },
|
||||||
|
{
|
||||||
|
id: 'external-deps',
|
||||||
|
label: '外部依赖',
|
||||||
|
probe: async () => {
|
||||||
|
attempts += 1
|
||||||
|
if (broken) throw new Error('mysql connection refused')
|
||||||
|
return 'mysql ok'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const first = await runner.runAll()
|
||||||
|
assert.equal(first.ok, false)
|
||||||
|
assert.equal(first.failed[0].id, 'external-deps')
|
||||||
|
assert.equal(first.passed.length, 1)
|
||||||
|
assert.ok(first.failed[0].output.includes('mysql connection refused'))
|
||||||
|
broken = false
|
||||||
|
const second = await runner.runAll()
|
||||||
|
assert.equal(second.ok, true)
|
||||||
|
assert.equal(second.passed.length, 2)
|
||||||
|
assert.equal(attempts, 2)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user