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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user