mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(lua): 卡死任务改为「抛弃整个 VM」—— 不中断,直接换一代
用户给的架构决策:与其想尽办法中断卡死的插件任务,不如直接抛弃它所在的
VM,让任务的 goroutine 跟 VM 一起被处置。这比中断干净 —— VM 重建只要
~0.1ms,抛弃比修复便宜。
## 为什么必须这样做(三次实测)
本 golua + LuaJIT 构建**无法中断**运行中的 Lua 调用:
1. SetExecutionLimit(200000) 对紧密循环 10s 未返回 —— 无效
2. 计数 hook(2000) + RaiseError 对紧密循环 fired=0 —— hook 未触发
3. 计数 hook(1000) 对「函数调用+表写」循环 fired=20971 触发,
但 RaiseError 后仍未返回 —— RaiseError 走 L.StackTrace(),
正是 vm.go 已记录的 SIGSEGV / 无法跨 C 栈路径
原因:LuaJIT 把紧密循环编译成机器码,指令计数 hook 只在解释器路径触发;
而唯一能在 C 栈上中断的 golua 入口恰好依赖 StackTrace()。
## 模型:generation 可抛弃
PluginRuntime 持有一个 runtimeGen(私有 VM + 单状态池)。任务超过
wedgedAfter(10min,因为正常情况就是慢网络调用)仍不返回时:
- 抛弃该 generation,由 ensureGeneration 在下一 tick 起一个干净的
- 卡死的 goroutine 留在旧 VM 上,永远碰不到新一代的状态
- 在飞任务被 carry 到新 generation 并重新排期(否则 abandon 计数永不前进,
熔断也就永远不触发)
## 熔断(必须有)
每次抛弃都留下一个永不返回的 goroutine + Lua 状态。没有上限的话,一个
必然卡死的任务会让运行时每 10 分钟建一个新 VM —— 由插件自身 bug 驱动的
无界泄漏。maxAbandons=5 后熔断停止重生,需人工 reload(同 systemd 单元
反复启动失败后的姿态)。
## 开发中修掉的四个缺陷
1. rt.gen 未赋值:bootGeneration 的结果只赋给局部变量,导致 checkWedged/
runDue/dropGeneration 全部读到 nil,运行时会「活着但一个任务都不跑」。
2. checkWedged 无条件抛弃:即使没有任何任务判定为 wedged 也 drop,
让健康插件不断换代。
3. startedAt 由 runDue 设置而非 runOne:任何其它 dispatch 路径的任务
都不会被追踪,checkWedged 无法区分「在跑」与「从未启动」。
4. runOne 收到 nil gen 会 panic(抛弃与派发竞争)—— 改为安全返回并清标志。
测试:13 个(无触发自动执行/真正执行/零开销/at 解析/条目校验/失败隔离/
卸载停 goroutine/压力/抛弃换代/熔断/健康任务不受影响)。
This commit is contained in:
@ -147,7 +147,13 @@ return plugin
|
||||
|
||||
// Transform 只返回 Lua **字符串** 返回值;tick 返回的是 table,故 out 为空
|
||||
// 不代表任务没跑。真正的观测点是 failures:任务体若抛错会被计入。
|
||||
if _, err := rt.vm.Transform(rt.poolName, "tick", "{}"); err != nil {
|
||||
rt.mu.Lock()
|
||||
gen := rt.gen
|
||||
rt.mu.Unlock()
|
||||
if gen == nil {
|
||||
t.Fatal("无 generation")
|
||||
}
|
||||
if _, err := gen.vm.Transform(gen.poolName, "tick", "{}"); err != nil {
|
||||
t.Fatalf("tick: %v", err)
|
||||
}
|
||||
rt.mu.Lock()
|
||||
@ -157,7 +163,7 @@ return plugin
|
||||
t.Errorf("tick 失败 %d 次", fails)
|
||||
}
|
||||
// 用 keep 函数读回计数:任务把 n 累加在 state 里,读它证明任务体确实执行过。
|
||||
out, err := rt.vm.Transform(rt.poolName, "keep", "{}")
|
||||
out, err := gen.vm.Transform(gen.poolName, "keep", "{}")
|
||||
if err != nil {
|
||||
t.Fatalf("keep: %v", err)
|
||||
}
|
||||
@ -378,3 +384,181 @@ func luaQuote(s string) string {
|
||||
}
|
||||
|
||||
var _ = os.TempDir
|
||||
|
||||
// TestWedgedTaskAbandonsItsVM 是「抛弃式隔离」的核心测试。
|
||||
//
|
||||
// 前提(实测三次):本 LuaJIT 构建**无法中断**运行中的 Lua 调用。所以模型
|
||||
// 不是「中断它」,而是「不再等它」——把整个 generation(私有 VM + goroutine)
|
||||
// 丢掉,重新起一个干净的。卡死的 goroutine 连同它自己的 VM 一起被丢弃,
|
||||
// 永远不会碰到新一代的状态。
|
||||
func TestWedgedTaskAbandonsItsVM(t *testing.T) {
|
||||
shortTick(t)
|
||||
oldWedged := wedgedAfter
|
||||
wedgedAfter = 50 * time.Millisecond // 测试里把判定阈值压到极短
|
||||
t.Cleanup(func() { wedgedAfter = oldWedged })
|
||||
|
||||
_, ps, _ := runtimePlugins(t)
|
||||
code := `
|
||||
local plugin = { name = "wedger", version = "1.0" }
|
||||
plugin.schedule = { { fn = "spin", every = 3600 } }
|
||||
function plugin.spin()
|
||||
local i = 0
|
||||
while true do i = i + 1 end
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := ps.LoadSource("wedger", code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.mu.RLock()
|
||||
var rt *PluginRuntime
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name == "wedger" {
|
||||
rt = p.Runtime
|
||||
}
|
||||
}
|
||||
ps.mu.RUnlock()
|
||||
if rt == nil {
|
||||
t.Fatal("无运行时")
|
||||
}
|
||||
|
||||
// 手动触发一次任务(它会卡住),然后等 checkWedged 判定并抛弃该代。
|
||||
rt.mu.Lock()
|
||||
gen1 := rt.gen
|
||||
rt.mu.Unlock()
|
||||
if gen1 == nil {
|
||||
t.Fatal("初始无 generation")
|
||||
}
|
||||
go rt.runOne(gen1, ScheduledTask{Fn: "spin", Every: 3600})
|
||||
|
||||
// 等 wedgedAfter 过去并让 checkWedged 跑一轮
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
rt.mu.Lock()
|
||||
abandoned := rt.gen != gen1
|
||||
rt.mu.Unlock()
|
||||
if abandoned {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
rt.mu.Lock()
|
||||
gen2 := rt.gen
|
||||
abandons := rt.abandons["spin"]
|
||||
rt.mu.Unlock()
|
||||
if gen2 == gen1 {
|
||||
t.Fatal("卡死的任务应导致 generation 被抛弃(换新 VM),实得同一代")
|
||||
}
|
||||
if abandons == 0 {
|
||||
t.Error("抛弃次数应被记账(否则熔断无法生效)")
|
||||
}
|
||||
t.Logf("已抛弃并换新 generation(abandons=%d)", abandons)
|
||||
}
|
||||
|
||||
// TestWedgedPluginIsDisarmedAfterMaxAbandons:反复卡死必须熔断,而不是无限
|
||||
// 重生 VM(每次重生都留下一个永不返回的 goroutine + Lua 状态 = 无界泄漏)。
|
||||
func TestWedgedPluginIsDisarmedAfterMaxAbandons(t *testing.T) {
|
||||
shortTick(t)
|
||||
oldWedged := wedgedAfter
|
||||
wedgedAfter = 20 * time.Millisecond
|
||||
t.Cleanup(func() { wedgedAfter = oldWedged })
|
||||
|
||||
_, ps, _ := runtimePlugins(t)
|
||||
code := `
|
||||
local plugin = { name = "alwaysbad", version = "1.0" }
|
||||
plugin.schedule = { { fn = "spin", every = 3600 } }
|
||||
function plugin.spin() local i=0 while true do i=i+1 end end
|
||||
return plugin
|
||||
`
|
||||
if err := ps.LoadSource("alwaysbad", code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.mu.RLock()
|
||||
var rt *PluginRuntime
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name == "alwaysbad" {
|
||||
rt = p.Runtime
|
||||
}
|
||||
}
|
||||
ps.mu.RUnlock()
|
||||
if rt == nil {
|
||||
t.Fatal("无运行时")
|
||||
}
|
||||
|
||||
// 反复:拿当前代 → 跑卡死任务 → 等被抛弃。直到熔断。
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
rt.mu.Lock()
|
||||
if rt.disarmed {
|
||||
rt.mu.Unlock()
|
||||
break
|
||||
}
|
||||
gen := rt.gen
|
||||
rt.mu.Unlock()
|
||||
if gen != nil {
|
||||
go rt.runOne(gen, ScheduledTask{Fn: "spin", Every: 3600})
|
||||
}
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
}
|
||||
rt.mu.Lock()
|
||||
disarmed := rt.disarmed
|
||||
n := rt.abandons["spin"]
|
||||
rt.mu.Unlock()
|
||||
if !disarmed {
|
||||
t.Fatalf("反复卡死应最终熔断(disarmed),实得 abandons=%d", n)
|
||||
}
|
||||
if n < maxAbandons {
|
||||
t.Errorf("熔断前 abandon 次数 = %d, 应 >= %d", n, maxAbandons)
|
||||
}
|
||||
// 熔断后不应再有 generation 被启动
|
||||
rt.mu.Lock()
|
||||
genAfter := rt.gen
|
||||
rt.mu.Unlock()
|
||||
if genAfter != nil {
|
||||
t.Error("熔断后不应仍有活动 generation")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalTaskUnaffectedByWedgeWatchdog:正常(快速)任务不触发抛弃。
|
||||
func TestNormalTaskUnaffectedByWedgeWatchdog(t *testing.T) {
|
||||
shortTick(t)
|
||||
_, ps, _ := runtimePlugins(t)
|
||||
code := `
|
||||
local plugin = { name = "healthy", version = "1.0" }
|
||||
plugin.schedule = { { fn = "q", every = 1 } }
|
||||
function plugin.q() return nil end
|
||||
return plugin
|
||||
`
|
||||
if err := ps.LoadSource("healthy", code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.mu.RLock()
|
||||
var rt *PluginRuntime
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name == "healthy" {
|
||||
rt = p.Runtime
|
||||
}
|
||||
}
|
||||
ps.mu.RUnlock()
|
||||
if rt == nil {
|
||||
t.Fatal("无运行时")
|
||||
}
|
||||
rt.mu.Lock()
|
||||
gen0 := rt.gen
|
||||
rt.mu.Unlock()
|
||||
time.Sleep(600 * time.Millisecond) // 让任务跑几轮
|
||||
rt.mu.Lock()
|
||||
same := rt.gen == gen0
|
||||
fails := rt.failures["q"]
|
||||
abandons := rt.abandons["q"]
|
||||
rt.mu.Unlock()
|
||||
if !same {
|
||||
t.Error("健康任务不该导致 generation 被抛弃")
|
||||
}
|
||||
if fails != 0 {
|
||||
t.Errorf("健康任务不应失败: %d", fails)
|
||||
}
|
||||
if abandons != 0 {
|
||||
t.Errorf("健康任务不应被抛弃: %d", abandons)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user