feat(all): 科学计算正确性修复、调度竞态消除、节点优雅退出、安全加固与收敛分析重构

科学计算正确性:
- 修复 Fortran 无-E 科学记数法(指数≥100 时 E 被挤掉,如 -1.35+118)导致
  发散行被静默跳过、误判收敛的 bug;扩展大气无效检测覆盖 Inf 与 *** 溢出标记
- 种子匹配改为 CNO 有向距离(富金属方向重罚 4×、贫金属方向轻罚 1×),
  基于 1191 个真实种子配对回测标定,回测净改善 314 个点
- GridAxisValue 反序列化拒绝非法文本(不再静默 NaN);chmax≤0 显式报错
- ions 行宽列宽对齐真实 fort.5 格式

调度与队列竞态:
- 原子选点(IMMEDIATE 事务 SELECT+UPDATE)消除并发调度重复派发 (#5)
- 调度互斥锁 + 冷启动优先策略(SeedStep 仅作失败后救援,不再正常路径热启动)
- 毒消息 dead_letter 标记防出队死循环;clear_queue 保留 claimed 行 (#6)
- 孤儿 running 点回收兜底;stale_sec 默认 7800→21600s(3 倍超时缓冲)

节点生命周期:
- SIGTERM+SIGINT 双信号监听(修复 Docker stop 发 SIGTERM 不触发优雅退出)
- SlotGuard RAII 防活动 slot 泄漏;子进程超时增加二级 30s wait 防 Fortran hang
- SeedStep 种子下载 fail-fast + 沙盒私有副本解耦 LRU 清理竞争
- reqwest Client 增加连接/请求超时;启动清理残留 task_* 沙盒

安全加固:
- 节点注册 registration_secret 二次凭据 (H8),恒定时间比对防时序旁路
- token 缓存 generation 机制消除 reissue 后旧 token TOCTOU 复活窗口
- 新增 /api/auth/logout 服务端 session 即时撤销;fail-closed 鉴权启动策略
- 前端 token 迁移 sessionStorage;YAML 高亮改 DOM API 消除 XSS 注入面
- 备份文件权限收紧 0600;点表动态值全面 escapeHtml

前端 Dashboard:
- 收敛性分析从热力图重构为 Parallel Sets 平行集合图(6 维+状态轴,手写 SVG 零依赖)
- 进度曲线横轴改为真实时间(服务端 now 锚定,停滞期诚实留白);轮询指数退避
- 移除 imported 收敛途径分类,导入点按实际途径 cold_run/seed_step 归类
- 初始化时 /api/auth/check 校验 token;401 toast 提示替代静默 reload

服务端恢复与工具链:
- 启动恢复 initializing 态工作流;默认工作流 INSERT-only 不覆盖 API 编辑
- body limit 分层(10MB 不再截断 256MB report);multipart 显式错误处理
- 嵌入二进制原子写(tmp+rename)防半写损坏
- import_results 判定收敛途径透传 success_method;conv.json 格式对齐本项目
- push_import_results.sh 退出码修复 + .bat UTF-8 BOM + scp 上传
- Docker USE_MIRRORS 默认关闭;移除无用 assets 挂载;删除 hosts.ini 入库
This commit is contained in:
fmq
2026-08-01 17:01:40 +08:00
parent 1bfa240cb0
commit c8fd24b120
45 changed files with 2821 additions and 831 deletions
+101 -4
View File
@@ -179,7 +179,30 @@ impl SqliteTaskQueue {
Err(e) => return Err(e.into()),
};
let task: TaskSpec = serde_json::from_str(&payload)?;
// 毒消息防护:payload 解析失败时,若直接返回 Err 会让事务回滚,
// 该行保持 pending,下次 pop_task 因 ORDER BY 确定性排序会再次
// 选中同一行 → 无限循环,阻塞全部任务分发(毒消息死循环)。
// 修复:把不可解析的行标记为 'dead_letter' 并提交事务,使其脱离
// pending 候选集,让出队能继续前进。dead_letter 行不再被
// pop_task/requeue_stale_tasks 触及,由运维侧事后审计清理。
let task: TaskSpec = match serde_json::from_str(&payload) {
Ok(t) => t,
Err(e) => {
tracing::error!(
"任务队列 payload 解析失败,标记为死信以避免毒消息死循环: \
task_id={} 错误: {}",
task_id,
e
);
tx.execute(
"UPDATE task_queue SET status = 'dead_letter' WHERE task_id = ?1",
params![task_id],
)?;
tx.commit()?;
// 死信已落库,循环继续尝试 pop 下一行 pending 任务。
continue;
}
};
// 记录任务归属:claim 时写入领用方 node_id,供 report 阶段校验,
// 杜绝「节点 A 领用、节点 B 上报」的跨节点伪造结果投毒。
@@ -322,9 +345,15 @@ impl SqliteTaskQueue {
Ok(())
}
/// 仅清理指定工作流的排队任务。
/// 仅清理指定工作流的**未被领取**的排队任务。
///
/// 用于 stop_workflow 按工作流隔离清理,避免在多工作流场景下误清其他工作流的任务。
/// 用于 stop_workflow / initialize_grid 按工作流隔离清理,避免在多工作流场景下
/// 误清其他工作流的任务。
///
/// 只删 `pending`(未被领取)行,保留 `claimed` 行:节点已领取正在执行的任务
/// 必须保留领用凭证,否则节点上报时 verify_task_claim 找不到记录 → 403 →
/// 计算结果丢失、网格点永久卡在 running(#6 修复)。
/// `claimed` 行会在上报成功后由 remove_task 自然清理。
pub async fn clear_queue_by_workflow(&self, workflow_name: &str) -> Result<()> {
let pool = self.pool.clone();
let wf_owned = workflow_name.to_string();
@@ -333,7 +362,7 @@ impl SqliteTaskQueue {
.get()
.map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?;
conn.execute(
"DELETE FROM task_queue WHERE workflow_name = ?1",
"DELETE FROM task_queue WHERE workflow_name = ?1 AND status = 'pending'",
params![wf_owned],
)?;
Ok(())
@@ -517,6 +546,74 @@ mod tests {
}
/// 出队同工作流内按 wave ASC:低难度 wave 优先,即使它 created_at 更晚。
/// 毒消息防护:payload 损坏的 pending 行不应无限阻塞出队。
///
/// 旧实现中 pop_task 遇到不可解析的 payload 会回滚事务,该行保持 pending,
/// 因 ORDER BY wave, created_at 确定性排序,下次仍选中同一行 → 死循环阻塞整个队列。
/// 现实现把不可解析行标记为 'dead_letter' 并提交,使其脱离 pending 候选集,
/// 后续 pending 任务能正常出队。
#[tokio::test]
async fn test_pop_task_skips_poison_message() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("poison.db");
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy())
.await
.unwrap();
// 直接写一行 payload 损坏的 pending 任务(绕过 push_task 的合法序列化)
{
let pool = queue.pool.clone();
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 ('poison-1', 'this is not valid json', 'pending', datetime('now'), 0)",
[],
)?;
Ok(())
})
.await
.unwrap()
.unwrap();
}
// 再推一个合法任务(wave=0,但 created_at 晚于 poison,故 pop 会先撞上 poison 行)
queue
.push_task(&mk_task("wf_ok", 0, "ok_point"))
.await
.unwrap();
// 第一次 pop:应跳过 poison(标记为 dead_letter)并返回合法任务
let popped = queue.pop_task("n1").await.unwrap();
assert!(
popped.is_some(),
"毒消息不应阻塞合法任务出队"
);
let task = popped.unwrap();
assert_eq!(task.point_name, "ok_point");
// 确认 poison 行已被标记为 dead_letter,不再处于 pending
{
let pool = queue.pool.clone();
let status: String = tokio::task::spawn_blocking(move || -> Result<String> {
let conn = pool.get().unwrap();
let s: String = conn.query_row(
"SELECT status FROM task_queue WHERE task_id = 'poison-1'",
[],
|r| r.get(0),
)?;
Ok(s)
})
.await
.unwrap()
.unwrap();
assert_eq!(status, "dead_letter", "毒消息应被标记为 dead_letter");
}
// 队列已空
assert!(queue.pop_task("n1").await.unwrap().is_none());
}
#[tokio::test]
async fn test_pop_wave_priority_within_workflow() {
let temp_dir = tempfile::tempdir().unwrap();