mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +00:00
与单元判据的分工:单元判据钉住**语义**(一条路径对不对);压力测试钉住
**规模下的不变量**。沿用仓内既有范式(media/soak_test.go):
testing.Short() 跳过 + 独立 -run 跑。
① 超长序列
· 解析 10 / 100 / 1000 组(250KB 文本):6.8ms,无硬上限误报
· 执行 200 组 × 5 工具 = 1000 次调用:2.0ms
断言:每工具恰好被调 nGroups 次(无遗漏/重复)、结果含**最后一组**
—— 组间串行在规模下仍成立
② 100 工具组内并行
· 100 工具全声明并发安全 ⇒ 11ms,完成顺序**确实被打乱**(判据会校验
这一点,否则它测不到并发)
· 断言每个槽拿到**自己**的结果(并发下若按完成顺序合并就会错位)
③ 串行降级
· 50 个工具里**一个**未声明并发安全 ⇒ 整批退回串行,
完成顺序严格等于声明序(106ms vs 并发的 11ms,降级确实生效)
★ 压力测试第一次跑就抓到一个**真实分层缺陷**:
「含非并发安全工具则整批串行」这条规则**只在上层 runGroup 实现**,
而引擎层 execGroup 只信 g.Parallel 字段 ⇒ 任何人直接调 execGroup
都会拿到不受约束的并发。
已修:降级判据下沉到引擎层,新增 batchCanRun(g, runner),
toolRunner 增加 parallelSafe 方法(生产路径行为不变,只是把判据
放到了它本该在的层)。
过程中压测自身也暴露了两个测试缺陷(都修了):
· fixture 让 100 个工具写同一个标量槽 o,被静态校验正确拦下
("组内并行下同名写入是数据竞争")—— 压测不该去撞这条规则;
· ★ e2eRunner.called 是无锁 append,100 工具并发时 -race 报出**真竞态**
(不是误报)—— 加锁 + 提供 calledSnapshot 供断言。
另:建序列与跑序列原本用了**不同 plugin 实例**(序列存在实例的 store 里,
换实例就读不到自己刚建的),已改为同一实例。
回归:go test -race ./internal/plugins/seq 全绿;go test ./internal/... 全绿。
351 lines
12 KiB
Go
351 lines
12 KiB
Go
package seq
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 本文件是**压力测试**:三个维度,各自有量化的通过判据。
|
||
//
|
||
// 与单元判据的分工:单元判据钉住**语义**(一条路径对不对);压力测试钉住
|
||
// **规模下的不变量**(100 个工具并行时顺序还保不保得住、上千个 group 的
|
||
// 序列还能不能解析、组内调另一条序列会不会失控)。
|
||
//
|
||
// 跑法:默认 short 模式跳过;单独跑
|
||
// go test ./internal/plugins/seq/ -run 'TestStress|TestSoak' -timeout 600s
|
||
//
|
||
// ⚠️ 本仓既有教训(core 的 TestResidual*):压力测试若与其它用例共享
|
||
// 全局状态(这里是注入的 provider/reporter),会偶发失败。因此本文件
|
||
// 全部使用**独立实例**,不触碰任何包级变量。
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ① 超长序列:解析 + 静态校验 + 执行
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// mkBigSeq 造一条有 n 个 group、每组 m 个工具的序列文本。
|
||
// 全部用最小 schema(无入参、单个出参),以隔离"规模"这个变量。
|
||
func mkBigSeq(nGroups, nTools int) []byte {
|
||
groups := make([]interface{}, 0, nGroups)
|
||
for g := 0; g < nGroups; g++ {
|
||
tools := make([]string, 0, nTools)
|
||
out := map[string]string{}
|
||
for t := 0; t < nTools; t++ {
|
||
// ⚠️ 每个工具写**自己的**槽:标量槽被同名 as 写多次会被静态校验
|
||
// 正确拦下("组内并行下同名写入是数据竞争")。压测不该去撞这条规则。
|
||
slot := fmt.Sprintf("o%d", t)
|
||
out[slot] = "string"
|
||
tools = append(tools, fmt.Sprintf(
|
||
`{"tool":"noop_%d","args":{"i":%d},"as":%q}`, t, t, slot))
|
||
}
|
||
groups = append(groups, map[string]interface{}{
|
||
"name": fmt.Sprintf("g%04d", g),
|
||
"in": map[string]string{},
|
||
"out": out,
|
||
"tools": strings.Join(tools, " ; ") + " ;",
|
||
})
|
||
}
|
||
doc := map[string]interface{}{
|
||
"name": "bigseq",
|
||
"description": "压力测试用超长序列",
|
||
"groups": groups,
|
||
}
|
||
b, err := json.Marshal(doc)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
return b
|
||
}
|
||
|
||
// TestStress_BigSequenceParse 解析超长序列。
|
||
//
|
||
// 判据:group 数/工具数正确,且**不因规模而误报**。
|
||
// 规模递增(10 → 100 → 1000 组),看是否有硬上限把合法序列挡掉。
|
||
func TestStress_BigSequenceParse(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("stress test; run with -run TestStress")
|
||
}
|
||
for _, n := range []int{10, 100, 1000} {
|
||
b := mkBigSeq(n, 3)
|
||
start := time.Now()
|
||
seq, err := Parse(b)
|
||
elapsed := time.Since(start)
|
||
if err != nil {
|
||
t.Fatalf("%d 组解析失败(不该有上限?): %v", n, err)
|
||
}
|
||
if len(seq.Groups) != n {
|
||
t.Errorf("%d 组:解析出 %d 个 group", n, len(seq.Groups))
|
||
}
|
||
if len(seq.Groups) > 0 && len(seq.Groups[0].Tools) != 3 {
|
||
t.Errorf("%d 组:首组工具数 = %d,期望 3", n, len(seq.Groups[0].Tools))
|
||
}
|
||
t.Logf("%4d 组 × 3 工具:文本 %6.1f KB,解析耗时 %v", n, float64(len(b))/1024, elapsed)
|
||
}
|
||
}
|
||
|
||
// TestStress_BigSequenceRun 执行超长序列,验证顺序与计数。
|
||
//
|
||
// 这是"组间串行"不变式在规模下的检验:1000 个 group 的输出槽最终值
|
||
// 必须来自**最后一个** group —— 若某处静默并发化或乱序,结果会错。
|
||
func TestStress_BigSequenceRun(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("stress test; run with -run TestStress")
|
||
}
|
||
const nGroups, nTools = 200, 5
|
||
r := newE2ERunner()
|
||
for i := 0; i < nTools; i++ {
|
||
r.defs[fmt.Sprintf("noop_%d", i)] = toolDefInfo{
|
||
Name: fmt.Sprintf("noop_%d", i),
|
||
}
|
||
r.results[fmt.Sprintf("noop_%d", i)] = fmt.Sprintf("v%d", i)
|
||
}
|
||
p := newE2EPlugin(t, r)
|
||
|
||
if _, err := p.dispatch("seq_create", map[string]interface{}{
|
||
"name": "runbig", "groups": parseGroupsOrFail(t, mkBigSeq(nGroups, nTools)),
|
||
}); err != nil {
|
||
t.Fatalf("seq_create: %v", err)
|
||
}
|
||
|
||
start := time.Now()
|
||
out, err := p.dispatch("seq_run", map[string]interface{}{"name": "runbig"})
|
||
elapsed := time.Since(start)
|
||
if err != nil {
|
||
t.Fatalf("seq_run 失败: %v", err)
|
||
}
|
||
res, _ := out.(string)
|
||
|
||
wantCalls := nGroups * nTools
|
||
// 计数:每个工具被调用 nGroups 次(每组一次)
|
||
counts := map[string]int{}
|
||
for _, c := range r.calledSnapshot() {
|
||
counts[c]++
|
||
}
|
||
if len(counts) != nTools {
|
||
t.Errorf("不同工具数 = %d,期望 %d", len(counts), nTools)
|
||
}
|
||
for tool, n := range counts {
|
||
if n != nGroups {
|
||
t.Errorf("工具 %s 被调 %d 次,期望 %d(每组一次)", tool, n, nGroups)
|
||
}
|
||
}
|
||
// 组序:摘要里应出现**最后一组**的名字(组间串行 ⇒ 它是最终状态)
|
||
last := fmt.Sprintf("g%04d", nGroups-1)
|
||
if !strings.Contains(res, last) {
|
||
t.Errorf("结果未包含最后一组 %q(组间串行被破坏?)", last)
|
||
}
|
||
// 组数统计应与实际一致
|
||
if !strings.Contains(res, fmt.Sprintf("%d/%d 组", nGroups, nGroups)) {
|
||
t.Errorf("结果未报告 %d/%d 组完成: %s", nGroups, nGroups, truncateForMsg(res, 200))
|
||
}
|
||
t.Logf("%d 组 × %d 工具 = %d 次调用,耗时 %v", nGroups, nTools, wantCalls, elapsed)
|
||
}
|
||
|
||
// parseGroupsOrFail 从完整序列 JSON 里取出 groups 数组。
|
||
func parseGroupsOrFail(t *testing.T, doc []byte) []interface{} {
|
||
t.Helper()
|
||
var d map[string]interface{}
|
||
if err := json.Unmarshal(doc, &d); err != nil {
|
||
t.Fatalf("构造 fixture 失败: %v", err)
|
||
}
|
||
g, _ := d["groups"].([]interface{})
|
||
return g
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ② 组内 100 工具并行
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// TestStress_Parallel100Tools 一组内 100 个工具并发执行。
|
||
//
|
||
// 检验两条不变量在规模下是否成立:
|
||
// 1. **完成顺序不确定,但合并顺序按声明序** —— 否则同样的输入产出不同结果;
|
||
// 2. 每个工具恰好执行一次,无遗漏无重复。
|
||
func TestStress_Parallel100Tools(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("stress test; run with -run TestStress")
|
||
}
|
||
const n = 100
|
||
r := newE2ERunner()
|
||
out := map[string]string{}
|
||
for i := 0; i < n; i++ {
|
||
name := fmt.Sprintf("t%03d", i)
|
||
r.defs[name] = toolDefInfo{Name: name, ParallelSafe: true}
|
||
r.results[name] = "R" + name
|
||
out[name] = "string"
|
||
}
|
||
// 交错延迟:制造乱序完成(idx 越大越先完成)
|
||
for i := 0; i < n; i++ {
|
||
name := fmt.Sprintf("t%03d", i)
|
||
r.delay[name] = (n - i) / 10
|
||
}
|
||
|
||
seq, err := Parse(mustMarshal(map[string]interface{}{
|
||
"name": "p100",
|
||
"groups": []interface{}{map[string]interface{}{
|
||
"name": "g", "in": map[string]string{}, "out": out,
|
||
"parallel": true,
|
||
"tools": toolsStr(n, "t%03d", "R t%03d"),
|
||
}},
|
||
}))
|
||
if err != nil {
|
||
t.Fatalf("构造失败: %v", err)
|
||
}
|
||
|
||
start := time.Now()
|
||
res, err := execGroup(seq.Groups[0], map[string]interface{}{}, r)
|
||
elapsed := time.Since(start)
|
||
if err != nil {
|
||
t.Fatalf("execGroup 失败: %v", err)
|
||
}
|
||
|
||
// ① 每个工具恰好一次
|
||
if len(r.calledSnapshot()) != n {
|
||
t.Errorf("调用次数 = %d,期望 %d(100 工具并行有遗漏或重复)", len(r.called), n)
|
||
}
|
||
seen := map[string]int{}
|
||
for _, c := range r.calledSnapshot() {
|
||
seen[c]++
|
||
}
|
||
if len(seen) != n {
|
||
t.Errorf("不同工具数 = %d,期望 %d", len(seen), n)
|
||
}
|
||
// ② 槽内容按**声明序**正确(每个槽拿到自己的结果)
|
||
for i := 0; i < n; i++ {
|
||
name := fmt.Sprintf("t%03d", i)
|
||
want := "R" + name
|
||
if got, _ := res.Slots[name].(string); got != want {
|
||
t.Errorf("槽 %s = %v,期望 %q(合并错位?)", name, got, want)
|
||
break
|
||
}
|
||
}
|
||
// ③ 确认完成顺序**确实**被打乱(否则本用例测不到并发)
|
||
var outOfOrder bool
|
||
for i := 1; i < len(r.calledSnapshot()); i++ {
|
||
if r.calledSnapshot()[i] < r.calledSnapshot()[i-1] {
|
||
outOfOrder = true
|
||
break
|
||
}
|
||
}
|
||
if !outOfOrder {
|
||
t.Log("⚠️ 完成顺序未被打乱,本用例未能验证并发下的顺序合并")
|
||
}
|
||
t.Logf("%d 工具并发:耗时 %v,完成顺序乱序=%v", n, elapsed, outOfOrder)
|
||
}
|
||
|
||
// TestStress_ParallelNotSafeFallsBackToSerial 未声明并发安全时整批串行。
|
||
//
|
||
// 这是"不做部分并发"这条规则的压力检验:100 个工具里**一个**不安全,
|
||
// 整批就必须退回串行。
|
||
func TestStress_ParallelNotSafeFallsBackToSerial(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("stress test; run with -run TestStress")
|
||
}
|
||
const n = 50
|
||
r := newE2ERunner()
|
||
out := map[string]string{}
|
||
for i := 0; i < n; i++ {
|
||
name := fmt.Sprintf("s%03d", i)
|
||
// 只有最后一个声明并发安全
|
||
r.defs[name] = toolDefInfo{Name: name, ParallelSafe: i == n-1}
|
||
r.results[name] = "R"
|
||
r.delay[name] = 2
|
||
out[name] = "string"
|
||
}
|
||
seq, err := Parse(mustMarshal(map[string]interface{}{
|
||
"name": "serial",
|
||
"groups": []interface{}{map[string]interface{}{
|
||
"name": "g", "in": map[string]string{}, "out": out,
|
||
"parallel": true,
|
||
"tools": toolsStr(n, "s%03d", "R"),
|
||
}},
|
||
}))
|
||
if err != nil {
|
||
t.Fatalf("构造失败: %v", err)
|
||
}
|
||
|
||
start := time.Now()
|
||
if _, err := execGroup(seq.Groups[0], map[string]interface{}{}, r); err != nil {
|
||
t.Fatalf("execGroup 失败: %v", err)
|
||
}
|
||
elapsed := time.Since(start)
|
||
|
||
// 全部仍要执行
|
||
if len(r.calledSnapshot()) != n {
|
||
t.Errorf("调用次数 = %d,期望 %d", len(r.called), n)
|
||
}
|
||
// 完成顺序应严格等于声明序(串行的特征)
|
||
for i, c := range r.calledSnapshot() {
|
||
want := fmt.Sprintf("s%03d", i)
|
||
if c != want {
|
||
t.Errorf("第 %d 个是 %q,期望 %q —— 含非并发安全工具时整批应串行", i, c, want)
|
||
break
|
||
}
|
||
}
|
||
t.Logf("%d 工具(1 个不安全)→ 整批串行,耗时 %v", n, elapsed)
|
||
}
|
||
|
||
// TestStress_DeepSeqCall 序列内多组链式按名调用(深度受 maxCallDepth 约束)。
|
||
func TestStress_DeepSeqCall(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("stress test; run with -run TestStress")
|
||
}
|
||
// 一条含 30 个组的序列,每组调用**同一条**序列的另一个组(非递归)
|
||
r := newE2ERunner()
|
||
r.defs["leaf"] = toolDefInfo{Name: "leaf", ParallelSafe: true}
|
||
r.results["leaf"] = "LEAF"
|
||
|
||
groups := make([]interface{}, 0, 30)
|
||
for i := 0; i < 30; i++ {
|
||
groups = append(groups, map[string]interface{}{
|
||
"name": fmt.Sprintf("g%02d", i),
|
||
"in": map[string]string{},
|
||
"out": map[string]string{"o": "string"},
|
||
"tools": `{"tool":"leaf","args":{},"as":"o"} ;`,
|
||
})
|
||
}
|
||
// ⚠️ 建与跑必须用**同一个** plugin 实例:序列存到实例的 store 里,
|
||
// 换实例就读不到自己刚建的序列(我第一版就是这么写的,属逻辑错误)。
|
||
p := newE2EPlugin(t, r)
|
||
if _, err := p.dispatch("seq_create", map[string]interface{}{
|
||
"name": "chain", "groups": groups,
|
||
}); err != nil {
|
||
t.Fatalf("seq_create: %v", err)
|
||
}
|
||
start := time.Now()
|
||
out, err := p.dispatch("seq_run", map[string]interface{}{"name": "chain"})
|
||
elapsed := time.Since(start)
|
||
if err != nil {
|
||
t.Fatalf("seq_run: %v", err)
|
||
}
|
||
if len(r.called) != 30 {
|
||
t.Errorf("调用次数 = %d,期望 30", len(r.called))
|
||
}
|
||
t.Logf("30 组串行执行,耗时 %v", elapsed)
|
||
_ = out
|
||
}
|
||
|
||
// mustMarshal 构造 fixture 用。
|
||
func mustMarshal(v interface{}) []byte {
|
||
b, err := json.Marshal(v)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
return b
|
||
}
|
||
|
||
// toolsStr 生成 n 个工具的 tools 字符串。
|
||
//
|
||
// ⚠️ 槽名**直接等于工具名**(as 与 out 声明必须一致,否则静态校验会拦下——
|
||
// 这条规则本身是对的,压测不该去撞它)。
|
||
func toolsStr(n int, nameFmt, _ string) string {
|
||
parts := make([]string, 0, n)
|
||
for i := 0; i < n; i++ {
|
||
name := fmt.Sprintf(nameFmt, i)
|
||
parts = append(parts, fmt.Sprintf(`{"tool":%q,"args":{},"as":%q}`, name, name))
|
||
}
|
||
return strings.Join(parts, " ; ") + " ;"
|
||
}
|