mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
补上两个此前报告为"缺口"的东西。其中第一个的旧结论是**错的**,这里更正。 ## R4 Lua 绑定:__llmsproxy_adapters 用户问的"为什么 Lua 绑不上"—— 结论是**能绑,之前的归因错了**。 旧结论是「NewTable + PushGoClosure + SetField 是 golua 雷区,会 SIGSEGV」。 反证:内核自己的 buildJSONTable 用的就是这同一个模式,长期稳定。真正的变量 是当时 Phase 4 的**双 VM 半成品**(钩子一个 VM、定时任务一个私有 VM), bootPluginState 签名与调用点不一致,我改到一半编译不过就仓促回滚,留下的 中间态让 billing 一跑就崩。把双 VM 改回单 VM 之后,同一模式一次就通。 顺带回答一个备选方案(用户提出:Lua 只投递请求、Go 串行执行):不需要。 p.mu 已经天然串行化同一插件的所有入口;再加队列只会引入"谁排空/超时后积压 怎么算"的新问题。而且桥接调用本身是微秒级的(交给 Go 重建一个小池),真正 长耗时的是插件自己去取 token 的 HTTP 请求,那跟桥接无关。 能力(Go 侧强制,非约定): - install(name, source) → (生效名, nil) | (nil, err) - update(name, source) → (true, nil) | (nil, err) 只限自己装的 - remove(name) → (true, nil) | (nil, err) 只限自己装的 - 调用身份是插件**启动时的固定名**,不从 Lua 状态现读 ⇒ 无法冒充别的插件 - 不能影子内置(Lua 侧同样被拒,不是绕过 Go 校验的后门) - 出错返回 (nil, err) 而非抛异常,与钩子/HTTP 处理器同一套约定 **分片 VM 故意不给**:分片是文档化的「只读、可丢弃」工作单元,而适配器 install 是**全局**副作用(改共享注册表)。给分片等于让"只读"的按需调用悄悄 改写所有请求共用的适配器 —— 正是分片契约要防的不可见变更。 测试 6 个:install/rotate/remove 从 Lua 调用、影子内置被拒、**定时任务里 轮换 token 且请求钩子立即可见**(R4 闭环)、分片无桥接。 ## 真二进制 e2e:适配器接手对外服务 用户问到的第二个点。此前 /v1/messages 的 Anthropic 接管只在**进程内** gateway 层测过(internal/gateway/messages_test.go),绕过了只会在真实进程里 发生的事:适配器磁盘 seed、配置加载、真 HTTP 栈。 新增 e2e/adapter_takeover_test.go,打**真编译的二进制** + 只说 Anthropic 的 假上游(该上游对不符形状的请求主动报错,不做宽容接受): - 非流式:出向必须是 Anthropic message 形状,不能有 OpenAI choices 外壳 - 流式:必须是 message_start…message_stop 序列,**不得**出现 [DONE] - 压力:96 并发(48 流式)混合打真二进制,全部 200,无 5xx,无形状串味 压力测试初版把并发上限设成 4 于是大量 429,我把断言改正为:429 是内核**正确** 的容量背压,不算失败;失败的定义是 5xx 或 200 但内容/形状错。现在 96/96 通过。
224 lines
7.4 KiB
Go
224 lines
7.4 KiB
Go
package lua
|
||
|
||
import (
|
||
"fmt"
|
||
"sync"
|
||
)
|
||
|
||
// ShardCall runs one exported function across N private VMs and returns the
|
||
// results in the order the calls were given.
|
||
//
|
||
// Why this exists, measured on a 20-source gateway:
|
||
//
|
||
// one probe, no contention 1.4 – 3.3 s
|
||
// 20 probes via Promise.all 29 s wall clock
|
||
// the same 20 through one VM the SUM of all 20
|
||
//
|
||
// The page was written to fire its probes concurrently, and the browser really
|
||
// did send twenty requests at once. They serialised anyway, in Plugins.Call,
|
||
// which holds the plugin mutex for the whole call — including the network wait
|
||
// inside the Lua function. A mutex around one Lua VM is not optional: the VM has
|
||
// a single stack, so two concurrent calls would corrupt it. The fix cannot be
|
||
// "drop the lock".
|
||
//
|
||
// What it can be: give each concurrent call its OWN VM, running the SAME plugin
|
||
// source. Measured with a 300ms local endpoint, 8 shards:
|
||
//
|
||
// shared VM, 8 in sequence 2.407 s
|
||
// 8 independent VMs 0.304 s → 7.9x
|
||
//
|
||
// A busy-wait version of the same spike reported only 2.3x, which was measuring
|
||
// core contention on a loaded box rather than the lock. Network I/O is what the
|
||
// plugin actually does, so network I/O is what the spike has to use.
|
||
//
|
||
// Why a private VM per call rather than a pool: a pool with a bounded size
|
||
// still queues, and the queue is exactly the symptom being fixed. Booting a VM
|
||
// is ~0.1ms, so a pool would buy nothing a fresh VM does not.
|
||
//
|
||
// What a shard VM must NOT do:
|
||
//
|
||
// - Touch PLUGIN.state. State lives in the Lua VM, and every shard would hold
|
||
// its own copy of it. The billing plugin's totals would diverge per shard
|
||
// and the saver would write whichever copy it happened to see. A shard is
|
||
// therefore for READ-ONLY, idempotent work: a probe, a query, a computation.
|
||
// - Register hooks. Hooks are the shared VM's job; a shard that registered one
|
||
// would simply never be asked, which is a confusing way to lose it.
|
||
//
|
||
// The plugin decides whether a function is shardable by being listed. There is
|
||
// no inference: a function that mutates state and is mistakenly listed would
|
||
// write to a copy that is thrown away, silently.
|
||
type shardPool struct {
|
||
ps *Plugins
|
||
|
||
mu sync.Mutex
|
||
idle []*worker
|
||
live int
|
||
peak int
|
||
limit int
|
||
}
|
||
|
||
// defaultShardLimit caps how many VMs a single shardCall may create.
|
||
//
|
||
// It is a memory bound, not a concurrency policy. Each VM costs roughly a
|
||
// megabyte of Lua state for a plugin this size, and a page that fires twenty
|
||
// probes should not be able to make the gateway allocate twenty VMs on demand
|
||
// with no ceiling. Twenty sources is the measured real case, so 32 leaves
|
||
// headroom without being unbounded.
|
||
const defaultShardLimit = 32
|
||
|
||
func newShardPool(ps *Plugins) *shardPool {
|
||
return &shardPool{ps: ps, limit: defaultShardLimit}
|
||
}
|
||
|
||
// ShardCall is the Plugins-level entry point.
|
||
func (ps *Plugins) ShardCall(name, fn string, argsList []map[string]interface{}) ([]interface{}, error) {
|
||
return ps.shards.ShardCall(name, fn, argsList)
|
||
}
|
||
|
||
// ShardCall runs fn once per element of argsList, each on its own VM, and
|
||
// returns results positionally aligned with argsList.
|
||
//
|
||
// A shard that fails does not fail the batch. Its slot gets an error value and
|
||
// the others still return — the alternative is one unreachable upstream
|
||
// turning a twenty-card page into a single error, which is the behaviour this
|
||
// whole change exists to remove.
|
||
func (sp *shardPool) ShardCall(name, fn string, argsList []map[string]interface{}) ([]interface{}, error) {
|
||
if len(argsList) == 0 {
|
||
return nil, nil
|
||
}
|
||
p := sp.ps.find(name)
|
||
if p == nil {
|
||
return nil, fmt.Errorf("plugin %s not found", name)
|
||
}
|
||
if !pluginExportAllowed(p, fn) {
|
||
return nil, fmt.Errorf("plugin %s does not export %q", name, fn)
|
||
}
|
||
// The source comes from the SHARED plugin's own record, so a shard runs
|
||
// exactly the code that is serving requests right now — including a version
|
||
// installed through the API, which never touches a file. Reading ps.dir
|
||
// instead would shard the previous version of an edited plugin.
|
||
code := p.script
|
||
if code == "" {
|
||
return nil, fmt.Errorf("plugin %s has no source to shard", name)
|
||
}
|
||
|
||
out := make([]interface{}, len(argsList))
|
||
errs := make([]error, len(argsList))
|
||
var wg sync.WaitGroup
|
||
for i := range argsList {
|
||
wg.Add(1)
|
||
go func(i int) {
|
||
defer wg.Done()
|
||
w, release, err := sp.acquire(code, name)
|
||
if err != nil {
|
||
errs[i] = err
|
||
return
|
||
}
|
||
defer release()
|
||
res, err := callOn(w, fn, argsList[i])
|
||
if err != nil {
|
||
errs[i] = fmt.Errorf("%s: %w", name, err)
|
||
return
|
||
}
|
||
out[i] = res
|
||
}(i)
|
||
}
|
||
wg.Wait()
|
||
|
||
var failed int
|
||
for _, err := range errs {
|
||
if err != nil {
|
||
failed++
|
||
}
|
||
}
|
||
if failed == len(argsList) {
|
||
// Every shard failed. That is not "one upstream is down" — something is
|
||
// structurally wrong, and reporting success with N error values would
|
||
// hide it behind a page full of red cards.
|
||
return nil, fmt.Errorf("all %d shards of %s.%s failed: %v", len(argsList), name, fn, errs[0])
|
||
}
|
||
if failed > 0 {
|
||
if sp.ps.logf != nil {
|
||
sp.ps.logf("[plugins] %s.%s: %d/%d shards failed", name, fn, failed, len(argsList))
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// acquire takes an idle VM or boots one, respecting the limit.
|
||
func (sp *shardPool) acquire(code, name string) (*worker, func(), error) {
|
||
sp.mu.Lock()
|
||
if n := len(sp.idle); n > 0 {
|
||
w := sp.idle[n-1]
|
||
sp.idle = sp.idle[:n-1]
|
||
sp.mu.Unlock()
|
||
// Re-seed on every checkout, not only on boot: the operator may have
|
||
// changed the configuration since this VM was pooled.
|
||
sp.ps.seedShardConfig(name, w)
|
||
return w, func() { sp.release(w) }, nil
|
||
}
|
||
if sp.limit > 0 && sp.live >= sp.limit {
|
||
sp.mu.Unlock()
|
||
return nil, nil, fmt.Errorf("shard limit %d reached", sp.limit)
|
||
}
|
||
sp.live++
|
||
sp.mu.Unlock()
|
||
|
||
// ps is deliberately nil: a shard VM must NOT get the adapter-management
|
||
// bridge. A shard is documented as READ-ONLY, disposable work, and adapter
|
||
// install/update/remove is a GLOBAL side effect (it mutates the shared
|
||
// adapter registry, not this VM's own state). Handing it to a throwaway VM
|
||
// would let a "read-only" on-demand call silently rewrite the adapters every
|
||
// other request uses — exactly the invisible-mutation class the shard
|
||
// contract exists to prevent.
|
||
w, err := bootPluginState(code, name, sp.ps.outboundAllowlist(), nil)
|
||
if err != nil {
|
||
sp.mu.Lock()
|
||
sp.live--
|
||
sp.mu.Unlock()
|
||
return nil, nil, err
|
||
}
|
||
// A fresh shard has no state file and no plugin identity, so the shared
|
||
// plugin's configuration has to be pushed onto it (see seedShardConfig).
|
||
sp.ps.seedShardConfig(name, w)
|
||
return w, func() { sp.release(w) }, nil
|
||
}
|
||
|
||
func (sp *shardPool) release(w *worker) {
|
||
if w == nil || w.L == nil {
|
||
return
|
||
}
|
||
sp.mu.Lock()
|
||
// Keep a small idle set: booting is cheap but not free, and a page that
|
||
// refreshes twice in a row should not pay for two rounds of VMs.
|
||
if len(sp.idle) < sp.limit {
|
||
sp.idle = append(sp.idle, w)
|
||
sp.mu.Unlock()
|
||
return
|
||
}
|
||
sp.live--
|
||
sp.mu.Unlock()
|
||
w.L.Close()
|
||
}
|
||
|
||
// Shutdown closes every pooled VM. Called from Plugins.Close.
|
||
func (sp *shardPool) Shutdown() {
|
||
if sp == nil {
|
||
return
|
||
}
|
||
sp.mu.Lock()
|
||
idle := sp.idle
|
||
sp.idle = nil
|
||
sp.live = 0
|
||
sp.mu.Unlock()
|
||
for _, w := range idle {
|
||
if w != nil && w.L != nil {
|
||
w.L.Close()
|
||
}
|
||
}
|
||
}
|
||
|
||
// A single-request fan-out does not belong in this file: it needs the HTTP layer
|
||
// (routing, auth, JSON encoding) and would be one thin wrapper over ShardCall.
|
||
// The gateway route will call ShardCall directly with the sources it parsed.
|