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
+84 -42
View File
@@ -5,7 +5,7 @@ use common::models::{ModelSummary, TaskSpec, TaskType};
use common::runner::ExecutionRunner;
use reqwest::Client;
use std::path::{Path, PathBuf};
use tracing::{info, warn};
use tracing::{debug, info, warn};
pub async fn execute_task(
client: &Client,
@@ -13,6 +13,7 @@ pub async fn execute_task(
runtime: &RuntimePaths,
work_dir: &Path,
task: &TaskSpec,
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Result<(ModelSummary, Option<Vec<u8>>)> {
info!(
"开始执行计算任务 {} (网格点: {})",
@@ -48,46 +49,80 @@ pub async fn execute_task(
let mut seed_atmos_path: Option<PathBuf> = None;
// 2. If seed_step, download seed .7 file from server using atomic file rename
if task.task_type == TaskType::SeedStep {
if let Some(ref seed_name) = task.seed_point_name {
let seed_url = format!("{}/api/seed/{}", server_url, seed_name);
info!("正在从服务端下载种子大气文件: {}", seed_url);
match client.get(&seed_url).send().await {
Ok(resp) if resp.status().is_success() => {
if let Ok(bytes) = resp.bytes().await {
let temp_seed_dir = work_dir.join(".seed_cache");
tokio::fs::create_dir_all(&temp_seed_dir).await?;
// LRU 上限清理:下载新种子前,删除最旧的超出 MAX_SEED_CACHE_FILES 的
// .seed.7 文件,防止长期运行后不同种子点累积到 GB 级。同名种子会被
// 覆盖写,真正累积的维度是「不同 seed_name」的数量。
cleanup_seed_cache(&temp_seed_dir).await;
let tmp_path = temp_seed_dir.join(format!(
"{}.{}.tmp",
seed_name,
uuid::Uuid::new_v4().simple()
));
let final_seed_path = temp_seed_dir.join(format!("{}.seed.7", seed_name));
tokio::fs::write(&tmp_path, bytes).await?;
tokio::fs::rename(&tmp_path, &final_seed_path).await?;
seed_atmos_path = Some(final_seed_path);
}
}
Ok(resp) => {
warn!("下载种子文件失败: HTTP {}", resp.status());
}
Err(e) => {
warn!("下载种子文件失败: {}", e);
}
}
}
}
// 3. Isolated task sandbox directory per slot to prevent multi-slot race collisions
// 提前创建 per-slot 隔离沙盒目录:种子下载后需复制一份私有副本进沙盒(见下方),
// 故沙盒必须先于种子下载就绪。
let slot_work_dir = work_dir.join(format!("task_{}", task.task_id));
tokio::fs::create_dir_all(&slot_work_dir).await?;
// 2. If seed_step, download seed .7 file from server using atomic file rename.
// 调度入口已改为「冷启动优先」,SeedStep 仅作为冷启动失败后的救援任务出现,服务端
// 派发前已经 find_best_seed_from_db 确认种子存在于 DB。因此下载失败(缺种子名/HTTP
// 错误/网络异常/响应体读取失败)按硬错误处理,直接失败该任务(fail-fast),不再无种
// 子继续运行:default_seed_chain 首阶段 seed_nc 的 ltgray="F" 依赖 fort.8,无种子时
// Tlusty 在无初始大气下运行必然崩溃。任务失败后由服务端走既有上报路径,
// has_seed_step_attempt 阻止重复回退,网格点保持 failed 终态。
if task.task_type == TaskType::SeedStep {
let seed_name = task.seed_point_name.as_deref().ok_or_else(|| {
anyhow::anyhow!(
"SeedStep 任务 {} 缺少 seed_point_name,无法热启动",
task.point_name
)
})?;
let seed_url = format!("{}/api/seed/{}", server_url, seed_name);
info!("正在从服务端下载种子大气文件: {}", seed_url);
let resp = client.get(&seed_url).send().await.map_err(|e| {
anyhow::anyhow!(
"SeedStep 任务 {} 下载种子文件 {} 失败: {}",
task.point_name,
seed_url,
e
)
})?;
if !resp.status().is_success() {
anyhow::bail!(
"SeedStep 任务 {} 下载种子文件 {} 失败: HTTP {}",
task.point_name,
seed_url,
resp.status()
);
}
let bytes = resp.bytes().await.map_err(|e| {
anyhow::anyhow!(
"SeedStep 任务 {} 读取种子文件 {} 响应体失败: {}",
task.point_name,
seed_url,
e
)
})?;
let temp_seed_dir = work_dir.join(".seed_cache");
tokio::fs::create_dir_all(&temp_seed_dir).await?;
// LRU 上限清理:下载新种子前,删除最旧的超出 MAX_SEED_CACHE_FILES 的
// .seed.7 文件,防止长期运行后不同种子点累积到 GB 级。同名种子会被
// 覆盖写,真正累积的维度是「不同 seed_name」的数量。
cleanup_seed_cache(&temp_seed_dir).await;
let tmp_path = temp_seed_dir.join(format!(
"{}.{}.tmp",
seed_name,
uuid::Uuid::new_v4().simple()
));
let final_seed_path = temp_seed_dir.join(format!("{}.seed.7", seed_name));
tokio::fs::write(&tmp_path, bytes).await?;
tokio::fs::rename(&tmp_path, &final_seed_path).await?;
// 关键:复制一份种子到本任务沙盒私有副本,让 seed_atmos_path
// 指向私有副本而非共享缓存。此后 runner 的 current_seed 全程
// 只引用沙盒内文件,与 .seed_cache 完全解耦——这样 LRU 清理
// (含并发竞争)即便删掉该缓存文件,也不会破坏正在使用该种子
// 的 in-flight 任务。.seed_cache 退化为纯粹的下载去重缓存。
let private_seed = slot_work_dir.join("seed_atmos.seed.7");
tokio::fs::copy(&final_seed_path, &private_seed).await?;
seed_atmos_path = Some(private_seed);
}
// 3. slot_work_dir 已在种子下载前提前创建,种子私有副本亦已落盘于沙盒内。)
let runner = ExecutionRunner::new(runtime, slot_work_dir.clone());
let summary = runner
.run_model_with_timeout(
@@ -96,10 +131,13 @@ pub async fn execute_task(
// 而非 task.params.model_name()(后者经 DB REAL 列回读已丢精度 "5.0"→"5")。
&task.point_name,
task.task_type.clone(),
// custom_chain 恒为 None:执行链由 task_type 决定(ColdRun→default_cold_chain、
// SeedStep→default_seed_chain),节点端不再做「缺种子回退冷启动链」的降级。
None,
seed_atmos_path.as_deref(),
None,
task.timeout_sec,
shutdown,
)
.await?;
@@ -368,10 +406,14 @@ pub async fn cleanup_seed_cache(seed_dir: &Path) {
entries.sort_by_key(|(mtime, _)| *mtime);
let to_remove = entries.len().saturating_sub(MAX_SEED_CACHE_FILES);
for (_, path) in entries.into_iter().take(to_remove) {
if let Err(e) = tokio::fs::remove_file(&path).await {
warn!("清理种子缓存文件 {} 失败: {}", path.display(), e);
} else {
info!("LRU 清理种子缓存文件: {}", path.display());
match tokio::fs::remove_file(&path).await {
Ok(()) => info!("LRU 清理种子缓存文件: {}", path.display()),
// 文件已被并发删除(多 slot 同时清理同一批最旧文件):清理目标已达成,
// 视为成功,不再误报 warn。其他真实 IO 错误才需要告警。
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
debug!("种子缓存文件已被并发删除: {}", path.display())
}
Err(e) => warn!("清理种子缓存文件 {} 失败: {}", path.display(), e),
}
}
}