fix(mem): 实例内存改为「基础 MIN → 最多 MAX」,与插件开关解耦(档案 96)

用户原话两段:①「改为 实例内存不要受插件开关影响,只受 min 和 max 值影响」
②「改成基础 min 最大可以浮动到 max」。

实现(cgroup 两个参数表达这个区间):
· src/supervisor/orchestrator.ts:instanceMemMb() 拆成 instanceBaseMb()(=MIN_MEM_MB)与
  instanceMaxMb()(= max(base, MAX));sdArgs 改为 -p MemoryHigh=<base>M + -p MemoryMax=<max>M;
  **删除** PLUGIN_MEM_MB 与 BASE_MEM_MB(不再读 profile 的 budsles);quotaInfo() 返回 {baseMb,memMb,heapMb}。
· src/supervisor/spawner.ts:quotaInfo?() 返回类型同步加 baseMb。
· poc/business-plugins/lib/client.js:quotaOf() 兜底值改为硬顶上界(进度条 100% 基准与「超限」判据同它);
  事实行改为「基础 448 MiB → 最多 1024 MiB · V8 堆 256」(新增 i18n mem.base);插件表保留但**仅用于预估**。
· scripts/verify-mem-model.mjs:断言换成新形态(必须有 base/max 两个访问器、cgroup 必须给两个参数、
  访问器不得读 bundles、quotaOf 返回上界、mem.base 存在)。
· 取值沿用用户裁定值:MIN 448(base)/ MAX 1024 —— **我没有自行改数**。

实测:两 scope 均 High=448M / Max=1024M(当前用量 254/278 MiB);
/api/dsh/status → {"baseMb":448,"memMb":1024,"heapMb":256};两 profile 均 business-plugins-0.3.19.tgz。
本机 npm run verify EXIT=0;服务器 ci.sh CI OK。

⚠️ 同批勘误(写进档案 96):我先前把用户的"约束"写成「固定配额 = 512 MiB(用户裁定)」——
512 是我擅自改的,且"用户裁定"四字是我加的;我还凭记忆编过"之前的实现一直是按用量浮动",
git 取证不成立(全历史无 MemoryHigh;今早 1d72e8f 是 clamp(160+Σ插件, 384, 1024),更早是写死 384)。

⚠️ 未提交:BRIEF.md / DEPLOY-本部署.md 的配额口径同步(那两个文件本就有别人未提交的改动)。
This commit is contained in:
admin committed 2026-09-14 22:26:06 +08:00
1 parent e18eaa2b64
commit 53b8eff870
5 files changed
+119 -114

No files matched your search

+51 -48
View File
@@ -1,14 +1,25 @@
#!/usr/bin/env node
/**
* verify-mem-model.mjs —— 内存模型「单一事实源」交叉校验(档案 84)
* verify-mem-model.mjs —— 内存模型「单一事实源」交叉校验(档案 84 → 档案 96 改版)
*
* 为什么需要:2026-09-13 实测发现「功能管理」那条内存条显示 388 MiB 并报「将超出上限」,
* 而平台真实配额是 672 / 384 —— 根因是**同一个事实被复制成了两份**:
* · 编排器 `src/supervisor/orchestrator.ts` 的 PLUGIN_MEM_MB / BASE / MIN / MAX(决定 MemoryMax)
* · 客户端 `poc/business-plugins/lib/client.js` 的 MEM_TABLE / MEM_BASE_MIB / …(决定界面显示)
* R1 把「写死 384」改成「按插件集合推导」时只改了前者 ⇒ 后者原地漂了。
* 这个脚本守的是一条**曾经漂过、又改过一次设计**的事实链,历史分三段,都留着:
*
* ① 2026-09-13(档案 84):发现问题 —— 「功能管理」页显示 388 MiB 并报「将超出上限」,
* 而平台真实配额是 672/384。根因 = **同一事实被复制成两份**:编排器的 BASE/MIN/MAX/插件表
* 与客户端的 MEM_BASE_MIB/…/MEM_TABLE。⇒ 本脚本把两处钉死。
*
* ② 2026-09-13(档案 81 · R1-④):编排器改成「基准 + Σ插件预估」,本脚本随之断言**插件表逐键一致**。
*
* ③ **2026-09-14(档案 96,用户要求)**:**与插件开关解耦** —— 最终口径为
* 「**基础 MIN、最多浮动到 MAX**」= cgroup `MemoryHigh=MIN`(软限)+ `MemoryMax=MAX`(硬限)。
* 理由(实测):用「估计表」定「硬上限」时,估偏低就真 OOM —— admin 带 mcn 后峰值 409 MiB 而当时
* 上限 384 ⇒ 一分钟内被 cgroup OOM 杀 4 次、打出 crash 熔断;且配额随开关跳动,界面「预估」与
* 实例真实上限绑死,语义混乱。
* ⇒ 因此本脚本**换向**:不再要求两处都维护插件表(配额只受 MIN/MAX 决定),而是断言
* · 编排器**不得**再用插件集合算配额(表与 BASE 已移除);
* · 客户端**保留**插件表,但**只用于预估**(`quotaOf` 不再依赖它);
* · MIN / MAX / HEADROOM 两处仍逐值一致。
*
* 本脚本把两处**钉死在一起**:任一常量或表项对不上,`npm run verify` 直接失败。
* 用法:node scripts/verify-mem-model.mjs 退出码 0=全绿 / 1=有失败
*/
import { readFileSync } from 'node:fs'
@@ -28,74 +39,66 @@ const ok = (name, cond, extra = '') => {
const orch = read('src/supervisor/orchestrator.ts')
const spawner = read('src/supervisor/spawner.ts')
const dshRoute = read('src/web/routes/dsh.ts')
const client = read('poc/business-plugins/lib/client.js')
const num = (src, name) => {
const m = new RegExp('const ' + name + ' = (\\d+)').exec(src)
return m ? Number(m[1]) : null
}
const ORCH = {
base: num(orch, 'BASE_MEM_MB'),
min: num(orch, 'MIN_MEM_MB'),
max: num(orch, 'MAX_MEM_MB'),
headroom: num(orch, 'HEAP_HEADROOM_MB'),
}
const orchTable = {}
{
const block = /const PLUGIN_MEM_MB: Record<string, number> = \{([\s\S]*?)\n\}/.exec(orch)
if (block) for (const m of block[1].matchAll(/'([^']+)':\s*(\d+)/g)) orchTable[m[1]] = Number(m[2])
}
ok('编排器常量齐备(BASE/MIN/MAX/HEADROOM)', ORCH.base !== null && ORCH.min !== null && ORCH.max !== null && ORCH.headroom !== null,
ok('编排器常量齐备(MIN/MAX/HEADROOM)', ORCH.min !== null && ORCH.max !== null && ORCH.headroom !== null,
`-> ${JSON.stringify(ORCH)}`)
ok('编排器插件成本表非空', Object.keys(orchTable).length > 0, '-> ' + JSON.stringify(orchTable))
ok('MIN ≤ MAX(配额取 MIN、MAX 作硬顶,顺序不能反)', ORCH.min !== null && ORCH.max !== null && ORCH.min <= ORCH.max,
`-> MIN ${ORCH.min} / MAX ${ORCH.max}`)
// ── ② 客户端(副本)────────────────────────────────────────
const client = read('poc/business-plugins/lib/client.js')
// ── ② 反回退:编排器的配额**不得**再依赖插件集合(档案 96 的核心)────────
ok('编排器已移除插件成本表 PLUGIN_MEM_MB', !/PLUGIN_MEM_MB/.test(orch))
ok('编排器已移除 BASE_MEM_MB(配额不再 = 基准 + Σ插件)', !/BASE_MEM_MB/.test(orch))
// ⚠️ 只看 `instanceMemMb` 的**函数体**:编排器别处仍会读 bundles(例如「从 bundles 摘掉某插件」,
// 与配额无关)——用全文件匹配会误报。
const memFns = [/function instanceBaseMb\(\)[\s\S]*?\n\}/.exec(orch)?.[0] ?? '', /function instanceMaxMb\(\)[\s\S]*?\n\}/.exec(orch)?.[0] ?? ''].join('\n')
ok('两个访问器都不读 profile/bundles', memFns.trim() !== '' && !/bundles|package\.json|readFileSync/.test(memFns),
'-> ' + memFns.replace(/\s+/g, ' ').slice(0, 110))
ok('编排器有 instanceBaseMb() / instanceMaxMb() 两个访问器',
/function instanceBaseMb\(\): number/.test(orch) && /function instanceMaxMb\(\): number/.test(orch))
ok('cgroup 给两个参数:MemoryHigh=基础 / MemoryMax=上界',
/'MemoryHigh=' \+ instanceBaseMb\(\) \+ 'M'/.test(orch) && /'MemoryMax=' \+ memMb \+ 'M'/.test(orch))
ok('调用点已不传 profile 路径(3 处 spawn/repair + quotaInfo)',
(orch.match(/instanceMaxMb\(\)/g) ?? []).length >= 3, `-> ${(orch.match(/instanceMaxMb\(\)/g) ?? []).length} 处`)
// ── ③ 客户端:常量仍与编排器一致,但插件表只准用于预估 ──────────────────
const cnum = (src, name) => {
const m = new RegExp('var ' + name + ' = (\\d+)').exec(src)
return m ? Number(m[1]) : null
}
const CLIENT = {
base: cnum(client, 'MEM_BASE_MIB'),
min: cnum(client, 'MEM_MIN_MIB'),
max: cnum(client, 'MEM_MAX_MIB'),
headroom: cnum(client, 'MEM_HEAP_HEADROOM_MIB'),
}
const clientTable = {}
{
const block = /var MEM_TABLE = \{([\s\S]*?)\}/.exec(client)
if (block) for (const m of block[1].matchAll(/"([^"]+)":\s*(\d+)/g)) clientTable[m[1]] = Number(m[2])
for (const [label, a, b] of [['下限 MIN', ORCH.min, CLIENT.min], ['硬顶 MAX', ORCH.max, CLIENT.max], ['堆预留 HEADROOM', ORCH.headroom, CLIENT.headroom]]) {
ok(`常量一致 · ${label}`, a === b, `-> 编排器 ${a} / 客户端 ${b}`)
}
ok('客户端 quotaOf 兜底值 = 硬顶上界 MEM_MAX_MIB', /function quotaOf\(_raw\)\s*\{\s*return MEM_MAX_MIB/.test(client))
ok('客户端显示「基础 → 最多」(i18n mem.base 存在)', /"mem\.base"\s*:/.test(client) && /quotaInfo\.baseMb/.test(client))
ok('客户端**保留**插件表 MEM_TABLE(给用户看的预估)', /var MEM_TABLE = \{/.test(client))
ok('客户端保留预估算式 rawMiB()(基线 + Σ插件)', /function rawMiB\(list, key\)/.test(client))
ok('预估用的基线常量仍在(MEM_BASE_MIB)', cnum(client, 'MEM_BASE_MIB') !== null)
ok('客户端注释标明插件表「仅用于预估」', /仅用于预估/.test(client))
// ── ③ 断言:两处必须逐项一致 ────────────────────────────────
const pairs = [
['基线 BASE', ORCH.base, CLIENT.base],
['下限 MIN', ORCH.min, CLIENT.min],
['硬顶 MAX', ORCH.max, CLIENT.max],
['堆预留 HEADROOM', ORCH.headroom, CLIENT.headroom],
]
for (const [label, a, b] of pairs) ok(`常量一致 · ${label}`, a === b, `-> 编排器 ${a} / 客户端 ${b}`)
const allKeys = [...new Set([...Object.keys(orchTable), ...Object.keys(clientTable)])].sort()
const bad = allKeys.filter((k) => orchTable[k] !== clientTable[k])
ok('插件成本表逐键一致', bad.length === 0,
bad.length === 0 ? `-> ${allKeys.length} 项:${allKeys.map((k) => k + '=' + orchTable[k]).join(', ')}`
: '-> 不一致:' + bad.map((k) => `${k}(编排器 ${orchTable[k]} / 客户端 ${clientTable[k]})`).join('; '))
// ── ④ 断言:clamp 算式同款(防「常量对了、规则又各写一套」)────
ok('编排器 clamp 规则在位', /Math\.min\(Math\.max\(mb, MIN_MEM_MB\), MAX_MEM_MB\)/.test(orch))
ok('客户端 clamp 规则与编排器同构', /Math\.min\(Math\.max\(raw, MEM_MIN_MIB\), MEM_MAX_MIB\)/.test(client))
// ── ④ heap 推导两侧同构 ─────────────────────────────────
ok('客户端 heap 推导与编排器同构', /Math\.max\(128, Math\.min\(256, memMb - MEM_HEAP_HEADROOM_MIB\)\)/.test(client))
ok('编排器 heap 推导算法未变', /Math\.max\(128, Math\.min\(256, memMb - HEAP_HEADROOM_MB\)\)/.test(orch))
// ── ⑤ 断言:status 真的把真值透出来了(否则前端的「读真值」是空接线)──
ok('Spawner 接口声明 quotaInfo?', /quotaInfo\?\(userId: string\): \{ memMb: number; heapMb: number \} \| null/.test(spawner))
ok('编排器实现 quotaInfo', /quotaInfo\(userId: string\): \{ memMb: number; heapMb: number \} \| null \{/.test(orch))
// 档案 87 顺手修:档案 86 把 status 主体抽成 `statusForUser(app, user)` 之后,这里从
// `request.user!.id` 变成了 `user.id` ⇒ 老断言正则失配、`npm run verify` 一直红着。
// 断言意图不变("status 真的把真值透出来了"),改成不绑定形参名。
// ── ⑤ status 真的把真值透出来了(否则前端「读真值」是空接线)──
ok('Spawner 接口声明 quotaInfo?', /quotaInfo\?\(userId: string\): { baseMb: number; memMb: number; heapMb: number } \| null/.test(spawner))
ok('编排器实现 quotaInfo', /quotaInfo\(userId: string\): { baseMb: number; memMb: number; heapMb: number } \| null \{/.test(orch))
ok('/api/dsh/status 返回 quota', /quota:\s*app\.supervisor\.quotaInfo\?\.\([^)]*\)\s*\?\?\s*null/.test(dshRoute))
ok('客户端会去读 /api/dsh/status', /\/api\/dsh\/status/.test(client) && /setQuotaInfo\(/.test(client))
ok('客户端会去读 /api/dsh/status(显示真配额)', /\/api\/dsh\/status/.test(client) && /setQuotaInfo\(/.test(client))
console.log(failed === 0 ? '\n结论:全绿 ✅' : '\n结论:有 ' + failed + ' 项失败 ❌')
process.exit(failed === 0 ? 0 : 1)