mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 20:27:23 +00:00
feat(plugins): 分片调用 —— 让阻塞型导出函数真正并行
## 问题
quota 页面 20 个源,刷新要 29 秒。页面用 `Promise.all` 并发发 20 个请求,
**看起来是并发的,实际不是**。
`Plugins.Call` 持 `p.mu.Lock()`,锁一直持有到 `L.Call` 返回 —— **包含 Lua 函数
内部的全部网络等待**。20 个请求到了网关后排队,串行累加。
生产实测:
单源探测(无竞争) 1.4 – 3.3 s
20 源 Promise.all 29 s 墙钟
串行理论值 ≈ 29 s ← 与实测吻合
代码里其实已经知道这个问题:quota.lua 第 104 行写着「the whole page shares ONE
Lua state under a write lock」,并为此把超时从 10s 压到 4s。但那只把 29s 变成
约 12s,没有解决「刷新不可用」。
而当初改成并发的理由(页面注释)是错的 —— 它假设并发能绕开串行。锁让它退化
回串行了。
## 不能去掉锁
一个 Lua VM 只有一个栈。两个并发调用会破坏栈。锁是必要的,答案只能是**让每个
并发调用有自己的 VM,跑同一份插件源码**。
先量,再写实现(8 分片 × 300ms 本地 HTTP 端点):
共享 VM,8 次串行 2.407 s
8 个独立 VM 并行 0.304 s → 7.9x
顺带一个测量教训:这个 spike 第一版用**忙等**而不是网络 I/O,报出 2.3x。
那是量到了 12 核机器上的 CPU 争抢(负载 8.95),和锁无关。插件做的是网络等待,
spike 就必须做网络等待 —— 和上一条提交里 os.clock 那个错误是同一类。
## 实现
- `callOn(w, fn, args)`:从 `Call` 里抽出的「在某 VM 上调一个导出函数」。`Call`
从 66 行缩到 18 行,仍负责查找、export 白名单和共享 VM 的锁。
- `shardPool`:私有 VM 池,上限 32。开机约 0.1ms,池化省不了什么但省两次刷新
的重复开销。
- `ShardCall(name, fn, argsList)`:结果与输入**按位置对齐**。
- `POST /api/plugins/{name}/fanout/{fn}`:`{"args": [...]}`,上限 64 条。
- `pluginAPI.fanout()`:页面一次请求。
- quota.lua 改用 fanout。
## 分片的硬约束(写进注释,不是靠约定)
分片 VM **不能碰 PLUGIN.state** —— state 在 VM 里,每个分片一份副本,billing
的累加会各分叉,saver 写出去的是随机某一份。分片只能用于只读、幂等的工作。
所以 `callOn` 既不查 export 白名单(`Call` 那层才是安全边界)也不落盘。
## 一个分片失败不拖垮整批
一个上游连不上,不该让 20 张卡片变成一个错误。失败的分片在自己的位置留
null,其余照常返回。**但全部分片失败时报错** —— 那不是「某个上游挂了」,
是结构性问题,报成功会把它藏在满屏红卡片后面。
## 顺手修掉一个既有缺陷:热重载是坏的
写分片源码来源时,判据直接把它顶了出来:`LoadSource` 无条件 append,而
`findLocked` 返回**第一个**匹配。实测:
LoadDir 后 1 条 → LoadSource 后 2 条
[0] version=1 [1] version=2
find 返回 version=1
也就是说,**编辑插件并保存看起来什么都没发生** —— 旧版本一直被返回,新版本
躺在旁边不生效。修法是先 Unload 再加载。这不是为分片做的,是分片正好照到了
这条路径,已单独补判据 `TestReloadReplacesRatherThanAppends`。
## 判据
七条:并行性(有界而非计时断言,8×200ms 串行是 1.6s,超过 1.0s 即红)、
位置对齐、未导出与未知插件被拒、单个失败不拖批、全失败报错、用已安装源码而
非磁盘、以及热重载替换而非追加。
全量 9 包通过。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
This commit is contained in:
@ -5781,6 +5781,38 @@ toast(t("kAutoChainGlobal"));
|
||||
if (!r.ok) throw new Error(body.error?.message || r.status);
|
||||
return body.result;
|
||||
},
|
||||
// fanout runs one of the plugin's exported functions once per argument
|
||||
// set, IN PARALLEL, from a single request.
|
||||
//
|
||||
// `call` in a Promise.all is NOT this. Twenty concurrent /call/ requests
|
||||
// arrive concurrently and then serialise on the plugin mutex inside the
|
||||
// kernel — the lock is held across the Lua function, network wait
|
||||
// included, because one Lua VM has one stack. Measured on a live
|
||||
// gateway: twenty quota probes took 29s of wall clock through /call/,
|
||||
// against 1.4–3.3s for a single uncontended probe. The browser-side
|
||||
// concurrency bought nothing and the page looked simply broken.
|
||||
//
|
||||
// The parallelism has to happen where the fan-out is received, so it
|
||||
// takes one request. Results come back positionally aligned with `argSets`;
|
||||
// a failed item is null in its slot rather than an error for the batch,
|
||||
// because one unreachable upstream must not blank a whole page of cards.
|
||||
//
|
||||
// Same authorization as call: admin-only, and the function must be in
|
||||
// the plugin's exports.
|
||||
async fanout(name, fn, argSets) {
|
||||
const r = await fetch(
|
||||
"/api/plugins/" + encodeURIComponent(name) + "/fanout/" + encodeURIComponent(fn),
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ args: argSets || [] }),
|
||||
},
|
||||
);
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(body.error?.message || r.status);
|
||||
return body.results || [];
|
||||
},
|
||||
onTabShown(fn) {
|
||||
PLUGIN_TAB_CBS.__last = PLUGIN_TAB_CBS.__last || [];
|
||||
PLUGIN_TAB_CBS.__last.push(fn);
|
||||
|
||||
Reference in New Issue
Block a user