mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
## 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。
335 lines
11 KiB
Go
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())
|
|
}
|
|
}
|