feat(all): 源精度命名体系、工作流可观测台、节点停用管理与白名单归档

核心变更:

  1. GridAxisValue 源精度命名
     - 新增 GridAxisValue 类型,携带 f64 数值 + YAML 源书写文本(Deref 透明兼容算术)
     - config.rs 绕过 serde_yaml 归一化,逐 token 捕获轴值原文(logg: 5.0 → g5.0)
     - runner/executor/scheduler 全链路改用 DB TEXT 列权威 point_name,
       修复 REAL 列回读丢精度导致的 model_name 错配

  2. 工作流执行可观测台
     - 新增 stats/progress/points 三组 API(进度时间序列、经验速率 ETA、
       停滞预警、逐点明细分页、收敛性热力图数据)
     - 新增 workflow_progress_snapshots 表 + tasks/grid_points 耗时列
     - runner 携带 last_iter/worst_depth/n_depths 进 conv.json
     - 前端新增 hash 路由、工作流详情页(概览/网格点/收敛分析三 Tab)、YAML 编辑器

  3. 节点停用/启用管理
     - 新增 disabled 状态 + disable/enable API;停用节点保持心跳但停止分发,
       worker 空闲待命而非退出;移除 revoke API,token 失效统一走重发覆盖;
       移除 host_name 字段

  4. 白名单结果归档
     - 新增 result_filter 模块,只归档有语义产物,丢弃 Tlusty 中间单元(~2MB/模型)
     - executor 原子写入归档 + 200 点 LRU 上限

  5. 历史数据导入
     - sync_seeds 重写为 import_results:经 /admin/import_seed 标记 converged +
       按新版命名迁移产物树

  6. 部署与目录重规划
     - data/results→seeds、data/archive→result + migrate_data_dirs.sh
     - deploy.sh 增强(SSH 复用、Profile、远程 env);Dockerfile 瘦身

  7. 文档同步更新 api/database/architecture/deployment
This commit is contained in:
fmq
2026-07-31 01:34:05 +08:00
parent b91f1e4fa5
commit 1bfa240cb0
73 changed files with 12332 additions and 1608 deletions
+120 -19
View File
@@ -69,12 +69,13 @@ impl SqliteTaskQueue {
created_at DATETIME NOT NULL,
claimed_at DATETIME,
workflow_name TEXT,
claimed_by_node_id TEXT
claimed_by_node_id TEXT,
wave INTEGER NOT NULL DEFAULT 0
)",
[],
)?;
// 兼容旧库:若 task_queue 表已存在但缺少 workflow_name / claimed_by_node_id 列,则补列。
// SQLite 的 ALTER TABLE ADD COLUMN 是在线操作,旧数据该列默认 NULL。
// 兼容旧库:若 task_queue 表已存在但缺少 workflow_name / claimed_by_node_id / wave 列,则补列。
// SQLite 的 ALTER TABLE ADD COLUMN 是在线操作,旧数据该列默认 NULL / 0
// PRAGMA table_info 检测列是否存在以实现幂等 migration。
let has_col = |conn: &rusqlite::Connection, col: &str| -> rusqlite::Result<bool> {
let mut stmt = conn.prepare("PRAGMA table_info(task_queue)")?;
@@ -92,6 +93,12 @@ impl SqliteTaskQueue {
if !has_col(&conn, "claimed_by_node_id")? {
conn.execute("ALTER TABLE task_queue ADD COLUMN claimed_by_node_id TEXT", [])?;
}
if !has_col(&conn, "wave")? {
conn.execute(
"ALTER TABLE task_queue ADD COLUMN wave INTEGER NOT NULL DEFAULT 0",
[],
)?;
}
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_task_queue_status_created ON task_queue(status, created_at)",
[],
@@ -100,6 +107,12 @@ impl SqliteTaskQueue {
"CREATE INDEX IF NOT EXISTS idx_task_queue_workflow ON task_queue(workflow_name)",
[],
)?;
// 覆盖 pop_task 出队排序:status 固定 'pending' 过滤后,按 wave(全局难度优先)
// → created_at(同 wave 内 FIFO)。
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_task_queue_pop ON task_queue(status, wave, created_at)",
[],
)?;
Ok(pool)
})
.await??;
@@ -112,14 +125,15 @@ impl SqliteTaskQueue {
let payload = serde_json::to_string(task)?;
let task_id_str = task.task_id.to_string();
let workflow_name = task.workflow_name.clone();
let wave = task.wave;
let pool = self.pool.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let conn = pool.get().map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?;
conn.execute(
"INSERT OR REPLACE INTO task_queue (task_id, payload, status, created_at, workflow_name)
VALUES (?1, ?2, 'pending', datetime('now'), ?3)",
params![task_id_str, payload, workflow_name],
"INSERT OR REPLACE INTO task_queue (task_id, payload, status, created_at, workflow_name, wave)
VALUES (?1, ?2, 'pending', datetime('now'), ?3, ?4)",
params![task_id_str, payload, workflow_name, wave],
)?;
Ok(())
})
@@ -139,8 +153,14 @@ impl SqliteTaskQueue {
let tx_res = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate);
match tx_res {
Ok(tx) => {
// 出队排序:wave ASC → created_at ASC(不按 workflow_name 分组)。
// - wave ASC:全局低难度 wave 优先,恢复"先易后难积累种子"的设计意图。
// 各工作流的低难度点会先于任何工作流的高难度点被消化,避免某个工作流
// 在高难度 wave 上饿死另一个工作流的低难度点。
// - created_at ASC:同 wave 内严格 FIFO。调度器按工作流批量推入时,
// 多个工作流的任务 created_at 天然交错,故同 wave 内不会单工作流独占。
let mut stmt = tx.prepare(
"SELECT task_id, payload FROM task_queue WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1"
"SELECT task_id, payload FROM task_queue WHERE status = 'pending' ORDER BY wave ASC, created_at ASC LIMIT 1"
)?;
let row = stmt.query_row([], |row| {
@@ -344,17 +364,18 @@ mod tests {
task_id,
point_name: "t35000_g5.5_he-1_c-2_n-2_o-2".to_string(),
params: GridPointParams {
teff: 35000.0,
logg: 5.5,
loghe: -1.0,
logc: -2.0,
logn: -2.0,
logo: -2.0,
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
},
task_type: TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 3600,
workflow_name: Some("test_wf".to_string()),
wave: 0,
};
queue.push_task(&task).await.unwrap();
@@ -387,12 +408,12 @@ mod tests {
let task_id = Uuid::new_v4();
let params = GridPointParams {
teff: 35000.0,
logg: 5.5,
loghe: -1.0,
logc: -2.0,
logn: -2.0,
logo: -2.0,
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let task = TaskSpec {
task_id,
@@ -402,6 +423,7 @@ mod tests {
seed_point_name: None,
timeout_sec: 60,
workflow_name: None,
wave: 0,
};
queue.push_task(&task).await.unwrap();
@@ -434,4 +456,83 @@ mod tests {
.unwrap();
assert!(claim_after.is_none());
}
/// 辅助:构造一个最小 TaskSpec,方便下面两个排序测试。
fn mk_task(wf: &str, wave: i32, point_name: &str) -> TaskSpec {
TaskSpec {
task_id: Uuid::new_v4(),
point_name: point_name.to_string(),
params: GridPointParams {
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
},
task_type: TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 60,
workflow_name: Some(wf.to_string()),
wave,
}
}
/// 跨工作流 wave 优先级:wf_a 推多个高 wave 任务,wf_b 推一个低 wave 任务,
/// 验证 wf_b 的低难度任务不会被 wf_a 的大量高难度任务饿死。
///
/// 排序为 wave ASC, created_at ASC(不按 workflow_name 分组):
/// wf_b 的 wave=0 任务优先于 wf_a 的所有 wave=9 任务出队,
/// 即使 wf_a 的任务 created_at 更早、数量更多。
/// 这保证了"全局先易后难"——任一工作流的低难度点优先于其他工作流的高难度点。
#[tokio::test]
async fn test_pop_cross_workflow_wave_priority() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("pop_fair.db");
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy())
.await
.unwrap();
// wf_a 推 3 个高难度任务(wave=9created_at 更早)
for i in 0..3 {
queue
.push_task(&mk_task("wf_a", 9, &format!("a{i}")))
.await
.unwrap();
}
// wf_b 推 1 个低难度任务(wave=0created_at 最晚)
queue.push_task(&mk_task("wf_b", 0, "b0")).await.unwrap();
// 第 1 次 popwf_b 的 wave=0(全局最低 wave 优先),而非 wf_a 的先入任务
let p1 = queue.pop_task("n1").await.unwrap().unwrap();
assert_eq!(p1.workflow_name.as_deref(), Some("wf_b"));
assert_eq!(p1.point_name, "b0");
// 之后才轮到 wf_a 的 wave=9 任务(FIFO 顺序)
for i in 0..3 {
let p = queue.pop_task("n2").await.unwrap().unwrap();
assert_eq!(p.workflow_name.as_deref(), Some("wf_a"));
assert_eq!(p.point_name, format!("a{i}"));
}
}
/// 出队同工作流内按 wave ASC:低难度 wave 优先,即使它 created_at 更晚。
#[tokio::test]
async fn test_pop_wave_priority_within_workflow() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("pop_wave.db");
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy())
.await
.unwrap();
// 先推 wave=5created_at 早),再推 wave=1created_at 晚)
queue.push_task(&mk_task("wf_x", 5, "hard")).await.unwrap();
queue.push_task(&mk_task("wf_x", 1, "easy")).await.unwrap();
// pop 应先拿到 wave=1 的 easy(若纯 FIFO 会先拿到先入的 hard)
let p1 = queue.pop_task("n1").await.unwrap().unwrap();
assert_eq!(p1.point_name, "easy", "低 wave 优先出队");
let p2 = queue.pop_task("n2").await.unwrap().unwrap();
assert_eq!(p2.point_name, "hard");
}
}