物理修复(400 失败点归因,见 docs/failed400_nan_pseudo_convergence_2026_08_17.md): - runner: 阶段 converged 后复查 fort.7,含 NaN/Inf 即否决(fort.9 全零伪收敛, 曾致 261 点误跳过 nl_direct 回退);否决阶段不产出种子,阻断污染传播 - runner: nl_tight 回退——仅能量边际失败时以 CHMAX 收紧 10× 从自身模型续迭代, 残差降幅 ~10×;execute_tlusty_stage 抽取供主链与回退共用 - conv_check: fort.14 为 Eddington 通量 Hλ,积分需乘 4π 再比 σTeff⁴ (旧版 ratio 稳定 0.0796=1/4π,全点系统性假阳性)+ 回归测试 - nst_writer: 单行超 80 字符被 TLUSTY 静默截断,IFALI/JALI/TRAD 等从未生效; 按 75 字符自动换行 - seed_finder: Teff 容忍度改含边界 <=,相邻 5000K 档恢复互为种子 + 回归测试 谱线表与网格: - 默认线表 gfVIS99 → gfATO(全波段 18-23000Å),TaskSpec.linelist 支持工作流 级覆盖,节点按需下载(进程互斥锁防并发重复下载 238MB) - sdB_cno Teff 加密至 5000K 步长,432 → 9216 点;tlusty/synspec 静态二进制更新 统计与部署: - grid 汇总改按 tlusty_status/synspec_status 分项计数,新增 tlusty_failed/ synspec_failed/synspec_pending,前端详情页双视图适配 - deploy/fetch_results 支持跳板机 ProxyJump 与 SSH 主连接复用,fetch 新增 --force; - Docker 构建支持 CARGO_MIRROR/USE_MIRRORS 国内镜像参数;移除 tools/ 拷贝 - 新增 tlusty-synspec-test skill 与 6 篇根因分析/验证文档
758 lines
32 KiB
Rust
758 lines
32 KiB
Rust
use anyhow::Result;
|
||
use common::config::{ChainStep, SynspecInput, TlustyInput};
|
||
use common::embedded::{ensure_specific_data_files, RuntimePaths};
|
||
use common::models::{ModelSummary, TaskSpec};
|
||
use common::result_filter::is_result_worthy;
|
||
use common::runner::ExecutionRunner;
|
||
use reqwest::Client;
|
||
use std::path::{Path, PathBuf};
|
||
use tracing::{debug, info, warn};
|
||
|
||
/// 进程级互斥锁:序列化谱线表按需下载,防止多 Slot 并发首次派发时对同一 238MB 文件
|
||
/// 重复发起下载请求(POSIX rename 保证写安全,但冗余下载浪费带宽与 IO)。
|
||
static LINELIST_DOWNLOAD_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||
|
||
pub async fn execute_task(
|
||
client: &Client,
|
||
server_url: &str,
|
||
runtime: &RuntimePaths,
|
||
work_dir: &Path,
|
||
result_dir: &Path,
|
||
task: &TaskSpec,
|
||
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||
) -> Result<(ModelSummary, Option<Vec<u8>>)> {
|
||
info!(
|
||
"开始执行计算任务 {} (网格点: {})",
|
||
task.task_id, task.point_name
|
||
);
|
||
|
||
// 1. Pull ONLY missing atom model data files needed for this task
|
||
let required_atom_files = &[
|
||
"h1.dat",
|
||
"he1.dat",
|
||
"he2.dat",
|
||
"c1.dat",
|
||
"c2.dat",
|
||
"c3_34+12lev.dat",
|
||
"c4.dat",
|
||
"n1.dat",
|
||
"n2_32+10lev.dat",
|
||
"n3.dat",
|
||
"n4_34+14lev.dat",
|
||
"n5.dat",
|
||
"o1_23+10lev.dat",
|
||
"o2_36+12lev.dat",
|
||
"o3_28+13lev.dat",
|
||
"o4.dat",
|
||
"o5.dat",
|
||
];
|
||
|
||
if let Err(e) =
|
||
ensure_specific_data_files(&runtime.data_dir, server_url, client, required_atom_files).await
|
||
{
|
||
warn!("拉取缺失原子数据文件失败: {}", e);
|
||
}
|
||
|
||
let mut seed_atmos_path: Option<PathBuf> = None;
|
||
|
||
// 提前创建 per-slot 隔离沙盒目录:种子下载后需复制一份私有副本进沙盒(见下方),
|
||
// 故沙盒必须先于种子下载就绪。
|
||
let slot_work_dir = work_dir.join(format!("task_{}", task.task_id));
|
||
tokio::fs::create_dir_all(&slot_work_dir).await?;
|
||
|
||
// 2. 种子大气获取(见 docs/task_engine_decoupling_design.md §5):
|
||
// - TLUSTY 启用 + 策略为 seed_step:下载近邻种子 .7 作热启动种子(既有逻辑)。
|
||
// - TLUSTY 关闭(仅 SYNSPEC 场景):需拉取目标点 .7 大气作光谱合成输入。
|
||
// 顺序:本地 result 归档 → server 拉取。
|
||
// 注:不查沙盒本地——TLUSTY 与 SYNSPEC 在同一任务内串行,种子获取先于 runner,
|
||
// 全新 slot 内不可能已有目标大气;重试任务的 slot 亦全新(task_id 唯一)。
|
||
let tlusty_enabled = task.tlusty_config.enabled;
|
||
let current_strategy = task.tlusty_config.current_strategy("cold_run");
|
||
let needs_seed_download = (tlusty_enabled && current_strategy == "seed_step")
|
||
|| (!tlusty_enabled && task.synspec_config.enabled);
|
||
|
||
if needs_seed_download {
|
||
let seed_name = if !tlusty_enabled {
|
||
// 仅 SYNSPEC 场景:大气来自目标点本身(atmosphere_ref 或 point_name)。
|
||
task.atmosphere_ref
|
||
.clone()
|
||
.unwrap_or_else(|| task.point_name.clone())
|
||
} else {
|
||
// SeedStep 热启动:大气来自近邻种子点。
|
||
task.seed_point_name.clone().ok_or_else(|| {
|
||
anyhow::anyhow!(
|
||
"SeedStep 任务 {} 缺少 seed_point_name,无法热启动",
|
||
task.point_name
|
||
)
|
||
})?
|
||
};
|
||
|
||
// 仅 SYNSPEC 场景先查本地 result 归档目录(节点此前算过同点大气,避免 server 拉取)。
|
||
if !tlusty_enabled {
|
||
let archived = result_dir
|
||
.join(&task.point_name)
|
||
.join(format!("{}.7", task.point_name));
|
||
if archived.is_file() {
|
||
info!(
|
||
"SYNSPEC-only:在本地 result 归档找到大气 {},复用避免 server 拉取",
|
||
archived.display()
|
||
);
|
||
seed_atmos_path = Some(archived);
|
||
}
|
||
}
|
||
|
||
// 归档无 → 向 server 拉取。
|
||
if seed_atmos_path.is_none() {
|
||
let seed_url = format!("{}/api/seed/{}", server_url, seed_name);
|
||
info!(
|
||
"正在从服务端下载大气文件 ({}, 用途: {}): {}",
|
||
seed_name,
|
||
if tlusty_enabled {
|
||
"TLUSTY 热启动种子"
|
||
} else {
|
||
"SYNSPEC 输入大气"
|
||
},
|
||
seed_url
|
||
);
|
||
|
||
let resp = client.get(&seed_url).send().await.map_err(|e| {
|
||
anyhow::anyhow!(
|
||
"任务 {} 下载大气文件 {} 失败: {}",
|
||
task.point_name,
|
||
seed_url,
|
||
e
|
||
)
|
||
})?;
|
||
if !resp.status().is_success() {
|
||
anyhow::bail!(
|
||
"任务 {} 下载大气文件 {} 失败: HTTP {}",
|
||
task.point_name,
|
||
seed_url,
|
||
resp.status()
|
||
);
|
||
}
|
||
let bytes = resp.bytes().await.map_err(|e| {
|
||
anyhow::anyhow!(
|
||
"任务 {} 读取大气文件 {} 响应体失败: {}",
|
||
task.point_name,
|
||
seed_url,
|
||
e
|
||
)
|
||
})?;
|
||
|
||
let temp_seed_dir = work_dir.join(".seed_cache");
|
||
tokio::fs::create_dir_all(&temp_seed_dir).await?;
|
||
cleanup_seed_cache(&temp_seed_dir).await;
|
||
let tmp_path = temp_seed_dir.join(format!(
|
||
"{}.{}.tmp",
|
||
seed_name,
|
||
uuid::Uuid::new_v4().simple()
|
||
));
|
||
let final_seed_path = temp_seed_dir.join(format!("{}.seed.7", seed_name));
|
||
tokio::fs::write(&tmp_path, bytes).await?;
|
||
tokio::fs::rename(&tmp_path, &final_seed_path).await?;
|
||
|
||
let private_seed = slot_work_dir.join("seed_atmos.seed.7");
|
||
tokio::fs::copy(&final_seed_path, &private_seed).await?;
|
||
seed_atmos_path = Some(private_seed);
|
||
}
|
||
}
|
||
|
||
// 3. (slot_work_dir 已在种子下载前提前创建,种子私有副本亦已落盘于沙盒内。)
|
||
|
||
// 反序列化工作流携带的 SYNSPEC 数值参数(波长范围等)。None → runner 用硬编码默认。
|
||
let synspec_cfg: Option<SynspecInput> = task
|
||
.synspec_params
|
||
.as_ref()
|
||
.and_then(|v| serde_json::from_value::<SynspecInput>(v.clone()).ok());
|
||
|
||
// 谱线表覆盖:TaskSpec.linelist 指定时(如 YAML 配 linelist: gfATO.dat),
|
||
// 按需从服务端下载到 runtime_dir 根目录,并构造覆盖了 linelist 路径的 RuntimePaths。
|
||
// None → 用 node 启动时下载的默认线表(ensure_runtime 的 default_linelist 参数)。
|
||
let runtime_override: Option<RuntimePaths> = if let Some(ref ll_name) = task.linelist {
|
||
// 线表放在 runtime_dir 根目录(与默认线表同级,runner symlink 为 fort.19)。
|
||
let ll_path = runtime
|
||
.data_dir
|
||
.parent()
|
||
.unwrap_or(&runtime.data_dir)
|
||
.join(ll_name);
|
||
// 互斥保护:多 Slot 并发首次派发时,仅第一个进入临界区的 Slot 执行下载,
|
||
// 后续 Slot 在获得锁后 double-check 发现文件已存在直接跳过,避免冗余下载。
|
||
{
|
||
let _guard = LINELIST_DOWNLOAD_MUTEX.lock().await;
|
||
if !ll_path.exists() {
|
||
info!("任务指定谱线表 {} 本地缺失,从服务端按需下载...", ll_name);
|
||
let url = format!("{}/api/data/file/{}", server_url, ll_name);
|
||
let resp = client.get(&url).send().await?;
|
||
if resp.status().is_success() {
|
||
let bytes = resp.bytes().await?;
|
||
let ll_dir = ll_path.parent().unwrap_or(std::path::Path::new("."));
|
||
let tmp = ll_dir.join(format!("{}.{}.tmp", ll_name, uuid::Uuid::new_v4().simple()));
|
||
tokio::fs::write(&tmp, &bytes).await?;
|
||
tokio::fs::rename(&tmp, &ll_path).await?;
|
||
info!("成功下载谱线表 {} ({} bytes)", ll_name, bytes.len());
|
||
} else {
|
||
anyhow::bail!(
|
||
"下载任务指定谱线表 {} 失败,HTTP {}",
|
||
ll_name,
|
||
resp.status()
|
||
);
|
||
}
|
||
}
|
||
}
|
||
let mut rt = runtime.clone();
|
||
rt.linelist = ll_path;
|
||
Some(rt)
|
||
} else {
|
||
None
|
||
};
|
||
let effective_runtime = runtime_override.as_ref().unwrap_or(runtime);
|
||
|
||
let runner = ExecutionRunner::new(effective_runtime, slot_work_dir.clone());
|
||
// 执行链来源(优先级):
|
||
// 1. TaskSpec.tlusty_chain_params(用户在 YAML `tlusty_chain:` 配置的多阶段 ChainStep
|
||
// 数组,由 scheduler 序列化注入)——非空时优先使用,使用户能细粒度控制 niter/chmax/
|
||
// ilvlin 等阶段参数。
|
||
// 2. default_chain_for_strategy(current_strategy) 兜底——按策略名(cold_run/seed_step)
|
||
// 选预设默认链(runner.rs 的 default_cold_chain / default_seed_chain)。
|
||
// 历史:Phase 6 起仅用 default 链(用户 config.chain 被忽略,是死字段);本次接通后
|
||
// 用户配置真正生效,default 链降级为兜底。旧 MQ payload(无 tlusty_chain_params 字段)
|
||
// 反序列化为 None → 回退 default 链,行为与旧版完全一致(向后兼容)。
|
||
let chain = resolve_execution_chain(
|
||
current_strategy,
|
||
&task.tlusty_chain_params,
|
||
&task.seed_chain_params,
|
||
&task.task_id.to_string(),
|
||
);
|
||
// TLUSTY 输入文件全局参数(NFREAD/ions 表/nst extra_keys 等)。
|
||
// None → runner 用代码内硬编码默认(向后兼容)。
|
||
let tlusty_input =
|
||
task.tlusty_input_params.as_ref().and_then(|v| {
|
||
match serde_json::from_value::<TlustyInput>(v.clone()) {
|
||
Ok(t) => Some(t),
|
||
Err(e) => {
|
||
warn!(
|
||
"任务 {} 的 tlusty_input_params 反序列化失败,回退默认输入: {}",
|
||
task.task_id, e
|
||
);
|
||
None
|
||
}
|
||
}
|
||
});
|
||
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,
|
||
current_strategy,
|
||
Some(chain),
|
||
seed_atmos_path.as_deref(),
|
||
synspec_cfg.as_ref(),
|
||
// 阶段独立配置开关(见 docs/task_engine_decoupling_design.md §5)。
|
||
task.tlusty_config.enabled,
|
||
task.synspec_config.enabled,
|
||
task.timeout_sec,
|
||
shutdown,
|
||
tlusty_input.as_ref(),
|
||
task.energy_tolerance,
|
||
task.temp_max_factor,
|
||
task.temp_floor,
|
||
task.temp_ceiling,
|
||
task.emflux_tolerance,
|
||
task.convergence_min_ratio,
|
||
task.bfac_max,
|
||
task.bfac_min,
|
||
)
|
||
.await?;
|
||
|
||
info!(
|
||
"完成计算任务 {} (网格点: {}, 结果可用: {})",
|
||
task.task_id, task.point_name, summary.result_valid
|
||
);
|
||
|
||
// Read seed bytes if result usable and clean
|
||
let mut seed_bytes: Option<Vec<u8>> = None;
|
||
if summary.result_valid && !summary.atmosphere_has_nan {
|
||
let model_sub_dir = slot_work_dir.join(&summary.name);
|
||
let candidates = [
|
||
model_sub_dir.join(format!("{}.7", summary.name)),
|
||
model_sub_dir.join(format!("{}.nl.7", summary.name)),
|
||
model_sub_dir.join(format!("{}.nc.7", summary.name)),
|
||
model_sub_dir.join("fort.7"),
|
||
slot_work_dir.join(format!("{}.7", summary.name)),
|
||
];
|
||
for cand in &candidates {
|
||
if cand.is_file() {
|
||
if let Ok(bytes) = tokio::fs::read(cand).await {
|
||
info!(
|
||
"找到网格点 {} 的种子二进制文件: {}",
|
||
summary.name,
|
||
cand.display()
|
||
);
|
||
seed_bytes = Some(bytes);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
info!(
|
||
"任务 {} 计算完成,沙盒目录: {}",
|
||
task.task_id,
|
||
slot_work_dir.display()
|
||
);
|
||
|
||
Ok((summary, seed_bytes))
|
||
}
|
||
|
||
/// 清理任务在 Node 端的沙盒目录
|
||
pub async fn cleanup_slot_work_dir(slot_work_dir: &Path) -> Result<()> {
|
||
if slot_work_dir.exists() {
|
||
tokio::fs::remove_dir_all(slot_work_dir).await?;
|
||
info!("已清理 Node 端沙盒目录: {}", slot_work_dir.display());
|
||
}
|
||
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`,以及 runner 在 SYNSPEC 覆盖前快照的
|
||
/// TLUSTY 最终产物:`<name>.bfac`(b 因子/非 LTE 偏离因子)、`<name>.emflux`(出射谱 λ–Fλ)
|
||
/// - 阶段快照:`<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
|
||
);
|
||
}
|
||
|
||
// 归档目录不做数量上限治理:所有已算网格点的完整产物(.spec/.cont/.iden/各阶段
|
||
// 快照/日志/种子二进制等)一律永久保留,避免 LRU 淘汰导致科学产物丢失
|
||
// (2026-08-02 修正:撤销 1bfa240 引入的 MAX_RESULT_MODELS=200 LRU 上限)。
|
||
|
||
/// `.seed_cache/` 内保留的 `.seed.7` 文件上限。超过则按 mtime 删除最旧的。
|
||
///
|
||
/// 审查修复 #N5:上限可经环境变量 `DCTS_SEED_CACHE_MAX` 覆盖(默认 8)。多工作流或密集
|
||
/// 网格下常用邻域种子可能超过 8 个,硬编码上限会导致反复从 server 下载,增加负载。
|
||
fn seed_cache_max_files() -> usize {
|
||
std::env::var("DCTS_SEED_CACHE_MAX")
|
||
.ok()
|
||
.and_then(|v| v.parse().ok())
|
||
.filter(|n: &usize| *n > 0)
|
||
.unwrap_or(8)
|
||
}
|
||
|
||
/// LRU 清理种子缓存目录:当 `.seed.7` 文件数超过上限(`DCTS_SEED_CACHE_MAX`,默认 8)时,
|
||
/// 按 mtime 升序删除最旧的若干个,直到不超过上限。仅统计 `.seed.7`,忽略 `.tmp` 中间文件。
|
||
/// 任何 IO 错误均降级为 warn,不阻断主流程。
|
||
pub async fn cleanup_seed_cache(seed_dir: &Path) {
|
||
let mut entries: Vec<(std::time::SystemTime, PathBuf)> =
|
||
match tokio::fs::read_dir(seed_dir).await {
|
||
Ok(mut rd) => {
|
||
let mut v = Vec::new();
|
||
while let Ok(Some(entry)) = rd.next_entry().await {
|
||
let path = entry.path();
|
||
// 仅纳入 .seed.7 文件(最终产物),跳过 .tmp 中间文件
|
||
if path.extension().and_then(|e| e.to_str()) != Some("7") {
|
||
continue;
|
||
}
|
||
let file_name = match path.file_name().and_then(|n| n.to_str()) {
|
||
Some(n) => n,
|
||
None => continue,
|
||
};
|
||
if !file_name.ends_with(".seed.7") {
|
||
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,
|
||
};
|
||
|
||
let max_files = seed_cache_max_files();
|
||
if entries.len() <= max_files {
|
||
return;
|
||
}
|
||
|
||
// 按 mtime 升序(最旧在前),删除超出上限的最旧文件
|
||
entries.sort_by_key(|(mtime, _)| *mtime);
|
||
let to_remove = entries.len().saturating_sub(max_files);
|
||
for (_, path) in entries.into_iter().take(to_remove) {
|
||
match tokio::fs::remove_file(&path).await {
|
||
Ok(()) => info!("LRU 清理种子缓存文件: {}", path.display()),
|
||
// 文件已被并发删除(多 slot 同时清理同一批最旧文件):清理目标已达成,
|
||
// 视为成功,不再误报 warn。其他真实 IO 错误才需要告警。
|
||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||
debug!("种子缓存文件已被并发删除: {}", path.display())
|
||
}
|
||
Err(e) => warn!("清理种子缓存文件 {} 失败: {}", path.display(), e),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 解析任务要执行的大气链(冷启动链 / 种子热启动链)。
|
||
///
|
||
/// 优先级:
|
||
/// - `current_strategy == "seed_step"` → 优先 TaskSpec.seed_chain_params(用户 YAML
|
||
/// `seed_chain:` 配置),非空即用;为空/反序列化失败 → `default_seed_chain()` 兜底。
|
||
/// 不复用 `tlusty_chain`:冷启动链首步 lte 的 `ltgray=T` 会删除 fort.8、丢弃已下载的
|
||
/// 热启动种子(runner.rs 阶段 fort.8 准备逻辑)。seed_chain 专用于种子热启动,
|
||
/// 首步 seed_nc 的 `ltgray=F` 保留 fort.8 种子。
|
||
/// - 其余策略 → 优先 TaskSpec.tlusty_chain_params(用户 YAML `tlusty_chain:` 配置),非空即用;
|
||
/// 为空/反序列化失败 → `default_chain_for_strategy(current_strategy)` 兜底。
|
||
fn resolve_execution_chain(
|
||
current_strategy: &str,
|
||
tlusty_chain_params: &Option<serde_json::Value>,
|
||
seed_chain_params: &Option<serde_json::Value>,
|
||
task_id: &str,
|
||
) -> Vec<ChainStep> {
|
||
if current_strategy == "seed_step" {
|
||
seed_chain_params
|
||
.as_ref()
|
||
.and_then(|v| match serde_json::from_value::<Vec<ChainStep>>(v.clone()) {
|
||
Ok(c) => Some(c),
|
||
Err(e) => {
|
||
warn!(
|
||
"任务 {} 的 seed_chain_params 反序列化失败,回退 default_seed_chain: {}",
|
||
task_id, e
|
||
);
|
||
None
|
||
}
|
||
})
|
||
.filter(|c| !c.is_empty())
|
||
.unwrap_or_else(common::runner::default_seed_chain)
|
||
} else {
|
||
tlusty_chain_params
|
||
.as_ref()
|
||
.and_then(
|
||
|v| match serde_json::from_value::<Vec<ChainStep>>(v.clone()) {
|
||
Ok(c) => Some(c),
|
||
Err(e) => {
|
||
warn!(
|
||
"任务 {} 的 tlusty_chain_params 反序列化失败,回退 default 链: {}",
|
||
task_id, e
|
||
);
|
||
None
|
||
}
|
||
},
|
||
)
|
||
.filter(|c| !c.is_empty())
|
||
.unwrap_or_else(|| common::runner::default_chain_for_strategy(current_strategy))
|
||
}
|
||
}
|
||
|
||
#[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![],
|
||
result_valid: 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,
|
||
energy_check: None,
|
||
temp_check: None,
|
||
emflux_check: None,
|
||
bfac_check: None,
|
||
note: None,
|
||
}
|
||
}
|
||
|
||
fn labels(chain: &[ChainStep]) -> Vec<String> {
|
||
chain.iter().map(|s| s.label.clone()).collect()
|
||
}
|
||
|
||
/// P1 回归防护:seed_step 忽略 tlusty_chain_params(冷启动链),只认 seed_chain_params。
|
||
/// 否则首步 lte(ltgray=T) 会删 fort.8、丢弃已下载种子,回退退化成本地冷启动。
|
||
#[test]
|
||
fn seed_step_ignores_custom_cold_chain() {
|
||
let custom_cold = serde_json::json!([
|
||
{"label": "lte", "lte": "T", "ltgray": "T", "ilvlin": 0, "niter": 0},
|
||
{"label": "nc", "lte": "F", "ltgray": "F", "ilvlin": 0, "niter": 10},
|
||
{"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100},
|
||
]);
|
||
|
||
// seed_chain_params=None → 走 default_seed_chain;tlusty_chain_params 被忽略。
|
||
let chain = resolve_execution_chain("seed_step", &Some(custom_cold), &None, "t1");
|
||
assert_eq!(labels(&chain), vec!["seed_nc", "nl"]);
|
||
// 首步必须是非灰 LTE(ltgray=F),否则会删 fort.8 丢弃种子。
|
||
assert_eq!(chain[0].ltgray, "F");
|
||
}
|
||
|
||
/// seed_step 使用 seed_chain_params(用户 YAML seed_chain 配置)时生效。
|
||
#[test]
|
||
fn seed_step_uses_custom_seed_chain_when_provided() {
|
||
let custom_seed = serde_json::json!([
|
||
{"label": "seed_nc", "lte": "F", "ltgray": "F", "ilvlin": 0, "niter": 20, "orelax": 0.3},
|
||
{"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100, "orelax": 0.5},
|
||
]);
|
||
|
||
let chain = resolve_execution_chain("seed_step", &None, &Some(custom_seed), "t1b");
|
||
assert_eq!(labels(&chain), vec!["seed_nc", "nl"]);
|
||
// 用户配置的 orelax 生效。
|
||
assert_eq!(chain[0].orelax, Some(0.3));
|
||
assert_eq!(chain[1].orelax, Some(0.5));
|
||
}
|
||
|
||
#[test]
|
||
fn cold_run_uses_custom_chain_when_provided() {
|
||
let custom = serde_json::json!([
|
||
{"label": "lte", "lte": "T", "ltgray": "T", "ilvlin": 0, "niter": 0},
|
||
{"label": "nc", "lte": "F", "ltgray": "F", "ilvlin": 0, "niter": 10},
|
||
{"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100},
|
||
]);
|
||
|
||
let chain = resolve_execution_chain("cold_run", &Some(custom), &None, "t2");
|
||
assert_eq!(labels(&chain), vec!["lte", "nc", "nl"]);
|
||
}
|
||
|
||
#[test]
|
||
fn cold_run_falls_back_to_default_when_no_custom_chain() {
|
||
let chain = resolve_execution_chain("cold_run", &None, &None, "t3");
|
||
assert_eq!(labels(&chain), vec!["lte", "nc", "nl"]);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_cleanup_slot_work_dir() {
|
||
let temp_dir =
|
||
std::env::temp_dir().join(format!("test_slot_work_dir_{}", uuid::Uuid::new_v4()));
|
||
tokio::fs::create_dir_all(&temp_dir).await.unwrap();
|
||
tokio::fs::write(temp_dir.join("dummy.txt"), "content")
|
||
.await
|
||
.unwrap();
|
||
|
||
assert!(temp_dir.exists());
|
||
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 日志
|
||
format!("{}.bfac", summary.name), // TLUSTY 最终 b 因子(快照 fort.12)
|
||
format!("{}.emflux", summary.name), // TLUSTY 最终出射谱(快照 fort.14)
|
||
"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 出射辐射场 (FREQ/FLUX/FH),未快照,丢弃
|
||
"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;
|
||
}
|
||
}
|