feat(all): 数据库模块化拆分与版本化迁移、任务引擎命名体系收敛、物理输出校验加固与用户配置接通

- server/db: 拆 4929 行 db.rs 单体为 db/ 目录,migrations.rs 引入 PRAGMA user_version
    版本化迁移运行器(M1~M13)
  - 任务引擎 Phase 6/7b/7c 改名收敛:EngineStageConfig→PhaseConfig、StagePolicy→ResumePolicy、
    Converged→Completed、删除 task_type 列、success_method 拆 tlusty_/synspec_ 双列、
    新增 tlusty_status/synspec_status 半失败阶段守卫
  - 科学正确性加固:conv_check 任意行 NaN/Inf/溢出判无效(0 行容忍)、新增 spec_is_valid
    校验 SYNSPEC 脏谱、itek_history 逐次迭代全量保真、fmt_abn powf 溢出饱和
  - 用户配置真正接通:tlusty_chain/tlusty_input 由死字段经 调度器→TaskSpec→executor→runner
    透传生效;config 加载期 validate + deny_unknown_fields + 解析失败记 warn
  - 调度修复:H1 活锁(pending_strategies 跳过已失败策略)、种子查找错误不再静默降级冷启动
  - dashboard: 阶段配置面板 tlusty_stage/synspec_stage、"已完成"标签、迭代诊断展示
  - docs: 新增 database_refactor_design.md,同步 database/api/PIPELINE/workflow_detail
This commit is contained in:
fmq
2026-08-06 20:51:21 +08:00
parent cd370d88e7
commit d16b3d3cdc
61 changed files with 10268 additions and 5881 deletions
+12 -3
View File
@@ -13,7 +13,16 @@ pub async fn healthz() -> Result<impl IntoResponse, crate::api::AppError> {
pub async fn get_status(
State(state): State<AppState>,
) -> Result<impl IntoResponse, crate::api::AppError> {
let nodes = state.db.get_active_nodes().await.unwrap_or_default();
// 审查修复 #N6:原 unwrap_or_default 吞掉 DB 错误,前端显示「0 节点在线」掩盖真实故障。
// 现至少记 warn,让运维能在日志里定位「dashboard 为何显示无节点」。仍降级为空列表
// (返回 500 会让 dashboard 整个 status 面板不可用,空列表是更安全的退化)。
let nodes = match state.db.get_active_nodes().await {
Ok(n) => n,
Err(e) => {
tracing::warn!("get_status 查询活跃节点失败,降级为空列表: {}", e);
Vec::new()
}
};
let total_active_slots: i32 = nodes.iter().map(|n| n.active_slots).sum();
let total_max_slots: i32 = nodes.iter().map(|n| n.max_slots).sum();
@@ -24,8 +33,8 @@ pub async fn get_status(
.get_grid_summary_stats(None)
.await
.unwrap_or(serde_json::json!({
"total": 0, "pending": 0, "queued": 0, "running": 0, "converged": 0, "failed": 0,
"cold_run_converged": 0, "seed_step_converged": 0
"total": 0, "pending": 0, "queued": 0, "running": 0, "completed": 0, "failed": 0,
"cold_run_converged": 0, "seed_step_converged": 0, "synspec_converged": 0
}));
Ok(Json(json!({
+15 -8
View File
@@ -46,7 +46,11 @@ pub async fn claim_task(
// 旧版在途任务 payload 无 workflow_nameNone)→ 归一到主库迁移回填的
// '__legacy__' 标记,使 mark_grid_point_running 能命中 legacy 网格点(H1 修复)。
let wf = crate::db::normalize_workflow_name(task.workflow_name.as_deref());
match state.db.mark_grid_point_running(&task.point_name, &wf).await {
match state
.db
.mark_grid_point_running(&task.point_name, &wf)
.await
{
Ok(false) => {
info!(
"领用任务 {}(网格点 {})时点已非 pending/queued 态,跳过 running 标记(迟到/重复领用)",
@@ -269,7 +273,7 @@ pub async fn report_task(
}
// Save seed file .7 using atomic temporary writing strategy
if report.converged && !report.atmosphere_has_nan {
if report.result_valid && !report.atmosphere_has_nan {
if let Some(bytes) = seed_file_data {
let seed_tmp =
model_dir.join(format!("{}.7.{}.tmp", name, uuid::Uuid::new_v4().simple()));
@@ -298,7 +302,7 @@ pub async fn report_task(
}
if state_changed
&& (!report.converged
&& (!report.result_valid
|| report.atmosphere_has_nan
|| report.status == TaskStatus::Failed
|| report.status == TaskStatus::Timeout)
@@ -369,8 +373,9 @@ pub async fn import_seed(
) -> Result<impl IntoResponse, crate::api::AppError> {
let mut summary_json: Option<String> = None;
let mut seed_file_data: Option<Vec<u8>> = None;
// 收敛途径(cold_run/seed_step):由 import_results 工具依据旧 conv.json 的 stages 是否
// 大气收敛途径(cold_run/seed_step):由 import_results 工具依据旧 conv.json 的 stages 是否
// 含 seed_nc 判定后透传。缺失或非法时兜底 cold_run(容错旧版工具 / 防注入)。
// 语义为 TLUSTY 阶段策略,写入 grid_points.tlusty_success_method。
let mut success_method: Option<String> = None;
let mut multipart_error = false;
@@ -400,13 +405,13 @@ pub async fn import_seed(
multipart_error = true;
}
}
} else if field_name == "success_method" {
} else if field_name == "tlusty_success_method" {
match field.text().await {
Ok(text) => {
success_method = Some(text);
}
Err(e) => {
warn!("历史种子导入:读取 success_method 字段失败: {}", e);
warn!("历史种子导入:读取 tlusty_success_method 字段失败: {}", e);
multipart_error = true;
}
}
@@ -463,7 +468,7 @@ pub async fn import_seed(
let workflow_name = query.workflow;
let params = summary.params.clone();
let converged = summary.converged && !summary.atmosphere_has_nan;
let converged = summary.result_valid && !summary.atmosphere_has_nan;
let max_relc = summary.final_max_relc;
// 1. 幂等写入 grid_pointsON CONFLICT DO NOTHING):无需事先 start 工作流。
@@ -524,7 +529,7 @@ pub async fn import_seed(
}
}
// 4. 更新 grid_points 状态:收敛→converged(success_method=工具判定的途径);否则维持 pending
// 4. 更新 grid_points 状态:收敛→converged(大气归因 tlusty_success_method=工具判定的途径);否则维持 pending
// 让正常调度处理(导入未收敛点无意义,但记录其尝试)。
// 途径缺失或非法时兜底 cold_run(容错旧版工具 / 防注入),由 db 层再次白名单校验。
if converged {
@@ -556,6 +561,8 @@ pub async fn import_seed(
Json(json!({
"status": "ok",
"point_name": name,
// 注:此 "converged" 键是大气收敛标志(由 summary.result_valid 派生),与 grid_points.status
// 的 completed 重命名无关,勿改(改键名会破坏历史种子导入客户端)。
"converged": converged,
"max_relc": max_relc,
})),
+28 -21
View File
@@ -426,8 +426,8 @@ pub async fn get_workflow_progress(
}
};
let rate_per_hour = avg_per_hour(|p| p.converged);
let done_rate_per_hour = avg_per_hour(|p| p.converged + p.failed);
let rate_per_hour = avg_per_hour(|p| p.completed);
let done_rate_per_hour = avg_per_hour(|p| p.completed + p.failed);
// 速率统计的实际时间跨度(近 2h 子窗口首末间隔,≤2h;回退整窗时为整窗跨度)。
let rate_span_hours: Option<f64> = {
@@ -448,8 +448,8 @@ pub async fn get_workflow_progress(
let stalled_minutes: Option<f64> = if series.len() >= 2 {
let mut last_progress_idx = None;
for i in 1..series.len() {
let prev = series[i - 1].converged + series[i - 1].failed;
let cur = series[i].converged + series[i].failed;
let prev = series[i - 1].completed + series[i - 1].failed;
let cur = series[i].completed + series[i].failed;
if cur > prev {
last_progress_idx = Some(i);
}
@@ -529,9 +529,11 @@ pub async fn get_workflow_points(
}
if let Some(s) = &pq.status {
// "converged" 保留作旧客户端兼容值(7c 由 converged 改名 completed),
// 通过后于下方归一化为 "completed" 再绑定 SQL。
if !matches!(
s.as_str(),
"pending" | "queued" | "running" | "converged" | "failed"
"pending" | "queued" | "running" | "completed" | "converged" | "failed"
) {
return Err(crate::api::AppError::BadRequest(format!(
"非法的 status 参数: {}",
@@ -540,7 +542,10 @@ pub async fn get_workflow_points(
}
}
if let Some(m) = &pq.method {
if !matches!(m.as_str(), "cold_run" | "seed_step") {
// 值域:TLUSTY 阶段策略名(cold_run/seed_step,映射 tlusty_success_method)∪
// sentinel "synspec_only"(光谱专用点,映射 tlusty IS NULL AND synspec IS NOT NULL)。
// 前缀化避免与潜在的同名 TLUSTY 策略碰撞(P9/E 语义拆分)。
if !matches!(m.as_str(), "cold_run" | "seed_step" | "synspec_only") {
return Err(crate::api::AppError::BadRequest(format!(
"非法的 method 参数: {}",
m
@@ -560,21 +565,16 @@ pub async fn get_workflow_points(
} else {
"ASC"
};
// P3 窗口重写后,ORDER BY 作用于外层子查询(列名裸露,无 gp./t. 前缀);
// 耗时排序引用内层 COALESCE 的输出别名 eff_elapsed。列名均为编译期白名单。
let order_by = match sort {
"wave" => format!("gp.wave {dir}, gp.cno_sum ASC, gp.teff ASC"),
"teff" => format!("gp.teff {dir}, gp.wave ASC, gp.cno_sum ASC"),
"max_relc" => format!("t.max_relc IS NULL ASC, t.max_relc {dir}, gp.wave ASC"),
"attempts" => format!("gp.attempt_count {dir}, gp.wave ASC, gp.cno_sum ASC"),
// 耗时取最近一次尝试的真实墙钟(与列表展示同口径 COALESCE),NULL(从未派发)靠后。
"elapsed" => {
format!(
"COALESCE(t.elapsed_sec, gp.last_elapsed_sec) IS NULL ASC, \
COALESCE(t.elapsed_sec, gp.last_elapsed_sec) {dir}, gp.wave ASC"
)
}
"last_completed_at" => {
format!("t.completed_at IS NULL ASC, t.completed_at {dir}, gp.wave ASC")
}
"wave" => format!("wave {dir}, cno_sum ASC, teff ASC"),
"teff" => format!("teff {dir}, wave ASC, cno_sum ASC"),
"max_relc" => format!("max_relc IS NULL ASC, max_relc {dir}, wave ASC"),
"attempts" => format!("attempt_count {dir}, wave ASC, cno_sum ASC"),
// 耗时取最近一次尝试的真实墙钟(与列表展示同口径 eff_elapsed),NULL(从未派发)靠后。
"elapsed" => format!("eff_elapsed IS NULL ASC, eff_elapsed {dir}, wave ASC"),
"last_completed_at" => format!("completed_at IS NULL ASC, completed_at {dir}, wave ASC"),
_ => {
return Err(crate::api::AppError::BadRequest(format!(
"非法的 sort 参数: {}",
@@ -584,7 +584,14 @@ pub async fn get_workflow_points(
};
let filter = crate::db::PointFilter {
status: pq.status.clone(),
// 7c 改名:旧值 "converged" 归一化为 "completed"DB 实际存储值)。
status: pq.status.as_deref().map(|s| {
if s == "converged" {
"completed".to_string()
} else {
s.to_string()
}
}),
method: pq.method.clone(),
wave: pq.wave,
q: pq.q.clone(),
File diff suppressed because it is too large Load Diff
+937
View File
@@ -0,0 +1,937 @@
//! 网格点(从原 db.rs 拆分,2026-08-06)。
//! `impl Database` 的 网格点 域方法。共享基础设施(Database struct、连接管理、类型、helper)见父模块 `super`crate::db)。
use super::*;
impl Database {
// --- Grid Point & Task operations ---
pub async fn upsert_grid_point(
&self,
params_in: &GridPointParams,
wave: i32,
workflow_name: &str,
) -> Result<()> {
let name = params_in.model_name();
self.upsert_grid_point_named(&name, params_in, wave, workflow_name)
.await
}
/// 与 `upsert_grid_point` 相同,但使用调用方提供的权威 `name`(而非从 params 重推)。
///
/// 历史种子导入专用:旧版 conv.json 的 `name` 是源精度真名(如 `t20000_g5.0_...`),
/// 而导入路径的 params 来自旧 JSON(数值,无源文本,`model_name()` 会失真),故必须
/// 显式传入旧名以保证 DB `name` 列与旧数据逐字符一致。
pub async fn upsert_grid_point_named(
&self,
name: &str,
params_in: &GridPointParams,
wave: i32,
workflow_name: &str,
) -> Result<()> {
let pool = self.pool.clone();
let p = params_in.clone();
let name = name.to_string();
let cno_sum = p.cno_sum();
let wf = workflow_name.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
conn.execute(
"INSERT INTO grid_points (name, workflow_name, teff, logg, loghe, logc, logn, logo, cno_sum, wave)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT(workflow_name, name) DO NOTHING",
params![name, wf, p.teff.value(), p.logg.value(), p.loghe.value(), p.logc.value(), p.logn.value(), p.logo.value(), cno_sum, wave],
)?;
Ok(())
})
.await??;
Ok(())
}
/// 计算某网格点的难度波次(wave):该工作流内所有 `cno_sum` 严格小于本点(1e-5 容差)
/// 的去重值个数,即 `cno_sum` 升序中的桶序号——与 `initialize_grid` 的波次分组口径一致
/// `scheduler.rs` 沿排序序扫描,cno_sum 变化 >1e-5 则 wave+1)。
///
/// 历史种子导入(`import_seed`)用它替代此前硬编码的 `wave=0`:导入点按现有规则归入
/// 正确波次,前端难度波次推进不再把全部导入点挤在第一波。对已完全填充的工作流(所有
/// cno_sum 等级都在库内),任意导入顺序的 rank 都正确;对全新工作流,配合 import_results
/// 工具按 cno_sum 升序导入即可保证最终波次一致。
pub async fn compute_wave_for_cno_sum(&self, workflow_name: &str, cno_sum: f64) -> Result<i32> {
let pool = self.pool.clone();
let wf = workflow_name.to_string();
let cno = cno_sum;
let wave = tokio::task::spawn_blocking(move || -> Result<i32> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let n: i64 = conn.query_row(
"SELECT COUNT(DISTINCT cno_sum) FROM grid_points WHERE workflow_name = ?1 AND cno_sum < ?2 - 1e-5",
params![wf, cno],
|r| r.get(0),
)?;
Ok(n as i32)
})
.await??;
Ok(wave)
}
/// 仅用于测试/诊断:取指定工作流的全部 pending 点(无 LIMIT)。生产调度走 _limit 版本。
pub async fn get_pending_grid_points(
&self,
workflow_name: &str,
) -> Result<Vec<(String, GridPointParams, i32)>> {
self.get_pending_grid_points_limit(usize::MAX, workflow_name)
.await
}
pub async fn get_pending_grid_points_limit(
&self,
limit: usize,
workflow_name: &str,
) -> Result<Vec<(String, GridPointParams, i32)>> {
let pool = self.pool.clone();
let wf = workflow_name.to_string();
tokio::task::spawn_blocking(move || -> Result<Vec<(String, GridPointParams, i32)>> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare(
"SELECT name, teff, logg, loghe, logc, logn, logo, wave FROM grid_points
WHERE status = 'pending' AND workflow_name = ?1
ORDER BY wave ASC, cno_sum ASC, teff ASC LIMIT ?2",
)?;
let limit_param = if limit == usize::MAX {
-1i64
} else {
limit as i64
};
let rows_iter = stmt.query_map(params![wf, limit_param], |r| {
Ok((
r.get::<_, String>(0)?,
GridPointParams {
teff: GridAxisValue::from_value(r.get::<_, f64>(1)?),
logg: GridAxisValue::from_value(r.get::<_, f64>(2)?),
loghe: GridAxisValue::from_value(r.get::<_, f64>(3)?),
logc: GridAxisValue::from_value(r.get::<_, f64>(4)?),
logn: GridAxisValue::from_value(r.get::<_, f64>(5)?),
logo: GridAxisValue::from_value(r.get::<_, f64>(6)?),
},
r.get::<_, i32>(7)?,
))
})?;
let mut list = Vec::new();
for r in rows_iter {
list.push(r?);
}
Ok(list)
})
.await?
}
/// 原子选点:在 IMMEDIATE 事务内将 pending 点标记为 queued 并返回。
///
/// 解决 `get_pending_grid_points_limit`SELECT)与 `update_grid_status`UPDATE
/// 分离导致的 TOCTOU 竞态:两个并发调度调用可能 SELECT 到同一批 pending 点,
/// 各自创建任务,产生重复派发(#5 修复)。
///
/// 与 `pop_task`sqlite_queue.rs)和 `take_pending_node_token` 同口径:
/// IMMEDIATE 事务在 BEGIN 时即获取写锁,SELECT 与 UPDATE 之间不会被其它
/// 调用方插入,从而只有一个调用方能 claiming 到某批点。
pub async fn claim_pending_grid_points(
&self,
limit: usize,
workflow_name: &str,
) -> Result<Vec<(String, GridPointParams, i32)>> {
let pool = self.pool.clone();
let wf = workflow_name.to_string();
tokio::task::spawn_blocking(move || -> Result<Vec<(String, GridPointParams, i32)>> {
let mut conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let limit_param = if limit == usize::MAX {
-1i64
} else {
limit as i64
};
// P3 附带优化(§5.3):先按调度优先级 SELECT 有序取行(ORDER BY wave/cno_sum/teff),
// 再在同一 IMMEDIATE 事务内按 rowid 原子标记 queued——UPDATE...RETURNING 不保序,
// 旧实现依赖 Rust 侧 sort_by 重排;改为 SELECT 排序贯穿后删除 Rust 排序。
let mut select_stmt = tx.prepare(
"SELECT rowid, name, teff, logg, loghe, logc, logn, logo, wave
FROM grid_points
WHERE status = 'pending' AND workflow_name = ?1
ORDER BY wave ASC, cno_sum ASC, teff ASC
LIMIT ?2",
)?;
let mut list = Vec::new();
let mut ids = Vec::new();
{
let rows = select_stmt.query_map(params![wf, limit_param], |r| {
Ok((
r.get::<_, i64>(0)?,
(
r.get::<_, String>(1)?,
GridPointParams {
teff: GridAxisValue::from_value(r.get::<_, f64>(2)?),
logg: GridAxisValue::from_value(r.get::<_, f64>(3)?),
loghe: GridAxisValue::from_value(r.get::<_, f64>(4)?),
logc: GridAxisValue::from_value(r.get::<_, f64>(5)?),
logn: GridAxisValue::from_value(r.get::<_, f64>(6)?),
logo: GridAxisValue::from_value(r.get::<_, f64>(7)?),
},
r.get::<_, i32>(8)?,
),
))
})?;
for r in rows {
let (id, item) = r?;
ids.push(id);
list.push(item);
}
}
drop(select_stmt);
// 同事务原子标记 queuedIMMEDIATE 已持有写锁,SELECT→UPDATE 间无竞态窗口)。
// 阶段列(5b)同步:tlusty_status 守卫保留既有终态(半失败重试时 tlusty_status 已
// converged 不被覆盖);synspec_status 自由流转为 queued(重试进行中可见——设计
// §7.3 打开项 #2 "仅 synspec 侧流转",审查修正:原实现连 synspec 'failed' 也保留,
// 与设计意图不符)。
if !ids.is_empty() {
// 审查修复 #M6SQLite 单语句参数上限 999limit=usize::MAX(映射为 LIMIT -1
// 且 pending 点极多时会撑爆 IN(?,?,...)。按 500 一批分次 UPDATE(与
// delete_tasks_by_ids 同口径),每批独立语句、同一事务,原子性与原实现等价。
for chunk in ids.chunks(500) {
let placeholders = vec!["?"; chunk.len()].join(", ");
let sql = format!(
"UPDATE grid_points SET status = 'queued',
tlusty_status = CASE WHEN tlusty_status NOT IN ('converged','failed') THEN 'queued' ELSE tlusty_status END,
synspec_status = 'queued'
WHERE rowid IN ({placeholders})"
);
tx.execute(&sql, rusqlite::params_from_iter(chunk.iter()))?;
}
}
tx.commit()?;
Ok(list)
})
.await?
}
/// 重置指定工作流的 queued 点为 pending(系统重启/工作流启动时使用)。
/// 按 workflow 隔离,避免误伤其他工作流(多工作流分区修复点)。
pub async fn reset_queued_grid_points_to_pending(&self, workflow_name: &str) -> Result<usize> {
let pool = self.pool.clone();
let wf = workflow_name.to_string();
tokio::task::spawn_blocking(move || -> Result<usize> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
// 只重置 queued 状态的任务为 pending。对于 running (正由 Worker 处理的项目),不可在系统重启或初始化时粗暴清零,让 Worker 正常完成汇报或触发心跳/超时自动逐回
let rows = conn.execute(
"UPDATE grid_points SET status = 'pending' WHERE status = 'queued' AND workflow_name = ?1",
params![wf],
)?;
Ok(rows)
})
.await?
}
/// ForceRecompute 策略的终态重置(见 docs/task_engine_decoupling_design.md §2.1):
/// 把工作流内**已收敛 / 已失败**的终态点全部打回 pending,使调度器无视历史状态与
/// 产物强制重算。在 workflow start 时由 initialize_grid 据策略调用。
///
/// 不触碰 queued/running(在途任务正常结算)与 pending(本就待派发)。
/// 按 workflow 隔离,避免跨工作流误改同名点(多工作流分区修复点)。
pub async fn reset_terminal_points_for_recompute(&self, workflow_name: &str) -> Result<usize> {
let pool = self.pool.clone();
let wf = workflow_name.to_string();
tokio::task::spawn_blocking(move || -> Result<usize> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let rows = conn.execute(
"UPDATE grid_points SET status = 'pending'
WHERE workflow_name = ?1 AND status IN ('completed', 'failed')",
params![wf],
)?;
Ok(rows)
})
.await?
}
/// SkipConverged 策略的启动时重置(2026-08-04 语义修正):仅把**已失败**的点打回
/// pending 重试,**已收敛**点保留(增量语义)。与 ForceRecompute(收敛+失败全量
/// 重置)和 SkipFailed(收敛/失败都保留)区别开——"跳过收敛、重试失败"是默认策略
/// 应有的行为,此前 SkipConverged 与 SkipFailed 在启动时行为相同,无法表达它。
///
/// 不触碰 queued/running(在途任务正常结算)与 pending(本就待派发)。
/// 按 workflow 隔离,避免跨工作流误改同名点。
pub async fn reset_failed_points_for_retry(&self, workflow_name: &str) -> Result<usize> {
let pool = self.pool.clone();
let wf = workflow_name.to_string();
tokio::task::spawn_blocking(move || -> Result<usize> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let rows = conn.execute(
"UPDATE grid_points SET status = 'pending'
WHERE workflow_name = ?1 AND status = 'failed'",
params![wf],
)?;
Ok(rows)
})
.await?
}
/// 重置指定工作流内一批点(按 name)为 pending。
/// 按 workflow 隔离,避免跨工作流误改同名点(多工作流分区修复点)。
pub async fn reset_specific_grid_points_to_pending(
&self,
names: &[String],
workflow_name: &str,
) -> Result<usize> {
if names.is_empty() {
return Ok(0);
}
let pool = self.pool.clone();
let names_owned = names.to_vec();
let wf = workflow_name.to_string();
tokio::task::spawn_blocking(move || -> Result<usize> {
let mut conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let tx = conn.transaction()?;
let mut count = 0;
for name in &names_owned {
count += tx.execute(
"UPDATE grid_points SET status = 'pending' WHERE name = ?1 AND workflow_name = ?2 AND status IN ('queued', 'running')",
params![name, wf],
)?;
}
tx.commit()?;
Ok(count)
})
.await?
}
/// 找出疑似孤儿的网格点及其 stale pending 任务行(#6 修复兜底,2026-08-02 重构)。
///
/// 候选条件:网格点处于 `running`/`queued` 态,且 `tasks` 表中存在该点创建时间
/// 老于 stale_sec 的 `pending` 行。候选**不等于**孤儿——老 pending 行可能是正常
/// 在途任务(requeue_stale_tasks 按同 task_id 重投不更新 tasks.created_at,长任务
/// 会天然变"老")。调用方(GridScheduler::reclaim_orphaned_points)必须对每个
/// task_id 做 MQ 活性交叉校验(task_row_exists):队列行仍 pending/claimed 即真
/// 在途,放行;全部无队列行才是凭证丢失的真孤儿。
///
/// 历史教训(2026-08-02 涡旋事故):旧实现 reset_orphaned_running_points 仅凭
/// "存在老 pending 行"即重置点,而 stop/重启遗留的僵尸 pending 行使该判据对每个
/// 在跑的点恒真 → 每 30s 巡检重置 + 同轮再派发 → 单点被重复计算上百次。
///
/// 返回 (name, workflow_name, task_id) 行列表,由调用方按点分组。
pub async fn find_stale_pending_points(
&self,
stale_sec: u64,
) -> Result<Vec<(String, String, String)>> {
let pool = self.pool.clone();
tokio::task::spawn_blocking(move || -> Result<Vec<(String, String, String)>> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let stale_offset = format!("-{} seconds", stale_sec);
let mut stmt = conn.prepare(
"SELECT gp.name, gp.workflow_name, t.task_id
FROM grid_points gp
JOIN tasks t
ON t.point_name = gp.name
AND t.workflow_name = gp.workflow_name
WHERE gp.status IN ('running', 'queued')
AND t.status = 'pending'
AND t.created_at < datetime('now', ?1)",
)?;
let rows = stmt.query_map(params![stale_offset], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
))
})?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
})
.await?
}
/// 把孤儿网格点救回 `pending`(仅当点仍处于 running/queued 时生效)。
///
/// 与 `find_stale_pending_points` + MQ 活性校验 + `delete_tasks_by_ids` 组合使用:
/// 调用方确认该点所有 stale pending 任务行均无队列凭证(真孤儿)并清除这些行后,
/// 调本方法让调度器重新派发。WHERE 的 status 条件是并发防护:若校验期间恰好有
/// 迟到上报把点置为终态(converged/failed),本 UPDATE 命中 0 行,不覆盖终态。
///
/// 返回是否实际重置(false = 点已不在 running/queued,无需处理)。
pub async fn rescue_orphaned_point(&self, name: &str, workflow_name: &str) -> Result<bool> {
let pool = self.pool.clone();
let name_owned = name.to_string();
let wf = workflow_name.to_string();
let changed = tokio::task::spawn_blocking(move || -> Result<bool> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let n = conn.execute(
"UPDATE grid_points SET status = 'pending'
WHERE name = ?1 AND workflow_name = ?2 AND status IN ('running', 'queued')",
params![name_owned, wf],
)?;
Ok(n > 0)
})
.await??;
Ok(changed)
}
/// 更新指定工作流内某点的状态。按 workflow 隔离,防跨工作流误改同名点。
pub async fn update_grid_status(
&self,
name: &str,
status: GridPointStatus,
workflow_name: &str,
) -> Result<()> {
let pool = self.pool.clone();
let name_owned = name.to_string();
let status_str = status.to_string();
let wf = workflow_name.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
conn.execute(
"UPDATE grid_points SET status = ?1 WHERE name = ?2 AND workflow_name = ?3",
params![status_str, name_owned, wf],
)?;
Ok(())
})
.await??;
Ok(())
}
/// H1 活锁修复辅助:记录网格点当前「剩余策略链」(JSON 数组)。运行时回退
/// trigger_strategy_fallback)把点打回 pending 等种子时调用,标记该点已失败过部分
/// 策略(如 cold_run),供调度路径重派时用剩余链而非完整 YAML 链,避免重跑已失败策略。
pub async fn set_pending_strategies(
&self,
name: &str,
workflow_name: &str,
strategies_json: &str,
) -> Result<()> {
let pool = self.pool.clone();
let name_owned = name.to_string();
let wf = workflow_name.to_string();
let s = strategies_json.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
conn.execute(
"UPDATE grid_points SET pending_strategies = ?1 WHERE name = ?2 AND workflow_name = ?3",
params![s, name_owned, wf],
)?;
Ok(())
})
.await??;
Ok(())
}
/// 读取网格点的剩余策略链标记(JSON 数组字符串)。无标记返回 None。
pub async fn get_pending_strategies(
&self,
name: &str,
workflow_name: &str,
) -> Result<Option<String>> {
let pool = self.pool.clone();
let name_owned = name.to_string();
let wf = workflow_name.to_string();
let v = tokio::task::spawn_blocking(move || -> Result<Option<String>> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare(
"SELECT pending_strategies FROM grid_points WHERE name = ?1 AND workflow_name = ?2",
)?;
let v = stmt
.query_row(params![name_owned, wf], |r| r.get::<_, Option<String>>(0))
.ok()
.flatten();
Ok(v)
})
.await??;
Ok(v)
}
/// 清除网格点的剩余策略链标记(调度路径消费后调用)。
pub async fn clear_pending_strategies(&self, name: &str, workflow_name: &str) -> Result<()> {
let pool = self.pool.clone();
let name_owned = name.to_string();
let wf = workflow_name.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
conn.execute(
"UPDATE grid_points SET pending_strategies = NULL WHERE name = ?1 AND workflow_name = ?2",
params![name_owned, wf],
)?;
Ok(())
})
.await??;
Ok(())
}
/// 领用任务时把网格点标记为 running(2026-08-02 涡旋事故修复:自带终态守卫)。
///
/// 仅允许 `pending`/`queued` → `running`:迟到/重复领用(对应点已被上报置为
/// converged/failed)不得复活终态点。源态集含 `pending` 是为兼容 requeue 路径
/// requeue_stale_tasks 把点重置为 pending 后,节点可能先于调度器领用重投行)。
///
/// 不委托 `update_grid_status`:其本体须保持无守卫——种子回退的 failed→Queued
/// 复活(scheduler.rs)依赖它覆盖终态。
///
/// 返回是否实际变更(false = 点已在 running 或终态,本次领用属迟到/重复)。
pub async fn mark_grid_point_running(&self, name: &str, workflow_name: &str) -> Result<bool> {
let pool = self.pool.clone();
let name_owned = name.to_string();
let wf = workflow_name.to_string();
let changed = tokio::task::spawn_blocking(move || -> Result<bool> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
// 阶段列(5b)同步置 runningtlusty_status 守卫保留终态(同 claim),synspec 自由流转。
let n = conn.execute(
"UPDATE grid_points SET status = 'running',
tlusty_status = CASE WHEN tlusty_status NOT IN ('converged','failed') THEN 'running' ELSE tlusty_status END,
synspec_status = 'running'
WHERE name = ?1 AND workflow_name = ?2 AND status IN ('pending', 'queued')",
params![name_owned, wf],
)?;
Ok(n > 0)
})
.await??;
Ok(changed)
}
/// 历史种子导入专用:把网格点标记为 converged 并记录大气收敛途径 `tlusty_success_method`。
///
/// 与正常 `record_task_report` 路径的区别:导入不走 task 队列,无 task_type 可取,
/// 故由导入工具(import_results)依据旧 conv.json 的 stages 是否含 seed_nc 判定该点
/// 当初是冷启动收敛(cold_run)还是种子步进收敛(seed_step),经 multipart 字段透传至此。
/// 导入点因此融入冷启动/种子步进统计,而非独立为 imported 分类。
///
/// - `success_method`:须为 "cold_run" 或 "seed_step",非法值兜底为 "cold_run"(防注入)。
/// 语义为 TLUSTY 阶段策略,写入 `tlusty_success_method` 列。
/// - `elapsed_sec`:旧版 conv.json 的单点墙钟耗时(`summary.elapsed_sec`),落入
/// `last_elapsed_sec` 列使迁移点在详情页/点表保留真实耗时;无此数据传 None。
pub async fn mark_grid_point_imported(
&self,
name: &str,
workflow_name: &str,
elapsed_sec: Option<f64>,
success_method: &str,
) -> Result<()> {
// 白名单校验:仅接受两种合法途径,非法值兜底 cold_run(避免拼接 SQL 注入风险)。
let method = match success_method {
"seed_step" => "seed_step",
_ => "cold_run",
};
let pool = self.pool.clone();
let name_owned = name.to_string();
let wf = workflow_name.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
// 阶段列(5b):导入点是历史 TLUSTY 大气收敛 → tlusty_status='converged'
// synspec_status 保持 NULL(历史结果仅大气,光谱未运行/未记录)。与正常结算路径的
// 状态一致性(审查 #2 修正:原实现不设阶段列,导入点与正常点状态口径不一致)。
conn.execute(
"UPDATE grid_points SET status = 'completed', tlusty_success_method = ?1, last_elapsed_sec = ?2, tlusty_status = 'converged' \
WHERE name = ?3 AND workflow_name = ?4",
params![method, elapsed_sec, name_owned, wf],
)?;
Ok(())
})
.await??;
Ok(())
}
pub async fn get_grid_point_status(
&self,
name: &str,
workflow_name: &str,
) -> Result<Option<(String, i32)>> {
let pool = self.pool.clone();
let name_owned = name.to_string();
let wf = workflow_name.to_string();
tokio::task::spawn_blocking(move || -> Result<Option<(String, i32)>> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare("SELECT status, attempt_count FROM grid_points WHERE name = ?1 AND workflow_name = ?2")?;
let res = stmt.query_row(params![name_owned, wf], |r| Ok((r.get(0)?, r.get(1)?)));
match res {
Ok(tuple) => Ok(Some(tuple)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await?
}
/// 网格汇总统计。
///
/// `workflow_filter`
/// - `None`:聚合全部工作流的 grid_pointsdashboard 全局概览用)。
/// - `Some(wf)`:仅聚合指定工作流(按工作流隔离的进度统计)。
///
/// 口径说明:`pending` 与 `queued` **分开**计数(详情页需要区分"未入队"与"排队中");
/// 旧版前端若需合并口径,自行相加(见 dashboard state.js)。导入的历史点按其实际
/// 收敛途径(cold_run/seed_step)归类,与正常计算点一并统计——不再有独立 imported 分类。
pub async fn get_grid_summary_stats(
&self,
workflow_filter: Option<&str>,
) -> Result<serde_json::Value> {
let pool = self.pool.clone();
let wf = workflow_filter.map(|s| s.to_string());
tokio::task::spawn_blocking(move || -> Result<serde_json::Value> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
// 合并原先 7 条独立 COUNT 查询为单次扫描,用 SUM(CASE WHEN ...) 一次性聚合所有口径,
// 显著降低 status API 的数据库往返与锁竞争开销。
let row = match &wf {
Some(name) => conn.query_row(
"SELECT
COUNT(*) AS total,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN status = 'queued' THEN 1 ELSE 0 END) AS queued,
SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
SUM(CASE WHEN status = 'completed' AND tlusty_success_method = 'cold_run' THEN 1 ELSE 0 END) AS cold_run_converged,
SUM(CASE WHEN status = 'completed' AND tlusty_success_method = 'seed_step' THEN 1 ELSE 0 END) AS seed_step_converged,
SUM(CASE WHEN status = 'completed' AND synspec_success_method IS NOT NULL THEN 1 ELSE 0 END) AS synspec_converged
FROM grid_points WHERE workflow_name = ?1",
params![name],
|r| {
let n = |i: usize| -> i64 { r.get::<_, Option<i64>>(i).unwrap_or(None).unwrap_or(0) };
Ok((n(0), n(1), n(2), n(3), n(4), n(5), n(6), n(7), n(8)))
},
),
None => conn.query_row(
"SELECT
COUNT(*) AS total,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN status = 'queued' THEN 1 ELSE 0 END) AS queued,
SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
SUM(CASE WHEN status = 'completed' AND tlusty_success_method = 'cold_run' THEN 1 ELSE 0 END) AS cold_run_converged,
SUM(CASE WHEN status = 'completed' AND tlusty_success_method = 'seed_step' THEN 1 ELSE 0 END) AS seed_step_converged,
SUM(CASE WHEN status = 'completed' AND synspec_success_method IS NOT NULL THEN 1 ELSE 0 END) AS synspec_converged
FROM grid_points",
[],
|r| {
let n = |i: usize| -> i64 { r.get::<_, Option<i64>>(i).unwrap_or(None).unwrap_or(0) };
Ok((n(0), n(1), n(2), n(3), n(4), n(5), n(6), n(7), n(8)))
},
),
}?;
let (
total,
pending,
queued,
running,
completed,
failed,
cold_run_converged,
seed_step_converged,
synspec_converged,
) = row;
Ok(serde_json::json!({
"total": total,
"pending": pending,
"queued": queued,
"running": running,
"completed": completed,
"failed": failed,
"cold_run_converged": cold_run_converged,
"seed_step_converged": seed_step_converged,
"synspec_converged": synspec_converged,
}))
})
.await?
}
/// 单工作流执行统计(详情页数据源)。
///
/// 在 `get_grid_summary_stats(Some(name))` 之上追加:
/// - `waves`:难度波次分布(调度时按 cno_sum 分组的批次进度);
/// - `avg_point_sec`:单点平均真实耗时 = `AVG(COALESCE(tasks.elapsed_sec, 时间戳差))`——
/// 优先用 Worker 回报的精确墙钟(P3 落库),历史无 elapsed_sec 的行回退时间戳差近似;
/// - `eta_sec``avg_point_sec × 剩余点数 ÷ total_slots`(并发感知;slots 为在线节点
/// 总槽位,由 handler 传入,≤0 时按串行兜底),无历史数据为 None。
///
/// `status` 由调用方传入(工作流当前状态),避免重复查询。
pub async fn get_workflow_detail_stats(
&self,
name: &str,
status: &str,
total_slots: i64,
) -> Result<WorkflowStats> {
let base = self.get_grid_summary_stats(Some(name)).await?;
let g = |k: &str| base.get(k).and_then(|v| v.as_i64()).unwrap_or(0);
let total = g("total");
let completed = g("completed");
let failed = g("failed");
let pool = self.pool.clone();
let wf = name.to_string();
let (waves, avg_point_sec) =
tokio::task::spawn_blocking(move || -> Result<(Vec<WaveStats>, Option<f64>)> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare(
"SELECT wave,
COUNT(*) AS total,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed
FROM grid_points WHERE workflow_name = ?1
GROUP BY wave ORDER BY wave ASC",
)?;
let rows = stmt.query_map(params![wf], |r| {
Ok(WaveStats {
wave: r.get(0)?,
total: r.get(1)?,
completed: r.get::<_, Option<i64>>(2)?.unwrap_or(0),
failed: r.get::<_, Option<i64>>(3)?.unwrap_or(0),
})
})?;
let mut waves = Vec::new();
for r in rows {
waves.push(r?);
}
// AVG 在无匹配行时返回 NULL;过滤掉非正/非有限值,保持 ETA 估算合理。
// COALESCE:精确耗时(elapsed_sec)优先,旧数据回退 created→completed 时间戳差。
let avg: Option<f64> = conn
.query_row(
"SELECT AVG(COALESCE(elapsed_sec, (julianday(completed_at) - julianday(created_at)) * 86400.0))
FROM tasks
WHERE workflow_name = ?1
AND completed_at IS NOT NULL
AND status IN ('completed', 'failed', 'timeout')",
params![wf],
|r| r.get(0),
)
.ok()
.flatten()
.filter(|v: &f64| v.is_finite() && *v > 0.0);
Ok((waves, avg))
})
.await??;
let remaining = total - completed - failed;
let slots = (total_slots.max(1)) as f64;
let eta_sec = match avg_point_sec {
Some(avg) if remaining > 0 => Some(avg * remaining as f64 / slots),
_ => None,
};
Ok(WorkflowStats {
name: name.to_string(),
status: status.to_string(),
total,
pending: g("pending"),
queued: g("queued"),
running: g("running"),
completed,
failed,
cold_run_converged: g("cold_run_converged"),
seed_step_converged: g("seed_step_converged"),
waves,
avg_point_sec,
eta_sec,
})
}
/// 工作流逐点列表(带最近一次尝试信息),分页返回 `(符合过滤的总数, 当前页行)`。
///
/// 最近尝试用关联子查询取 tasks 最新行(走 idx_tasks_point_wf_time);
/// 所有过滤值参数化绑定,`q` 的 LIKE 通配符先转义;ORDER BY 片段来自白名单。
pub async fn list_workflow_points(
&self,
wf: &str,
f: &PointFilter,
) -> Result<(i64, Vec<PointRow>)> {
let pool = self.pool.clone();
let wf = wf.to_string();
let f = f.clone();
tokio::task::spawn_blocking(move || -> Result<(i64, Vec<PointRow>)> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
// 动态 WHERE:子句与占位符同步增长,值全部走绑定参数(SQLite 动态类型,
// wave 以字符串绑定由列亲和性转回 INTEGER 比较)。
// 列名**不带 gp. 前缀**:窗口重写后筛选作用于外层子查询(列名裸露)。
let mut clauses: Vec<String> = vec!["workflow_name = ?1".to_string()];
let mut binds: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(wf.clone())];
if let Some(s) = &f.status {
binds.push(Box::new(s.clone()));
clauses.push(format!("status = ?{}", binds.len()));
}
if let Some(m) = &f.method {
if m == "synspec_only" {
// 光谱专用收敛点(tlusty 禁用):仅命中 synspec-only 点(tlusty 归因 NULL),
// 与徽章/parSets 的 SYNSPEC 档同口径。双阶段点归因在大气侧,不含在这里。
clauses.push(
"tlusty_success_method IS NULL AND synspec_success_method IS NOT NULL"
.to_string(),
);
} else {
// cold_run/seed_step 等 TLUSTY 阶段策略 → 过滤 tlusty_success_method。
binds.push(Box::new(m.clone()));
clauses.push(format!("tlusty_success_method = ?{}", binds.len()));
}
}
if let Some(w) = f.wave {
binds.push(Box::new(w.to_string()));
clauses.push(format!("wave = ?{}", binds.len()));
}
if let Some(q) = &f.q {
// 转义 LIKE 通配符(\ % _),仅影响匹配语义,不构成注入面(值仍绑定)。
let escaped = q
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_");
binds.push(Box::new(format!("%{}%", escaped)));
clauses.push(format!("name LIKE ?{} ESCAPE '\\'", binds.len()));
}
let where_sql = clauses.join(" AND ");
let bind_refs = || binds.iter().map(|b| b.as_ref());
// 总数(同过滤条件,作用于 grid_points,无需 JOIN
let total: i64 = conn.query_row(
&format!("SELECT COUNT(*) FROM grid_points WHERE {}", where_sql),
rusqlite::params_from_iter(bind_refs()),
|r| r.get(0),
)?;
// 数据行:ROW_NUMBER() 窗口单遍取每点最新任务,替代逐行相关子查询
// (P3,数千点 → 数千次子查询消除)。窗口 PARTITION BY (name, workflow_name)
// 内按「未完成靠后 → completed_at DESC → created_at DESC」选最新行,与旧
// 相关子查询 ORDER BY 语义逐行等价;外层 rn=1 过滤 + 用户排序/分页。
let mut all_binds = binds;
// limit=None 时不拼 LIMIT 子句(联合分析需全量,截断会让分析失真)。
let limit_clause = match f.limit {
Some(lim) => {
let limit_idx = all_binds.len() + 1;
all_binds.push(Box::new(lim));
format!(" LIMIT ?{}", limit_idx)
}
None => String::new(),
};
// offset 仅在有 limit 或非零时才有意义;None-limit 全量场景强制忽略 offset。
let offset_clause = if f.limit.is_some() {
let offset_idx = all_binds.len() + 1;
all_binds.push(Box::new(f.offset));
format!(" OFFSET ?{}", offset_idx)
} else {
String::new()
};
// 排序:order_by 由 API 层编译期白名单拼出;空(如测试用 default filter)则省略子句。
let order_clause = if f.order_by.is_empty() {
String::new()
} else {
format!(" ORDER BY {}", f.order_by)
};
let sql = format!(
"SELECT * FROM (
SELECT gp.name, gp.teff, gp.logg, gp.loghe, gp.logc, gp.logn, gp.logo,
gp.cno_sum, gp.wave, gp.status, gp.tlusty_success_method, gp.attempt_count,
t.max_relc,
t.seed_point_name, t.node_id,
t.completed_at, t.error_message,
COALESCE(t.elapsed_sec, gp.last_elapsed_sec) AS eff_elapsed,
gp.workflow_name,
gp.synspec_success_method,
ROW_NUMBER() OVER (
PARTITION BY gp.name, gp.workflow_name
ORDER BY t.completed_at IS NULL, t.completed_at DESC, t.created_at DESC
) AS rn
FROM grid_points gp
LEFT JOIN tasks t
ON t.point_name = gp.name AND t.workflow_name = gp.workflow_name
) WHERE rn = 1 AND {}{}{}{}",
where_sql, order_clause, limit_clause, offset_clause
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(
rusqlite::params_from_iter(all_binds.iter().map(|b| b.as_ref())),
point_row_from_query,
)?;
let mut points = Vec::new();
for r in rows {
points.push(r?);
}
Ok((total, points))
})
.await?
}
/// 单点行(带最近尝试信息),点不存在返回 None。与列表端点同一 SELECT 列序。
pub async fn get_workflow_point_row(&self, wf: &str, point: &str) -> Result<Option<PointRow>> {
let pool = self.pool.clone();
let wf = wf.to_string();
let point = point.to_string();
tokio::task::spawn_blocking(move || -> Result<Option<PointRow>> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare(
// 与 list_workflow_points 同构的 ROW_NUMBER() 窗口取最近任务(P3),外层 rn=1。
"SELECT * FROM (
SELECT gp.name, gp.teff, gp.logg, gp.loghe, gp.logc, gp.logn, gp.logo,
gp.cno_sum, gp.wave, gp.status, gp.tlusty_success_method, gp.attempt_count,
t.max_relc,
t.seed_point_name, t.node_id,
t.completed_at, t.error_message,
COALESCE(t.elapsed_sec, gp.last_elapsed_sec) AS eff_elapsed,
gp.workflow_name,
gp.synspec_success_method,
ROW_NUMBER() OVER (
PARTITION BY gp.name, gp.workflow_name
ORDER BY t.completed_at IS NULL, t.completed_at DESC, t.created_at DESC
) AS rn
FROM grid_points gp
LEFT JOIN tasks t
ON t.point_name = gp.name AND t.workflow_name = gp.workflow_name
) WHERE rn = 1 AND workflow_name = ?1 AND name = ?2",
)?;
let res = stmt.query_row(params![wf, point], point_row_from_query);
match res {
Ok(row) => Ok(Some(row)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await?
}
}
File diff suppressed because it is too large Load Diff
+577
View File
@@ -0,0 +1,577 @@
//! Node/凭据(从原 db.rs 拆分,2026-08-06)。
//! `impl Database` 的 Node/凭据 域方法。共享基础设施(Database struct、连接管理、类型、helper)见父模块 `super`crate::db)。
use super::*;
impl Database {
// --- Node operations ---
/// 注册/刷新节点。返回 (is_new, existing_status)
/// - 新申请:`(true, None)`
/// - 已存在(含 online 等已审批态):`(false, Some(<旧状态>))`,仅更新配置保持既有状态。
///
/// 返回旧状态供 API 层区分响应:已审批(online)的节点免凭据重新注册时,
/// 不应回 "pending_approval"(误导运维以为还需审批),而应如实告知其已是已授权节点。
pub async fn register_node(
&self,
req: &NodeRegisterRequest,
) -> Result<(bool, Option<String>, Option<String>)> {
let pool = self.pool.clone();
let req_cloned = req.clone();
let (is_new, existing_status, registration_secret) =
tokio::task::spawn_blocking(move || -> Result<(bool, Option<String>, Option<String>)> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare("SELECT status FROM nodes WHERE node_id = ?1")?;
let existing_status: Option<String> =
stmt.query_row(params![req_cloned.node_id], |r| r.get(0)).ok();
match &existing_status {
Some(_st) => {
// 已存在的节点:更新配置,保持既有状态
conn.execute(
"UPDATE nodes SET max_slots = ?1, last_heartbeat = datetime('now') WHERE node_id = ?2",
params![req_cloned.max_slots, req_cloned.node_id],
)?;
Ok((false, existing_status, None))
}
None => {
// 新申请节点:生成一次性 registration_secretH8)并插入待审批状态。
// registration_secret 用于 /node/check_status 取走专属 token 的二次凭据,
// 防止知道 node_id(常源自主机名,可猜测)的攻击者抢先取走待发 token。
let secret = format!(
"{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
);
conn.execute(
"INSERT INTO nodes (node_id, max_slots, status, last_heartbeat, registration_secret)
VALUES (?1, ?2, 'pending_approval', datetime('now'), ?3)",
params![req_cloned.node_id, req_cloned.max_slots, secret],
)?;
Ok((true, None, Some(secret)))
}
}
})
.await??;
Ok((is_new, existing_status, registration_secret))
}
/// 管理员审批同意节点接入:将节点状态切为 online 并生成专属 node_token(返回明文 token)。
pub async fn approve_node(&self, node_id: &str) -> Result<String> {
let pool = self.pool.clone();
let node_id_owned = node_id.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
conn.execute(
"UPDATE nodes SET status = 'online', last_heartbeat = datetime('now') WHERE node_id = ?1",
params![node_id_owned],
)?;
Ok(())
})
.await??;
// 颁发专属 node_token
let new_token = self.issue_node_token(node_id).await?;
Ok(new_token)
}
/// 管理员拒绝节点接入:彻底清理该节点的注册申请记录。
pub async fn reject_node(&self, node_id: &str) -> Result<()> {
let pool = self.pool.clone();
let node_id_owned = node_id.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
conn.execute(
"DELETE FROM nodes WHERE node_id = ?1",
params![node_id_owned],
)?;
conn.execute(
"DELETE FROM node_credentials WHERE node_id = ?1",
params![node_id_owned],
)?;
Ok(())
})
.await??;
Ok(())
}
/// 记录心跳并回读管理员配额。
///
/// 返回 `Option<i32>`:当前节点的 `admin_max_slots`None = 无限制)。供 API 层
/// 在心跳响应体透传给 Worker(见 docs/dynamic_cpu_slots_design.md §3.1)。在 UPDATE
/// 之后立即 SELECT,保证管理员刚设置的配额在本次心跳即下发(心跳间隔内的延迟可接受)。
pub async fn heartbeat_node(&self, req: &NodeHeartbeatRequest) -> Result<Option<i32>> {
let pool = self.pool.clone();
let req_cloned = req.clone();
let admin_max_slots = tokio::task::spawn_blocking(move || -> Result<Option<i32>> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
// 心跳刷新指标与时间戳,status 由 CASE 决定:
// - online/offline → onlineoffline 节点复活)
// - disabled → disabled(管理员手动停用,永不被心跳复活;
// 仅刷新 cpu/mem/心跳,让 Dashboard 仍能看到该节点存活待命)
// - pending_approval 不在 IN 列表,心跳对其无效(维持审批语义)。
conn.execute(
"UPDATE nodes SET active_slots = ?1, cpu_usage = ?2, memory_usage = ?3,
last_heartbeat = datetime('now'),
status = CASE WHEN status = 'disabled' THEN 'disabled' ELSE 'online' END
WHERE node_id = ?4 AND status IN ('online', 'offline', 'disabled')",
params![
req_cloned.active_slots,
req_cloned.cpu_usage,
req_cloned.memory_usage,
req_cloned.node_id
],
)?;
// 回读配额。注:即便 UPDATE 命中 0 行(如 pending_approval 节点心跳),
// 节点行仍存在,配额回读仍应给出当前值;这里以 node_id 直查即可。
// 仅把「无行」视为 None;真实 DB 错误(I/O、列缺失等)须向上传播,
// 避免把故障静默降级为「无配额」而让 Worker 以物理上限满负荷运行。
// (审查修复 N2:旧实现 .ok().flatten() 会把 SQL 错误一并吞成 None。)
let quota: Option<i32> = {
let res = conn.query_row(
"SELECT admin_max_slots FROM nodes WHERE node_id = ?1",
params![req_cloned.node_id],
|r| r.get::<_, Option<i32>>(0),
);
match res {
Ok(v) => v,
Err(rusqlite::Error::QueryReturnedNoRows) => None,
Err(e) => return Err(e.into()),
}
};
Ok(quota)
})
.await??;
Ok(admin_max_slots)
}
/// 设置管理员强制并发槽位上限(动态调整 CPU 核数)。
///
/// - `Some(n)`n>=0):限制节点最多并发 n 个任务(0 = 暂停接新任务,正在跑的不会中断)。
/// - `None`:清除限制,恢复节点物理 `max_slots` 上限。
///
/// 返回是否命中行(节点未注册时返回 false,调用方据此返回 404)。负数由 API 层
/// 校验拒绝(不在此处理),避免把非法值写入 DB。
pub async fn set_node_admin_max_slots(
&self,
node_id: &str,
admin_max_slots: Option<i32>,
) -> Result<bool> {
let pool = self.pool.clone();
let node_id_owned = node_id.to_string();
let count = tokio::task::spawn_blocking(move || -> Result<u64> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let rows = conn.execute(
"UPDATE nodes SET admin_max_slots = ?1 WHERE node_id = ?2",
params![admin_max_slots, node_id_owned],
)?;
Ok(rows as u64)
})
.await??;
Ok(count > 0)
}
/// 手动停用/启用节点(管理员运维操作)。
///
/// - `enabled=false`:把 `online`/`offline` 节点切为 `disabled`claim 将不再向其分发任务。
/// `pending_approval` 不在范围(审批流程独立),`disabled` 再点为幂等空操作。
/// - `enabled=true`:把 `disabled` 节点切为 `offline`(而非 online),靠节点下一次心跳
/// 自然翻成 online——既能自愈,又不会对真实离线的节点虚报在线。
///
/// 返回 `true` 表示状态确有迁移;`false` 表示当前状态不匹配(调用方据此返回 409)。
pub async fn set_node_enabled(&self, node_id: &str, enabled: bool) -> Result<bool> {
let pool = self.pool.clone();
let node_id_owned = node_id.to_string();
let count = tokio::task::spawn_blocking(move || -> Result<u64> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let rows = if enabled {
conn.execute(
"UPDATE nodes SET status = 'offline' WHERE node_id = ?1 AND status = 'disabled'",
params![node_id_owned],
)?
} else {
conn.execute(
"UPDATE nodes SET status = 'disabled' WHERE node_id = ?1 AND status IN ('online', 'offline')",
params![node_id_owned],
)?
};
Ok(rows as u64)
})
.await??;
Ok(count > 0)
}
/// 节点是否处于手动停用态(claim 热路径用,命中主键索引,开销可忽略)。
pub async fn is_node_disabled(&self, node_id: &str) -> Result<bool> {
let pool = self.pool.clone();
let node_id_owned = node_id.to_string();
let disabled = tokio::task::spawn_blocking(move || -> Result<bool> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt =
conn.prepare("SELECT 1 FROM nodes WHERE node_id = ?1 AND status = 'disabled'")?;
let exists = stmt.exists(params![node_id_owned])?;
Ok(exists)
})
.await??;
Ok(disabled)
}
/// 为指定 node 颁发专属 token:生成随机明文 tokenDB 存其 SHA-256 hash。
/// 返回明文 token(仅此一次,由调用方转交 node 持久化)。
/// 若该 node 已有凭据则覆盖(重新颁发)。
pub async fn issue_node_token(&self, node_id: &str) -> Result<String> {
let pool = self.pool.clone();
let node_id_owned = node_id.to_string();
// 两个 v4 UUID(各 16 字节随机)拼接 → 各 32 hex 字符 = 64 字符 token
let token =
uuid::Uuid::new_v4().simple().to_string() + &uuid::Uuid::new_v4().simple().to_string();
let token_hash = hash_token(&token);
let token_for_ret = token.clone();
let token_to_db = token_for_ret.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
conn.execute(
"INSERT INTO node_credentials (node_id, token_hash, issued_at, raw_token_pending)
VALUES (?1, ?2, datetime('now'), ?3)
ON CONFLICT(node_id) DO UPDATE SET
token_hash = excluded.token_hash,
issued_at = datetime('now'),
raw_token_pending = excluded.raw_token_pending",
params![node_id_owned, token_hash, token_to_db],
)?;
Ok(())
})
.await??;
// token 轮换:旧 token_hash 已失效,新 token_hash 即将生效。整体清空缓存最稳妥
// (issue 是低频运维动作,全清代价可忽略)。
self.invalidate_token_cache().await;
Ok(token_for_ret)
}
/// 一次性拉取并清除暂存的明文 node_token(取走即焚安全策略)。
///
/// 在单个 IMMEDIATE 事务内:先 SELECT 读出明文,再 UPDATE 置 NULL。IMMEDIATE 事务在
/// BEGIN 时即获取写锁,保证 SELECT 与 UPDATE 之间不会被其它调用方插入,从而只有一个
/// 调用方能取到 token(原子语义)。
///
/// 注:SQLite 的 `UPDATE ... RETURNING` 返回的是列的**新值**(SET 之后),故清空后
/// RETURNING 该列只会得到 NULL,无法用于读旧值;因此这里用显式 SELECT + UPDATE。
pub async fn take_pending_node_token(
&self,
node_id: &str,
registration_secret: Option<&str>,
) -> Result<Option<String>> {
let pool = self.pool.clone();
let node_id_owned = node_id.to_string();
let secret_owned = registration_secret.map(|s| s.to_string());
let token = tokio::task::spawn_blocking(move || -> Result<Option<String>> {
let mut conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
// H8:取走待发 token 前校验 registration_secret(节点注册时下发的一次性凭据)。
// 仅当 nodes 表记录的 registration_secret 与请求提供的一致(恒定时间比对),
// 才允许取走 token,防止仅知道 node_id(可猜测)的攻击者抢先取走。
let stored_secret: Option<String> = {
let mut secret_stmt =
tx.prepare("SELECT registration_secret FROM nodes WHERE node_id = ?1")?;
secret_stmt
.query_row(params![node_id_owned], |r| r.get::<_, Option<String>>(0))
.ok()
.flatten()
};
let secret_ok = match (&stored_secret, &secret_owned) {
(Some(a), Some(b)) => ct_eq_option(a, b),
// 旧库节点(无 registration_secret)不强制要求,保持向后兼容;
// 新节点(有 secret)必须提供正确 secret。
(None, _) => true,
(Some(_), None) => false,
};
if !secret_ok {
tx.commit()?;
return Ok(None);
}
let raw_token: Option<String> = {
let mut select_stmt = tx.prepare(
"SELECT raw_token_pending FROM node_credentials
WHERE node_id = ?1 AND raw_token_pending IS NOT NULL",
)?;
select_stmt
.query_row(params![node_id_owned], |r| r.get::<_, String>(0))
.ok()
};
if raw_token.is_some() {
tx.execute(
"UPDATE node_credentials SET raw_token_pending = NULL
WHERE node_id = ?1 AND raw_token_pending IS NOT NULL",
params![&node_id_owned],
)?;
// M2 修复:取走后把 registration_secret 轮换为全新随机值(本地一次性凭据)。
// 设计文档(node_credentials 注释)明确 registration_secret 是「审批前一次性凭据,
// 取走专属 token 前消费」。若取走后旧 secret 仍有效,被攻陷节点/截获注册响应的
// 攻击者可凭「可猜测的 node_id + 旧 secret」反复取走每次 reissue 产生的新 token。
// 轮换成无人知晓的新值后旧 secret 立即失效,强制 reissue 的 token 走管理员线下
// 下发路径(worker.rs 401 提示引导),杜绝凭 secret 复活取 token。
// 注意刻意**不清空为 NULL**:下方鉴权把 `(None, _) => true` 当作旧库兼容放行,
// 清空会让取走后的节点退化为「免 secret 可取」的旧库语义,反而开新洞。
let new_secret = format!(
"{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
);
tx.execute(
"UPDATE nodes SET registration_secret = ?2 WHERE node_id = ?1",
params![&node_id_owned, new_secret],
)?;
}
tx.commit()?;
Ok(raw_token)
})
.await??;
Ok(token)
}
/// 按 token(明文)反查所属 node_id;仅当 token 有效(未被重发覆盖)时返回 Some。
/// 用于中间件:请求带来 node token,由此确定调用方身份。
///
/// 高频路径(每个 Node 请求一次):先查内存 token_cache,命中且未过期直接返回;
/// miss 才落 DB,并回填缓存。issue 会主动清空整个缓存。
///
/// 失效语义:重发(issue_node_token)会用 ON CONFLICT 覆盖该 node 的 token_hash
/// 旧 token 明文 hash 不再存在于表 → 查询返回 None → 401。无需独立的 revoked 标记。
///
/// 撤销竞态修复:历史上存在 TOCTOU 窗口——线程 A 用旧 token miss 落 DB 查到 node_id
/// 后准备回填,期间线程 B(管理员 reissue)覆盖 DB 的 token_hash 并 clear() 缓存,
/// 随后线程 A 拿到写锁把旧 token_hash 回填进缓存,导致已撤销的旧 token 在 TTL(60s)
/// 内仍能鉴权。修复:回填时在同一把写锁内重新校验该 token_hash 是否仍是 DB 当前值
/// (未被 reissue 覆盖),是才回填,杜绝旧 token 复活窗口。
pub async fn find_node_by_token(&self, token: &str) -> Option<String> {
let token_hash = hash_token(token);
// 1) 先查内存缓存
{
let cache = self.token_cache.read().await;
if let Some((node_id, inserted)) = cache.entries.get(&token_hash) {
if inserted.elapsed() < TOKEN_CACHE_TTL {
return Some(node_id.clone());
}
}
}
// 2) 记录 DB 查询前的缓存 generationmiss 落 DB。
// generation 用于回填时的 TOCTOU 终极防护:若 DB 查询与回填之间发生过
// invalidatereissue),generation 会变化,本次回填将被丢弃。
let gen_before = { self.token_cache.read().await.generation };
let pool = self.pool.clone();
let hash_for_db = token_hash.clone();
let db_hit: Option<String> =
tokio::task::spawn_blocking(move || -> Result<Option<String>> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare(
"SELECT node_id FROM node_credentials WHERE token_hash = ?1 LIMIT 1",
)?;
let res = stmt.query_row(params![hash_for_db], |r| r.get::<_, String>(0));
match res {
Ok(id) => Ok(Some(id)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await
.ok()
.and_then(|r| match r {
Ok(id) => id,
// 审查修复 #M4:原 `.and_then(|r| r.ok())` 把 DB 错误静默吞成 None(鉴权 401),
// 故障期所有节点请求被判 401 且无任何日志。现至少记录 warn 便于排障。
Err(e) => {
tracing::warn!("find_node_by_token DB 查询失败,降级为鉴权未命中: {}", e);
None
}
});
// 3) 命中则回填缓存;回填前校验 generation 未变化(期间无 invalidate),
// 彻底消除"旧 token_hash 复活"窗口。generation 变化则视为已撤销,不缓存、不返回。
if let Some(id) = db_hit {
let mut cache = self.token_cache.write().await;
if cache.generation == gen_before {
cache
.entries
.insert(token_hash, (id.clone(), std::time::Instant::now()));
Some(id)
} else {
// 期间发生过 reissue 导致的 invalidate:旧 token_hash 已不应复活。
None
}
} else {
None
}
}
/// 清空全部 token 反查缓存并自增 generation。在 issuetoken 轮换使旧 token 失效)时调用。
/// 自增 generation 使所有在途的 find_node_by_token 回填(gen_before 已过期)被丢弃,
/// 彻底消除"DB 读取旧 hash → reissue clear → 回填旧 hash"的 TOCTOU 复活窗口。
async fn invalidate_token_cache(&self) {
let mut cache = self.token_cache.write().await;
cache.entries.clear();
cache.generation = cache.generation.wrapping_add(1);
}
/// 判断指定 node_id 是否已存在于 nodes 表(重发 token 前置校验,防幽灵 node_id)。
pub async fn get_node_exists(&self, node_id: &str) -> Result<bool> {
let pool = self.pool.clone();
let node_id_owned = node_id.to_string();
let exists = tokio::task::spawn_blocking(move || -> Result<bool> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare("SELECT 1 FROM nodes WHERE node_id = ?1 LIMIT 1")?;
Ok(stmt.exists(params![node_id_owned])?)
})
.await??;
Ok(exists)
}
/// 统计已颁发 node 凭据数量(用于启动期半配置告警判断)。
pub async fn node_credentials_count(&self) -> Result<i64> {
let pool = self.pool.clone();
let count = tokio::task::spawn_blocking(move || -> Result<i64> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let n: i64 =
conn.query_row("SELECT COUNT(*) FROM node_credentials", [], |r| r.get(0))?;
Ok(n)
})
.await??;
Ok(count)
}
/// 列出全部节点及其凭据状态(LEFT JOIN node_credentials)。
/// 用于管理 API:admin 可查看每个节点的在线状态、是否已颁发 token、颁发时间。
/// 尚未注册凭据的节点(如旧数据迁移)token_status 为 "none"、token_issued_at 为 None。
pub async fn list_nodes_with_credentials(&self) -> Result<Vec<NodeCredentialView>> {
let pool = self.pool.clone();
tokio::task::spawn_blocking(move || -> Result<Vec<NodeCredentialView>> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare(
"SELECT n.node_id, n.max_slots, n.active_slots, n.status,
n.cpu_usage, n.memory_usage,
strftime('%Y-%m-%dT%H:%M:%SZ', n.last_heartbeat),
c.token_hash, strftime('%Y-%m-%dT%H:%M:%SZ', c.issued_at),
n.admin_max_slots
FROM nodes n
LEFT JOIN node_credentials c ON c.node_id = n.node_id
ORDER BY n.status ASC, n.node_id ASC",
)?;
let rows = stmt.query_map([], |r| {
let hb_str: String = r.get::<_, String>(6)?;
Ok(NodeCredentialView {
node_id: r.get(0)?,
max_slots: r.get(1)?,
active_slots: r.get(2)?,
status: r.get(3)?,
cpu_usage: r.get(4)?,
memory_usage: r.get(5)?,
last_heartbeat: chrono::DateTime::parse_from_rfc3339(&hb_str)
.map(|d| d.with_timezone(&chrono::Utc))
.unwrap_or_else(|_| chrono::DateTime::UNIX_EPOCH),
// c.token_hash 为 NULL 表示该节点无凭据记录;非空即为有效 token
// token 失效靠重发覆盖 hash 实现,不存在「已吊销」中间态)
token_status: match r.get::<_, Option<String>>(7)? {
None => "none".to_string(),
Some(_) => "active".to_string(),
},
token_issued_at: r.get::<_, Option<String>>(8)?,
admin_max_slots: r.get::<_, Option<i32>>(9)?,
})
})?;
let mut list = Vec::new();
for row in rows {
list.push(row?);
}
Ok(list)
})
.await?
}
pub async fn get_active_nodes(&self) -> Result<Vec<NodeInfo>> {
let pool = self.pool.clone();
tokio::task::spawn_blocking(move || -> Result<Vec<NodeInfo>> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare(
"SELECT node_id, max_slots, active_slots, status, cpu_usage, memory_usage, strftime('%Y-%m-%dT%H:%M:%SZ', last_heartbeat), admin_max_slots FROM nodes WHERE status = 'online'"
)?;
let node_iter = stmt.query_map([], |r| {
let hb_str: String = r.get(6)?;
Ok(NodeInfo {
node_id: r.get(0)?,
max_slots: r.get(1)?,
active_slots: r.get(2)?,
status: r.get(3)?,
cpu_usage: r.get(4)?,
memory_usage: r.get(5)?,
last_heartbeat: chrono::DateTime::parse_from_rfc3339(&hb_str)
.map(|d| d.with_timezone(&chrono::Utc))
.unwrap_or_else(|_| chrono::Utc::now()),
admin_max_slots: r.get::<_, Option<i32>>(7)?,
})
})?;
let mut nodes = Vec::new();
for n in node_iter {
nodes.push(n?);
}
Ok(nodes)
})
.await?
}
pub async fn mark_stale_nodes_offline(&self, stale_sec: u64) -> Result<u64> {
let pool = self.pool.clone();
let count = tokio::task::spawn_blocking(move || -> Result<u64> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let rows = conn.execute(
"UPDATE nodes SET status = 'offline' WHERE status = 'online' AND strftime('%s', 'now') - strftime('%s', last_heartbeat) > ?1",
params![stale_sec as i64],
)?;
Ok(rows as u64)
})
.await??;
Ok(count)
}
}
+199
View File
@@ -0,0 +1,199 @@
//! 种子(从原 db.rs 拆分,2026-08-06)。
//! `impl Database` 的 种子 域方法。共享基础设施(Database struct、连接管理、类型、helper)见父模块 `super`crate::db)。
use super::*;
impl Database {
pub async fn reload_seed_cache(&self) -> Result<()> {
let pool = self.pool.clone();
let items = tokio::task::spawn_blocking(move || -> Result<Vec<SeedCacheItem>> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare(
"SELECT point_name, teff, logg, loghe, logc, logn, logo, file_path FROM seeds WHERE is_clean = 1"
)?;
let rows = stmt.query_map([], |row| {
Ok(SeedCacheItem {
point_name: row.get(0)?,
params: GridPointParams {
teff: GridAxisValue::from_value(row.get::<_, f64>(1)?),
logg: GridAxisValue::from_value(row.get::<_, f64>(2)?),
loghe: GridAxisValue::from_value(row.get::<_, f64>(3)?),
logc: GridAxisValue::from_value(row.get::<_, f64>(4)?),
logn: GridAxisValue::from_value(row.get::<_, f64>(5)?),
logo: GridAxisValue::from_value(row.get::<_, f64>(6)?),
},
file_path: row.get(7)?,
})
})?;
let mut list = Vec::new();
for r in rows {
list.push(r?);
}
Ok(list)
})
.await??;
// 同步重建 exact_family 索引(每个种子写入其 floor/floor+1 两个桶)。
let mut index: std::collections::HashMap<SeedBucketKey, Vec<SeedCacheItem>> =
std::collections::HashMap::new();
for item in &items {
for key in SeedBucketKey::from_params(&item.params) {
index.entry(key).or_default().push(item.clone());
}
}
let mut lock = self.seed_cache.write().await;
*lock = items;
drop(lock);
let mut idx_lock = self.seed_index.write().await;
*idx_lock = index;
Ok(())
}
pub async fn insert_seed(&self, params_in: &GridPointParams, file_path: &str) -> Result<()> {
let name = params_in.model_name();
self.insert_seed_named(&name, params_in, file_path).await
}
/// 与 `insert_seed` 相同,但用调用方提供的权威 `name`(而非从 params 重推)。
///
/// 历史种子导入专用:保证 `seeds.point_name` 与旧版 conv.json 的源精度真名一致。
pub async fn insert_seed_named(
&self,
name: &str,
params_in: &GridPointParams,
file_path: &str,
) -> Result<()> {
let pool = self.pool.clone();
let p = params_in.clone();
let name = name.to_string();
let path_owned = file_path.to_string();
let name_db = name.clone();
let path_db = path_owned.clone();
let p_db = p.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
conn.execute(
"INSERT INTO seeds (point_name, teff, logg, loghe, logc, logn, logo, file_path)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(point_name) DO UPDATE SET file_path = excluded.file_path",
params![
name_db,
p_db.teff.value(),
p_db.logg.value(),
p_db.loghe.value(),
p_db.logc.value(),
p_db.logn.value(),
p_db.logo.value(),
path_db
],
)?;
Ok(())
})
.await??;
let item = SeedCacheItem {
point_name: name.clone(),
params: p.clone(),
file_path: path_owned,
};
let mut lock = self.seed_cache.write().await;
let is_new;
if let Some(pos) = lock.iter().position(|x| x.point_name == item.point_name) {
// 已存在:seeds 表 ON CONFLICT 只更新 file_pathpoint_name/物理参数不变,
// 故 exact_family 桶键不变,索引无需重写,仅同步 Vec 里的 file_path。
lock[pos].file_path = item.file_path.clone();
is_new = false;
} else {
lock.push(item.clone());
is_new = true;
}
drop(lock);
// 新种子才需写入索引(已存在的种子 params 不变,桶键未变)。
if is_new {
let mut idx_lock = self.seed_index.write().await;
for key in SeedBucketKey::from_params(&p) {
idx_lock.entry(key).or_default().push(item.clone());
}
}
Ok(())
}
pub async fn find_best_seed_from_db(
&self,
target: &GridPointParams,
) -> Result<Option<common::seed_finder::SeedMatch>> {
// 优先走 exact_family 索引(O(1)~O(小)):取出 target 的两个候选桶的全部种子快照后
// 立即释放读锁,避免阻塞 insert_seed 写。exact_family 是绝大多数命中的路径。
let exact_candidates: Vec<SeedCacheItem> = {
let idx_lock = self.seed_index.read().await;
let keys = SeedBucketKey::from_params(target);
let mut out = Vec::new();
for key in keys {
if let Some(bucket) = idx_lock.get(&key) {
out.extend(bucket.iter().cloned());
}
}
out
};
let mut exact_family: Option<(String, std::path::PathBuf, f64)> = None;
for item in &exact_candidates {
let (is_exact, d) = common::seed_finder::calculate_seed_distance(&item.params, target);
if is_exact {
let path = std::path::PathBuf::from(&item.file_path);
if exact_family.is_none() || d < exact_family.as_ref().unwrap().2 {
exact_family = Some((item.point_name.clone(), path, d));
}
}
}
if let Some((name, path, d)) = exact_family {
return Ok(Some(common::seed_finder::SeedMatch {
name,
path,
distance: d,
}));
}
// exact_family 未命中:退化到全量 global 扫描。克隆参数缩小读锁持有范围。
let snapshot: Vec<_> = {
let lock = self.seed_cache.read().await;
lock.iter()
.map(|item| {
(
item.point_name.clone(),
item.params.clone(),
item.file_path.clone(),
)
})
.collect()
};
let mut global_closest: Option<(String, std::path::PathBuf, f64)> = None;
for (point_name, params, file_path) in snapshot {
let (is_exact, d) = common::seed_finder::calculate_seed_distance(&params, target);
// exact_family 路径已在上面处理过(索引已覆盖),这里只关心 global 候选。
if !is_exact
&& d <= common::seed_finder::MAX_GLOBAL_SEED_DISTANCE
&& (global_closest.is_none() || d < global_closest.as_ref().unwrap().2)
{
let path = std::path::PathBuf::from(&file_path);
global_closest = Some((point_name, path, d));
}
}
if let Some((name, path, d)) = global_closest {
Ok(Some(common::seed_finder::SeedMatch {
name,
path,
distance: d,
}))
} else {
Ok(None)
}
}
}
+98
View File
@@ -0,0 +1,98 @@
//! 进度快照(从原 db.rs 拆分,2026-08-06)。
//! `impl Database` 的 进度快照 域方法。共享基础设施(Database struct、连接管理、类型、helper)见父模块 `super`crate::db)。
use super::*;
impl Database {
/// 记录一条进度快照:与上一条快照计数完全一致时不写(去重防膨胀)。
/// 返回是否实际写入。由后台循环对每个运行中工作流调用。
pub async fn record_progress_snapshot(&self, wf: &str) -> Result<bool> {
let base = self.get_grid_summary_stats(Some(wf)).await?;
let g = |k: &str| base.get(k).and_then(|v| v.as_i64()).unwrap_or(0);
let cur = (
g("total"),
g("pending"),
g("queued"),
g("running"),
g("completed"),
g("failed"),
);
let pool = self.pool.clone();
let wf = wf.to_string();
tokio::task::spawn_blocking(move || -> Result<bool> {
let mut conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
// 审查修复 #M1:原 SELECT last + INSERT 非原子,两个并发后台循环会各自读到
// last != cur 各自 INSERT 相同快照,去重失效导致快照表膨胀。现用单条
// INSERT ... WHERE NOT EXISTS 原子去重(单语句天然原子,无需 IMMEDIATE——
// 写语句在提交时持有写锁,WHERE NOT EXISTS 的读与写同锁内一致)。
let tx = conn.transaction()?;
let inserted = tx.execute(
"INSERT INTO workflow_progress_snapshots
(workflow_name, total, pending, queued, running, completed, failed)
SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7
WHERE NOT EXISTS (
SELECT 1 FROM workflow_progress_snapshots
WHERE workflow_name = ?1
AND total = ?2 AND pending = ?3 AND queued = ?4
AND running = ?5 AND completed = ?6 AND failed = ?7
)",
params![wf, cur.0, cur.1, cur.2, cur.3, cur.4, cur.5],
)?;
tx.commit()?;
Ok(inserted > 0)
})
.await?
}
/// 清理超过保留期(天)的进度快照。返回无意义计数以外的错误。
pub async fn purge_progress_snapshots(&self, keep_days: u64) -> Result<()> {
let pool = self.pool.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
conn.execute(
"DELETE FROM workflow_progress_snapshots WHERE ts < datetime('now', ?1)",
params![format!("-{} days", keep_days)],
)?;
Ok(())
})
.await??;
Ok(())
}
/// 最近 `hours` 小时的进度时间序列(ts 升序)。
pub async fn get_progress_series(&self, wf: &str, hours: i64) -> Result<Vec<ProgressPoint>> {
let pool = self.pool.clone();
let wf = wf.to_string();
tokio::task::spawn_blocking(move || -> Result<Vec<ProgressPoint>> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare(
"SELECT ts, total, pending, queued, running, completed, failed
FROM workflow_progress_snapshots
WHERE workflow_name = ?1 AND ts >= datetime('now', ?2)
ORDER BY ts ASC, id ASC",
)?;
let rows = stmt.query_map(params![wf, format!("-{} hours", hours)], |r| {
Ok(ProgressPoint {
ts: r.get(0)?,
total: r.get(1)?,
pending: r.get(2)?,
queued: r.get(3)?,
running: r.get(4)?,
completed: r.get(5)?,
failed: r.get(6)?,
})
})?;
let mut series = Vec::new();
for r in rows {
series.push(r?);
}
Ok(series)
})
.await?
}
}
+603
View File
@@ -0,0 +1,603 @@
//! 任务/结算(从原 db.rs 拆分,2026-08-06)。
//! `impl Database` 的 任务/结算 域方法。共享基础设施(Database struct、连接管理、类型、helper)见父模块 `super`crate::db)。
use super::*;
impl Database {
/// 判断某个网格点在特定工作流内是否已经派发过种子步进 (seed_step) 任务。
///
/// 注意:**生产回退逻辑已改用策略链弹栈**`pop_stage_strategy_for_fallback` +
/// `trigger_strategy_fallback`"种子回退仅一次"守卫由「链耗尽 → 保持 failed」取代)。
/// 本方法仅保留作**测试断言辅助**api_tests / db.rs 单测核验"全程未产生 seed_step 行")。
///
/// 判定口径(Phase 6 起派生):`json_extract(tlusty_strategies, '$[0]') = 'seed_step'`——
/// task_type 列已删除,策略链首项即"当前执行策略"的权威快照。
///
/// 为何计数 pending2026-08-02 涡旋事故定稿,回退 2026-08-01 的 pending 排除):
/// pending 的 seed_step 行要么是正在排队/在途的真任务(计数它正是对在途回退的
/// 去重——否则救援途中收到的迟到失败报告会触发第二份 seed_step 重复派发);
/// 要么是"无队列行"的僵尸行(insert_task 后 push 前崩溃等)。2026-08-01 事故中
/// 僵尸行曾永久堵死 21 个点的正当回退,当时的 pending 排除是应急解法;本修复后
/// 僵尸行被结构性清除——stop/重启卫生(clear_queue_by_workflow 返回 ids 同步删
/// tasks 行)、派发去重(schedule 前 MQ 活性校验删死行)、回退内僵尸卫生
/// trigger_strategy_fallback 先删死行再弹链)——pending 行不再可能是僵尸,
/// 计数它既安全又必要。
pub async fn has_seed_step_attempt(&self, name: &str, workflow_name: &str) -> Result<bool> {
let pool = self.pool.clone();
let name_owned = name.to_string();
let wf_owned = workflow_name.to_string();
let exists = tokio::task::spawn_blocking(move || -> Result<bool> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare(
"SELECT 1 FROM tasks WHERE point_name = ?1 AND json_extract(tlusty_strategies, '$[0]') = 'seed_step' AND workflow_name = ?2 LIMIT 1",
)?;
let res = stmt.exists(params![name_owned, wf_owned])?;
Ok(res)
})
.await??;
Ok(exists)
}
/// 幂等上报吸收(见 api/task.rs report_task):查 tasks 表中某任务是否**已由本节点结算**
/// node_id 归属匹配 + status 为终态)。首轮上报成功后 `remove_task` 清掉了 MQ 领用行,
/// 若响应在链路上丢失,节点重试上报会经 `verify_task_claim` 落空——此时凭本方法判定为
/// 已结算的幂等重放,返回 200 并补写种子,而非 403 误诊「token 失效」。
///
/// 返回 `(point_name, workflow_name)` 供调用方复用定向校验;未命中返回 None。
pub async fn find_settled_task_claim(
&self,
task_id: &str,
node_id: &str,
) -> Result<Option<(String, Option<String>)>> {
let pool = self.pool.clone();
let tid = task_id.to_string();
let nid = node_id.to_string();
let row = tokio::task::spawn_blocking(move || -> Result<Option<(String, Option<String>)>> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare(
"SELECT point_name, workflow_name FROM tasks
WHERE task_id = ?1 AND node_id = ?2 AND status IN ('completed', 'failed', 'timeout')
LIMIT 1",
)?;
Ok(stmt
.query_row(params![tid, nid], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
})
.ok())
})
.await??;
Ok(row)
}
/// 策略链自动回退(见 docs/task_engine_decoupling_design.md §4.2):
/// 读取指定网格点最近一条**已上报**(非 pendingtasks 行的 `tlusty_strategies`
/// 弹出首项,返回 `(剩余链, 被弹出的策略)`。
///
/// 兼容别名:`trigger_strategy_fallback` 的 TLUSTY 分支使用(failed_stage 为
/// "tlusty" 或缺省时)。SYNSPEC 链的弹栈走 `pop_stage_strategy_for_fallback(_, _, "synspec")`。
pub async fn pop_tlusty_strategy_for_fallback(
&self,
name: &str,
workflow_name: &str,
) -> Result<Option<FallbackSnapshot>> {
self.pop_stage_strategy_for_fallback(name, workflow_name, "tlusty")
.await
}
/// 策略链自动回退(阶段参数化版,见 docs/task_engine_decoupling_design.md §4.2 注):
/// 与 `pop_tlusty_strategy_for_fallback` 语义一致,但按 `stage` 选择弹哪条链——
/// `"synspec"` → `synspec_strategies` 列,其余 → `tlusty_strategies` 列。
///
/// 设计 §4.2 注明确要求 SYNSPEC 策略链的自动弹栈与 TLUSTY 完全一致(若配置了多策略链),
/// 由失败阶段归因(TaskReport.failed_stage)决定弹哪条链。
///
/// **只读不改写**(见审查修复):旧实现把剩余链写回旧行,随后调度器 insert_task
/// 新建一行携带相同链 → 两行共享同一链、旧行的 strategies 变陈旧。现改为纯读取:
/// 调度器拿到剩余链后构造新 TaskSpec(insert_task 写入新行携带剩余链),旧行保持
/// 原状(记录该任务实际执行的策略,审计正确)。
///
/// **并发安全**(审查修复 #C2):本方法本身非原子(内存 remove(0) 不落库),并发调用
/// 会各自读到完整链、各自派发回退任务。现由调用方 `trigger_strategy_fallback` 持有
/// `schedule_lock` 串行化,且 fallback 的「status == failed」状态守卫保证第二次调用
/// 进入时点已被首次 fallback 改为 queued 而被拦截。故「只读 + 锁 + 状态守卫」组合
/// 在保证审计正确性的同时消除了并发重复派发,无需改写旧行。
///
/// **返回派发时快照([`FallbackSnapshot`]**:策略链 + 被弹策略 + 该行落库的 policy。
/// policy 与策略链同源(同一行、同一时刻派发),供回退决策(SkipFailed 门控)与重试
/// 任务构造使用——回退行为由派发时配置决定,不随运行期 YAML 编辑漂移(对齐 §4.2
/// 「不修改原有 policy,保持用户初始配置」)。
///
/// 过滤 `status IN ('failed','timeout')`:只有**失败**任务才触发回退、其策略链才应被弹。
/// L4 修复:原 `status != 'pending'` 也会命中已完成的 `completed` 行——在孤儿回收重派
/// 场景下,「最新已上报行」可能是一条 stale 的 completed 行,其链与刚失败触发回退的任务
/// 不符,弹出会移除错误链的顺位。收紧到失败/超时行后,弹栈始终对准触发回退的失败任务。
/// pending 行(未上报僵尸/在途)与 completed 行(已成功、链已消费完)均不参与弹栈。
///
/// 仅匹配精确 workflow_name(移除旧 `IS NULL / = ''` 兜底子句——多工作流分区迁移
/// 已把历史行回填为 `__legacy__`,NULL 兜底反而让同名点跨工作流命中 legacy 行)。
pub async fn pop_stage_strategy_for_fallback(
&self,
name: &str,
workflow_name: &str,
stage: &str,
) -> Result<Option<FallbackSnapshot>> {
// 阶段 → 策略链列 / policy 列的白名单映射(防注入:非法 stage 兜底为 tlusty 列)。
let (col, policy_col) = match stage {
"synspec" => ("synspec_strategies", "synspec_policy"),
_ => ("tlusty_strategies", "tlusty_policy"),
};
let pool = self.pool.clone();
let name_owned = name.to_string();
let wf = workflow_name.to_string();
let sql = format!(
"SELECT {col}, {policy_col} FROM tasks
WHERE point_name = ?1 AND workflow_name = ?2 AND status IN ('failed', 'timeout')
ORDER BY created_at DESC, rowid DESC LIMIT 1"
);
tokio::task::spawn_blocking(move || -> Result<Option<FallbackSnapshot>> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
// 只读最新已上报行(非 pending),单条查询无需事务。
let row: Option<(String, String)> = {
let mut stmt = conn.prepare(&sql)?;
stmt.query_row(params![name_owned, wf], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})
.ok()
};
let Some((json_str, policy_str)) = row else {
return Ok(None);
};
let mut strategies: Vec<String> = serde_json::from_str(&json_str).unwrap_or_default();
if strategies.is_empty() {
return Ok(None);
}
let popped = strategies.remove(0);
Ok(Some(FallbackSnapshot {
rest_strategies: strategies,
popped,
policy: ResumePolicy::from_str_lossy(&policy_str),
}))
})
.await?
}
/// 读取指定网格点最近一条 tasks 行的 TLUSTY 策略链(不修改)。供回退守卫判断
/// 「策略链是否已含 seed_step 且为当前执行策略」等。无行返回空 Vec。
pub async fn get_latest_tlusty_strategies(
&self,
name: &str,
workflow_name: &str,
) -> Result<Vec<String>> {
let pool = self.pool.clone();
let name_owned = name.to_string();
let wf = workflow_name.to_string();
let strategies = tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let json_str: Option<String> = {
let mut stmt = conn.prepare(
"SELECT tlusty_strategies FROM tasks
WHERE point_name = ?1 AND workflow_name = ?2
ORDER BY created_at DESC, rowid DESC LIMIT 1",
)?;
stmt.query_row(params![name_owned, wf], |r| r.get::<_, String>(0))
.ok()
};
let json_str = match json_str {
Some(s) => s,
None => return Ok(Vec::new()),
};
Ok(serde_json::from_str(&json_str).unwrap_or_default())
})
.await??;
Ok(strategies)
}
pub async fn insert_task(&self, spec: &common::models::TaskSpec) -> Result<()> {
let pool = self.pool.clone();
let spec = spec.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
// 阶段配置打平入库(见 docs/task_engine_decoupling_design.md §4.1)。
// strategies 序列化为 JSON 数组文本列;policy 取 as_str。
// Phase 6 起无 task_type 列,当前执行策略由 tlusty_strategies[0] 派生。
let tlusty_enabled = spec.tlusty_config.enabled;
let tlusty_policy = spec.tlusty_config.policy.as_str();
let tlusty_strategies = serde_json::to_string(&spec.tlusty_config.strategies)?;
let synspec_enabled = spec.synspec_config.enabled;
let synspec_policy = spec.synspec_config.policy.as_str();
let synspec_strategies = serde_json::to_string(&spec.synspec_config.strategies)?;
conn.execute(
"INSERT INTO tasks (task_id, point_name, seed_point_name, status, created_at, workflow_name,
tlusty_enabled, tlusty_policy, tlusty_strategies,
synspec_enabled, synspec_policy, synspec_strategies, atmosphere_ref)
VALUES (?1, ?2, ?3, 'pending', datetime('now'), ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(task_id) DO NOTHING",
params![
spec.task_id.to_string(),
spec.point_name,
spec.seed_point_name,
spec.workflow_name,
tlusty_enabled,
tlusty_policy,
tlusty_strategies,
synspec_enabled,
synspec_policy,
synspec_strategies,
spec.atmosphere_ref,
],
)?;
Ok(())
})
.await??;
Ok(())
}
/// 删除 tasks 历史表中指定 task_id 的行。
///
/// 用于调度回滚:当 push_task 失败时,grid_points 已回滚、queue 已清理,
/// 但先于 push 插入的 tasks 历史行(status='pending')会遗留,污染每点尝试计数统计。
/// 此方法在回滚路径中调用以保持三者一致。
pub async fn delete_task(&self, task_id: &uuid::Uuid) -> Result<()> {
let pool = self.pool.clone();
let id = task_id.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
conn.execute("DELETE FROM tasks WHERE task_id = ?1", params![id])?;
Ok(())
})
.await??;
Ok(())
}
/// 列出指定网格点在当前工作流内仍处于 `pending` 的 tasks 行 task_id。
///
/// tasks 行的 pending 意为"已创建但从未上报",可能是真在途(队列行仍 pending/
/// claimed)或僵尸(队列行已不存在)。调用方须配合 SqliteTaskQueue::task_row_exists
/// 做 MQ 活性交叉校验区分二者(2026-08-02 涡旋事故修复引入):
/// - 派发去重:任一活 → 跳过派发;全死 → 清僵尸后正常派发。
/// - 孤儿回收 / 回退种子卫生:同上分流。
///
/// `strategy` 传 Some("seed_step")/Some("cold_run") 时按当前策略过滤(Phase 6 起派生:
/// 比较 `json_extract(tlusty_strategies, '$[0]')`,不再依赖已删除的 task_type 列),
/// None 不过滤。生产调用仅传 None,Some 分支供测试断言用。
pub async fn has_pending_tasks_for_point(
&self,
point_name: &str,
workflow_name: &str,
strategy: Option<&str>,
) -> Result<Vec<String>> {
let pool = self.pool.clone();
let point = point_name.to_string();
let wf = workflow_name.to_string();
let tt = strategy.map(|s| s.to_string());
tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut ids = Vec::new();
match tt {
Some(tt) => {
let mut stmt = conn.prepare(
"SELECT task_id FROM tasks WHERE point_name = ?1 AND workflow_name = ?2 AND status = 'pending' AND json_extract(tlusty_strategies, '$[0]') = ?3",
)?;
let rows = stmt.query_map(params![point, wf, tt], |r| r.get::<_, String>(0))?;
for r in rows {
ids.push(r?);
}
}
None => {
let mut stmt = conn.prepare(
"SELECT task_id FROM tasks WHERE point_name = ?1 AND workflow_name = ?2 AND status = 'pending'",
)?;
let rows = stmt.query_map(params![point, wf], |r| r.get::<_, String>(0))?;
for r in rows {
ids.push(r?);
}
}
}
Ok(ids)
})
.await?
}
/// 批量删除 tasks 表中指定 task_id 且仍为 `pending` 的行,返回删除行数。
///
/// 用于僵尸行清理(派发去重 / 孤儿回收 / stop·重启卫生)。`AND status = 'pending'`
/// 是 TOCTOU 防护:校验活性与删除之间若有上报事务恰好提交(行变 completed/failed),
/// 本删除不会误删已完成的审计历史(attempt_count/阶段归因列均不受影响)。
/// 空切片短路返回 0;按 500 个一批分块(SQLite 默认宿主参数上限 999)。
pub async fn delete_tasks_by_ids(&self, task_ids: &[String]) -> Result<usize> {
if task_ids.is_empty() {
return Ok(0);
}
let pool = self.pool.clone();
let ids = task_ids.to_vec();
tokio::task::spawn_blocking(move || -> Result<usize> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut total = 0usize;
for chunk in ids.chunks(500) {
let placeholders = vec!["?"; chunk.len()].join(", ");
let sql = format!(
"DELETE FROM tasks WHERE task_id IN ({}) AND status = 'pending'",
placeholders
);
let params: Vec<&dyn rusqlite::ToSql> =
chunk.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
total += conn.execute(&sql, params.as_slice())?;
}
Ok(total)
})
.await?
}
/// 记录节点上报的任务结果。返回网格点状态是否发生**实际迁移**(bool):
/// 上层 report_task 据此决定失败报告是否触发种子回退——被终态守卫吸收的
/// 重复报告返回 false,不再触发回退(2026-08-02 涡旋事故修复)。
pub async fn record_task_report(
&self,
report: &TaskReport,
workflow_name: &str,
) -> Result<bool> {
let pool = self.pool.clone();
let report_cloned = report.clone();
let point_name = report.point_name.clone();
let wf = workflow_name.to_string();
let converged = report.result_valid;
let atmo_has_nan = report.atmosphere_has_nan;
tokio::task::spawn_blocking(move || -> Result<bool> {
let mut conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let tx = conn.transaction()?;
let status_str = match report_cloned.status {
TaskStatus::Completed => "completed",
TaskStatus::Failed => "failed",
TaskStatus::Timeout => "timeout",
_ => "pending",
};
// P1(Phase 1):结算补落阶段信息。failed_stage 仅对失败/超时任务落库(缺省兜底
// 'tlusty',旧节点不携带该字段 → 按 TLUSTY 链回退,与调度端 infer_failed_stage
// 默认一致);成功任务显式置 NULL,避免污染按 failed_stage 过滤的审计/统计
// (审查修复:旧实现 COALESCE 无条件填充,成功任务也被写入 'tlusty')。
// summary_json 透传 ModelSummary 序列化(错误路径为 {"error": ...},服务端只透传不解析)。
let is_failure = matches!(
report_cloned.status,
TaskStatus::Failed | TaskStatus::Timeout
);
let failed_stage_value: Option<&str> = if is_failure {
report_cloned.failed_stage.as_deref().or(Some("tlusty"))
} else {
None
};
// H2 修复:任务结算去重守卫。并发重复上报同一 task 时,仅首个报告能把 tasks 行
// 从非终态置为终态(WHERE status NOT IN 终态)。若 UPDATE 命中 0 行且任务行仍存在,
// 说明该 task 已被先前报告结算为终态 → 本报告为重复结算 → 直接返回 changed=false、
// 跳过网格点翻转。这消除了「report2 把 report1 回退后已置为 queued 的网格点再翻回
// failed、重复触发 seed_step 回退」的 TOCTOU2026-08-02 涡旋事故的并发残余):
// 两个报告即便都通过 API 层的 verify_task_claim(纯 SELECT 不消费),也只有一个
// 会在 DB 层把任务置为终态并结算网格点。
let task_updated = tx.execute(
"UPDATE tasks SET status = ?1, node_id = ?2, max_relc = ?3, atmosphere_has_nan = ?4, completed_at = datetime('now'), error_message = ?5, elapsed_sec = ?6, failed_stage = ?8, summary_json = ?9 WHERE task_id = ?7 AND status NOT IN ('completed', 'failed', 'timeout')",
params![
status_str,
report_cloned.node_id,
report_cloned.max_relc,
report_cloned.atmosphere_has_nan,
report_cloned.error_message,
report_cloned.elapsed_sec,
report_cloned.task_id.to_string(),
failed_stage_value,
report_cloned.summary_json,
],
)?;
if task_updated == 0 {
// 0 行更新:要么任务行已终态(重复结算),要么任务行不存在(被清理)。
// 区分二者:任务行仍存在则为重复结算,跳过网格点翻转(首次结算的审计信息
// 已保留在任务行);任务行不存在则继续走原逻辑(策略读取回落到安全默认)。
let task_exists = match tx.query_row(
"SELECT 1 FROM tasks WHERE task_id = ?1",
params![report_cloned.task_id.to_string()],
|_| Ok(true),
) {
Ok(_) => true,
Err(rusqlite::Error::QueryReturnedNoRows) => false,
Err(e) => return Err(e.into()),
};
if task_exists {
tx.rollback()?;
return Ok(false);
}
}
// 失败次数计数自增(attempt_count 仅作观测/统计用途,保留原子 UPDATE 避免并发竞态)。
// 注意:状态迁移不再依赖该计数值(失败统一置 failed,是否回退复活由
// trigger_strategy_fallback 按策略链弹栈 + policy 快照决定,见 db.rs FallbackSnapshot)。
// 三条 UPDATE grid_points 均带 workflow_name 过滤,避免跨工作流误改同名点。
//
// 审查修复 #S2:原 `unwrap_or(0)` 把任何 rusqlite::Error(锁超时、磁盘 I/O)吞成 0。
// 现区分 QueryReturnedNoRowsgrid_points 无该点行,返回 0 合理)与其余错误(上抛)。
match tx.query_row(
"UPDATE grid_points SET attempt_count = attempt_count + 1 WHERE name = ?1 AND workflow_name = ?2 RETURNING attempt_count",
params![point_name, wf],
|r| r.get::<_, i32>(0),
) {
Ok(_) => {}
Err(rusqlite::Error::QueryReturnedNoRows) => {}
Err(e) => return Err(e.into()),
}
// 成功归因(修复审查 #4 + Phase 6 派生口径 + Phase 5a + P9 命名拆分):
// - tlusty_success_methodTLUSTY 阶段以何策略收敛。tlusty_enabled=1 时取
// `tlusty_strategies[0]`cold_run/seed_step);TLUSTY 禁用(synspec-only)为 NULL。
// - synspec_success_method5a):光谱阶段以何策略收敛。synspec_enabled=1 时取
// `synspec_strategies[0]`(如 "standard");TLUSTY-only 成功保持 NULL。
// 成功分支(status=Completed)已由 derive_report_status 保证「synspec 启用则无错」,
// 故"synspec 成功" ⟺ synspec_enabled=1,无需再查 synspec_rc。
// 整体归因(原 success_method 值域混用)改为消费方派生:tlusty ?? synspec。
// 注:依赖 SQLite 内建 JSON1 的 json_extractrusqlite bundled SQLite 默认启用)。
//
// 审查修复 #S2(严重):原 `unwrap_or((None, None, true, true))` 把任何 DB 错误
// (锁超时、I/O)当成「行不存在」,用 tlusty_enabled=true/synspec_enabled=true 默认值
// 继续写阶段状态,故障期会静默写入错误归因数据。现区分 QueryReturnedNoRows(行不存在,
// 用安全默认)与其余错误(上抛)。
let (tlusty_success_method, synspec_success_method, tlusty_enabled, synspec_enabled): (
Option<String>,
Option<String>,
bool,
bool,
) = match tx.query_row(
"SELECT
CASE WHEN tlusty_enabled = 1
THEN json_extract(tlusty_strategies, '$[0]')
END,
CASE WHEN synspec_enabled = 1
THEN json_extract(synspec_strategies, '$[0]')
END,
tlusty_enabled,
synspec_enabled
FROM tasks WHERE task_id = ?1",
params![report_cloned.task_id.to_string()],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
) {
Ok(v) => v,
Err(rusqlite::Error::QueryReturnedNoRows) => (None, None, true, true),
Err(e) => return Err(e.into()),
};
// Phase 5b:阶段状态(§7.2 语义表)。NULL = 阶段不适用(enabled=0)。
// 整体 grid_points.status 仍是权威状态,阶段列是补充可查信息。
// **阶段列值固定用 'converged'**(阶段收敛语义,与 StageSummary.converged 一致)——
// claim/running 守卫按 'converged' 保留终态,二者必须匹配(审查 CRITICAL#17c 曾误
// 把这里改成 'completed' 导致半失败守卫失效,已回退)。
let pipe_ok = report_cloned.status == TaskStatus::Completed && converged && !atmo_has_nan;
let (tlusty_status, synspec_status): (Option<&str>, Option<&str>) = if pipe_ok {
// 整管线成功:启用阶段均 converged。
(
tlusty_enabled.then_some("converged"),
synspec_enabled.then_some("converged"),
)
} else if converged {
// 半失败:大气收敛 + 光谱失败。tlusty 阶段确已收敛(tlusty_enabled 防御性守卫——
// 该分支在 synspec-only 下不可达,但避免未来代码路径把禁用阶段误标为 converged)。
(tlusty_enabled.then_some("converged"), Some("failed"))
} else if tlusty_enabled {
// 大气未收敛(TLUSTY 启用)→ TLUSTY 失败、SYNSPEC 未运行。
(Some("failed"), Some("pending"))
} else {
// synspec-only 失败(converged 由 synspec_rc 决定)。
(None, Some("failed"))
};
// 终态守卫(2026-08-02 涡旋事故修复):
// - 成功分支:converged 为吸收态,迟到的重复成功报告不覆盖阶段归因列;
// 允许 failed/queued/pending/running → converged(迟到的真收敛结果获胜,
// 含种子救援路径 failed → converged)。
// - 失败分支:NOT IN ('completed','failed')——converged 不可被迟到失败报告翻黑
// (事故直接症状);failed 吸收重复失败报告(changed=0 → 不触发重复回退)。
// queued/pending → failed 是正确语义:能通过 verify_task_claim 的报告必为
// 真实领用并计算过的任务(如回收器重置后在途任务才上报、领用后 mark 失败等)。
// - 阶段列(5b):tlusty_status 用 CASE 守卫保留既有终态——半失败重试的 synspec-only
// 任务(tlusty 关闭)不覆盖已收敛的 tlusty_status(设计 §7.3 打开项 #2:仅 synspec
// 侧流转);synspec_status 自由流转。
let grid_changed = if pipe_ok {
tx.execute(
"UPDATE grid_points SET status = 'completed', last_elapsed_sec = ?1, tlusty_success_method = ?2, synspec_success_method = ?3,
tlusty_status = CASE WHEN ?6 IS NOT NULL THEN ?6 ELSE tlusty_status END,
synspec_status = ?7
WHERE name = ?4 AND workflow_name = ?5 AND status != 'completed'",
params![
report_cloned.elapsed_sec,
tlusty_success_method,
synspec_success_method,
point_name,
wf,
tlusty_status,
synspec_status
],
)?
} else {
tx.execute(
"UPDATE grid_points SET status = 'failed', last_elapsed_sec = ?1,
tlusty_status = CASE WHEN ?4 IS NOT NULL THEN ?4 ELSE tlusty_status END,
synspec_status = ?5
WHERE name = ?2 AND workflow_name = ?3 AND status NOT IN ('completed', 'failed')",
params![report_cloned.elapsed_sec, point_name, wf, tlusty_status, synspec_status],
)?
};
// 审查修复 #S1workflow 完成 flip 并入同一事务(原实现在 tx.commit() 后单独 UPDATE
// 崩溃窗口期 task 已终态但 workflow 卡 running;且 `let _ =` 丢弃 I/O 错误)。
// 现在事务内更新,与 task/grid_points 结算原子提交,错误正常传播。
tx.execute(
"UPDATE workflows
SET status = 'completed', updated_at = datetime('now')
WHERE name = ?1
AND status = 'running'
AND EXISTS (SELECT 1 FROM grid_points WHERE workflow_name = workflows.name)
AND NOT EXISTS (
SELECT 1 FROM grid_points
WHERE workflow_name = workflows.name
AND status IN ('pending', 'queued', 'running')
)",
params![wf],
)?;
tx.commit()?;
Ok(grid_changed > 0)
})
.await?
}
/// 单点的全部任务尝试(created_at 升序)。
///
/// 严格匹配 `workflow_name = ?`:分区迁移前的历史任务(NULL/'')不会出现,
/// 这是有意的多工作流隔离语义。
pub async fn list_point_attempts(&self, wf: &str, point: &str) -> Result<Vec<AttemptRow>> {
let pool = self.pool.clone();
let wf = wf.to_string();
let point = point.to_string();
tokio::task::spawn_blocking(move || -> Result<Vec<AttemptRow>> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare(
"SELECT task_id, seed_point_name, status, max_relc,
atmosphere_has_nan, node_id, error_message, created_at, completed_at,
elapsed_sec, failed_stage, summary_json
FROM tasks
WHERE point_name = ?1 AND workflow_name = ?2
ORDER BY created_at ASC, completed_at ASC",
)?;
let rows = stmt.query_map(params![point, wf], |r| {
Ok(AttemptRow {
task_id: r.get(0)?,
seed_point_name: r.get(1)?,
status: r.get(2)?,
max_relc: r.get(3)?,
atmosphere_has_nan: r.get(4)?,
node_id: r.get(5)?,
error_message: r.get(6)?,
created_at: r.get(7)?,
completed_at: r.get(8)?,
elapsed_sec: r.get(9)?,
failed_stage: r.get(10)?,
summary_json: r.get(11)?,
})
})?;
let mut attempts = Vec::new();
for r in rows {
attempts.push(r?);
}
Ok(attempts)
})
.await?
}
}
+303
View File
@@ -0,0 +1,303 @@
//! 工作流(从原 db.rs 拆分,2026-08-06)。
//! `impl Database` 的 工作流 域方法。共享基础设施(Database struct、连接管理、类型、helper)见父模块 `super`crate::db)。
use super::*;
impl Database {
pub async fn upsert_workflow(
&self,
name: &str,
description: Option<&str>,
config_yaml: &str,
status: &str,
) -> Result<()> {
let pool = self.pool.clone();
let name_owned = name.to_string();
let desc_owned = description.map(|s| s.to_string());
let yaml_owned = config_yaml.to_string();
let status_owned = status.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
conn.execute(
"INSERT INTO workflows (name, description, config_yaml, status, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))
ON CONFLICT(name) DO UPDATE SET
description = excluded.description,
config_yaml = excluded.config_yaml,
status = CASE WHEN workflows.status = 'running' THEN workflows.status ELSE excluded.status END,
updated_at = datetime('now')",
params![name_owned, desc_owned, yaml_owned, status_owned],
)?;
Ok(())
})
.await??;
Ok(())
}
/// 自动巡检所有处于 running 状态的工作流:
/// 若某个工作流下的所有网格点均已到达终态(无 pending/queued/running 点),
/// 则自动将该工作流的数据库 status 翻转为 'completed'。
pub async fn sync_all_running_workflows_completion(&self) -> Result<()> {
let pool = self.pool.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
// L6 修复:原 `let _ =` 丢弃 r2d2/rusqlite 错误——若该后台对账 UPDATE 失败,
// 工作流可持续卡在 running 无任何提示。现记录错误(仍返回 Ok 不中断主流程,
// 因为每份上报内的 workflow-completion flip 才是主路径,见 record_task_report)。
if let Err(e) = conn.execute(
"UPDATE workflows
SET status = 'completed', updated_at = datetime('now')
WHERE status = 'running'
AND EXISTS (SELECT 1 FROM grid_points WHERE workflow_name = workflows.name)
AND NOT EXISTS (
SELECT 1 FROM grid_points
WHERE workflow_name = workflows.name
AND status IN ('pending', 'queued', 'running')
)",
[],
) {
tracing::error!("后台对账:同步 running 工作流完成态失败: {}", e);
}
Ok(())
})
.await?
}
pub async fn list_workflows(&self) -> Result<Vec<WorkflowSummary>> {
let pool = self.pool.clone();
tokio::task::spawn_blocking(move || -> Result<Vec<WorkflowSummary>> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare(
"SELECT name, description, status, created_at, updated_at FROM workflows ORDER BY updated_at DESC"
)?;
let rows = stmt.query_map([], |row| {
Ok(WorkflowSummary {
name: row.get(0)?,
description: row.get(1)?,
status: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
stats: None,
})
})?;
let mut list = Vec::new();
for r in rows {
list.push(r?);
}
// 一次 GROUP BY 聚合全部工作流的网格计数并回填(不做逐工作流查询,无 N+1)。
let mut agg_stmt = conn.prepare(
"SELECT workflow_name,
COUNT(*) AS total,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running,
SUM(CASE WHEN status = 'completed' AND tlusty_success_method = 'cold_run' THEN 1 ELSE 0 END) AS cold,
SUM(CASE WHEN status = 'completed' AND tlusty_success_method = 'seed_step' THEN 1 ELSE 0 END) AS seed,
SUM(CASE WHEN status = 'completed' AND synspec_success_method IS NOT NULL THEN 1 ELSE 0 END) AS synspec
FROM grid_points GROUP BY workflow_name",
)?;
let agg_rows = agg_stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
WorkflowListStats {
total: r.get(1)?,
completed: r.get::<_, Option<i64>>(2)?.unwrap_or(0),
failed: r.get::<_, Option<i64>>(3)?.unwrap_or(0),
running: r.get::<_, Option<i64>>(4)?.unwrap_or(0),
cold_run_converged: r.get::<_, Option<i64>>(5)?.unwrap_or(0),
seed_step_converged: r.get::<_, Option<i64>>(6)?.unwrap_or(0),
synspec_converged: r.get::<_, Option<i64>>(7)?.unwrap_or(0),
},
))
})?;
let mut stats_map = std::collections::HashMap::new();
for r in agg_rows {
let (wf, s) = r?;
stats_map.insert(wf, s);
}
for wf in &mut list {
wf.stats = stats_map.remove(&wf.name);
}
Ok(list)
})
.await?
}
pub async fn get_workflow(&self, name: &str) -> Result<Option<WorkflowItem>> {
let pool = self.pool.clone();
let name_owned = name.to_string();
tokio::task::spawn_blocking(move || -> Result<Option<WorkflowItem>> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare(
"SELECT name, description, config_yaml, status, created_at, updated_at FROM workflows WHERE name = ?1"
)?;
let row = stmt.query_row(params![name_owned], |row| {
Ok(WorkflowItem {
name: row.get(0)?,
description: row.get(1)?,
config_yaml: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
})
});
match row {
Ok(item) => Ok(Some(item)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
})
.await?
}
pub async fn update_workflow_status(&self, name: &str, status: &str) -> Result<()> {
let pool = self.pool.clone();
let name_owned = name.to_string();
let status_owned = status.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
conn.execute(
"UPDATE workflows SET status = ?1, updated_at = datetime('now') WHERE name = ?2",
params![status_owned, name_owned],
)?;
Ok(())
})
.await??;
Ok(())
}
pub async fn delete_workflow(&self, name: &str) -> Result<()> {
let pool = self.pool.clone();
let name_owned = name.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let mut conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let tx = conn.transaction()?;
tx.execute("DELETE FROM workflows WHERE name = ?1", params![name_owned])?;
tx.execute(
"DELETE FROM grid_points WHERE workflow_name = ?1",
params![name_owned],
)?;
tx.execute(
"DELETE FROM tasks WHERE workflow_name = ?1",
params![name_owned],
)?;
tx.commit()?;
Ok(())
})
.await??;
Ok(())
}
pub async fn has_running_workflow(&self) -> Result<bool> {
let pool = self.pool.clone();
tokio::task::spawn_blocking(move || -> Result<bool> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM workflows WHERE status = 'running'",
[],
|r| r.get(0),
)?;
Ok(count > 0)
})
.await?
}
/// 返回当前处于 running / initializing 状态的**全部**工作流名称。
///
/// 多工作流并发分区:后台调度需对每个 running 工作流分别派发任务,
/// 替代原来「全局只有一个 running workflow」的 LIMIT 1 假设。
pub async fn get_running_workflow_names(&self) -> Result<Vec<String>> {
let pool = self.pool.clone();
tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare(
"SELECT name FROM workflows WHERE status IN ('running', 'initializing') ORDER BY updated_at ASC",
)?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
let mut list = Vec::new();
for r in rows {
list.push(r?);
}
Ok(list)
})
.await?
}
/// 原子切转工作流至 initializing 预占启动状态,杜绝高并发 POST /start 触发双重全量排队与重置网格竞态
pub async fn transition_workflow_to_initializing(&self, name: &str) -> Result<bool> {
let pool = self.pool.clone();
let name_owned = name.to_string();
let affected = tokio::task::spawn_blocking(move || -> Result<usize> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let count = conn.execute(
"UPDATE workflows SET status = 'initializing', updated_at = datetime('now') WHERE name = ?1 AND status NOT IN ('running', 'initializing')",
params![name_owned],
)?;
Ok(count)
})
.await??;
Ok(affected > 0)
}
/// 获取运行或启动态中的所有工作流 YAML 配置(替代原来低效 N 次循环与嵌套查询)
pub async fn get_running_workflow_config_yamls(&self) -> Result<Vec<String>> {
let pool = self.pool.clone();
tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn.prepare(
"SELECT config_yaml FROM workflows WHERE status IN ('running', 'initializing')",
)?;
let rows = stmt.query_map([], |row| row.get(0))?;
let mut list = Vec::new();
for r in rows {
list.push(r?);
}
Ok(list)
})
.await?
}
/// 获取所有处于 `initializing` 态的工作流 (name, config_yaml)。
///
/// 用于服务端启动恢复:`start_workflow` 把状态切到 `initializing` 后在后台 spawn
/// `initialize_grid`。若进程在初始化中途崩溃/重启,工作流会永久卡在 `initializing`
/// `get_running_workflow_names` 仍把它算作可调度,但无人完成网格展开)。
/// 启动时检测到这些半初始化工作流后重新跑 `initialize_grid`(幂等,ON CONFLICT DO NOTHING
/// 把状态推进到 `running`,避免半初始化网格被调度。
pub async fn get_initializing_workflows(&self) -> Result<Vec<(String, String)>> {
let pool = self.pool.clone();
tokio::task::spawn_blocking(move || -> Result<Vec<(String, String)>> {
let conn = pool
.get()
.map_err(|e| anyhow::anyhow!("DB Pool Error: {}", e))?;
let mut stmt = conn
.prepare("SELECT name, config_yaml FROM workflows WHERE status = 'initializing'")?;
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
let mut list = Vec::new();
for r in rows {
list.push(r?);
}
Ok(list)
})
.await?
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod api;
pub mod cors;
pub mod db;
pub mod migrations;
pub mod scheduler;
+16 -5
View File
@@ -228,7 +228,17 @@ async fn main() -> Result<()> {
// 校验确认真孤儿后重置为 pending 让调度器重新派发,并清除作为判据的
// 僵尸 tasks 行(旧实现仅凭 tasks 表 stale pending 行判定,僵尸行使
// 判据恒真 → 重复派发涡旋,已废弃)。
match bg_scheduler_clone.reclaim_orphaned_points(stale_sec).await {
//
// 审查修复 #M4:reclaim 用独立且更大的阈值(2 * stale_sec)。reclaim 的语义是
// 「孤儿回收」(队列凭证完全丢失),时间尺度应比 requeue 的「claim 超时重投」
// 更宽松:刚被 claim 的任务 tasks 行仍 pending,过小阈值会把它误判孤儿候选、
// 在 requeue 把队列行打回 pending 到节点重新 claim 的窗口内增加抖动。2x 给
// 正常长任务足够缓冲。
let reclaim_threshold = stale_sec.saturating_mul(2);
match bg_scheduler_clone
.reclaim_orphaned_points(reclaim_threshold)
.await
{
Ok(reset) => {
if reset > 0 {
info!(
@@ -494,14 +504,15 @@ async fn security_headers_middleware(
let headers = resp.headers_mut();
// CSPdefault-src 'self';放行 Google Fontsindex.html 引用);允许 data: 图片。
// 已移除 'unsafe-eval'dashboard 构建产物不使用 eval/new Function(已核实),保留它会
// 显著削弱 CSP 的脚本注入防护。'unsafe-inline' 暂留(静态 SPA 内联脚本/handler 需要),
// 彻底方案需前端改造为外链 + per-request nonce 注入,见 docs TODO
// 已移除 'unsafe-eval'dashboard 用 eval/new Function)与 script-src 'unsafe-inline'
// (主题初始化脚本已移为外链 public/theme-init.js,见 dashboard/index.html)。无内联
// 脚本/内联事件处理器,外链脚本走 'self' 即可,脚本注入防御最大化
// style-src 保留 'unsafe-inline'index.html 含内联 style 属性(SVG flex 布局等),移除会破坏渲染。
headers
.entry(axum::http::header::CONTENT_SECURITY_POLICY)
.or_insert_with(|| {
HeaderValue::from_static(
"default-src 'self'; script-src 'self' 'unsafe-inline'; \
"default-src 'self'; script-src 'self'; \
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \
font-src 'self' data: https://fonts.gstatic.com; \
connect-src 'self'; img-src 'self' data: blob:; \
+565
View File
@@ -0,0 +1,565 @@
//! 主库版本化迁移基础设施(Phase 0,见 docs/database_refactor_design.md §2)。
//!
//! 背景:init_tables 内持续堆积手写幂等 ALTER 块,无版本追踪,风险随 schema 演进累积。
//! 本模块引入 `PRAGMA user_version` 驱动的版本化迁移,为后续各 Phase 的结构变更
//! P1/P2/P4/P5a/P6)提供统一、可检测、事务化、可中断恢复的迁移通道。
//!
//! 约定:
//! - **V0 = 0**`PRAGMA user_version` 对全新库的默认值);后续编号迁移从 1 开始。
//! - **新库**bootstrap 的 CREATE TABLE 始终是最新形态(含各 Phase 新增列)→ 置 V0 →
//! 顺序应用 V0+1..N。每个迁移自带 detect 守卫,已存在的列/索引直接跳过 → 新库上所有迁移为 no-op。
//! - **旧库**user_version=0 但表已存在):bootstrap 幂等补全既有列 → 置 V0 → 应用后续迁移,
//! detect 守卫保证只补缺的列/索引,数据零搬运。
//! - **幂等性关键**SQLite 无 `ADD COLUMN IF NOT EXISTS`,迁移必须靠 detect 守卫而非裸 SQL
//! 数组实现幂等(审查 CRITICAL#3——否则全新库上 bootstrap 已建新列,迁移再 ADD 会报
//! duplicate column 崩启动)。
//!
//! 队列库(dcts_queue.db)无版本迁移,维持现状;本模块**仅主库**引入版本号。
use anyhow::{Context, Result};
use rusqlite::{params, Connection, TransactionBehavior};
/// 一个版本化迁移。
///
/// - `version`> V0(=0) 的顺序号(1..N),`PRAGMA user_version = version` 即代表已应用。
/// - `name`:便于日志与审计。
/// - `detect`:该迁移是否已应用(列/索引存在性检测)。为 true 时跳过 `up`,仅推进版本号。
/// - `up`:未应用时才执行,同一事务内顺序执行。
///
/// `Clone + Copy`fn 指针与 `&'static str` 均 Copy,迁移定义可原地复用(测试重跑场景)。
#[derive(Clone, Copy)]
pub struct Migration {
pub version: u32,
pub name: &'static str,
pub detect: fn(&Connection) -> Result<bool>,
pub up: &'static [&'static str],
}
/// 全部迁移。随各 Phase 追加(Phase 0 交付基础设施,Phase 1 起逐个加入)。
pub const MIGRATIONS: &[Migration] = &[
// M1Phase 1P1):tasks 阶段信息补全。failed_stage = 失败阶段归因,
// summary_json = ModelSummary 全保真 JSON。两列均在线 ADD COLUMN,旧节点上报不破坏结算。
Migration {
version: 1,
name: "tasks-stage-info",
detect: |c| {
Ok(has_column(c, "tasks", "failed_stage")?
&& has_column(c, "tasks", "summary_json")?)
},
up: &[
"ALTER TABLE tasks ADD COLUMN failed_stage TEXT",
"ALTER TABLE tasks ADD COLUMN summary_json TEXT",
],
},
// M2Phase 2P2):tasks 单列 workflow_name 查询的覆盖索引。
// 覆盖 `COUNT(*) WHERE workflow_name=?` 及详情页按工作流统计;用户决策不做 tasks 清理。
// CREATE INDEX IF NOT EXISTS 天然幂等,新库/旧库统一由此迁移建立(无需进 init_tables)。
Migration {
version: 2,
name: "tasks-wf-status-created-index",
detect: |c| has_index(c, "idx_tasks_wf_status_created"),
up: &["CREATE INDEX IF NOT EXISTS idx_tasks_wf_status_created ON tasks(workflow_name, status, created_at)"],
},
// M4Phase 4P5):清除 node_credentials 死列 revoked。
// 新代码不读写它;registration_secret 保留在 nodes(审查 CRITICAL#1/#2pending 节点无
// node_credentials 行,迁移会静默丢凭据;token_hash NOT NULL + 唯一索引塞不下空占位)。
// DROP COLUMN 涉及表重建(bundled SQLite 3.45+),部署走低峰窗口 + 手动备份(§11)。
Migration {
version: 4,
name: "drop-revoked-dead-column",
detect: |c| Ok(!has_column(c, "node_credentials", "revoked")?),
up: &["ALTER TABLE node_credentials DROP COLUMN revoked"],
},
// M6Phase 6P8):删除 tasks 冗余列 task_type。
// 该列与 tlusty_strategies[0] 恒等、synspec-only 场景为"假值",执行链已改由 strategies[0]
// 推导(executor.rs),归因/过滤全部改派生口径。前提:集群无历史节点(用户决策)。
// DROP COLUMN 涉及表重建,部署走低峰窗口 + 手动备份(§11);升级前确认队列为空(§8.8)。
Migration {
version: 6,
name: "drop-task-type-column",
detect: |c| Ok(!has_column(c, "tasks", "task_type")?),
up: &["ALTER TABLE tasks DROP COLUMN task_type"],
},
// M7Phase 5aP6):grid_points 补 synspec 收敛归因列。
// 与 success_method 镜像的 synspec 分支(光谱以什么策略收敛),解锁「光谱以 standard 等
// 策略收敛了多少点」的 SQL 统计;TLUSTY-only 成功保持 NULL。在线 ADD COLUMN,无停写窗口。
//
// 版本号 = 7(而非 5):**迁移版本必须与部署顺序单调一致**——§11 部署顺序是 6 → 5a,
// 若 5a 编号为 5,则已升到 v6 的库会因 `version <= current` 跳过它,synspec 列永不创建。
//(设计 §2.2 的 "M5" 标签是早期命名,此处按部署序改号 M7。)
Migration {
version: 7,
name: "synspec-success-method",
detect: |c| has_column(c, "grid_points", "synspec_success_method"),
up: &["ALTER TABLE grid_points ADD COLUMN synspec_success_method TEXT"],
},
// M8Phase 5bP6):grid_points 阶段状态列。
// 解除点级单值 status 掩盖两阶段管线:半失败点(大气收敛+光谱失败)可查
// tlusty_status='converged' + synspec_status='failed'。NULL = 阶段不适用(tlusty_enabled=0
// 或 synspec_enabled=0)。整体 grid_points.status 仍是权威状态,阶段列是补充可查信息。
// 在线 ADD COLUMN;同步触点见 docs/database_refactor_design.md §7.3 5b。
Migration {
version: 8,
name: "grid-point-stage-status",
detect: |c| {
Ok(has_column(c, "grid_points", "tlusty_status")?
&& has_column(c, "grid_points", "synspec_status")?)
},
up: &[
"ALTER TABLE grid_points ADD COLUMN tlusty_status TEXT",
"ALTER TABLE grid_points ADD COLUMN synspec_status TEXT",
],
},
// M9Phase 7c):grid_points.status 值 'converged' → 'completed'。
// TLUSTY-first 残留:'converged' 暗示"大气收敛",实为"管线完成"(大气+光谱)。
// 数据迁移 + 全链 SQL 字面量同步(见 db.rs/scheduler.rs,值全部改 'completed')。
Migration {
version: 9,
name: "grid-status-converged-to-completed",
detect: |c| {
// 已迁移 = 不再存在旧值 'converged'detect 检查数据而非列)。
// 安全性:apply_migrations_with 以 user_version 闸控——M9 只在 version<9 时评估,
// 一旦版本推进到 9 即永不再走此 detect,故即便后续代码意外再写入 'converged'
// 也不会触发本迁移重放(user_version 不会回退)。
let mut stmt = c.prepare("SELECT 1 FROM grid_points WHERE status = 'converged' LIMIT 1")?;
Ok(!stmt.exists([])?)
},
up: &["UPDATE grid_points SET status = 'completed' WHERE status = 'converged'"],
},
// M10Phase 7c):workflow_progress_snapshots 列名 converged → completed。
// 该列存"管线完成点数",列名随状态值改名保持一致(§9.5 耦合项)。RENAME COLUMN 在线。
Migration {
version: 10,
name: "snapshots-converged-column-rename",
detect: |c| has_column(c, "workflow_progress_snapshots", "completed"),
up: &["ALTER TABLE workflow_progress_snapshots RENAME COLUMN converged TO completed"],
},
// M11H1 活锁修复):grid_points 补 pending_strategies 列。
// 运行时回退(trigger_strategy_fallback)把点打回 pending 等种子时,记录「剩余策略链」
// (JSON 数组),调度路径据此用剩余链重派、避免重跑已失败策略导致的无界失败重试活锁。
// 在线 ADD COLUMN,无停写窗口。detect 幂等(全新库 bootstrap 已含该列 → 跳过)。
Migration {
version: 11,
name: "grid-point-pending-strategies",
detect: |c| has_column(c, "grid_points", "pending_strategies"),
up: &["ALTER TABLE grid_points ADD COLUMN pending_strategies TEXT"],
},
// M12P9 命名拆分):rid_points.success_method 值域混用列拆为阶段列 tlusty_success_method。
// success_method 原是 TLUSTY-first 整体归因:TLUSTY 任务存 tlusty_strategies[0]
// cold_run/seed_step),synspec-only 任务却存 synspec_strategies[0]standard)——
// 同一列两个值域,前端需猜策略名区分。拆后:
// - tlusty_success_methodTLUSTY 阶段策略(tlusty 禁用为 NULL
// - synspec_success_method(既有):光谱阶段策略(synspec 禁用为 NULL
// 整体归因改由消费方派生(前端 tlusty ?? synspec)。
//
// 回填判别:旧数据里 synspec-only 点 success_method 与 synspec_success_method 同值
// (正是被清理的冗余);故命中该等式的点不写 tlusty(保持 NULL),其余(正常双阶段
// cold_run/seed_step)→ tlusty = 原 success_method。局限:极端情形下 TLUSTY 点
// tlusty_strategies[0]==synspec_strategies[0](如都叫 standard)会被误判为 NULL
// 实际 sdB_cno 中大气策略与光谱策略不冲突,可接受。在线 ADD + UPDATE,无停写窗口。
Migration {
version: 12,
name: "tlusty-success-method",
detect: |c| has_column(c, "grid_points", "tlusty_success_method"),
up: &[
"ALTER TABLE grid_points ADD COLUMN tlusty_success_method TEXT",
"UPDATE grid_points SET tlusty_success_method = success_method \
WHERE success_method IS NOT NULL \
AND NOT (success_method = synspec_success_method AND synspec_success_method IS NOT NULL)",
],
},
// M13(P9 命名拆分):删除值域混用列 success_methodM12 已回填 tlusty_success_method)。
// DROP COLUMN 涉及表重建(bundled SQLite 3.45+),部署走低峰窗口 + 手动备份(§11)。
Migration {
version: 13,
name: "drop-success-method-column",
detect: |c| Ok(!has_column(c, "grid_points", "success_method")?),
up: &["ALTER TABLE grid_points DROP COLUMN success_method"],
},
];
/// 当前 schema 版本(`PRAGMA user_version`)。
pub fn current_version(conn: &Connection) -> Result<u32> {
Ok(conn.pragma_query_value(None, "user_version", |r| r.get(0))?)
}
/// 从当前版本顺序应用常量表 `MIGRATIONS` 中尚未执行的迁移。
pub fn apply_migrations(conn: &mut Connection) -> Result<()> {
apply_migrations_with(conn, MIGRATIONS)
}
/// 应用给定迁移列表中尚未执行的部分,每个迁移独立事务(供测试传入自定义列表)。
///
/// 对每个 `version > current` 的迁移:
/// - `detect = true`(已应用,如新库 bootstrap 已建列)→ 仅推进 user_version,不执行 `up`
/// - `detect = false` → `BEGIN IMMEDIATE` → 执行 `up` SQL → `PRAGMA user_version = V` → `COMMIT`。
///
/// 中途失败不推进版本(进程启动时重试):当前迁移所在事务回滚,之前迁移的版本号已持久化。
fn apply_migrations_with(conn: &mut Connection, migrations: &[Migration]) -> Result<()> {
let mut current = current_version(conn)?;
for m in migrations {
if m.version <= current {
continue;
}
if (m.detect)(conn)? {
// 已应用(detect 命中,如全新库 bootstrap 已建列)→ 仅推进版本号,不执行 up。
conn.pragma_update(None, "user_version", m.version)?;
tracing::info!(
version = m.version,
name = m.name,
"迁移已应用(detect 跳过)"
);
current = m.version;
continue;
}
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
for sql in m.up {
tx.execute_batch(sql)
.with_context(|| format!("迁移 M{} ({}) 失败:{}", m.version, m.name, sql))?;
}
tx.pragma_update(None, "user_version", m.version)?;
tx.commit()?;
tracing::info!(version = m.version, name = m.name, "迁移已应用");
current = m.version;
}
Ok(())
}
/// 检测表是否已含指定列(PRAGMA table_info)。`table` 必须是 MIGRATIONS 内写死的常量表名,
/// 绝不来自外部输入(表名不参与任何用户数据路径)。
fn has_column(conn: &Connection, table: &str, column: &str) -> Result<bool> {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
let rows = stmt.query_map([], |r| r.get::<_, String>(1))?;
for r in rows {
if r.map(|name| name == column).unwrap_or(false) {
return Ok(true);
}
}
Ok(false)
}
/// 检测索引是否已存在(sqlite_master)。
fn has_index(conn: &Connection, index: &str) -> Result<bool> {
let mut stmt = conn.prepare("SELECT 1 FROM sqlite_master WHERE type='index' AND name=?1")?;
Ok(stmt.exists(params![index])?)
}
#[cfg(test)]
mod tests {
use super::*;
fn mem_conn() -> Connection {
Connection::open_in_memory().unwrap()
}
/// 新库全流程(对齐 Database::new):bootstrap 已建出最新形态 schema(含 M1 新增列)
/// → 版本 0 → apply_migrations 应**无报错**地把版本推进到最新(已存在的列/索引经 detect
/// 跳过,不因 duplicate column 崩溃——审查 CRITICAL#3 回归;索引类迁移在最新列上正常建立)。
#[test]
fn fresh_db_bootstrap_then_migrations_advance_version() {
let mut conn = mem_conn();
// 模拟 init_tables bootstraptasks 是含全部迁移引用列的最新形态(M1 列已存在、
// M2 索引目标列已存在但索引本身未建)。
conn.execute_batch(
"CREATE TABLE tasks (
task_id TEXT PRIMARY KEY,
point_name TEXT NOT NULL,
node_id TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL,
completed_at DATETIME,
workflow_name TEXT,
failed_stage TEXT,
summary_json TEXT
);
CREATE TABLE grid_points (
name TEXT NOT NULL,
workflow_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
tlusty_success_method TEXT,
synspec_success_method TEXT,
tlusty_status TEXT,
synspec_status TEXT,
pending_strategies TEXT
);
CREATE TABLE workflow_progress_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_name TEXT NOT NULL,
ts DATETIME NOT NULL,
total INTEGER NOT NULL,
pending INTEGER NOT NULL,
queued INTEGER NOT NULL,
running INTEGER NOT NULL,
completed INTEGER NOT NULL,
failed INTEGER NOT NULL
)",
)
.unwrap();
assert_eq!(current_version(&conn).unwrap(), 0);
apply_migrations(&mut conn).unwrap();
let latest = MIGRATIONS.last().map(|m| m.version).unwrap_or(0);
assert_eq!(current_version(&conn).unwrap(), latest);
}
/// 正常迁移:up 顺序执行,版本推进。
#[test]
fn migration_applies_and_advances_version() {
let mut conn = mem_conn();
let m = Migration {
version: 1,
name: "test-add-col",
detect: |c| has_column(c, "t", "c"),
up: &[
"CREATE TABLE t(id INTEGER PRIMARY KEY)",
"ALTER TABLE t ADD COLUMN c TEXT",
],
};
apply_migrations_with(&mut conn, &[m]).unwrap();
assert_eq!(current_version(&conn).unwrap(), 1);
assert!(has_column(&conn, "t", "c").unwrap());
}
/// detect=true(已应用)→ 跳过 up(若执行会 duplicate column 崩),仅推进版本。
#[test]
fn detect_skip_advances_version_without_running_up() {
let mut conn = mem_conn();
conn.execute_batch("CREATE TABLE t(id INTEGER PRIMARY KEY, c TEXT)")
.unwrap();
let m = Migration {
version: 1,
name: "test-add-col",
detect: |c| has_column(c, "t", "c"),
up: &["ALTER TABLE t ADD COLUMN c TEXT"],
};
apply_migrations_with(&mut conn, &[m]).unwrap();
assert_eq!(current_version(&conn).unwrap(), 1);
}
/// M4 专项:旧库 node_credentials 含 revoked 死列 → apply_migrations 后列消失、版本推进。
/// 同时验证 M1(列已存在跳过)+ M2(索引缺失建立)+ M4(DROP COLUMN)在同库顺序生效。
#[test]
fn m4_drops_revoked_dead_column_on_old_db() {
let mut conn = mem_conn();
conn.execute_batch(
"CREATE TABLE tasks (
task_id TEXT PRIMARY KEY,
point_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL,
workflow_name TEXT,
failed_stage TEXT,
summary_json TEXT
);
CREATE TABLE grid_points (
name TEXT NOT NULL,
workflow_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
success_method TEXT,
synspec_success_method TEXT
);
CREATE TABLE workflow_progress_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_name TEXT NOT NULL,
ts DATETIME NOT NULL,
total INTEGER NOT NULL,
pending INTEGER NOT NULL,
queued INTEGER NOT NULL,
running INTEGER NOT NULL,
converged INTEGER NOT NULL,
failed INTEGER NOT NULL
);
CREATE TABLE node_credentials (
node_id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL,
issued_at DATETIME NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0,
raw_token_pending TEXT
);",
)
.unwrap();
apply_migrations(&mut conn).unwrap();
assert!(
!has_column(&conn, "node_credentials", "revoked").unwrap(),
"revoked 死列应被 M4 清除"
);
assert!(
has_index(&conn, "idx_tasks_wf_status_created").unwrap(),
"M2 索引应建立"
);
// M10:旧 snapshots 的 converged 列应被重命名为 completed。
assert!(has_column(&conn, "workflow_progress_snapshots", "completed").unwrap());
assert!(!has_column(&conn, "workflow_progress_snapshots", "converged").unwrap());
// M11grid_points 的 pending_strategies 列应被补齐(H1 活锁修复标记)。
assert!(has_column(&conn, "grid_points", "pending_strategies").unwrap());
let latest = MIGRATIONS.last().map(|m| m.version).unwrap_or(0);
assert_eq!(current_version(&conn).unwrap(), latest);
}
/// M9Phase 7c):grid_points.status 值 'converged' → 'completed' 数据迁移。
/// 旧库残留 'converged' 值 → apply_migrations 后全部转为 'completed'。
#[test]
fn m9_converged_status_value_migrated() {
let mut conn = mem_conn();
conn.execute_batch(
"CREATE TABLE tasks (
task_id TEXT PRIMARY KEY,
point_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL,
workflow_name TEXT
);
CREATE TABLE grid_points (
name TEXT NOT NULL,
workflow_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
success_method TEXT,
synspec_success_method TEXT
);
INSERT INTO grid_points (name, workflow_name, status) VALUES ('p1','wf_a','converged');
INSERT INTO grid_points (name, workflow_name, status) VALUES ('p2','wf_a','failed');
CREATE TABLE workflow_progress_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_name TEXT NOT NULL,
ts DATETIME NOT NULL,
total INTEGER NOT NULL,
pending INTEGER NOT NULL,
queued INTEGER NOT NULL,
running INTEGER NOT NULL,
converged INTEGER NOT NULL,
failed INTEGER NOT NULL
);",
)
.unwrap();
apply_migrations(&mut conn).unwrap();
// 旧值迁为 'completed''failed' 不受影响。
let statuses: Vec<String> = {
let mut stmt = conn
.prepare("SELECT status FROM grid_points ORDER BY name")
.unwrap();
stmt.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.filter_map(Result::ok)
.collect()
};
assert_eq!(
statuses,
vec!["completed".to_string(), "failed".to_string()]
);
// M10snapshots 列改名。
assert!(has_column(&conn, "workflow_progress_snapshots", "completed").unwrap());
}
/// 中断恢复:M1 成功(版本 1),M2 中途失败(事务回滚,版本停在 1);
/// 修正 M2 后重跑,M1 跳过、M2 成功。
#[test]
fn interrupted_migration_rolls_back_and_retries() {
let mut conn = mem_conn();
let m1 = Migration {
version: 1,
name: "m1",
detect: |c| has_column(c, "t", "c1"),
up: &[
"CREATE TABLE t(id INTEGER PRIMARY KEY)",
"ALTER TABLE t ADD COLUMN c1 TEXT",
],
};
let m2_bad = Migration {
version: 2,
name: "m2-bad",
detect: |c| has_column(c, "t", "c2"),
up: &["ALTER TABLE t ADD COLUMN c2 TEXT", "THIS IS NOT VALID SQL"],
};
let err = apply_migrations_with(&mut conn, &[m1, m2_bad]).unwrap_err();
assert!(err.to_string().contains("M2 (m2-bad) 失败"));
// M1 已提交、版本停在 1;M2 回滚(c2 未建)。
assert_eq!(current_version(&conn).unwrap(), 1);
assert!(!has_column(&conn, "t", "c2").unwrap());
// 重跑:M1version 1)跳过,M2 修正后成功。
let m2_good = Migration {
version: 2,
name: "m2-good",
detect: |c| has_column(c, "t", "c2"),
up: &["ALTER TABLE t ADD COLUMN c2 TEXT"],
};
apply_migrations_with(&mut conn, &[m1, m2_good]).unwrap();
assert_eq!(current_version(&conn).unwrap(), 2);
assert!(has_column(&conn, "t", "c2").unwrap());
}
/// M12/M13 专项(P9 命名拆分):旧库 success_method 值域混用列 → 拆为
/// tlusty_success_methodTLUSTY 阶段策略)+ 既有 synspec_success_method,再删 success_method。
/// 回填判别:synspec-only 点 success_method 与 synspec_success_method 同值(值域混用冗余),
/// 命中该等式 → tlusty 保持 NULL;其余(正常双阶段 cold_run/seed_step)→ tlusty = 原值。
#[test]
fn m12_m13_split_success_method_backfills_and_drops() {
let mut conn = mem_conn();
conn.execute_batch(
"CREATE TABLE grid_points (
name TEXT NOT NULL,
workflow_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
success_method TEXT,
synspec_success_method TEXT
);
-- 正常双阶段点:success_method = 大气策略 cold_run(≠ 光谱归因 standard
INSERT INTO grid_points (name, workflow_name, status, success_method, synspec_success_method)
VALUES ('p_tlusty', 'wf', 'completed', 'cold_run', 'standard');
-- 种子步进点
INSERT INTO grid_points (name, workflow_name, status, success_method, synspec_success_method)
VALUES ('p_seed', 'wf', 'completed', 'seed_step', 'standard');
-- synspec-only 点:success_method == synspec_success_method(值域混用冗余)
INSERT INTO grid_points (name, workflow_name, status, success_method, synspec_success_method)
VALUES ('p_syn', 'wf', 'completed', 'standard', 'standard');
-- 失败点:归因为 NULL
INSERT INTO grid_points (name, workflow_name, status)
VALUES ('p_failed', 'wf', 'failed')",
)
.unwrap();
// M12 单跑:ADD tlusty_success_method + 回填。
let m12 = MIGRATIONS.iter().find(|m| m.version == 12).unwrap();
apply_migrations_with(&mut conn, &[*m12]).unwrap();
let read = |conn: &Connection, name: &str, col: &str| -> Option<String> {
// `col` 为测试内写死的列名常量(非用户输入)。
conn.query_row(
&format!("SELECT {col} FROM grid_points WHERE name = ?1"),
rusqlite::params![name],
|r| r.get(0),
)
.ok()
};
assert_eq!(
read(&conn, "p_tlusty", "tlusty_success_method").as_deref(),
Some("cold_run"),
"双阶段点回填大气策略"
);
assert_eq!(
read(&conn, "p_seed", "tlusty_success_method").as_deref(),
Some("seed_step"),
"种子步进点回填 seed_step"
);
assert_eq!(
read(&conn, "p_syn", "tlusty_success_method"),
None,
"synspec-only 点(两列同值)tlusty 保持 NULL"
);
assert_eq!(read(&conn, "p_failed", "tlusty_success_method"), None);
assert_eq!(current_version(&conn).unwrap(), 12);
// M13 单跑:DROP success_method 列。
let m13 = MIGRATIONS.iter().find(|m| m.version == 13).unwrap();
apply_migrations_with(&mut conn, &[*m13]).unwrap();
assert!(
!has_column(&conn, "grid_points", "success_method").unwrap(),
"success_method 值域混用列应被删除"
);
assert!(has_column(&conn, "grid_points", "tlusty_success_method").unwrap());
assert!(has_column(&conn, "grid_points", "synspec_success_method").unwrap());
// 回填数据在 DROP 后仍保留(tlusty_success_method 是独立列)。
assert_eq!(
read(&conn, "p_tlusty", "tlusty_success_method").as_deref(),
Some("cold_run")
);
assert_eq!(current_version(&conn).unwrap(), 13);
}
}
File diff suppressed because it is too large Load Diff
+57 -107
View File
@@ -266,6 +266,17 @@ async fn test_l2_node_token_issue_reissue_flow() {
);
// 旧 token 仍失效(已被覆盖)
assert!(db.find_node_by_token(&token).await.is_none());
// M2registration_secret 被首次 take 轮换后,旧 secret 无法取走 reissue 产生的新 token。
// 首次 take(上文)已把 secret 轮换为无人知晓的新值,旧 secret(注册时下发的)立即失效。
let stolen = db
.take_pending_node_token("node-l2-test", secret.as_deref())
.await
.unwrap();
assert!(
stolen.is_none(),
"被轮换的旧 registration_secret 不得取走 reissue 后的新 tokenM2 一次性凭据)"
);
}
/// 验证中间件对 node token 的端到端鉴权:
@@ -1323,9 +1334,9 @@ fn make_import_multipart(
body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
body.extend_from_slice(seed_bytes);
body.extend_from_slice(b"\r\n");
// 收敛途径字段(cold_run/seed_step):模拟 import_results 工具判定后透传的途径。
// 大气收敛途径字段(cold_run/seed_step):模拟 import_results 工具判定后透传的途径。
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
body.extend_from_slice(b"Content-Disposition: form-data; name=\"success_method\"\r\n");
body.extend_from_slice(b"Content-Disposition: form-data; name=\"tlusty_success_method\"\r\n");
body.extend_from_slice(b"Content-Type: text/plain\r\n\r\n");
body.extend_from_slice(success_method.as_bytes());
body.extend_from_slice(b"\r\n");
@@ -1433,7 +1444,7 @@ async fn test_import_seed_admin_endpoint() {
.await
.unwrap()
.expect("grid_points 应存在");
assert_eq!(gp.0, "converged", "导入的收敛点应为 converged 状态");
assert_eq!(gp.0, "completed", "导入的收敛点应为 converged 状态");
// 3. 幂等:重复导入同名点不应报错,状态仍 converged。
let body_bytes = make_import_multipart(
@@ -1462,7 +1473,7 @@ async fn test_import_seed_admin_endpoint() {
.await
.unwrap()
.unwrap();
assert_eq!(gp.0, "converged");
assert_eq!(gp.0, "completed");
// 4. 未收敛点 → 200,但不写 .7、grid_points 维持 pending(未建 converged)。
let conv_fail = make_legacy_conv_json("t20000_g5.0_he-2_c-4_n-4_o-4_fail", false);
@@ -1570,8 +1581,8 @@ async fn test_import_seed_python_legacy_conv_json() {
.await
.unwrap()
.expect("grid_points 应存在");
assert_eq!(row.status, "converged");
assert_eq!(row.success_method.as_deref(), Some("cold_run"));
assert_eq!(row.status, "completed");
assert_eq!(row.tlusty_success_method.as_deref(), Some("cold_run"));
assert_eq!(
row.last_elapsed_sec,
Some(715.0),
@@ -1585,7 +1596,7 @@ async fn test_import_seed_python_legacy_conv_json() {
#[tokio::test]
async fn test_node_disable_enable_flow() {
use common::models::{GridAxisValue, GridPointParams, TaskSpec, TaskType};
use common::models::{GridAxisValue, GridPointParams, TaskSpec};
use uuid::Uuid;
let temp_dir = tempfile::tempdir().unwrap();
@@ -1668,7 +1679,6 @@ async fn test_node_disable_enable_flow() {
logn: GridAxisValue::from_value(-2.0),
logo: GridAxisValue::from_value(-2.0),
},
task_type: TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 60,
workflow_name: Some("wf_test".to_string()),
@@ -2120,13 +2130,13 @@ async fn test_admin_set_node_quota_and_heartbeat_sync() {
/// 测试专用:走真实写路径派发并回报一个网格点任务。
///
/// `insert_task` → `record_task_report` 会回填 `grid_points.success_method`、
/// `insert_task` → `record_task_report` 会回填 `grid_points.tlusty_success_method`、
/// `attempt_count` 与 `tasks.completed_at`,与生产链路一致(不绕过任何状态机逻辑)。
async fn dispatch_and_report(
db: &Database,
wf: &str,
p: &common::models::GridPointParams,
task_type: common::models::TaskType,
strategy: &str,
seed: Option<String>,
converged: bool,
) {
@@ -2135,11 +2145,15 @@ async fn dispatch_and_report(
task_id,
point_name: p.model_name(),
params: p.clone(),
task_type,
seed_point_name: seed,
timeout_sec: 7200,
workflow_name: Some(wf.to_string()),
wave: 0,
// Phase 6 起策略链首项即"当前策略"(归因/过滤全派生自它)。
tlusty_config: common::models::PhaseConfig {
strategies: vec![strategy.to_string()],
..common::models::PhaseConfig::default_tlusty()
},
..Default::default()
};
db.insert_task(&spec).await.unwrap();
@@ -2153,7 +2167,7 @@ async fn dispatch_and_report(
} else {
common::models::TaskStatus::Failed
},
converged,
result_valid: converged,
max_relc: if converged { Some(0.0005) } else { Some(9.5e5) },
atmosphere_has_nan: false,
elapsed_sec: 120.0,
@@ -2267,33 +2281,17 @@ async fn test_wf_stats_endpoint() {
.await
.unwrap();
dispatch_and_report(
&db,
"wf_stats",
&p_cold,
common::models::TaskType::ColdRun,
None,
true,
)
.await;
dispatch_and_report(&db, "wf_stats", &p_cold, "cold_run", None, true).await;
dispatch_and_report(
&db,
"wf_stats",
&p_seed,
common::models::TaskType::SeedStep,
"seed_step",
Some(p_cold.model_name()),
true,
)
.await;
dispatch_and_report(
&db,
"wf_stats",
&p_failed,
common::models::TaskType::ColdRun,
None,
false,
)
.await;
dispatch_and_report(&db, "wf_stats", &p_failed, "cold_run", None, false).await;
db.mark_grid_point_imported(&p_imported.model_name(), "wf_stats", None, "cold_run")
.await
.unwrap();
@@ -2347,7 +2345,7 @@ async fn test_wf_stats_endpoint() {
assert_eq!(data["pending"], 1, "pending 与 queued 必须分开计数");
assert_eq!(data["queued"], 1);
assert_eq!(data["running"], 1);
assert_eq!(data["converged"], 3);
assert_eq!(data["completed"], 3);
assert_eq!(data["failed"], 1);
assert_eq!(data["cold_run_converged"], 2);
assert_eq!(data["seed_step_converged"], 1);
@@ -2356,8 +2354,8 @@ async fn test_wf_stats_endpoint() {
assert_eq!(waves.len(), 3);
assert_eq!(waves[0]["wave"], 0);
assert_eq!(waves[0]["total"], 3);
assert_eq!(waves[0]["converged"], 0);
assert_eq!(waves[1]["converged"], 2);
assert_eq!(waves[0]["completed"], 0);
assert_eq!(waves[1]["completed"], 2);
assert_eq!(waves[2]["failed"], 1);
// 有已完成任务且有剩余点 → ETA 可估算。
// P3 后 avg 取精确 elapsed_sec(夹具每次回报 120s),无在线节点按串行兜底:
@@ -2436,48 +2434,24 @@ async fn seed_obs_fixture(db: &Database, db_path: &std::path::Path, wf: &str) ->
}
db.upsert_grid_point(&p_imported, 1, wf).await.unwrap();
dispatch_and_report(
db,
wf,
&p_cold,
common::models::TaskType::ColdRun,
None,
true,
)
.await;
dispatch_and_report(db, wf, &p_cold, "cold_run", None, true).await;
dispatch_and_report(
db,
wf,
&p_seed,
common::models::TaskType::SeedStep,
"seed_step",
Some(p_cold.model_name()),
true,
)
.await;
dispatch_and_report(
db,
wf,
&p_failed,
common::models::TaskType::ColdRun,
None,
false,
)
.await;
dispatch_and_report(db, wf, &p_failed, "cold_run", None, false).await;
// rescued:先冷启动失败,再种子步进救回(2 次尝试,最终 converged/seed_step
dispatch_and_report(db, wf, &p_rescued, "cold_run", None, false).await;
dispatch_and_report(
db,
wf,
&p_rescued,
common::models::TaskType::ColdRun,
None,
false,
)
.await;
dispatch_and_report(
db,
wf,
&p_rescued,
common::models::TaskType::SeedStep,
"seed_step",
Some(p_cold.model_name()),
true,
)
@@ -2490,7 +2464,7 @@ async fn seed_obs_fixture(db: &Database, db_path: &std::path::Path, wf: &str) ->
conn.execute(
"UPDATE tasks SET created_at = datetime('now','-60 seconds'),
completed_at = datetime('now','-60 seconds')
WHERE workflow_name = ?1 AND NOT (point_name = ?2 AND task_type = 'seed_step')",
WHERE workflow_name = ?1 AND NOT (point_name = ?2 AND json_extract(tlusty_strategies, '$[0]') = 'seed_step')",
rusqlite::params![wf, p_rescued.model_name()],
)
.unwrap();
@@ -2670,19 +2644,14 @@ async fn test_wf_points_endpoint() {
// ---- 10. 最近尝试 JOINrescued 双尝试取最新(seed_step 救回) ----
let (_, data) = get_points(&app, "/api/workflows/wf_pts/points").await;
let rescued = find_point(&data, &n.rescued);
assert_eq!(rescued["status"], "converged");
assert_eq!(rescued["success_method"], "seed_step");
assert_eq!(rescued["status"], "completed");
assert_eq!(rescued["tlusty_success_method"], "seed_step");
assert_eq!(rescued["attempt_count"], 2, "两次尝试都应计数");
assert_eq!(
rescued["last_task_type"], "seed_step",
"最近尝试应为种子步进"
);
assert_eq!(rescued["seed_point_name"], n.cold, "种子来源应为 cold 点");
assert_eq!(rescued["last_max_relc"], 0.0005);
assert_eq!(rescued["last_elapsed_sec"], 120.0, "真实墙钟耗时应落库");
// pending 点无任何尝试 → last_* 全 null
let pending = find_point(&data, &n.pending);
assert!(pending["last_task_type"].is_null());
assert!(pending["last_completed_at"].is_null());
assert!(pending["last_elapsed_sec"].is_null());
assert_eq!(pending["attempt_count"], 0);
@@ -2782,13 +2751,13 @@ async fn test_point_detail_endpoint() {
let (st, data) = get_detail(&app, &uri).await;
assert_eq!(st, StatusCode::OK);
assert_eq!(data["point"]["name"], n.cold);
assert_eq!(data["point"]["status"], "converged");
assert_eq!(data["point"]["success_method"], "cold_run");
assert_eq!(data["point"]["status"], "completed");
assert_eq!(data["point"]["tlusty_success_method"], "cold_run");
let attempts = data["attempts"].as_array().unwrap();
assert_eq!(attempts.len(), 1);
assert_eq!(attempts[0]["task_type"], "cold_run");
assert!(attempts[0]["seed_point_name"].is_null(), "冷启动无种子来源");
assert_eq!(attempts[0]["status"], "completed");
assert_eq!(data["conv"]["converged"], true, "conv.json 应被解析");
assert_eq!(data["conv"]["result_valid"], true, "conv.json 应被解析");
assert_eq!(data["conv"]["final_max_relc"], 0.000321);
// ---- 3. rescued 点:2 次尝试按时间升序(冷启失败 → 种子步进救回),conv 为 null ----
@@ -2798,10 +2767,9 @@ async fn test_point_detail_endpoint() {
assert_eq!(data["point"]["attempt_count"], 2);
let attempts = data["attempts"].as_array().unwrap();
assert_eq!(attempts.len(), 2);
assert_eq!(attempts[0]["task_type"], "cold_run", "首次应为冷启动");
assert!(attempts[0]["seed_point_name"].is_null(), "首次冷启动无种子");
assert_eq!(attempts[0]["status"], "failed");
assert_eq!(attempts[0]["elapsed_sec"], 120.0, "每次尝试耗时应落库");
assert_eq!(attempts[1]["task_type"], "seed_step", "第二次应为种子步进");
assert_eq!(attempts[1]["status"], "completed");
assert_eq!(
attempts[1]["seed_point_name"], n.cold,
@@ -2878,15 +2846,7 @@ async fn test_list_workflows_inline_stats() {
let p2 = wf_stats_test_params(25000.0, -4.0);
db.upsert_grid_point(&p1, 0, "wf_list_a").await.unwrap();
db.upsert_grid_point(&p2, 0, "wf_list_a").await.unwrap();
dispatch_and_report(
&db,
"wf_list_a",
&p1,
common::models::TaskType::ColdRun,
None,
true,
)
.await;
dispatch_and_report(&db, "wf_list_a", &p1, "cold_run", None, true).await;
// 无 token → 401
let res = app
@@ -2923,7 +2883,7 @@ async fn test_list_workflows_inline_stats() {
let wf_b = list.iter().find(|w| w["name"] == "wf_list_b").unwrap();
assert_eq!(wf_a["stats"]["total"], 2);
assert_eq!(wf_a["stats"]["converged"], 1);
assert_eq!(wf_a["stats"]["completed"], 1);
assert_eq!(wf_a["stats"]["cold_run_converged"], 1);
assert_eq!(wf_a["stats"]["failed"], 0);
assert_eq!(wf_a["stats"]["running"], 0);
@@ -2985,15 +2945,7 @@ async fn test_wf_progress_endpoint() {
db.record_progress_snapshot("wf_prog").await.unwrap(),
"首次记录应写入"
);
dispatch_and_report(
&db,
"wf_prog",
&p1,
common::models::TaskType::ColdRun,
None,
true,
)
.await;
dispatch_and_report(&db, "wf_prog", &p1, "cold_run", None, true).await;
assert!(
db.record_progress_snapshot("wf_prog").await.unwrap(),
"计数变化应写入"
@@ -3008,7 +2960,7 @@ async fn test_wf_progress_endpoint() {
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"UPDATE workflow_progress_snapshots SET ts = datetime('now', '-2 hours') \
WHERE workflow_name = 'wf_prog' AND converged = 0",
WHERE workflow_name = 'wf_prog' AND completed = 0",
[],
)
.unwrap();
@@ -3057,7 +3009,7 @@ async fn test_wf_progress_endpoint() {
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"UPDATE workflow_progress_snapshots SET ts = datetime('now', '-10 days') \
WHERE workflow_name = 'wf_prog' AND converged = 0",
WHERE workflow_name = 'wf_prog' AND completed = 0",
[],
)
.unwrap();
@@ -3171,7 +3123,6 @@ async fn test_duplicate_failure_report_cannot_flip_converged() {
task_id: uuid::Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
task_type: common::models::TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 7200,
workflow_name: Some("wf_flip".to_string()),
@@ -3201,7 +3152,7 @@ async fn test_duplicate_failure_report_cannot_flip_converged() {
params: Some(params.clone()),
node_id: "node-flip".to_string(),
status: common::models::TaskStatus::Completed,
converged: true,
result_valid: true,
max_relc: Some(0.0005),
atmosphere_has_nan: false,
elapsed_sec: 120.0,
@@ -3231,7 +3182,7 @@ async fn test_duplicate_failure_report_cannot_flip_converged() {
.unwrap()
.unwrap()
.0,
"converged"
"completed"
);
// ---- 任务 B:迟到的重复失败报告(涡旋残留任务的典型行为)----
@@ -3239,7 +3190,6 @@ async fn test_duplicate_failure_report_cannot_flip_converged() {
task_id: uuid::Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
task_type: common::models::TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 7200,
workflow_name: Some("wf_flip".to_string()),
@@ -3269,7 +3219,7 @@ async fn test_duplicate_failure_report_cannot_flip_converged() {
params: Some(params.clone()),
node_id: "node-flip".to_string(),
status: common::models::TaskStatus::Failed,
converged: false,
result_valid: false,
max_relc: Some(9.5e5),
atmosphere_has_nan: false,
elapsed_sec: 130.0,
@@ -3305,7 +3255,7 @@ async fn test_duplicate_failure_report_cannot_flip_converged() {
.unwrap()
.unwrap()
.0,
"converged",
"completed",
"迟到失败报告不得翻黑 converged 点"
);
assert!(
@@ -3384,7 +3334,7 @@ async fn test_save_workflow_validates_stage_configs() {
// 1. 双阶段全关 → 400
let both_off = format!(
"{}\ntlusty:\n enabled: false\n policy: skip_converged\n strategies: [cold_run, seed_step]\n\
"{}\ntlusty_stage:\n enabled: false\n policy: skip_converged\n strategies: [cold_run, seed_step]\n\
synspec_stage:\n enabled: false\n policy: skip_converged\n strategies: [standard]\n",
base_yaml
);
@@ -3393,7 +3343,7 @@ async fn test_save_workflow_validates_stage_configs() {
// 2. 启用阶段空策略链 → 400
let empty_chain = format!(
"{}\ntlusty:\n enabled: true\n policy: skip_converged\n strategies: []\n",
"{}\ntlusty_stage:\n enabled: true\n policy: skip_converged\n strategies: []\n",
base_yaml
);
let res = post_save("wf_val_b", &empty_chain).await;
@@ -3405,7 +3355,7 @@ async fn test_save_workflow_validates_stage_configs() {
// 3. 合法:TLUSTY 关 + SYNSPEC 启(设计 §2.2 场景 B:仅更新光谱)→ 200
let syn_only = format!(
"{}\ntlusty:\n enabled: false\n policy: skip_converged\n strategies: [cold_run, seed_step]\n\
"{}\ntlusty_stage:\n enabled: false\n policy: skip_converged\n strategies: [cold_run, seed_step]\n\
synspec_stage:\n enabled: true\n policy: force_recompute\n strategies: [standard]\n",
base_yaml
);
@@ -66,7 +66,7 @@ async fn test_same_workflow_name_preserves_converged() {
let st = db.get_grid_point_status(name, "sdB_cno").await.unwrap();
assert_eq!(
st.unwrap().0,
"converged",
"completed",
"同名工作流:导入的 converged 应被保留,避免重算"
);
println!("✓ 场景1(同名):status=converged,旧结果被保留,不会重算");
@@ -96,7 +96,7 @@ async fn test_different_workflow_name_causes_recompute() {
// imported 分区:converged(种子库有,但不会被 sdB_cno 调度看到)
let st_imp = db.get_grid_point_status(name, "imported").await.unwrap();
assert_eq!(st_imp.unwrap().0, "converged");
assert_eq!(st_imp.unwrap().0, "completed");
// sdB_cno 分区:pending(重新算!看不到 imported 的 converged
let st_real = db.get_grid_point_status(name, "sdB_cno").await.unwrap();
@@ -138,7 +138,7 @@ async fn test_mixed_grid_import_then_init_avoids_recompute() {
.unwrap();
assert_eq!(
st_old.unwrap().0,
"converged",
"completed",
"旧点应保持 converged 不重算"
);
@@ -192,7 +192,7 @@ async fn test_precision_diff_import_then_init_preserves_converged() {
.unwrap();
assert_eq!(
st.unwrap().0,
"converged",
"completed",
"精度一致(g5.0=g5.0)时导入的 converged 必须保留,不重算"
);
println!("✓ 场景4(精度差异命门):g5.0 入库 + initialize_grid → converged 保留,避免重算");