Files
ModelRouter/internal/lua/plugin_shard.go
JianFeeeee 65075b6d44 fix(plugins): 分片 VM 拿不到插件配置
页面探测走的是 fanout(分片)路径,结果写回走共享 VM。分片 VM 从同一份
源码启动,但没有 state 文件、没有插件身份,所以 PLUGIN.prices(配置,不是
累计状态)在分片里是空的。

对只读累计状态的插件无所谓;对按配置分支的插件就是致命的。OpenDesign
适配器读 prices.control_key,未播种时它对每个主机都得出「未配置」并报
skip,而同一个调用走共享 VM 是好的。也就是说:生产上死、所有调共享 VM 的
测试全绿。

pool 在每次取用时推送配置(不只是首次启动时),并把配置变化也带给池中
复用的 VM;推送是拷贝,分片拿不到共享表的引用。

这条不是在写代码时想到的,是端到端验证时撞出来的:单元测试全过,隔离实例
打真上游时 vela 报「未配置」,而 control_key 明明写进去了。
2026-10-07 01:19:15 +08:00

217 lines
7.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
w, err := bootPluginState(code, name, sp.ps.outboundAllowlist())
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.