feat(server,dashboard): 引入多工作流数据隔离、安全中间件与前端 ESM 模块化重构

- server: 实现按 workflow_name 的多工作流数据隔离与旧数据库平滑迁移机制
- server: 新增 API Key 认证(auth)、限流中间件(rate_limit)与运维备份接口(admin)
- server: 统一 AppError 错误处理体系,重构调度器 scheduler 支持工作流级重置与抢占
- node: 节点 ID 缺失时自动生成随机 UUID,原生支持 `docker compose --scale node=N` 动态扩容
- dashboard: 前端模块化重构(state/api/components),升级 CSS 变量设计系统与 Toast 通知
- docker/docs: 更新 /healthz 健康检查、部署脚本 IP 配置及数据库设计文档
This commit is contained in:
fmq
2026-07-28 21:54:02 +08:00
parent 4b4238d702
commit b91f1e4fa5
59 changed files with 7697 additions and 1490 deletions
+112 -28
View File
@@ -135,19 +135,56 @@ impl GridConfig {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub bind_addr: String,
pub db_path: String,
pub queue_db_path: String,
pub results_dir: String,
/// 数据库备份目录(每日自动备份落盘位置)。默认 "data/backups",可经 DCTS_BACKUP_DIR 覆盖。
pub backup_dir: String,
pub grid_config: String,
pub stale_sec: u64,
#[serde(default = "default_node_stale_sec")]
pub node_stale_sec: u64,
pub mq_type: String, // "sqlite" or "rabbitmq"
pub rabbitmq_url: Option<String>,
/// 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,
}
// 手写 Debugtoken 类字段脱敏为 ***REDACTED***,防止日志/错误链泄露明文凭据。
impl std::fmt::Debug for ServerConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("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("backup_dir", &self.backup_dir)
.field("grid_config", &self.grid_config)
.field("stale_sec", &self.stale_sec)
.field("node_stale_sec", &self.node_stale_sec)
.field("mq_type", &self.mq_type)
.field("rabbitmq_url", &self.rabbitmq_url)
.field(
"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()
}
}
fn default_node_stale_sec() -> u64 {
@@ -160,12 +197,13 @@ impl Default for ServerConfig {
.or_else(|_| std::env::var("CNO_PORT"))
.or_else(|_| std::env::var("PORT"))
.unwrap_or_else(|_| "8090".to_string());
let db_path = std::env::var("DCTS_DB_PATH")
.unwrap_or_else(|_| "data/dcts.db".to_string());
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());
let results_dir =
std::env::var("DCTS_RESULTS_DIR").unwrap_or_else(|_| "data/results".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")
.unwrap_or_else(|_| "workflows/sdB_cno.yaml".to_string());
// 默认设置为 7800 秒,比计算任务默认超时(7200 秒)高 600 秒缓冲,避免两边的超时检测同时触发冲突
@@ -177,26 +215,63 @@ impl Default for ServerConfig {
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(60);
let mq_type = std::env::var("DCTS_MQ_TYPE")
.unwrap_or_else(|_| "sqlite".to_string());
let mq_type = std::env::var("DCTS_MQ_TYPE").unwrap_or_else(|_| "sqlite".to_string());
let rabbitmq_url = std::env::var("DCTS_RABBITMQ_URL").ok();
let auth_token = std::env::var("DCTS_AUTH_TOKEN").ok();
// ── 鉴权凭据解析 ──
let legacy_token = std::env::var("DCTS_AUTH_TOKEN")
.ok()
.filter(|s| !s.is_empty());
let admin_token = std::env::var("DCTS_ADMIN_TOKEN")
.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())
{
tracing::warn!(
"检测到旧的 DCTS_AUTH_TOKEN,已自动用作 admin/enrollment 凭据。\
建议迁移到 DCTS_ADMIN_TOKEN(管理)与 DCTS_ENROLLMENT_TOKEN(节点注册)"
);
}
let auth_disabled = std::env::var("DCTS_AUTH_DISABLE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
if auth_disabled {
tracing::warn!(
"⚠️ DCTS_AUTH_DISABLE=1 已生效:全部鉴权被跳过,仅供本地调试,切勿用于生产!"
);
}
// 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,
backup_dir,
grid_config,
stale_sec,
node_stale_sec,
mq_type,
rabbitmq_url,
admin_token,
auth_token,
auth_disabled,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize)]
pub struct NodeConfig {
pub node_id: String,
pub server_url: String,
@@ -204,7 +279,19 @@ pub struct NodeConfig {
pub runtime_dir: String,
pub work_dir: String,
pub heartbeat_sec: u64,
pub auth_token: Option<String>,
}
impl std::fmt::Debug for NodeConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NodeConfig")
.field("node_id", &self.node_id)
.field("server_url", &self.server_url)
.field("max_slots", &self.max_slots)
.field("runtime_dir", &self.runtime_dir)
.field("work_dir", &self.work_dir)
.field("heartbeat_sec", &self.heartbeat_sec)
.finish()
}
}
impl Default for NodeConfig {
@@ -215,22 +302,27 @@ impl Default for NodeConfig {
.unwrap_or_else(|_| "http://127.0.0.1:8090".to_string());
let node_id = std::env::var("DCTS_NODE_ID")
.or_else(|_| std::env::var("NODE_ID"))
.and_then(|v| if v.trim().is_empty() { Err(std::env::VarError::NotPresent) } else { Ok(v) })
.and_then(|v| {
if v.trim().is_empty() {
Err(std::env::VarError::NotPresent)
} else {
Ok(v)
}
})
.unwrap_or_else(|_| format!("node-{}", uuid::Uuid::new_v4().simple()));
let max_slots = std::env::var("DCTS_MAX_SLOTS")
.or_else(|_| std::env::var("MAX_SLOTS"))
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(4);
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());
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());
let heartbeat_sec = std::env::var("DCTS_HEARTBEAT_SEC")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(15);
let auth_token = std::env::var("DCTS_AUTH_TOKEN").ok();
Self {
node_id,
server_url,
@@ -238,7 +330,6 @@ impl Default for NodeConfig {
runtime_dir,
work_dir,
heartbeat_sec,
auth_token,
}
}
}
@@ -250,21 +341,14 @@ mod tests {
#[test]
fn test_load_real_grid_configs() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let sdb_path = root.join("workflows/sdB_cno.yaml");
if sdb_path.exists() {
let cfg = GridConfig::load_from_file(&sdb_path).expect("解析 workflows/sdB_cno.yaml 发生失败");
let cfg = GridConfig::load_from_file(&sdb_path)
.expect("解析 workflows/sdB_cno.yaml 发生失败");
assert_eq!(cfg.nworkers, 16);
assert_eq!(cfg.niter, Some(100));
}
let dense_path = root.join("config_dense.yaml");
if dense_path.exists() {
let cfg = GridConfig::load_from_file(&dense_path).expect("解析 config_dense.yaml 发生失败");
assert_eq!(cfg.template.as_deref(), Some("templates/cno_atmos.5.tpl"));
assert_eq!(cfg.linelist.as_deref(), Some("data/gfVIS99.dat"));
assert!(cfg.seed_step_fallback);
}
}
}
+22 -13
View File
@@ -80,15 +80,12 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
}
// Safely find depth with maximum absolute change without unwrap panic on NaN
let worst = match cur_rows
.iter()
.max_by(|a, b| {
a.maximum
.abs()
.partial_cmp(&b.maximum.abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
{
let worst = match cur_rows.iter().max_by(|a, b| {
a.maximum
.abs()
.partial_cmp(&b.maximum.abs())
.unwrap_or(std::cmp::Ordering::Equal)
}) {
Some(row) => row,
None => {
return ConvCheckResult {
@@ -98,7 +95,9 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
last_iter,
n_depths: 0,
chmax,
error: Some("No valid iteration rows found when calculating maximum change".to_string()),
error: Some(
"No valid iteration rows found when calculating maximum change".to_string(),
),
};
}
};
@@ -113,15 +112,22 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
last_iter,
n_depths: cur_rows.len(),
chmax,
error: if is_valid_num { None } else { Some("Convergence value is NaN or Inf".to_string()) },
error: if is_valid_num {
None
} else {
Some("Convergence value is NaN or Inf".to_string())
},
}
}
/// Checks if an atmosphere file (.7) contains NaN lines (>10% NaN lines = invalid) using exact word boundary
///
/// 文件缺失时返回 `false`(语义:不存在 NaN 内容)。这与“含 NaN 导致无效”是不同语义;
/// 调用方需先自行确认文件存在性,不应将“缺失”与“含 NaN”混为一谈。
pub fn atmosphere_has_nan(path: &Path) -> bool {
let file = match File::open(path) {
Ok(f) => f,
Err(_) => return true,
Err(_) => return false,
};
let reader = BufReader::new(file);
let mut total_lines = 0;
@@ -142,7 +148,6 @@ pub fn atmosphere_has_nan(path: &Path) -> bool {
(nan_lines as f64) > (total_lines as f64 * 0.1)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -162,5 +167,9 @@ mod tests {
let banana_file_path = dir.path().join("banana.7");
std::fs::write(&banana_file_path, "banana 2 3\nbanana 5 6\n7 8 9\n").unwrap();
assert!(!atmosphere_has_nan(&banana_file_path));
// Missing file returns false (absence != contains NaN)
let missing_path = dir.path().join("missing.7");
assert!(!atmosphere_has_nan(&missing_path));
}
}
+26 -7
View File
@@ -54,9 +54,13 @@ pub async fn ensure_runtime(
write_if_changed(&synspec_exe, SYNSPEC_BIN, true)?;
}
// 2. Fetch baseline equation of state partition function tables if missing locally
let common_files = &["irwin_bc.dat", "irwin_orig.dat", "tsuji.molec_bc2", "tsuji.molec_orig"];
let common_files = &[
"irwin_bc.dat",
"irwin_orig.dat",
"tsuji.molec_bc2",
"tsuji.molec_orig",
];
ensure_specific_data_files(&data_dir, server_url, client, common_files).await?;
// 3. Check gfVIS99.dat
@@ -69,11 +73,15 @@ pub async fn ensure_runtime(
fs::write(&linelist, &bytes)?;
info!("成功下载并保存主谱线库 gfVIS99.dat");
} else {
anyhow::bail!("从服务端下载主谱线库 gfVIS99.dat 失败,HTTP 状态码: {}", resp.status());
anyhow::bail!(
"从服务端下载主谱线库 gfVIS99.dat 失败,HTTP 状态码: {}",
resp.status()
);
}
}
let abs_runtime_dir = fs::canonicalize(runtime_dir).unwrap_or_else(|_| runtime_dir.to_path_buf());
let abs_runtime_dir =
fs::canonicalize(runtime_dir).unwrap_or_else(|_| runtime_dir.to_path_buf());
let tlusty_exe = abs_runtime_dir.join("tlusty_static");
let synspec_exe = abs_runtime_dir.join("synspec_static");
let data_dir = abs_runtime_dir.join("data");
@@ -103,17 +111,28 @@ pub async fn ensure_specific_data_files(
let local_file = data_dir.join(filename);
if !local_file.exists() {
let file_url = format!("{}/api/data/file/{}", server_url, filename);
info!("本地缺失数据文件 {},开始从服务端拉取: {}", filename, file_url);
info!(
"本地缺失数据文件 {},开始从服务端拉取: {}",
filename, file_url
);
let resp = client.get(&file_url).send().await?;
if resp.status().is_success() {
let bytes = resp.bytes().await?;
let tmp_file = data_dir.join(format!("{}.{}.tmp", filename, uuid::Uuid::new_v4().simple()));
let tmp_file = data_dir.join(format!(
"{}.{}.tmp",
filename,
uuid::Uuid::new_v4().simple()
));
tokio::fs::write(&tmp_file, &bytes).await?;
tokio::fs::rename(&tmp_file, &local_file).await?;
info!("成功保存数据文件: {}", filename);
} else {
anyhow::bail!("服务端返回 HTTP {} 错误,数据文件: {}", resp.status(), filename);
anyhow::bail!(
"服务端返回 HTTP {} 错误,数据文件: {}",
resp.status(),
filename
);
}
}
}
+8 -2
View File
@@ -7,10 +7,16 @@ pub fn generate_fort55_content(cfg: &SynspecConfig) -> String {
let line3 = " 0 0 0 0 0";
let line4 = " 1 1 0 0 0";
let line5 = " 0 0 0";
let line6 = format!(" {:.1} {:.1} 10 0 {} {}", cfg.wstart, cfg.wend, cfg.rel_cutoff, cfg.abs_cutoff);
let line6 = format!(
" {:.1} {:.1} 10 0 {} {}",
cfg.wstart, cfg.wend, cfg.rel_cutoff, cfg.abs_cutoff
);
let line7 = " 0 0";
format!("{}\n{}\n{}\n{}\n{}\n{}\n{}\n", line1, line2, line3, line4, line5, line6, line7)
format!(
"{}\n{}\n{}\n{}\n{}\n{}\n{}\n",
line1, line2, line3, line4, line5, line6, line7
)
}
#[cfg(test)]
+161 -28
View File
@@ -9,40 +9,172 @@ struct IonDef {
}
const IONS_H: &[IonDef] = &[
IonDef { iat: 1, iz: 0, nlevs: 9, typion: " H 1", filei: "data/h1.dat" },
IonDef { iat: 1, iz: 1, nlevs: 1, typion: " H 2", filei: " " },
IonDef {
iat: 1,
iz: 0,
nlevs: 9,
typion: " H 1",
filei: "data/h1.dat",
},
IonDef {
iat: 1,
iz: 1,
nlevs: 1,
typion: " H 2",
filei: " ",
},
];
const IONS_HE: &[IonDef] = &[
IonDef { iat: 2, iz: 0, nlevs: 14, typion: "He 1", filei: "data/he1.dat" },
IonDef { iat: 2, iz: 1, nlevs: 14, typion: "He 2", filei: "data/he2.dat" },
IonDef { iat: 2, iz: 2, nlevs: 1, typion: "He 3", filei: " " },
IonDef {
iat: 2,
iz: 0,
nlevs: 14,
typion: "He 1",
filei: "data/he1.dat",
},
IonDef {
iat: 2,
iz: 1,
nlevs: 14,
typion: "He 2",
filei: "data/he2.dat",
},
IonDef {
iat: 2,
iz: 2,
nlevs: 1,
typion: "He 3",
filei: " ",
},
];
const IONS_C: &[IonDef] = &[
IonDef { iat: 6, iz: 0, nlevs: 40, typion: " C 1", filei: "data/c1.dat" },
IonDef { iat: 6, iz: 1, nlevs: 22, typion: " C 2", filei: "data/c2.dat" },
IonDef { iat: 6, iz: 2, nlevs: 46, typion: " C 3", filei: "data/c3_34+12lev.dat" },
IonDef { iat: 6, iz: 3, nlevs: 25, typion: " C 4", filei: "data/c4.dat" },
IonDef { iat: 6, iz: 4, nlevs: 1, typion: " C 5", filei: " " },
IonDef {
iat: 6,
iz: 0,
nlevs: 40,
typion: " C 1",
filei: "data/c1.dat",
},
IonDef {
iat: 6,
iz: 1,
nlevs: 22,
typion: " C 2",
filei: "data/c2.dat",
},
IonDef {
iat: 6,
iz: 2,
nlevs: 46,
typion: " C 3",
filei: "data/c3_34+12lev.dat",
},
IonDef {
iat: 6,
iz: 3,
nlevs: 25,
typion: " C 4",
filei: "data/c4.dat",
},
IonDef {
iat: 6,
iz: 4,
nlevs: 1,
typion: " C 5",
filei: " ",
},
];
const IONS_N: &[IonDef] = &[
IonDef { iat: 7, iz: 0, nlevs: 34, typion: " N 1", filei: "data/n1.dat" },
IonDef { iat: 7, iz: 1, nlevs: 42, typion: " N 2", filei: "data/n2_32+10lev.dat" },
IonDef { iat: 7, iz: 2, nlevs: 32, typion: " N 3", filei: "data/n3.dat" },
IonDef { iat: 7, iz: 3, nlevs: 48, typion: " N 4", filei: "data/n4_34+14lev.dat" },
IonDef { iat: 7, iz: 4, nlevs: 16, typion: " N 5", filei: "data/n5.dat" },
IonDef { iat: 7, iz: 5, nlevs: 1, typion: " N 6", filei: " " },
IonDef {
iat: 7,
iz: 0,
nlevs: 34,
typion: " N 1",
filei: "data/n1.dat",
},
IonDef {
iat: 7,
iz: 1,
nlevs: 42,
typion: " N 2",
filei: "data/n2_32+10lev.dat",
},
IonDef {
iat: 7,
iz: 2,
nlevs: 32,
typion: " N 3",
filei: "data/n3.dat",
},
IonDef {
iat: 7,
iz: 3,
nlevs: 48,
typion: " N 4",
filei: "data/n4_34+14lev.dat",
},
IonDef {
iat: 7,
iz: 4,
nlevs: 16,
typion: " N 5",
filei: "data/n5.dat",
},
IonDef {
iat: 7,
iz: 5,
nlevs: 1,
typion: " N 6",
filei: " ",
},
];
const IONS_O: &[IonDef] = &[
IonDef { iat: 8, iz: 0, nlevs: 33, typion: " O 1", filei: "data/o1_23+10lev.dat" },
IonDef { iat: 8, iz: 1, nlevs: 48, typion: " O 2", filei: "data/o2_36+12lev.dat" },
IonDef { iat: 8, iz: 2, nlevs: 41, typion: " O 3", filei: "data/o3_28+13lev.dat" },
IonDef { iat: 8, iz: 3, nlevs: 39, typion: " O 4", filei: "data/o4.dat" },
IonDef { iat: 8, iz: 4, nlevs: 6, typion: " O 5", filei: "data/o5.dat" },
IonDef { iat: 8, iz: 5, nlevs: 1, typion: " O 6", filei: " " },
IonDef {
iat: 8,
iz: 0,
nlevs: 33,
typion: " O 1",
filei: "data/o1_23+10lev.dat",
},
IonDef {
iat: 8,
iz: 1,
nlevs: 48,
typion: " O 2",
filei: "data/o2_36+12lev.dat",
},
IonDef {
iat: 8,
iz: 2,
nlevs: 41,
typion: " O 3",
filei: "data/o3_28+13lev.dat",
},
IonDef {
iat: 8,
iz: 3,
nlevs: 39,
typion: " O 4",
filei: "data/o4.dat",
},
IonDef {
iat: 8,
iz: 4,
nlevs: 6,
typion: " O 5",
filei: "data/o5.dat",
},
IonDef {
iat: 8,
iz: 5,
nlevs: 1,
typion: " O 6",
filei: " ",
},
];
fn fmt_abn(logx: f64) -> String {
@@ -64,11 +196,11 @@ 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 {
@@ -81,7 +213,8 @@ pub fn make_input5(
atom_rows.push((2, fmt_abn(params.logo))); // 8 O
}
let natoms = 5 + (if has_c { 1 } else { 0 }) + (if has_n { 1 } else { 0 }) + (if has_o { 1 } else { 0 });
let natoms =
5 + (if has_c { 1 } else { 0 }) + (if has_n { 1 } else { 0 }) + (if has_o { 1 } else { 0 });
let mut atoms_block = format!(" {}\n* mode abn modpf\n", natoms);
for (mode, abn) in &atom_rows {
-1
View File
@@ -8,4 +8,3 @@ pub mod models;
pub mod nst_writer;
pub mod runner;
pub mod seed_finder;
+5 -3
View File
@@ -17,7 +17,6 @@ impl FormatTime for LocalTimeFormatter {
}
}
/// Initializes high-performance, non-blocking structured logging for DCTS applications
pub fn init_logging(app_name: &str, default_filter: &str) -> Result<Vec<WorkerGuard>> {
let mut guards = Vec::new();
@@ -29,7 +28,8 @@ pub fn init_logging(app_name: &str, default_filter: &str) -> Result<Vec<WorkerGu
let log_outputs = env::var("LOG_OUTPUTS").unwrap_or_else(|_| "stdout,file".to_string());
let log_dir = env::var("LOG_DIR").unwrap_or_else(|_| "data/logs".to_string());
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&log_level));
let env_filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&log_level));
let is_json = log_format.to_lowercase() == "json";
let mut layers: Vec<Box<dyn Layer<tracing_subscriber::Registry> + Send + Sync>> = Vec::new();
@@ -39,7 +39,9 @@ pub fn init_logging(app_name: &str, default_filter: &str) -> Result<Vec<WorkerGu
let (non_blocking, guard) = tracing_appender::non_blocking(std::io::stdout());
guards.push(guard);
let fmt_layer = fmt::layer().with_timer(LocalTimeFormatter).with_writer(non_blocking);
let fmt_layer = fmt::layer()
.with_timer(LocalTimeFormatter)
.with_writer(non_blocking);
if is_json {
layers.push(fmt_layer.json().with_ansi(false).boxed());
} else {
+9 -4
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// 6D grid point parameter specification
@@ -84,7 +84,6 @@ impl std::fmt::Display for GridPointStatus {
}
}
impl From<&str> for GridPointStatus {
fn from(s: &str) -> Self {
match s {
@@ -106,6 +105,10 @@ pub struct TaskSpec {
pub task_type: TaskType,
pub seed_point_name: Option<String>,
pub timeout_sec: u64,
/// 所属工作流名称,用于按工作流隔离队列清理(stop_workflow 只清当前工作流的任务)。
/// 旧数据反序列化时缺省为 None。
#[serde(default)]
pub workflow_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -251,10 +254,12 @@ mod tests {
assert_eq!(GridPointStatus::Failed.to_string(), "failed");
assert_eq!(GridPointStatus::from("queued"), GridPointStatus::Queued);
assert_eq!(GridPointStatus::from("converged"), GridPointStatus::Converged);
assert_eq!(
GridPointStatus::from("converged"),
GridPointStatus::Converged
);
assert_eq!(GridPointStatus::from("done"), GridPointStatus::Converged);
assert_eq!(GridPointStatus::from("failed"), GridPointStatus::Failed);
assert_eq!(GridPointStatus::from("unknown"), GridPointStatus::Pending);
}
}
-1
View File
@@ -63,4 +63,3 @@ mod tests {
assert!(content.contains("IELCOR=-1"));
}
}
+72 -16
View File
@@ -6,11 +6,11 @@ use crate::gen_input5::make_input5;
use crate::models::{GridPointParams, ModelSummary, StageSummary, TaskType};
use crate::nst_writer::generate_nst_content;
use anyhow::Result;
use tokio::fs::File;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use tokio::process::Command as AsyncCommand;
use std::time::Instant;
use tokio::fs::File;
use tokio::process::Command as AsyncCommand;
use tracing::{info, warn};
pub fn default_cold_chain() -> Vec<StageConfig> {
@@ -130,7 +130,15 @@ impl<'a> ExecutionRunner<'a> {
seed_atmos: Option<&Path>,
synspec_cfg: Option<&SynspecConfig>,
) -> Result<ModelSummary> {
self.run_model_with_timeout(params, task_type, custom_chain, seed_atmos, synspec_cfg, 7200).await
self.run_model_with_timeout(
params,
task_type,
custom_chain,
seed_atmos,
synspec_cfg,
7200,
)
.await
}
pub async fn run_model_with_timeout(
@@ -157,7 +165,9 @@ impl<'a> ExecutionRunner<'a> {
#[cfg(unix)]
{
let abs_data_dir = tokio::fs::canonicalize(&self.runtime.data_dir).await.unwrap_or_else(|_| self.runtime.data_dir.clone());
let abs_data_dir = tokio::fs::canonicalize(&self.runtime.data_dir)
.await
.unwrap_or_else(|_| self.runtime.data_dir.clone());
if let Err(e) = std::os::unix::fs::symlink(&abs_data_dir, &link_data) {
warn!("构建 data 数据集软链时发生提示性告警: {}", e);
}
@@ -171,7 +181,10 @@ impl<'a> ExecutionRunner<'a> {
if let Some(seed_path) = seed_atmos {
if seed_path.is_file() {
if let Err(e) = tokio::fs::copy(seed_path, &fort8).await {
warn!("向工作沙盒引导填载首期收敛模型种子 fort.8 发生复制错误: {}", e);
warn!(
"向工作沙盒引导填载首期收敛模型种子 fort.8 发生复制错误: {}",
e
);
}
}
}
@@ -219,15 +232,24 @@ impl<'a> ExecutionRunner<'a> {
} else if let Some(ref s_path) = current_seed {
if s_path.is_file() {
if let Err(e) = tokio::fs::copy(s_path, &fort8).await {
warn!("阶段 {} 重载候选近邻推算种子模型期间发生文件复制异常: {}", stage_def.label, e);
warn!(
"阶段 {} 重载候选近邻推算种子模型期间发生文件复制异常: {}",
stage_def.label, e
);
}
}
}
// Run tlusty.exe
let fin = File::open(&input5_path).await?.into_std().await;
let fout = File::create(model_dir.join(format!("{}.6", name))).await?.into_std().await;
let ferr = File::create(model_dir.join(format!("{}.err", name))).await?.into_std().await;
let fout = File::create(model_dir.join(format!("{}.6", name)))
.await?
.into_std()
.await;
let ferr = File::create(model_dir.join(format!("{}.err", name)))
.await?
.into_std()
.await;
let child = AsyncCommand::new(&self.runtime.tlusty_exe)
.current_dir(&model_dir)
@@ -246,7 +268,6 @@ impl<'a> ExecutionRunner<'a> {
}
};
let fort9 = model_dir.join("fort.9");
let fort7 = model_dir.join("fort.7");
@@ -293,7 +314,10 @@ impl<'a> ExecutionRunner<'a> {
stage_summaries.push(stage_summary);
if !final_converged && stage_def.require_converged {
warn!("阶段 {} 要求收敛但未达标,中止后续收敛链阶段", stage_def.label);
warn!(
"阶段 {} 要求收敛但未达标,中止后续收敛链阶段",
stage_def.label
);
break;
}
}
@@ -344,14 +368,19 @@ impl<'a> ExecutionRunner<'a> {
#[cfg(unix)]
{
let abs_linelist = tokio::fs::canonicalize(&self.runtime.linelist).await.unwrap_or_else(|_| self.runtime.linelist.clone());
let abs_linelist = tokio::fs::canonicalize(&self.runtime.linelist)
.await
.unwrap_or_else(|_| self.runtime.linelist.clone());
let _ = std::os::unix::fs::symlink(&abs_linelist, &fort19_path);
}
let input5_path = model_dir.join(format!("{}.5", name));
if input5_path.is_file() {
let fin = File::open(&input5_path).await?.into_std().await;
let fout = File::create(model_dir.join(format!("{}.log", name))).await?.into_std().await;
let fout = File::create(model_dir.join(format!("{}.log", name)))
.await?
.into_std()
.await;
let child = AsyncCommand::new(&self.runtime.synspec_exe)
.current_dir(&model_dir)
@@ -361,7 +390,8 @@ impl<'a> ExecutionRunner<'a> {
.kill_on_drop(true)
.spawn()?;
let status_res = run_child_async_with_timeout(child, timeout_sec).await;
let synspec_timeout_sec = 600_u64.min(timeout_sec);
let status_res = run_child_async_with_timeout(child, synspec_timeout_sec).await;
let rc = match status_res {
Ok(st) => st.code().unwrap_or(-1),
Err(e) => {
@@ -374,13 +404,25 @@ impl<'a> ExecutionRunner<'a> {
// Copy/move outputs: fort.7 (Synspec spectrum) -> .spec, fort.17 -> .cont, fort.12 -> .iden
if model_dir.join("fort.7").is_file() {
let _ = tokio::fs::rename(model_dir.join("fort.7"), model_dir.join(format!("{}.spec", name))).await;
let _ = tokio::fs::rename(
model_dir.join("fort.7"),
model_dir.join(format!("{}.spec", name)),
)
.await;
}
if model_dir.join("fort.17").is_file() {
let _ = tokio::fs::copy(model_dir.join("fort.17"), model_dir.join(format!("{}.cont", name))).await;
let _ = tokio::fs::copy(
model_dir.join("fort.17"),
model_dir.join(format!("{}.cont", name)),
)
.await;
}
if model_dir.join("fort.12").is_file() {
let _ = tokio::fs::copy(model_dir.join("fort.12"), model_dir.join(format!("{}.iden", name))).await;
let _ = tokio::fs::copy(
model_dir.join("fort.12"),
model_dir.join(format!("{}.iden", name)),
)
.await;
}
}
} else {
@@ -415,3 +457,17 @@ impl<'a> ExecutionRunner<'a> {
Ok(summary)
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_synspec_timeout_calculation() {
let long_tlusty_timeout: u64 = 7200;
let synspec_timeout = 600_u64.min(long_tlusty_timeout);
assert_eq!(synspec_timeout, 600);
let short_tlusty_timeout: u64 = 300;
let synspec_timeout_short = 600_u64.min(short_tlusty_timeout);
assert_eq!(synspec_timeout_short, 300);
}
}
+5 -4
View File
@@ -18,7 +18,11 @@ pub fn calculate_seed_distance(cand: &GridPointParams, target: &GridPointParams)
+ (cand.logn - target.logn).abs()
+ (cand.logo - target.logo).abs();
if d_teff < 1.0 && d_logg < 0.01 && d_loghe < 0.01 {
// exact family 判定:Teff/logg/logHe 视为“同物理族”,仅 CNO 丰度不同。
// Teff 容忍度取半步 5000K:实际网格 Teff 档位通常为整数千(20000/30000/.../60000),
// 半步既能覆盖 config_dense 等 10000K 步长的相邻档互作种子,
// 又避免跨过大 Teff 间距导致 sdB 高温模型用低温种子而不收敛(sdB_cno 步长 40000K 仍不命中 exact)。
if d_teff < 5000.0 && d_logg < 0.01 && d_loghe < 0.01 {
(true, d_cno)
} else {
// 距离公式物理意义与标定阐释:
@@ -32,6 +36,3 @@ pub fn calculate_seed_distance(cand: &GridPointParams, target: &GridPointParams)
(false, global_d)
}
}