用户原话两段:①「改为 实例内存不要受插件开关影响,只受 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 的配额口径同步(那两个文件本就有别人未提交的改动)。
105 lines
6.8 KiB
JavaScript
105 lines
6.8 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* verify-mem-model.mjs —— 内存模型「单一事实源」交叉校验(档案 84 → 档案 96 改版)
|
||
*
|
||
* 这个脚本守的是一条**曾经漂过、又改过一次设计**的事实链,历史分三段,都留着:
|
||
*
|
||
* ① 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 两处仍逐值一致。
|
||
*
|
||
* 用法:node scripts/verify-mem-model.mjs 退出码 0=全绿 / 1=有失败
|
||
*/
|
||
import { readFileSync } from 'node:fs'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { dirname, join } from 'node:path'
|
||
|
||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||
const read = (p) => readFileSync(join(ROOT, p), 'utf8')
|
||
|
||
let failed = 0
|
||
const ok = (name, cond, extra = '') => {
|
||
console.log((cond ? ' ✓ ' : ' ✗ ') + name + (extra ? ' ' + extra : ''))
|
||
if (!cond) failed++
|
||
}
|
||
|
||
// ── ① 编排器(权威)────────────────────────────────────────
|
||
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 = {
|
||
min: num(orch, 'MIN_MEM_MB'),
|
||
max: num(orch, 'MAX_MEM_MB'),
|
||
headroom: num(orch, 'HEAP_HEADROOM_MB'),
|
||
}
|
||
ok('编排器常量齐备(MIN/MAX/HEADROOM)', ORCH.min !== null && ORCH.max !== null && ORCH.headroom !== null,
|
||
`-> ${JSON.stringify(ORCH)}`)
|
||
ok('MIN ≤ MAX(配额取 MIN、MAX 作硬顶,顺序不能反)', ORCH.min !== null && ORCH.max !== null && ORCH.min <= ORCH.max,
|
||
`-> MIN ${ORCH.min} / MAX ${ORCH.max}`)
|
||
|
||
// ── ② 反回退:编排器的配额**不得**再依赖插件集合(档案 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 = {
|
||
min: cnum(client, 'MEM_MIN_MIB'),
|
||
max: cnum(client, 'MEM_MAX_MIB'),
|
||
headroom: cnum(client, 'MEM_HEAP_HEADROOM_MIB'),
|
||
}
|
||
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))
|
||
|
||
// ── ④ 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\): { 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))
|
||
|
||
console.log(failed === 0 ? '\n结论:全绿 ✅' : '\n结论:有 ' + failed + ' 项失败 ❌')
|
||
process.exit(failed === 0 ? 0 : 1)
|