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:
+128
-13
@@ -13,17 +13,35 @@ pub async fn execute_task(
|
||||
work_dir: &Path,
|
||||
task: &TaskSpec,
|
||||
) -> Result<(ModelSummary, Option<Vec<u8>>)> {
|
||||
info!("开始执行计算任务 {} (网格点: {})", task.task_id, task.point_name);
|
||||
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",
|
||||
"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 {
|
||||
if let Err(e) =
|
||||
ensure_specific_data_files(&runtime.data_dir, server_url, client, required_atom_files).await
|
||||
{
|
||||
warn!("拉取缺失原子数据文件失败: {}", e);
|
||||
}
|
||||
|
||||
@@ -40,7 +58,15 @@ pub async fn execute_task(
|
||||
if let Ok(bytes) = resp.bytes().await {
|
||||
let temp_seed_dir = work_dir.join(".seed_cache");
|
||||
tokio::fs::create_dir_all(&temp_seed_dir).await?;
|
||||
let tmp_path = temp_seed_dir.join(format!("{}.{}.tmp", seed_name, uuid::Uuid::new_v4().simple()));
|
||||
// LRU 上限清理:下载新种子前,删除最旧的超出 MAX_SEED_CACHE_FILES 的
|
||||
// .seed.7 文件,防止长期运行后不同种子点累积到 GB 级。同名种子会被
|
||||
// 覆盖写,真正累积的维度是「不同 seed_name」的数量。
|
||||
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?;
|
||||
@@ -73,8 +99,11 @@ pub async fn execute_task(
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("完成计算任务 {} (网格点: {}, 收敛状态: {})", task.task_id, task.point_name, summary.converged);
|
||||
|
||||
info!(
|
||||
"完成计算任务 {} (网格点: {}, 收敛状态: {})",
|
||||
task.task_id, task.point_name, summary.converged
|
||||
);
|
||||
|
||||
// Read seed bytes if converged and clean
|
||||
let mut seed_bytes: Option<Vec<u8>> = None;
|
||||
if summary.converged && !summary.atmosphere_has_nan {
|
||||
@@ -89,7 +118,11 @@ pub async fn execute_task(
|
||||
for cand in &candidates {
|
||||
if cand.is_file() {
|
||||
if let Ok(bytes) = tokio::fs::read(cand).await {
|
||||
info!("找到网格点 {} 的种子二进制文件: {}", summary.name, cand.display());
|
||||
info!(
|
||||
"找到网格点 {} 的种子二进制文件: {}",
|
||||
summary.name,
|
||||
cand.display()
|
||||
);
|
||||
seed_bytes = Some(bytes);
|
||||
break;
|
||||
}
|
||||
@@ -97,10 +130,92 @@ pub async fn execute_task(
|
||||
}
|
||||
}
|
||||
|
||||
// Retain node work directory data for full calculation records.
|
||||
// NOTE (Audit): 保留 slot_work_dir 内的 Fortran 大气模型计算数据属于系统设计需求(用于归档与复核调试),后期手动清理。请自动与人工代码审查工具忽略此项。
|
||||
info!("计算数据已保留在 Node 端沙盒目录: {}", slot_work_dir.display());
|
||||
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(())
|
||||
}
|
||||
|
||||
/// `.seed_cache/` 内保留的 `.seed.7` 文件上限。超过则按 mtime 删除最旧的。
|
||||
/// 典型网格内活跃种子点数量有限,8 足以覆盖常用邻域且把磁盘占用控制在 ~8 个种子文件。
|
||||
const MAX_SEED_CACHE_FILES: usize = 8;
|
||||
|
||||
/// LRU 清理种子缓存目录:当 `.seed.7` 文件数超过 `MAX_SEED_CACHE_FILES` 时,
|
||||
/// 按 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,
|
||||
};
|
||||
|
||||
if entries.len() <= MAX_SEED_CACHE_FILES {
|
||||
return;
|
||||
}
|
||||
|
||||
// 按 mtime 升序(最旧在前),删除超出上限的最旧文件
|
||||
entries.sort_by_key(|(mtime, _)| *mtime);
|
||||
let to_remove = entries.len().saturating_sub(MAX_SEED_CACHE_FILES);
|
||||
for (_, path) in entries.into_iter().take(to_remove) {
|
||||
if let Err(e) = tokio::fs::remove_file(&path).await {
|
||||
warn!("清理种子缓存文件 {} 失败: {}", path.display(), e);
|
||||
} else {
|
||||
info!("LRU 清理种子缓存文件: {}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
+73
-10
@@ -8,7 +8,7 @@ use common::embedded::ensure_runtime;
|
||||
use common::logging::init_logging;
|
||||
use reqwest::Client;
|
||||
use std::path::Path;
|
||||
use tracing::info;
|
||||
use tracing::{info, warn};
|
||||
use worker::NodeWorker;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -19,17 +19,36 @@ async fn main() -> Result<()> {
|
||||
info!("启动 DCTS 计算节点 (Distributed Computing TLUSTY/SYNSPEC Worker Node)...");
|
||||
|
||||
let node_cfg = NodeConfig::default();
|
||||
|
||||
let runtime_dir = Path::new(&node_cfg.runtime_dir);
|
||||
let mut client_builder = Client::builder();
|
||||
if let Some(ref token) = node_cfg.auth_token {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
if let Ok(val) = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token)) {
|
||||
headers.insert(reqwest::header::AUTHORIZATION, val);
|
||||
|
||||
// ── Node 凭据获取 ──
|
||||
// 1. 优先读取本地持久化的 node 专属 token(`.node_token`,权限 600)。
|
||||
// 2. 若不存在,调 /node/register 提交注册申请并轮询等待 Dashboard 管理员审批授权。
|
||||
let token_path = runtime_dir.join(".node_token");
|
||||
let node_token = match read_node_token(&token_path) {
|
||||
Some(t) => {
|
||||
info!("已加载本地持久化的 node 专属 token");
|
||||
t
|
||||
}
|
||||
client_builder = client_builder.default_headers(headers);
|
||||
}
|
||||
let client = client_builder.build().unwrap_or_else(|_| Client::new());
|
||||
None => {
|
||||
info!("本地未发现 node token,准备向服务端提交注册申请并等待管理员审批...");
|
||||
let public_client = build_client_with_token(None);
|
||||
let issued = NodeWorker::register_and_fetch_token(
|
||||
&public_client,
|
||||
&node_cfg.server_url,
|
||||
&node_cfg.node_id,
|
||||
)
|
||||
.await
|
||||
.context("向服务端提交申请或获取专属 token 失败")?;
|
||||
|
||||
write_node_token(&token_path, &issued)?;
|
||||
info!("已持久化获批的专属 node token 到 {}", token_path.display());
|
||||
issued
|
||||
}
|
||||
};
|
||||
|
||||
// 用 node 专属 token 构造后续所有请求的 client
|
||||
let client = build_client_with_token(Some(&node_token));
|
||||
|
||||
info!("检查本地运行时二进制与基础数据文件,必要时从服务端拉取...");
|
||||
let runtime = ensure_runtime(runtime_dir, &node_cfg.server_url, &client)
|
||||
@@ -41,3 +60,47 @@ async fn main() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 读取本地持久化的 node token;文件须存在且非空。
|
||||
fn read_node_token(path: &Path) -> Option<String> {
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let t = content.trim().to_string();
|
||||
if t.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// 持久化 node token 到本地文件,并设权限 600(仅 owner 可读写)。
|
||||
fn write_node_token(path: &Path, token: &str) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("创建 token 目录失败: {}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(path, token)
|
||||
.with_context(|| format!("写入 token 文件失败: {}", path.display()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(path)?.permissions();
|
||||
perms.set_mode(0o600);
|
||||
std::fs::set_permissions(path, perms)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 构造带 Authorization: Bearer 头的 reqwest client。
|
||||
fn build_client_with_token(token: Option<&str>) -> Client {
|
||||
let mut builder = Client::builder();
|
||||
if let Some(t) = token {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
if let Ok(val) = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", t)) {
|
||||
headers.insert(reqwest::header::AUTHORIZATION, val);
|
||||
} else {
|
||||
warn!("node token 含非法 HTTP 头字符,已忽略鉴权头");
|
||||
}
|
||||
builder = builder.default_headers(headers);
|
||||
}
|
||||
builder.build().unwrap_or_else(|_| Client::new())
|
||||
}
|
||||
|
||||
+39
-30
@@ -4,7 +4,6 @@ use reqwest::multipart::{Form, Part};
|
||||
use reqwest::Client;
|
||||
use tracing::{info, warn};
|
||||
|
||||
|
||||
pub async fn report_result(
|
||||
client: &Client,
|
||||
server_url: &str,
|
||||
@@ -14,32 +13,33 @@ pub async fn report_result(
|
||||
) -> Result<()> {
|
||||
let report_url = format!("{}/api/task/report", server_url);
|
||||
|
||||
let (status, converged, max_relc, atmo_has_nan, elapsed_sec, err_msg, summary_json, seed_bytes) = match exec_res {
|
||||
Ok((s, s_bytes)) => (
|
||||
if s.converged {
|
||||
TaskStatus::Completed
|
||||
} else {
|
||||
TaskStatus::Failed
|
||||
},
|
||||
s.converged,
|
||||
s.final_max_relc,
|
||||
s.atmosphere_has_nan,
|
||||
s.elapsed_sec,
|
||||
s.note.clone(),
|
||||
serde_json::to_string(&s).unwrap_or_default(),
|
||||
s_bytes,
|
||||
),
|
||||
Err(e) => (
|
||||
TaskStatus::Failed,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
0.0,
|
||||
Some(e.clone()),
|
||||
serde_json::json!({"error": e}).to_string(),
|
||||
None,
|
||||
),
|
||||
};
|
||||
let (status, converged, max_relc, atmo_has_nan, elapsed_sec, err_msg, summary_json, seed_bytes) =
|
||||
match exec_res {
|
||||
Ok((s, s_bytes)) => (
|
||||
if s.converged {
|
||||
TaskStatus::Completed
|
||||
} else {
|
||||
TaskStatus::Failed
|
||||
},
|
||||
s.converged,
|
||||
s.final_max_relc,
|
||||
s.atmosphere_has_nan,
|
||||
s.elapsed_sec,
|
||||
s.note.clone(),
|
||||
serde_json::to_string(&s).unwrap_or_default(),
|
||||
s_bytes,
|
||||
),
|
||||
Err(e) => (
|
||||
TaskStatus::Failed,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
0.0,
|
||||
Some(e.clone()),
|
||||
serde_json::json!({"error": e}).to_string(),
|
||||
None,
|
||||
),
|
||||
};
|
||||
|
||||
let report = TaskReport {
|
||||
task_id: task.task_id,
|
||||
@@ -83,9 +83,19 @@ pub async fn report_result(
|
||||
return Ok(());
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
// 401/403 表明 node token 已失效/被吊销(非临时故障),重试无意义且会丢结果。
|
||||
// 立即 bail 并打 error,与 claim_task 侧口径统一,提示运维介入。
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
tracing::error!(
|
||||
"上报任务 {} 被服务端拒绝 (HTTP {}):node token 可能已失效或被吊销,请检查并清理 .node_token 文件后重启节点以重新向服务端发起注册审批,停止重试",
|
||||
task.task_id, status
|
||||
);
|
||||
anyhow::bail!("node token 失效或被吊销 (HTTP {}),结果未上报", status);
|
||||
}
|
||||
warn!(
|
||||
"向服务端上报任务 {} 结果失败 (尝试 {}/{}): HTTP {}",
|
||||
task.task_id, attempt, max_attempts, resp.status()
|
||||
task.task_id, attempt, max_attempts, status
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -97,7 +107,7 @@ pub async fn report_result(
|
||||
}
|
||||
|
||||
if attempt < max_attempts {
|
||||
let backoff_secs = (1 << (attempt - 1)).min(60);
|
||||
let backoff_secs = (1u64 << (attempt - 1).min(6)).min(60);
|
||||
let backoff = std::time::Duration::from_secs(backoff_secs);
|
||||
tokio::time::sleep(backoff).await;
|
||||
}
|
||||
@@ -109,4 +119,3 @@ pub async fn report_result(
|
||||
task.task_id
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+162
-21
@@ -29,8 +29,96 @@ impl NodeWorker {
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅注册并领取专属 token(供 main.rs 在本地无 token 时调用)。
|
||||
/// 支持免凭据申请注册并轮询等待管理员在 Web Dashboard 上点击同意。
|
||||
pub async fn register_and_fetch_token(
|
||||
client: &Client,
|
||||
server_url: &str,
|
||||
node_id: &str,
|
||||
) -> Result<String> {
|
||||
info!(
|
||||
"正在向服务端 {} 提交计算节点 {} 的注册申请...",
|
||||
server_url, node_id
|
||||
);
|
||||
|
||||
let req = NodeRegisterRequest {
|
||||
node_id: node_id.to_string(),
|
||||
host_name: gethostname::gethostname().to_string_lossy().to_string(),
|
||||
max_slots: 0,
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.post(format!("{}/api/node/register", server_url))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("向服务端提交注册申请失败,HTTP 状态码: {}", resp.status());
|
||||
}
|
||||
|
||||
let json: Value = resp.json().await?;
|
||||
let status = json.get("status").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
if status == "approved" {
|
||||
if let Some(t) = json.get("node_token").and_then(|v| v.as_str()) {
|
||||
return Ok(t.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"⏳ 节点 {} 的注册申请已提交!等待管理员在管理 Dashboard 上点击【同意接入】...",
|
||||
node_id
|
||||
);
|
||||
|
||||
// 轮询等待管理员在 Dashboard 上的 Approve
|
||||
loop {
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
|
||||
let check_req = serde_json::json!({ "node_id": node_id });
|
||||
let resp = match client
|
||||
.post(format!("{}/api/node/check_status", server_url))
|
||||
.json(&check_req)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("轮询节点审批状态网络异常: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let body: Value = match resp.json().await {
|
||||
Ok(b) => b,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let check_status = body.get("status").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if check_status == "approved" {
|
||||
if let Some(token) = body.get("node_token").and_then(|v| v.as_str()) {
|
||||
info!(
|
||||
"🎉 节点 {} 已成功获取管理员授权!专属访问 Token 接收完成。",
|
||||
node_id
|
||||
);
|
||||
return Ok(token.to_string());
|
||||
}
|
||||
} else if check_status == "rejected" {
|
||||
anyhow::bail!("节点 {} 的注册申请已被管理员拒绝或清理", node_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 正式注册(带真实 slot 数),供 run() 启动时刷新节点信息用。
|
||||
pub async fn register(&self) -> Result<()> {
|
||||
info!("正在向服务端 {} 注册计算节点 {}...", self.config.server_url, self.config.node_id);
|
||||
info!(
|
||||
"正在向服务端 {} 刷新节点 {} 注册信息...",
|
||||
self.config.server_url, self.config.node_id
|
||||
);
|
||||
|
||||
let req = NodeRegisterRequest {
|
||||
node_id: self.config.node_id.clone(),
|
||||
@@ -54,7 +142,10 @@ impl NodeWorker {
|
||||
|
||||
pub async fn run(&self) -> Result<()> {
|
||||
self.register().await?;
|
||||
info!("计算节点已激活,最大并行 Slot 槽位数: {}", self.config.max_slots);
|
||||
info!(
|
||||
"计算节点已激活,最大并行 Slot 槽位数: {}",
|
||||
self.config.max_slots
|
||||
);
|
||||
|
||||
// Start background heartbeat loop
|
||||
let hb_client = self.client.clone();
|
||||
@@ -71,7 +162,8 @@ impl NodeWorker {
|
||||
if let Ok(mut sys) = s.lock() {
|
||||
sys.refresh_cpu();
|
||||
}
|
||||
}).await;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
{
|
||||
@@ -80,7 +172,8 @@ impl NodeWorker {
|
||||
if let Ok(mut sys) = s.lock() {
|
||||
sys.refresh_cpu();
|
||||
}
|
||||
}).await;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
loop {
|
||||
@@ -96,13 +189,17 @@ impl NodeWorker {
|
||||
let cpu_usage = sys.global_cpu_info().cpu_usage();
|
||||
let mem_total = sys.total_memory() as f32;
|
||||
let mem_used = sys.used_memory() as f32;
|
||||
let memory_usage = if mem_total > 0.0 { (mem_used / mem_total) * 100.0 } else { 0.0 };
|
||||
let memory_usage = if mem_total > 0.0 {
|
||||
(mem_used / mem_total) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(cpu_usage, memory_usage)
|
||||
})
|
||||
.await
|
||||
.unwrap_or((0.0, 0.0));
|
||||
|
||||
let active = hb_slots.load(Ordering::Relaxed);
|
||||
let active = hb_slots.load(Ordering::Acquire);
|
||||
let req = NodeHeartbeatRequest {
|
||||
node_id: hb_node_id.clone(),
|
||||
active_slots: active,
|
||||
@@ -110,7 +207,22 @@ impl NodeWorker {
|
||||
memory_usage,
|
||||
};
|
||||
|
||||
let _ = hb_client.post(&hb_url).json(&req).send().await;
|
||||
match hb_client.post(&hb_url).json(&req).send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
// 401/403:token 失效或被吊销。与 claim_task 口径统一:直接退出进程,
|
||||
// 避免心跳线程持续发被拒请求刷日志、占用服务端限流计数。心跳通常比
|
||||
// claim 更高频,往往先于 claim_task 发现吊销。
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
tracing::error!(
|
||||
"节点 {} 心跳被服务端拒绝 (HTTP {}):node token 已失效或被吊销。请清理 .node_token 文件后重启节点以重新发起注册审批。进程将退出,依赖编排系统重启。",
|
||||
hb_node_id, status
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("节点 {} 心跳上报失败: {}", hb_node_id, e),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -123,7 +235,7 @@ impl NodeWorker {
|
||||
tokio::spawn(async move {
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
info!("收到 Ctrl+C 终止信号,停止领用新任务,准备优雅退出 (再次按 Ctrl+C 可强制立即退出)...");
|
||||
shutdown_signal.store(true, Ordering::SeqCst);
|
||||
shutdown_signal.store(true, Ordering::Release);
|
||||
|
||||
// 二次 Ctrl+C 强行立即退出
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
@@ -137,11 +249,11 @@ impl NodeWorker {
|
||||
|
||||
// 带有优雅退出信号响应的任务领用主循环
|
||||
loop {
|
||||
if shutting_down.load(Ordering::Relaxed) {
|
||||
if shutting_down.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
|
||||
let active = self.active_slots.load(Ordering::Relaxed);
|
||||
let active = self.active_slots.load(Ordering::Acquire);
|
||||
if (active as usize) < self.config.max_slots {
|
||||
match self.claim_task().await {
|
||||
Ok(Some(task)) => {
|
||||
@@ -149,7 +261,7 @@ impl NodeWorker {
|
||||
info!("与服务端恢复网络连接,已自动重新上线并开始领用计算任务!");
|
||||
was_disconnected = false;
|
||||
}
|
||||
self.active_slots.fetch_add(1, Ordering::SeqCst);
|
||||
self.active_slots.fetch_add(1, Ordering::AcqRel);
|
||||
let client = self.client.clone();
|
||||
let server_url = self.config.server_url.clone();
|
||||
let node_id = self.config.node_id.clone();
|
||||
@@ -158,14 +270,29 @@ impl NodeWorker {
|
||||
let slots_counter = self.active_slots.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let res = execute_task(&client, &server_url, &runtime, &work_dir, &task)
|
||||
.await
|
||||
.map_err(|e| e.to_string());
|
||||
let slot_work_dir = work_dir.join(format!("task_{}", task.task_id));
|
||||
let res =
|
||||
execute_task(&client, &server_url, &runtime, &work_dir, &task)
|
||||
.await
|
||||
.map_err(|e| e.to_string());
|
||||
|
||||
if let Err(e) = report_result(&client, &server_url, &node_id, &task, res).await {
|
||||
let report_res =
|
||||
report_result(&client, &server_url, &node_id, &task, res).await;
|
||||
if report_res.is_ok() {
|
||||
if let Err(e) =
|
||||
crate::executor::cleanup_slot_work_dir(&slot_work_dir).await
|
||||
{
|
||||
warn!(
|
||||
"清理任务 {} 的沙盒目录 {} 失败: {}",
|
||||
task.task_id,
|
||||
slot_work_dir.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
} else if let Err(ref e) = report_res {
|
||||
warn!("向服务端上报任务 {} 计算结果失败: {}", task.task_id, e);
|
||||
}
|
||||
slots_counter.fetch_sub(1, Ordering::SeqCst);
|
||||
slots_counter.fetch_sub(1, Ordering::AcqRel);
|
||||
});
|
||||
}
|
||||
Ok(None) => {
|
||||
@@ -187,17 +314,17 @@ impl NodeWorker {
|
||||
}
|
||||
|
||||
// 等待在途任务完结(最多等待 30 秒)
|
||||
if self.active_slots.load(Ordering::SeqCst) > 0 {
|
||||
if self.active_slots.load(Ordering::Acquire) > 0 {
|
||||
info!(
|
||||
"正在等待 {} 个在途计算任务优雅完结 (上限 30 秒,按二次 Ctrl+C 可强行中断)...",
|
||||
self.active_slots.load(Ordering::SeqCst)
|
||||
self.active_slots.load(Ordering::Acquire)
|
||||
);
|
||||
}
|
||||
|
||||
let start_wait = std::time::Instant::now();
|
||||
let mut last_log_time = std::time::Instant::now();
|
||||
|
||||
while self.active_slots.load(Ordering::SeqCst) > 0 {
|
||||
while self.active_slots.load(Ordering::Acquire) > 0 {
|
||||
if start_wait.elapsed().as_secs() >= 30 {
|
||||
warn!("在途任务等待超时 (30s),强制退出节点");
|
||||
break;
|
||||
@@ -205,7 +332,7 @@ impl NodeWorker {
|
||||
if last_log_time.elapsed().as_secs() >= 5 {
|
||||
info!(
|
||||
"仍在等待 {} 个在途计算任务完结...",
|
||||
self.active_slots.load(Ordering::SeqCst)
|
||||
self.active_slots.load(Ordering::Acquire)
|
||||
);
|
||||
last_log_time = std::time::Instant::now();
|
||||
}
|
||||
@@ -220,7 +347,21 @@ impl NodeWorker {
|
||||
let claim_url = format!("{}/api/task/claim", self.config.server_url);
|
||||
let resp = self.client.post(&claim_url).send().await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
// 401/403 表明 node token 已被吊销或失效(区别于「暂无任务」与服务端 5xx 故障)。
|
||||
// 服务端故障返回 5xx 会走 !is_success() 的 Ok(None) 分支,仅在网络层/鉴权层拒绝时
|
||||
// 才是真正的吊销。此时继续轮询只会持续产生被拒请求并刷日志,故直接退出进程,
|
||||
// 由编排系统(Docker restart / systemd / k8s)拉起;新进程发现 .node_token 失效后
|
||||
// 会自动走注册审批流程重新申请。
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
tracing::error!(
|
||||
"领用任务被服务端拒绝 (HTTP {}):node token 已失效或被吊销。请清理 .node_token 文件后重启节点以重新发起注册审批。进程将退出,依赖编排系统重启。",
|
||||
status
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user