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:
@@ -21,6 +21,6 @@ reqwest.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["embed-binaries"]
|
||||
default = []
|
||||
embed-binaries = []
|
||||
|
||||
|
||||
+166
-43
@@ -1,15 +1,143 @@
|
||||
use crate::models::GridAxisValue;
|
||||
use anyhow::{Context, Result};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GridAxesConfig {
|
||||
pub teff: Vec<f64>,
|
||||
pub logg: Vec<f64>,
|
||||
pub loghe: Vec<f64>,
|
||||
pub logc: Vec<f64>,
|
||||
pub logn: Vec<f64>,
|
||||
pub logo: Vec<f64>,
|
||||
pub teff: Vec<GridAxisValue>,
|
||||
pub logg: Vec<GridAxisValue>,
|
||||
pub loghe: Vec<GridAxisValue>,
|
||||
pub logc: Vec<GridAxisValue>,
|
||||
pub logn: Vec<GridAxisValue>,
|
||||
pub logo: Vec<GridAxisValue>,
|
||||
}
|
||||
|
||||
/// 解析单个标量 token 为 `GridAxisValue`:保留 YAML 源书写原文(`5.0`→`"5.0"`,
|
||||
/// `20000`→`"20000"`,`-2`→`"-2"`),同时取其 f64 数值。
|
||||
fn axis_value_from_token(tok: &str) -> Option<GridAxisValue> {
|
||||
let tok = tok.trim().trim_matches(',');
|
||||
// 允许的标量形式:可选符号 + 数字(含小数、科学计数)。剔除引号/非法字符。
|
||||
if tok.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cleaned = tok.trim_matches(|c| c == '"' || c == '\'');
|
||||
if cleaned.parse::<f64>().is_ok() {
|
||||
Some(GridAxisValue::from_text(cleaned))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 YAML 文本中按轴名提取**源精度原文 token**,构造 `GridAxesConfig`。
|
||||
///
|
||||
/// # 为什么需要它
|
||||
/// `serde_yaml` 的公开 API 在解析时会把纯标量 `5.0` 解析为 `visit_f64(5.0)`、`20000`
|
||||
/// 解析为 `visit_i64`,**丢失原始书写文本**——而 `model_name()` 必须严格忠于源精度
|
||||
/// (`logg: 5.0` → `g5.0`,不是 `g5`),否则与旧版 Python `gen_input5.model_name`
|
||||
/// 对不上、迁移失败。本函数直接扫 YAML 文本的 `grid:` 块,逐 token 捕获原文,绕过
|
||||
/// serde_yaml 的类型归一化。
|
||||
///
|
||||
/// 支持两种块格式(与 run_grid.py / DCTS 配置一致):
|
||||
/// - 流式:`logg: [5.0, 6.0]`
|
||||
/// - 块式:`logg:\n - 5.0\n - 6.0`
|
||||
fn parse_grid_axes_raw(yaml: &str) -> Option<GridAxesConfig> {
|
||||
let axes_re = {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
// 捕获轴名与该行 `key:` 之后的内容(流式 `[...]` 或空),供后续按行解析块式。
|
||||
RE.get_or_init(|| Regex::new(r"^\s*(teff|logg|loghe|logc|logn|logo)\s*:\s*(.*)$").unwrap())
|
||||
};
|
||||
|
||||
let mut axes: std::collections::HashMap<&str, Vec<GridAxisValue>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut current_axis: Option<&str> = None; // 块式 `- value` 续行归属
|
||||
let lines: Vec<&str> = yaml.lines().collect();
|
||||
|
||||
// 是否已进入 `grid:` 块(grid 块之外的同名键不误捕,尽管实际不会重名)。
|
||||
let mut in_grid = false;
|
||||
|
||||
for (i, raw) in lines.iter().enumerate() {
|
||||
let line = raw.split('#').next().unwrap_or("").trim_end();
|
||||
let stripped = line.trim_start();
|
||||
if stripped.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// 顶层键:grid / 其它。仅顶层(无缩进)键切换 in_grid。
|
||||
if !raw.starts_with(' ') && !raw.starts_with('\t') {
|
||||
in_grid = stripped.starts_with("grid:");
|
||||
current_axis = None;
|
||||
continue;
|
||||
}
|
||||
if !in_grid {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 块式列表续行:` - 5.0`
|
||||
if let Some(rest) = stripped.strip_prefix("- ") {
|
||||
if let Some(axis) = current_axis {
|
||||
if let Some(v) = axis_value_from_token(rest) {
|
||||
axes.entry(axis).or_default().push(v);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 轴定义行:` logg: [5.0, 6.0]` 或 ` logg:`(块式,值在下几行)
|
||||
if let Some(caps) = axes_re.captures(line) {
|
||||
let axis = caps.get(1).unwrap().as_str();
|
||||
let tail = caps.get(2).unwrap().as_str().trim();
|
||||
current_axis = Some(axis);
|
||||
if tail.starts_with('[') {
|
||||
// 流式:`[5.0, 6.0]` —— 可能在单行内闭合,也可能跨行(本配置不会跨行)。
|
||||
let inner = tail.trim_start_matches('[').split(']').next().unwrap_or("");
|
||||
for tok in inner.split(',') {
|
||||
if let Some(v) = axis_value_from_token(tok) {
|
||||
axes.entry(axis).or_default().push(v);
|
||||
}
|
||||
}
|
||||
current_axis = None; // 流式在本行闭合,不再续行
|
||||
}
|
||||
// tail 为空 → 块式,等后续 `- value` 行(current_axis 已设)
|
||||
}
|
||||
// 跨行未闭合的流式列表(grid 块极少如此)不单独处理;YAML 规范允许但本配置不使用。
|
||||
let _ = i;
|
||||
}
|
||||
|
||||
// 六轴齐全才算解析成功;任一缺失回退 None(调用方走纯 serde 数值路径)。
|
||||
let get = |k: &str| axes.get(k).cloned().filter(|v| !v.is_empty());
|
||||
Some(GridAxesConfig {
|
||||
teff: get("teff")?,
|
||||
logg: get("logg")?,
|
||||
loghe: get("loghe")?,
|
||||
logc: get("logc")?,
|
||||
logn: get("logn")?,
|
||||
logo: get("logo")?,
|
||||
})
|
||||
}
|
||||
|
||||
impl GridConfig {
|
||||
pub fn load_from_file(path: &Path) -> Result<Self> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
|
||||
GridConfig::from_yaml_str(&content)
|
||||
.with_context(|| format!("Failed to parse YAML config: {}", path.display()))
|
||||
}
|
||||
|
||||
/// 解析 YAML 配置,**优先用源精度原文重建 grid 轴**。
|
||||
///
|
||||
/// 两步:
|
||||
/// 1. `serde_yaml` 解析整个 `GridConfig`(chain/synspec 等正常字段,grid 轴为数值)。
|
||||
/// 2. 若能从原文捕到六轴 token,用源精度 `GridAxisValue` 覆盖 grid 字段;
|
||||
/// 捕不到(格式异常)则保留 serde 数值结果(命名精度回退,不阻断解析)。
|
||||
pub fn from_yaml_str(yaml: &str) -> Result<Self> {
|
||||
let mut cfg: GridConfig = serde_yaml::from_str(yaml)?;
|
||||
if let Some(raw_axes) = parse_grid_axes_raw(yaml) {
|
||||
cfg.grid = raw_axes;
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -99,6 +227,11 @@ pub struct GridConfig {
|
||||
pub resume: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub seed_step_fallback: bool,
|
||||
/// **已弃用的死字段**:旧版 Python 工具链遗留,无任何代码读取(实际目录以
|
||||
/// `ServerConfig.seeds_dir` / `DCTS_SEEDS_DIR` 为准)。仅因 `deny_unknown_fields`
|
||||
/// 必须能解析而保留。workflow YAML 里仍可写(如 `results: data/seeds`)但被忽略。
|
||||
#[deprecated(note = "死字段,实际目录以 DCTS_SEEDS_DIR 为准")]
|
||||
#[serde(default)]
|
||||
pub results: Option<String>,
|
||||
#[serde(default)]
|
||||
pub itek_fallback: Vec<StageConfig>,
|
||||
@@ -125,22 +258,16 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl GridConfig {
|
||||
pub fn load_from_file(path: &Path) -> Result<Self> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
|
||||
let cfg: GridConfig = serde_yaml::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse YAML config: {}", path.display()))?;
|
||||
Ok(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub bind_addr: String,
|
||||
pub db_path: String,
|
||||
pub queue_db_path: String,
|
||||
pub results_dir: String,
|
||||
/// server 端种子库目录:node 上报/历史导入收敛后,落地 `<name>/conv.json` +
|
||||
/// `<name>/<name>.7` 于此,供 `download_seed` 端点给远程 node 热启动下载。
|
||||
/// **永不清理**(种子是 SeedStep 热启动的必需资源,删除会导致已收敛点重算)。
|
||||
/// 默认 "data/seeds",可经 DCTS_SEEDS_DIR 覆盖(回退读旧 DCTS_RESULTS_DIR)。
|
||||
pub seeds_dir: String,
|
||||
/// 数据库备份目录(每日自动备份落盘位置)。默认 "data/backups",可经 DCTS_BACKUP_DIR 覆盖。
|
||||
pub backup_dir: String,
|
||||
pub grid_config: String,
|
||||
@@ -152,9 +279,6 @@ pub struct ServerConfig {
|
||||
/// Admin 凭据(Dashboard 登录用)。优先 DCTS_ADMIN_TOKEN,回退旧变量 DCTS_AUTH_TOKEN。
|
||||
#[serde(default)]
|
||||
pub admin_token: Option<String>,
|
||||
/// 兼容字段:保留以判断「是否启用鉴权」与旧中间件逻辑。取 admin_token 的值。
|
||||
#[serde(default)]
|
||||
pub auth_token: Option<String>,
|
||||
/// 应急开关:DCTS_AUTH_DISABLE=1 时跳过全部鉴权(仅本地调试,默认关闭)。
|
||||
#[serde(default)]
|
||||
pub auth_disabled: bool,
|
||||
@@ -167,7 +291,7 @@ impl std::fmt::Debug for ServerConfig {
|
||||
.field("bind_addr", &self.bind_addr)
|
||||
.field("db_path", &self.db_path)
|
||||
.field("queue_db_path", &self.queue_db_path)
|
||||
.field("results_dir", &self.results_dir)
|
||||
.field("seeds_dir", &self.seeds_dir)
|
||||
.field("backup_dir", &self.backup_dir)
|
||||
.field("grid_config", &self.grid_config)
|
||||
.field("stale_sec", &self.stale_sec)
|
||||
@@ -178,10 +302,6 @@ impl std::fmt::Debug for ServerConfig {
|
||||
"admin_token",
|
||||
&self.admin_token.as_ref().map(|_| "***REDACTED***"),
|
||||
)
|
||||
.field(
|
||||
"auth_token",
|
||||
&self.auth_token.as_ref().map(|_| "***REDACTED***"),
|
||||
)
|
||||
.field("auth_disabled", &self.auth_disabled)
|
||||
.finish()
|
||||
}
|
||||
@@ -200,8 +320,10 @@ impl Default for ServerConfig {
|
||||
let db_path = std::env::var("DCTS_DB_PATH").unwrap_or_else(|_| "data/dcts.db".to_string());
|
||||
let queue_db_path = std::env::var("DCTS_QUEUE_DB_PATH")
|
||||
.unwrap_or_else(|_| "data/dcts_queue.db".to_string());
|
||||
let results_dir =
|
||||
std::env::var("DCTS_RESULTS_DIR").unwrap_or_else(|_| "data/results".to_string());
|
||||
// 种子库目录:优先 DCTS_SEEDS_DIR,回退旧 DCTS_RESULTS_DIR(已弃用,保留兼容)。
|
||||
let seeds_dir = std::env::var("DCTS_SEEDS_DIR")
|
||||
.or_else(|_| std::env::var("DCTS_RESULTS_DIR"))
|
||||
.unwrap_or_else(|_| "data/seeds".to_string());
|
||||
let backup_dir =
|
||||
std::env::var("DCTS_BACKUP_DIR").unwrap_or_else(|_| "data/backups".to_string());
|
||||
let grid_config = std::env::var("DCTS_GRID_CONFIG")
|
||||
@@ -226,13 +348,10 @@ impl Default for ServerConfig {
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| legacy_token.clone());
|
||||
if legacy_token.is_some()
|
||||
&& (std::env::var("DCTS_ADMIN_TOKEN").is_err()
|
||||
|| std::env::var("DCTS_ENROLLMENT_TOKEN").is_err())
|
||||
{
|
||||
if legacy_token.is_some() && std::env::var("DCTS_ADMIN_TOKEN").is_err() {
|
||||
tracing::warn!(
|
||||
"检测到旧的 DCTS_AUTH_TOKEN,已自动用作 admin/enrollment 凭据。\
|
||||
建议迁移到 DCTS_ADMIN_TOKEN(管理)与 DCTS_ENROLLMENT_TOKEN(节点注册)"
|
||||
"检测到旧的 DCTS_AUTH_TOKEN,已自动回退用作 admin 凭据。\
|
||||
建议迁移到 DCTS_ADMIN_TOKEN"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -245,19 +364,11 @@ impl Default for ServerConfig {
|
||||
);
|
||||
}
|
||||
|
||||
// auth_token 兼容字段:用于 main.rs 判断「是否启用鉴权中间件」。
|
||||
// 启用条件 = 显式配置了 admin 或 enrollment 凭据,且未应急关闭。
|
||||
let auth_token = if auth_disabled {
|
||||
None
|
||||
} else {
|
||||
admin_token.clone()
|
||||
};
|
||||
|
||||
Self {
|
||||
bind_addr: format!("0.0.0.0:{}", port),
|
||||
db_path,
|
||||
queue_db_path,
|
||||
results_dir,
|
||||
seeds_dir,
|
||||
backup_dir,
|
||||
grid_config,
|
||||
stale_sec,
|
||||
@@ -265,7 +376,6 @@ impl Default for ServerConfig {
|
||||
mq_type,
|
||||
rabbitmq_url,
|
||||
admin_token,
|
||||
auth_token,
|
||||
auth_disabled,
|
||||
}
|
||||
}
|
||||
@@ -278,6 +388,13 @@ pub struct NodeConfig {
|
||||
pub max_slots: usize,
|
||||
pub runtime_dir: String,
|
||||
pub work_dir: String,
|
||||
/// node 端完整计算结果归档目录:任务上报成功后、沙盒清理前,把完整科学产物
|
||||
/// (.spec/.cont/.iden/.7/各阶段快照/.6/.err/.log/conv.json 等)拷贝至此,
|
||||
/// 避免随沙盒删除而丢失。与 server 的 `seeds` 目录区分:此处存的是**完整产物**
|
||||
/// (光谱/连续谱/各阶段大气快照等),seeds 只存最小种子集(.7+conv.json)。
|
||||
/// 默认 "data/result",可经 DCTS_RESULT_DIR 覆盖(回退读旧 DCTS_ARCHIVE_DIR)。
|
||||
/// 超过 MAX_RESULT_MODELS 个网格点子目录时按 LRU 删除最旧的。
|
||||
pub result_dir: String,
|
||||
pub heartbeat_sec: u64,
|
||||
}
|
||||
|
||||
@@ -289,6 +406,7 @@ impl std::fmt::Debug for NodeConfig {
|
||||
.field("max_slots", &self.max_slots)
|
||||
.field("runtime_dir", &self.runtime_dir)
|
||||
.field("work_dir", &self.work_dir)
|
||||
.field("result_dir", &self.result_dir)
|
||||
.field("heartbeat_sec", &self.heartbeat_sec)
|
||||
.finish()
|
||||
}
|
||||
@@ -318,6 +436,10 @@ impl Default for NodeConfig {
|
||||
let runtime_dir =
|
||||
std::env::var("DCTS_RUNTIME_DIR").unwrap_or_else(|_| "data/runtime".to_string());
|
||||
let work_dir = std::env::var("DCTS_WORK_DIR").unwrap_or_else(|_| "data/work".to_string());
|
||||
// 结果归档目录:优先 DCTS_RESULT_DIR,回退旧 DCTS_ARCHIVE_DIR(已弃用,保留兼容)。
|
||||
let result_dir = std::env::var("DCTS_RESULT_DIR")
|
||||
.or_else(|_| std::env::var("DCTS_ARCHIVE_DIR"))
|
||||
.unwrap_or_else(|_| "data/result".to_string());
|
||||
let heartbeat_sec = std::env::var("DCTS_HEARTBEAT_SEC")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
@@ -329,6 +451,7 @@ impl Default for NodeConfig {
|
||||
max_slots,
|
||||
runtime_dir,
|
||||
work_dir,
|
||||
result_dir,
|
||||
heartbeat_sec,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,21 +196,21 @@ pub fn make_input5(
|
||||
|
||||
// Atoms block
|
||||
let mut atom_rows: Vec<(i32, String)> = vec![
|
||||
(2, "0.".to_string()), // 1 H
|
||||
(2, fmt_abn(params.loghe)), // 2 He
|
||||
(0, "0.".to_string()), // 3 Li
|
||||
(0, "0.".to_string()), // 4 Be
|
||||
(0, "0.".to_string()), // 5 B
|
||||
(2, "0.".to_string()), // 1 H
|
||||
(2, fmt_abn(*params.loghe)), // 2 He
|
||||
(0, "0.".to_string()), // 3 Li
|
||||
(0, "0.".to_string()), // 4 Be
|
||||
(0, "0.".to_string()), // 5 B
|
||||
];
|
||||
|
||||
if has_c {
|
||||
atom_rows.push((2, fmt_abn(params.logc))); // 6 C
|
||||
atom_rows.push((2, fmt_abn(*params.logc))); // 6 C
|
||||
}
|
||||
if has_n {
|
||||
atom_rows.push((2, fmt_abn(params.logn))); // 7 N
|
||||
atom_rows.push((2, fmt_abn(*params.logn))); // 7 N
|
||||
}
|
||||
if has_o {
|
||||
atom_rows.push((2, fmt_abn(params.logo))); // 8 O
|
||||
atom_rows.push((2, fmt_abn(*params.logo))); // 8 O
|
||||
}
|
||||
|
||||
let natoms =
|
||||
@@ -271,12 +271,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_make_input5() {
|
||||
let params = GridPointParams {
|
||||
teff: 35000.0,
|
||||
logg: 5.5,
|
||||
loghe: -1.0,
|
||||
logc: -2.0,
|
||||
logn: -2.0,
|
||||
logo: -2.0,
|
||||
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 input5 = make_input5(¶ms, "F", "F", "cno", 100);
|
||||
assert!(input5.contains("35000.0 5.5"));
|
||||
|
||||
@@ -6,5 +6,6 @@ pub mod gen_input5;
|
||||
pub mod logging;
|
||||
pub mod models;
|
||||
pub mod nst_writer;
|
||||
pub mod result_filter;
|
||||
pub mod runner;
|
||||
pub mod seed_finder;
|
||||
|
||||
+401
-32
@@ -1,19 +1,122 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::ops::Deref;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 6D grid point parameter specification
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct GridPointParams {
|
||||
pub teff: f64,
|
||||
pub logg: f64,
|
||||
pub loghe: f64,
|
||||
pub logc: f64,
|
||||
pub logn: f64,
|
||||
pub logo: f64,
|
||||
/// 网格轴值:同时携带**数值**(供算术/排序/DB REAL 列)与**源书写文本**(供命名)。
|
||||
///
|
||||
/// # 为什么需要它
|
||||
/// `f64` 无法区分 YAML 里 `5.0`(带小数)与 `5`(省略小数)——两者解析后都是 `5.0_f64`。
|
||||
/// 而 `model_name()` 必须严格忠于源精度("配置里多少位小数就多少位"),否则 Rust 名
|
||||
/// (`t20000_g5_...`) 与 Python `gen_input5.model_name` 名 (`t20000_g5.0_...`) 对不上,
|
||||
/// 旧版单机网格数据无法迁移到本系统。
|
||||
///
|
||||
/// 本类型在反序列化时通过 `deserialize_any` 的 `visit_str` 捕获 YAML/JSON 标量原文,
|
||||
/// 把它一路携带到 `model_name()` 直接拼接;算术则通过 `Deref<Target = f64>` 透明转发到数值,
|
||||
/// 绝大多数 `params.teff` 算术调用点无需改动。
|
||||
///
|
||||
/// # serde 行为
|
||||
/// - **反序列化**:优先抓原文(`5.0`→text=`"5.0"`,`20000`→text=`"20000"`);纯数值来源
|
||||
/// (DB REAL 列回读、JSON 数值 payload)无原文时用数值兜底文本(仅占位,该路径应使用
|
||||
/// 存储的 `name` 列而非 `model_name()` 重推)。
|
||||
/// - **序列化**:输出为纯 `f64` 数值,保证 JSON payload / 旧消费者无感。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct GridAxisValue {
|
||||
value: f64,
|
||||
text: Box<str>,
|
||||
}
|
||||
|
||||
fn fmt_num(val: f64) -> String {
|
||||
impl GridAxisValue {
|
||||
/// 构造:用给定数值,文本由数值生成(兜底路径:DB 回读 / 程序内构造)。
|
||||
pub fn from_value(value: f64) -> Self {
|
||||
let text = format_float_minimal(value).into();
|
||||
Self { value, text }
|
||||
}
|
||||
|
||||
/// 构造:用给定文本,数值由文本解析(反序列化主路径,信任源精度)。
|
||||
pub(crate) fn from_text(text: &str) -> Self {
|
||||
let value = text.parse::<f64>().unwrap_or(f64::NAN);
|
||||
Self {
|
||||
value,
|
||||
text: text.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 数值(算术用)。
|
||||
pub fn value(&self) -> f64 {
|
||||
self.value
|
||||
}
|
||||
|
||||
/// 源书写文本(命名用)。
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
}
|
||||
|
||||
/// `Deref` 到 `f64`:所有 `params.teff` 形式的算术调用点(排序、量化、求和等)
|
||||
/// 无需显式 `.value()` 即可继续工作,把跨 crate 改动量压到最低。
|
||||
impl Deref for GridAxisValue {
|
||||
type Target = f64;
|
||||
fn deref(&self) -> &f64 {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
|
||||
/// 排序委托数值,保证调度排序语义不变。
|
||||
impl PartialOrd for GridAxisValue {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
self.value.partial_cmp(&other.value)
|
||||
}
|
||||
}
|
||||
|
||||
/// 算术运算全部委托内部 f64,使本类型在数值计算上与 f64 完全等价。
|
||||
/// 结果一律退化为纯 f64——轴值只在其作为网格轴字段时才需保留源文本,
|
||||
/// 参与运算后的中间结果无需再保留精度(也不会用于命名)。
|
||||
/// 借用场景(如 `&GridPointParams` 字段相减)请用 `.value()` 显式取值。
|
||||
macro_rules! impl_arith {
|
||||
($trait:ident, $method:ident) => {
|
||||
impl std::ops::$trait for GridAxisValue {
|
||||
type Output = f64;
|
||||
fn $method(self, rhs: Self) -> f64 {
|
||||
self.value.$method(rhs.value)
|
||||
}
|
||||
}
|
||||
impl std::ops::$trait<f64> for GridAxisValue {
|
||||
type Output = f64;
|
||||
fn $method(self, rhs: f64) -> f64 {
|
||||
self.value.$method(rhs)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
impl_arith!(Add, add);
|
||||
impl_arith!(Sub, sub);
|
||||
impl_arith!(Mul, mul);
|
||||
impl_arith!(Div, div);
|
||||
|
||||
/// `Display` 委托数值(用于 `format!("{}", params.x)` 这类数值展示场景,
|
||||
/// 如 gen_input5 的 `{:.1f}` teff/logg 格式化)。命名场景请用 `.text()`。
|
||||
impl std::fmt::Display for GridAxisValue {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
std::fmt::Display::fmt(&self.value, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for GridAxisValue {
|
||||
fn from(value: f64) -> Self {
|
||||
Self::from_value(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for GridAxisValue {
|
||||
fn from(value: i32) -> Self {
|
||||
Self::from_value(value as f64)
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 f64 格式化为最简可解析文本(兜底路径用;源精度路径不走这里)。
|
||||
/// 整数取整无小数,非整数去尾零。
|
||||
fn format_float_minimal(val: f64) -> String {
|
||||
let rounded = (val * 1e6).round() / 1e6;
|
||||
if (rounded - rounded.round()).abs() < 1e-6 {
|
||||
format!("{:.0}", rounded.round())
|
||||
@@ -25,17 +128,85 @@ fn fmt_num(val: f64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for GridAxisValue {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_f64(self.value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for GridAxisValue {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct AxisVisitor;
|
||||
|
||||
impl<'de> serde::de::Visitor<'de> for AxisVisitor {
|
||||
type Value = GridAxisValue;
|
||||
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a numeric grid axis value")
|
||||
}
|
||||
|
||||
// 主路径:YAML/JSON 标量原文(`5.0`、`20000`、`-2`、`"-4"`)。
|
||||
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
|
||||
Ok(GridAxisValue::from_text(v))
|
||||
}
|
||||
|
||||
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Self::Value, E> {
|
||||
Ok(GridAxisValue::from_text(&v))
|
||||
}
|
||||
|
||||
// 兜底路径:纯数值来源(无原文)。先尝试解析原 deserializer 文本不可得,
|
||||
// 这里只能从数值反推文本(占位;该路径应用存储的 name 列)。
|
||||
fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Self::Value, E> {
|
||||
Ok(GridAxisValue::from_value(v))
|
||||
}
|
||||
|
||||
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
|
||||
// 整数来源:文本取整无损(如 JSON 里 `20000`),保留无小数形式。
|
||||
Ok(GridAxisValue::from_text(&v.to_string()))
|
||||
}
|
||||
|
||||
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
|
||||
Ok(GridAxisValue::from_text(&v.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
// deserialize_any:serde_yaml 的标量会先尝试以 str 形式投递给 visit_str,
|
||||
// 从而捕获原文;纯数值 deserializer(如 serde_json 的 f64 字段)会走数值分支。
|
||||
deserializer.deserialize_any(AxisVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
/// 6D grid point parameter specification
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct GridPointParams {
|
||||
pub teff: GridAxisValue,
|
||||
pub logg: GridAxisValue,
|
||||
pub loghe: GridAxisValue,
|
||||
pub logc: GridAxisValue,
|
||||
pub logn: GridAxisValue,
|
||||
pub logo: GridAxisValue,
|
||||
}
|
||||
|
||||
impl GridPointParams {
|
||||
/// Generates canonical model name string e.g. "t35000_g5.5_he-1_c-2_n-2_o-2"
|
||||
/// 生成规范模型名,**严格忠于各轴的源书写精度**:直接拼接 YAML 原文。
|
||||
///
|
||||
/// 例:YAML 中 `teff: [20000]`、`logg: [5.0]`、丰度 `[-2]` → `t20000_g5.0_he-2_c-2_n-2_o-2`。
|
||||
/// 这与旧版 Python `gen_input5.model_name` 逐字符一致,保证旧数据可迁移。
|
||||
pub fn model_name(&self) -> String {
|
||||
format!(
|
||||
"t{}_g{}_he{}_c{}_n{}_o{}",
|
||||
fmt_num(self.teff),
|
||||
fmt_num(self.logg),
|
||||
fmt_num(self.loghe),
|
||||
fmt_num(self.logc),
|
||||
fmt_num(self.logn),
|
||||
fmt_num(self.logo)
|
||||
self.teff.text(),
|
||||
self.logg.text(),
|
||||
self.loghe.text(),
|
||||
self.logc.text(),
|
||||
self.logn.text(),
|
||||
self.logo.text(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,7 +215,7 @@ impl GridPointParams {
|
||||
/// 注:此数值专门用于网格调度中的 Wave 难度分级与保序分组(对数和越小代表重元素丰度越低,
|
||||
/// 通常在大气模型计算中更容易收敛,作为冷启动基准)。
|
||||
pub fn cno_sum(&self) -> f64 {
|
||||
self.logc + self.logn + self.logo
|
||||
*self.logc + *self.logn + *self.logo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +280,10 @@ pub struct TaskSpec {
|
||||
/// 旧数据反序列化时缺省为 None。
|
||||
#[serde(default)]
|
||||
pub workflow_name: Option<String>,
|
||||
/// 网格点所属 wave(难度分批),用于队列内按难度优先出队,恢复"先易后难积累种子"语义。
|
||||
/// 旧 payload 反序列化时缺省为 0。
|
||||
#[serde(default)]
|
||||
pub wave: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -149,7 +324,6 @@ pub struct TaskReport {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeRegisterRequest {
|
||||
pub node_id: String,
|
||||
pub host_name: String,
|
||||
pub max_slots: i32,
|
||||
}
|
||||
|
||||
@@ -166,7 +340,6 @@ pub struct NodeHeartbeatRequest {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeInfo {
|
||||
pub node_id: String,
|
||||
pub host_name: String,
|
||||
pub max_slots: i32,
|
||||
pub active_slots: i32,
|
||||
pub status: String,
|
||||
@@ -197,6 +370,16 @@ pub struct StageSummary {
|
||||
pub best_max_relc: Option<f64>,
|
||||
pub elapsed_sec: f64,
|
||||
pub note: Option<String>,
|
||||
/// 末次迭代序号(fort.9 解析所得);NITER=0 的 grey 阶段为 null。
|
||||
/// 收敛难度直接指标:17 次迭代收敛 vs 顶着 NITER 上限勉强收敛稳定性迥异。
|
||||
#[serde(default)]
|
||||
pub last_iter: Option<i32>,
|
||||
/// 收敛最差的深度点编号(|maximum| 最大处),诊断定位用。
|
||||
#[serde(default)]
|
||||
pub worst_depth: Option<i32>,
|
||||
/// 深度点总数,诊断用。
|
||||
#[serde(default)]
|
||||
pub n_depths: Option<usize>,
|
||||
}
|
||||
|
||||
/// Full execution summary for a grid point
|
||||
@@ -213,6 +396,9 @@ pub struct ModelSummary {
|
||||
pub synspec_rc: Option<i32>,
|
||||
pub synspec_error: Option<String>,
|
||||
pub synspec_sec: Option<f64>,
|
||||
/// 单点总墙钟耗时(秒)。极旧版 conv.json 可能缺此字段,default 0.0 兜底
|
||||
/// (展示层把 ≤0 视为"无数据");现版 run_one.py 总是写入。
|
||||
#[serde(default)]
|
||||
pub elapsed_sec: f64,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
@@ -221,30 +407,213 @@ pub struct ModelSummary {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// StageSummary 新增迭代诊断字段的向后兼容:
|
||||
/// 旧 conv.json(无 last_iter/worst_depth/n_depths)必须能正常反序列化为 null,
|
||||
/// 新写入的 conv.json 往返保真。
|
||||
#[test]
|
||||
fn test_stage_summary_iter_fields_backward_compat() {
|
||||
let legacy = r#"{
|
||||
"label": "nl", "chmax": 0.001, "lte": "F", "converged": true,
|
||||
"best_max_relc": 0.0005, "elapsed_sec": 64.0, "note": null
|
||||
}"#;
|
||||
let st: StageSummary = serde_json::from_str(legacy).unwrap();
|
||||
assert_eq!(st.last_iter, None);
|
||||
assert_eq!(st.worst_depth, None);
|
||||
assert_eq!(st.n_depths, None);
|
||||
|
||||
let full = StageSummary {
|
||||
label: "nl".to_string(),
|
||||
chmax: Some(0.001),
|
||||
lte: "F".to_string(),
|
||||
converged: true,
|
||||
best_max_relc: Some(0.0005),
|
||||
elapsed_sec: 64.0,
|
||||
note: None,
|
||||
last_iter: Some(17),
|
||||
worst_depth: Some(1),
|
||||
n_depths: Some(50),
|
||||
};
|
||||
let round: StageSummary =
|
||||
serde_json::from_str(&serde_json::to_string(&full).unwrap()).unwrap();
|
||||
assert_eq!(round.last_iter, Some(17));
|
||||
assert_eq!(round.worst_depth, Some(1));
|
||||
assert_eq!(round.n_depths, Some(50));
|
||||
}
|
||||
|
||||
/// 旧版 Python run_one.py 写出的 conv.json 必须能完整解析为 ModelSummary
|
||||
/// (历史结果迁移链路 import_results → /api/admin/import_seed 的兼容性命门)。
|
||||
///
|
||||
/// 载荷严格复刻 run_one.py 的真实输出形态:stage 含 `itek_attempts`/`final` 嵌套
|
||||
/// dict、`note`、可选 `best_max_relc`,顶层含 `final_chmax`/`synspec_*`/`seed` 等。
|
||||
/// Rust StageSummary 未声明的字段(itek_attempts/final)应被 serde 静默忽略。
|
||||
#[test]
|
||||
fn test_model_summary_parses_python_legacy_conv_json() {
|
||||
let legacy = r#"{
|
||||
"name": "t20000_g5.0_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": "lte", "chmax": null, "lte": "T",
|
||||
"itek_attempts": [{"itek": null, "rc": 0, "converged": true, "max_relc": 0.0,
|
||||
"note": "NITER=0 grey start (no iterations)"}],
|
||||
"converged": true,
|
||||
"final": {"itek": null, "rc": 0, "converged": true, "max_relc": 0.0,
|
||||
"note": "NITER=0 grey start (no iterations)"},
|
||||
"best_max_relc": 0.0, "elapsed_sec": 2.1},
|
||||
{"label": "nc", "chmax": null, "lte": "F",
|
||||
"itek_attempts": [{"itek": null, "rc": 0, "converged": false, "max_relc": 0.957,
|
||||
"worst_depth": 1, "last_iter": 10, "n_depths": 50}],
|
||||
"converged": false,
|
||||
"final": {"itek": null, "rc": 0, "converged": false, "max_relc": 0.957,
|
||||
"worst_depth": 1, "last_iter": 10, "n_depths": 50},
|
||||
"note": "accepted as seed (convergence not required)",
|
||||
"best_max_relc": 0.957, "elapsed_sec": 62.4},
|
||||
{"label": "nl", "chmax": null, "lte": "F",
|
||||
"itek_attempts": [{"itek": null, "rc": 0, "converged": true, "max_relc": 0.0069,
|
||||
"worst_depth": 1, "last_iter": 17, "n_depths": 50}],
|
||||
"converged": true,
|
||||
"final": {"itek": null, "rc": 0, "converged": true, "max_relc": 0.0069,
|
||||
"worst_depth": 1, "last_iter": 17, "n_depths": 50},
|
||||
"best_max_relc": 0.0069, "elapsed_sec": 650.2}
|
||||
],
|
||||
"converged": true,
|
||||
"final_max_relc": 0.0069,
|
||||
"final_chmax": null,
|
||||
"seed": null,
|
||||
"atmosphere_has_nan": false,
|
||||
"synspec_rc": 0,
|
||||
"synspec_sec": 3.1,
|
||||
"elapsed_sec": 715.0
|
||||
}"#;
|
||||
let s: ModelSummary = serde_json::from_str(legacy).expect("旧版 conv.json 应可解析");
|
||||
assert_eq!(s.name, "t20000_g5.0_he-2_c-4_n-4_o-4");
|
||||
assert_eq!(*s.params.teff, 20000.0);
|
||||
assert!(s.converged);
|
||||
assert!(!s.atmosphere_has_nan);
|
||||
assert_eq!(s.final_max_relc, Some(0.0069));
|
||||
assert_eq!(s.elapsed_sec, 715.0);
|
||||
assert_eq!(s.synspec_rc, Some(0));
|
||||
assert_eq!(s.stages.len(), 3);
|
||||
assert_eq!(s.stages[0].label, "lte");
|
||||
assert_eq!(s.stages[0].lte, "T");
|
||||
assert!(s.stages[0].converged);
|
||||
assert_eq!(s.stages[2].label, "nl");
|
||||
assert_eq!(s.stages[2].best_max_relc, Some(0.0069));
|
||||
assert_eq!(s.stages[2].elapsed_sec, 650.2);
|
||||
// 旧版把迭代诊断嵌在 final/itek_attempts 里(非扁平字段)→ 扁平字段为 None,
|
||||
// 但原始诊断仍随 conv.json 原文落盘(import_seed 存原始 summary_json),无数据丢失。
|
||||
assert_eq!(s.stages[2].last_iter, None);
|
||||
assert_eq!(s.stages[2].worst_depth, None);
|
||||
}
|
||||
|
||||
/// 极旧 conv.json 变体(无 elapsed_sec 字段)也应可解析(default 0.0 兜底),
|
||||
/// 使历史迁移不因缺耗时字段而整点跳过。
|
||||
#[test]
|
||||
fn test_model_summary_tolerates_missing_elapsed() {
|
||||
let minimal = r#"{
|
||||
"name": "t20000_g5.0_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": [],
|
||||
"converged": true,
|
||||
"final_max_relc": 0.001,
|
||||
"seed": null,
|
||||
"atmosphere_has_nan": false
|
||||
}"#;
|
||||
let s: ModelSummary = serde_json::from_str(minimal).expect("缺 elapsed_sec 应可解析");
|
||||
assert_eq!(s.elapsed_sec, 0.0);
|
||||
assert_eq!(s.synspec_rc, None);
|
||||
assert_eq!(s.final_chmax, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_name_formatting_and_decimal_precision() {
|
||||
let p1 = GridPointParams {
|
||||
teff: 35000.0,
|
||||
logg: 5.5,
|
||||
loghe: -1.0,
|
||||
logc: -2.0,
|
||||
logn: -2.0,
|
||||
logo: -2.0,
|
||||
teff: 35000.0.into(),
|
||||
logg: 5.5.into(),
|
||||
loghe: (-1.0).into(),
|
||||
logc: (-2.0).into(),
|
||||
logn: (-2.0).into(),
|
||||
logo: (-2.0).into(),
|
||||
};
|
||||
assert_eq!(p1.model_name(), "t35000_g5.5_he-1_c-2_n-2_o-2");
|
||||
|
||||
let p2 = GridPointParams {
|
||||
teff: 35000.0,
|
||||
logg: 5.25,
|
||||
loghe: -1.5,
|
||||
logc: -2.75,
|
||||
logn: -2.0,
|
||||
logo: -1.25,
|
||||
teff: 35000.0.into(),
|
||||
logg: 5.25.into(),
|
||||
loghe: (-1.5).into(),
|
||||
logc: (-2.75).into(),
|
||||
logn: (-2.0).into(),
|
||||
logo: (-1.25).into(),
|
||||
};
|
||||
assert_eq!(p2.model_name(), "t35000_g5.25_he-1.5_c-2.75_n-2_o-1.25");
|
||||
assert_ne!(p1.model_name(), p2.model_name());
|
||||
}
|
||||
|
||||
/// 回归:YAML 源书写的整数小数位必须原样保留(`logg: 5.0` → `g5.0`,不是 `g5`)。
|
||||
/// 这是旧版单机网格数据迁移的命门——Python `gen_input5.model_name` 用 `g{:.1f}`,
|
||||
/// 故 `t20000_g5.0_he-2_c-4_n-4_o-4` 才是正确名。`f64` 无法区分 `5.0` 与 `5`,
|
||||
/// 必须由 `GridAxisValue` 的源文本携带。
|
||||
#[test]
|
||||
fn test_model_name_preserves_source_decimal_precision() {
|
||||
// 模拟 serde_yaml 解析 grid: { teff: [20000], logg: [5.0], loghe: [-2], ... }
|
||||
// 每个轴值通过 from_text(visit_str 路径)构造,保留原文。
|
||||
let p = GridPointParams {
|
||||
teff: GridAxisValue::from_text("20000"),
|
||||
logg: GridAxisValue::from_text("5.0"),
|
||||
loghe: GridAxisValue::from_text("-2"),
|
||||
logc: GridAxisValue::from_text("-4"),
|
||||
logn: GridAxisValue::from_text("-4"),
|
||||
logo: GridAxisValue::from_text("-4"),
|
||||
};
|
||||
assert_eq!(p.model_name(), "t20000_g5.0_he-2_c-4_n-4_o-4");
|
||||
// 数值正确解析(算术/排序不受影响)
|
||||
assert!((p.teff.value() - 20000.0).abs() < 1e-9);
|
||||
assert!((p.logg.value() - 5.0).abs() < 1e-9);
|
||||
assert!((p.cno_sum() - (-12.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// 端到端:`GridConfig::from_yaml_str` 解析真实 config(含 `grid:` 块),确认
|
||||
/// `logg: 5.0` 的源精度原文被捕获、命名逐字符等于 Python `gen_input5.model_name`。
|
||||
///
|
||||
/// 这是数据迁移的命门:旧版单机网格目录名是 `t20000_g5.0_...`,DCTS 必须产出同名。
|
||||
/// 注:纯 `serde_yaml::from_str` 会把 `5.0` 归一化为 `visit_f64` 丢失原文,故走
|
||||
/// `from_yaml_str` 的源文本捕获路径(见 config.rs `parse_grid_axes_raw`)。
|
||||
#[test]
|
||||
fn test_grid_axis_value_preserves_yaml_source_via_serde() {
|
||||
use crate::config::GridConfig;
|
||||
// 两种块格式都覆盖:流式 + 块式。
|
||||
let yaml_flow = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
|
||||
let yaml_block = "grid:\n teff:\n - 20000\n logg:\n - 5.0\n loghe:\n - -2\n logc:\n - -4\n logn:\n - -4\n logo:\n - -4\n";
|
||||
|
||||
for (label, yaml) in [("flow", yaml_flow), ("block", yaml_block)] {
|
||||
let cfg =
|
||||
GridConfig::from_yaml_str(yaml).unwrap_or_else(|_| panic!("{} 解析失败", label));
|
||||
let pt = GridPointParams {
|
||||
teff: cfg.grid.teff[0].clone(),
|
||||
logg: cfg.grid.logg[0].clone(),
|
||||
loghe: cfg.grid.loghe[0].clone(),
|
||||
logc: cfg.grid.logc[0].clone(),
|
||||
logn: cfg.grid.logn[0].clone(),
|
||||
logo: cfg.grid.logo[0].clone(),
|
||||
};
|
||||
assert_eq!(
|
||||
pt.model_name(),
|
||||
"t20000_g5.0_he-2_c-4_n-4_o-4",
|
||||
"{} 格式应保留源精度",
|
||||
label
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Deref<Target=f64>` 让算术调用点无感:可直接比较/运算。
|
||||
#[test]
|
||||
fn test_grid_axis_value_deref_to_f64() {
|
||||
let v: GridAxisValue = 5.0.into();
|
||||
assert!((*v - 5.0).abs() < 1e-9);
|
||||
assert!(*v > 4.0);
|
||||
let sum = *v + 1.0_f64;
|
||||
assert!((sum - 6.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grid_point_status_display_and_conversion() {
|
||||
assert_eq!(GridPointStatus::Pending.to_string(), "pending");
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
//! 归档白名单:决定沙盒里的哪些文件值得进入持久归档目录(`data/result/<name>/`)。
|
||||
//!
|
||||
//! node 端实时结果归档(`executor::save_result_artifacts`)与离线迁移工具(`import_results`)
|
||||
//! 共用本模块的 [`is_result_worthy`],确保两条路径的结果归档口径一致、不会随时间漂移。
|
||||
//!
|
||||
//! ## 设计原则
|
||||
//!
|
||||
//! 归档目标只保留**有语义价值的产物**,丢弃 Tlusty/Synspec 运行时产生的中间工作单元
|
||||
//! (`fort.1/2/3/13/14/18/22/42/44/50/57/69/82/95` 等)。旧版用「拷贝所有普通文件」的
|
||||
//! catch-all 策略,把这些无语义单元也搬进了归档,浪费磁盘(每个模型约 2MB / 4.8MB)。
|
||||
//!
|
||||
//! ## 保留的文件名形态(`model_name` 为网格点权威名,如 `t20000_g5.0_he-2_c-4_n-4_o-4`)
|
||||
//!
|
||||
//! 1. **裸名保留**(有独立语义,不以 model_name 为前缀):
|
||||
//! `conv.json`、`fort.8`(synspec 输入大气)、`fort.55`(synspec 控制卡)
|
||||
//! 2. **科学核心**:`<name>.7`、`<name>.spec`、`<name>.cont`、`<name>.iden`、`<name>.log`
|
||||
//! 3. **阶段快照**:`<name>.<label>.5/.6/.err/.nst/.7`(label ∈ lte/nc/nl/seed_nc)
|
||||
//! 4. **收敛诊断**:`<name>.<label>_chmax*.9`(**唯一保留的 .9**;裸 `<name>.<label>.9`
|
||||
//! 已在 runner 源头停止写出,因其与 `_chmax*.9` 内容完全重复)
|
||||
//!
|
||||
//! 符号链接、子目录、`.tmp`、`fort.84` 及所有 Tlusty 中间单元均不在白名单内,自然被跳过。
|
||||
|
||||
/// 科学核心产物的文件名后缀(挂在 `<name>.` 之后,无阶段标签)。
|
||||
const SCIENCE_SUFFIXES: &[&str] = &["7", "spec", "cont", "iden", "log"];
|
||||
|
||||
/// 阶段快照的文件名后缀(挂在 `<name>.<label>.` 之后)。
|
||||
const STAGE_SNAPSHOT_SUFFIXES: &[&str] = &["5", "6", "err", "nst", "7"];
|
||||
|
||||
/// 合法阶段标签(来自 `default_cold_chain` / `default_seed_chain` 的 label)。
|
||||
/// 阶段标签由 workflow 配置保证唯一,不会与科学后缀或 synspec 产物冲突。
|
||||
///
|
||||
/// **约束**:此处硬编码了默认链的 4 个标签。runner 的 `run_model_with_timeout`
|
||||
/// 虽接受 `custom_chain`(可含任意 label),但当前唯一生产调用方(executor)传 `None`
|
||||
/// 走默认链,故白名单覆盖安全。若将来启用自定义 chain 且引入新标签,需同步加入此处,
|
||||
/// 否则带新标签的阶段快照(`.5/.6/.err/.nst/.7`)和 `_chmax*.9` 会被白名单静默丢弃。
|
||||
const STAGE_LABELS: &[&str] = &["lte", "nc", "nl", "seed_nc"];
|
||||
|
||||
/// 有独立语义、保留的裸文件名(不以 model_name 为前缀)。
|
||||
const BARE_KEEPS: &[&str] = &["conv.json", "fort.8", "fort.55"];
|
||||
|
||||
/// 判断沙盒里的文件 `fname` 是否值得归档。
|
||||
///
|
||||
/// `model_name` 是网格点的权威名(如 `t20000_g5.0_he-2_c-4_n-4_o-4`),用于识别
|
||||
/// `<name>.<后缀>` 形式的产物。裸名保留(conv.json/fort.8/fort.55)不依赖 model_name。
|
||||
///
|
||||
/// 返回 `true` 表示应归档,`false` 表示应跳过(中间单元、未知后缀、`.tmp`、fort.84 等)。
|
||||
///
|
||||
/// # 示例
|
||||
/// ```
|
||||
/// use common::result_filter::is_result_worthy;
|
||||
/// let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||||
/// assert!(is_result_worthy("conv.json", name));
|
||||
/// assert!(is_result_worthy(&format!("{name}.7"), name));
|
||||
/// assert!(is_result_worthy(&format!("{name}.nl.nst"), name));
|
||||
/// assert!(is_result_worthy(&format!("{name}.nl_chmax0.001.9"), name));
|
||||
/// assert!(!is_result_worthy("fort.18", name));
|
||||
/// assert!(!is_result_worthy("fort.84", name));
|
||||
/// assert!(!is_result_worthy(&format!("{name}.nl.9"), name)); // 无 _chmax 的 .9 已不再写
|
||||
/// ```
|
||||
pub fn is_result_worthy(fname: &str, model_name: &str) -> bool {
|
||||
// 原子写入残留、NATOMS 崩溃缓存:显式排除(虽不在白名单,提前 short-circuit 更清晰)。
|
||||
if fname.ends_with(".tmp") || fname == "fort.84" {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. 裸名保留(有独立语义)。
|
||||
if BARE_KEEPS.contains(&fname) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 其余保留项必须以 `<model_name>.` 开头。不以该前缀开头的文件(含所有其它 fort.N
|
||||
// 中间单元)一律跳过。
|
||||
let prefix = format!("{}.", model_name);
|
||||
let Some(rest) = fname.strip_prefix(&prefix) else {
|
||||
return false;
|
||||
};
|
||||
// 此时 rest 形如:`7` / `spec` / `nl.7` / `nl.nst` / `nl_chmax0.001.9` 等。
|
||||
|
||||
// 2. 科学核心:rest 本身就是后缀(`<name>.7`、`<name>.spec` 等)。
|
||||
if SCIENCE_SUFFIXES.contains(&rest) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. 阶段快照:`<label>.<suffix>`(如 `nl.7`、`nc.nst`)。
|
||||
// 用 split_once('.', label/suffix) 切一刀;label 必须在 STAGE_LABELS 内,
|
||||
// suffix 必须在 STAGE_SNAPSHOT_SUFFIXES 内。这样能精确排除 `<name>.nl.9`
|
||||
// (suffix=9 不在快照后缀集)等。
|
||||
if let Some((label, suffix)) = rest.split_once('.') {
|
||||
if STAGE_LABELS.contains(&label) && STAGE_SNAPSHOT_SUFFIXES.contains(&suffix) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 收敛诊断:`<label>_chmax*.9`(如 `nl_chmax0.001.9`)。
|
||||
// rest 以 `<label>_chmax` 开头,以 `.9` 结尾。这是唯一保留的 .9 形态。
|
||||
if rest.ends_with(".9") {
|
||||
for label in STAGE_LABELS {
|
||||
let tag = format!("{}_chmax", label);
|
||||
if rest.starts_with(&tag) && rest.ends_with(".9") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const NAME: &str = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||||
|
||||
#[test]
|
||||
fn test_bare_keeps() {
|
||||
assert!(is_result_worthy("conv.json", NAME));
|
||||
assert!(is_result_worthy("fort.8", NAME));
|
||||
assert!(is_result_worthy("fort.55", NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_science_core() {
|
||||
for s in ["7", "spec", "cont", "iden", "log"] {
|
||||
let f = format!("{}.{}", NAME, s);
|
||||
assert!(is_result_worthy(&f, NAME), "{} 应归档", f);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stage_snapshots() {
|
||||
// 各阶段标签 × 快照后缀 都应保留
|
||||
for label in STAGE_LABELS {
|
||||
for s in STAGE_SNAPSHOT_SUFFIXES {
|
||||
let f = format!("{}.{}.{}", NAME, label, s);
|
||||
assert!(is_result_worthy(&f, NAME), "{} 应归档", f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convergence_diagnostic_chmax9() {
|
||||
// 唯一保留的 .9:带 _chmax 标签
|
||||
for label in STAGE_LABELS {
|
||||
let f = format!("{}.{}_chmax0.001.9", NAME, label);
|
||||
assert!(is_result_worthy(&f, NAME), "{} 应归档", f);
|
||||
}
|
||||
// 科学计数法形态的 chmax(1e-05)也应保留
|
||||
assert!(is_result_worthy(&format!("{}.nc_chmax1e-05.9", NAME), NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redundant_dot9_without_chmax_skipped() {
|
||||
// 无 _chmax 的阶段 .9 快照:与 _chmax.9 内容重复,跳过
|
||||
for label in STAGE_LABELS {
|
||||
let f = format!("{}.{}.9", NAME, label);
|
||||
assert!(
|
||||
!is_result_worthy(&f, NAME),
|
||||
"{} 应跳过(与 _chmax.9 重复)",
|
||||
f
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tlusty_intermediate_units_skipped() {
|
||||
// 所有 Tlusty 中间工作单元一律跳过(含 0 字节空单元和大文件)
|
||||
for f in [
|
||||
"fort.1", "fort.2", "fort.3", "fort.10", "fort.11", "fort.13", "fort.14", "fort.18",
|
||||
"fort.22", "fort.42", "fort.44", "fort.50", "fort.57", "fort.69", "fort.82", "fort.95",
|
||||
"fort.84",
|
||||
] {
|
||||
assert!(!is_result_worthy(f, NAME), "{} 应跳过", f);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tmp_and_unknown_skipped() {
|
||||
assert!(!is_result_worthy("residue.tmp", NAME));
|
||||
assert!(!is_result_worthy("random_file.txt", NAME));
|
||||
assert!(!is_result_worthy("nst", NAME)); // 裸 nst(应已被 runner 改名)
|
||||
assert!(!is_result_worthy("fort.9", NAME)); // 裸 fort.9(应已被 runner 删除)
|
||||
assert!(!is_result_worthy("fort.12", NAME)); // 裸 fort.12(已 copy 为 .iden)
|
||||
assert!(!is_result_worthy("fort.17", NAME)); // 裸 fort.17(已 copy 为 .cont)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_suffix_with_name_prefix_skipped() {
|
||||
// 以 model_name 开头但后缀不在白名单的也应跳过
|
||||
assert!(!is_result_worthy(&format!("{}.unknown", NAME), NAME));
|
||||
assert!(!is_result_worthy(&format!("{}.nl.foo", NAME), NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_stage_label_skipped() {
|
||||
// 未知的阶段标签不归档(防御性:未来若引入新标签需显式加入 STAGE_LABELS)
|
||||
assert!(!is_result_worthy(&format!("{}.xxx.7", NAME), NAME));
|
||||
assert!(!is_result_worthy(&format!("{}.xxx.nst", NAME), NAME));
|
||||
}
|
||||
}
|
||||
+109
-3
@@ -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_name(DB 的 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.8(synspec 输入大气)和 fort.55(synspec 控制卡)有独立语义,予以保留。
|
||||
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>.iden(synspec 谱线证认)
|
||||
"fort.17".to_string(), // 同 <name>.cont(synspec 连续谱)
|
||||
] {
|
||||
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 列回读后的 params:logg 经 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_name(DB 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@ pub struct SeedMatch {
|
||||
pub const MAX_GLOBAL_SEED_DISTANCE: f64 = 3.0;
|
||||
|
||||
pub fn calculate_seed_distance(cand: &GridPointParams, target: &GridPointParams) -> (bool, f64) {
|
||||
let d_teff = (cand.teff - target.teff).abs();
|
||||
let d_logg = (cand.logg - target.logg).abs();
|
||||
let d_loghe = (cand.loghe - target.loghe).abs();
|
||||
let d_cno = (cand.logc - target.logc).abs()
|
||||
+ (cand.logn - target.logn).abs()
|
||||
+ (cand.logo - target.logo).abs();
|
||||
let d_teff = (cand.teff.value() - target.teff.value()).abs();
|
||||
let d_logg = (cand.logg.value() - target.logg.value()).abs();
|
||||
let d_loghe = (cand.loghe.value() - target.loghe.value()).abs();
|
||||
let d_cno = (cand.logc.value() - target.logc.value()).abs()
|
||||
+ (cand.logn.value() - target.logn.value()).abs()
|
||||
+ (cand.logo.value() - target.logo.value()).abs();
|
||||
|
||||
// exact family 判定:Teff/logg/logHe 视为“同物理族”,仅 CNO 丰度不同。
|
||||
// Teff 容忍度取半步 5000K:实际网格 Teff 档位通常为整数千(20000/30000/.../60000),
|
||||
|
||||
+120
-19
@@ -69,12 +69,13 @@ impl SqliteTaskQueue {
|
||||
created_at DATETIME NOT NULL,
|
||||
claimed_at DATETIME,
|
||||
workflow_name TEXT,
|
||||
claimed_by_node_id TEXT
|
||||
claimed_by_node_id TEXT,
|
||||
wave INTEGER NOT NULL DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
// 兼容旧库:若 task_queue 表已存在但缺少 workflow_name / claimed_by_node_id 列,则补列。
|
||||
// SQLite 的 ALTER TABLE ADD COLUMN 是在线操作,旧数据该列默认 NULL。
|
||||
// 兼容旧库:若 task_queue 表已存在但缺少 workflow_name / claimed_by_node_id / wave 列,则补列。
|
||||
// SQLite 的 ALTER TABLE ADD COLUMN 是在线操作,旧数据该列默认 NULL / 0。
|
||||
// PRAGMA table_info 检测列是否存在以实现幂等 migration。
|
||||
let has_col = |conn: &rusqlite::Connection, col: &str| -> rusqlite::Result<bool> {
|
||||
let mut stmt = conn.prepare("PRAGMA table_info(task_queue)")?;
|
||||
@@ -92,6 +93,12 @@ impl SqliteTaskQueue {
|
||||
if !has_col(&conn, "claimed_by_node_id")? {
|
||||
conn.execute("ALTER TABLE task_queue ADD COLUMN claimed_by_node_id TEXT", [])?;
|
||||
}
|
||||
if !has_col(&conn, "wave")? {
|
||||
conn.execute(
|
||||
"ALTER TABLE task_queue ADD COLUMN wave INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_task_queue_status_created ON task_queue(status, created_at)",
|
||||
[],
|
||||
@@ -100,6 +107,12 @@ impl SqliteTaskQueue {
|
||||
"CREATE INDEX IF NOT EXISTS idx_task_queue_workflow ON task_queue(workflow_name)",
|
||||
[],
|
||||
)?;
|
||||
// 覆盖 pop_task 出队排序:status 固定 'pending' 过滤后,按 wave(全局难度优先)
|
||||
// → created_at(同 wave 内 FIFO)。
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_task_queue_pop ON task_queue(status, wave, created_at)",
|
||||
[],
|
||||
)?;
|
||||
Ok(pool)
|
||||
})
|
||||
.await??;
|
||||
@@ -112,14 +125,15 @@ impl SqliteTaskQueue {
|
||||
let payload = serde_json::to_string(task)?;
|
||||
let task_id_str = task.task_id.to_string();
|
||||
let workflow_name = task.workflow_name.clone();
|
||||
let wave = task.wave;
|
||||
let pool = self.pool.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let conn = pool.get().map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO task_queue (task_id, payload, status, created_at, workflow_name)
|
||||
VALUES (?1, ?2, 'pending', datetime('now'), ?3)",
|
||||
params![task_id_str, payload, workflow_name],
|
||||
"INSERT OR REPLACE INTO task_queue (task_id, payload, status, created_at, workflow_name, wave)
|
||||
VALUES (?1, ?2, 'pending', datetime('now'), ?3, ?4)",
|
||||
params![task_id_str, payload, workflow_name, wave],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
@@ -139,8 +153,14 @@ impl SqliteTaskQueue {
|
||||
let tx_res = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate);
|
||||
match tx_res {
|
||||
Ok(tx) => {
|
||||
// 出队排序:wave ASC → created_at ASC(不按 workflow_name 分组)。
|
||||
// - wave ASC:全局低难度 wave 优先,恢复"先易后难积累种子"的设计意图。
|
||||
// 各工作流的低难度点会先于任何工作流的高难度点被消化,避免某个工作流
|
||||
// 在高难度 wave 上饿死另一个工作流的低难度点。
|
||||
// - created_at ASC:同 wave 内严格 FIFO。调度器按工作流批量推入时,
|
||||
// 多个工作流的任务 created_at 天然交错,故同 wave 内不会单工作流独占。
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT task_id, payload FROM task_queue WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1"
|
||||
"SELECT task_id, payload FROM task_queue WHERE status = 'pending' ORDER BY wave ASC, created_at ASC LIMIT 1"
|
||||
)?;
|
||||
|
||||
let row = stmt.query_row([], |row| {
|
||||
@@ -344,17 +364,18 @@ mod tests {
|
||||
task_id,
|
||||
point_name: "t35000_g5.5_he-1_c-2_n-2_o-2".to_string(),
|
||||
params: GridPointParams {
|
||||
teff: 35000.0,
|
||||
logg: 5.5,
|
||||
loghe: -1.0,
|
||||
logc: -2.0,
|
||||
logn: -2.0,
|
||||
logo: -2.0,
|
||||
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,
|
||||
};
|
||||
queue.push_task(&task).await.unwrap();
|
||||
|
||||
@@ -387,12 +408,12 @@ mod tests {
|
||||
|
||||
let task_id = Uuid::new_v4();
|
||||
let params = GridPointParams {
|
||||
teff: 35000.0,
|
||||
logg: 5.5,
|
||||
loghe: -1.0,
|
||||
logc: -2.0,
|
||||
logn: -2.0,
|
||||
logo: -2.0,
|
||||
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,
|
||||
@@ -402,6 +423,7 @@ mod tests {
|
||||
seed_point_name: None,
|
||||
timeout_sec: 60,
|
||||
workflow_name: None,
|
||||
wave: 0,
|
||||
};
|
||||
queue.push_task(&task).await.unwrap();
|
||||
|
||||
@@ -434,4 +456,83 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(claim_after.is_none());
|
||||
}
|
||||
|
||||
/// 辅助:构造一个最小 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(),
|
||||
},
|
||||
task_type: TaskType::ColdRun,
|
||||
seed_point_name: None,
|
||||
timeout_sec: 60,
|
||||
workflow_name: Some(wf.to_string()),
|
||||
wave,
|
||||
}
|
||||
}
|
||||
|
||||
/// 跨工作流 wave 优先级:wf_a 推多个高 wave 任务,wf_b 推一个低 wave 任务,
|
||||
/// 验证 wf_b 的低难度任务不会被 wf_a 的大量高难度任务饿死。
|
||||
///
|
||||
/// 排序为 wave ASC, created_at ASC(不按 workflow_name 分组):
|
||||
/// wf_b 的 wave=0 任务优先于 wf_a 的所有 wave=9 任务出队,
|
||||
/// 即使 wf_a 的任务 created_at 更早、数量更多。
|
||||
/// 这保证了"全局先易后难"——任一工作流的低难度点优先于其他工作流的高难度点。
|
||||
#[tokio::test]
|
||||
async fn test_pop_cross_workflow_wave_priority() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("pop_fair.db");
|
||||
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// wf_a 推 3 个高难度任务(wave=9,created_at 更早)
|
||||
for i in 0..3 {
|
||||
queue
|
||||
.push_task(&mk_task("wf_a", 9, &format!("a{i}")))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// wf_b 推 1 个低难度任务(wave=0,created_at 最晚)
|
||||
queue.push_task(&mk_task("wf_b", 0, "b0")).await.unwrap();
|
||||
|
||||
// 第 1 次 pop:wf_b 的 wave=0(全局最低 wave 优先),而非 wf_a 的先入任务
|
||||
let p1 = queue.pop_task("n1").await.unwrap().unwrap();
|
||||
assert_eq!(p1.workflow_name.as_deref(), Some("wf_b"));
|
||||
assert_eq!(p1.point_name, "b0");
|
||||
|
||||
// 之后才轮到 wf_a 的 wave=9 任务(FIFO 顺序)
|
||||
for i in 0..3 {
|
||||
let p = queue.pop_task("n2").await.unwrap().unwrap();
|
||||
assert_eq!(p.workflow_name.as_deref(), Some("wf_a"));
|
||||
assert_eq!(p.point_name, format!("a{i}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// 出队同工作流内按 wave ASC:低难度 wave 优先,即使它 created_at 更晚。
|
||||
#[tokio::test]
|
||||
async fn test_pop_wave_priority_within_workflow() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("pop_wave.db");
|
||||
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 先推 wave=5(created_at 早),再推 wave=1(created_at 晚)
|
||||
queue.push_task(&mk_task("wf_x", 5, "hard")).await.unwrap();
|
||||
queue.push_task(&mk_task("wf_x", 1, "easy")).await.unwrap();
|
||||
|
||||
// pop 应先拿到 wave=1 的 easy(若纯 FIFO 会先拿到先入的 hard)
|
||||
let p1 = queue.pop_task("n1").await.unwrap().unwrap();
|
||||
assert_eq!(p1.point_name, "easy", "低 wave 优先出队");
|
||||
let p2 = queue.pop_task("n2").await.unwrap().unwrap();
|
||||
assert_eq!(p2.point_name, "hard");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
common = { path = "../common", features = ["embed-binaries"] }
|
||||
mq = { path = "../mq" }
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
@@ -14,7 +14,6 @@ tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
anyhow.workspace = true
|
||||
sysinfo.workspace = true
|
||||
gethostname.workspace = true
|
||||
clap.workspace = true
|
||||
uuid.workspace = true
|
||||
dotenvy.workspace = true
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use common::result_filter::is_result_worthy;
|
||||
use common::embedded::{ensure_specific_data_files, RuntimePaths};
|
||||
use common::models::{ModelSummary, TaskSpec, TaskType};
|
||||
use common::runner::ExecutionRunner;
|
||||
@@ -91,6 +92,9 @@ pub async fn execute_task(
|
||||
let summary = runner
|
||||
.run_model_with_timeout(
|
||||
&task.params,
|
||||
// 用权威的 point_name(DB grid_points.name 列,源精度正确)作为模型名,
|
||||
// 而非 task.params.model_name()(后者经 DB REAL 列回读已丢精度 "5.0"→"5")。
|
||||
&task.point_name,
|
||||
task.task_type.clone(),
|
||||
None,
|
||||
seed_atmos_path.as_deref(),
|
||||
@@ -148,6 +152,177 @@ pub async fn cleanup_slot_work_dir(slot_work_dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 把单个任务沙盒内的产物拷贝到持久归档目录。
|
||||
///
|
||||
/// 采用**白名单**策略([`is_result_worthy`])而非「拷贝所有普通文件」的 catch-all:
|
||||
/// 只保留有语义价值的产物,丢弃 Tlusty/Synspec 运行时产生的中间工作单元
|
||||
/// (`fort.1/2/3/13/14/18/22/42/44/50/57/69/82/95` 等,旧版 catch-all 会把它们
|
||||
/// 一并搬进归档,每个模型浪费约 2MB / 4.8MB)。
|
||||
///
|
||||
/// 保留内容(详见 [`is_result_worthy`]):
|
||||
/// - 裸名:`conv.json`、`fort.8`(synspec 输入大气)、`fort.55`(synspec 控制卡)
|
||||
/// - 科学核心:`<name>.7/.spec/.cont/.iden/.log`
|
||||
/// - 阶段快照:`<name>.<label>.5/.6/.err/.nst/.7`
|
||||
/// - 收敛诊断:`<name>.<label>_chmax*.9`(**唯一保留的 .9**)
|
||||
///
|
||||
/// 跳过内容:符号链接(`data`/`fort.19` 等共享 runtime 资源)、子目录、`.tmp`、`fort.84`、
|
||||
/// 所有 Tlusty 中间单元、以及不以 `<name>.` 为前缀的无语义裸文件。
|
||||
///
|
||||
/// 任何 IO 错误均降级为 warn,不阻断上报/清理主流程(归档是尽力而为)。
|
||||
///
|
||||
/// `name` 为网格点权威名:取自 summary.name(runner 现用 task.point_name 作权威名),
|
||||
/// 严重失败(runner 抛 Err、无 summary)时回退到 task.point_name,确保失败任务的
|
||||
/// 排错日志也能落盘。
|
||||
pub async fn save_result_artifacts(
|
||||
result_dir: &Path,
|
||||
slot_work_dir: &Path,
|
||||
name: &str,
|
||||
) {
|
||||
let src_dir = slot_work_dir.join(name);
|
||||
if !src_dir.is_dir() {
|
||||
// 模型子目录不存在(极早期失败),无可归档内容
|
||||
return;
|
||||
}
|
||||
let dest_dir = result_dir.join(name);
|
||||
if let Err(e) = tokio::fs::create_dir_all(&dest_dir).await {
|
||||
warn!(
|
||||
"归档网格点 {} 失败:创建归档目录 {} 失败: {}",
|
||||
name,
|
||||
dest_dir.display(),
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut kept = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
let mut skipped_link = 0usize;
|
||||
let mut rd = match tokio::fs::read_dir(&src_dir).await {
|
||||
Ok(rd) => rd,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"归档网格点 {} 失败:读取源目录 {} 失败: {}",
|
||||
name,
|
||||
src_dir.display(),
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
while let Ok(Some(entry)) = rd.next_entry().await {
|
||||
let path = entry.path();
|
||||
let file_name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// 跳过符号链接(指向共享 runtime 资源,不归档)
|
||||
if tokio::fs::symlink_metadata(&path)
|
||||
.await
|
||||
.map(|m| m.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
skipped_link += 1;
|
||||
continue;
|
||||
}
|
||||
// 只归档普通文件(跳过意外的子目录)
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
// 白名单判别:只保留有语义价值的产物,丢弃 Tlusty 中间单元
|
||||
if !is_result_worthy(&file_name, name) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let dest_path = dest_dir.join(&file_name);
|
||||
// 原子写入:先拷到 .result.tmp.<uuid> 再 rename,防止中途崩溃产生半截文件
|
||||
let tmp_path = dest_dir.join(format!("{}.result.tmp.{}", file_name, uuid::Uuid::new_v4().simple()));
|
||||
match tokio::fs::copy(&path, &tmp_path).await {
|
||||
Ok(_) => {
|
||||
if let Err(e) = tokio::fs::rename(&tmp_path, &dest_path).await {
|
||||
// rename 失败则清理 tmp,避免残留
|
||||
let _ = tokio::fs::remove_file(&tmp_path).await;
|
||||
warn!(
|
||||
"归档网格点 {} 的文件 {} rename 失败: {}",
|
||||
name,
|
||||
file_name,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
kept += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(&tmp_path).await;
|
||||
warn!(
|
||||
"归档网格点 {} 的文件 {} 拷贝失败: {}",
|
||||
name,
|
||||
file_name,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"已归档网格点 {} 的产物:保留 {} 个文件到 {}(跳过 {} 个非白名单文件、{} 个符号链接)",
|
||||
name,
|
||||
kept,
|
||||
dest_dir.display(),
|
||||
skipped,
|
||||
skipped_link
|
||||
);
|
||||
}
|
||||
|
||||
/// 归档目录保留的网格点(子目录)数量上限。超过则按 mtime 删除最旧的。
|
||||
/// 200 足以覆盖中等规模网格的完整归档;更大网格可经环境变量或常量调整。
|
||||
const MAX_RESULT_MODELS: usize = 200;
|
||||
|
||||
/// LRU 治理归档目录:当网格点子目录数超过 `MAX_RESULT_MODELS` 时,
|
||||
/// 按 mtime 升序删除最旧的若干个子目录,直到不超过上限。
|
||||
/// 仅统计子目录(每个对应一个网格点),忽略散落文件。错误降级为 warn,不阻断主流程。
|
||||
pub async fn cleanup_result_dir(result_dir: &Path) {
|
||||
let mut entries: Vec<(std::time::SystemTime, PathBuf)> =
|
||||
match tokio::fs::read_dir(result_dir).await {
|
||||
Ok(mut rd) => {
|
||||
let mut v = Vec::new();
|
||||
while let Ok(Some(entry)) = rd.next_entry().await {
|
||||
let path = entry.path();
|
||||
// 仅纳入子目录(网格点归档目录),跳过散落文件
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let meta = match entry.metadata().await {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let mtime = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
||||
v.push((mtime, path));
|
||||
}
|
||||
v
|
||||
}
|
||||
// 归档目录不存在或不可读:无操作(首次归档尚未创建)
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if entries.len() <= MAX_RESULT_MODELS {
|
||||
return;
|
||||
}
|
||||
|
||||
// 按 mtime 升序(最旧在前),删除超出上限的最旧子目录
|
||||
entries.sort_by_key(|(mtime, _)| *mtime);
|
||||
let to_remove = entries.len().saturating_sub(MAX_RESULT_MODELS);
|
||||
for (_, path) in entries.into_iter().take(to_remove) {
|
||||
if let Err(e) = tokio::fs::remove_dir_all(&path).await {
|
||||
warn!("LRU 清理归档目录 {} 失败: {}", path.display(), e);
|
||||
} else {
|
||||
info!("LRU 清理归档目录: {}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `.seed_cache/` 内保留的 `.seed.7` 文件上限。超过则按 mtime 删除最旧的。
|
||||
/// 典型网格内活跃种子点数量有限,8 足以覆盖常用邻域且把磁盘占用控制在 ~8 个种子文件。
|
||||
const MAX_SEED_CACHE_FILES: usize = 8;
|
||||
@@ -204,6 +379,33 @@ pub async fn cleanup_seed_cache(seed_dir: &Path) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use common::models::{GridPointParams, ModelSummary};
|
||||
|
||||
fn make_summary() -> ModelSummary {
|
||||
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(),
|
||||
};
|
||||
ModelSummary {
|
||||
name: params.model_name(),
|
||||
params,
|
||||
stages: vec![],
|
||||
converged: true,
|
||||
final_max_relc: Some(0.0005),
|
||||
final_chmax: None,
|
||||
seed: None,
|
||||
atmosphere_has_nan: false,
|
||||
synspec_rc: Some(0),
|
||||
synspec_error: None,
|
||||
synspec_sec: Some(10.0),
|
||||
elapsed_sec: 120.0,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cleanup_slot_work_dir() {
|
||||
@@ -218,4 +420,164 @@ mod tests {
|
||||
cleanup_slot_work_dir(&temp_dir).await.unwrap();
|
||||
assert!(!temp_dir.exists());
|
||||
}
|
||||
|
||||
/// 验证归档:完整产物被拷贝、符号链接/fort.84/.tmp 被跳过
|
||||
#[tokio::test]
|
||||
async fn test_save_result_artifacts() {
|
||||
let root = std::env::temp_dir().join(format!("test_result_{}", uuid::Uuid::new_v4()));
|
||||
let result_dir = root.join("result");
|
||||
let summary = make_summary();
|
||||
let slot_work_dir = root.join("work");
|
||||
let model_dir = slot_work_dir.join(&summary.name);
|
||||
tokio::fs::create_dir_all(&model_dir).await.unwrap();
|
||||
|
||||
// 应被归档的白名单产物(科学核心 + 阶段快照 + 收敛诊断 + 裸名保留)
|
||||
let kept_files = [
|
||||
format!("{}.7", summary.name), // 最终大气
|
||||
format!("{}.spec", summary.name), // 合成光谱
|
||||
format!("{}.cont", summary.name), // 连续谱
|
||||
format!("{}.iden", summary.name), // 谱线证认
|
||||
format!("{}.log", summary.name), // synspec 日志
|
||||
"conv.json".to_string(), // 摘要
|
||||
"fort.8".to_string(), // synspec 输入大气(裸名保留)
|
||||
"fort.55".to_string(), // synspec 控制卡(裸名保留)
|
||||
format!("{}.nl.7", summary.name), // nl 阶段大气快照
|
||||
format!("{}.nc.7", summary.name), // nc 阶段大气快照
|
||||
format!("{}.nl.5", summary.name), // nl 阶段输入卡快照
|
||||
format!("{}.nl.6", summary.name), // nl 阶段输出日志快照
|
||||
format!("{}.nl.err", summary.name), // nl 阶段错误日志快照
|
||||
format!("{}.nl.nst", summary.name), // nl 阶段控制卡快照
|
||||
format!("{}.nc.nst", summary.name), // nc 阶段控制卡快照
|
||||
format!("{}.nl_chmax0.001.9", summary.name), // nl 收敛诊断(唯一保留的 .9)
|
||||
];
|
||||
// 应被白名单过滤掉的文件:Tlusty 中间单元、裸的 runner 已清理文件、
|
||||
// 无 _chmax 的重复 .9 快照、未知后缀
|
||||
let skipped_files: [String; 14] = [
|
||||
"fort.1".to_string(), // 空单元
|
||||
"fort.13".to_string(), // Tlusty NLTE 跃迁频率网格
|
||||
"fort.18".to_string(), // Tlusty 大气结构内部表
|
||||
"fort.22".to_string(), // Tlusty 中间大气副本
|
||||
"fort.82".to_string(), // Tlusty 运行时诊断表
|
||||
"fort.95".to_string(), // Tlusty 旧模型定义副本
|
||||
"fort.84".to_string(), // NATOMS 崩溃缓存
|
||||
"residue.tmp".to_string(), // 原子写入残留
|
||||
"nst".to_string(), // 裸 nst(runner 已改名为 <name>.<label>.nst)
|
||||
"fort.9".to_string(), // 裸 fort.9(runner 已删,内容在 _chmax.9)
|
||||
"fort.12".to_string(), // 裸 fort.12(已 copy 为 .iden)
|
||||
"fort.17".to_string(), // 裸 fort.17(已 copy 为 .cont)
|
||||
format!("{}.nl.9", summary.name), // 无 _chmax 的 .9 快照(与 _chmax.9 重复)
|
||||
format!("{}.unknown", summary.name), // 未知后缀
|
||||
];
|
||||
for f in kept_files.iter() {
|
||||
tokio::fs::write(model_dir.join(f), "payload")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
for f in skipped_files.iter() {
|
||||
tokio::fs::write(model_dir.join(f), "payload")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// 符号链接(指向共享资源,应被跳过)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link_target = root.join("shared_data");
|
||||
tokio::fs::create_dir_all(&link_target).await.unwrap();
|
||||
std::os::unix::fs::symlink(&link_target, model_dir.join("data")).unwrap();
|
||||
std::os::unix::fs::symlink("/dev/null", model_dir.join("fort.19")).unwrap();
|
||||
}
|
||||
|
||||
save_result_artifacts(&result_dir, &slot_work_dir, &summary.name).await;
|
||||
|
||||
let dest_dir = result_dir.join(&summary.name);
|
||||
assert!(dest_dir.is_dir(), "归档目标目录应被创建");
|
||||
// 验证白名单产物都被拷贝
|
||||
for f in &kept_files {
|
||||
assert!(
|
||||
dest_dir.join(f).is_file(),
|
||||
"白名单产物 {} 应被归档",
|
||||
f
|
||||
);
|
||||
}
|
||||
// 验证非白名单文件未进归档
|
||||
for f in &skipped_files {
|
||||
assert!(
|
||||
!dest_dir.join(f).exists(),
|
||||
"非白名单文件 {} 应被跳过",
|
||||
f
|
||||
);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
assert!(!dest_dir.join("data").exists(), "符号链接 data 应被跳过");
|
||||
assert!(
|
||||
!dest_dir.join("fort.19").exists(),
|
||||
"符号链接 fort.19 应被跳过"
|
||||
);
|
||||
}
|
||||
// 不应有残留的 .result.tmp 文件
|
||||
let mut rd = tokio::fs::read_dir(&dest_dir).await.unwrap();
|
||||
while let Ok(Some(e)) = rd.next_entry().await {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
assert!(
|
||||
!name.contains(".result.tmp"),
|
||||
"不应残留 tmp 文件: {}",
|
||||
name
|
||||
);
|
||||
}
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&root).await;
|
||||
}
|
||||
|
||||
/// 验证 LRU 治理:超过上限时按 mtime 删最旧的子目录
|
||||
#[tokio::test]
|
||||
async fn test_cleanup_result_dir() {
|
||||
let result_dir =
|
||||
std::env::temp_dir().join(format!("test_result_lru_{}", uuid::Uuid::new_v4()));
|
||||
tokio::fs::create_dir_all(&result_dir).await.unwrap();
|
||||
|
||||
// 创建 MAX+10 个子目录,按创建顺序递增 mtime(每个 sleep 制造可测的时间差)。
|
||||
// model_0000 最早创建(最旧),model_0209 最新创建。
|
||||
let total = MAX_RESULT_MODELS + 10;
|
||||
for i in 0..total {
|
||||
let dir = result_dir.join(format!("model_{:04}", i));
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
tokio::fs::write(dir.join("marker"), format!("{}", i))
|
||||
.await
|
||||
.unwrap();
|
||||
// 10ms 间隔足以让多数文件系统的 mtime 分辨出先后顺序
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
cleanup_result_dir(&result_dir).await;
|
||||
|
||||
let mut remaining: Vec<String> = Vec::new();
|
||||
let mut rd = tokio::fs::read_dir(&result_dir).await.unwrap();
|
||||
while let Ok(Some(e)) = rd.next_entry().await {
|
||||
if e.path().is_dir() {
|
||||
remaining.push(e.file_name().to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
// 清理后剩余数量应恰为上限
|
||||
assert_eq!(
|
||||
remaining.len(),
|
||||
MAX_RESULT_MODELS,
|
||||
"清理后应剩余 {} 个,实际 {} 个",
|
||||
MAX_RESULT_MODELS,
|
||||
remaining.len()
|
||||
);
|
||||
// 最旧的那批(model_0000~model_0009)应被删除,最新的 MAX 个应保留
|
||||
remaining.sort();
|
||||
assert!(
|
||||
!remaining.contains(&"model_0000".to_string()),
|
||||
"最旧的 model_0000 应被 LRU 删除"
|
||||
);
|
||||
assert!(
|
||||
remaining.contains(&format!("model_{:04}", total - 1)),
|
||||
"最新的 model_{:04} 应被保留",
|
||||
total - 1
|
||||
);
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&result_dir).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,14 +84,14 @@ pub async fn report_result(
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
// 401/403 表明 node token 已失效/被吊销(非临时故障),重试无意义且会丢结果。
|
||||
// 401/403 表明 node token 已失效(被重发覆盖,非临时故障),重试无意义且会丢结果。
|
||||
// 立即 bail 并打 error,与 claim_task 侧口径统一,提示运维介入。
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
tracing::error!(
|
||||
"上报任务 {} 被服务端拒绝 (HTTP {}):node token 可能已失效或被吊销,请检查并清理 .node_token 文件后重启节点以重新向服务端发起注册审批,停止重试",
|
||||
"上报任务 {} 被服务端拒绝 (HTTP {}):node token 已失效(已被重发覆盖),请用新 token 更新 .node_token 后重启节点,停止重试",
|
||||
task.task_id, status
|
||||
);
|
||||
anyhow::bail!("node token 失效或被吊销 (HTTP {}),结果未上报", status);
|
||||
anyhow::bail!("node token 失效 (HTTP {}),结果未上报", status);
|
||||
}
|
||||
warn!(
|
||||
"向服务端上报任务 {} 结果失败 (尝试 {}/{}): HTTP {}",
|
||||
|
||||
+91
-17
@@ -12,6 +12,25 @@ use std::sync::Arc;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// 领用请求的归一化结果。
|
||||
///
|
||||
/// 区分「被管理员停用」与「暂无任务」:前者节点保持存活、空闲待命(拉长轮询),
|
||||
/// 后者按常规节奏轮询。服务端返回 `{"status":"disabled"}` 映射为 `Disabled`。
|
||||
enum ClaimOutcome {
|
||||
/// 成功领用到任务。
|
||||
Task(TaskSpec),
|
||||
/// 暂无排队任务。
|
||||
Empty,
|
||||
/// 节点被管理员手动停用,不再分发任务。
|
||||
Disabled,
|
||||
}
|
||||
|
||||
/// 拼接 token 文件的绝对路径,供 401 退出日志给出可操作的恢复位置。
|
||||
fn token_file_display(runtime_dir: &str) -> String {
|
||||
let p = std::path::Path::new(runtime_dir).join(".node_token");
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
pub struct NodeWorker {
|
||||
config: NodeConfig,
|
||||
client: Client,
|
||||
@@ -43,7 +62,6 @@ impl NodeWorker {
|
||||
|
||||
let req = NodeRegisterRequest {
|
||||
node_id: node_id.to_string(),
|
||||
host_name: gethostname::gethostname().to_string_lossy().to_string(),
|
||||
max_slots: 0,
|
||||
};
|
||||
|
||||
@@ -122,7 +140,6 @@ impl NodeWorker {
|
||||
|
||||
let req = NodeRegisterRequest {
|
||||
node_id: self.config.node_id.clone(),
|
||||
host_name: gethostname::gethostname().to_string_lossy().to_string(),
|
||||
max_slots: self.config.max_slots as i32,
|
||||
};
|
||||
|
||||
@@ -153,6 +170,7 @@ impl NodeWorker {
|
||||
let hb_node_id = self.config.node_id.clone();
|
||||
let hb_slots = self.active_slots.clone();
|
||||
let hb_interval = self.config.heartbeat_sec;
|
||||
let hb_runtime_dir = self.config.runtime_dir.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let sys_arc = std::sync::Arc::new(std::sync::Mutex::new(sysinfo::System::new_all()));
|
||||
@@ -210,13 +228,18 @@ impl NodeWorker {
|
||||
match hb_client.post(&hb_url).json(&req).send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
// 401/403:token 失效或被吊销。与 claim_task 口径统一:直接退出进程,
|
||||
// 401/403:token 失效(被重发覆盖)。与 claim_task 口径统一:直接退出进程,
|
||||
// 避免心跳线程持续发被拒请求刷日志、占用服务端限流计数。心跳通常比
|
||||
// claim 更高频,往往先于 claim_task 发现吊销。
|
||||
// claim 更高频,往往先于 claim_task 发现 token 失效。
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
tracing::error!(
|
||||
"节点 {} 心跳被服务端拒绝 (HTTP {}):node token 已失效或被吊销。请清理 .node_token 文件后重启节点以重新发起注册审批。进程将退出,依赖编排系统重启。",
|
||||
hb_node_id, status
|
||||
"节点 {} 心跳被服务端拒绝 (HTTP {}):node token 已失效(已被重发覆盖)。\n\
|
||||
恢复方式:把管理员重发的新 token 明文写入 {} 后重启节点,\n\
|
||||
或删除该文件后重启以重新提交注册申请等待审批。\n\
|
||||
进程将退出,依赖编排系统重启。",
|
||||
hb_node_id,
|
||||
status,
|
||||
token_file_display(&hb_runtime_dir)
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -228,6 +251,7 @@ impl NodeWorker {
|
||||
|
||||
let work_dir = PathBuf::from(&self.config.work_dir);
|
||||
tokio::fs::create_dir_all(&work_dir).await?;
|
||||
let result_dir = PathBuf::from(&self.config.result_dir);
|
||||
|
||||
let shutting_down = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let shutdown_signal = shutting_down.clone();
|
||||
@@ -256,7 +280,7 @@ impl NodeWorker {
|
||||
let active = self.active_slots.load(Ordering::Acquire);
|
||||
if (active as usize) < self.config.max_slots {
|
||||
match self.claim_task().await {
|
||||
Ok(Some(task)) => {
|
||||
Ok(ClaimOutcome::Task(task)) => {
|
||||
if was_disconnected {
|
||||
info!("与服务端恢复网络连接,已自动重新上线并开始领用计算任务!");
|
||||
was_disconnected = false;
|
||||
@@ -267,6 +291,7 @@ impl NodeWorker {
|
||||
let node_id = self.config.node_id.clone();
|
||||
let runtime = self.runtime.clone();
|
||||
let work_dir = work_dir.clone();
|
||||
let result_dir = result_dir.clone();
|
||||
let slots_counter = self.active_slots.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -276,9 +301,28 @@ impl NodeWorker {
|
||||
.await
|
||||
.map_err(|e| e.to_string());
|
||||
|
||||
// 在上报前克隆 summary,供上报成功后的归档使用(归档目录名取
|
||||
// summary.name,与 executor 内部 model_sub_dir 路径口径一致)。
|
||||
// 严重失败(runner 抛 Err)时 summary_opt=None,此时回退到 task.point_name
|
||||
// (二者均源自 params.model_name())作为归档目录名,确保失败任务的
|
||||
// 排错日志也能落盘而非随沙盒删除丢失。
|
||||
let summary_opt =
|
||||
res.as_ref().ok().map(|(s, _)| s.clone());
|
||||
let result_name =
|
||||
summary_opt.as_ref().map(|s| s.name.clone()).unwrap_or_else(|| task.point_name.clone());
|
||||
|
||||
let report_res =
|
||||
report_result(&client, &server_url, &node_id, &task, res).await;
|
||||
if report_res.is_ok() {
|
||||
// 上报成功后、清理沙盒前,先把完整产物归档到持久目录,
|
||||
// 避免随沙盒删除丢失(.spec/.cont/.iden/各阶段快照/日志等)。
|
||||
crate::executor::save_result_artifacts(
|
||||
&result_dir,
|
||||
&slot_work_dir,
|
||||
&result_name,
|
||||
)
|
||||
.await;
|
||||
crate::executor::cleanup_result_dir(&result_dir).await;
|
||||
if let Err(e) =
|
||||
crate::executor::cleanup_slot_work_dir(&slot_work_dir).await
|
||||
{
|
||||
@@ -290,18 +334,41 @@ impl NodeWorker {
|
||||
);
|
||||
}
|
||||
} else if let Err(ref e) = report_res {
|
||||
// 上报失败:仍尝试归档(保留产物用于排查),再清沙盒避免滞留泄漏。
|
||||
warn!("向服务端上报任务 {} 计算结果失败: {}", task.task_id, e);
|
||||
crate::executor::save_result_artifacts(
|
||||
&result_dir,
|
||||
&slot_work_dir,
|
||||
&result_name,
|
||||
)
|
||||
.await;
|
||||
if let Err(e) =
|
||||
crate::executor::cleanup_slot_work_dir(&slot_work_dir).await
|
||||
{
|
||||
warn!(
|
||||
"清理任务 {} 的沙盒目录 {} 失败: {}",
|
||||
task.task_id,
|
||||
slot_work_dir.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
slots_counter.fetch_sub(1, Ordering::AcqRel);
|
||||
});
|
||||
}
|
||||
Ok(None) => {
|
||||
Ok(ClaimOutcome::Empty) => {
|
||||
if was_disconnected {
|
||||
info!("与服务端恢复网络连接,已自动重新上线 (当前暂无排队任务)。");
|
||||
was_disconnected = false;
|
||||
}
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
Ok(ClaimOutcome::Disabled) => {
|
||||
// 节点被管理员手动停用:保持存活、空闲待命,拉长轮询避免刷请求。
|
||||
// 管理员调用 enable 后,下一次轮询即恢复领用,无需重启节点。
|
||||
info!("节点已被管理员停用,保持空闲待命(不再领用任务)。等待重新启用...");
|
||||
sleep(Duration::from_secs(60)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
was_disconnected = true;
|
||||
warn!("向服务端请求领用计算任务时出错: {}", e);
|
||||
@@ -343,34 +410,41 @@ impl NodeWorker {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn claim_task(&self) -> Result<Option<TaskSpec>> {
|
||||
async fn claim_task(&self) -> Result<ClaimOutcome> {
|
||||
let claim_url = format!("{}/api/task/claim", self.config.server_url);
|
||||
let resp = self.client.post(&claim_url).send().await?;
|
||||
|
||||
let status = resp.status();
|
||||
// 401/403 表明 node token 已被吊销或失效(区别于「暂无任务」与服务端 5xx 故障)。
|
||||
// 服务端故障返回 5xx 会走 !is_success() 的 Ok(None) 分支,仅在网络层/鉴权层拒绝时
|
||||
// 才是真正的吊销。此时继续轮询只会持续产生被拒请求并刷日志,故直接退出进程,
|
||||
// 401/403 表明 node token 已失效(被重发覆盖;区别于「暂无任务」与服务端 5xx 故障)。
|
||||
// 服务端故障返回 5xx 会走 !is_success() 的 Ok(Empty) 分支,仅在网络层/鉴权层拒绝时
|
||||
// 才是真正的 token 失效。此时继续轮询只会持续产生被拒请求并刷日志,故直接退出进程,
|
||||
// 由编排系统(Docker restart / systemd / k8s)拉起;新进程发现 .node_token 失效后
|
||||
// 会自动走注册审批流程重新申请。
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
tracing::error!(
|
||||
"领用任务被服务端拒绝 (HTTP {}):node token 已失效或被吊销。请清理 .node_token 文件后重启节点以重新发起注册审批。进程将退出,依赖编排系统重启。",
|
||||
status
|
||||
"领用任务被服务端拒绝 (HTTP {}):node token 已失效(已被重发覆盖)。\n\
|
||||
恢复方式:把管理员重发的新 token 明文写入 {} 后重启节点,\n\
|
||||
或删除该文件后重启以重新提交注册申请等待审批。\n\
|
||||
进程将退出,依赖编排系统重启。",
|
||||
status,
|
||||
token_file_display(&self.config.runtime_dir)
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
return Ok(None);
|
||||
return Ok(ClaimOutcome::Empty);
|
||||
}
|
||||
|
||||
let json: Value = resp.json().await?;
|
||||
if json["status"] == "ok" && !json["task"].is_null() {
|
||||
let task: TaskSpec = serde_json::from_value(json["task"].clone())?;
|
||||
Ok(Some(task))
|
||||
Ok(ClaimOutcome::Task(task))
|
||||
} else if json["status"] == "disabled" {
|
||||
// 管理员手动停用:节点保持存活、空闲待命,不退出进程。
|
||||
Ok(ClaimOutcome::Disabled)
|
||||
} else {
|
||||
Ok(None)
|
||||
Ok(ClaimOutcome::Empty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
//! 管理 API(Admin 角色)。
|
||||
//!
|
||||
//! 提供 node 凭据的可视化与运维操作,供 Dashboard 管理界面调用:
|
||||
//! - 列出所有节点及其凭据状态(在线/token 是否有效/吊销/颁发时间)
|
||||
//! - 吊销指定节点的专属 token(立即失效,不影响其他节点)
|
||||
//! - 列出所有节点及其凭据状态(在线/token 是否有效/颁发时间)
|
||||
//! - 重新颁发指定节点的专属 token(返回新明文,旧 token 失效)
|
||||
//!
|
||||
//! 这些端点均要求 Admin 角色(见 mod.rs 授权矩阵),node 自身无权操作他人或自身凭据,
|
||||
//! 从而保证「吊销/重发」是管理员主动行为,避免被攻陷节点篡改凭据体系。
|
||||
//! 从而保证「重发」是管理员主动行为,避免被攻陷节点篡改凭据体系。
|
||||
|
||||
use super::{is_valid_node_id, AppState};
|
||||
use axum::{
|
||||
@@ -31,37 +30,6 @@ pub async fn list_nodes(
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/admin/nodes/:node_id/revoke — 吊销指定节点的专属 token。
|
||||
///
|
||||
/// 吊销后该 node 的现有 token 立即失效,须重新走注册流程领取新 token。
|
||||
/// 操作幂等:对无凭据记录或已吊销的节点调用不会报错。
|
||||
pub async fn revoke_node(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(node_id): AxumPath<String>,
|
||||
) -> Result<impl IntoResponse, crate::api::AppError> {
|
||||
// node_id 白名单校验,防止注入或异常输入(与 register_node 的 node_id 来源口径一致)
|
||||
if !is_valid_node_id(&node_id) {
|
||||
return Err(crate::api::AppError::BadRequest(
|
||||
"非法的节点 ID 参数".to_string(),
|
||||
));
|
||||
}
|
||||
match state.db.revoke_node_token(&node_id).await {
|
||||
Ok(_) => {
|
||||
info!("管理员已吊销节点 {} 的专属 token", node_id);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(
|
||||
json!({ "success": true, "message": format!("节点 '{}' 的 token 已吊销", node_id) }),
|
||||
),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("吊销节点 {} token 失败: {}", node_id, e);
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/admin/nodes/:node_id/reissue — 重新颁发指定节点的专属 token。
|
||||
///
|
||||
/// 旧 token 立即失效,返回新 token 明文(仅此一次,DB 只存 hash)。
|
||||
@@ -93,7 +61,12 @@ pub async fn reissue_node(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"message": format!("节点 '{}' 的 token 已重新颁发,请将新 token 同步到该节点", node_id),
|
||||
// 重发使旧 token 立即失效:node 进程内存里仍握着旧 token,下一次心跳/领用会 401 退出。
|
||||
// 必须显式提示恢复方式,否则管理员易困惑"为什么重发后节点反而掉了"。
|
||||
"message": format!(
|
||||
"节点 '{}' 的 token 已重新颁发,旧 token 立即失效(该节点下次心跳/领用将 401 退出)。\n恢复方式(二选一):① 把下方新 token 写入该节点本地 .node_token 文件后重启节点;② 在节点机器删除 .node_token 后重启(会自动重注册取回新 token,限 1 天内有效)。",
|
||||
node_id
|
||||
),
|
||||
"node_token": new_token,
|
||||
})),
|
||||
))
|
||||
@@ -152,3 +125,69 @@ pub async fn reject_node(
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/admin/nodes/:node_id/disable — 手动停用一个在线/离线节点。
|
||||
///
|
||||
/// 停用后节点保持在线心跳(Dashboard 可见其存活),但 claim 不再向其分发任务,
|
||||
/// worker 收到 `{"status":"disabled"}` 后会拉长轮询、空闲待命。可随时调用 `/enable` 恢复。
|
||||
/// 仅 `online`/`offline` 节点可停用;对 `pending_approval`/`disabled` 调用返回 409。
|
||||
pub async fn disable_node(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(node_id): AxumPath<String>,
|
||||
) -> Result<impl IntoResponse, crate::api::AppError> {
|
||||
if !is_valid_node_id(&node_id) {
|
||||
return Err(crate::api::AppError::BadRequest(
|
||||
"非法的节点 ID 参数".to_string(),
|
||||
));
|
||||
}
|
||||
match state.db.set_node_enabled(&node_id, false).await {
|
||||
Ok(true) => {
|
||||
info!("管理员已停用节点 {}(停止分发任务,保持空闲待命)", node_id);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(
|
||||
json!({ "success": true, "message": format!("节点 '{}' 已停用,不再分发任务", node_id) }),
|
||||
),
|
||||
))
|
||||
}
|
||||
Ok(false) => Err(crate::api::AppError::Conflict(format!(
|
||||
"节点 '{}' 当前状态不支持停用(仅在线/离线节点可停用)",
|
||||
node_id
|
||||
))),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/admin/nodes/:node_id/enable — 重新启用被手动停用的节点。
|
||||
///
|
||||
/// 将节点从 `disabled` 切为 `offline`,靠下一次心跳自然翻成 online 后恢复分发。
|
||||
/// 仅 `disabled` 节点可启用;对其他状态调用返回 409(幂等保护)。
|
||||
pub async fn enable_node(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(node_id): AxumPath<String>,
|
||||
) -> Result<impl IntoResponse, crate::api::AppError> {
|
||||
if !is_valid_node_id(&node_id) {
|
||||
return Err(crate::api::AppError::BadRequest(
|
||||
"非法的节点 ID 参数".to_string(),
|
||||
));
|
||||
}
|
||||
match state.db.set_node_enabled(&node_id, true).await {
|
||||
Ok(true) => {
|
||||
info!(
|
||||
"管理员已重新启用节点 {}(下一次心跳后恢复分发任务)",
|
||||
node_id
|
||||
);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(
|
||||
json!({ "success": true, "message": format!("节点 '{}' 已重新启用,将在下一次心跳后恢复分发任务", node_id) }),
|
||||
),
|
||||
))
|
||||
}
|
||||
Ok(false) => Err(crate::api::AppError::Conflict(format!(
|
||||
"节点 '{}' 当前状态不支持启用(仅已停用节点可启用)",
|
||||
node_id
|
||||
))),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,11 +29,10 @@ pub struct AppState {
|
||||
pub db: Database,
|
||||
pub queue: Arc<SqliteTaskQueue>,
|
||||
pub scheduler: Arc<GridScheduler>,
|
||||
pub results_dir: String,
|
||||
/// server 端种子库目录(原 results_dir)。下载种子时读此路径。**永不清理**。
|
||||
pub seeds_dir: String,
|
||||
/// 限流与密码防暴破限速器
|
||||
pub rate_limiter: rate_limit::RateLimiter,
|
||||
/// 兼容字段:Some 表示「已启用某种鉴权」,用于 main.rs 决定是否挂载鉴权中间件。
|
||||
pub auth_token: Option<String>,
|
||||
/// Admin 凭据(管理 Dashboard / workflow 写操作)。
|
||||
pub admin_token: Option<String>,
|
||||
/// 应急开关:跳过全部鉴权(仅本地调试)。
|
||||
@@ -70,7 +69,7 @@ enum Role {
|
||||
///
|
||||
/// 设计依据(最小权限):
|
||||
/// - Admin 写操作(workflow CRUD / start / stop / status / approve / reject)只对 admin token 开放。
|
||||
/// - Node 运行态接口只认 node 专属 token(管理员在 Dashboard 审批后颁发,绑定 node_id,可吊销)。
|
||||
/// - Node 运行态接口只认 node 专属 token(管理员在 Dashboard 审批后颁发,绑定 node_id,可重发轮换)。
|
||||
/// - 注册端点 /node/register 和状态轮询 /node/check_status 为 Public 免凭据(提交申请 ➔ 待管理员审批)。
|
||||
///
|
||||
/// 注意:路径已去掉 `/api` 前缀(nest 挂载后中间件看到的 path 不含 nest 前缀)。
|
||||
@@ -97,10 +96,14 @@ fn required_role(path: &str, method: &axum::http::Method) -> Option<Role> {
|
||||
if path == "/status" && method == Method::GET {
|
||||
return Some(Role::Admin);
|
||||
}
|
||||
// 管理 API(节点凭据查看/审批/吊销/重发)→ Admin
|
||||
// 管理 API(节点凭据查看/审批/重发/停用/启用)→ Admin
|
||||
if path.starts_with("/admin/") {
|
||||
return Some(Role::Admin);
|
||||
}
|
||||
// 历史种子导入(run_grid.py 旧产物回灌)→ Admin
|
||||
if path == "/admin/import_seed" && method == Method::POST {
|
||||
return Some(Role::Admin);
|
||||
}
|
||||
// Node 运行态 → Node
|
||||
if path == "/node/heartbeat" && method == Method::POST {
|
||||
return Some(Role::Node);
|
||||
@@ -139,7 +142,7 @@ fn ct_eq_str(a: &str, b: &str) -> bool {
|
||||
}
|
||||
|
||||
/// node_id 白名单:字母、数字、点、下划线、连字符,长度 1-128。
|
||||
/// 用于 register_node / admin revoke / reissue 统一入口校验,与 Dashboard XSS 防护口径一致。
|
||||
/// 用于 register_node / admin reissue / disable / enable 统一入口校验,与 Dashboard XSS 防护口径一致。
|
||||
pub(crate) fn is_valid_node_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 128
|
||||
@@ -148,14 +151,6 @@ pub(crate) fn is_valid_node_id(id: &str) -> bool {
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
/// host_name 白名单:可打印 ASCII(排除控制字符),长度 1-128。
|
||||
/// 防止 host_name 携带 HTML/控制字符进入管理 Dashboard 触发存储型 XSS 或污染显示。
|
||||
pub(crate) fn is_valid_host_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 128
|
||||
&& name.chars().all(|c| c.is_ascii() && !c.is_ascii_control())
|
||||
}
|
||||
|
||||
/// 从请求头提取凭据原文(支持 `Authorization: Bearer <t>` 与 `X-API-Key: <t>`)。
|
||||
///
|
||||
/// 安全:非 `Bearer ` 前缀的 Authorization 一律视为无 token(不再回退为裸头值比较),
|
||||
@@ -240,8 +235,13 @@ pub async fn auth_middleware(
|
||||
let mut sessions = state.admin_sessions.write().await;
|
||||
let now = std::time::Instant::now();
|
||||
sessions.retain(|_, expiry| *expiry > now);
|
||||
if sessions.contains_key(&token) {
|
||||
valid = true;
|
||||
// 恒定时间比对:遍历全部 session key 逐个 ct_eq_str,不提前返回
|
||||
// (与 admin token 的恒定时间口径一致,消除 key 存在性的时序旁路)。
|
||||
// 容量受 MAX_ADMIN_SESSIONS(100)约束,遍历开销可接受。
|
||||
for k in sessions.keys() {
|
||||
if ct_eq_str(&token, k) {
|
||||
valid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ pub async fn auth_middleware(
|
||||
}
|
||||
None => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Unauthorized: invalid or revoked node token",
|
||||
"Unauthorized: invalid or stale node token",
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{is_valid_host_name, is_valid_node_id, AppState, AuthenticatedNode};
|
||||
use super::{is_valid_node_id, AppState, AuthenticatedNode};
|
||||
use axum::{
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
@@ -20,11 +20,6 @@ pub async fn register_node(
|
||||
"非法的节点 ID(仅允许字母、数字、点、下划线、连字符,长度 1-128)".to_string(),
|
||||
));
|
||||
}
|
||||
if !is_valid_host_name(&req.host_name) {
|
||||
return Err(crate::api::AppError::BadRequest(
|
||||
"非法的主机名(仅允许可打印 ASCII,长度 1-128)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 已认证已拿到 Token 的节点刷新元数据配置
|
||||
if let Some(Extension(ref auth)) = auth_node {
|
||||
|
||||
@@ -28,7 +28,7 @@ pub async fn download_seed(
|
||||
));
|
||||
}
|
||||
|
||||
let seed_file_path = std::path::Path::new(&state.results_dir)
|
||||
let seed_file_path = std::path::Path::new(&state.seeds_dir)
|
||||
.join(&name)
|
||||
.join(format!("{}.7", name));
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ pub async fn get_status(
|
||||
.get_grid_summary_stats(None)
|
||||
.await
|
||||
.unwrap_or(serde_json::json!({
|
||||
"total": 0, "pending": 0, "running": 0, "converged": 0, "failed": 0
|
||||
"total": 0, "pending": 0, "queued": 0, "running": 0, "converged": 0, "failed": 0,
|
||||
"cold_run_converged": 0, "seed_step_converged": 0, "imported_converged": 0
|
||||
}));
|
||||
|
||||
Ok(Json(json!({
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use super::{AppState, AuthenticatedNode};
|
||||
use axum::{
|
||||
extract::{Extension, Multipart, State},
|
||||
extract::{Extension, Multipart, Query, State},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use common::models::{GridPointParams, ModelSummary, TaskReport, TaskStatus};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
@@ -16,6 +17,23 @@ pub async fn claim_task(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth_node): Extension<AuthenticatedNode>,
|
||||
) -> Result<impl IntoResponse, crate::api::AppError> {
|
||||
// 管理员手动停用拦截:被停用的节点保持在线但不再分发任务。
|
||||
// 返回 HTTP 200 + {"status":"disabled"}(绝不能用 403 —— worker 见 403 会判定
|
||||
// token 失效而 exit(1),停用是运维意图而非凭据失效,应让 worker 空闲待命)。
|
||||
match state.db.is_node_disabled(&auth_node.node_id).await {
|
||||
Ok(true) => {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({"status": "disabled", "task": null})),
|
||||
));
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
tracing::error!("查询节点停用状态异常: {}", e);
|
||||
return Err(crate::api::AppError::Internal(e));
|
||||
}
|
||||
}
|
||||
|
||||
// 领用时记录任务归属:pop_task 写入 claimed_by_node_id,
|
||||
// report 阶段据此校验「上报者确为领用者」,杜绝跨节点伪造结果。
|
||||
match state.queue.pop_task(&auth_node.node_id).await {
|
||||
@@ -112,12 +130,7 @@ pub async fn report_task(
|
||||
|
||||
let name = report.point_name.clone();
|
||||
|
||||
if name.is_empty()
|
||||
|| name.starts_with('.')
|
||||
|| !name.chars().all(|c| {
|
||||
c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' || c == '+' || c == '@'
|
||||
})
|
||||
{
|
||||
if !super::workflow::is_valid_point_name(&name) {
|
||||
warn!(
|
||||
"拒绝可能包含路径穿越或特殊非常规编码号攻击的网格点名称请求: {}",
|
||||
name
|
||||
@@ -157,7 +170,7 @@ pub async fn report_task(
|
||||
}
|
||||
|
||||
// 采用原子写入模式保持 conv.json 与核心二进制数据完整落地后才揭晓真实文件名
|
||||
let model_dir = Path::new(&state.results_dir).join(&name);
|
||||
let model_dir = Path::new(&state.seeds_dir).join(&name);
|
||||
if fs::create_dir_all(&model_dir).await.is_ok() {
|
||||
let conv_tmp = model_dir.join(format!("conv.json.{}.tmp", uuid::Uuid::new_v4().simple()));
|
||||
let conv_path = model_dir.join("conv.json");
|
||||
@@ -197,7 +210,7 @@ pub async fn report_task(
|
||||
info!("网格点 {} 计算未成功完成,检查种子回退机制...", name);
|
||||
if let Err(e) = state
|
||||
.scheduler
|
||||
.trigger_seed_step_fallback(¶ms, &workflow_name)
|
||||
.trigger_seed_step_fallback(¶ms, &name, &workflow_name)
|
||||
.await
|
||||
{
|
||||
warn!("网格点 {} 触发种子回退机制失败: {}", name, e);
|
||||
@@ -218,3 +231,166 @@ fn extract_params(report: &TaskReport) -> Option<GridPointParams> {
|
||||
.ok()
|
||||
.map(|summary| summary.params)
|
||||
}
|
||||
|
||||
/// `/admin/import_seed` 的查询参数。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ImportSeedQuery {
|
||||
/// 目标工作流名(导入到此工作流的 grid_points)。缺省归入 `imported` 工作流。
|
||||
#[serde(default = "default_import_workflow")]
|
||||
pub workflow: String,
|
||||
}
|
||||
|
||||
fn default_import_workflow() -> String {
|
||||
"imported".to_string()
|
||||
}
|
||||
|
||||
/// 历史种子导入端点(Admin 鉴权)。
|
||||
///
|
||||
/// 供 `tools/import_results` 把旧版单机 `run_grid.py` 产物(`conv.json` + `.7` 大气文件)
|
||||
/// 批量回灌进 DCTS。与 `/task/report` 的关键区别:
|
||||
/// - **跳过任务归属校验**(`verify_task_claim`):历史数据无领用语义,导入端点不经过
|
||||
/// claim/report 队列,直接幂等落库。
|
||||
/// - **`point_name` 取旧 `conv.json` 的 `name` 字段**(Python `gen_input5.model_name`
|
||||
/// 生成的源精度真名,如 `t20000_g5.0_...`),而非从数值重推——保证迁移逐字符保真。
|
||||
/// - **真实 `max_relc`** 取自 `summary.final_max_relc`(旧版已记录),不硬编码。
|
||||
///
|
||||
/// 幂等:`upsert_grid_point` 用 `ON CONFLICT DO NOTHING`,`.7`/`conv.json` 原子覆盖写,
|
||||
/// 可重复运行。
|
||||
pub async fn import_seed(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ImportSeedQuery>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<impl IntoResponse, crate::api::AppError> {
|
||||
let mut summary_json: Option<String> = None;
|
||||
let mut seed_file_data: Option<Vec<u8>> = None;
|
||||
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
let field_name = field.name().unwrap_or("").to_string();
|
||||
if field_name == "report" {
|
||||
if let Ok(bytes) = field.bytes().await {
|
||||
summary_json = Some(String::from_utf8_lossy(&bytes).to_string());
|
||||
}
|
||||
} else if field_name == "seed_file" {
|
||||
if let Ok(bytes) = field.bytes().await {
|
||||
seed_file_data = Some(bytes.to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let summary_json = match summary_json {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err(crate::api::AppError::BadRequest(
|
||||
"请求中缺少 report 字段(旧版 conv.json 内容)".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 解析旧版 conv.json(ModelSummary 结构)取 name / params / 收敛状态 / 真实 max_relc。
|
||||
let summary: ModelSummary = match serde_json::from_str(&summary_json) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!("历史种子导入:conv.json 解析失败: {}", e);
|
||||
return Err(crate::api::AppError::BadRequest(
|
||||
"conv.json 解析失败,非合法 ModelSummary".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// point_name 优先用旧 conv.json 的 name(源精度真名);回退到 params 规范名。
|
||||
let name = if !summary.name.is_empty() {
|
||||
summary.name.clone()
|
||||
} else {
|
||||
summary.params.model_name()
|
||||
};
|
||||
|
||||
// 名称合法性校验(防路径穿越),与 report_task 同口径。
|
||||
if !super::workflow::is_valid_point_name(&name) {
|
||||
warn!("历史种子导入:拒绝非法网格点名称: {}", name);
|
||||
return Err(crate::api::AppError::BadRequest(
|
||||
"非法的网格点名称参数".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let workflow_name = query.workflow;
|
||||
let params = summary.params.clone();
|
||||
let converged = summary.converged && !summary.atmosphere_has_nan;
|
||||
let max_relc = summary.final_max_relc;
|
||||
|
||||
// 1. 幂等写入 grid_points(ON CONFLICT DO NOTHING):无需事先 start 工作流。
|
||||
// 用权威 name(旧 conv.json 的源精度真名),而非从 params 重推——导入路径的
|
||||
// params 来自旧 JSON(无源文本,model_name() 会失真)。
|
||||
if let Err(e) = state
|
||||
.db
|
||||
.upsert_grid_point_named(&name, ¶ms, 0, &workflow_name)
|
||||
.await
|
||||
{
|
||||
tracing::error!("历史种子导入:upsert grid_points {} 失败: {}", name, e);
|
||||
return Err(crate::api::AppError::Internal(e));
|
||||
}
|
||||
|
||||
// 2. 落地 conv.json(原子 tmp→rename)。
|
||||
let model_dir = Path::new(&state.seeds_dir).join(&name);
|
||||
if fs::create_dir_all(&model_dir).await.is_ok() {
|
||||
let conv_tmp = model_dir.join(format!("conv.json.{}.tmp", uuid::Uuid::new_v4().simple()));
|
||||
let conv_path = model_dir.join("conv.json");
|
||||
if fs::write(&conv_tmp, &summary_json).await.is_ok() {
|
||||
let _ = fs::rename(&conv_tmp, &conv_path).await;
|
||||
}
|
||||
|
||||
// 3. 收敛且干净才写 .7 + 入种子库(与 report_task 同口径)。
|
||||
if converged {
|
||||
if let Some(bytes) = seed_file_data {
|
||||
let seed_tmp =
|
||||
model_dir.join(format!("{}.7.{}.tmp", name, uuid::Uuid::new_v4().simple()));
|
||||
let seed_path = model_dir.join(format!("{}.7", name));
|
||||
if fs::write(&seed_tmp, bytes).await.is_ok()
|
||||
&& fs::rename(&seed_tmp, &seed_path).await.is_ok()
|
||||
{
|
||||
info!(
|
||||
"历史种子导入:网格点 {} 收敛种子已落地: {} (max_relc={:?})",
|
||||
name,
|
||||
seed_path.display(),
|
||||
max_relc
|
||||
);
|
||||
let _ = state
|
||||
.db
|
||||
.insert_seed_named(&name, ¶ms, &seed_path.to_string_lossy())
|
||||
.await;
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
"历史种子导入:网格点 {} 声称收敛但未上传 seed_file,跳过种子写入",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 更新 grid_points 状态:收敛→converged(success_method='imported');否则维持 pending
|
||||
// 让正常调度处理(导入未收敛点无意义,但记录其尝试)。
|
||||
if converged {
|
||||
if let Err(e) = state
|
||||
.db
|
||||
.mark_grid_point_imported(&name, &workflow_name, Some(summary.elapsed_sec))
|
||||
.await
|
||||
{
|
||||
warn!("历史种子导入:标记 {} 为 converged 失败: {}", name, e);
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"历史种子导入完成:网格点 {} (workflow={}, converged={}, max_relc={:?})",
|
||||
name, workflow_name, converged, max_relc
|
||||
);
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"status": "ok",
|
||||
"point_name": name,
|
||||
"converged": converged,
|
||||
"max_relc": max_relc,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::AppState;
|
||||
use axum::{
|
||||
extract::{Path as AxumPath, State},
|
||||
extract::{Path as AxumPath, Query as AxumQuery, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
@@ -71,6 +71,19 @@ fn is_valid_workflow_name(name: &str) -> bool {
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
/// 网格点名称白名单(全端统一口径):字母/数字/`.`/`_`/`-`/`+`/`@`,
|
||||
/// 非空、不以 `.` 开头(拒绝 `..` 穿越)、长度 ≤ 128。
|
||||
///
|
||||
/// 字符集不含 `/`、`\`,从源头杜绝路径穿越——任何磁盘路径拼接前的唯一闸门。
|
||||
pub(crate) fn is_valid_point_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& !name.starts_with('.')
|
||||
&& name.len() <= 128
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '+' | '@'))
|
||||
}
|
||||
|
||||
pub async fn save_workflow(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateWorkflowRequest>,
|
||||
@@ -82,8 +95,8 @@ pub async fn save_workflow(
|
||||
));
|
||||
}
|
||||
|
||||
// Validate YAML config string
|
||||
if let Err(e) = serde_yaml::from_str::<GridConfig>(&req.config_yaml) {
|
||||
// Validate YAML config string(用源精度解析,校验 + 保留 grid 轴书写小数位)
|
||||
if let Err(e) = GridConfig::from_yaml_str(&req.config_yaml) {
|
||||
return Err(crate::api::AppError::BadRequest(format!(
|
||||
"无效的 YAML 配置: {}",
|
||||
e
|
||||
@@ -186,7 +199,7 @@ pub async fn start_workflow(
|
||||
Ok(true) => {}
|
||||
}
|
||||
|
||||
let grid_cfg: GridConfig = match serde_yaml::from_str(&item.config_yaml) {
|
||||
let grid_cfg: GridConfig = match GridConfig::from_yaml_str(&item.config_yaml) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
let _ = state.db.update_workflow_status(&name, "idle").await;
|
||||
@@ -233,6 +246,335 @@ pub async fn start_workflow(
|
||||
))
|
||||
}
|
||||
|
||||
/// 单工作流执行统计:进度(pending/queued/running/converged/failed 分开计数)、
|
||||
/// 收敛手段归因(冷启动/种子步进/历史导入)、难度波次分布、近似 ETA。详情页数据源。
|
||||
pub async fn get_workflow_stats(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
) -> Result<impl IntoResponse, crate::api::AppError> {
|
||||
if !is_valid_workflow_name(&name) {
|
||||
return Err(crate::api::AppError::BadRequest(
|
||||
"工作流名称含非法字符".to_string(),
|
||||
));
|
||||
}
|
||||
let item = match state.db.get_workflow(&name).await {
|
||||
Ok(Some(item)) => item,
|
||||
Ok(None) => {
|
||||
return Err(crate::api::AppError::NotFound(format!(
|
||||
"工作流 '{}' 未找到",
|
||||
name
|
||||
)))
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
// 在线节点总槽位:ETA 并发感知除数(无在线节点时按串行兜底,db 层 max(1))。
|
||||
let total_slots: i64 = state
|
||||
.db
|
||||
.get_active_nodes()
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|n| n.max_slots as i64)
|
||||
.sum();
|
||||
match state
|
||||
.db
|
||||
.get_workflow_detail_stats(&name, &item.status, total_slots)
|
||||
.await
|
||||
{
|
||||
Ok(stats) => Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse {
|
||||
success: true,
|
||||
message: "成功获取工作流统计".to_string(),
|
||||
data: Some(stats),
|
||||
}),
|
||||
)),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 进度时间序列查询参数。
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct ProgressQuery {
|
||||
/// 时间窗口(小时),默认 24,钳位 1–168(7 天)。
|
||||
pub hours: Option<i64>,
|
||||
}
|
||||
|
||||
/// 工作流进度时间序列 + 经验速率 + 停滞时长(详情页概览进度曲线数据源)。
|
||||
///
|
||||
/// - `series`:窗口内的计数快照(超 300 点自动降采样,首末点保留);
|
||||
/// - `rate_per_hour`:窗口首末 converged 增量 ÷ 时长(快照 <2 条或时长 ≤0 为 null);
|
||||
/// - `stalled_minutes`:终态数(converged+failed)最后一次增长到窗口末端的分钟数
|
||||
/// (用于"进度停滞"预警;快照 <2 条为 null)。
|
||||
pub async fn get_workflow_progress(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
AxumQuery(q): AxumQuery<ProgressQuery>,
|
||||
) -> Result<impl IntoResponse, crate::api::AppError> {
|
||||
if !is_valid_workflow_name(&name) {
|
||||
return Err(crate::api::AppError::BadRequest(
|
||||
"工作流名称含非法字符".to_string(),
|
||||
));
|
||||
}
|
||||
match state.db.get_workflow(&name).await {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
return Err(crate::api::AppError::NotFound(format!(
|
||||
"工作流 '{}' 未找到",
|
||||
name
|
||||
)))
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
|
||||
let hours = q.hours.unwrap_or(24).clamp(1, 168);
|
||||
let mut series = state.db.get_progress_series(&name, hours).await?;
|
||||
|
||||
// 降采样:过密时等间隔抽取,首末点强制保留(曲线端点不失真)。
|
||||
if series.len() > 300 {
|
||||
let step = (series.len() as f64 / 300.0).ceil() as usize;
|
||||
let last = series.last().cloned();
|
||||
series = series.into_iter().step_by(step).collect();
|
||||
if let Some(l) = last {
|
||||
if series.last().map(|p| p.ts.as_str()) != Some(l.ts.as_str()) {
|
||||
series.push(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite datetime('now') 为 UTC 'YYYY-MM-DD HH:MM:SS'
|
||||
let parse_ts = |ts: &str| chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S").ok();
|
||||
|
||||
let rate_per_hour: Option<f64> = match (series.first(), series.last()) {
|
||||
(Some(first), Some(last)) if series.len() >= 2 => {
|
||||
match (parse_ts(&first.ts), parse_ts(&last.ts)) {
|
||||
(Some(t0), Some(t1)) => {
|
||||
let dh = (t1 - t0).num_seconds() as f64 / 3600.0;
|
||||
if dh > 0.0 {
|
||||
Some((last.converged - first.converged) as f64 / dh)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let stalled_minutes: Option<f64> = if series.len() >= 2 {
|
||||
let mut last_progress_idx = None;
|
||||
for i in 1..series.len() {
|
||||
let prev = series[i - 1].converged + series[i - 1].failed;
|
||||
let cur = series[i].converged + series[i].failed;
|
||||
if cur > prev {
|
||||
last_progress_idx = Some(i);
|
||||
}
|
||||
}
|
||||
let anchor = last_progress_idx.unwrap_or(0);
|
||||
let end = series.len() - 1;
|
||||
match (parse_ts(&series[anchor].ts), parse_ts(&series[end].ts)) {
|
||||
(Some(t0), Some(t1)) => Some(((t1 - t0).num_seconds() as f64 / 60.0).max(0.0)),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse {
|
||||
success: true,
|
||||
message: "成功获取进度时间序列".to_string(),
|
||||
data: Some(serde_json::json!({
|
||||
"hours": hours,
|
||||
"series": series,
|
||||
"rate_per_hour": rate_per_hour,
|
||||
"stalled_minutes": stalled_minutes,
|
||||
})),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// 逐点列表查询参数。枚举类参数(status/method/sort/order)一律白名单校验后
|
||||
/// 才进入 db 层;limit/offset 钳位;任何用户文本都不会拼进 SQL 字符串。
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct PointsQuery {
|
||||
pub status: Option<String>,
|
||||
pub method: Option<String>,
|
||||
pub wave: Option<i32>,
|
||||
pub q: Option<String>,
|
||||
pub sort: Option<String>,
|
||||
pub order: Option<String>,
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
}
|
||||
|
||||
/// 工作流逐点列表:点参数 + 状态 + 收敛手段 + 最近尝试(max_relc/种子来源/节点/错误)。
|
||||
/// 支持状态/手段/波次过滤、点名搜索、白名单排序与分页(limit ≤ 500)。详情页点表数据源。
|
||||
pub async fn get_workflow_points(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
AxumQuery(pq): AxumQuery<PointsQuery>,
|
||||
) -> Result<impl IntoResponse, crate::api::AppError> {
|
||||
if !is_valid_workflow_name(&name) {
|
||||
return Err(crate::api::AppError::BadRequest(
|
||||
"工作流名称含非法字符".to_string(),
|
||||
));
|
||||
}
|
||||
// 未知工作流返回 404(与 stats 端点一致),而非空列表
|
||||
match state.db.get_workflow(&name).await {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
return Err(crate::api::AppError::NotFound(format!(
|
||||
"工作流 '{}' 未找到",
|
||||
name
|
||||
)))
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
|
||||
if let Some(s) = &pq.status {
|
||||
if !matches!(
|
||||
s.as_str(),
|
||||
"pending" | "queued" | "running" | "converged" | "failed"
|
||||
) {
|
||||
return Err(crate::api::AppError::BadRequest(format!(
|
||||
"非法的 status 参数: {}",
|
||||
s
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Some(m) = &pq.method {
|
||||
if !matches!(m.as_str(), "cold_run" | "seed_step" | "imported") {
|
||||
return Err(crate::api::AppError::BadRequest(format!(
|
||||
"非法的 method 参数: {}",
|
||||
m
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// 排序白名单 → 编译期列名片段;NULL 统一靠后(IS NULL 升序前置),不受 dir 影响。
|
||||
let sort = pq.sort.as_deref().unwrap_or("wave");
|
||||
let dir = if pq
|
||||
.order
|
||||
.as_deref()
|
||||
.map(|o| o.eq_ignore_ascii_case("desc"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
"DESC"
|
||||
} else {
|
||||
"ASC"
|
||||
};
|
||||
let order_by = match sort {
|
||||
"wave" => format!("gp.wave {dir}, gp.cno_sum ASC, gp.teff ASC"),
|
||||
"teff" => format!("gp.teff {dir}, gp.wave ASC, gp.cno_sum ASC"),
|
||||
"max_relc" => format!("t.max_relc IS NULL ASC, t.max_relc {dir}, gp.wave ASC"),
|
||||
"attempts" => format!("gp.attempt_count {dir}, gp.wave ASC, gp.cno_sum ASC"),
|
||||
"last_completed_at" => {
|
||||
format!("t.completed_at IS NULL ASC, t.completed_at {dir}, gp.wave ASC")
|
||||
}
|
||||
_ => {
|
||||
return Err(crate::api::AppError::BadRequest(format!(
|
||||
"非法的 sort 参数: {}",
|
||||
sort
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let filter = crate::db::PointFilter {
|
||||
status: pq.status.clone(),
|
||||
method: pq.method.clone(),
|
||||
wave: pq.wave,
|
||||
q: pq.q.clone(),
|
||||
order_by,
|
||||
limit: pq.limit.unwrap_or(100).clamp(1, 500),
|
||||
offset: pq.offset.unwrap_or(0).max(0),
|
||||
};
|
||||
|
||||
match state.db.list_workflow_points(&name, &filter).await {
|
||||
Ok((total, points)) => Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse {
|
||||
success: true,
|
||||
message: "成功获取工作流网格点列表".to_string(),
|
||||
data: Some(serde_json::json!({ "total": total, "points": points })),
|
||||
}),
|
||||
)),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 单网格点详情:点行 + 全部尝试历史 + conv.json 逐阶段诊断。
|
||||
///
|
||||
/// conv.json 读自 `seeds_dir/<point>/conv.json`(单层目录)。点名经 `is_valid_point_name`
|
||||
/// 白名单(无 `/`、`\`,拒前导 `.`)——即路径穿越的前置闸门;读盘后再做 canonicalize
|
||||
/// 归属兜底校验(纵深防御)。缺失/读失败/解析失败一律 `conv: null`(仍 200),
|
||||
/// 前端降级显示"诊断文件不可用"。
|
||||
pub async fn get_workflow_point_detail(
|
||||
State(state): State<AppState>,
|
||||
AxumPath((name, point)): AxumPath<(String, String)>,
|
||||
) -> Result<impl IntoResponse, crate::api::AppError> {
|
||||
if !is_valid_workflow_name(&name) || !is_valid_point_name(&point) {
|
||||
return Err(crate::api::AppError::BadRequest(
|
||||
"工作流名称或网格点名称含非法字符".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let point_row = match state.db.get_workflow_point_row(&name, &point).await {
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
return Err(crate::api::AppError::NotFound(format!(
|
||||
"网格点 '{}' 未找到(工作流 '{}')",
|
||||
point, name
|
||||
)))
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let attempts = state.db.list_point_attempts(&name, &point).await?;
|
||||
|
||||
let conv_path = std::path::Path::new(&state.seeds_dir)
|
||||
.join(&point)
|
||||
.join("conv.json");
|
||||
let conv: Option<common::models::ModelSummary> =
|
||||
match tokio::fs::read_to_string(&conv_path).await {
|
||||
Ok(s) => {
|
||||
let confined = std::path::Path::new(&state.seeds_dir)
|
||||
.canonicalize()
|
||||
.ok()
|
||||
.zip(conv_path.canonicalize().ok())
|
||||
.map(|(root, f)| f.starts_with(root))
|
||||
.unwrap_or(false);
|
||||
if confined {
|
||||
match serde_json::from_str(&s) {
|
||||
Ok(summary) => Some(summary),
|
||||
Err(e) => {
|
||||
tracing::warn!("网格点 {} 的 conv.json 解析失败: {}", point, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("网格点 {} 的 conv.json 路径越界,拒绝读取", point);
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse {
|
||||
success: true,
|
||||
message: "成功获取网格点详情".to_string(),
|
||||
data: Some(serde_json::json!({
|
||||
"point": point_row,
|
||||
"attempts": attempts,
|
||||
"conv": conv,
|
||||
})),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn stop_workflow(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
|
||||
+926
-202
File diff suppressed because it is too large
Load Diff
+52
-25
@@ -55,11 +55,7 @@ async fn main() -> Result<()> {
|
||||
|
||||
let db = Database::new(&server_cfg.db_path).await?;
|
||||
let queue = Arc::new(SqliteTaskQueue::new(&server_cfg.queue_db_path).await?);
|
||||
let scheduler = Arc::new(GridScheduler::new(
|
||||
db.clone(),
|
||||
queue.clone(),
|
||||
server_cfg.results_dir.clone(),
|
||||
));
|
||||
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
|
||||
|
||||
// Auto-register sdB_cno.yaml if exists and not yet in DB
|
||||
let default_wf_path = Path::new(&server_cfg.grid_config);
|
||||
@@ -81,18 +77,10 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// 弱口令凭据安全警告检测
|
||||
let is_weak_token = |t: Option<&str>| -> bool {
|
||||
match t {
|
||||
Some(s) => {
|
||||
s.len() < 12 || s == "fmqi123" || s == "admin" || s == "123456" || s == "secret"
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
};
|
||||
if is_weak_token(server_cfg.auth_token.as_deref())
|
||||
|| is_weak_token(server_cfg.admin_token.as_deref())
|
||||
{
|
||||
// 弱口令凭据安全警告检测:仅按强度阈值判断(短于 16 字节视为弱口令)。
|
||||
// 推荐用 `openssl rand -hex 32`(64 字符)生成。
|
||||
let is_weak_token = |t: Option<&str>| -> bool { t.map(|s| s.len() < 16).unwrap_or(false) };
|
||||
if is_weak_token(server_cfg.admin_token.as_deref()) {
|
||||
tracing::warn!("⚠️ 检测到系统当前正在使用弱口令凭据或默认 Token!建议生产环境在 .env 中配置使用 openssl rand -hex 32 生成的高强度 Token!");
|
||||
}
|
||||
|
||||
@@ -102,9 +90,8 @@ async fn main() -> Result<()> {
|
||||
db,
|
||||
queue: queue.clone(),
|
||||
scheduler: scheduler.clone(),
|
||||
results_dir: server_cfg.results_dir.clone(),
|
||||
seeds_dir: server_cfg.seeds_dir.clone(),
|
||||
rate_limiter,
|
||||
auth_token: server_cfg.auth_token.clone(),
|
||||
admin_token: server_cfg.admin_token.clone(),
|
||||
auth_disabled: server_cfg.auth_disabled,
|
||||
admin_sessions: std::sync::Arc::new(tokio::sync::RwLock::new(
|
||||
@@ -188,6 +175,23 @@ async fn main() -> Result<()> {
|
||||
has_error = true;
|
||||
}
|
||||
|
||||
// P3 进度快照:对每个运行中工作流记录计数(record_progress_snapshot
|
||||
// 内部去重——计数无变化不落库);顺带清理超过 7 天的旧快照。
|
||||
// 观测性写入失败不回退调度退避(不置 has_error)。
|
||||
match bg_db_clone.get_running_workflow_names().await {
|
||||
Ok(names) => {
|
||||
for wf in names {
|
||||
if let Err(e) = bg_db_clone.record_progress_snapshot(&wf).await {
|
||||
tracing::warn!("记录工作流 {} 进度快照失败: {}", wf, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("获取运行中工作流列表失败: {}", e),
|
||||
}
|
||||
if let Err(e) = bg_db_clone.purge_progress_snapshots(7).await {
|
||||
tracing::warn!("清理过期进度快照失败: {}", e);
|
||||
}
|
||||
|
||||
has_error
|
||||
});
|
||||
|
||||
@@ -234,6 +238,8 @@ async fn main() -> Result<()> {
|
||||
|
||||
let report_router = Router::new()
|
||||
.route("/task/report", post(api::task::report_task))
|
||||
// 历史种子导入同样上传 .7 大气文件,并入宽松 body limit / 并发限流组。
|
||||
.route("/admin/import_seed", post(api::task::import_seed))
|
||||
.layer(DefaultBodyLimit::max(REPORT_BODY_LIMIT))
|
||||
.layer(tower::ServiceBuilder::new().concurrency_limit(REPORT_MAX_CONCURRENCY));
|
||||
|
||||
@@ -284,7 +290,24 @@ async fn main() -> Result<()> {
|
||||
post(api::workflow::start_workflow),
|
||||
)
|
||||
.route("/workflows/:name/stop", post(api::workflow::stop_workflow))
|
||||
// Admin Management API(节点凭据查看/审批/吊销/重发,均要求 Admin 角色)
|
||||
// 工作流执行观测 API(进度统计 / 逐点明细 / 单点诊断,均要求 Admin 角色)
|
||||
.route(
|
||||
"/workflows/:name/stats",
|
||||
get(api::workflow::get_workflow_stats),
|
||||
)
|
||||
.route(
|
||||
"/workflows/:name/progress",
|
||||
get(api::workflow::get_workflow_progress),
|
||||
)
|
||||
.route(
|
||||
"/workflows/:name/points",
|
||||
get(api::workflow::get_workflow_points),
|
||||
)
|
||||
.route(
|
||||
"/workflows/:name/points/:point",
|
||||
get(api::workflow::get_workflow_point_detail),
|
||||
)
|
||||
// Admin Management API(节点凭据查看/审批/重发/停用/启用,均要求 Admin 角色)
|
||||
.route("/admin/nodes", get(api::admin::list_nodes))
|
||||
.route(
|
||||
"/admin/nodes/:node_id/approve",
|
||||
@@ -294,14 +317,18 @@ async fn main() -> Result<()> {
|
||||
"/admin/nodes/:node_id/reject",
|
||||
post(api::admin::reject_node),
|
||||
)
|
||||
.route(
|
||||
"/admin/nodes/:node_id/revoke",
|
||||
post(api::admin::revoke_node),
|
||||
)
|
||||
.route(
|
||||
"/admin/nodes/:node_id/reissue",
|
||||
post(api::admin::reissue_node),
|
||||
)
|
||||
.route(
|
||||
"/admin/nodes/:node_id/disable",
|
||||
post(api::admin::disable_node),
|
||||
)
|
||||
.route(
|
||||
"/admin/nodes/:node_id/enable",
|
||||
post(api::admin::enable_node),
|
||||
)
|
||||
// 合并大体积上报路由(继承各自的 body limit)
|
||||
.merge(report_router)
|
||||
.layer(DefaultBodyLimit::max(DEFAULT_BODY_LIMIT));
|
||||
@@ -320,7 +347,7 @@ async fn main() -> Result<()> {
|
||||
api_router.layer(auth_layer).layer(rate_limit_layer)
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"⚠️ 警告:未配置 DCTS_ADMIN_TOKEN / DCTS_ENROLLMENT_TOKEN(且未启用 DCTS_AUTH_DISABLE),\
|
||||
"⚠️ 警告:未配置 DCTS_ADMIN_TOKEN(且未启用 DCTS_AUTH_DISABLE),\
|
||||
服务端运行在【无鉴权模式】!公网部署务必配置凭据。"
|
||||
);
|
||||
api_router
|
||||
|
||||
@@ -11,16 +11,11 @@ use crate::db::Database;
|
||||
pub struct GridScheduler {
|
||||
db: Database,
|
||||
queue: Arc<SqliteTaskQueue>,
|
||||
_results_dir: String,
|
||||
}
|
||||
|
||||
impl GridScheduler {
|
||||
pub fn new(db: Database, queue: Arc<SqliteTaskQueue>, results_dir: String) -> Self {
|
||||
Self {
|
||||
db,
|
||||
queue,
|
||||
_results_dir: results_dir,
|
||||
}
|
||||
pub fn new(db: Database, queue: Arc<SqliteTaskQueue>) -> Self {
|
||||
Self { db, queue }
|
||||
}
|
||||
|
||||
/// Expands grid points from config and registers them into the database.
|
||||
@@ -52,19 +47,19 @@ impl GridScheduler {
|
||||
}
|
||||
let mut points = Vec::new();
|
||||
|
||||
for &teff in &cfg.grid.teff {
|
||||
for &logg in &cfg.grid.logg {
|
||||
for &loghe in &cfg.grid.loghe {
|
||||
for &logc in &cfg.grid.logc {
|
||||
for &logn in &cfg.grid.logn {
|
||||
for &logo in &cfg.grid.logo {
|
||||
for teff in &cfg.grid.teff {
|
||||
for logg in &cfg.grid.logg {
|
||||
for loghe in &cfg.grid.loghe {
|
||||
for logc in &cfg.grid.logc {
|
||||
for logn in &cfg.grid.logn {
|
||||
for logo in &cfg.grid.logo {
|
||||
points.push(GridPointParams {
|
||||
teff,
|
||||
logg,
|
||||
loghe,
|
||||
logc,
|
||||
logn,
|
||||
logo,
|
||||
teff: teff.clone(),
|
||||
logg: logg.clone(),
|
||||
loghe: loghe.clone(),
|
||||
logc: logc.clone(),
|
||||
logn: logn.clone(),
|
||||
logo: logo.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -196,7 +191,7 @@ impl GridScheduler {
|
||||
.await?;
|
||||
let mut dispatched = 0;
|
||||
|
||||
for (name, params, _wave) in pending {
|
||||
for (name, params, wave) in pending {
|
||||
// Check if any seed is available in DB for active SeedStep scheduling(seeds 全局共享)
|
||||
let (task_type, seed_name) = match self.db.find_best_seed_from_db(¶ms).await {
|
||||
Ok(Some(seed_match)) => {
|
||||
@@ -217,6 +212,7 @@ impl GridScheduler {
|
||||
seed_point_name: seed_name,
|
||||
timeout_sec,
|
||||
workflow_name: Some(workflow_name.to_string()),
|
||||
wave,
|
||||
};
|
||||
|
||||
self.db.insert_task(&task_spec).await?;
|
||||
@@ -274,6 +270,7 @@ impl GridScheduler {
|
||||
pub async fn trigger_seed_step_fallback(
|
||||
&self,
|
||||
params: &GridPointParams,
|
||||
name: &str,
|
||||
workflow_name: &str,
|
||||
) -> Result<bool> {
|
||||
// 该工作流须仍处于 running 态才回退(避免 stop 后继续派发)
|
||||
@@ -291,9 +288,13 @@ impl GridScheduler {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let name = params.model_name();
|
||||
// name 取自权威的 report.point_name(= grid_points.name 列,源精度正确),
|
||||
// 而非 params.model_name()。原因:此处 params 经 node 上报回传,其 logg 等
|
||||
// 轴在服务端 DB REAL 列回读时已丢精度(5.0→"5"),重推 model_name() 会得到
|
||||
// 降级名(g5 而非 g5.0),导致回退任务的 point_name 与 grid_points.name 列错配,
|
||||
// 状态更新静默失败。与 runner 的修复保持同一原则:用权威 name。
|
||||
// 种子回退仅一次:该点在该工作流中已经派发过 seed_step 任务就不再触发新的回退
|
||||
if self.db.has_seed_step_attempt(&name, workflow_name).await? {
|
||||
if self.db.has_seed_step_attempt(name, workflow_name).await? {
|
||||
info!(
|
||||
"网格点 {} 已使用过一次种子热启动回退,不再重复回退,保持 failed 终态",
|
||||
name
|
||||
@@ -306,30 +307,27 @@ impl GridScheduler {
|
||||
|
||||
if let Some(seed_match) = seed_match_opt {
|
||||
let timeout_sec = self.get_workflow_timeout_sec(workflow_name).await;
|
||||
let name = params.model_name();
|
||||
let task_spec = TaskSpec {
|
||||
task_id: Uuid::new_v4(),
|
||||
point_name: name.clone(),
|
||||
point_name: name.to_string(),
|
||||
params: params.clone(),
|
||||
task_type: TaskType::SeedStep,
|
||||
seed_point_name: Some(seed_match.name.clone()),
|
||||
timeout_sec,
|
||||
workflow_name: Some(workflow_name.to_string()),
|
||||
// seed_step 是失败后的回退任务,wave 设 0 不抢占正常调度队列里的低难度 wave 优先级。
|
||||
wave: 0,
|
||||
};
|
||||
|
||||
self.db.insert_task(&task_spec).await?;
|
||||
self.db
|
||||
.update_grid_status(
|
||||
&name,
|
||||
common::models::GridPointStatus::Queued,
|
||||
workflow_name,
|
||||
)
|
||||
.update_grid_status(name, common::models::GridPointStatus::Queued, workflow_name)
|
||||
.await?;
|
||||
if let Err(e) = self.queue.push_task(&task_spec).await {
|
||||
let _ = self
|
||||
.db
|
||||
.update_grid_status(
|
||||
&name,
|
||||
name,
|
||||
common::models::GridPointStatus::Pending,
|
||||
workflow_name,
|
||||
)
|
||||
@@ -358,7 +356,6 @@ mod tests {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("sched_db.db");
|
||||
let queue_db_path = temp_dir.path().join("sched_queue.db");
|
||||
let results_dir = temp_dir.path().join("results");
|
||||
|
||||
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
|
||||
let queue = Arc::new(
|
||||
@@ -366,20 +363,17 @@ mod tests {
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let scheduler = GridScheduler::new(
|
||||
db.clone(),
|
||||
queue.clone(),
|
||||
results_dir.to_string_lossy().to_string(),
|
||||
);
|
||||
let scheduler = GridScheduler::new(db.clone(), queue.clone());
|
||||
|
||||
#[allow(deprecated)] // results 是死字段,构造时必须填 None
|
||||
let cfg = GridConfig {
|
||||
grid: GridAxesConfig {
|
||||
teff: vec![35000.0],
|
||||
logg: vec![5.5],
|
||||
loghe: vec![-1.0],
|
||||
logc: vec![-2.0],
|
||||
logn: vec![-2.0],
|
||||
logo: vec![-2.0],
|
||||
teff: vec![35000.0.into()],
|
||||
logg: vec![5.5.into()],
|
||||
loghe: vec![(-1.0).into()],
|
||||
logc: vec![(-2.0).into()],
|
||||
logn: vec![(-2.0).into()],
|
||||
logo: vec![(-2.0).into()],
|
||||
},
|
||||
chain: vec![],
|
||||
synspec: None,
|
||||
@@ -425,16 +419,17 @@ mod tests {
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let scheduler = GridScheduler::new(db.clone(), queue.clone(), "results".to_string());
|
||||
let scheduler = GridScheduler::new(db.clone(), queue.clone());
|
||||
|
||||
#[allow(deprecated)] // results 是死字段,构造时必须填 None
|
||||
let mk_cfg = |teff: f64| GridConfig {
|
||||
grid: GridAxesConfig {
|
||||
teff: vec![teff],
|
||||
logg: vec![5.5],
|
||||
loghe: vec![-1.0],
|
||||
logc: vec![-2.0],
|
||||
logn: vec![-2.0],
|
||||
logo: vec![-2.0],
|
||||
teff: vec![teff.into()],
|
||||
logg: vec![5.5.into()],
|
||||
loghe: vec![(-1.0).into()],
|
||||
logc: vec![(-2.0).into()],
|
||||
logn: vec![(-2.0).into()],
|
||||
logo: vec![(-2.0).into()],
|
||||
},
|
||||
chain: vec![],
|
||||
synspec: None,
|
||||
@@ -466,12 +461,12 @@ mod tests {
|
||||
// 重新推一个 wf_a 任务(上一行 pop 掉了),再初始化 wf_b
|
||||
db.update_grid_status(
|
||||
&GridPointParams {
|
||||
teff: 35000.0,
|
||||
logg: 5.5,
|
||||
loghe: -1.0,
|
||||
logc: -2.0,
|
||||
logn: -2.0,
|
||||
logo: -2.0,
|
||||
teff: 35000.0.into(),
|
||||
logg: 5.5.into(),
|
||||
loghe: (-1.0).into(),
|
||||
logc: (-2.0).into(),
|
||||
logn: (-2.0).into(),
|
||||
logo: (-2.0).into(),
|
||||
}
|
||||
.model_name(),
|
||||
common::models::GridPointStatus::Pending,
|
||||
@@ -496,12 +491,12 @@ mod tests {
|
||||
// 关键断言:把 wf_a 任务重新推回队列后,初始化 wf_b 不应清空它。
|
||||
db.update_grid_status(
|
||||
&GridPointParams {
|
||||
teff: 35000.0,
|
||||
logg: 5.5,
|
||||
loghe: -1.0,
|
||||
logc: -2.0,
|
||||
logn: -2.0,
|
||||
logo: -2.0,
|
||||
teff: 35000.0.into(),
|
||||
logg: 5.5.into(),
|
||||
loghe: (-1.0).into(),
|
||||
logc: (-2.0).into(),
|
||||
logn: (-2.0).into(),
|
||||
logo: (-2.0).into(),
|
||||
}
|
||||
.model_name(),
|
||||
common::models::GridPointStatus::Pending,
|
||||
|
||||
+1655
-148
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
||||
//! 验证「历史种子导入的工作流名」与「正式工作流名」的隔离关系。
|
||||
//!
|
||||
//! 用户意图:import_results 把旧 Python 计算结果导入,标记为已完成,避免重算。
|
||||
//! 关键问题:导入到工作流 A,之后正式启动工作流 B(同名/异名),B 能否看到 A 标记的 converged?
|
||||
|
||||
use common::config::GridConfig;
|
||||
use common::models::GridPointParams;
|
||||
use mq::sqlite_queue::SqliteTaskQueue;
|
||||
use server::{db::Database, scheduler::GridScheduler};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn make_params() -> GridPointParams {
|
||||
use common::models::GridAxisValue;
|
||||
GridPointParams {
|
||||
teff: GridAxisValue::from_value(20000.0),
|
||||
logg: GridAxisValue::from_value(5.0),
|
||||
loghe: GridAxisValue::from_value(-2.0),
|
||||
logc: GridAxisValue::from_value(-4.0),
|
||||
logn: GridAxisValue::from_value(-4.0),
|
||||
logo: GridAxisValue::from_value(-4.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造只含一个网格点(t20000_g5.0_he-2_c-4_n-4_o-4)的 config。
|
||||
fn make_grid_cfg() -> GridConfig {
|
||||
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
|
||||
GridConfig::from_yaml_str(yaml).unwrap()
|
||||
}
|
||||
|
||||
async fn setup() -> (Database, Arc<GridScheduler>) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let db = Database::new(&tmp.path().join("db.db").to_string_lossy())
|
||||
.await
|
||||
.unwrap();
|
||||
let queue = Arc::new(
|
||||
SqliteTaskQueue::new(&tmp.path().join("q.db").to_string_lossy())
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let sched = Arc::new(GridScheduler::new(db.clone(), queue));
|
||||
(db, sched)
|
||||
}
|
||||
|
||||
/// 场景 1(正确用法):导入到工作流 "sdB_cno",再用同名 config initialize_grid。
|
||||
/// 期望:initialize_grid 的 ON CONFLICT(workflow_name, name) DO NOTHING 保留 converged 状态。
|
||||
#[tokio::test]
|
||||
async fn test_same_workflow_name_preserves_converged() {
|
||||
let (db, sched) = setup().await;
|
||||
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||||
let p = make_params();
|
||||
|
||||
// 模拟 import_seed:upsert + mark_imported,工作流名 = sdB_cno
|
||||
db.upsert_grid_point_named(name, &p, 0, "sdB_cno")
|
||||
.await
|
||||
.unwrap();
|
||||
db.mark_grid_point_imported(name, "sdB_cno", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 之后正式启动同名工作流:initialize_grid(sdB_cno)
|
||||
sched
|
||||
.initialize_grid(&make_grid_cfg(), "sdB_cno")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let st = db.get_grid_point_status(name, "sdB_cno").await.unwrap();
|
||||
assert_eq!(
|
||||
st.unwrap().0,
|
||||
"converged",
|
||||
"同名工作流:导入的 converged 应被保留,避免重算"
|
||||
);
|
||||
println!("✓ 场景1(同名):status=converged,旧结果被保留,不会重算");
|
||||
}
|
||||
|
||||
/// 场景 2(错误用法):导入到工作流 "imported",之后正式启动 "sdB_cno"。
|
||||
/// 期望:sdB_cno 分区下是新插入的 pending 行,看不到 imported 分区的 converged。
|
||||
#[tokio::test]
|
||||
async fn test_different_workflow_name_causes_recompute() {
|
||||
let (db, sched) = setup().await;
|
||||
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||||
let p = make_params();
|
||||
|
||||
// 模拟 import_seed:导入到 "imported" 工作流
|
||||
db.upsert_grid_point_named(name, &p, 0, "imported")
|
||||
.await
|
||||
.unwrap();
|
||||
db.mark_grid_point_imported(name, "imported", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 之后正式启动 "sdB_cno" 工作流
|
||||
sched
|
||||
.initialize_grid(&make_grid_cfg(), "sdB_cno")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// imported 分区:converged(种子库有,但不会被 sdB_cno 调度看到)
|
||||
let st_imp = db.get_grid_point_status(name, "imported").await.unwrap();
|
||||
assert_eq!(st_imp.unwrap().0, "converged");
|
||||
|
||||
// sdB_cno 分区:pending(重新算!看不到 imported 的 converged)
|
||||
let st_real = db.get_grid_point_status(name, "sdB_cno").await.unwrap();
|
||||
assert_eq!(
|
||||
st_real.unwrap().0,
|
||||
"pending",
|
||||
"异名工作流:sdB_cno 看不到 imported 的 converged,会重算"
|
||||
);
|
||||
println!("✓ 场景2(异名):sdB_cno 分区 status=pending,会重复计算 —— 验证了工作流名必须匹配");
|
||||
}
|
||||
|
||||
/// 场景 3(真实意图验证):旧网格有部分点已算完(导入为 converged),
|
||||
/// 新网格比旧网格多了若干点。用同名工作流启动后:
|
||||
/// - 旧点保持 converged(不重算)
|
||||
/// - 新点是 pending(会被调度计算)
|
||||
/// 这正是「同步旧结果避免重算」的核心语义。
|
||||
#[tokio::test]
|
||||
async fn test_mixed_grid_import_then_init_avoids_recompute() {
|
||||
let (db, sched) = setup().await;
|
||||
let p_old = make_params(); // t20000_g5.0_...
|
||||
|
||||
// 模拟 import_seed:旧网格里这个点已收敛,导入到 sdB_cno
|
||||
db.upsert_grid_point_named("t20000_g5.0_he-2_c-4_n-4_o-4", &p_old, 0, "sdB_cno")
|
||||
.await
|
||||
.unwrap();
|
||||
db.mark_grid_point_imported("t20000_g5.0_he-2_c-4_n-4_o-4", "sdB_cno", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 正式启动 sdB_cno,config 比旧网格多了一个新点(t25000)
|
||||
let yaml = "grid:\n teff: [20000, 25000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
|
||||
let cfg = GridConfig::from_yaml_str(yaml).unwrap();
|
||||
sched.initialize_grid(&cfg, "sdB_cno").await.unwrap();
|
||||
|
||||
// 旧点:converged(不重算)
|
||||
let st_old = db
|
||||
.get_grid_point_status("t20000_g5.0_he-2_c-4_n-4_o-4", "sdB_cno")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
st_old.unwrap().0,
|
||||
"converged",
|
||||
"旧点应保持 converged 不重算"
|
||||
);
|
||||
|
||||
// 新点:pending(会被调度)
|
||||
let st_new = db
|
||||
.get_grid_point_status("t25000_g5.0_he-2_c-4_n-4_o-4", "sdB_cno")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(st_new.unwrap().0, "pending", "新点应为 pending 等待计算");
|
||||
|
||||
println!("✓ 场景3(混合网格):旧点converged保留 + 新点pending待算 —— 完全符合避免重算的意图");
|
||||
}
|
||||
|
||||
/// 场景 4(精度差异命门):旧 conv.json name=g5(无小数),但配置 logg=5.0 → model_name()=g5.0。
|
||||
/// 导入工具必须把 name 重写为 g5.0 入库,否则 initialize_grid 插入的 g5.0 行与导入的 g5 行
|
||||
/// 复合唯一键不匹配,导入的 converged 被孤立、g5.0 被重算。
|
||||
/// 本测试直接模拟「入库的 grid_points.name = g5.0」(即工具重写后的状态),
|
||||
/// 验证 initialize_grid(sdB_cno) 后该行保持 converged(不重算)。
|
||||
#[tokio::test]
|
||||
async fn test_precision_diff_import_then_init_preserves_converged() {
|
||||
let (db, sched) = setup().await;
|
||||
// 配置权威名(logg=5.0 → g5.0,保留小数)
|
||||
let canonical = "t20000_g5.0_he-2_c-4_n-4_o-4";
|
||||
use common::models::GridAxisValue;
|
||||
let p = GridPointParams {
|
||||
teff: GridAxisValue::from_value(20000.0),
|
||||
logg: GridAxisValue::from_value(5.0),
|
||||
loghe: GridAxisValue::from_value(-2.0),
|
||||
logc: GridAxisValue::from_value(-4.0),
|
||||
logn: GridAxisValue::from_value(-4.0),
|
||||
logo: GridAxisValue::from_value(-4.0),
|
||||
};
|
||||
|
||||
// 模拟 import_results 重写 name 后入库:grid_points.name = canonical(g5.0)
|
||||
db.upsert_grid_point_named(canonical, &p, 0, "sdB_cno")
|
||||
.await
|
||||
.unwrap();
|
||||
db.mark_grid_point_imported(canonical, "sdB_cno", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 启动同名工作流:initialize_grid 用配置 model_name()(=g5.0) 插入
|
||||
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\n";
|
||||
let cfg = GridConfig::from_yaml_str(yaml).unwrap();
|
||||
sched.initialize_grid(&cfg, "sdB_cno").await.unwrap();
|
||||
|
||||
// 关键断言:name=g5.0 的行保持 converged(ON CONFLICT DO NOTHING 命中)
|
||||
let st = db
|
||||
.get_grid_point_status(canonical, "sdB_cno")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
st.unwrap().0,
|
||||
"converged",
|
||||
"精度一致(g5.0=g5.0)时导入的 converged 必须保留,不重算"
|
||||
);
|
||||
println!("✓ 场景4(精度差异命门):g5.0 入库 + initialize_grid → converged 保留,避免重算");
|
||||
}
|
||||
Reference in New Issue
Block a user