feat(all): 源精度命名体系、工作流可观测台、节点停用管理与白名单归档

核心变更:

  1. GridAxisValue 源精度命名
     - 新增 GridAxisValue 类型,携带 f64 数值 + YAML 源书写文本(Deref 透明兼容算术)
     - config.rs 绕过 serde_yaml 归一化,逐 token 捕获轴值原文(logg: 5.0 → g5.0)
     - runner/executor/scheduler 全链路改用 DB TEXT 列权威 point_name,
       修复 REAL 列回读丢精度导致的 model_name 错配

  2. 工作流执行可观测台
     - 新增 stats/progress/points 三组 API(进度时间序列、经验速率 ETA、
       停滞预警、逐点明细分页、收敛性热力图数据)
     - 新增 workflow_progress_snapshots 表 + tasks/grid_points 耗时列
     - runner 携带 last_iter/worst_depth/n_depths 进 conv.json
     - 前端新增 hash 路由、工作流详情页(概览/网格点/收敛分析三 Tab)、YAML 编辑器

  3. 节点停用/启用管理
     - 新增 disabled 状态 + disable/enable API;停用节点保持心跳但停止分发,
       worker 空闲待命而非退出;移除 revoke API,token 失效统一走重发覆盖;
       移除 host_name 字段

  4. 白名单结果归档
     - 新增 result_filter 模块,只归档有语义产物,丢弃 Tlusty 中间单元(~2MB/模型)
     - executor 原子写入归档 + 200 点 LRU 上限

  5. 历史数据导入
     - sync_seeds 重写为 import_results:经 /admin/import_seed 标记 converged +
       按新版命名迁移产物树

  6. 部署与目录重规划
     - data/results→seeds、data/archive→result + migrate_data_dirs.sh
     - deploy.sh 增强(SSH 复用、Profile、远程 env);Dockerfile 瘦身

  7. 文档同步更新 api/database/architecture/deployment
This commit is contained in:
fmq
2026-07-31 01:34:05 +08:00
parent b91f1e4fa5
commit 1bfa240cb0
73 changed files with 12332 additions and 1608 deletions
+109 -3
View File
@@ -125,6 +125,7 @@ impl<'a> ExecutionRunner<'a> {
pub async fn run_model(
&self,
params: &GridPointParams,
name: &str,
task_type: TaskType,
custom_chain: Option<Vec<StageConfig>>,
seed_atmos: Option<&Path>,
@@ -132,6 +133,7 @@ impl<'a> ExecutionRunner<'a> {
) -> Result<ModelSummary> {
self.run_model_with_timeout(
params,
name,
task_type,
custom_chain,
seed_atmos,
@@ -141,17 +143,32 @@ impl<'a> ExecutionRunner<'a> {
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn run_model_with_timeout(
&self,
params: &GridPointParams,
name: &str,
task_type: TaskType,
custom_chain: Option<Vec<StageConfig>>,
seed_atmos: Option<&Path>,
synspec_cfg: Option<&SynspecConfig>,
timeout_sec: u64,
) -> Result<ModelSummary> {
let name = params.model_name();
let model_dir = self.work_dir.join(&name);
// `name` 取自权威的 TaskSpec.point_nameDB 的 grid_points.name 列,源精度正确),
// 而非 params.model_name()。原因:服务端把 GridPointParams 存成 6 个 REAL 数值列,
// 回读时用 from_value() 反推文本会丢精度("5.0"→"5"),导致 params.model_name()
// 产出错误名(g5 而非 g5.0)。point_name 走独立 TEXT 列,精度全程保留。
// 下游(沙盒子目录、各阶段快照、conv.json.name、归档目录)全部用此 name,
// 故只需在此处用权威 name 即可让整条链精度正确。
let derived = params.model_name();
if derived != name {
warn!(
"网格点权威名 {} 与 params 重推名 {} 不一致(DB REAL 列回读丢精度所致),\
采用权威 point_name",
name, derived
);
}
let model_dir = self.work_dir.join(name);
tokio::fs::create_dir_all(&model_dir).await?;
info!("开始物理计算网格模型 {} (类型: {:?})", name, task_type);
@@ -279,6 +296,9 @@ impl<'a> ExecutionRunner<'a> {
best_max_relc: None,
elapsed_sec: stage_t0.elapsed().as_secs_f64(),
note: None,
last_iter: None,
worst_depth: None,
n_depths: None,
};
if rc == 0 && fort7.is_file() {
@@ -287,6 +307,11 @@ impl<'a> ExecutionRunner<'a> {
let res = check_fort9(&fort9, eff_chmax);
stage_summary.converged = res.converged;
stage_summary.best_max_relc = Some(res.max_relc);
// 携带迭代诊断量进 conv.json(旧版在此处丢弃):
// 迭代数/最差深度点供详情页阶段链展示收敛难度。
stage_summary.last_iter = res.last_iter;
stage_summary.worst_depth = Some(res.worst_depth);
stage_summary.n_depths = Some(res.n_depths);
// Save fort.9 snapshot
let snap_name = format!("{}.{}_chmax{}.9", name, stage_def.label, eff_chmax);
@@ -306,6 +331,30 @@ impl<'a> ExecutionRunner<'a> {
stage_summary.note = Some(format!("tlusty rc={} or missing fort.7", rc));
}
// 快照本阶段的同名输入/输出文件,带阶段标签保留。
// 背景:.5/.6/.err/nst 在每阶段用同名文件覆盖,若不快照则只有最后阶段(nl)
// 的版本能存活到归档,nc 等中间阶段的日志/输入会丢失。失败阶段的日志对排错
// 尤其重要,因此此处无条件(不论 rc 是否为 0)快照。
// 命名风格与上方 .7/.9 快照一致:<name>.<label>.<后缀>(单 name,不重复)。
// 注意:stage_def.label 由配置保证唯一(lte/nc/nl/seed_nc),不会与 synspec 产物冲突。
//
// 不快照 fort.9:上方 L292 已把 fort.9 收敛诊断存为 `<name>.<label>_chmax*.9`
// (带 chmax 阈值语义),再快照成 `<name>.<label>.9` 会与它内容完全重复。
// 故 .9 收敛诊断只保留 `_chmax*.9` 一份,不留重复快照。
// (suffix, full_src_name) —— suffix 用于快照名后缀,full_src_name 用于定位源文件
for (suffix, full_name) in [
("5", format!("{}.5", name)),
("6", format!("{}.6", name)),
("err", format!("{}.err", name)),
("nst", "nst".to_string()),
] {
let src = model_dir.join(&full_name);
if src.is_file() {
let snap = model_dir.join(format!("{}.{}.{}", name, stage_def.label, suffix));
let _ = tokio::fs::copy(&src, &snap).await;
}
}
final_chmax = stage_def.chmax;
final_converged = stage_summary.converged;
if let Some(r) = stage_summary.best_max_relc {
@@ -429,9 +478,28 @@ impl<'a> ExecutionRunner<'a> {
synspec_err = Some("No atmosphere .7 produced".to_string());
}
// 清理冗余的裸文件:这些文件的内容已被带阶段标签的快照或重命名的科学产物覆盖,
// 保留它们只会与归档里的 <name>.<label>.* / <name>.iden / <name>.cont 等重复(尤其
// .spec/.cont 是大文件,双份存储浪费磁盘)。删除后归档目录干净无冗余。
// 注意:fort.8synspec 输入大气)和 fort.55synspec 控制卡)有独立语义,予以保留。
for redundant in [
format!("{}.5", name), // 同 <name>.<最后阶段label>.5
format!("{}.6", name), // 同 <name>.<最后阶段label>.6
format!("{}.err", name), // 同 <name>.<最后阶段label>.err
"nst".to_string(), // 同 <name>.<最后阶段label>.nst
"fort.9".to_string(), // 内容已被 <name>.<label>_chmax*.9 收敛诊断覆盖
"fort.12".to_string(), // 同 <name>.idensynspec 谱线证认)
"fort.17".to_string(), // 同 <name>.contsynspec 连续谱)
] {
let p = model_dir.join(&redundant);
if p.is_file() {
let _ = tokio::fs::remove_file(&p).await;
}
}
let elapsed_sec = t0.elapsed().as_secs_f64();
let summary = ModelSummary {
name,
name: name.to_string(),
params: params.clone(),
stages: stage_summaries,
converged: final_converged,
@@ -460,6 +528,8 @@ impl<'a> ExecutionRunner<'a> {
#[cfg(test)]
mod tests {
use crate::models::{GridAxisValue, GridPointParams};
#[test]
fn test_synspec_timeout_calculation() {
let long_tlusty_timeout: u64 = 7200;
@@ -470,4 +540,40 @@ mod tests {
let synspec_timeout_short = 600_u64.min(short_tlusty_timeout);
assert_eq!(synspec_timeout_short, 300);
}
/// 回归测试:复现命名精度丢失场景,并锁定「runner 用 point_name 作权威名」的契约。
///
/// 背景:服务端 grid_points 表把 GridPointParams 存成 6 个 REAL 列,回读时用
/// `GridAxisValue::from_value()` 反推文本(`format_float_minimal`),整数-valued
/// 浮点数会丢小数(5.0 → "5")。于是 `params.model_name()` 产出 `g5` 而非 `g5.0`。
/// 而 `TaskSpec.point_name`DB 的 name TEXT 列,源精度)始终是 `g5.0`。
///
/// runner 的 `run_model_with_timeout` 现接收外部 `name: &str`(由 executor 传入
/// `task.point_name`),不再用降级的 `params.model_name()`。本测试构造降级后的
/// params,证明二者确实不同,从而确认「必须用 point_name」的修复是必要的。
#[test]
fn test_point_name_bypasses_degraded_params_model_name() {
// 模拟 DB REAL 列回读后的 paramslogg 经 from_value(5.0) 丢精度
let degraded = GridPointParams {
teff: GridAxisValue::from_value(20000.0),
logg: GridAxisValue::from_value(5.0), // text 退化为 "5"
loghe: GridAxisValue::from_value(-2.0),
logc: GridAxisValue::from_value(-4.0),
logn: GridAxisValue::from_value(-4.0),
logo: GridAxisValue::from_value(-4.0),
};
// 权威 point_nameDB name 列,保留源精度)
let point_name = "t20000_g5.0_he-2_c-4_n-4_o-4";
// 降级的 params 重推出的名字丢了 ".0"
assert_ne!(
degraded.model_name(),
point_name,
"降级 params.model_name() 应与权威 point_name 不同(这是 bug 的可观测证据)"
);
assert_eq!(degraded.model_name(), "t20000_g5_he-2_c-4_n-4_o-4");
// runner 现在直接采用 point_name(不再调 params.model_name()),故归档/产物名正确
let authoritative_name = point_name; // 即 executor 传入的 task.point_name
assert_eq!(authoritative_name, "t20000_g5.0_he-2_c-4_n-4_o-4");
}
}