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:
+333
-52
@@ -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 点为 pending(reset_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 scheduling(seeds 全局共享)
|
||||
let (task_type, seed_name) = match self.db.find_best_seed_from_db(¶ms).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 点应被派发");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user