feat(all): 任务引擎双阶段解耦、僵尸涡旋修复、动态 CPU 配额与前端详情页重构
将 TLUSTY/SYNSPEC 拆为各自独立的 enabled/policy/strategies 阶段,
以策略链自动弹栈取代单级 seed_step 布尔回退;定向修复 2026-08-02
僵尸任务涡旋事故;新增节点并发配额热调;前端详情页从 1412 行巨型
视图拆为薄控制器 + detail 子模块,并补齐工具层与单测。
引擎与调度(task_engine_decoupling_design.md)
- models.rs: 新增 StagePolicy / EngineStageConfig / TaskSpec 阶段字段、
normalize_compat() 校正旧版在途消息策略链、failed_stage 归因
- scheduler.rs: resolve_dispatchable_chain 派发门控、
trigger_strategy_fallback 按 failed_stage 精确弹栈;启动期
force_recompute/skip_converged(默认)/skip_failed 三策略
- db.rs: tasks 表 +7 列持久化阶段配置;终态守卫
(mark_grid_point_running 仅 pending/queued→running;
record_task_report 拒绝迟到失败翻黑 converged);策略弹栈快照
僵尸涡旋修复(runbook-20260802-zombie-vortex-fix.md)
- 全链路跨库活性交叉校验:派发/claim/孤儿回收/回退统一查 MQ 队列活性,
活则放行、死则清僵尸,结构性消除"每点重复派发"
- stop/重启卫生:清队列同步 delete_tasks_by_ids,杜绝遗留 pending 行
- report_task: 幂等吸收 + 409 区分迟到冗余结果,仅 state_changed 时回退
- MQ: NULL workflow_name 回填 __legacy__、requeue 后迟到上报被 403 竞态修复
动态 CPU 配额(dynamic_cpu_slots_design.md)
- admin.rs: POST /admin/nodes/:id/quota(Option<Option<i32>> 区分
缺字段/显式 null);nodes 表 +admin_max_slots
- worker.rs: effective_max_slots = min(admin, physical),心跳下发原子生效
科学产物保全(tlusty_result_artifacts.md)
- runner.rs: SYNSPEC 启动前快照 fort.12/fort.14 → .bfac/.emflux 防覆盖
- 半失败点(大气收敛+光谱失败)改判 Failed 并写入 note;仅 SYNSPEC
场景不再恒判失败;撤销归档 LRU 200 上限改为永久保留
- executor.rs: 透传 synspec_params 数值参数(此前固定 None)
前端(dashboard/)
- workflowDetail.js 1412→328 行,拆出 views/detail/{ctx,overview,
pointsTable,parSets,pointPanel}.js,AbortController 治理监听/请求生命周期
- 删除 wfActions.js,新增 wfEnginePanel.js(双阶段三维配置编辑面板)
- 新增 utils/{errors,format,icons,polling,yamlStage}.js 纯函数模块
- 路由级动态 import 代码分割;节点配额三点菜单 + Modal 管理
- 首次引入 node:test 单测(format/polling/yamlStage/psCache,644 行)
- 系统性补齐 a11y:skip-link、ARIA、Tab 键盘漫游、toast 关闭、退出动画
文档与工具
- 新增 6 篇设计/调研:引擎解耦、动态配额、涡旋 runbook、
光谱正确性分析、收敛判断、产物归档
- PIPELINE/design/api/database 等协同重写为分布式 C/S 架构口径
- scripts/fetch_results.sh 跨节点产物备份;import_results 按 cno 升序导入
- workflows/sdB_cno.yaml: 新增 tlusty/synspec_stage 配置块,修正 wstart 笔误
This commit is contained in:
+353
-32
@@ -99,6 +99,14 @@ impl SqliteTaskQueue {
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
// H1 修复:历史遗留行(旧版在途任务)的 workflow_name 为 NULL。回填为
|
||||
// '__legacy__'(与主库 grid_points 迁移口径一致),否则新节点领用/上报时
|
||||
// claim/report 无法定向更新 '__legacy__' 网格点,旧任务结算后点永久卡死。
|
||||
// 幂等:新 push 的行恒带 workflow_name,NULL 行只会出现在迁移遗留,重复执行无害。
|
||||
conn.execute(
|
||||
"UPDATE task_queue SET workflow_name = '__legacy__' WHERE workflow_name IS NULL",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_task_queue_status_created ON task_queue(status, created_at)",
|
||||
[],
|
||||
@@ -204,6 +212,15 @@ impl SqliteTaskQueue {
|
||||
}
|
||||
};
|
||||
|
||||
// 旧版兼容归一化(见 models.rs::normalize_compat 文档):
|
||||
// 旧 MQ 在途消息可能只含 task_type、无 tlusty_config 字段。serde default
|
||||
// 会把 tlusty_config.strategies 填为完整默认链 [cold_run, seed_step],
|
||||
// 导致旧 SeedStep 热启动消息首项被误判为 cold_run。normalize_compat
|
||||
// 据 task_type 校正首项策略。在 pop 出队后立即调用,保证节点拿到的是
|
||||
// 语义正确的策略链。对新版消息(已显式设置 tlusty_config)无副作用。
|
||||
let mut task = task;
|
||||
task.normalize_compat();
|
||||
|
||||
// 记录任务归属:claim 时写入领用方 node_id,供 report 阶段校验,
|
||||
// 杜绝「节点 A 领用、节点 B 上报」的跨节点伪造结果投毒。
|
||||
tx.execute(
|
||||
@@ -270,10 +287,15 @@ impl SqliteTaskQueue {
|
||||
let conn = pool
|
||||
.get()
|
||||
.map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?;
|
||||
// 仅 claimed 态(尚未被 report 清理)且归属匹配才算有效领用
|
||||
// claimed 或 pending 态 + 归属匹配才算有效领用。status 含 'pending' 的缘由:
|
||||
// `requeue_stale_tasks` 会把超时 claimed 行打回 pending(保留 claimed_by_node_id,
|
||||
// 仅清 claimed_at)。若任务实际仍在运行、只是耗时接近 timeout,原节点(仍持
|
||||
// 归属)迟到的上报若被拒绝 → 403 → 昂贵计算结果被静默丢弃、重投重算(代码注释
|
||||
// 记录的竞态,旧实现靠 stale_sec ≥ 3×timeout 缓冲而非根治)。放行 pending 态后
|
||||
// 该竞态消除;重新领用会覆盖 claimed_by_node_id,故跨节点伪造上报仍被拦截。
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT payload FROM task_queue
|
||||
WHERE task_id = ?1 AND claimed_by_node_id = ?2 AND status = 'claimed' LIMIT 1",
|
||||
WHERE task_id = ?1 AND claimed_by_node_id = ?2 AND status IN ('claimed', 'pending') LIMIT 1",
|
||||
)?;
|
||||
let row = stmt.query_row(params![task_id, claimant], |r| r.get::<_, String>(0));
|
||||
match row {
|
||||
@@ -345,7 +367,7 @@ impl SqliteTaskQueue {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 仅清理指定工作流的**未被领取**的排队任务。
|
||||
/// 仅清理指定工作流的**未被领取**的排队任务,并返回被删行的 task_id 列表。
|
||||
///
|
||||
/// 用于 stop_workflow / initialize_grid 按工作流隔离清理,避免在多工作流场景下
|
||||
/// 误清其他工作流的任务。
|
||||
@@ -354,21 +376,55 @@ impl SqliteTaskQueue {
|
||||
/// 必须保留领用凭证,否则节点上报时 verify_task_claim 找不到记录 → 403 →
|
||||
/// 计算结果丢失、网格点永久卡在 running(#6 修复)。
|
||||
/// `claimed` 行会在上报成功后由 remove_task 自然清理。
|
||||
pub async fn clear_queue_by_workflow(&self, workflow_name: &str) -> Result<()> {
|
||||
///
|
||||
/// 返回被删 task_id 供调用方同步清理主库 `tasks` 审计表对应行(2026-08-02 僵尸
|
||||
/// 任务事故修复):此前只删队列行而遗留 tasks 表 pending 行,成为孤儿回收器误判
|
||||
/// 与重复派发涡旋的燃料。调用方拿到 ids 后应调 Database::delete_tasks_by_ids。
|
||||
pub async fn clear_queue_by_workflow(&self, workflow_name: &str) -> Result<Vec<String>> {
|
||||
let pool = self.pool.clone();
|
||||
let wf_owned = workflow_name.to_string();
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let ids = tokio::task::spawn_blocking(move || -> Result<Vec<String>> {
|
||||
let conn = pool
|
||||
.get()
|
||||
.map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?;
|
||||
conn.execute(
|
||||
"DELETE FROM task_queue WHERE workflow_name = ?1 AND status = 'pending'",
|
||||
params![wf_owned],
|
||||
// DELETE...RETURNING 须 prepare + query_map(execute 不返回结果集)。
|
||||
let mut stmt = conn.prepare(
|
||||
"DELETE FROM task_queue WHERE workflow_name = ?1 AND status = 'pending' RETURNING task_id",
|
||||
)?;
|
||||
Ok(())
|
||||
let rows = stmt.query_map(params![wf_owned], |row| row.get::<_, String>(0))?;
|
||||
let mut ids = Vec::new();
|
||||
for r in rows {
|
||||
if let Ok(id) = r {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
})
|
||||
.await??;
|
||||
Ok(())
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// 判断指定 task_id 是否仍有**活**队列行(pending 或 claimed)。
|
||||
///
|
||||
/// 供派发去重与孤儿回收做跨库活性交叉校验:主库 tasks 表的 pending 行可能是
|
||||
/// 从未入队/已被清理的僵尸记录,唯有 task_queue 中存在 pending/claimed 行才证明
|
||||
/// 该任务真在途(排队中或已被节点领用)。
|
||||
pub async fn task_row_exists(&self, task_id: &str) -> Result<bool> {
|
||||
let pool = self.pool.clone();
|
||||
let id_owned = task_id.to_string();
|
||||
let exists = tokio::task::spawn_blocking(move || -> Result<bool> {
|
||||
let conn = pool
|
||||
.get()
|
||||
.map_err(|e| anyhow::anyhow!("Queue DB pool error: {}", e))?;
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM task_queue WHERE task_id = ?1 AND status IN ('pending', 'claimed')",
|
||||
params![id_owned],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
})
|
||||
.await??;
|
||||
Ok(exists)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,6 +434,53 @@ mod tests {
|
||||
use common::models::{GridPointParams, TaskType};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// H1 修复:队列库迁移必须把历史遗留的 NULL workflow_name 行回填为 '__legacy__'
|
||||
/// (与主库 grid_points 迁移口径一致)。否则新节点领用/上报时 claim/report 无法
|
||||
/// 定向更新 '__legacy__' 网格点,旧任务结算后网格点永久卡死。
|
||||
#[tokio::test]
|
||||
async fn test_queue_migration_backfills_null_workflow_name() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("legacy_mig.db");
|
||||
|
||||
// 模拟旧版队列库:建表 + 插入一条 workflow_name 为 NULL 的遗留 pending 行。
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE task_queue (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
claimed_at DATETIME,
|
||||
workflow_name TEXT,
|
||||
claimed_by_node_id TEXT,
|
||||
wave INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO task_queue (task_id, payload, status, created_at, wave)
|
||||
VALUES ('legacy-1', '{}', 'pending', datetime('now'), 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 打开队列库(触发迁移:补列检查 + NULL workflow_name 回填)。
|
||||
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy()).await.unwrap();
|
||||
|
||||
let wf: String = {
|
||||
let conn = queue.pool.get().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT workflow_name FROM task_queue WHERE task_id = 'legacy-1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(wf, "__legacy__", "NULL workflow_name 应回填为 __legacy__");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sqlite_task_queue_operations() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
@@ -393,18 +496,19 @@ mod tests {
|
||||
task_id,
|
||||
point_name: "t35000_g5.5_he-1_c-2_n-2_o-2".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(),
|
||||
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,
|
||||
..Default::default()
|
||||
};
|
||||
queue.push_task(&task).await.unwrap();
|
||||
|
||||
@@ -437,12 +541,12 @@ mod tests {
|
||||
|
||||
let task_id = Uuid::new_v4();
|
||||
let 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(),
|
||||
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,
|
||||
@@ -453,6 +557,7 @@ mod tests {
|
||||
timeout_sec: 60,
|
||||
workflow_name: None,
|
||||
wave: 0,
|
||||
..Default::default()
|
||||
};
|
||||
queue.push_task(&task).await.unwrap();
|
||||
|
||||
@@ -486,24 +591,111 @@ mod tests {
|
||||
assert!(claim_after.is_none());
|
||||
}
|
||||
|
||||
/// requeue 竞态回归(修复):任务被 `requeue_stale_tasks` 打回 pending 后(claimed_at
|
||||
/// 清零但 claimed_by_node_id 保留),原节点(仍持归属)迟到的上报必须被放行——否则
|
||||
/// 耗时接近 timeout 的任务会因 403 被静默丢弃计算结果、重投重算(代码注释记录的竞态,
|
||||
/// 旧实现只靠 stale_sec ≥ 3×timeout 缓冲)。重新领用后归属被覆盖,原节点校验失败。
|
||||
#[tokio::test]
|
||||
async fn test_verify_task_claim_accepts_requeued_original_claimant() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("claim_requeue.db");
|
||||
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let task_id = Uuid::new_v4();
|
||||
let 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(),
|
||||
};
|
||||
let task = TaskSpec {
|
||||
task_id,
|
||||
point_name: params.model_name(),
|
||||
params: params.clone(),
|
||||
task_type: TaskType::ColdRun,
|
||||
seed_point_name: None,
|
||||
timeout_sec: 7200,
|
||||
workflow_name: Some("wf_rq".to_string()),
|
||||
wave: 0,
|
||||
..Default::default()
|
||||
};
|
||||
queue.push_task(&task).await.unwrap();
|
||||
|
||||
// node-A 领用 → claimed。
|
||||
assert!(queue.pop_task("node-A").await.unwrap().is_some());
|
||||
|
||||
// 模拟 requeue_stale_tasks(stale=0):超时 claimed 行打回 pending(claimed_by_node_id 保留)。
|
||||
{
|
||||
let pool = queue.pool.clone();
|
||||
let tid = task_id.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = pool.get().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE task_queue SET status = 'pending', claimed_at = NULL WHERE task_id = ?1",
|
||||
rusqlite::params![tid],
|
||||
)
|
||||
.unwrap();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 原节点 node-A 迟到的上报:pending + 归属匹配 → 放行(修复后不再 403)。
|
||||
let claim_a = queue
|
||||
.verify_task_claim(&task_id.to_string(), "node-A")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
claim_a.map(|(p, w)| (p, w)),
|
||||
Some((params.model_name(), Some("wf_rq".to_string()))),
|
||||
"requeue 后原节点仍持归属,应放行"
|
||||
);
|
||||
|
||||
// 其它节点仍被拒(归属不变)。
|
||||
let claim_b = queue
|
||||
.verify_task_claim(&task_id.to_string(), "node-B")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(claim_b.is_none(), "非归属节点仍应被拒");
|
||||
|
||||
// 重新领用后归属覆盖:node-B claim 后,node-A 校验失败、node-B 成功。
|
||||
let popped_b = queue.pop_task("node-B").await.unwrap();
|
||||
assert!(popped_b.is_some(), "pending 任务可被 node-B 重新领用");
|
||||
let claim_a2 = queue
|
||||
.verify_task_claim(&task_id.to_string(), "node-A")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(claim_a2.is_none(), "归属已移交 node-B,node-A 迟报应被拒");
|
||||
let claim_b2 = queue
|
||||
.verify_task_claim(&task_id.to_string(), "node-B")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(claim_b2.is_some(), "node-B 现持归属,应放行");
|
||||
}
|
||||
|
||||
/// 辅助:构造一个最小 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(),
|
||||
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,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,10 +777,7 @@ mod tests {
|
||||
|
||||
// 第一次 pop:应跳过 poison(标记为 dead_letter)并返回合法任务
|
||||
let popped = queue.pop_task("n1").await.unwrap();
|
||||
assert!(
|
||||
popped.is_some(),
|
||||
"毒消息不应阻塞合法任务出队"
|
||||
);
|
||||
assert!(popped.is_some(), "毒消息不应阻塞合法任务出队");
|
||||
let task = popped.unwrap();
|
||||
assert_eq!(task.point_name, "ok_point");
|
||||
|
||||
@@ -614,6 +803,64 @@ mod tests {
|
||||
assert!(queue.pop_task("n1").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
/// 旧版 MQ 在途消息兼容(二次审查 critical 修复验证):
|
||||
/// 旧消息只含 task_type=seed_step、无 tlusty_config 字段。serde default 会把
|
||||
/// strategies 填为 [cold_run, seed_step],首项 cold_run 与 task_type 不符。
|
||||
/// pop_task 出队时须调用 normalize_compat() 把首项校正为 seed_step,
|
||||
/// 否则旧热启动消息会被节点误当冷启动执行。
|
||||
#[tokio::test]
|
||||
async fn test_pop_normalizes_legacy_seed_step_message() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("legacy.db");
|
||||
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 直接写一行旧版 payload(仅 task_type,无 tlusty_config/synspec_config 字段)。
|
||||
let legacy_task_id = uuid::Uuid::new_v4();
|
||||
let legacy_payload = serde_json::json!({
|
||||
"task_id": legacy_task_id.to_string(),
|
||||
"point_name": "t35000_g5.5_he-1_c-2_n-2_o-2",
|
||||
"params": {"teff": 35000.0, "logg": 5.5, "loghe": -1.0, "logc": -2.0, "logn": -2.0, "logo": -2.0},
|
||||
"task_type": "seed_step",
|
||||
"seed_point_name": "neighbor_seed",
|
||||
"timeout_sec": 7200,
|
||||
"workflow_name": "wf_legacy",
|
||||
"wave": 0
|
||||
}).to_string();
|
||||
{
|
||||
let pool = queue.pool.clone();
|
||||
let payload_clone = legacy_payload.clone();
|
||||
let tid = legacy_task_id.to_string();
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let conn = pool.get().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO task_queue (task_id, payload, status, created_at, wave)
|
||||
VALUES (?1, ?2, 'pending', datetime('now'), 0)",
|
||||
rusqlite::params![tid, payload_clone],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let popped = queue.pop_task("n1").await.unwrap();
|
||||
let task = popped.expect("旧版消息应能正常出队");
|
||||
// normalize_compat 应把 strategies 校正为 [seed_step](与 task_type 一致)。
|
||||
assert_eq!(
|
||||
task.tlusty_config.strategies,
|
||||
vec!["seed_step".to_string()],
|
||||
"旧 seed_step 消息经 normalize_compat 后首项应为 seed_step"
|
||||
);
|
||||
assert_eq!(
|
||||
task.tlusty_config.current_strategy("cold_run"),
|
||||
"seed_step",
|
||||
"current_strategy 应为 seed_step(而非默认链的 cold_run)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pop_wave_priority_within_workflow() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
@@ -632,4 +879,78 @@ mod tests {
|
||||
let p2 = queue.pop_task("n2").await.unwrap().unwrap();
|
||||
assert_eq!(p2.point_name, "hard");
|
||||
}
|
||||
|
||||
/// 回归测试(2026-08-02 僵尸任务事故):clear_queue_by_workflow 必须返回被删
|
||||
/// pending 行的 task_id(供调用方同步清理主库 tasks 审计行),且保留 claimed 行、
|
||||
/// 不波及他工作流。
|
||||
#[tokio::test]
|
||||
async fn test_clear_queue_by_workflow_returns_deleted_ids() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let queue = SqliteTaskQueue::new(&temp_dir.path().join("clear_ret.db").to_string_lossy())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// wf_a 两个任务:一个被领用(claimed),一个排队(pending)
|
||||
queue
|
||||
.push_task(&mk_task("wf_a", 0, "a_claimed"))
|
||||
.await
|
||||
.unwrap();
|
||||
queue
|
||||
.push_task(&mk_task("wf_a", 0, "a_pending"))
|
||||
.await
|
||||
.unwrap();
|
||||
// wf_b 一个排队任务(不应被 wf_a 的清理波及)
|
||||
queue
|
||||
.push_task(&mk_task("wf_b", 0, "b_pending"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let claimed = queue.pop_task("node-a").await.unwrap().unwrap();
|
||||
assert_eq!(claimed.point_name, "a_claimed");
|
||||
|
||||
let deleted = queue.clear_queue_by_workflow("wf_a").await.unwrap();
|
||||
assert_eq!(deleted.len(), 1, "仅 a_pending 一行被删");
|
||||
|
||||
// 被删行对应的点名为 a_pending:用剩余可 pop 任务反证(wf_a 已无 pending 行)
|
||||
let next = queue.pop_task("node-b").await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
next.workflow_name,
|
||||
Some("wf_b".to_string()),
|
||||
"wf_b 行应完好"
|
||||
);
|
||||
|
||||
// claimed 行保留:领用凭证仍在,原节点归属校验通过(#6 设计不动)
|
||||
let verify = queue
|
||||
.verify_task_claim(&claimed.task_id.to_string(), "node-a")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(verify.is_some(), "claimed 行必须保留");
|
||||
|
||||
// 被删的 id 不再有任何活行
|
||||
for id in &deleted {
|
||||
assert!(!queue.task_row_exists(id).await.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
/// task_row_exists 三态:pending/claimed 视为活,remove 后为死。
|
||||
#[tokio::test]
|
||||
async fn test_task_row_exists_liveness() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let queue = SqliteTaskQueue::new(&temp_dir.path().join("exists.db").to_string_lossy())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let task = mk_task("wf_e", 0, "point_e");
|
||||
let id = task.task_id.to_string();
|
||||
assert!(!queue.task_row_exists(&id).await.unwrap(), "未入队为死");
|
||||
|
||||
queue.push_task(&task).await.unwrap();
|
||||
assert!(queue.task_row_exists(&id).await.unwrap(), "pending 为活");
|
||||
|
||||
queue.pop_task("node-e").await.unwrap().unwrap();
|
||||
assert!(queue.task_row_exists(&id).await.unwrap(), "claimed 仍为活");
|
||||
|
||||
queue.remove_task(&id).await.unwrap();
|
||||
assert!(!queue.task_row_exists(&id).await.unwrap(), "删除后为死");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user