feat(all): 任务引擎双阶段解耦、僵尸涡旋修复、动态 CPU 配额与前端详情页重构
将 TLUSTY/SYNSPEC 拆为各自独立的 enabled/policy/strategies 阶段,
以策略链自动弹栈取代单级 seed_step 布尔回退;定向修复 2026-08-02
僵尸任务涡旋事故;新增节点并发配额热调;前端详情页从 1412 行巨型
视图拆为薄控制器 + detail 子模块,并补齐工具层与单测。
引擎与调度(task_engine_decoupling_design.md)
- models.rs: 新增 StagePolicy / EngineStageConfig / TaskSpec 阶段字段、
normalize_compat() 校正旧版在途消息策略链、failed_stage 归因
- scheduler.rs: resolve_dispatchable_chain 派发门控、
trigger_strategy_fallback 按 failed_stage 精确弹栈;启动期
force_recompute/skip_converged(默认)/skip_failed 三策略
- db.rs: tasks 表 +7 列持久化阶段配置;终态守卫
(mark_grid_point_running 仅 pending/queued→running;
record_task_report 拒绝迟到失败翻黑 converged);策略弹栈快照
僵尸涡旋修复(runbook-20260802-zombie-vortex-fix.md)
- 全链路跨库活性交叉校验:派发/claim/孤儿回收/回退统一查 MQ 队列活性,
活则放行、死则清僵尸,结构性消除"每点重复派发"
- stop/重启卫生:清队列同步 delete_tasks_by_ids,杜绝遗留 pending 行
- report_task: 幂等吸收 + 409 区分迟到冗余结果,仅 state_changed 时回退
- MQ: NULL workflow_name 回填 __legacy__、requeue 后迟到上报被 403 竞态修复
动态 CPU 配额(dynamic_cpu_slots_design.md)
- admin.rs: POST /admin/nodes/:id/quota(Option<Option<i32>> 区分
缺字段/显式 null);nodes 表 +admin_max_slots
- worker.rs: effective_max_slots = min(admin, physical),心跳下发原子生效
科学产物保全(tlusty_result_artifacts.md)
- runner.rs: SYNSPEC 启动前快照 fort.12/fort.14 → .bfac/.emflux 防覆盖
- 半失败点(大气收敛+光谱失败)改判 Failed 并写入 note;仅 SYNSPEC
场景不再恒判失败;撤销归档 LRU 200 上限改为永久保留
- executor.rs: 透传 synspec_params 数值参数(此前固定 None)
前端(dashboard/)
- workflowDetail.js 1412→328 行,拆出 views/detail/{ctx,overview,
pointsTable,parSets,pointPanel}.js,AbortController 治理监听/请求生命周期
- 删除 wfActions.js,新增 wfEnginePanel.js(双阶段三维配置编辑面板)
- 新增 utils/{errors,format,icons,polling,yamlStage}.js 纯函数模块
- 路由级动态 import 代码分割;节点配额三点菜单 + Modal 管理
- 首次引入 node:test 单测(format/polling/yamlStage/psCache,644 行)
- 系统性补齐 a11y:skip-link、ARIA、Tab 键盘漫游、toast 关闭、退出动画
文档与工具
- 新增 6 篇设计/调研:引擎解耦、动态配额、涡旋 runbook、
光谱正确性分析、收敛判断、产物归档
- PIPELINE/design/api/database 等协同重写为分布式 C/S 架构口径
- scripts/fetch_results.sh 跨节点产物备份;import_results 按 cno 升序导入
- workflows/sdB_cno.yaml: 新增 tlusty/synspec_stage 配置块,修正 wstart 笔误
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
/* DCTS 轮询器单测(node:test,零依赖)
|
||||
*
|
||||
* utils/polling.js 的 createPoller:指数退避、failCount 重置/增长、stop 清理。
|
||||
* 通过 stubbing setTimeout / clearTimeout 捕获调度延迟,不真等 5s+。
|
||||
*
|
||||
* 时序说明:start() 触发 schedule() 时 fn 是同步调用的(calls 立即生效),
|
||||
* 后续的 failCount 更新与 setTimeout 排期发生在微任务里——`await p.start()`
|
||||
* 之后测试续体排在其后,故断言时调度已完成(确定性)。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPoller } from '../src/utils/polling.js';
|
||||
|
||||
// 最小 document stub:node 环境无 DOM,轮询器只在 visibilitychange 处触碰 document。
|
||||
function installDocStub() {
|
||||
const doc = { hidden: false, addEventListener() {}, removeEventListener() {} };
|
||||
const prev = globalThis.document;
|
||||
globalThis.document = doc;
|
||||
return { doc, restore() { globalThis.document = prev; } };
|
||||
}
|
||||
|
||||
// 捕获 setTimeout 调用。fire(n) 手动执行第 n 次已排定的回调(await 其完整完成)。
|
||||
function captureTimers() {
|
||||
const realSet = globalThis.setTimeout;
|
||||
const realClear = globalThis.clearTimeout;
|
||||
const scheduled = [];
|
||||
globalThis.setTimeout = (fn, ms) => {
|
||||
const id = { fn, ms };
|
||||
scheduled.push(id);
|
||||
return id;
|
||||
};
|
||||
globalThis.clearTimeout = (id) => {
|
||||
const i = scheduled.indexOf(id);
|
||||
if (i >= 0) scheduled.splice(i, 1);
|
||||
};
|
||||
return {
|
||||
delays: () => scheduled.map(t => t.ms),
|
||||
fire: async (n) => { await scheduled[n].fn(); },
|
||||
restore() {
|
||||
globalThis.setTimeout = realSet;
|
||||
globalThis.clearTimeout = realClear;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('start() 立即执行一次 fn', async () => {
|
||||
const stub = installDocStub();
|
||||
const timers = captureTimers();
|
||||
let calls = 0;
|
||||
const p = createPoller(() => { calls++; return true; }, { baseMs: 5000 });
|
||||
await p.start();
|
||||
assert.equal(calls, 1); // 立即跑了一次(fn 同步调用)
|
||||
assert.equal(p.isRunning(), true);
|
||||
// 成功后下一轮间隔为 baseMs
|
||||
assert.deepEqual(timers.delays(), [5000]);
|
||||
p.stop();
|
||||
timers.restore();
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('fn 返回 false 触发指数退避,成功后重置', async () => {
|
||||
const stub = installDocStub();
|
||||
const timers = captureTimers();
|
||||
let ok = false;
|
||||
const p = createPoller(() => ok, { baseMs: 5000, maxMs: 60000 });
|
||||
await p.start();
|
||||
// 第一次失败 → failCount=1 → 5k*2^1
|
||||
assert.deepEqual(timers.delays(), [10000]);
|
||||
await timers.fire(0); // 第二轮仍失败 → failCount=2
|
||||
assert.deepEqual(timers.delays().slice(-1), [20000]);
|
||||
ok = true;
|
||||
await timers.fire(0); // 第三轮成功 → failCount 重置
|
||||
assert.deepEqual(timers.delays().slice(-1), [5000]);
|
||||
p.stop();
|
||||
timers.restore();
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('fn 抛错同样计为失败', async () => {
|
||||
const stub = installDocStub();
|
||||
const timers = captureTimers();
|
||||
const p = createPoller(() => { throw new Error('boom'); }, { baseMs: 5000 });
|
||||
await p.start();
|
||||
assert.deepEqual(timers.delays().slice(-1), [10000]);
|
||||
p.stop();
|
||||
timers.restore();
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('stop() 清除挂起定时器且不再调度', async () => {
|
||||
const stub = installDocStub();
|
||||
const timers = captureTimers();
|
||||
let calls = 0;
|
||||
const p = createPoller(() => { calls++; return true; }, { baseMs: 5000 });
|
||||
await p.start(); // calls=1,已排下一轮
|
||||
assert.equal(timers.delays().length, 1);
|
||||
p.stop();
|
||||
assert.equal(timers.delays().length, 0); // 挂起定时器已被清除
|
||||
assert.equal(calls, 1); // fn 未再被调用
|
||||
assert.equal(p.isRunning(), false);
|
||||
timers.restore();
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('triggerNow() 立即触发一次并续排', async () => {
|
||||
const stub = installDocStub();
|
||||
const timers = captureTimers();
|
||||
let calls = 0;
|
||||
const p = createPoller(() => { calls++; return true; }, { baseMs: 5000 });
|
||||
await p.start(); // calls=1
|
||||
await p.triggerNow(); // calls=2
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(p.isRunning(), true);
|
||||
p.stop();
|
||||
timers.restore();
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('退避上限 maxMs 生效(长时间连续失败不无限增长)', async () => {
|
||||
const stub = installDocStub();
|
||||
const timers = captureTimers();
|
||||
const p = createPoller(() => false, { baseMs: 5000, maxMs: 60000, maxFails: 4 });
|
||||
await p.start();
|
||||
let maxDelay = 0;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
maxDelay = Math.max(maxDelay, timers.delays()[timers.delays().length - 1] || 0);
|
||||
await timers.fire(0);
|
||||
}
|
||||
assert.ok(maxDelay <= 60000, `maxDelay=${maxDelay} 不应超过 60s`);
|
||||
assert.equal(maxDelay, 60000); // 5k*2^4 clamp 到 60s
|
||||
p.stop();
|
||||
timers.restore();
|
||||
stub.restore();
|
||||
});
|
||||
Reference in New Issue
Block a user