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
+333 -52
View File
@@ -23,13 +23,32 @@ impl GridScheduler {
}
}
/// Expands grid points from config and registers them into the database
pub async fn initialize_grid(&self, cfg: &GridConfig) -> Result<()> {
if let Err(e) = self.queue.clear_queue().await {
tracing::warn!("初始化网格时清理闲置排队记录发生警告: {}", e);
/// Expands grid points from config and registers them into the database.
///
/// 多工作流分区(#3 修复):
/// - 仅清理**本工作流**的排队任务(clear_queue_by_workflow),不再 clear_queue() 全局清空,
/// 避免启动工作流 B 时误删工作流 A 的在队任务。
/// - 仅重置**本工作流**的 queued 点为 pendingreset_queued_grid_points_to_pending 带 wf),
/// 避免误伤其他工作流。
/// - upsert 带 workflow_name,使同一物理点可属于多个工作流。
pub async fn initialize_grid(&self, cfg: &GridConfig, workflow_name: &str) -> Result<()> {
if let Err(e) = self.queue.clear_queue_by_workflow(workflow_name).await {
tracing::warn!(
"初始化工作流 {} 网格时清理该流闲置排队记录发生警告: {}",
workflow_name,
e
);
}
if let Err(e) = self.db.reset_queued_grid_points_to_pending().await {
tracing::warn!("重置网格状态到 pending 处理过程遇到异常: {}", e);
if let Err(e) = self
.db
.reset_queued_grid_points_to_pending(workflow_name)
.await
{
tracing::warn!(
"重置工作流 {} 网格状态到 pending 处理过程遇到异常: {}",
workflow_name,
e
);
}
let mut points = Vec::new();
@@ -59,9 +78,21 @@ impl GridScheduler {
a.cno_sum()
.partial_cmp(&b.cno_sum())
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.teff.partial_cmp(&b.teff).unwrap_or(std::cmp::Ordering::Equal))
.then_with(|| b.logg.partial_cmp(&a.logg).unwrap_or(std::cmp::Ordering::Equal))
.then_with(|| a.loghe.partial_cmp(&b.loghe).unwrap_or(std::cmp::Ordering::Equal))
.then_with(|| {
a.teff
.partial_cmp(&b.teff)
.unwrap_or(std::cmp::Ordering::Equal)
})
.then_with(|| {
b.logg
.partial_cmp(&a.logg)
.unwrap_or(std::cmp::Ordering::Equal)
})
.then_with(|| {
a.loghe
.partial_cmp(&b.loghe)
.unwrap_or(std::cmp::Ordering::Equal)
})
});
// Group into Waves by cno_sum
@@ -79,46 +110,100 @@ impl GridScheduler {
current_cno = Some(cno);
}
self.db.upsert_grid_point(pt, wave_idx).await?;
// upsert 是幂等的 ON CONFLICT DO NOTHING:若 initialize_grid 中途失败,
// 重新 start 该工作流会自然补齐(#4 半初始化回退由幂等性消解)。
self.db
.upsert_grid_point(pt, wave_idx, workflow_name)
.await?;
}
info!("已在数据库中成功初始化并记录 {} 个恒星大气网格点", points.len());
info!(
"已在数据库中成功初始化并记录工作流 {} 的 {} 个恒星大气网格点",
workflow_name,
points.len()
);
Ok(())
}
async fn get_active_timeout_sec(&self) -> u64 {
if let Ok(yamls) = self.db.get_running_workflow_config_yamls().await {
for yaml in yamls {
if let Ok(cfg) = serde_yaml::from_str::<GridConfig>(&yaml) {
return cfg.timeout_sec;
}
/// 读取指定工作流的 timeout_sec(按工作流分区:多工作流各有自己的超时配置)。
async fn get_workflow_timeout_sec(&self, workflow_name: &str) -> u64 {
if let Ok(Some(wf)) = self.db.get_workflow(workflow_name).await {
if let Ok(cfg) = serde_yaml::from_str::<GridConfig>(&wf.config_yaml) {
return cfg.timeout_sec;
}
}
7200
}
/// Enqueues pending grid points into MQ with active seed detection and batching
/// 读取指定工作流的 seed_step_fallback 配置。
async fn get_workflow_seed_step_fallback(&self, workflow_name: &str) -> bool {
if let Ok(Some(wf)) = self.db.get_workflow(workflow_name).await {
if let Ok(cfg) = serde_yaml::from_str::<GridConfig>(&wf.config_yaml) {
return cfg.seed_step_fallback;
}
}
true
}
/// Enqueues pending grid points into MQ with active seed detection and batching.
///
/// 多工作流分区(#3 修复):对**每个** running/initializing 工作流分别派发任务,
/// 替代原来「全局只一个 running workflow」的 LIMIT 1 假设。各工作流独立 batch、
/// 独立 seed 匹配(seeds 仍是全局共享的物理资源池)。
pub async fn schedule_pending_tasks(&self) -> Result<usize> {
if !self.db.has_running_workflow().await? {
let workflows = self.db.get_running_workflow_names().await?;
if workflows.is_empty() {
return Ok(0);
}
let timeout_sec = self.get_active_timeout_sec().await;
let batch_limit: usize = std::env::var("DCTS_BATCH_LIMIT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100);
// SQL 层直接附加 LIMIT = batch_limit 筛选,完全免除数万点位无谓内存反序列化和空耗对象释放开销
let pending = self.db.get_pending_grid_points_limit(batch_limit).await?;
let mut total_dispatched = 0;
for wf in &workflows {
let dispatched = self
.schedule_pending_tasks_for_workflow(wf, batch_limit)
.await?;
total_dispatched += dispatched;
}
if total_dispatched > 0 {
info!(
"已成功将 {} 个待计算网格点推进任务队列(跨 {} 个工作流)",
total_dispatched,
workflows.len()
);
}
Ok(total_dispatched)
}
/// 为单个工作流派发 pending 点。
async fn schedule_pending_tasks_for_workflow(
&self,
workflow_name: &str,
batch_limit: usize,
) -> Result<usize> {
let timeout_sec = self.get_workflow_timeout_sec(workflow_name).await;
// SQL 层直接附加 LIMIT = batch_limit + workflow_name 筛选,完全免除数万点位无谓内存反序列化
let pending = self
.db
.get_pending_grid_points_limit(batch_limit, workflow_name)
.await?;
let mut dispatched = 0;
for (name, params, _wave) in pending {
// Check if any seed is available in DB for active SeedStep scheduling
// Check if any seed is available in DB for active SeedStep schedulingseeds 全局共享)
let (task_type, seed_name) = match self.db.find_best_seed_from_db(&params).await {
Ok(Some(seed_match)) => {
info!("网格点 {} 匹配到数据库近邻种子 {} (距离: {:.2}),安排 SeedStep 热启动调度", name, seed_match.name, seed_match.distance);
info!(
"工作流 {} 网格点 {} 匹配到数据库近邻种子 {} (距离: {:.2}),安排 SeedStep 热启动调度",
workflow_name, name, seed_match.name, seed_match.distance
);
(TaskType::SeedStep, Some(seed_match.name))
}
_ => (TaskType::ColdRun, None),
@@ -131,48 +216,96 @@ impl GridScheduler {
task_type,
seed_point_name: seed_name,
timeout_sec,
workflow_name: Some(workflow_name.to_string()),
};
self.db.insert_task(&task_spec).await?;
// 采用先标记 DB 状态为 Queued 后发 MQ 的时序,防止推入 MQ 后数据库修改异常导向下一轮误重投
self.db.update_grid_status(&name, common::models::GridPointStatus::Queued).await?;
self.db
.update_grid_status(
&name,
common::models::GridPointStatus::Queued,
workflow_name,
)
.await?;
match self.queue.push_task(&task_spec).await {
Ok(_) => {
dispatched += 1;
}
Err(e) => {
tracing::warn!("将任务 {} 推入 MQ 队列失败,回滚网格点状态: {}", name, e);
let _ = self.db.update_grid_status(&name, common::models::GridPointStatus::Pending).await;
tracing::warn!(
"将任务 {} 推入 MQ 队列失败,执行严格状态回滚以避免脏数据: {}",
name,
e
);
if let Err(db_e) = self
.db
.update_grid_status(
&name,
common::models::GridPointStatus::Pending,
workflow_name,
)
.await
{
tracing::error!(
"关键性回滚异常:任务 {} 无法重置回 Pending: {}",
name,
db_e
);
}
let _ = self.queue.remove_task(&task_spec.task_id.to_string()).await;
}
}
}
if dispatched > 0 {
info!("已成功将 {} 个待计算网格点推进任务队列", dispatched);
}
Ok(dispatched)
}
/// Triggers seed_step fallback for a failed point if a seed is available
pub async fn trigger_seed_step_fallback(&self, params: &GridPointParams) -> Result<bool> {
if !self.db.has_running_workflow().await? {
/// Triggers seed_step fallback for a failed point if a seed is available.
///
/// 多工作流分区(#3 修复):传入 `workflow_name` 明确该失败点所属工作流,
/// 用该工作流自身的 timeout / seed_step_fallback 配置,并把 TaskSpec.workflow_name
/// 绑定到该工作流。
///
/// 语义(种子回退仅一次):
/// - 仅当该工作流配置 `seed_step_fallback: true` 时才考虑回退;
/// - 仅当该点**尚未**派发过任何 seed_step 任务时才回退一次;
/// - 找不到合适近邻种子则不回退,由调用方保持 failed 终态。
pub async fn trigger_seed_step_fallback(
&self,
params: &GridPointParams,
workflow_name: &str,
) -> Result<bool> {
// 该工作流须仍处于 running 态才回退(避免 stop 后继续派发)
let still_running = self
.db
.get_running_workflow_names()
.await?
.iter()
.any(|w| w == workflow_name);
if !still_running {
return Ok(false);
}
if !self.get_workflow_seed_step_fallback(workflow_name).await {
return Ok(false);
}
let name = params.model_name();
if let Ok(Some((status, attempt_count))) = self.db.get_grid_point_status(&name).await {
if status == "failed" || attempt_count >= 3 {
info!("网格点 {} 已达到最大重试次数 ({}) 或处于 failed 状态,跳过种子热启动回退", name, attempt_count);
return Ok(false);
}
// 种子回退仅一次:该点在该工作流中已经派发过 seed_step 任务就不再触发新的回退
if self.db.has_seed_step_attempt(&name, workflow_name).await? {
info!(
"网格点 {} 已使用过一次种子热启动回退,不再重复回退,保持 failed 终态",
name
);
return Ok(false);
}
// seeds 全局共享:跨工作流复用已收敛的邻近种子
let seed_match_opt = self.db.find_best_seed_from_db(params).await.ok().flatten();
if let Some(seed_match) = seed_match_opt {
let timeout_sec = self.get_active_timeout_sec().await;
let timeout_sec = self.get_workflow_timeout_sec(workflow_name).await;
let name = params.model_name();
let task_spec = TaskSpec {
task_id: Uuid::new_v4(),
@@ -181,22 +314,38 @@ impl GridScheduler {
task_type: TaskType::SeedStep,
seed_point_name: Some(seed_match.name.clone()),
timeout_sec,
workflow_name: Some(workflow_name.to_string()),
};
self.db.insert_task(&task_spec).await?;
self.db.update_grid_status(&name, common::models::GridPointStatus::Queued).await?;
self.db
.update_grid_status(
&name,
common::models::GridPointStatus::Queued,
workflow_name,
)
.await?;
if let Err(e) = self.queue.push_task(&task_spec).await {
let _ = self.db.update_grid_status(&name, common::models::GridPointStatus::Pending).await;
let _ = self
.db
.update_grid_status(
&name,
common::models::GridPointStatus::Pending,
workflow_name,
)
.await;
let _ = self.queue.remove_task(&task_spec.task_id.to_string()).await;
return Err(e);
}
info!("触发种子步进 (seed_step):网格点 {} 将使用 6 维近邻种子 {} 热启动重试", name, seed_match.name);
info!(
"触发种子步进 (seed_step):工作流 {} 网格点 {} 将使用 6 维近邻种子 {} 热启动重试",
workflow_name, name, seed_match.name
);
Ok(true)
} else {
Ok(false)
}
}
}
#[cfg(test)]
@@ -212,8 +361,16 @@ mod tests {
let results_dir = temp_dir.path().join("results");
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let queue = Arc::new(SqliteTaskQueue::new(&queue_db_path.to_string_lossy()).await.unwrap());
let scheduler = GridScheduler::new(db.clone(), queue.clone(), results_dir.to_string_lossy().to_string());
let queue = Arc::new(
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
.await
.unwrap(),
);
let scheduler = GridScheduler::new(
db.clone(),
queue.clone(),
results_dir.to_string_lossy().to_string(),
);
let cfg = GridConfig {
grid: GridAxesConfig {
@@ -238,17 +395,141 @@ mod tests {
linelist: None,
};
scheduler.initialize_grid(&cfg).await.unwrap();
db.upsert_workflow("test_wf", None, "", "running").await.unwrap();
scheduler.initialize_grid(&cfg, "test_wf").await.unwrap();
db.upsert_workflow("test_wf", None, "", "running")
.await
.unwrap();
let pending = db.get_pending_grid_points().await.unwrap();
let pending = db.get_pending_grid_points("test_wf").await.unwrap();
assert_eq!(pending.len(), 1);
let dispatched = scheduler.schedule_pending_tasks().await.unwrap();
assert_eq!(dispatched, 1);
let popped = queue.pop_task().await.unwrap();
let popped = queue.pop_task("test-node").await.unwrap();
assert!(popped.is_some());
}
}
/// 多工作流分区调度测试(#3 修复验证):
/// 1. wf_a 调度推入队列的任务,在初始化 wf_b 后依然存在(initialize_grid 改用
/// clear_queue_by_workflow,不再全局 clear_queue)。
/// 2. 两个 running 工作流的 pending 点都能被 schedule_pending_tasks 派发。
#[tokio::test]
async fn test_multi_workflow_dispatch_isolation() {
let temp_dir = tempfile::tempdir().unwrap();
let db = Database::new(&temp_dir.path().join("mw_db.db").to_string_lossy())
.await
.unwrap();
let queue = Arc::new(
SqliteTaskQueue::new(&temp_dir.path().join("mw_queue.db").to_string_lossy())
.await
.unwrap(),
);
let scheduler = GridScheduler::new(db.clone(), queue.clone(), "results".to_string());
let mk_cfg = |teff: f64| GridConfig {
grid: GridAxesConfig {
teff: vec![teff],
logg: vec![5.5],
loghe: vec![-1.0],
logc: vec![-2.0],
logn: vec![-2.0],
logo: vec![-2.0],
},
chain: vec![],
synspec: None,
nworkers: 4,
timeout_sec: 3600,
resume: true,
seed_step_fallback: true,
results: None,
itek_fallback: vec![],
niter: Some(100),
template: None,
fort55: None,
linelist: None,
};
// wf_a 初始化并推入队列
scheduler
.initialize_grid(&mk_cfg(35000.0), "wf_a")
.await
.unwrap();
db.upsert_workflow("wf_a", None, "", "running")
.await
.unwrap();
let d_a = scheduler.schedule_pending_tasks().await.unwrap();
assert_eq!(d_a, 1);
// 任务已在队
assert!(queue.pop_task("node-a").await.unwrap().is_some());
// 重新推一个 wf_a 任务(上一行 pop 掉了),再初始化 wf_b
db.update_grid_status(
&GridPointParams {
teff: 35000.0,
logg: 5.5,
loghe: -1.0,
logc: -2.0,
logn: -2.0,
logo: -2.0,
}
.model_name(),
common::models::GridPointStatus::Pending,
"wf_a",
)
.await
.unwrap();
let _ = scheduler
.schedule_pending_tasks_for_workflow("wf_a", 100)
.await
.unwrap();
// 此时 wf_a 队列里应有一个任务
assert_eq!(
queue
.pop_task("node-a")
.await
.unwrap()
.and_then(|t| t.workflow_name),
Some("wf_a".to_string())
);
// 关键断言:把 wf_a 任务重新推回队列后,初始化 wf_b 不应清空它。
db.update_grid_status(
&GridPointParams {
teff: 35000.0,
logg: 5.5,
loghe: -1.0,
logc: -2.0,
logn: -2.0,
logo: -2.0,
}
.model_name(),
common::models::GridPointStatus::Pending,
"wf_a",
)
.await
.unwrap();
let _ = scheduler
.schedule_pending_tasks_for_workflow("wf_a", 100)
.await
.unwrap();
// 初始化 wf_b(内部 clear_queue_by_workflow("wf_b"),不该动 wf_a 的任务)
scheduler
.initialize_grid(&mk_cfg(40000.0), "wf_b")
.await
.unwrap();
db.upsert_workflow("wf_b", None, "", "running")
.await
.unwrap();
// wf_a 的任务仍在队:可被 node 弹出,且 workflow_name == wf_a
let popped_a = queue.pop_task("node-a").await.unwrap();
assert!(popped_a.is_some(), "初始化 wf_b 不应清空 wf_a 的队列任务");
assert_eq!(popped_a.unwrap().workflow_name, Some("wf_a".to_string()));
// wf_b 的点也能被调度(两个 running 工作流并存)
let d_b = scheduler.schedule_pending_tasks().await.unwrap();
assert!(d_b >= 1, "wf_b 的 pending 点应被派发");
}
}