将 TLUSTY/SYNSPEC 拆为各自独立的 enabled/policy/strategies 阶段,
以策略链自动弹栈取代单级 seed_step 布尔回退;定向修复 2026-08-02
僵尸任务涡旋事故;新增节点并发配额热调;前端详情页从 1412 行巨型
视图拆为薄控制器 + detail 子模块,并补齐工具层与单测。
引擎与调度(task_engine_decoupling_design.md)
- models.rs: 新增 StagePolicy / EngineStageConfig / TaskSpec 阶段字段、
normalize_compat() 校正旧版在途消息策略链、failed_stage 归因
- scheduler.rs: resolve_dispatchable_chain 派发门控、
trigger_strategy_fallback 按 failed_stage 精确弹栈;启动期
force_recompute/skip_converged(默认)/skip_failed 三策略
- db.rs: tasks 表 +7 列持久化阶段配置;终态守卫
(mark_grid_point_running 仅 pending/queued→running;
record_task_report 拒绝迟到失败翻黑 converged);策略弹栈快照
僵尸涡旋修复(runbook-20260802-zombie-vortex-fix.md)
- 全链路跨库活性交叉校验:派发/claim/孤儿回收/回退统一查 MQ 队列活性,
活则放行、死则清僵尸,结构性消除"每点重复派发"
- stop/重启卫生:清队列同步 delete_tasks_by_ids,杜绝遗留 pending 行
- report_task: 幂等吸收 + 409 区分迟到冗余结果,仅 state_changed 时回退
- MQ: NULL workflow_name 回填 __legacy__、requeue 后迟到上报被 403 竞态修复
动态 CPU 配额(dynamic_cpu_slots_design.md)
- admin.rs: POST /admin/nodes/:id/quota(Option<Option<i32>> 区分
缺字段/显式 null);nodes 表 +admin_max_slots
- worker.rs: effective_max_slots = min(admin, physical),心跳下发原子生效
科学产物保全(tlusty_result_artifacts.md)
- runner.rs: SYNSPEC 启动前快照 fort.12/fort.14 → .bfac/.emflux 防覆盖
- 半失败点(大气收敛+光谱失败)改判 Failed 并写入 note;仅 SYNSPEC
场景不再恒判失败;撤销归档 LRU 200 上限改为永久保留
- executor.rs: 透传 synspec_params 数值参数(此前固定 None)
前端(dashboard/)
- workflowDetail.js 1412→328 行,拆出 views/detail/{ctx,overview,
pointsTable,parSets,pointPanel}.js,AbortController 治理监听/请求生命周期
- 删除 wfActions.js,新增 wfEnginePanel.js(双阶段三维配置编辑面板)
- 新增 utils/{errors,format,icons,polling,yamlStage}.js 纯函数模块
- 路由级动态 import 代码分割;节点配额三点菜单 + Modal 管理
- 首次引入 node:test 单测(format/polling/yamlStage/psCache,644 行)
- 系统性补齐 a11y:skip-link、ARIA、Tab 键盘漫游、toast 关闭、退出动画
文档与工具
- 新增 6 篇设计/调研:引擎解耦、动态配额、涡旋 runbook、
光谱正确性分析、收敛判断、产物归档
- PIPELINE/design/api/database 等协同重写为分布式 C/S 架构口径
- scripts/fetch_results.sh 跨节点产物备份;import_results 按 cno 升序导入
- workflows/sdB_cno.yaml: 新增 tlusty/synspec_stage 配置块,修正 wstart 笔误
187 lines
6.3 KiB
Rust
187 lines
6.3 KiB
Rust
use anyhow::{Context, Result};
|
|
use reqwest::Client;
|
|
use sha2::{Digest, Sha256};
|
|
use std::fs::{self, File};
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
use tracing::info;
|
|
|
|
#[cfg(feature = "embed-binaries")]
|
|
pub static TLUSTY_BIN: &[u8] = include_bytes!("../../../assets/tlusty_static");
|
|
#[cfg(not(feature = "embed-binaries"))]
|
|
pub static TLUSTY_BIN: &[u8] = &[];
|
|
|
|
#[cfg(feature = "embed-binaries")]
|
|
pub static SYNSPEC_BIN: &[u8] = include_bytes!("../../../assets/synspec_static");
|
|
#[cfg(not(feature = "embed-binaries"))]
|
|
pub static SYNSPEC_BIN: &[u8] = &[];
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct RuntimePaths {
|
|
pub tlusty_exe: PathBuf,
|
|
pub synspec_exe: PathBuf,
|
|
pub data_dir: PathBuf,
|
|
pub linelist: PathBuf,
|
|
}
|
|
|
|
fn calc_hash(bytes: &[u8]) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(bytes);
|
|
hex::encode(hasher.finalize())
|
|
}
|
|
|
|
/// Ensures Fortran runtime binaries are unpacked and common partition function data files are fetched
|
|
pub async fn ensure_runtime(
|
|
runtime_dir: &Path,
|
|
server_url: &str,
|
|
client: &Client,
|
|
) -> Result<RuntimePaths> {
|
|
fs::create_dir_all(runtime_dir)
|
|
.with_context(|| format!("Failed to create runtime dir: {}", runtime_dir.display()))?;
|
|
|
|
let tlusty_exe = runtime_dir.join("tlusty_static");
|
|
let synspec_exe = runtime_dir.join("synspec_static");
|
|
let data_dir = runtime_dir.join("data");
|
|
let linelist = runtime_dir.join("gfVIS99.dat");
|
|
|
|
fs::create_dir_all(&data_dir)?;
|
|
|
|
// 1. Unpack tlusty_static & synspec_static binaries if embedded
|
|
if !TLUSTY_BIN.is_empty() {
|
|
write_if_changed(&tlusty_exe, TLUSTY_BIN, true)?;
|
|
}
|
|
if !SYNSPEC_BIN.is_empty() {
|
|
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",
|
|
];
|
|
ensure_specific_data_files(&data_dir, server_url, client, common_files).await?;
|
|
|
|
// 3. Check gfVIS99.dat
|
|
if !linelist.exists() {
|
|
let url = format!("{}/api/data/linelist", server_url);
|
|
info!("本地缺失主谱线库 gfVIS99.dat,开始从服务端下载: {}...", url);
|
|
let resp = client.get(&url).send().await?;
|
|
if resp.status().is_success() {
|
|
let bytes = resp.bytes().await?;
|
|
fs::write(&linelist, &bytes)?;
|
|
info!("成功下载并保存主谱线库 gfVIS99.dat");
|
|
} else {
|
|
anyhow::bail!(
|
|
"从服务端下载主谱线库 gfVIS99.dat 失败,HTTP 状态码: {}",
|
|
resp.status()
|
|
);
|
|
}
|
|
}
|
|
|
|
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");
|
|
let linelist = abs_runtime_dir.join("gfVIS99.dat");
|
|
|
|
Ok(RuntimePaths {
|
|
tlusty_exe,
|
|
synspec_exe,
|
|
data_dir,
|
|
linelist,
|
|
})
|
|
}
|
|
|
|
static DATA_DOWNLOAD_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
|
|
|
/// Checks local `./runtime/data/` for specific required files. If missing, downloads ONLY those specific files from Server!
|
|
pub async fn ensure_specific_data_files(
|
|
data_dir: &Path,
|
|
server_url: &str,
|
|
client: &Client,
|
|
required_files: &[&str],
|
|
) -> Result<()> {
|
|
let _guard = DATA_DOWNLOAD_MUTEX.lock().await;
|
|
tokio::fs::create_dir_all(data_dir).await?;
|
|
|
|
for &filename in required_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
|
|
);
|
|
|
|
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()
|
|
));
|
|
tokio::fs::write(&tmp_file, &bytes).await?;
|
|
tokio::fs::rename(&tmp_file, &local_file).await?;
|
|
info!("成功保存数据文件: {}", filename);
|
|
} else {
|
|
anyhow::bail!(
|
|
"服务端返回 HTTP {} 错误,数据文件: {}",
|
|
resp.status(),
|
|
filename
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn write_if_changed(target_path: &Path, content: &[u8], executable: bool) -> Result<()> {
|
|
let should_write = if target_path.exists() {
|
|
match fs::read(target_path) {
|
|
Ok(existing) => calc_hash(&existing) != calc_hash(content),
|
|
Err(_) => true,
|
|
}
|
|
} else {
|
|
true
|
|
};
|
|
|
|
if should_write {
|
|
// 原子写:先写 .tmp 再 rename,避免半写后崩溃留下截断/损坏的可执行二进制。
|
|
// 历史上直接 File::create 覆盖目标,若 write_all 中途进程被杀/磁盘满,会留下
|
|
// 截断的 tlusty_static,且权限可能已设 0o755(可执行但损坏),node 尝试运行时
|
|
// 产生难以定位的 Fortran 崩溃。tmp + rename 保证目标要么是完整旧版、要么是完整新版。
|
|
let tmp_path = target_path.with_extension("tmp.write");
|
|
{
|
|
let mut file = File::create(&tmp_path)
|
|
.with_context(|| format!("创建临时文件失败: {}", tmp_path.display()))?;
|
|
file.write_all(content)?;
|
|
file.flush()?;
|
|
// drop 前 sync_all 确保数据落盘,降低断电丢数据的概率。
|
|
let _ = file.sync_all();
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
if executable {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = fs::metadata(&tmp_path)?.permissions();
|
|
perms.set_mode(0o755);
|
|
fs::set_permissions(&tmp_path, perms)?;
|
|
}
|
|
|
|
fs::rename(&tmp_path, target_path).with_context(|| {
|
|
format!(
|
|
"原子重命名 {} -> {} 失败",
|
|
tmp_path.display(),
|
|
target_path.display()
|
|
)
|
|
})?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|