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:
fmq
2026-08-04 23:40:52 +08:00
parent c8fd24b120
commit cd370d88e7
74 changed files with 13386 additions and 3033 deletions
+353 -32
View File
@@ -99,6 +99,14 @@ impl SqliteTaskQueue {
[],
)?;
}
// H1 修复:历史遗留行(旧版在途任务)的 workflow_name 为 NULL。回填为
// '__legacy__'(与主库 grid_points 迁移口径一致),否则新节点领用/上报时
// claim/report 无法定向更新 '__legacy__' 网格点,旧任务结算后点永久卡死。
// 幂等:新 push 的行恒带 workflow_name,NULL 行只会出现在迁移遗留,重复执行无害。
conn.execute(
"UPDATE task_queue SET workflow_name = '__legacy__' WHERE workflow_name IS NULL",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_task_queue_status_created ON task_queue(status, created_at)",
[],
@@ -204,6 +212,15 @@ impl SqliteTaskQueue {
}
};
// 旧版兼容归一化(见 models.rs::normalize_compat 文档):
// 旧 MQ 在途消息可能只含 task_type、无 tlusty_config 字段。serde default
// 会把 tlusty_config.strategies 填为完整默认链 [cold_run, seed_step]
// 导致旧 SeedStep 热启动消息首项被误判为 cold_run。normalize_compat
// 据 task_type 校正首项策略。在 pop 出队后立即调用,保证节点拿到的是
// 语义正确的策略链。对新版消息(已显式设置 tlusty_config)无副作用。
let mut task = task;
task.normalize_compat();
// 记录任务归属:claim 时写入领用方 node_id,供 report 阶段校验,
// 杜绝「节点 A 领用、节点 B 上报」的跨节点伪造结果投毒。
tx.execute(
@@ -270,10 +287,15 @@ impl SqliteTaskQueue {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?;
// claimed 态(尚未被 report 清理)且归属匹配才算有效领用
// claimed 或 pending 态 + 归属匹配才算有效领用。status 含 'pending' 的缘由:
// `requeue_stale_tasks` 会把超时 claimed 行打回 pending(保留 claimed_by_node_id
// 仅清 claimed_at)。若任务实际仍在运行、只是耗时接近 timeout,原节点(仍持
// 归属)迟到的上报若被拒绝 → 403 → 昂贵计算结果被静默丢弃、重投重算(代码注释
// 记录的竞态,旧实现靠 stale_sec ≥ 3×timeout 缓冲而非根治)。放行 pending 态后
// 该竞态消除;重新领用会覆盖 claimed_by_node_id,故跨节点伪造上报仍被拦截。
let mut stmt = conn.prepare(
"SELECT payload FROM task_queue
WHERE task_id = ?1 AND claimed_by_node_id = ?2 AND status = 'claimed' LIMIT 1",
WHERE task_id = ?1 AND claimed_by_node_id = ?2 AND status IN ('claimed', 'pending') LIMIT 1",
)?;
let row = stmt.query_row(params![task_id, claimant], |r| r.get::<_, String>(0));
match row {
@@ -345,7 +367,7 @@ impl SqliteTaskQueue {
Ok(())
}
/// 仅清理指定工作流的**未被领取**的排队任务。
/// 仅清理指定工作流的**未被领取**的排队任务,并返回被删行的 task_id 列表
///
/// 用于 stop_workflow / initialize_grid 按工作流隔离清理,避免在多工作流场景下
/// 误清其他工作流的任务。
@@ -354,21 +376,55 @@ impl SqliteTaskQueue {
/// 必须保留领用凭证,否则节点上报时 verify_task_claim 找不到记录 → 403 →
/// 计算结果丢失、网格点永久卡在 running(#6 修复)。
/// `claimed` 行会在上报成功后由 remove_task 自然清理。
pub async fn clear_queue_by_workflow(&self, workflow_name: &str) -> Result<()> {
///
/// 返回被删 task_id 供调用方同步清理主库 `tasks` 审计表对应行(2026-08-02 僵尸
/// 任务事故修复):此前只删队列行而遗留 tasks 表 pending 行,成为孤儿回收器误判
/// 与重复派发涡旋的燃料。调用方拿到 ids 后应调 Database::delete_tasks_by_ids。
pub async fn clear_queue_by_workflow(&self, workflow_name: &str) -> Result<Vec<String>> {
let pool = self.pool.clone();
let wf_owned = workflow_name.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let ids = tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?;
conn.execute(
"DELETE FROM task_queue WHERE workflow_name = ?1 AND status = 'pending'",
params![wf_owned],
// DELETE...RETURNING 须 prepare + query_mapexecute 不返回结果集)。
let mut stmt = conn.prepare(
"DELETE FROM task_queue WHERE workflow_name = ?1 AND status = 'pending' RETURNING task_id",
)?;
Ok(())
let rows = stmt.query_map(params![wf_owned], |row| row.get::<_, String>(0))?;
let mut ids = Vec::new();
for r in rows {
if let Ok(id) = r {
ids.push(id);
}
}
Ok(ids)
})
.await??;
Ok(())
Ok(ids)
}
/// 判断指定 task_id 是否仍有**活**队列行(pending 或 claimed)。
///
/// 供派发去重与孤儿回收做跨库活性交叉校验:主库 tasks 表的 pending 行可能是
/// 从未入队/已被清理的僵尸记录,唯有 task_queue 中存在 pending/claimed 行才证明
/// 该任务真在途(排队中或已被节点领用)。
pub async fn task_row_exists(&self, task_id: &str) -> Result<bool> {
let pool = self.pool.clone();
let id_owned = task_id.to_string();
let exists = tokio::task::spawn_blocking(move || -> Result<bool> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM task_queue WHERE task_id = ?1 AND status IN ('pending', 'claimed')",
params![id_owned],
|r| r.get(0),
)?;
Ok(count > 0)
})
.await??;
Ok(exists)
}
}
@@ -378,6 +434,53 @@ mod tests {
use common::models::{GridPointParams, TaskType};
use uuid::Uuid;
/// H1 修复:队列库迁移必须把历史遗留的 NULL workflow_name 行回填为 '__legacy__'
/// (与主库 grid_points 迁移口径一致)。否则新节点领用/上报时 claim/report 无法
/// 定向更新 '__legacy__' 网格点,旧任务结算后网格点永久卡死。
#[tokio::test]
async fn test_queue_migration_backfills_null_workflow_name() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("legacy_mig.db");
// 模拟旧版队列库:建表 + 插入一条 workflow_name 为 NULL 的遗留 pending 行。
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute_batch(
"CREATE TABLE task_queue (
task_id TEXT PRIMARY KEY,
payload TEXT NOT NULL,
status TEXT NOT NULL,
created_at DATETIME NOT NULL,
claimed_at DATETIME,
workflow_name TEXT,
claimed_by_node_id TEXT,
wave INTEGER NOT NULL DEFAULT 0
);",
)
.unwrap();
conn.execute(
"INSERT INTO task_queue (task_id, payload, status, created_at, wave)
VALUES ('legacy-1', '{}', 'pending', datetime('now'), 0)",
[],
)
.unwrap();
}
// 打开队列库(触发迁移:补列检查 + NULL workflow_name 回填)。
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy()).await.unwrap();
let wf: String = {
let conn = queue.pool.get().unwrap();
conn.query_row(
"SELECT workflow_name FROM task_queue WHERE task_id = 'legacy-1'",
[],
|r| r.get(0),
)
.unwrap()
};
assert_eq!(wf, "__legacy__", "NULL workflow_name 应回填为 __legacy__");
}
#[tokio::test]
async fn test_sqlite_task_queue_operations() {
let temp_dir = tempfile::tempdir().unwrap();
@@ -393,18 +496,19 @@ mod tests {
task_id,
point_name: "t35000_g5.5_he-1_c-2_n-2_o-2".to_string(),
params: GridPointParams {
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
},
task_type: TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 3600,
workflow_name: Some("test_wf".to_string()),
wave: 0,
..Default::default()
};
queue.push_task(&task).await.unwrap();
@@ -437,12 +541,12 @@ mod tests {
let task_id = Uuid::new_v4();
let params = GridPointParams {
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let task = TaskSpec {
task_id,
@@ -453,6 +557,7 @@ mod tests {
timeout_sec: 60,
workflow_name: None,
wave: 0,
..Default::default()
};
queue.push_task(&task).await.unwrap();
@@ -486,24 +591,111 @@ mod tests {
assert!(claim_after.is_none());
}
/// requeue 竞态回归(修复):任务被 `requeue_stale_tasks` 打回 pending 后(claimed_at
/// 清零但 claimed_by_node_id 保留),原节点(仍持归属)迟到的上报必须被放行——否则
/// 耗时接近 timeout 的任务会因 403 被静默丢弃计算结果、重投重算(代码注释记录的竞态,
/// 旧实现只靠 stale_sec ≥ 3×timeout 缓冲)。重新领用后归属被覆盖,原节点校验失败。
#[tokio::test]
async fn test_verify_task_claim_accepts_requeued_original_claimant() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("claim_requeue.db");
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy())
.await
.unwrap();
let task_id = Uuid::new_v4();
let params = GridPointParams {
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let task = TaskSpec {
task_id,
point_name: params.model_name(),
params: params.clone(),
task_type: TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 7200,
workflow_name: Some("wf_rq".to_string()),
wave: 0,
..Default::default()
};
queue.push_task(&task).await.unwrap();
// node-A 领用 → claimed。
assert!(queue.pop_task("node-A").await.unwrap().is_some());
// 模拟 requeue_stale_tasks(stale=0):超时 claimed 行打回 pendingclaimed_by_node_id 保留)。
{
let pool = queue.pool.clone();
let tid = task_id.to_string();
tokio::task::spawn_blocking(move || {
let conn = pool.get().unwrap();
conn.execute(
"UPDATE task_queue SET status = 'pending', claimed_at = NULL WHERE task_id = ?1",
rusqlite::params![tid],
)
.unwrap();
})
.await
.unwrap();
}
// 原节点 node-A 迟到的上报:pending + 归属匹配 → 放行(修复后不再 403)。
let claim_a = queue
.verify_task_claim(&task_id.to_string(), "node-A")
.await
.unwrap();
assert_eq!(
claim_a.map(|(p, w)| (p, w)),
Some((params.model_name(), Some("wf_rq".to_string()))),
"requeue 后原节点仍持归属,应放行"
);
// 其它节点仍被拒(归属不变)。
let claim_b = queue
.verify_task_claim(&task_id.to_string(), "node-B")
.await
.unwrap();
assert!(claim_b.is_none(), "非归属节点仍应被拒");
// 重新领用后归属覆盖:node-B claim 后,node-A 校验失败、node-B 成功。
let popped_b = queue.pop_task("node-B").await.unwrap();
assert!(popped_b.is_some(), "pending 任务可被 node-B 重新领用");
let claim_a2 = queue
.verify_task_claim(&task_id.to_string(), "node-A")
.await
.unwrap();
assert!(claim_a2.is_none(), "归属已移交 node-Bnode-A 迟报应被拒");
let claim_b2 = queue
.verify_task_claim(&task_id.to_string(), "node-B")
.await
.unwrap();
assert!(claim_b2.is_some(), "node-B 现持归属,应放行");
}
/// 辅助:构造一个最小 TaskSpec,方便下面两个排序测试。
fn mk_task(wf: &str, wave: i32, point_name: &str) -> TaskSpec {
TaskSpec {
task_id: Uuid::new_v4(),
point_name: point_name.to_string(),
params: GridPointParams {
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
},
task_type: TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 60,
workflow_name: Some(wf.to_string()),
wave,
..Default::default()
}
}
@@ -585,10 +777,7 @@ mod tests {
// 第一次 pop:应跳过 poison(标记为 dead_letter)并返回合法任务
let popped = queue.pop_task("n1").await.unwrap();
assert!(
popped.is_some(),
"毒消息不应阻塞合法任务出队"
);
assert!(popped.is_some(), "毒消息不应阻塞合法任务出队");
let task = popped.unwrap();
assert_eq!(task.point_name, "ok_point");
@@ -614,6 +803,64 @@ mod tests {
assert!(queue.pop_task("n1").await.unwrap().is_none());
}
/// 旧版 MQ 在途消息兼容(二次审查 critical 修复验证):
/// 旧消息只含 task_type=seed_step、无 tlusty_config 字段。serde default 会把
/// strategies 填为 [cold_run, seed_step],首项 cold_run 与 task_type 不符。
/// pop_task 出队时须调用 normalize_compat() 把首项校正为 seed_step
/// 否则旧热启动消息会被节点误当冷启动执行。
#[tokio::test]
async fn test_pop_normalizes_legacy_seed_step_message() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("legacy.db");
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy())
.await
.unwrap();
// 直接写一行旧版 payload(仅 task_type,无 tlusty_config/synspec_config 字段)。
let legacy_task_id = uuid::Uuid::new_v4();
let legacy_payload = serde_json::json!({
"task_id": legacy_task_id.to_string(),
"point_name": "t35000_g5.5_he-1_c-2_n-2_o-2",
"params": {"teff": 35000.0, "logg": 5.5, "loghe": -1.0, "logc": -2.0, "logn": -2.0, "logo": -2.0},
"task_type": "seed_step",
"seed_point_name": "neighbor_seed",
"timeout_sec": 7200,
"workflow_name": "wf_legacy",
"wave": 0
}).to_string();
{
let pool = queue.pool.clone();
let payload_clone = legacy_payload.clone();
let tid = legacy_task_id.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool.get().unwrap();
conn.execute(
"INSERT INTO task_queue (task_id, payload, status, created_at, wave)
VALUES (?1, ?2, 'pending', datetime('now'), 0)",
rusqlite::params![tid, payload_clone],
)?;
Ok(())
})
.await
.unwrap()
.unwrap();
}
let popped = queue.pop_task("n1").await.unwrap();
let task = popped.expect("旧版消息应能正常出队");
// normalize_compat 应把 strategies 校正为 [seed_step](与 task_type 一致)。
assert_eq!(
task.tlusty_config.strategies,
vec!["seed_step".to_string()],
"旧 seed_step 消息经 normalize_compat 后首项应为 seed_step"
);
assert_eq!(
task.tlusty_config.current_strategy("cold_run"),
"seed_step",
"current_strategy 应为 seed_step(而非默认链的 cold_run"
);
}
#[tokio::test]
async fn test_pop_wave_priority_within_workflow() {
let temp_dir = tempfile::tempdir().unwrap();
@@ -632,4 +879,78 @@ mod tests {
let p2 = queue.pop_task("n2").await.unwrap().unwrap();
assert_eq!(p2.point_name, "hard");
}
/// 回归测试(2026-08-02 僵尸任务事故):clear_queue_by_workflow 必须返回被删
/// pending 行的 task_id(供调用方同步清理主库 tasks 审计行),且保留 claimed 行、
/// 不波及他工作流。
#[tokio::test]
async fn test_clear_queue_by_workflow_returns_deleted_ids() {
let temp_dir = tempfile::tempdir().unwrap();
let queue = SqliteTaskQueue::new(&temp_dir.path().join("clear_ret.db").to_string_lossy())
.await
.unwrap();
// wf_a 两个任务:一个被领用(claimed),一个排队(pending
queue
.push_task(&mk_task("wf_a", 0, "a_claimed"))
.await
.unwrap();
queue
.push_task(&mk_task("wf_a", 0, "a_pending"))
.await
.unwrap();
// wf_b 一个排队任务(不应被 wf_a 的清理波及)
queue
.push_task(&mk_task("wf_b", 0, "b_pending"))
.await
.unwrap();
let claimed = queue.pop_task("node-a").await.unwrap().unwrap();
assert_eq!(claimed.point_name, "a_claimed");
let deleted = queue.clear_queue_by_workflow("wf_a").await.unwrap();
assert_eq!(deleted.len(), 1, "仅 a_pending 一行被删");
// 被删行对应的点名为 a_pending:用剩余可 pop 任务反证(wf_a 已无 pending 行)
let next = queue.pop_task("node-b").await.unwrap().unwrap();
assert_eq!(
next.workflow_name,
Some("wf_b".to_string()),
"wf_b 行应完好"
);
// claimed 行保留:领用凭证仍在,原节点归属校验通过(#6 设计不动)
let verify = queue
.verify_task_claim(&claimed.task_id.to_string(), "node-a")
.await
.unwrap();
assert!(verify.is_some(), "claimed 行必须保留");
// 被删的 id 不再有任何活行
for id in &deleted {
assert!(!queue.task_row_exists(id).await.unwrap());
}
}
/// task_row_exists 三态:pending/claimed 视为活,remove 后为死。
#[tokio::test]
async fn test_task_row_exists_liveness() {
let temp_dir = tempfile::tempdir().unwrap();
let queue = SqliteTaskQueue::new(&temp_dir.path().join("exists.db").to_string_lossy())
.await
.unwrap();
let task = mk_task("wf_e", 0, "point_e");
let id = task.task_id.to_string();
assert!(!queue.task_row_exists(&id).await.unwrap(), "未入队为死");
queue.push_task(&task).await.unwrap();
assert!(queue.task_row_exists(&id).await.unwrap(), "pending 为活");
queue.pop_task("node-e").await.unwrap().unwrap();
assert!(queue.task_row_exists(&id).await.unwrap(), "claimed 仍为活");
queue.remove_task(&id).await.unwrap();
assert!(!queue.task_row_exists(&id).await.unwrap(), "删除后为死");
}
}