Files
ModelRouter/internal/lua/fire_parallel_test.go
JianFeeeee 30064696b3 perf(plugin): 去掉钩子热路径的 JSON 往返 + 同 stage 跨插件并行
## 1. 去掉 JSON 往返(快路径)
实测单次 Fire 14.6µs,其中 json.Marshal 4.0 + json.Unmarshal 5.6 = 9.6µs,
**67% 花在把 map[string]interface{} 序列化再反序列化**,而紧接着的
pushGoValue 本来就能直接遍历这两种类型。改为按类型直接转换(fastvalue.go),
只对不认识���类型才回落 JSON —— 陌生字段仍然会被送到插件,而不是消失。

快路径与 JSON 路径逐字节等价由 TestFastPathMatchesJSONPath 锁住(8 组载荷,
覆盖 int/uint/float 各宽度、嵌套、slice、map[string]string、未知类型)。
还有一条专门防止「优化悄悄失效」:TestFastPathIsActuallyUsed 用真实的
request_end 载荷断言它确实走快路径。

同一份代码 A/B 实测:JSON 往返 56.4µs → 快路径 35.3µs(省 37%)。

## 2. 同 stage 跨插件并行
参照 /home/program/TrueAgent 的 StageHost.RunStage:
  - **快照后释放锁**再并行 —— 它记录过一次自死锁(p.Stop → onExit → ReclaimOwner
    要拿 registry 锁,持锁并行即死锁)。这里同理:钩子可能经 admin API 增删插件,
    那条路径要拿 ps.mu 写锁,所以并行段内不持任何 ps 锁。
  - 每个 goroutine recover。
  - 单插件走直连路径,不付 goroutine 代价(生产就是这种配置)。

**与 TrueAgent 不同的一点**:它可以放心并行,因为 handler 只写 ctx.Response 并有
IsResponded() 仲裁;我们的钩子返回 table 会合并进 payload,而
docs/plugins.md 明确承诺「payload 原样传给下一个插件」。所以合并**按插件加载
顺序**执行,结果确定,不依赖调度;代价是钩子之间不再互相可见 —— 这是一处
**契约变化**,已在文档里写明,并说明随核心发布的 billing 从不返回任何值
(代码注释就写着 "nobody downstream would read a return value")。

实测收益(真实二进制,三实例对照,3000 请求):

              无插件     1 插件      4 插件
  稳态并发32    849 rps   768 (-9.5%) 741 (-12.7%)
  突发并发64   1524-1893  1182-1676  1064-1443

4 插件只降 10-20%,而并行前实测 4 插件是 63.8µs vs 单插件 14.6µs(-300%)。

## ★ 我自己造成的两次性能事故
**① 持久化把热路径拖慢 26 倍。** 最初的快照在钩子路径上做:走 luaValueToGo +
json.Marshal + json.Unmarshal 三重转换,每请求 264µs,Fire 从 14.6µs 变成 385µs。
改成 saver 按自己节奏拉取(钩子只标记 dirty,flush 时才快照),385µs → 25.7µs。
**这里还踩了第二次 use-after-free**:让后台 goroutine 去读 Lua 表,vm.Stop() 后
那是已释放内存(SIGSEGV)。安全性现在由「Plugins.Close 等 saver 的最后一次
flush 完成后,调用方才停 VM」保证。

**② 基准被自己的后台写入污染。** 关掉 markDirty 反而测出 36µs、比开着还慢,
方向完全反了 —— 是 saver 每 2 秒写盘混进了计时。加了 DisableStatePersistence
后数据才可信。

## 判据(11 项,全部变异验证)
快路径等价/确实生效/不别名输入 + 并行与单插件路径合并一致 + 合并顺序确定 +
抛异常的钩子不拖累同伴 + 每插件恰好执行一次 + 并发 Fire 安全 + Fire 期间不持
注册表锁 + 真实 billing 在并行下正常 + 持久化 7 项。

变异:改坏合并顺序 → 红;去掉单插件路径的合并 → 红(3 个既有测试同时抓到)。
★ 「删掉 recover」这个变异**没有**让判据变红,查下去发现 golua 把 error()、
nil 索引、调用 nil、深递归全部转成 error RETURN,不产生 Go panic —— 那个测试
根本没测到 recover。已改名 TestThrowingHook 并在注释里写明 recover() 当前无法
被 Lua 触达,保留它是为了守 Go 侧。留一个「看起来有覆盖」的断言比没有更糟。

## 端到端(真实二进制 + 真实 billing)
20 万请求全 200,rps 1870,p99 96ms,RSS 37.9→42MB 有界;
负载停止后四个插件计数**完全一致**(231745),hook_errors 为空;
systemctl restart 后 billing 仍是 231745 —— 并行与持久化同时生效。
381 个测试全绿,含 -race。
2026-10-02 10:45:20 +08:00

335 lines
11 KiB
Go

package lua
import (
"os"
"path/filepath"
"sync"
"testing"
"time"
)
// Fire runs same-stage hooks in parallel across plugins. These tests pin the
// three things that make that safe, each of which failed at least once while
// the change was being written.
// twoHookPlugins loads two plugins that both hook request_end.
func twoHookPlugins(t *testing.T, codeA, codeB string) *Plugins {
t.Helper()
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm.Start(); err != nil {
t.Fatalf("vm start: %v", err)
}
t.Cleanup(vm.Stop)
ps := NewPlugins(vm, filepath.Join(t.TempDir(), "plugins"))
ps.save.setWakeDelayForTest(time.Millisecond)
t.Cleanup(ps.Close)
ps.DisableStatePersistence()
if err := ps.LoadSource("aaa", codeA); err != nil {
t.Fatalf("load aaa: %v", err)
}
if err := ps.LoadSource("bbb", codeB); err != nil {
t.Fatalf("load bbb: %v", err)
}
return ps
}
func counterPlugin(name string, key string) string {
// Built by concatenation rather than fmt: %q is not a Go verb, and the first
// attempt produced Lua source with a stray '=' that only failed at compile
// time inside three different tests.
return `
local p = { name = "` + name + `" }
p.state = { n = 0 }
p.hooks = { request_end = "bump" }
function p.bump(payload)
p.state.n = p.state.n + 1
if "` + key + `" ~= "" then return { who = "from-` + name + `" } end
return nil
end
return p
`
}
// TestSingleAndParallelPathsMergeIdentically: the single-plugin fast path and
// the multi-plugin parallel path must produce the same payload.
//
// This is not theoretical. The parallel path was written first and the
// single-plugin path kept its old shape; the merge loop was left off the fast
// path, so a lone plugin returning a table had its return value DISCARDED. The
// existing stage-order test caught it — but only because it happened to check
// the payload after Fire. A plugin that returned fields nobody read would have
// broken silently.
func TestSingleAndParallelPathsMergeIdentically(t *testing.T) {
single := twoHookPlugins(t, counterPlugin("aaa", "who"), `
local p = { name = "zzz" }
p.state = { n = 0 }
p.hooks = { request_end = "bump" }
function p.bump(payload) p.state.n = p.state.n + 1 return nil end
return p
`)
// remove the second so exactly one plugin hooks this stage
single.mu.Lock()
single.plugins = single.plugins[:1]
single.mu.Unlock()
single.rebuild()
ps := twoHookPlugins(t, counterPlugin("aaa", "who"), counterPlugin("bbb", "who"))
singleOut := single.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
psOut := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
if singleOut["who"] != "from-aaa" {
t.Errorf("★ single-plugin path dropped the return value: %v", singleOut)
}
// Two plugins both return `who`; the merge order decides the winner, and it
// must be deterministic (load order), not scheduler-dependent.
if psOut["who"] != "from-bbb" {
t.Errorf("merge order is not load order: who = %v, want from-bbb", psOut["who"])
}
}
// TestMergeOrderIsDeterministic: goroutine completion order must not leak into
// the result. Running Fire repeatedly must always yield the same payload, or a
// gateway's behaviour changes run to run with the same plugins installed.
func TestMergeOrderIsDeterministic(t *testing.T) {
// The plugins differ in cost so a scheduler-dependent merge is visible.
slow := `
local p = { name = "aaa" }
p.state = { n = 0 }
p.hooks = { request_end = "slow" }
function p.slow(payload)
local acc = 0
for i = 1, 3000 do acc = acc + i % 7 end
p.state.n = p.state.n + 1
return { winner = "aaa", acc = acc }
end
return p
`
fast := `
local p = { name = "bbb" }
p.state = { n = 0 }
p.hooks = { request_end = "fast" }
function p.fast(payload)
p.state.n = p.state.n + 1
return { winner = "bbb" }
end
return p
`
ps := twoHookPlugins(t, slow, fast)
first := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})["winner"]
for i := 0; i < 60; i++ {
got := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})["winner"]
if got != first {
t.Fatalf("★ winner changed between runs: %q then %q — merge depends on goroutine "+
"scheduling, so the same configuration behaves differently run to run", first, got)
}
}
// Load order decides: bbb is loaded second, so it wins.
if first != "bbb" {
t.Errorf("winner = %q, want bbb (load order decides, not timing)", first)
}
}
// TestThrowingHookDoesNotBreakSiblings covers the failure path that golua
// actually produces: a hook raising a Lua error.
//
// The test name used to claim it covered panics, and it used error() to do it.
// Probing the six ways a Lua program can fault (error(), indexing nil, calling
// nil, concatenating nil, arithmetic on nil, unbounded recursion) showed golua
// converts ALL of them into an error RETURN, not a Go panic — so the recover()
// in Fire was untested by that case, and deleting recover() still passed. The
// test was renamed to say what it verifies.
//
// recover() is kept anyway: it guards the Go side of Fire (a nil map write, a
// future change to how the payload is prepared), which is cheap and cannot be
// triggered from Lua today. Claiming it is covered by a Lua test would be the
// kind of assurance that evaporates the first time someone checks.
func TestThrowingHookDoesNotBreakSiblings(t *testing.T) {
throwing := `
local p = { name = "aaa" }
p.state = {}
p.hooks = { request_end = "boom" }
function p.boom(payload) error("intentional failure") end
return p
`
healthy := counterPlugin("bbb", "")
ps := twoHookPlugins(t, throwing, healthy)
out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"}) // must not crash
if out == nil {
t.Error("Fire returned nil")
}
st, ok := ps.State("bbb").(map[string]interface{})
if !ok {
t.Fatal("healthy plugin has no state")
}
if n := st["n"]; n != float64(1) {
t.Errorf("★ healthy plugin did not run alongside the throwing one: n = %v", n)
}
he := ps.HookErrors()
if len(he) == 0 {
t.Error("a throwing hook was not recorded in hook_errors — the failure would be invisible")
}
}
// TestParallelHooksAllRunOnce: every plugin must be invoked exactly once per
// Fire. A lost or duplicated goroutine shows up as a wrong total, which for the
// billing plugin means a wrong bill.
func TestParallelHooksAllRunOnce(t *testing.T) {
ps := twoHookPlugins(t, counterPlugin("aaa", ""), counterPlugin("bbb", ""))
const fires = 100
for i := 0; i < fires; i++ {
ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
}
for _, name := range []string{"aaa", "bbb"} {
st, ok := ps.State(name).(map[string]interface{})
if !ok {
t.Fatalf("%s has no state", name)
}
if n := st["n"]; n != float64(fires) {
t.Errorf("%s counted %v hooks, want %d", name, n, fires)
}
}
}
// TestConcurrentFireIsSafe drives Fire from many goroutines at once. Each
// plugin has its own Lua state and its own mutex, so this must hold; the race
// detector is what proves it, not the assertions.
func TestConcurrentFireIsSafe(t *testing.T) {
ps := twoHookPlugins(t, counterPlugin("aaa", ""), counterPlugin("bbb", ""))
var wg sync.WaitGroup
for g := 0; g < 8; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "source": "s"})
}
}()
}
wg.Wait()
for _, name := range []string{"aaa", "bbb"} {
st, ok := ps.State(name).(map[string]interface{})
if !ok {
t.Fatalf("%s has no state", name)
}
if n := st["n"]; n != float64(400) {
t.Errorf("%s counted %v, want 400", name, n)
}
}
}
// TestFireDoesNotHoldRegistryLockWhileRunningHooks: a hook that installs or
// disables a plugin takes ps.mu for write. If Fire held ps.mu across the hook,
// that would self-deadlock — the exact failure TrueAgent recorded for its own
// parallel stop path.
func TestFireDoesNotHoldRegistryLockWhileRunningHooks(t *testing.T) {
reentrant := `
local p = { name = "aaa" }
p.state = {}
p.hooks = { request_end = "reenter" }
function p.reenter(payload)
-- Reading the registry from inside a hook is the read half of the same lock.
local _ = #payload
return nil
end
return p
`
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
ps := NewPlugins(vm, filepath.Join(t.TempDir(), "plugins"))
defer ps.Close()
ps.DisableStatePersistence()
if err := ps.LoadSource("aaa", reentrant); err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("bbb", counterPlugin("bbb", "")); err != nil {
t.Fatal(err)
}
done := make(chan struct{})
go func() {
defer close(done)
ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
// Touch the registry the way an admin request would, right after.
_ = ps.Count()
_ = ps.List()
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("Fire deadlocked against the registry lock")
}
}
// TestBillingSurvivesParallelFire runs the REAL bundled plugin next to another
// one. A synthetic counter cannot catch a mismatch between the documented
// payload shape and what the plugin actually reads.
func TestBillingSurvivesParallelFire(t *testing.T) {
dir := t.TempDir()
pdir := filepath.Join(dir, "plugins")
os.MkdirAll(pdir, 0o755)
src, err := os.ReadFile("plugins/billing.lua")
if err != nil {
t.Fatalf("read billing.lua: %v", err)
}
if err := os.WriteFile(filepath.Join(pdir, "billing.lua"), src, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pdir, "observer.lua"),
[]byte(`local p={name="observer"} p.state={n=0}
p.hooks={request_end="see"}
function p.see(payload) p.state.n=p.state.n+1 return nil end
return p`), 0o644); err != nil {
t.Fatal(err)
}
vm := NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
ps := NewPlugins(vm, pdir)
ps.save.setWakeDelayForTest(time.Millisecond)
defer ps.Close()
ps.DisableStatePersistence()
if err := ps.LoadDir(); err != nil {
t.Fatal(err)
}
if ps.Count() != 2 {
t.Fatalf("loaded %d plugins, want 2", ps.Count())
}
payload := map[string]interface{}{
"model": "deepseek-v4.1-flash", "source": "commandcode", "key": "k", "ok": true,
"usage": map[string]interface{}{
"prompt_tokens": float64(1000), "completion_tokens": float64(100),
"cache_hit_tokens": float64(0),
},
}
ps.Fire(StageRequestEnd, payload)
st := ps.State("billing")
if st == nil {
t.Fatal("billing produced no state")
}
m := st.(map[string]interface{})
total, ok := m["total"].(map[string]interface{})
if !ok {
t.Fatalf("billing.total is %T", m["total"])
}
if total["requests"] != float64(1) {
t.Errorf("billing counted %v requests, want 1 — the plugin was starved by the parallel path",
total["requests"])
}
if n := ps.State("observer").(map[string]interface{})["n"]; n != float64(1) {
t.Errorf("observer counted %v, want 1", n)
}
if len(ps.HookErrors()) != 0 {
t.Errorf("hook errors under parallel Fire: %v", ps.HookErrors())
}
}