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
+60 -14
View File
@@ -9,7 +9,7 @@ use reqwest::multipart::{Form, Part};
use reqwest::Client;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{info, warn};
use tracing::{debug, info, warn};
/// DCTS 历史计算结果导入工具。
///
@@ -77,6 +77,9 @@ struct CandidateResult {
src_dir: PathBuf,
/// 权威新名(来自 workflow 配置的小数精度命名)。
canonical_name: String,
/// 该点当初的收敛途径("cold_run" / "seed_step"),据 stages 是否含 seed_nc 判定,
/// 透传给服务端写入 grid_points.success_method,让导入点融入冷启动/种子步进统计。
success_method: String,
}
#[tokio::main]
@@ -327,12 +330,34 @@ async fn scan_dir_for_results(
continue;
};
// 判定该历史点的收敛途径(供服务端写入 success_method,让导入点融入冷启动/种子步进统计):
// 旧 run_grid.py 流程——冷启动失败才改用 seed_step 链重跑,种子链首段 label 为 "seed_nc"。
// 故 stages 含 seed_nc → seed_step(种子步进救活);否则 → cold_run(冷启动一次收敛)。
let has_seed_nc_stage = summary
.stages
.iter()
.any(|s| s.label == "seed_nc");
let success_method = if has_seed_nc_stage {
"seed_step"
} else {
"cold_run"
};
// 辅助诊断:探测同级 <legacy_name>.coldfail 目录(冷启动失败备份),仅记日志不改变判定。
let coldfail_dir = dir_path.join(format!("{}.coldfail", summary.name));
if coldfail_dir.is_dir() {
debug!(
"网格点 {} 检测到同级 .coldfail 备份目录,判定途径={}",
summary.name, success_method
);
}
results.push(CandidateResult {
legacy_name: summary.name.clone(),
summary_json: content,
seed_path: seed_file,
src_dir: sub_path,
canonical_name: canonical_name.clone(),
success_method: success_method.to_string(),
});
}
@@ -626,6 +651,13 @@ async fn upload_results_to_server(
Part::bytes(seed_bytes)
.file_name(format!("{}.7", c.canonical_name))
.mime_str("application/octet-stream")?,
)
// 收敛途径(cold_run/seed_step):服务端据此写 grid_points.success_method
// 让导入点融入冷启动/种子步进统计而非独立 imported 分类。
// Part::text 需 'static 生命周期,克隆一份脱离 candidates 借用。
.part(
"success_method",
Part::text(c.success_method.clone()).mime_str("text/plain")?,
);
let mut req = client.post(&import_url);
@@ -678,16 +710,15 @@ fn urlencoding(s: &str) -> String {
/// `initialize_grid` 的 `model_name()` 逐字符一致,必须用配置权威名覆盖旧 name。
/// 解析为 `serde_json::Value` 后仅替换 name,其余字段(params/stages/收敛状态等)原样保留。
fn rewrite_summary_name(summary_json: &str, canonical_name: &str) -> Result<String> {
let mut val: serde_json::Value =
serde_json::from_str(summary_json).context("解析旧 conv.json 失败")?;
let obj = val
.as_object_mut()
.context("旧 conv.json 顶层不是 JSON 对象,无法重写 name")?;
obj.insert(
"name".to_string(),
serde_json::Value::String(canonical_name.to_string()),
);
Ok(serde_json::to_string(&val)?)
// 解析成 ModelSummary 结构体(与本项目计算生成 conv.json 同源),改 name 后用
// to_string_pretty 序列化——字段按 ModelSummary 声明顺序、pretty 缩进,与
// runner.rs:597 本项目计算写出的 conv.json 格式完全一致,使导入产物与正常计算产物统一。
// 注:ModelSummary 未开 deny_unknown_fields,旧 conv.json 里 ModelSummary 未定义的
// 额外字段会被丢弃——这正是"对齐本项目"的预期(本项目计算的 conv.json 本就不含这些字段)。
let mut summary: ModelSummary =
serde_json::from_str(summary_json).context("解析旧 conv.json 为 ModelSummary 失败")?;
summary.name = canonical_name.to_string();
Ok(serde_json::to_string_pretty(&summary)?)
}
#[cfg(test)]
@@ -896,21 +927,36 @@ mod tests {
#[test]
fn test_rewrite_summary_name_changes_only_name() {
let original = r#"{"name":"t20000_g5_he-2_c-4_n-4_o-4","params":{"teff":20000.0,"logg":5.0,"loghe":-2.0,"logc":-4.0,"logn":-4.0,"logo":-4.0},"converged":true,"final_max_relc":0.001,"atmosphere_has_nan":false}"#;
// 夹具含 stagesModelSummary.stages 为必需字段,无 serde default)。
let original = r#"{"name":"t20000_g5_he-2_c-4_n-4_o-4","params":{"teff":20000.0,"logg":5.0,"loghe":-2.0,"logc":-4.0,"logn":-4.0,"logo":-4.0},"stages":[{"label":"nl","lte":"F","converged":true,"elapsed_sec":120.0}],"converged":true,"final_max_relc":0.001,"atmosphere_has_nan":false}"#;
let rewritten = rewrite_summary_name(original, "t20000_g5.0_he-2_c-4_n-4_o-4").unwrap();
let v: serde_json::Value = serde_json::from_str(&rewritten).unwrap();
assert_eq!(v["name"].as_str().unwrap(), "t20000_g5.0_he-2_c-4_n-4_o-4");
assert_eq!(v["params"]["logg"].as_f64().unwrap(), 5.0);
assert!(v["converged"].as_bool().unwrap());
assert_eq!(v["final_max_relc"].as_f64().unwrap(), 0.001);
// 格式对齐本项目计算生成的 conv.jsonrunner.rs:597 to_string_pretty(&ModelSummary)):
// pretty 多行缩进 + 字段按 ModelSummary 声明顺序(name 为首字段,非字母序)。
assert!(rewritten.contains('\n'), "应为 pretty 多行格式而非紧凑单行");
assert!(
rewritten.trim_start().starts_with("{\n \"name\""),
"首字段应为 name(结构体声明顺序),实际开头: {}",
&rewritten[..rewritten.len().min(40)]
);
}
#[test]
fn test_rewrite_summary_name_rejects_non_object() {
// 非对象 JSON(数组/标量)应报错,而非静默放过
fn test_rewrite_summary_name_rejects_invalid_summary() {
// 非合法 ModelSummary(数组/标量/缺必需字段 stages)应报错,而非静默放过
assert!(rewrite_summary_name("[1,2,3]", "x").is_err());
assert!(rewrite_summary_name("42", "x").is_err());
assert!(rewrite_summary_name("\"hello\"", "x").is_err());
// 缺必需字段 stages → 解析为 ModelSummary 失败
assert!(rewrite_summary_name(
r#"{"name":"x","params":{"teff":1.0,"logg":1.0,"loghe":0.0,"logc":0.0,"logn":0.0,"logo":0.0},"converged":true,"atmosphere_has_nan":false}"#,
"x"
)
.is_err());
}
#[test]