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
+86
View File
@@ -0,0 +1,86 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
DCTS is a Rust-based distributed computing scheduler for stellar atmosphere modeling. It discretizes a multi-dimensional parameter grid (Teff, log g, log He, log C, log N, log O) into independent compute points, runs TLUSTY (atmosphere) + SYNSPEC (synthetic spectrum) physics binaries on distributed worker nodes, and orchestrates convergence via seed passing and divergence fallback. Fully documented (in Chinese) under `docs/`.
## Commands
```bash
# Build / check
cargo build --release # binaries: server, node, import_results
cargo check --workspace --all-targets
# Tests (per crate)
cargo test --workspace
cargo test -p common # physics/input/conv-check unit tests
cargo test -p mq # SQLite queue concurrency + requeue tests
cargo test -p server # axum integration tests (api_tests.rs, wf_migration_isolation.rs)
cargo test -p server --test api_tests # single integration test file
cargo test -p server --test api_tests test_name # single test by name filter
# Lint / format (must be clean before commit — see contributing.md)
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
# Dashboard (Vite + native ESM, Node built-in test runner)
cd dashboard && npm install
npm run dev # dev server
npm run build # outputs dashboard/dist (served by server on /)
npm test # node --test "test/*.test.js"
```
## Runtime
```bash
./target/release/server --workflow workflows/sdB_cno.yaml --port 8090
./target/release/node # worker; reads .env, DCTS_SERVER_URL
```
All config is via environment variables prefixed `DCTS_` (see `.env.example`): `DCTS_ADMIN_TOKEN`, `DCTS_NODE_ID`, `DCTS_SERVER_URL`, `DCTS_MAX_SLOTS`, `DCTS_HEARTBEAT_SEC`, `DCTS_DB_PATH`, `DCTS_QUEUE_DB_PATH`, `DCTS_SEEDS_DIR`, etc. `dotenvy` loads `.env`. With `DCTS_AUTH_DISABLE=1` auth is skipped (local debug only).
## Architecture
Workspace crates (`Cargo.toml`): `common` (lib), `server` (bin), `node` (bin), `mq` (lib), `tools/import_results` (bin), plus `dashboard/` (web UI).
### Master-Worker topology (pull-based)
- **`server`** — Axum HTTP service. `main.rs` builds the router + background loops (offline detection, stale-task requeue). `db/` module (SQLite + r2d2): `db/mod.rs` holds the `Database` struct, connection/migration infra, shared types & the consolidated unit tests; `db/grid.rs`/`db/nodes.rs`/`db/tasks.rs`/`db/seeds.rs`/`db/workflows.rs`/`db/snapshots.rs` hold the per-domain `impl Database` methods. `scheduler.rs` expands the grid and pushes pending points into the MQ queue. `api/` submodules: `node.rs` (register/heartbeat), `task.rs` (claim/report), `seed.rs` (.7 download), `data.rs` (binary/runtime deps), `workflow.rs` (CRUD + start/stop), `status.rs`, `admin.rs`, `auth.rs`, `rate_limit.rs`.
- **`node`** — stateless worker daemon. `worker.rs` is the poll loop (heartbeat + claim + concurrent slot pool). `executor.rs` runs the physics chain in a per-task sandbox. `reporter.rs` uploads results + `.7` seed via multipart. Bootstrap pulls missing runtime deps from master.
- **`mq`** — `SqliteTaskQueue` (WAL mode): transactional Push/Claim/Report/Stale-Requeue. Claim is atomic so one task goes to one worker.
- **`common`** — physics engine: `config.rs` (YAML parsing), `gen_input5.rs`/`fort55_writer.rs`/`nst_writer.rs` (TLUSTY input streams), `conv_check.rs` (parses fort.6 log, judges convergence), `runner.rs` (async subprocess with timeout), `seed_finder.rs` (nearest-neighbor seed matching), `models.rs` (shared types/enums), `embedded.rs` (bootstrap), `logging.rs`.
### Task scheduling — deliberate pull model
The scheduler only pushes pending grid points into the MQ queue; it does NOT decide which node gets a task. Workers actively claim work whenever `active_slots < max_slots` and stop claiming when full — this achieves natural load balancing in steady state. Dequeue order is `wave ASC, created_at ASC` (low CNO hard-sum first, then FIFO). Do not "improve" this to server-side load distribution unless the sparse-state tradeoffs are explicitly accepted (see `docs/architecture.md §5`).
### Task lifecycle
`pending → queued → running → completed / failed`. Statuses map to `GridPointStatus` in `common/src/models.rs` (`completed` supersedes the old `converged` alias, still parsed). Running tasks that exceed the stale timeout (~1800s) are requeued back to `pending`.
## Physics pipeline (TLUSTY/SYNSPEC)
Each grid point runs a **strategy chain** (`tlusty_strategies` in the workflow YAML, e.g. `["cold_run", "seed_step"]`):
- **Cold run** (`default_cold_chain` in `common/src/runner.rs`): `lte` (grey LTE guess) → `nc` (NLTE continuum, `ilvlin=0, niter=10`) → `nl` (NLTE full lines, `ilvlin=100`, must converge) → `synspec`.
- **Seed step** (`default_seed_chain`): skip the grey LTE stage, hot-start from a nearby converged atmosphere `.7` as `fort.8`: `seed_nc``nl``synspec`.
On `nl` non-convergence the server pops the chain head and retries with the next strategy (`trigger_strategy_fallback`). `seed_step` requires a resolvable neighboring seed before dispatch; if none exists the grid point stays pending and self-heals once a seed appears. Only `failed` tasks with an actual state transition trigger fallback (a 2026-08-02 fix prevents duplicate failure reports from re-triggering). Nodes are strategy-unaware — they only execute `strategies[0]` plus the injected `seed_point_name`.
### Seed finding (`common/src/seed_finder.rs`)
The seed pool is a global shared resource (seeds table + in-memory cache, cross-workflow). `exact_family` buckets by quantized `(teff/5000, logg, loghe)`; within a family, candidates are ranked by **directed CNO distance** — increasing metal abundance (target richer) is penalized 4×, decreasing (target poorer) 1×, because adding metals destabilizes NLTE radiation equilibrium (historically 311% success vs 4254% for metal-poor direction). Global fallback uses `d = Δteff/5000 + Δlogg×2 + Δloghe×0.5 + Δcno×0.1` with `d ≤ 3.0`.
## Web dashboard (`dashboard/`)
Native ESM modules (no framework), Vite build. `main.js`/`router.js`/`state.js`/`api.js` are the shell; `utils/` (format, polling, yamlStage, errors) and `components/`, `views/` (home, workflowDetail, detail/ for per-point panels). Tests are pure Node scripts in `dashboard/test/` run via `node --test`. State is centralized in `state.js` with polling from `utils/polling.js`.
## Conventions & gotchas
- **Convergence tuning is empirical** — parameters like `niter=10` for `nc` are the result of documented measurements (see `workflows/sdB_cno.yaml` comments and `docs/spectrum_correctness_analysis.md`). Don't change them casually; validate against the guide before altering.
- **Update docs when you change contracts**: API changes → `docs/api.md`; DB schema → `docs/database.md`; physics/scheduling → `docs/design.md`, `docs/task_engine_decoupling_design.md`. The repo treats these as living specs.
- **Keep `serde_yaml` pinned to 0.9.34** (the upstream crate is archived; the workspace pins the final stable version deliberately).
- **DB migrations** live in `crates/server/src/migrations.rs`; old SQLite DBs are migrated seamlessly at startup (see `docs/database_refactor_design.md`).
- Commit messages follow Conventional Commits with scopes `common`/`server`/`node`/`mq`/`dashboard`/`all` (see `docs/contributing.md`).
+167 -33
View File
@@ -1,11 +1,13 @@
use crate::models::{EngineStageConfig, GridAxisValue};
use crate::models::{GridAxisValue, PhaseConfig};
use anyhow::{Context, Result};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::sync::OnceLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GridAxesConfig {
pub teff: Vec<GridAxisValue>,
pub logg: Vec<GridAxisValue>,
@@ -136,24 +138,77 @@ impl GridConfig {
if let Some(raw_axes) = parse_grid_axes_raw(yaml) {
cfg.grid = raw_axes;
}
cfg.validate()?;
Ok(cfg)
}
/// 配置合法性校验(审查修复 #M2/#N3):把配置错误从运行时逐点失败提前到加载时,
/// 避免笔误(如 chmax: 0、logc: 400)静默产出错误输入文件、浪费算力。
///
/// 校验项:
/// - `tlusty_chain` 中每个 ChainStep 的 `chmax` 若为 Some,必须 > 0conv_check.rs
/// 的守卫会把 chmax <= 0 判为非法、整 stage 判发散;runner.rs 的 unwrap_or(0.001)
/// 只在字段缺失时兜底,显式写 0 不会触发)。
/// - grid 六轴数值范围合理性(teff > 0;丰度 logc/logn/logo 物理上 ∈ [-20, 10]
/// 超出几乎必为笔误,如 logc: 400 会让 gen_input5 的 10^logx 溢出为 Inf 污染输入文件)。
fn validate(&self) -> Result<()> {
for (i, step) in self.tlusty_chain.iter().enumerate() {
if let Some(chmax) = step.chmax {
// 用 partial_cmp 显式判断:`!(chmax > 0.0)` 对 NaN 为 trueNaN 比较恒 false),
// 应拒绝 NaN;改写为 `chmax <= 0.0` 会漏掉 NaNNaN<=0 也是 false),故不用。
if !matches!(chmax.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) {
anyhow::bail!(
"tlusty_chain[{}] (label={}) 的 chmax={} 非法:必须 > 0<=0 会被 conv_check 判为整 stage 发散)",
i, step.label, chmax
);
}
}
// 注:niter 不校验 > 0——LTE grey start 步骤 niter=0 是合法设计
//runner.rs:455 显式处理 niter==0 为「不迭代,直接用 grey atmosphere 作初值」)。
}
// teff 必须为正(物理温度)。
for (i, t) in self.grid.teff.iter().enumerate() {
let v = t.value();
// 同 chmax`!(v > 0.0)` 保持对 NaN 的拒绝语义,用 partial_cmp 显式表达。
if !matches!(v.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) {
anyhow::bail!("grid.teff[{}] = {} 非法:温度必须 > 0", i, v);
}
}
// 丰度对数轴范围校验(超出 [-20, 10] 几乎必为笔误,且会令 10^logx 溢出为 Inf)。
for (axis_name, vals) in [
("logc", &self.grid.logc),
("logn", &self.grid.logn),
("logo", &self.grid.logo),
("loghe", &self.grid.loghe),
] {
for (i, av) in vals.iter().enumerate() {
let v = av.value();
if !(-20.0..=10.0).contains(&v) {
anyhow::bail!(
"grid.{}[{}] = {} 超出物理合理范围 [-20, 10]:请检查是否笔误(超出会让 10^logx 溢出为 Inf 污染输入文件)",
axis_name, i, v
);
}
}
}
Ok(())
}
/// 解析 TLUSTY 阶段配置。
///
/// 优先级(见 docs/task_engine_decoupling_design.md §3):
/// 1. 新版顶层 `tlusty:` 块(EngineStageConfig)—— 显式覆盖;
/// 1. 新版顶层 `tlusty_stage:` 块(PhaseConfig)—— 显式覆盖;
/// 2. 旧版 `seed_step_fallback: bool` —— true → `[cold_run, seed_step]`
/// false → `[cold_run]`(不回退种子步进);
/// 3. 兜底 `default_tlusty()`。
///
/// 注:旧版只控制是否回退种子步进,无 enabled/policy 维度,故回退路径固定
/// enabled=true / policy=SkipConverged(与新默认一致)。
pub fn resolve_tlusty_config(&self) -> EngineStageConfig {
if let Some(cfg) = &self.tlusty {
pub fn resolve_tlusty_config(&self) -> PhaseConfig {
if let Some(cfg) = &self.tlusty_stage {
return cfg.clone();
}
let mut cfg = EngineStageConfig::default_tlusty();
let mut cfg = PhaseConfig::default_tlusty();
if !self.seed_step_fallback {
cfg.strategies = vec!["cold_run".to_string()];
}
@@ -163,22 +218,90 @@ impl GridConfig {
/// 解析 SYNSPEC 阶段配置。
///
/// 优先级:
/// 1. 新版顶层 `synspec_stage:` 块(EngineStageConfig)—— 显式覆盖(含 enabled 开关);
/// 1. 新版顶层 `synspec_stage:` 块(PhaseConfig)—— 显式覆盖(含 enabled 开关);
/// 2. 兜底 `default_synspec()`enabled=true,保持旧行为:有大气就跑光谱)。
///
/// 注:旧版 `synspec: SynspecConfig`(数值参数)不影响阶段启用/策略——它只携带
/// 注:旧版 `synspec: SynspecInput`(数值参数)不影响阶段启用/策略——它只携带
/// 波长范围等数值,由调度器透传到 TaskSpec.synspec_params。如需禁用 SYNSPEC
/// 必须用新版 `synspec_stage: { enabled: false }`。
pub fn resolve_synspec_config(&self) -> EngineStageConfig {
pub fn resolve_synspec_config(&self) -> PhaseConfig {
if let Some(cfg) = &self.synspec_stage {
return cfg.clone();
}
EngineStageConfig::default_synspec()
PhaseConfig::default_synspec()
}
}
// ===== TLUSTY 输入文件(.5 + nst)全局物理参数 =====
// 这些参数不随收敛阶段(lte/nc/nl)变化——NFREAD 频率网格、ions 能级数据表等是
// 物理建模选择,一旦确定对整个大气计算全局生效。阶段差异参数(lte/ltgray/niter/
// chmax/metals 等)保留在 ChainStep 里。
// 缺省 None → 走代码内硬编码默认(gen_input5.rs/nst_writer.rs 的常量),与改动前行为一致。
/// 单个元素的 atoms 块配置(.5 文件的 `mode abn modpf` 行)。
/// abn(丰度)不在此配——每网格点不同,由 GridPointParams.loghe/logc/logn/logo 计算。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StageConfig {
pub struct AtomConfig {
/// 0=不计算, 1=隐式(仅 LTE Saha 算电荷,不出现在 ions 块), 2=显式(统计平衡)。
/// None → 走代码默认(H/He/CNO=2Li/Be/B=0)。
pub mode: Option<i32>,
/// partition function 模式(.5 atoms 行第 3 列 modpf)。None → 默认 0。
#[serde(default)]
pub modpf: Option<i32>,
}
/// 单个离子的能级数据配置(.5 文件 ions 块的一行)。
/// ilast 由 nlevs 推导(nlevs==1 → ilast=1 裸核终止标志,否则 0),不暴露。
/// ilvlin 由 ChainStep.ilvlin 提供(nlevs==1 时强制 0),不在此配。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IonConfig {
/// 原子序数(1=H, 2=He, 6=C, 7=N, 8=O...)。
pub iat: i32,
/// 电离级(0=中性, 1=一次电离, 2=二次电离...)。
pub iz: i32,
/// 能级数(该离子的能级模型复杂度)。
pub nlevs: i32,
/// 4 字符离子标识(如 `" H 1"`、`"He 2"`),用于 tlusty 日志与诊断。
pub typion: String,
/// 能级数据文件路径(如 `"data/h1.dat"`),裸核(nlevs==1)用 `" "`。
pub filei: String,
}
/// TLUSTY 输入文件的全局物理参数(.5 + nst 的非阶段差异部分)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TlustyInput {
/// .5 frequencies 块的 NFREAD:连续频率网格点数。
/// 取值 &gt;0 → 从预设频率表读取(高精度、慢);≤0 → 由 frmin/frmax 自动生成对数网格(快)。
/// 默认 2000gen_input5.rs 原硬编码值)。
#[serde(default = "default_nfread")]
pub nfread: i32,
/// atoms 块特定元素的 mode/modpf 覆盖。
/// key = 元素符号(`"H"`/`"He"`/`"C"`/`"N"`/`"O"`/`"Li"`/`"Be"`/`"B"`)。
/// 缺省的元素走代码默认 modeH/He/CNO=2Li/Be/B=0)。
#[serde(default)]
pub atoms: HashMap<String, AtomConfig>,
/// ions 能级数据表(完全替换默认的 H/He/C/N/O 23 行表)。
/// 空Vec → 用 gen_input5.rs 的默认常量表。
/// 非空时由 metals 参数按 iat 筛选参与元素。
#[serde(default)]
pub ions: Vec<IonConfig>,
/// nst 文件的额外关键字(逃逸口):自由传入任意 `KEY=VALUE` 对。
/// 生成 nst 时追加到末尾(每行一个),用于暴露未结构化的 220+ nst 关键字
/// (如 FRCMAX/CUTBAL/TAU/NDGREY 等)。
#[serde(default)]
pub nst_extra_keys: Vec<(String, String)>,
}
fn default_nfread() -> i32 {
2000
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ChainStep {
pub label: String,
#[serde(default = "default_false_str")]
pub lte: String,
@@ -207,8 +330,10 @@ fn default_niter() -> i32 {
50
}
/// SYNSPEC 输入文件(fort.55)的数值参数。与 `TlustyInput` 语义对称——
/// 分别承载各阶段输入文件的物理参数(tlusty 的 .5/nst vs synspec 的 fort.55)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SynspecConfig {
pub struct SynspecInput {
#[serde(default = "default_wstart")]
pub wstart: f64,
#[serde(default = "default_wend")]
@@ -249,12 +374,28 @@ fn default_abs_cutoff() -> f64 {
0.01
}
/// 审查修复:`#[serde(deny_unknown_fields)]` 让旧字段名(如已重命名的 `chain`/`synspec`/
/// `tlusty`/`results`/`itek_fallback`)在反序列化时**立即报错**,而非静默丢弃导致配置失效。
/// 用户明确不需要向后兼容,故用严格模式让配置错误尽早暴露。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GridConfig {
pub grid: GridAxesConfig,
/// TLUSTY 物理迭代步进链(lte/nc/nl 多阶段 ChainStep 数组)。
/// 每阶段独立配置 niter/chmax/metals/ilvlin 等参数,由 scheduler 序列化进
/// TaskSpec.tlusty_chain_paramsexecutor 反序列化后透传给 runner.custom_chain。
/// 空(缺省)→ executor 用 `default_chain_for_strategy` 兜底(按策略名选默认链)。
#[serde(default)]
pub chain: Vec<StageConfig>,
pub synspec: Option<SynspecConfig>,
pub tlusty_chain: Vec<ChainStep>,
/// TLUSTY 输入文件(.5 + nst)的全局物理参数。缺省 None → 走代码内硬编码默认。
/// 由 scheduler 序列化进 TaskSpec.tlusty_input_paramsexecutor 反序列化后
/// 透传给 runner → make_input5 / generate_nst_content。
#[serde(default)]
pub tlusty_input: Option<TlustyInput>,
/// SYNSPEC 输入文件(fort.55)的数值参数。与 `tlusty_input` 语义对称。
/// 由 scheduler 序列化进 TaskSpec.synspec_paramsexecutor 反序列化后透传给 runner。
#[serde(default)]
pub synspec_input: Option<SynspecInput>,
#[serde(default = "default_nworkers")]
pub nworkers: usize,
#[serde(default = "default_timeout")]
@@ -263,13 +404,6 @@ pub struct GridConfig {
pub resume: bool,
#[serde(default = "default_true")]
pub seed_step_fallback: bool,
/// **已弃用的死字段**:旧版 Python 工具链遗留,无任何代码读取(实际目录以
/// `ServerConfig.seeds_dir` / `DCTS_SEEDS_DIR` 为准)。保留以兼容旧 workflow YAML。
#[deprecated(note = "死字段,实际目录以 DCTS_SEEDS_DIR 为准")]
#[serde(default)]
pub results: Option<String>,
#[serde(default)]
pub itek_fallback: Vec<StageConfig>,
#[serde(default = "default_grid_niter")]
pub niter: Option<i32>,
pub template: Option<String>,
@@ -277,12 +411,14 @@ pub struct GridConfig {
pub linelist: Option<String>,
/// TLUSTY 阶段独立配置(见 docs/task_engine_decoupling_design.md §3)。
/// 缺省 None → `resolve_tlusty_config()` 据旧 `seed_step_fallback` 推断默认链。
/// 命名为 `tlusty_stage` 以与同级 `synspec_stage` 对称(均带 `_stage` 后缀,
/// 表示阶段启用/策略配置,区别于 `synspec: SynspecInput` 数值参数)。
#[serde(default)]
pub tlusty: Option<EngineStageConfig>,
/// SYNSPEC 阶段独立配置。命名为 `synspec_stage` 以与上方旧 `synspec: SynspecConfig`
pub tlusty_stage: Option<PhaseConfig>,
/// SYNSPEC 阶段独立配置。命名为 `synspec_stage` 以与上方旧 `synspec: SynspecInput`
///(光谱合成数值参数)区分。缺省 None → `resolve_synspec_config()` 给默认 `[standard]`。
#[serde(default)]
pub synspec_stage: Option<EngineStageConfig>,
pub synspec_stage: Option<PhaseConfig>,
}
fn default_grid_niter() -> Option<i32> {
@@ -356,17 +492,16 @@ fn default_node_stale_sec() -> u64 {
impl Default for ServerConfig {
fn default() -> Self {
// Phase 7b:清理旧 CNO_* / DCTS_RESULTS_DIR 回退(TLUSTY-first 遗留命名)。
let port = std::env::var("DCTS_PORT")
.or_else(|_| std::env::var("CNO_PORT"))
.or_else(|_| std::env::var("PORT"))
.unwrap_or_else(|_| "8090".to_string());
let db_path = std::env::var("DCTS_DB_PATH").unwrap_or_else(|_| "data/dcts.db".to_string());
let queue_db_path = std::env::var("DCTS_QUEUE_DB_PATH")
.unwrap_or_else(|_| "data/dcts_queue.db".to_string());
// 种子库目录:优先 DCTS_SEEDS_DIR回退旧 DCTS_RESULTS_DIR(已弃用,保留兼容)
let seeds_dir = std::env::var("DCTS_SEEDS_DIR")
.or_else(|_| std::env::var("DCTS_RESULTS_DIR"))
.unwrap_or_else(|_| "data/seeds".to_string());
// 种子库目录:DCTS_SEEDS_DIR缺省 data/seeds
let seeds_dir =
std::env::var("DCTS_SEEDS_DIR").unwrap_or_else(|_| "data/seeds".to_string());
let backup_dir =
std::env::var("DCTS_BACKUP_DIR").unwrap_or_else(|_| "data/backups".to_string());
let grid_config = std::env::var("DCTS_GRID_CONFIG")
@@ -474,9 +609,9 @@ impl std::fmt::Debug for NodeConfig {
impl Default for NodeConfig {
fn default() -> Self {
// Phase 7b:清理旧 CNO_SERVER_URL 回退。
let server_url = std::env::var("DCTS_SERVER_URL")
.or_else(|_| std::env::var("SERVER_URL"))
.or_else(|_| std::env::var("CNO_SERVER_URL"))
.unwrap_or_else(|_| "http://127.0.0.1:8090".to_string());
let node_id = std::env::var("DCTS_NODE_ID")
.or_else(|_| std::env::var("NODE_ID"))
@@ -496,10 +631,9 @@ impl Default for NodeConfig {
let runtime_dir =
std::env::var("DCTS_RUNTIME_DIR").unwrap_or_else(|_| "data/runtime".to_string());
let work_dir = std::env::var("DCTS_WORK_DIR").unwrap_or_else(|_| "data/work".to_string());
// 结果归档目录:优先 DCTS_RESULT_DIR回退旧 DCTS_ARCHIVE_DIR(已弃用,保留兼容)。
let result_dir = std::env::var("DCTS_RESULT_DIR")
.or_else(|_| std::env::var("DCTS_ARCHIVE_DIR"))
.unwrap_or_else(|_| "data/result".to_string());
// 结果归档目录:DCTS_RESULT_DIR缺省 data/resultPhase 7b 清理旧 DCTS_ARCHIVE_DIR 回退)。
let result_dir =
std::env::var("DCTS_RESULT_DIR").unwrap_or_else(|_| "data/result".to_string());
let heartbeat_sec = std::env::var("DCTS_HEARTBEAT_SEC")
.ok()
.and_then(|v| v.parse::<u64>().ok())
+412 -8
View File
@@ -1,4 +1,4 @@
use crate::models::ConvCheckResult;
use crate::models::{ConvCheckResult, IterCheck};
use regex::Regex;
use std::fs::File;
use std::io::{BufRead, BufReader};
@@ -6,9 +6,20 @@ use std::path::Path;
use std::sync::OnceLock;
static FORT9_RE: OnceLock<Regex> = OnceLock::new();
/// 无效数值正则模式(NaN / Inf / Infinity / Fortran 字段溢出 `***` / 超高正指数 E+300+)。
///
/// 抽为常量是因为 `NAN_RE` 这个 `OnceLock` 被 `atmosphere_has_nan` 与 `spec_is_valid`
/// 共用——若两处 `get_or_init` 闭包传不同的字符串,`OnceLock` 全局只采用首次初始化的
/// 版本,第二处闭包被静默丢弃,导致两函数行为不一致且依赖调用顺序(并发测试间歇性失败)。
/// 常量化保证两处字面一致。超高正指数分支物理论据见 `atmosphere_has_nan` 文档注释。
const NAN_RE_PATTERN: &str = r"(?i)(\bnan\b|\binf(?:inity)?\b|\*{3,}|[eE]\+(?:3\d{2}|[4-9]\d{2,}))";
static NAN_RE: OnceLock<Regex> = OnceLock::new();
/// 匹配 Fortran 无-E 科学记数法的尾数+指数部分(归一化用,见 parse_fortran_float)。
static NO_E_EXP_RE: OnceLock<Regex> = OnceLock::new();
/// 匹配 fort.6 中求解器发散 STOP 行(如 `**** STOP in SOLVE after ITER 8`)。
static SOLVER_STOP_RE: OnceLock<Regex> = OnceLock::new();
/// 匹配 call quit / stop 留言关键字。
static QUIT_RE: OnceLock<Regex> = OnceLock::new();
/// 解析 fort.9 / fort.7 中的数值字符串为 f64,兼容 Fortran 的**无-E 科学记数法**。
///
@@ -63,6 +74,7 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
last_iter: None,
n_depths: 0,
chmax,
itek_history: Vec::new(),
error: Some(format!(
"非法 chmax={}(须为正有限数):请检查工作流 YAML 中该 stage 的 chmax 配置",
chmax
@@ -80,6 +92,7 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
last_iter: None,
n_depths: 0,
chmax,
itek_history: Vec::new(),
error: Some(format!("Failed to open fort.9: {}", e)),
}
}
@@ -95,6 +108,9 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
let mut last_iter: Option<i32> = None;
let mut cur_iter: Option<i32> = None;
let mut cur_rows: Vec<Fort9Row> = Vec::new();
// 逐次迭代诊断(Phase 5b itek 全量保真):每次迭代记录该次最大 |maximum|。
let mut itek_history: Vec<IterCheck> = Vec::new();
let mut iter_max_relc: f64 = 0.0;
for line in reader.lines().map_while(Result::ok) {
if let Some(caps) = re.captures(&line) {
@@ -112,14 +128,35 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
};
if cur_iter != Some(iter) {
// 迭代切换:结算上一拍(若有),并开启新拍。
if let Some(prev_iter) = cur_iter {
itek_history.push(IterCheck {
iter: prev_iter,
max_relc: iter_max_relc,
n_depths: cur_rows.len(),
});
}
cur_iter = Some(iter);
cur_rows.clear();
iter_max_relc = 0.0;
}
cur_rows.push(Fort9Row { depth, maximum });
let abs_max = maximum.abs();
if abs_max > iter_max_relc {
iter_max_relc = abs_max;
}
last_iter = Some(iter);
}
}
// 收尾:结算最后一拍。
if let Some(prev_iter) = cur_iter {
itek_history.push(IterCheck {
iter: prev_iter,
max_relc: iter_max_relc,
n_depths: cur_rows.len(),
});
}
if cur_rows.is_empty() || last_iter.is_none() {
return ConvCheckResult {
@@ -129,6 +166,7 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
last_iter: None,
n_depths: 0,
chmax,
itek_history: Vec::new(),
error: Some("No valid iteration data found in fort.9".to_string()),
};
}
@@ -149,6 +187,7 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
last_iter,
n_depths: 0,
chmax,
itek_history: Vec::new(),
error: Some(
"No valid iteration rows found when calculating maximum change".to_string(),
),
@@ -166,6 +205,7 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
last_iter,
n_depths: cur_rows.len(),
chmax,
itek_history,
error: if is_valid_num {
None
} else {
@@ -182,11 +222,24 @@ pub fn check_fort9(path: &Path, chmax: f64) -> ConvCheckResult {
/// - Fortran 字段宽度溢出标记 `***`(如 `********`):Tlusty 数值溢出发散时常以星号填满
/// 字段而非写 NaN。历史上只检 `\bnan\b`,全溢出发散的大气会被判"无 NaN"→converged
/// 产出物理上完全错误的大气。
/// - 超高指数科学记数法 `[Ee]\+(3\d{2}|[4-9]\d{2,})`(漏洞4补充盲区):匹配 E+300 以上的
/// 正指数值(如 `1.0E+308`、`9.99E+307`)。这是数值发散的产物——恒星大气物理量天花板
/// 在 E+07 量级(温度/密度/布居数),E+300 在物理上无意义。gfortran 实际溢出时多写 `***`
/// (已被上一条覆盖),但"未溢出但接近 f64 上限"的窄窗口需此分支兜底。锁定正号 `\+`
/// 避免误伤合法的极小值(如 E-300)。真实数据集最高仅 E+07,零误报。
///
/// 超过 10% 的行命中任一标记即判定无效
/// **任意一行**命中任一标记即判定无效(0 行容忍)。物理论据:大气模型每个深度点的
/// 物理量都是耦合求解的,任意一个深度点 NaN/Inf/溢出意味着该层解已破坏,整个大气不可用。
///
/// 文件缺失时返回 `false`(语义:不存在 NaN 内容)。这与“含 NaN 导致无效”是不同语义;
/// 调用方需先自行确认文件存在性,不应将“缺失”与“含 NaN”混为一谈
/// 历史:曾用 10% 阈值(`bad_lines > total*0.1`),但单行 NaN(如表层发散)会被放过,
/// 导致部分坏大气被误判为可用。见 `docs/tlusty&synspec收敛性判断.md` §4 漏洞 4
///
/// 文件缺失时返回 `false`(语义:不存在 NaN 内容)。这与"含 NaN 导致无效"是不同语义;
/// 调用方需先自行确认文件存在性,不应将"缺失"与"含 NaN"混为一谈。
///
/// **空文件(存在但 0 行数据)返回 `true`**(语义:无效大气)。0 行意味着大气数据
/// 缺失/损坏(如 TLUSTY 启动后立即崩溃、写出的文件截断),不应被当作"无 NaN 的可用
/// 大气"。runner 凭此判定 final_converged=false,行为安全。
pub fn atmosphere_has_nan(path: &Path) -> bool {
let file = match File::open(path) {
Ok(f) => f,
@@ -195,8 +248,7 @@ pub fn atmosphere_has_nan(path: &Path) -> bool {
let reader = BufReader::new(file);
let mut total_lines = 0;
let mut bad_lines = 0;
let nan_re =
NAN_RE.get_or_init(|| Regex::new(r"(?i)(\bnan\b|\binf(?:inity)?\b|\*{3,})").unwrap());
let nan_re = NAN_RE.get_or_init(|| Regex::new(NAN_RE_PATTERN).unwrap());
for line in reader.lines().map_while(Result::ok) {
total_lines += 1;
@@ -209,7 +261,120 @@ pub fn atmosphere_has_nan(path: &Path) -> bool {
return true;
}
(bad_lines as f64) > (total_lines as f64 * 0.1)
// 漏洞4修复:0 行容忍——任意一行 NaN/Inf/溢出/超高指数即判定大气不可用。
bad_lines > 0
}
/// 校验 SYNSPEC 产出的 `.spec` 光谱文件内容是否有效。
///
/// # 背景(漏洞 1P0
/// gfortran 下 SYNSPEC 几乎所有错误路径都用裸 `STOP`(rc=0),且即便发散也会写出
/// 一个"看起来存在"的 `.spec`。旧代码只做 `is_file()` 存在性检查,导致脏谱(含
/// NaN/Inf、行数极少、流量全零)被当作 `Completed` 归档——这是全链路最大的科学
/// 正确性风险。见 `docs/tlusty&synspec收敛性判断.md` §4 漏洞 1。
///
/// # 校验规则(按序短路)
/// 1. 文件缺失/无法读取 → `Some("...")`
/// 2. 逐行扫描累计:含 NaN/Inf/`***` 的 bad_lines、含 ≥2 个可解析数值 token 的有效行、
/// 流量列(第 2 列)非零的有效行
/// 3. 空文件 → `Some("spec 为空")`
/// 4. bad_lines > 0 → `Some("spec 含 NaN/Inf/溢出行 (共 N 行)")`
/// 5. 有效行数 < 10 → `Some("spec 有效行数不足 (N<10)")`
/// 6. 非零流量行数 == 0 → `Some("spec 流量全为零")`
/// 7. 全通过 → `None`
///
/// 返回 `None` 表示有效,`Some(原因)` 表示无效(调用方据此置 `synspec_rc`/`synspec_error`)。
pub fn spec_is_valid(path: &Path) -> Option<String> {
let file = match File::open(path) {
Ok(f) => f,
Err(_) => return Some("spec 文件缺失或无法读取".to_string()),
};
let reader = BufReader::new(file);
let nan_re = NAN_RE.get_or_init(|| Regex::new(NAN_RE_PATTERN).unwrap());
let mut total_lines = 0;
let mut bad_lines = 0;
let mut valid_lines = 0; // 含 ≥2 个可解析数值 token 的行
let mut nonzero_flux_lines = 0; // 流量列(第 2 个 token)非零的有效行
for line in reader.lines().map_while(Result::ok) {
total_lines += 1;
if nan_re.is_match(&line) {
bad_lines += 1;
continue;
}
// 拆 token,尝试解析为数值。.spec 每行 2 列:波长、流量(FLAM)。
let tokens: Vec<&str> = line.split_whitespace().collect();
let parsed: Vec<f64> = tokens
.iter()
.filter_map(|t| parse_fortran_float(t))
.filter(|v| v.is_finite())
.collect();
if parsed.len() >= 2 {
valid_lines += 1;
// 第 2 个数值列是流量;流量非零才算有效行(避免全零谱)。
if parsed[1].abs() > 0.0 {
nonzero_flux_lines += 1;
}
}
}
if total_lines == 0 {
return Some("spec 为空".to_string());
}
if bad_lines > 0 {
return Some(format!("spec 含 NaN/Inf/溢出行 (共 {} 行)", bad_lines));
}
if valid_lines < 10 {
return Some(format!("spec 有效行数不足 ({}<10)", valid_lines));
}
if nonzero_flux_lines == 0 {
return Some("spec 流量全为零".to_string());
}
None
}
/// 从 fort.6TLUSTY stdout 日志)提取失败诊断提示。
///
/// # 背景(漏洞 5P2
/// TLUSTY 失败时旧 note 统一记 `"tlusty rc=N or missing fort.7"`,丢失"为何未收敛"线索。
/// 求解器发散时 fort.6 会印 `**** STOP in SOLVE/SOLVES/RYBSOL after ITER N`(来自
/// `tlusty208.f:14731/15077/47598`FORMAT 610);fort.7 缺失(输入错误、temp 越界等
/// `call quit`)时 fort.6 尾部会有 `stop 'msg'` 留言。两类信息都能显著提升归因质量。
/// 见 `docs/tlusty&synspec收敛性判断.md` §4 漏洞 5。
///
/// # 规则
/// 1. 优先全文匹配 `STOP in (SOLVE|SOLVES|RYBSOL) after ITER N`(发散求解器名),返回该行。
/// 2. 否则取最后 5 行,找含 `stop|quit|error`(忽略大小写)的行返回。
/// 3. 都没有 → `None`。
pub fn extract_failure_hint(fort6_path: &Path) -> Option<String> {
let file = File::open(fort6_path).ok()?;
let reader = BufReader::new(file);
let solver_re = SOLVER_STOP_RE.get_or_init(|| {
Regex::new(r"(?i)STOP\s+in\s+(SOLVES?|RYBSOL)\s+after\s+ITER\s+\d+").unwrap()
});
// 单词边界 \b 避免子串误报(如 "stopping criterion"/"no error detected")。
// 仅在已确认失败的语境下提取 hint,误报后果仅是 note 多一行提示,不影响 converged 判定。
let quit_re = QUIT_RE.get_or_init(|| Regex::new(r"(?i)\b(stop|quit|error)\b").unwrap());
let mut all_lines: Vec<String> = Vec::new();
for line in reader.lines().map_while(Result::ok) {
all_lines.push(line);
}
// 1. 全文找发散求解器 STOP 行(取最后一次出现)。
for line in all_lines.iter().rev() {
if solver_re.is_match(line) {
return Some(line.trim().to_string());
}
}
// 2. 尾部 5 行找 call quit / stop / error 留言(取最后一条)。
for line in all_lines.iter().rev().take(5) {
if quit_re.is_match(line) {
return Some(line.trim().to_string());
}
}
None
}
#[cfg(test)]
@@ -245,6 +410,215 @@ mod tests {
let inf_file_path = dir.path().join("inf.7");
std::fs::write(&inf_file_path, "Inf 2 3\nInfinity 5 6\n7 8 9\n").unwrap();
assert!(atmosphere_has_nan(&inf_file_path));
// 漏洞4修复:0 行容忍——单行 NaN(1/3 行)也应判定无效。
let single_nan_path = dir.path().join("single_nan.7");
std::fs::write(&single_nan_path, "NaN 2 3\n4 5 6\n7 8 9\n").unwrap();
assert!(
atmosphere_has_nan(&single_nan_path),
"单行 NaN(表层发散的典型形态)应被判为无效"
);
// 漏洞4补充盲区:超高指数科学记数法(E+300 以上)是数值发散产物,物理上无意义。
// 正常大气物理量天花板在 E+07,应被判无效。
let huge_exp_path = dir.path().join("huge_exp.7");
std::fs::write(
&huge_exp_path,
"1.0E+07 2.0 3.0\n9.99E+308 5.0 6.0\n7.0 8.0 9.0\n",
)
.unwrap();
assert!(
atmosphere_has_nan(&huge_exp_path),
"超高指数 E+308 应被判为无效(数值发散产物)"
);
// 边界:合法的极高正指数(E+50,仍远超物理上限但未被超高指数分支命中)
// —— 此测试确认正则只匹配 E+300+,不误伤。
// 注:E+50 在物理上无意义但 regex 不拦,由物理论据留给将来收紧。
let high_but_ok_path = dir.path().join("high_ok.7");
std::fs::write(&high_but_ok_path, "1.0E+50 2.0 3.0\n4.0 5.0 6.0\n").unwrap();
assert!(
!atmosphere_has_nan(&high_but_ok_path),
"E+50 不在 E+300+ 检测范围,不应被超高指数分支误判"
);
// 边界:合法的极小值(E-300)不得被误伤(正则锁定正号 \\+)。
let tiny_path = dir.path().join("tiny.7");
std::fs::write(&tiny_path, "1.0E-300 2.0 3.0\n4.0 5.0 6.0\n").unwrap();
assert!(
!atmosphere_has_nan(&tiny_path),
"极小值 E-300(合法)不得被超高指数分支误伤"
);
}
/// 漏洞1修复:SYNSPEC `.spec` 内容校验。
#[test]
fn test_spec_validation() {
let dir = tempfile::tempdir().unwrap();
// 1. 正常谱:≥10 行、每行 2 数值列、流量非零 → None(有效)
let good_spec = dir.path().join("good.spec");
let mut content = String::new();
for i in 0..15 {
content.push_str(&format!(
" {:.5} {:.6E}\n",
1400.0 + i as f64 * 0.5,
1e-12
));
}
std::fs::write(&good_spec, &content).unwrap();
assert!(
spec_is_valid(&good_spec).is_none(),
"正常多行数值谱应判有效"
);
// 2. 脏谱(仓库内真实失败样本形态):仅 2 行 + Infinity/NaN → Some
let dirty_spec = dir.path().join("dirty.spec");
std::fs::write(
&dirty_spec,
" Infinity NaN\n 1410.00005 NaN\n",
)
.unwrap();
let reason = spec_is_valid(&dirty_spec).expect("脏谱应判无效");
assert!(
reason.contains("NaN") || reason.contains("Inf"),
"脏谱原因应提及 NaN/Inf,实际:{}",
reason
);
// 3. 行数不足(< 10)→ Some
let short_spec = dir.path().join("short.spec");
std::fs::write(
&short_spec,
" 1400.0 1.0E-12\n 1401.0 1.0E-12\n 1402.0 1.0E-12\n",
)
.unwrap();
let reason = spec_is_valid(&short_spec).expect("行数不足应判无效");
assert!(
reason.contains("行数不足"),
"行数不足原因,实际:{}",
reason
);
// 4. 全零流量 → Some
let zero_spec = dir.path().join("zero.spec");
let mut content = String::new();
for i in 0..15 {
content.push_str(&format!(" {:.5} {:.6E}\n", 1400.0 + i as f64 * 0.5, 0.0));
}
std::fs::write(&zero_spec, &content).unwrap();
let reason = spec_is_valid(&zero_spec).expect("全零谱应判无效");
assert!(reason.contains("全为零"), "全零谱原因,实际:{}", reason);
// 5. 文件缺失 → Some
let missing_spec = dir.path().join("missing.spec");
let reason = spec_is_valid(&missing_spec).expect("文件缺失应判无效");
assert!(reason.contains("缺失"), "文件缺失原因,实际:{}", reason);
// 6. Fortran 字段溢出 *** → Some
let overflow_spec = dir.path().join("overflow.spec");
let mut content = String::new();
for i in 0..15 {
content.push_str(&format!(
" {:.5} {:.6E}\n",
1400.0 + i as f64 * 0.5,
1e-12
));
}
// 第 3 行混入溢出行
let mut lines: Vec<&str> = content.lines().collect();
if lines.len() > 2 {
lines[2] = " 1401.0 ********";
}
std::fs::write(&overflow_spec, lines.join("\n") + "\n").unwrap();
let reason = spec_is_valid(&overflow_spec).expect("含溢出标记的谱应判无效");
assert!(reason.contains("溢出"), "溢出标记原因,实际:{}", reason);
// 7. 超高指数(E+308,数值发散产物)→ Some。
// 双重作用:(a) 验证 spec_is_valid 拦截超高指数;
// (b) 守护 NAN_RE OnceLock 一致性——spec_is_valid 与 atmosphere_has_nan
// 共用 NAN_RE,若两处正则字符串不一致(OnceLock 竞态),此断言会
// 在多线程测试时间歇性失败。
let huge_exp_spec = dir.path().join("huge_exp.spec");
let mut content = String::new();
for i in 0..15 {
content.push_str(&format!(
" {:.5} {:.6E}\n",
1400.0 + i as f64 * 0.5,
1e-12
));
}
let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
if lines.len() > 2 {
lines[2] = " 1401.0 9.99E+308".to_string();
}
std::fs::write(&huge_exp_spec, lines.join("\n") + "\n").unwrap();
let reason = spec_is_valid(&huge_exp_spec).expect("含超高指数的谱应判无效");
assert!(
reason.contains("NaN") || reason.contains("Inf") || reason.contains("溢出"),
"超高指数应被识别为无效数值,实际原因:{}",
reason
);
}
/// 漏洞5修复:fort.6 失败诊断提示提取。
#[test]
fn test_extract_failure_hint() {
let dir = tempfile::tempdir().unwrap();
// 1. 发散求解器 STOP 行 → 提取该行
let solve_log = dir.path().join("solve.6");
std::fs::write(
&solve_log,
" KANTOROVICH acceleration: ITER 7\n\
**** STOP in SOLVE after ITER 8\n\
Max change: 3.59E+20\n",
)
.unwrap();
let hint = extract_failure_hint(&solve_log).expect("发散日志应返回 hint");
assert!(
hint.contains("STOP in SOLVE"),
"应提取求解器 STOP 行,实际:{}",
hint
);
// RYBSOL 路径
let rybsol_log = dir.path().join("rybsol.6");
std::fs::write(&rybsol_log, "**** STOP in RYBSOL after ITER 3\n").unwrap();
let hint = extract_failure_hint(&rybsol_log).expect("RYBSOL 日志应返回 hint");
assert!(hint.contains("RYBSOL"));
// 2. call quit 留言(fort.7 缺失场景,如 temp 越界)→ 提取尾部
let quit_log = dir.path().join("quit.6");
std::fs::write(
&quit_log,
" some normal output\n\
partf; temp<1000 K\n\
stop 'partf; temp<1000 K'\n",
)
.unwrap();
let hint = extract_failure_hint(&quit_log).expect("call quit 日志应返回 hint");
assert!(
hint.contains("partf") || hint.to_lowercase().contains("stop"),
"应提取 quit 留言,实际:{}",
hint
);
// 3. 无关日志(正常收敛,无 STOP/quit)→ None
let clean_log = dir.path().join("clean.6");
std::fs::write(
&clean_log,
" KANTOROVICH acceleration: ITER 10\n Converged.\n",
)
.unwrap();
assert!(
extract_failure_hint(&clean_log).is_none(),
"正常日志不应返回 hint"
);
// 4. 文件缺失 → None
let missing = dir.path().join("missing.6");
assert!(extract_failure_hint(&missing).is_none());
}
/// 验证 parse_fortran_float 对各种数值格式(含 Fortran 无-E 记数法)的解析。
@@ -253,7 +627,7 @@ mod tests {
// 标准 parse 能覆盖的
assert_eq!(parse_fortran_float("100"), Some(100.0));
assert_eq!(parse_fortran_float("-0.001"), Some(-0.001));
assert_eq!(parse_fortran_float("3.14"), Some(3.14));
assert_eq!(parse_fortran_float("2.5"), Some(2.5));
assert_eq!(parse_fortran_float("-5.42E+72"), Some(-5.42e72));
assert_eq!(parse_fortran_float("1.5e-99"), Some(1.5e-99));
assert_eq!(parse_fortran_float("0"), Some(0.0));
@@ -336,5 +710,35 @@ mod tests {
let res = check_fort9(&fort9, 0.001);
assert!(res.converged, "正常收敛行应判为收敛");
assert!((res.max_relc - 1.0e-5).abs() < 1e-15);
assert_eq!(res.itek_history.len(), 1, "单迭代应只有一拍");
assert_eq!(res.itek_history[0].iter, 10);
}
/// Phase 5b itek 全量保真:多迭代 fort.9 → itek_history 逐拍记录(iter → 该次最大 |maximum|)。
#[test]
fn test_fort9_itek_history_per_iteration() {
let dir = tempfile::tempdir().unwrap();
let fort9 = dir.path().join("multi_iter.9");
// 两拍:iter1 最深 max_relc=2.0E-2depth2)、iter2 最深 max_relc=5.0E-4depth1)。
std::fs::write(
&fort9,
" 1 1 0 1.0E-3 1.0E-3 1.0E-3 1.0E-2 5 10\n\
1 2 0 1.0E-3 1.0E-3 1.0E-3 2.0E-2 5 10\n\
2 1 0 1.0E-3 1.0E-3 1.0E-3 5.0E-4 5 10\n\
2 2 0 1.0E-3 1.0E-3 1.0E-3 3.0E-4 5 10\n",
)
.unwrap();
let res = check_fort9(&fort9, 0.001);
// 末拍(iter2)收敛。
assert!(res.converged);
assert!((res.max_relc - 5.0e-4).abs() < 1e-15);
// itek_history 逐拍完整。
assert_eq!(res.itek_history.len(), 2);
assert_eq!(res.itek_history[0].iter, 1);
assert!((res.itek_history[0].max_relc - 2.0e-2).abs() < 1e-15);
assert_eq!(res.itek_history[0].n_depths, 2);
assert_eq!(res.itek_history[1].iter, 2);
assert!((res.itek_history[1].max_relc - 5.0e-4).abs() < 1e-15);
assert_eq!(res.itek_history[1].n_depths, 2);
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
use crate::config::SynspecConfig;
use crate::config::SynspecInput;
/// Dynamic generator for SYNSPEC fort.55 parameter control file
pub fn generate_fort55_content(cfg: &SynspecConfig) -> String {
pub fn generate_fort55_content(cfg: &SynspecInput) -> String {
let line1 = format!(" {} {} {}", cfg.imode, cfg.idrv, cfg.ifreq);
let line2 = " 1 0 0 0";
let line3 = " 0 0 0 0 0";
@@ -25,7 +25,7 @@ mod tests {
#[test]
fn test_fort55_generation() {
let cfg = SynspecConfig {
let cfg = SynspecInput {
wstart: 3000.0,
wend: 7000.0,
imode: 0,
+290 -37
View File
@@ -1,4 +1,6 @@
use crate::config::{AtomConfig, IonConfig, TlustyInput};
use crate::models::GridPointParams;
use std::collections::HashMap;
struct IonDef {
iat: i32,
@@ -177,63 +179,193 @@ const IONS_O: &[IonDef] = &[
},
];
/// 把对数丰度 logx 格式化为 10^logx 的科学计数法字符串。
///
/// 审查修复 #N3:深度防御 powf 溢出——config.validate 已把丰度轴限制在 [-20, 10]
/// 但 fmt_abn 也可能被 tlusty_chain 里的元素丰度等其它路径调用。若 logx 超出约 [-308, 308]
/// `10f64.powf(logx)` 会溢出为 Inf/0,写出 `inf` 污染 .5 输入文件导致 TLUSTY 行为未定义。
/// 配置层校验在前,此处仅作最终防线:溢出时 saturate 到 f64 可表示范围并按原格式输出。
fn fmt_abn(logx: f64) -> String {
format!("{:.4E}", 10.0f64.powf(logx))
let val = if logx.abs() < 300.0 {
10.0f64.powf(logx)
} else {
// 超出安全范围:clamp 到 0(丰度对数极小)或 f64::MAX(丰度对数极大),
// 避免写出 inf。配置层应已拦截,这里不会成为正常路径。
if logx < 0.0 {
0.0
} else {
f64::MAX
}
};
format!("{:.4E}", val)
}
/// 元素符号首字母大写归一("h"→"H", "he"→"He", "HE"→"He")。
/// 容忍用户在 YAML atoms 块用小写/全大写写元素符号。
fn capitalize_first(s: &str) -> String {
let mut c = s.chars();
match c.next() {
Some(f) => f.to_uppercase().collect::<String>() + c.as_str().to_lowercase().as_str(),
None => String::new(),
}
}
/// 默认 ions 能级数据表(与历史 IONS_* 常量等价)。
/// 用户未在 `tlusty_input.ions` 配置时使用此表,按 metals 筛选参与元素。
fn default_ions(metals: &str) -> Vec<IonConfig> {
let mt = metals.to_lowercase();
let has_c = mt.contains('c');
let has_n = mt.contains('n');
let has_o = mt.contains('o');
let mut ions: Vec<IonConfig> = Vec::new();
// H(恒在)
for d in IONS_H {
ions.push(IonConfig {
iat: d.iat,
iz: d.iz,
nlevs: d.nlevs,
typion: d.typion.to_string(),
filei: d.filei.to_string(),
});
}
// He(恒在)
for d in IONS_HE {
ions.push(IonConfig {
iat: d.iat,
iz: d.iz,
nlevs: d.nlevs,
typion: d.typion.to_string(),
filei: d.filei.to_string(),
});
}
let extend = |ions: &mut Vec<IonConfig>, table: &[IonDef]| {
for d in table {
ions.push(IonConfig {
iat: d.iat,
iz: d.iz,
nlevs: d.nlevs,
typion: d.typion.to_string(),
filei: d.filei.to_string(),
});
}
};
if has_c {
extend(&mut ions, IONS_C);
}
if has_n {
extend(&mut ions, IONS_N);
}
if has_o {
extend(&mut ions, IONS_O);
}
ions
}
/// Constructs the complete text of a `.5` input file for TLUSTY
///
/// `input_cfg`:全局输入参数(NFREAD / atoms.mode / ions 表)。
/// None → 走代码内硬编码默认(与改动前行为完全一致,向后兼容)。
pub fn make_input5(
params: &GridPointParams,
lte: &str,
ltgray: &str,
metals: &str,
ilvlin: i32,
input_cfg: Option<&TlustyInput>,
) -> String {
let mt = metals.to_lowercase();
let has_c = mt.contains('c');
let has_n = mt.contains('n');
let has_o = mt.contains('o');
// Atoms block
let mut atom_rows: Vec<(i32, String)> = vec![
(2, "0.".to_string()), // 1 H
(2, fmt_abn(*params.loghe)), // 2 He
(0, "0.".to_string()), // 3 Li
(0, "0.".to_string()), // 4 Be
(0, "0.".to_string()), // 5 B
];
// 元素符号 → (mode, abn)。mode 优先取 input_cfg.atoms 覆写,否则走默认。
// abn(丰度)永远由 GridPointParams 计算——每网格点不同,不可全局配置。
// 查找时对 key 做首字母大写归一(H/He/Li/Be/B/C/N/O),容忍用户写小写 "h"/"he"。
//
// M1 修复:config.rs 的 `atoms` 是 Serde 反序列化的 `HashMap<String, AtomConfig>`
// 键按 YAML 原样存储(无规范化)。若用户写 `he:`/`HE:`,原实现只归一探针、不归一存储键,
// 查不到规范键 "He" → mode 覆写静默回落默认(错误物理:想要的 LTE-only 变统计平衡)。
// 这里把用户键也按同一 capitalize_first 规则归一一次,保证任意大小写都能命中。
let atoms_normalized: Option<HashMap<String, &AtomConfig>> = input_cfg.map(|c| {
let mut m = HashMap::with_capacity(c.atoms.len());
for (k, v) in &c.atoms {
let canon = capitalize_first(k);
// 归一撞键检测:用户同时写 "C" 与 "c"(或 "He"/"HE")等大小写变体时,两键归一为
// 同一规范键。原 collect() 静默覆盖(迭代序非确定 → 取哪个任选)。现显式提示
// 让歧义可见,避免 mode 覆写悄悄取到任意一个变体。
if let Some(_prev) = m.insert(canon.clone(), v) {
tracing::warn!(
"atoms 键 {:?} 与已有键(归一后同为 {:?})冲突:取最后读取项,请改用单一规范键",
k,
canon
);
}
}
m
});
let atom_mode = |sym: &str, default_mode: i32| -> i32 {
atoms_normalized
.as_ref()
.and_then(|m| m.get(&capitalize_first(sym)))
.and_then(|a| a.mode)
.unwrap_or(default_mode)
};
let atom_modpf = |sym: &str| -> i32 {
atoms_normalized
.as_ref()
.and_then(|m| m.get(&capitalize_first(sym)))
.and_then(|a| a.modpf)
.unwrap_or(0)
};
// Atoms blockH/He 恒在;Li/Be/B 恒在(mode=0 不参与);C/N/O 按 metals。
let mut atom_rows: Vec<(i32, String, i32)> = vec![
(atom_mode("H", 2), "0.".to_string(), atom_modpf("H")), // 1 H
(atom_mode("He", 2), fmt_abn(*params.loghe), atom_modpf("He")), // 2 He
(atom_mode("Li", 0), "0.".to_string(), atom_modpf("Li")), // 3 Li
(atom_mode("Be", 0), "0.".to_string(), atom_modpf("Be")), // 4 Be
(atom_mode("B", 0), "0.".to_string(), atom_modpf("B")), // 5 B
];
if has_c {
atom_rows.push((2, fmt_abn(*params.logc))); // 6 C
atom_rows.push((atom_mode("C", 2), fmt_abn(*params.logc), atom_modpf("C")));
// 6 C
}
if has_n {
atom_rows.push((2, fmt_abn(*params.logn))); // 7 N
atom_rows.push((atom_mode("N", 2), fmt_abn(*params.logn), atom_modpf("N")));
// 7 N
}
if has_o {
atom_rows.push((2, fmt_abn(*params.logo))); // 8 O
atom_rows.push((atom_mode("O", 2), fmt_abn(*params.logo), atom_modpf("O")));
// 8 O
}
let natoms =
5 + (if has_c { 1 } else { 0 }) + (if has_n { 1 } else { 0 }) + (if has_o { 1 } else { 0 });
let natoms = atom_rows.len() as i32;
let mut atoms_block = format!(" {}\n* mode abn modpf\n", natoms);
for (mode, abn) in &atom_rows {
atoms_block.push_str(&format!(" {} {} 0\n", mode, abn));
for (mode, abn, modpf) in &atom_rows {
atoms_block.push_str(&format!(" {} {} {}\n", mode, abn, modpf));
}
// Ions block
let mut ions: Vec<&IonDef> = Vec::new();
ions.extend(IONS_H.iter());
ions.extend(IONS_HE.iter());
if has_c {
ions.extend(IONS_C.iter());
}
if has_n {
ions.extend(IONS_N.iter());
}
if has_o {
ions.extend(IONS_O.iter());
}
// Ions block:用户配置非空时完全替换默认表,仍按 metals 筛选 iat。
let ions: Vec<IonConfig> = if let Some(cfg) = input_cfg {
if cfg.ions.is_empty() {
default_ions(metals)
} else {
// 用户自定义表——按 metals 筛选参与元素(H/He 恒在,C/N/O 按 metals)。
cfg.ions
.iter()
.filter(|ion| match ion.iat {
1 | 2 => true, // H, He 恒在
6 => has_c,
7 => has_n,
8 => has_o,
_ => true, // 用户自定义元素不筛
})
.cloned()
.collect()
}
} else {
default_ions(metals)
};
let mut ions_block = "*iat iz nlevs ilast ilvlin nonstd typion filei\n*\n".to_string();
for ion in &ions {
@@ -243,9 +375,6 @@ pub fn make_input5(
// (tests/tlusty/hhe/fort.5) 逐字节一致:
// iat 结束于 col4(|iat|=4), iz col10(+6), nlevs col16(+6),
// ilast col23(+7), ilvl col30(+7), nonstd col37(+7)。
// 实测:此宽列宽与窄列宽对 tlusty 输出(fort.7/9 等)完全相同(list-directed I/O
// 列宽无关),但对齐真实文件便于与历史参考 diff、符合 tlusty 官方输入惯例。
// 历史上曾用 `" {} {:2} {:5}..."`(窄列宽,与 Python 旧实现一致但偏离真实 fort.5)。
ions_block.push_str(&format!(
"{:>4}{:>6}{:>6}{:>7}{:>7}{:>7} '{}' '{}'\n",
ion.iat, ion.iz, ion.nlevs, ilast, ilvl, 0, ion.typion, ion.filei
@@ -257,13 +386,16 @@ pub fn make_input5(
0, 0, 0, -1, 0, 0, " ", " "
));
// NFREAD:从配置读,None → 默认 2000。
let nfread = input_cfg.map(|c| c.nfread).unwrap_or(2000);
format!(
"{:.1} {:.1} ! TEFF, GRAV\n \
{} {} ! LTE, LTGRAY\n \
'nst' ! name of file containing non-standard flags\n\
*-----------------------------------------------------------------\n\
* frequencies\n \
2000 ! NFREAD\n\
{} ! NFREAD\n\
*-----------------------------------------------------------------\n\
* data for atoms\n\
{}\
@@ -271,7 +403,7 @@ pub fn make_input5(
* data for ions\n*\n\
{}\
*\n* end\n",
params.teff, params.logg, lte, ltgray, atoms_block, ions_block
params.teff, params.logg, lte, ltgray, nfread, atoms_block, ions_block
)
}
@@ -289,7 +421,7 @@ mod tests {
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let input5 = make_input5(&params, "F", "F", "cno", 100);
let input5 = make_input5(&params, "F", "F", "cno", 100, None);
assert!(input5.contains("35000.0 5.5"));
assert!(input5.contains("data/h1.dat"));
assert!(input5.contains("data/c1.dat"));
@@ -312,7 +444,7 @@ mod tests {
logn: (-1.0).into(),
logo: (-1.0).into(),
};
let input5 = make_input5(&params, "T", "T", "", 100);
let input5 = make_input5(&params, "T", "T", "", 100, None);
let lines: Vec<&str> = input5.lines().collect();
// 真实 fort.5 的 ions 数据行(数值部分 + typion/filei)。
@@ -346,4 +478,125 @@ mod tests {
"ions 行数值部分必须与真实 fort.5 逐字节一致(宽列宽)"
);
}
/// 回归守护:None 配置时 NFREAD=2000、atoms.mode 走默认(H/He=2Li/Be/B=0)。
#[test]
fn test_make_input5_none_cfg_defaults() {
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 input5 = make_input5(&params, "F", "F", "cno", 100, None);
// NFREAD 默认 2000
assert!(input5.contains("2000"), "None 配置时 NFREAD 应为默认 2000");
// H 的 mode=2(显式)
assert!(input5.contains(" 2 0. 0"), "H 的 mode 应为默认 2");
}
/// 用户配置生效:nfread 覆写、atoms.mode 覆写、ions 自定义表。
#[test]
fn test_make_input5_user_cfg_override() {
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 mut atoms = std::collections::HashMap::new();
atoms.insert(
"C".to_string(),
crate::config::AtomConfig {
mode: Some(1),
modpf: None,
},
);
let cfg = TlustyInput {
nfread: 500,
atoms,
ions: vec![],
nst_extra_keys: vec![],
};
let input5 = make_input5(&params, "F", "F", "cno", 100, Some(&cfg));
// NFREAD 被覆写为 500
assert!(
input5.contains("500") && !input5.contains("2000"),
"NFREAD 应被用户配置覆写为 500"
);
// C 的 mode 被覆写为 1(隐式),而非默认 2
// atoms 行格式 " {mode} {abn} {modpf}"C 的 abn 是 fmt_abn(logc)
let c_line = input5
.lines()
.find(|l| l.contains(&format!("{:.4E}", 10.0f64.powf(-2.0))))
.expect("应找到 C 的 atoms 行");
assert!(
c_line.trim_start().starts_with("1"),
"C 的 mode 应被覆写为 1(隐式),实际: {}",
c_line
);
}
/// M1 回归:`atoms` 键大小写不敏感查找。用户写小写/全大写元素键(`c:`/`HE:`)时,
/// 与规范键(`C`/`He`)必须同样命中覆写,否则 mode 覆写会静默回落默认(错误物理)。
#[test]
fn test_atoms_key_case_insensitive() {
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(),
};
// 用户用小写 "c" 与全大写 "HE" 覆写 mode。
let mut atoms = std::collections::HashMap::new();
atoms.insert(
"c".to_string(),
crate::config::AtomConfig {
mode: Some(1),
modpf: None,
},
);
atoms.insert(
"HE".to_string(),
crate::config::AtomConfig {
mode: Some(0),
modpf: None,
},
);
let cfg = TlustyInput {
nfread: 2000,
atoms,
ions: vec![],
nst_extra_keys: vec![],
};
let input5 = make_input5(&params, "F", "F", "cno", 100, Some(&cfg));
// C(键 "c" 小写)应命中得到 mode=1,而非默认 2。
let c_abn = format!("{:.4E}", 10.0f64.powf(-2.0));
let c_line = input5
.lines()
.find(|l| l.contains(&c_abn))
.expect("应找到 C 的 atoms 行");
assert!(
c_line.trim_start().starts_with("1"),
"小写键 c 应覆写 C 的 mode 为 1,实际: {}",
c_line
);
// He(键 "HE" 全大写)应命中得到 mode=0,而非默认 2。
let he_abn = format!("{:.4E}", 10.0f64.powf(-1.0));
let he_line = input5
.lines()
.find(|l| l.contains(&he_abn))
.expect("应找到 He 的 atoms 行");
assert!(
he_line.trim_start().starts_with("0"),
"全大写键 HE 应覆写 He 的 mode 为 0,实际: {}",
he_line
);
}
}
+149 -125
View File
@@ -242,7 +242,10 @@ pub struct GridPoint {
pub wave: i32,
pub status: GridPointStatus,
pub attempt_count: i32,
pub success_method: Option<String>,
/// TLUSTY 阶段收敛策略(TLUSTY 禁用为 NULL)。
pub tlusty_success_method: Option<String>,
/// 光谱阶段收敛策略(TLUSTY-only 为 NULL)。
pub synspec_success_method: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -251,7 +254,9 @@ pub enum GridPointStatus {
Pending,
Queued,
Running,
Converged,
/// Phase 7c:由 `Converged` 改名——点级"管线完成"(大气收敛 + 光谱合成),
/// 消除 TLUSTY-first 的"大气收敛"误读。DB 值 'completed' 经 M9 迁为 'completed'。
Completed,
Failed,
}
@@ -261,7 +266,7 @@ impl std::fmt::Display for GridPointStatus {
GridPointStatus::Pending => "pending",
GridPointStatus::Queued => "queued",
GridPointStatus::Running => "running",
GridPointStatus::Converged => "converged",
GridPointStatus::Completed => "completed",
GridPointStatus::Failed => "failed",
};
write!(f, "{}", s)
@@ -273,7 +278,8 @@ impl From<&str> for GridPointStatus {
match s {
"queued" => GridPointStatus::Queued,
"running" => GridPointStatus::Running,
"converged" | "done" => GridPointStatus::Converged,
// 7c'completed' 是权威值;'converged'/'done' 为 legacy 别名(M9 迁移前旧数据/旧代码)。
"completed" | "converged" | "done" => GridPointStatus::Completed,
"failed" => GridPointStatus::Failed,
_ => GridPointStatus::Pending,
}
@@ -288,7 +294,7 @@ impl From<&str> for GridPointStatus {
/// 的处理;失败后的策略链回退**只由启动时的策略链(回退优先级排序)驱动**,不受策略门控。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum StagePolicy {
pub enum ResumePolicy {
/// 跳过已收敛、重试已失败:启动时把已失败点打回 pending 重试,收敛点保留(增量+重试失败)。
/// 默认值。
#[default]
@@ -299,22 +305,22 @@ pub enum StagePolicy {
SkipFailed,
}
impl StagePolicy {
impl ResumePolicy {
/// 序列化为 DB 文本列存储用的 snake_case 字符串。
pub fn as_str(&self) -> &'static str {
match self {
StagePolicy::SkipConverged => "skip_converged",
StagePolicy::ForceRecompute => "force_recompute",
StagePolicy::SkipFailed => "skip_failed",
ResumePolicy::SkipConverged => "skip_converged",
ResumePolicy::ForceRecompute => "force_recompute",
ResumePolicy::SkipFailed => "skip_failed",
}
}
/// 从 DB 文本列回读;非法值兜底为默认 SkipConverged(防注入与脏数据)。
pub fn from_str_lossy(s: &str) -> Self {
match s {
"force_recompute" => StagePolicy::ForceRecompute,
"skip_failed" => StagePolicy::SkipFailed,
_ => StagePolicy::SkipConverged,
"force_recompute" => ResumePolicy::ForceRecompute,
"skip_failed" => ResumePolicy::SkipFailed,
_ => ResumePolicy::SkipConverged,
}
}
}
@@ -324,16 +330,16 @@ impl StagePolicy {
/// 见 docs/task_engine_decoupling_design.md §3:嵌套式单阶段配置模型,
/// 包含三个正交维度:enabled / policy / strategies。
///
/// 为避免与 `common::config::StageConfig`(迭代步进参数)同名冲突,命名为
/// `EngineStageConfig`。
/// 为避免与 `common::config::ChainStep`(迭代步进参数)同名冲突,命名为
/// `PhaseConfig`。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EngineStageConfig {
pub struct PhaseConfig {
/// 是否在当前计算流中启用该阶段。
#[serde(default = "default_engine_stage_enabled")]
pub enabled: bool,
/// 决定如何处理历史记录。
#[serde(default)]
pub policy: StagePolicy,
pub policy: ResumePolicy,
/// 策略链队列(按回退优先级排序),如 `["cold_run", "seed_step"]`。
/// 节点总是执行 `strategies[0]`;失败后由服务端弹出首项,下一顺位顶上。
#[serde(default)]
@@ -344,12 +350,12 @@ fn default_engine_stage_enabled() -> bool {
true
}
impl EngineStageConfig {
impl PhaseConfig {
/// TLUSTY 阶段默认配置:启用、增量、策略链 `[cold_run, seed_step]`。
pub fn default_tlusty() -> Self {
Self {
enabled: true,
policy: StagePolicy::SkipConverged,
policy: ResumePolicy::SkipConverged,
strategies: vec!["cold_run".to_string(), "seed_step".to_string()],
}
}
@@ -358,7 +364,7 @@ impl EngineStageConfig {
pub fn default_synspec() -> Self {
Self {
enabled: true,
policy: StagePolicy::SkipConverged,
policy: ResumePolicy::SkipConverged,
strategies: vec!["standard".to_string()],
}
}
@@ -382,20 +388,18 @@ impl EngineStageConfig {
/// Task execution specification sent to Node
///
/// 注:`EngineStageConfig` 刻意**不实现 `Default`**——阶段默认值随阶段而异(TLUSTY
/// 注:`PhaseConfig` 刻意**不实现 `Default`**——阶段默认值随阶段而异(TLUSTY
/// `[cold_run, seed_step]` vs SYNSPEC `[standard]`),无中立的默认语义。构造某阶段的配置请用
/// `..EngineStageConfig::default_tlusty()` / `..EngineStageConfig::default_synspec()`
/// `..PhaseConfig::default_tlusty()` / `..PhaseConfig::default_synspec()`
/// 避免把 TLUSTY 默认链误用到 synspec。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskSpec {
pub task_id: Uuid,
pub point_name: String,
pub params: GridPointParams,
/// **已废弃**:保留以兼容历史 MQ 在途消息与旧节点。新代码应读
/// `tlusty_config.strategies[0]` 判定当前 TLUSTY 策略。
/// 字段仍是必填(serde 反序列化要求),调度器在派发时会据
/// `tlusty_config.strategies[0]` 同步设置它,保证旧节点能正常工作。
pub task_type: TaskType,
/// **双义**Phase 7a 标注):TLUSTY 启用时 = seed_step 热启动的近邻种子点;
/// SYNSPEC-onlyTLUSTY 关闭)时 = 光谱输入大气来源点(由 atmosphere_ref/point_name 决定,
/// 字段此时恒 None)。executor 以 `tlusty_config.enabled` 区分两种语义。
pub seed_point_name: Option<String>,
pub timeout_sec: u64,
/// 所属工作流名称,用于按工作流隔离队列清理(stop_workflow 只清当前工作流的任务)。
@@ -408,46 +412,37 @@ pub struct TaskSpec {
pub wave: i32,
/// TLUSTY 阶段独立配置(见 docs/task_engine_decoupling_design.md §3)。
/// 旧 payload 反序列化时缺省为 `default_tlusty()`。
#[serde(default = "EngineStageConfig::default_tlusty")]
pub tlusty_config: EngineStageConfig,
#[serde(default = "PhaseConfig::default_tlusty")]
pub tlusty_config: PhaseConfig,
/// SYNSPEC 阶段独立配置。旧 payload 反序列化时缺省为 `default_synspec()`。
#[serde(default = "EngineStageConfig::default_synspec")]
pub synspec_config: EngineStageConfig,
/// SYNSPEC 数值参数(波长范围等,对应 `config::SynspecConfig`)。
#[serde(default = "PhaseConfig::default_synspec")]
pub synspec_config: PhaseConfig,
/// SYNSPEC 数值参数(波长范围等,对应 `config::SynspecInput`)。
/// 以 `serde_json::Value` 携带避免 models ↔ config 循环依赖;executor 侧
/// 反序列化为 `SynspecConfig` 后透传给 runner。None → runner 用硬编码默认。
/// 反序列化为 `SynspecInput` 后透传给 runner。None → runner 用硬编码默认。
/// 旧 payload 反序列化时缺省为 None(旧节点本就用默认,无回归)。
#[serde(default)]
pub synspec_params: Option<serde_json::Value>,
/// TLUSTY 物理迭代步进链(lte/nc/nl 多阶段 `config::ChainStep` 数组)。
/// 同样以 `serde_json::Value` 携带避免循环依赖;executor 反序列化为
/// `Vec<ChainStep>` 后透传给 runner 的 custom_chain 参数。
/// None/空 → executor 用 `default_chain_for_strategy` 兜底(按策略名选默认链)。
/// 旧 payload 反序列化时缺省为 None(旧节点本就用 default 链,无回归)。
#[serde(default)]
pub tlusty_chain_params: Option<serde_json::Value>,
/// TLUSTY 输入文件(.5 + nst)的全局物理参数(`config::TlustyInput`)。
/// NFREAD 频率网格、ions 能级表、nst extra_keys 等不随阶段变化的参数。
/// None → runner 用代码内硬编码默认(gen_input5.rs/nst_writer.rs 的常量)。
/// 旧 payload 反序列化时缺省为 None(旧节点本就用默认,无回归)。
#[serde(default)]
pub tlusty_input_params: Option<serde_json::Value>,
/// 仅 SYNSPEC-only 场景(TLUSTY 关闭)拉取大气用:显式关联大气网格点名。
#[serde(default)]
pub atmosphere_ref: Option<String>,
}
impl TaskSpec {
/// 旧版兼容归一化:据废弃的 `task_type` 回填 `tlusty_config.strategies` 首项。
///
/// 修复(审查 #8):serde default 已把 strategies 填为完整默认链 `[cold_run, seed_step]`
/// 故仅判 `is_empty` 无法覆盖「旧 seed_step 消息被误判为 cold_run」的场景。
/// 现据 task_type 把首项校正为对应的单策略链(旧消息的 task_type 是权威来源):
/// - task_type=SeedStep → `[seed_step]`(旧热启动消息不应被当冷启动重跑);
/// - task_type=ColdRun → 保持默认链(cold_run 本就是默认首项)。
pub fn normalize_compat(&mut self) {
let legacy_first = match self.task_type {
TaskType::ColdRun => "cold_run",
TaskType::SeedStep => "seed_step",
};
// 仅当当前 strategies 首项与 task_type 不一致时校正(避免覆盖显式配置)。
let needs_fix =
self.tlusty_config.strategies.first().map(|s| s.as_str()) != Some(legacy_first);
if needs_fix {
self.tlusty_config.strategies = vec![legacy_first.to_string()];
}
}
}
/// 仅供测试夹具构造便利:`TaskSpec::default()` 给出合法占位(task_id 零值、
/// 空点/参数、ColdRun、默认阶段配置)。生产代码应显式构造所有字段,避免依赖占位。
/// 空点/参数、默认阶段配置)。生产代码应显式构造所有字段,避免依赖占位。
impl Default for TaskSpec {
fn default() -> Self {
TaskSpec {
@@ -461,26 +456,20 @@ impl Default for TaskSpec {
logn: 0.0.into(),
logo: 0.0.into(),
},
task_type: TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 3600,
workflow_name: None,
wave: 0,
tlusty_config: EngineStageConfig::default_tlusty(),
synspec_config: EngineStageConfig::default_synspec(),
tlusty_config: PhaseConfig::default_tlusty(),
synspec_config: PhaseConfig::default_synspec(),
synspec_params: None,
tlusty_chain_params: None,
tlusty_input_params: None,
atmosphere_ref: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskType {
ColdRun,
SeedStep,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
@@ -499,10 +488,20 @@ pub struct TaskReport {
#[serde(default)]
pub params: Option<GridPointParams>,
pub node_id: String,
/// 任务整体成败(`derive_report_status`converged && 无 synspec 错误 → Completed
/// 半失败 = Failed)。整体语义,非阶段成败。
pub status: TaskStatus,
pub converged: bool,
/// **双义**Phase 7a/7b 标注):TLUSTY 启用时 = 大气收敛标志("本次大气产物是否可用");
/// SYNSPEC-onlyTLUSTY 关闭)被重写为管线成功。7b 改名 `result_valid` 消除字段名误读——
/// 阶段成败请用 `failed_stage` / `summary_json.synspec_*`。
///
/// 旧节点仍以字段名 `converged` 上报,serde alias 兼容(支持滚动升级)。
#[serde(alias = "converged")]
pub result_valid: bool,
/// 仅 TLUSTY 大气迭代有效(SYNSPEC-only 任务此量为 None 或大气来源值)。
pub max_relc: Option<f64>,
pub atmosphere_has_nan: bool,
/// 单点总墙钟耗时(秒,含 TLUSTY + SYNSPEC`synspec_sec` 是其子集)。
pub elapsed_sec: f64,
pub error_message: Option<String>,
pub summary_json: String,
@@ -570,11 +569,25 @@ pub struct ConvCheckResult {
pub n_depths: usize,
pub chmax: f64,
pub error: Option<String>,
/// 逐次迭代诊断(iter → 该次最大相对变化;Phase 5b 起由 fort.9 全量解析)。
/// 完整收敛轨迹:17 次迭代缓降 vs 顶着 NITER 上限勉强的发散轨迹一眼可辨。
#[serde(default)]
pub itek_history: Vec<IterCheck>,
}
/// 单次迭代的收敛诊断(fort.9 每迭代一拍)。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IterCheck {
pub iter: i32,
/// 该次迭代的最大相对变化(所有深度点 |maximum| 的最大值)。
pub max_relc: f64,
/// 该次迭代参与解析的深度点行数。
pub n_depths: usize,
}
/// Convergence stage summary recorded in conv.json
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StageSummary {
pub struct StepSummary {
pub label: String,
pub chmax: Option<f64>,
pub lte: String,
@@ -592,6 +605,10 @@ pub struct StageSummary {
/// 深度点总数,诊断用。
#[serde(default)]
pub n_depths: Option<usize>,
/// 逐次迭代诊断(iter → 该次最大相对变化)。**全量保真**summary_json 与 conv.json
/// 同源于 runner 的 StepSummary,故完整收敛轨迹随 summary_json 落库,不再仅存磁盘。
#[serde(default)]
pub itek_history: Vec<IterCheck>,
}
/// Full execution summary for a grid point
@@ -599,8 +616,15 @@ pub struct StageSummary {
pub struct ModelSummary {
pub name: String,
pub params: GridPointParams,
pub stages: Vec<StageSummary>,
pub converged: bool,
/// TLUSTY 收敛链子步骤摘要(lte/nc/nl 或 seed_nc/nl),**不是** TLUSTY/SYNSPEC 管线大阶段。
pub stages: Vec<StepSummary>,
/// 本次结果是否可用(P9 拆分,与 `TaskReport.result_valid` 对齐):
/// TLUSTY 启用时 = 大气收敛;SYNSPEC-onlyTLUSTY 关闭)被 reporter 重写为管线成功。
/// 读方不能仅凭字段名判断是哪个阶段——整体成败请用 `TaskReport.status`。
/// 旧 `conv.json`/`summary_json` 序列化的键名是 `converged``#[serde(alias)]` 兼容读取。
#[serde(alias = "converged")]
pub result_valid: bool,
/// 仅 TLUSTY 大气迭代有效(最大相对修正;SYNSPEC 阶段无此量)。
pub final_max_relc: Option<f64>,
pub final_chmax: Option<f64>,
pub seed: Option<String>,
@@ -608,8 +632,9 @@ pub struct ModelSummary {
pub synspec_rc: Option<i32>,
pub synspec_error: Option<String>,
pub synspec_sec: Option<f64>,
/// 单点总墙钟耗时(秒)。极旧版 conv.json 可能缺此字段,default 0.0 兜底
/// (展示层把 ≤0 视为"无数据");现版 run_one.py 总是写入
/// 单点总墙钟耗时(秒,含 TLUSTY + SYNSPEC)。**包含** `synspec_sec`(子集):
/// `elapsed_sec ≥ synspec_sec` 恒成立(synspec 为空时 synspec_sec=None
/// 极旧版 conv.json 可能缺此字段,default 0.0 兜底(展示层把 ≤0 视为"无数据")。
#[serde(default)]
pub elapsed_sec: f64,
pub note: Option<String>,
@@ -619,7 +644,7 @@ pub struct ModelSummary {
mod tests {
use super::*;
/// StageSummary 新增迭代诊断字段的向后兼容:
/// StepSummary 新增迭代诊断字段的向后兼容:
/// 旧 conv.json(无 last_iter/worst_depth/n_depths)必须能正常反序列化为 null,
/// 新写入的 conv.json 往返保真。
#[test]
@@ -628,12 +653,12 @@ mod tests {
"label": "nl", "chmax": 0.001, "lte": "F", "converged": true,
"best_max_relc": 0.0005, "elapsed_sec": 64.0, "note": null
}"#;
let st: StageSummary = serde_json::from_str(legacy).unwrap();
let st: StepSummary = serde_json::from_str(legacy).unwrap();
assert_eq!(st.last_iter, None);
assert_eq!(st.worst_depth, None);
assert_eq!(st.n_depths, None);
let full = StageSummary {
let full = StepSummary {
label: "nl".to_string(),
chmax: Some(0.001),
lte: "F".to_string(),
@@ -644,8 +669,13 @@ mod tests {
last_iter: Some(17),
worst_depth: Some(1),
n_depths: Some(50),
itek_history: vec![IterCheck {
iter: 1,
max_relc: 0.5,
n_depths: 50,
}],
};
let round: StageSummary =
let round: StepSummary =
serde_json::from_str(&serde_json::to_string(&full).unwrap()).unwrap();
assert_eq!(round.last_iter, Some(17));
assert_eq!(round.worst_depth, Some(1));
@@ -657,7 +687,7 @@ mod tests {
///
/// 载荷严格复刻 run_one.py 的真实输出形态:stage 含 `itek_attempts`/`final` 嵌套
/// dict、`note`、可选 `best_max_relc`,顶层含 `final_chmax`/`synspec_*`/`seed` 等。
/// Rust StageSummary 未声明的字段(itek_attempts/final)应被 serde 静默忽略。
/// Rust StepSummary 未声明的字段(itek_attempts/final)应被 serde 静默忽略。
#[test]
fn test_model_summary_parses_python_legacy_conv_json() {
let legacy = r#"{
@@ -699,7 +729,8 @@ mod tests {
let s: ModelSummary = serde_json::from_str(legacy).expect("旧版 conv.json 应可解析");
assert_eq!(s.name, "t20000_g5.0_he-2_c-4_n-4_o-4");
assert_eq!(*s.params.teff, 20000.0);
assert!(s.converged);
// 旧版键名 "converged" 经 #[serde(alias)] 兼容读入 result_valid。
assert!(s.result_valid);
assert!(!s.atmosphere_has_nan);
assert_eq!(s.final_max_relc, Some(0.0069));
assert_eq!(s.elapsed_sec, 715.0);
@@ -852,75 +883,80 @@ mod tests {
assert_eq!(GridPointStatus::Pending.to_string(), "pending");
assert_eq!(GridPointStatus::Queued.to_string(), "queued");
assert_eq!(GridPointStatus::Running.to_string(), "running");
assert_eq!(GridPointStatus::Converged.to_string(), "converged");
assert_eq!(GridPointStatus::Completed.to_string(), "completed");
assert_eq!(GridPointStatus::Failed.to_string(), "failed");
assert_eq!(GridPointStatus::from("queued"), GridPointStatus::Queued);
// 7c'completed' 权威值;'converged'/'done' 为 legacy 别名。
assert_eq!(
GridPointStatus::from("completed"),
GridPointStatus::Completed
);
assert_eq!(
GridPointStatus::from("converged"),
GridPointStatus::Converged
GridPointStatus::Completed
);
assert_eq!(GridPointStatus::from("done"), GridPointStatus::Converged);
assert_eq!(GridPointStatus::from("done"), GridPointStatus::Completed);
assert_eq!(GridPointStatus::from("failed"), GridPointStatus::Failed);
assert_eq!(GridPointStatus::from("unknown"), GridPointStatus::Pending);
}
/// `StagePolicy` 的 snake_case serde 往返 + DB 文本兜底。
/// `ResumePolicy` 的 snake_case serde 往返 + DB 文本兜底。
#[test]
fn test_stage_policy_serde_roundtrip() {
for p in [
StagePolicy::SkipConverged,
StagePolicy::ForceRecompute,
StagePolicy::SkipFailed,
ResumePolicy::SkipConverged,
ResumePolicy::ForceRecompute,
ResumePolicy::SkipFailed,
] {
let s = serde_json::to_string(&p).unwrap();
let back: StagePolicy = serde_json::from_str(&s).unwrap();
let back: ResumePolicy = serde_json::from_str(&s).unwrap();
assert_eq!(p, back);
}
// snake_case 形态锁定(前端 payload 与 DB 列口径)
assert_eq!(
serde_json::to_string(&StagePolicy::SkipConverged).unwrap(),
serde_json::to_string(&ResumePolicy::SkipConverged).unwrap(),
"\"skip_converged\""
);
assert_eq!(
serde_json::to_string(&StagePolicy::ForceRecompute).unwrap(),
serde_json::to_string(&ResumePolicy::ForceRecompute).unwrap(),
"\"force_recompute\""
);
assert_eq!(
serde_json::to_string(&StagePolicy::SkipFailed).unwrap(),
serde_json::to_string(&ResumePolicy::SkipFailed).unwrap(),
"\"skip_failed\""
);
// as_str/from_str_lossy 互逆(非法值兜底 SkipConverged
assert_eq!(
StagePolicy::from_str_lossy("skip_converged"),
StagePolicy::SkipConverged
ResumePolicy::from_str_lossy("skip_converged"),
ResumePolicy::SkipConverged
);
assert_eq!(
StagePolicy::from_str_lossy("garbage"),
StagePolicy::SkipConverged
ResumePolicy::from_str_lossy("garbage"),
ResumePolicy::SkipConverged
);
assert_eq!(
StagePolicy::from_str_lossy(StagePolicy::ForceRecompute.as_str()),
StagePolicy::ForceRecompute
ResumePolicy::from_str_lossy(ResumePolicy::ForceRecompute.as_str()),
ResumePolicy::ForceRecompute
);
}
/// `EngineStageConfig` serde 往返 + 默认值(缺字段时 serde default 兜底)。
/// `PhaseConfig` serde 往返 + 默认值(缺字段时 serde default 兜底)。
#[test]
fn test_engine_stage_config_serde_and_defaults() {
let cfg = EngineStageConfig {
let cfg = PhaseConfig {
enabled: false,
policy: StagePolicy::ForceRecompute,
policy: ResumePolicy::ForceRecompute,
strategies: vec!["cold_run".to_string(), "seed_step".to_string()],
};
let json = serde_json::to_string(&cfg).unwrap();
let back: EngineStageConfig = serde_json::from_str(&json).unwrap();
let back: PhaseConfig = serde_json::from_str(&json).unwrap();
assert_eq!(cfg, back);
// 空 payload 应产出默认值(enabled=true, policy=skip_converged, strategies=[]
let empty: EngineStageConfig = serde_json::from_str("{}").unwrap();
let empty: PhaseConfig = serde_json::from_str("{}").unwrap();
assert!(empty.enabled);
assert_eq!(empty.policy, StagePolicy::SkipConverged);
assert_eq!(empty.policy, ResumePolicy::SkipConverged);
assert!(empty.strategies.is_empty());
// current_strategy 空链兜底
@@ -930,43 +966,31 @@ mod tests {
assert!(!cfg.has_strategy("standard"));
}
/// 旧版 MQ 在途消息(仅含 task_type,无 tlusty_config)经 `#[serde(default)]`
/// 反序列化后,`normalize_compat()` 应据 task_type 回填 strategies。
/// Phase 6P8)删除 task_type 后,`normalize_compat` 与旧版单策略链校正机制整体移除。
/// 升级前需确认队列为空(docs/database_refactor_design.md §8.8):残留旧 payload 的
/// strategies 会按 serde default 填成默认链 [cold_run, seed_step],无 task_type 可校正。
#[test]
fn test_task_spec_normalize_compat_from_legacy_task_type() {
fn test_legacy_payload_without_task_type_uses_default_strategy_chain() {
let legacy_json = r#"{
"task_id": "00000000-0000-0000-0000-000000000001",
"point_name": "t20000_g5.0_he-2_c-4_n-4_o-4",
"params": {"teff": 20000.0, "logg": 5.0, "loghe": -2.0, "logc": -4.0, "logn": -4.0, "logo": -4.0},
"task_type": "seed_step",
"seed_point_name": "neighbor",
"timeout_sec": 7200,
"workflow_name": "wf_a",
"wave": 0
}"#;
let mut spec: TaskSpec = serde_json::from_str(legacy_json).unwrap();
// 修复后 normalize_compat 据 task_type 校正首项:旧 seed_step 消息的 strategies
// 首项应被校正为 seed_step(而非保留默认链的 cold_run 首项,否则会被误当冷启动)。
spec.normalize_compat();
// task_type 字段已被移除;旧 payload 即使仍携带该键也会被 serde 忽略(未知字段)。
// tlusty_config 缺省回落到默认链,首项 cold_run。
let spec: TaskSpec = serde_json::from_str(legacy_json).unwrap();
assert_eq!(
spec.tlusty_config.current_strategy("cold_run"),
"cold_run",
"无显式 strategies 的旧 payload 回落到默认链首项"
);
assert_eq!(
spec.tlusty_config.strategies,
vec!["seed_step".to_string()],
"旧 seed_step 消息应校正为 [seed_step] 单策略链"
);
assert_eq!(spec.tlusty_config.current_strategy("cold_run"), "seed_step");
// 对照:旧 cold_run 消息 → 首项已是 cold_run(默认链首项),无需校正。
let mut cold_spec = TaskSpec::default();
cold_spec.task_type = TaskType::ColdRun;
cold_spec.normalize_compat();
assert_eq!(
cold_spec
.tlusty_config
.strategies
.first()
.map(|s| s.as_str()),
Some("cold_run"),
"旧 cold_run 消息保持默认链"
vec!["cold_run".to_string(), "seed_step".to_string()]
);
}
}
+114 -5
View File
@@ -1,6 +1,17 @@
use crate::config::StageConfig;
use crate::config::{ChainStep, TlustyInput};
pub fn generate_nst_content(stage: &StageConfig) -> String {
/// 生成 TLUSTY 的 nst(非标准标志)文件内容。
///
/// nst 文件由 `KEY=VALUE` 对组成,TLUSTY 的 NSTPAR 子程序逐行解析(无行数限制)。
/// 内容来源(优先级从高到低):
/// 1. `input_cfg.nst_extra_keys`:用户自由传入的任意 KEY=VALUE(逃逸口),追加到末尾。
/// 2. `stage`ChainStep):CHMAX/ITEK/NITER/ORELAX/IDLTE/IACC/ICHANG 等阶段差异参数。
/// 3. 硬编码默认:ND/NLAMBD/VTB/ISPODF/DDNU/CNU1/IELCORinput_cfg 无对应字段时)。
///
/// `input_cfg` 为 None 时走全默认(与改动前行为完全一致,向后兼容)。
pub fn generate_nst_content(stage: &ChainStep, input_cfg: Option<&TlustyInput>) -> String {
// 第 1 行:深度点数/角度数/湍速/ODF 等物理网格参数 + 收敛控制。
// ND/NLAMBD/VTB/ISPODF/DDNU/CNU1 当前无结构化字段,保留硬编码(如需覆写用 extra_keys)。
let mut line1_parts = vec![
"ND=50".to_string(),
"NLAMBD=3".to_string(),
@@ -18,6 +29,7 @@ pub fn generate_nst_content(stage: &StageConfig) -> String {
}
line1_parts.push(format!("NITER={}", stage.niter));
// 第 2 行:加速/收敛控制开关。
let mut line2_parts = Vec::new();
if let Some(orelax) = stage.orelax {
line2_parts.push(format!("ORELAX={}", orelax));
@@ -33,7 +45,38 @@ pub fn generate_nst_content(stage: &StageConfig) -> String {
}
line2_parts.push("IELCOR=-1".to_string());
format!("{}\n{}\n", line1_parts.join(","), line2_parts.join(","))
let mut out = format!("{}\n{}\n", line1_parts.join(","), line2_parts.join(","));
// 第 3 行起:用户自由传入的额外 nst 关键字(逃逸口)。
// 每行一个 KEY=VALUE,追加到末尾。用于暴露未结构化的 220+ nst 关键字
// (如 FRCMAX/CUTBAL/TAU/NDGREY 等)。
// 多行格式经 TLUSTY 源码验证(tlusty208.f:1819 NSTPAR 用 `READ(INPFI,500,END=70)`
// + `GO TO 10` 逐行循环读至 EOF,每行用 GETWRD 解析 KEY=VALUE),追加行可被正确解析。
// 安全校验:key/value 含逗号/换行/等号会破坏 nst 的逗号分隔或 KEY=VALUE 解析,
// 跳过非法项并记 warn(避免生成损坏的 nst 导致 tlusty 行为异常)。
// L1 修复:**key 与 value 都校验**——key 含 `=`/`,`/空白/换行会产出畸形 `KEY=VALUE`
// 行,TLUSTY 的 GETWRD 解析器可能误读。key 必须是合法标识符(非空、无上述分隔符)。
if let Some(cfg) = input_cfg {
for (key, value) in &cfg.nst_extra_keys {
let bad_key = key.is_empty()
|| key.contains(',')
|| key.contains('\n')
|| key.contains('=')
|| key.chars().any(char::is_whitespace);
let bad_value = value.contains(',') || value.contains('\n') || value.contains('=');
if bad_key || bad_value {
tracing::warn!(
"跳过非法 nst_extra_keys 项 {:?}={:?}: 含逗号/换行/等号/空白会破坏 nst 解析",
key,
value
);
continue;
}
out.push_str(&format!("{}={}\n", key, value));
}
}
out
}
#[cfg(test)]
@@ -42,7 +85,7 @@ mod tests {
#[test]
fn test_nst_generation() {
let stage = StageConfig {
let stage = ChainStep {
label: "nc".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
@@ -57,9 +100,75 @@ mod tests {
iacc: None,
orelax: None,
};
let content = generate_nst_content(&stage);
let content = generate_nst_content(&stage, None);
assert!(content.contains("ND=50"));
assert!(content.contains("NITER=10"));
assert!(content.contains("IELCOR=-1"));
}
/// extra_keys 追加到 nst 末尾(每行一个 KEY=VALUE)。
#[test]
fn test_nst_extra_keys() {
let stage = ChainStep {
label: "nl".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
ilvlin: 100,
require_converged: true,
niter: 100,
chmax: Some(0.001),
itek: None,
metals: None,
ichang: None,
idlte: None,
iacc: None,
orelax: None,
};
let cfg = TlustyInput {
nfread: 2000,
atoms: Default::default(),
ions: vec![],
nst_extra_keys: vec![
("FRCMAX".to_string(), "0.01".to_string()),
("CUTBAL".to_string(), "0.3".to_string()),
],
};
let content = generate_nst_content(&stage, Some(&cfg));
assert!(content.contains("FRCMAX=0.01"), "extra_keys 应追加到 nst");
assert!(content.contains("CUTBAL=0.3"));
// 原有内容仍存在
assert!(content.contains("NITER=100"));
assert!(content.contains("CHMAX=0.001"));
}
/// None 配置时与改动前行为一致(无 extra_keys 行)。
#[test]
fn test_nst_none_cfg_backward_compat() {
let stage = ChainStep {
label: "lte".to_string(),
lte: "T".to_string(),
ltgray: "T".to_string(),
ilvlin: 0,
require_converged: false,
niter: 0,
chmax: None,
itek: None,
metals: None,
ichang: None,
idlte: None,
iacc: None,
orelax: None,
};
let content = generate_nst_content(&stage, None);
// None 配置时只有 2 行(第1行 ND/NITER 等 + 第2行 IELCOR 等),无 extra_keys 追加行。
let non_empty_lines: Vec<&str> = content.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
non_empty_lines.len(),
2,
"None 配置时 nst 应只有 2 行,实际 {} 行: {:?}",
non_empty_lines.len(),
non_empty_lines
);
assert!(!content.contains("FRCMAX"));
}
}
+41 -24
View File
@@ -15,7 +15,8 @@
//! `conv.json`、`fort.8`synspec 输入大气)、`fort.55`synspec 控制卡)
//! 2. **科学核心**`<name>.7`、`<name>.spec`、`<name>.cont`、`<name>.iden`、`<name>.log`、
//! `<name>.bfac`TLUSTY 最终 b 因子/非 LTE 偏离因子)、`<name>.emflux`TLUSTY 最终出射谱 λ–Fλ)
//! 3. **阶段快照**`<name>.<label>.5/.6/.err/.nst/.7`label ∈ lte/nc/nl/seed_nc
//! 3. **阶段快照**`<name>.<label>.5/.6/.err/.nst/.7`label 为任意单一标识符,
//! 含默认链 lte/nc/nl/seed_nc 与用户 `tlusty_chain` 自定义标签——M3 起不再硬编码白名单)
//! 4. **收敛诊断**`<name>.<label>_chmax*.9`**唯一保留的 .9**;裸 `<name>.<label>.9`
//! 已在 runner 源头停止写出,因其与 `_chmax*.9` 内容完全重复)
//!
@@ -30,15 +31,6 @@ const SCIENCE_SUFFIXES: &[&str] = &["7", "spec", "cont", "iden", "log", "bfac",
/// 阶段快照的文件名后缀(挂在 `<name>.<label>.` 之后)。
const STAGE_SNAPSHOT_SUFFIXES: &[&str] = &["5", "6", "err", "nst", "7"];
/// 合法阶段标签(来自 `default_cold_chain` / `default_seed_chain` 的 label)。
/// 阶段标签由 workflow 配置保证唯一,不会与科学后缀或 synspec 产物冲突。
///
/// **约束**:此处硬编码了默认链的 4 个标签。runner 的 `run_model_with_timeout`
/// 虽接受 `custom_chain`(可含任意 label),但当前唯一生产调用方(executor)传 `None`
/// 走默认链,故白名单覆盖安全。若将来启用自定义 chain 且引入新标签,需同步加入此处,
/// 否则带新标签的阶段快照(`.5/.6/.err/.nst/.7`)和 `_chmax*.9` 会被白名单静默丢弃。
const STAGE_LABELS: &[&str] = &["lte", "nc", "nl", "seed_nc"];
/// 有独立语义、保留的裸文件名(不以 model_name 为前缀)。
const BARE_KEEPS: &[&str] = &["conv.json", "fort.8", "fort.55"];
@@ -86,21 +78,25 @@ pub fn is_result_worthy(fname: &str, model_name: &str) -> bool {
}
// 3. 阶段快照:`<label>.<suffix>`(如 `nl.7`、`nc.nst`)。
// 用 split_once('.', label/suffix) 切一刀;label 必须在 STAGE_LABELS 内,
// suffix 必须在 STAGE_SNAPSHOT_SUFFIXES 内。这样能精确排除 `<name>.nl.9`
// (suffix=9 不在快照后缀集)等。
// M3 修复:不再硬编码 label 白名单——runner 的 `custom_chain` 允许用户在 YAML
// `tlusty_chain` 配置任意阶段标签,硬编码白名单(lte/nc/nl/seed_nc)会把自定义
// 标签的阶段快照静默丢弃。改按**结构**识别:`<label>.<suffix>`label 为不含
// '.' 的单一标识符,suffix 限定在快照后缀集内。这样自定义标签(如 `grey`/`base`
// 与默认标签同等归档;代价是 `<name>.<任意>.7` 这类罕见杂散文件也会被保留
// (低风险,宁可多留一份也不丢科学产物)。
if let Some((label, suffix)) = rest.split_once('.') {
if STAGE_LABELS.contains(&label) && STAGE_SNAPSHOT_SUFFIXES.contains(&suffix) {
if !label.is_empty() && !label.contains('.') && STAGE_SNAPSHOT_SUFFIXES.contains(&suffix) {
return true;
}
}
// 4. 收敛诊断:`<label>_chmax*.9`(如 `nl_chmax0.001.9`)。
// rest 以 `<label>_chmax` 开头,以 `.9` 结尾。这是唯一保留的 .9 形态
if rest.ends_with(".9") {
for label in STAGE_LABELS {
let tag = format!("{}_chmax", label);
if rest.starts_with(&tag) && rest.ends_with(".9") {
// 4. 收敛诊断:`<label>_chmax<value>.9`(如 `nl_chmax0.001.9`)。唯一保留的 .9 形态。
// 同样按结构识别:以 `.9` 结尾 + 去掉 `.9` 后含 `_chmax` + 标签为非空单 token
// 精确排除冗余的 `<name>.<label>.9`(无 `_chmax`,与此完全重复)。
if let Some(stem) = rest.strip_suffix(".9") {
if let Some(pos) = stem.rfind("_chmax") {
let label = &stem[..pos];
if !label.is_empty() && !label.contains('.') {
return true;
}
}
@@ -114,6 +110,8 @@ mod tests {
use super::*;
const NAME: &str = "t20000_g5.0_he-2_c-4_n-4_o-4";
// 默认链标签(测试沿用历史白名单;生产逻辑已改为结构识别,见 is_result_worthy)。
const STAGE_LABELS: &[&str] = &["lte", "nc", "nl", "seed_nc"];
#[test]
fn test_bare_keeps() {
@@ -201,10 +199,29 @@ mod tests {
assert!(!is_result_worthy(&format!("{}.nl.foo", NAME), NAME));
}
/// M3 回归:用户自定义阶段标签(非默认 lte/nc/nl/seed_nc)的阶段快照必须归档。
/// 硬编码 STAGE_LABELS 白名单会把自定义标签的 `.5/.6/.err/.nst/.7` 与 `_chmax*.9`
/// 静默丢弃;现改为结构识别,任意单一标识符标签均保留。
#[test]
fn test_unknown_stage_label_skipped() {
// 未知的阶段标签不归档(防御性:未来若引入新标签需显式加入 STAGE_LABELS
assert!(!is_result_worthy(&format!("{}.xxx.7", NAME), NAME));
assert!(!is_result_worthy(&format!("{}.xxx.nst", NAME), NAME));
fn test_custom_stage_label_kept() {
for label in ["grey", "base", "myscenario"] {
for s in ["5", "6", "err", "nst", "7"] {
let f = format!("{}.{}.{}", NAME, label, s);
assert!(
is_result_worthy(&f, NAME),
"自定义标签 {} 的阶段快照 {} 应归档",
label,
f
);
}
let chmax = format!("{}.{}_chmax0.001.9", NAME, label);
assert!(
is_result_worthy(&chmax, NAME),
"自定义标签 {} 的 chmax 诊断应归档",
label
);
}
// 冗余的 `<name>.<label>.9`(无 _chmax)仍不应归档(与 _chmax.9 重复)。
assert!(!is_result_worthy(&format!("{}.grey.9", NAME), NAME));
}
}
+142 -38
View File
@@ -1,9 +1,9 @@
use crate::config::{StageConfig, SynspecConfig};
use crate::conv_check::{atmosphere_has_nan, check_fort9};
use crate::config::{ChainStep, SynspecInput, TlustyInput};
use crate::conv_check::{atmosphere_has_nan, check_fort9, extract_failure_hint, spec_is_valid};
use crate::embedded::RuntimePaths;
use crate::fort55_writer::generate_fort55_content;
use crate::gen_input5::make_input5;
use crate::models::{GridPointParams, ModelSummary, StageSummary, TaskType};
use crate::models::{GridPointParams, ModelSummary, StepSummary};
use crate::nst_writer::generate_nst_content;
use anyhow::Result;
use std::path::{Path, PathBuf};
@@ -13,9 +13,9 @@ use tokio::fs::File;
use tokio::process::Command as AsyncCommand;
use tracing::{info, warn};
pub fn default_cold_chain() -> Vec<StageConfig> {
pub fn default_cold_chain() -> Vec<ChainStep> {
vec![
StageConfig {
ChainStep {
label: "lte".to_string(),
lte: "T".to_string(),
ltgray: "T".to_string(),
@@ -30,7 +30,7 @@ pub fn default_cold_chain() -> Vec<StageConfig> {
iacc: None,
orelax: None,
},
StageConfig {
ChainStep {
label: "nc".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
@@ -45,7 +45,7 @@ pub fn default_cold_chain() -> Vec<StageConfig> {
iacc: None,
orelax: None,
},
StageConfig {
ChainStep {
label: "nl".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
@@ -63,9 +63,9 @@ pub fn default_cold_chain() -> Vec<StageConfig> {
]
}
pub fn default_seed_chain() -> Vec<StageConfig> {
pub fn default_seed_chain() -> Vec<ChainStep> {
vec![
StageConfig {
ChainStep {
label: "seed_nc".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
@@ -80,7 +80,7 @@ pub fn default_seed_chain() -> Vec<StageConfig> {
iacc: None,
orelax: None,
},
StageConfig {
ChainStep {
label: "nl".to_string(),
lte: "F".to_string(),
ltgray: "F".to_string(),
@@ -98,6 +98,19 @@ pub fn default_seed_chain() -> Vec<StageConfig> {
]
}
/// 按当前策略选默认执行链(`custom_chain` 为 None/空时的兜底)。
///
/// Phase 6P8)起取代废弃的 task_type 匹配:`"seed_step"` → 种子热启动链,其余策略
/// `cold_run` 等)→ 冷启动链。executor 现优先使用 TaskSpec.tlusty_chain_params
/// (用户 YAML `tlusty_chain:` 配置),None/空才回退本函数的默认链。
pub fn default_chain_for_strategy(current_strategy: &str) -> Vec<ChainStep> {
if current_strategy == "seed_step" {
default_seed_chain()
} else {
default_cold_chain()
}
}
/// 运行子进程,带超时与优雅退出(shutdown)感知。
///
/// 三种终止路径:
@@ -199,19 +212,21 @@ impl<'a> ExecutionRunner<'a> {
Self { runtime, work_dir }
}
#[allow(clippy::too_many_arguments)] // 透传全参给 run_model_with_timeout(后者同 allow
pub async fn run_model(
&self,
params: &GridPointParams,
name: &str,
task_type: TaskType,
custom_chain: Option<Vec<StageConfig>>,
current_strategy: &str,
custom_chain: Option<Vec<ChainStep>>,
seed_atmos: Option<&Path>,
synspec_cfg: Option<&SynspecConfig>,
synspec_cfg: Option<&SynspecInput>,
tlusty_input: Option<&TlustyInput>,
) -> Result<ModelSummary> {
self.run_model_with_timeout(
params,
name,
task_type,
current_strategy,
custom_chain,
seed_atmos,
synspec_cfg,
@@ -219,6 +234,7 @@ impl<'a> ExecutionRunner<'a> {
true,
7200,
None,
tlusty_input,
)
.await
}
@@ -228,19 +244,24 @@ impl<'a> ExecutionRunner<'a> {
/// `tlusty_enabled` / `synspec_enabled` 控制各阶段是否运行:
/// - TLUSTY 关闭:跳过 chain 循环,直接以 seed_atmos(或单独拉取的大气)作 final_7;
/// - SYNSPEC 关闭:跳过光谱合成块(即便 final_7 存在)。
///
/// Phase 6P8):`current_strategy` 取代废弃的 `task_type`——执行链由
/// `custom_chain`executor 按 `tlusty_config.strategies[0]` 显式推导)决定;
/// 该参数仅用于日志与 custom_chain=None 时的兜底("seed_step"→种子链,否则冷启动链)。
#[allow(clippy::too_many_arguments)]
pub async fn run_model_with_timeout(
&self,
params: &GridPointParams,
name: &str,
task_type: TaskType,
custom_chain: Option<Vec<StageConfig>>,
current_strategy: &str,
custom_chain: Option<Vec<ChainStep>>,
seed_atmos: Option<&Path>,
synspec_cfg: Option<&SynspecConfig>,
synspec_cfg: Option<&SynspecInput>,
tlusty_enabled: bool,
synspec_enabled: bool,
timeout_sec: u64,
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
tlusty_input: Option<&TlustyInput>,
) -> Result<ModelSummary> {
// `name` 取自权威的 TaskSpec.point_nameDB 的 grid_points.name 列,源精度正确),
// 而非 params.model_name()。原因:服务端把 GridPointParams 存成 6 个 REAL 数值列,
@@ -255,7 +276,7 @@ impl<'a> ExecutionRunner<'a> {
let model_dir = self.work_dir.join(name);
tokio::fs::create_dir_all(&model_dir).await?;
info!("开始物理计算网格模型 {} (类型: {:?})", name, task_type);
info!("开始物理计算网格模型 {} (策略: {})", name, current_strategy);
let t0 = Instant::now();
// 1. Data directory symlink setup
@@ -296,10 +317,7 @@ impl<'a> ExecutionRunner<'a> {
let _ = tokio::fs::remove_file(&fort84).await;
}
let chain = custom_chain.unwrap_or_else(|| match task_type {
TaskType::ColdRun => default_cold_chain(),
TaskType::SeedStep => default_seed_chain(),
});
let chain = custom_chain.unwrap_or_else(|| default_chain_for_strategy(current_strategy));
let mut stage_summaries = Vec::new();
let mut current_seed: Option<PathBuf> = seed_atmos.map(|p| p.to_path_buf());
@@ -329,13 +347,14 @@ impl<'a> ExecutionRunner<'a> {
&stage_def.ltgray,
metals,
stage_def.ilvlin,
tlusty_input,
);
let input5_path = model_dir.join(format!("{}.5", name));
tokio::fs::write(&input5_path, &input5_text).await?;
// Write nst file
let nst_text = generate_nst_content(stage_def);
let nst_text = generate_nst_content(stage_def, tlusty_input);
tokio::fs::write(model_dir.join("nst"), &nst_text).await?;
// Prepare fort.8 for this stage
@@ -386,7 +405,7 @@ impl<'a> ExecutionRunner<'a> {
let fort9 = model_dir.join("fort.9");
let fort7 = model_dir.join("fort.7");
let mut stage_summary = StageSummary {
let mut stage_summary = StepSummary {
label: stage_def.label.clone(),
chmax: stage_def.chmax,
lte: stage_def.lte.clone(),
@@ -397,6 +416,7 @@ impl<'a> ExecutionRunner<'a> {
last_iter: None,
worst_depth: None,
n_depths: None,
itek_history: Vec::new(),
};
if rc == 0 && fort7.is_file() {
@@ -410,15 +430,50 @@ impl<'a> ExecutionRunner<'a> {
stage_summary.last_iter = res.last_iter;
stage_summary.worst_depth = Some(res.worst_depth);
stage_summary.n_depths = Some(res.n_depths);
// itek 全量保真(Phase 5b):逐次迭代诊断随 summary_json/conv.json 落库。
stage_summary.itek_history = res.itek_history;
// 漏洞5修复:发散时从 fort.6 提取求解器 STOP 行(SOLVE/SOLVES/RYBSOL
// 作为 note,提升归因质量。仅未收敛且无既有 note 时补(避免覆盖错误信息)。
if !res.converged && stage_summary.note.is_none() {
let fort6 = model_dir.join(format!("{}.6", name));
if let Some(h) = extract_failure_hint(&fort6) {
stage_summary.note = Some(format!("未收敛 [{}]", h));
}
}
// Save fort.9 snapshot
let snap_name = format!("{}.{}_chmax{}.9", name, stage_def.label, eff_chmax);
let _ = tokio::fs::copy(&fort9, model_dir.join(snap_name)).await;
} else {
// NITER=0 grey start without fort.9
stage_summary.converged = true;
stage_summary.best_max_relc = Some(0.0);
stage_summary.note = Some("NITER=0 grey start".to_string());
// fort.9 缺失:按 stage_def.niter 区分两种场景(漏洞2进阶修复)。
// - niter==0:合法 grey startlte 阶段不迭代,TLUSTY 不写 fort.9)。
// converged=true 保留 grey start 语义;best_max_relc=None 不虚构
// (避免污染 final_max_relc/种子选择)。
// - niter>0:异常——配了迭代却无 fort.9,通常是 TLUSTY 启动失败
// call quit,如 temp 越界)或 IO 异常。判 converged=false
// 避免把崩溃误判为收敛。此前两种场景共用无校验分支无法区分。
if stage_def.niter == 0 {
stage_summary.converged = true;
stage_summary.best_max_relc = None;
stage_summary.note = Some("NITER=0 grey start".to_string());
} else {
stage_summary.converged = false;
stage_summary.best_max_relc = None;
// 补 fort.6 失败诊断(call quit 留言),便于排查启动失败原因。
let fort6 = model_dir.join(format!("{}.6", name));
let hint = extract_failure_hint(&fort6);
stage_summary.note = Some(match hint {
Some(h) => format!(
"stage {} 配置 NITER={} 但 fort.9 缺失 [{}]",
stage_def.label, stage_def.niter, h
),
None => format!(
"stage {} 配置 NITER={} 但 fort.9 缺失(TLUSTY 未完成迭代)",
stage_def.label, stage_def.niter
),
});
}
}
// Copy fort.7 as stage seed
@@ -426,7 +481,14 @@ impl<'a> ExecutionRunner<'a> {
let _ = tokio::fs::copy(&fort7, &stage_seed_path).await;
current_seed = Some(stage_seed_path);
} else {
stage_summary.note = Some(format!("tlusty rc={} or missing fort.7", rc));
// 漏洞5修复:fort.7 缺失分支(输入错误、temp 越界等 call quit 场景),
// 从 fort.6 尾部提取 call quit / stop 留言补进 note,便于排查。
let fort6 = model_dir.join(format!("{}.6", name));
let hint = extract_failure_hint(&fort6);
stage_summary.note = Some(match hint {
Some(h) => format!("tlusty rc={} or missing fort.7 [{}]", rc, h),
None => format!("tlusty rc={} or missing fort.7", rc),
});
}
// 快照本阶段的同名输入/输出文件,带阶段标签保留。
@@ -485,7 +547,15 @@ impl<'a> ExecutionRunner<'a> {
// fort.12/14 不存在,函数内按文件是否存在静默跳过。
snapshot_tlusty_outputs(&model_dir, name).await;
let atmo_has_nan = atmosphere_has_nan(&final_7);
// L2 修复:`atmosphere_has_nan` 对**缺失**文件返回 false(语义是"无 NaN"而非"有效"),
// 与**空文件返回 true** 语义不对称。缺失最终大气 = 无可判定收敛的干净大气 →
// 在此显式判定为无效(NaN),与空文件语义对齐。调用前提:final_7 应在收敛链产出;
// 若缺失(如种子拷贝失败、TLUSTY 崩溃未写 fort.7),本守卫强制最终不收敛。
let atmo_has_nan = if final_7.is_file() {
atmosphere_has_nan(&final_7)
} else {
true
};
if atmo_has_nan {
final_converged = false;
}
@@ -518,7 +588,7 @@ impl<'a> ExecutionRunner<'a> {
let _ = tokio::fs::remove_file(&fort55_path).await;
let _ = tokio::fs::remove_file(&fort19_path).await;
let default_cfg = SynspecConfig {
let default_cfg = SynspecInput {
wstart: 1400.0,
wend: 1410.0,
imode: 0,
@@ -577,11 +647,22 @@ impl<'a> ExecutionRunner<'a> {
// Copy/move outputs: fort.7 (Synspec spectrum) -> .spec, fort.17 -> .cont, fort.12 -> .iden
if model_dir.join("fort.7").is_file() {
let _ = tokio::fs::rename(
model_dir.join("fort.7"),
model_dir.join(format!("{}.spec", name)),
)
.await;
let spec_path = model_dir.join(format!("{}.spec", name));
let _ = tokio::fs::rename(model_dir.join("fort.7"), &spec_path).await;
// 漏洞1修复(P0):SYNSPEC .spec 内容校验。
// gfortran 下 SYNSPEC 几乎所有错误路径 rc=0,旧代码只做 is_file() 存在性
// 检查,导致脏谱(NaN/Inf/行数不足/全零)被当作 Completed 归档——全链路
// 最大的科学正确性风险。命中无效则置 synspec_rc 非零 + synspec_error 描述,
// 让 reporter 判 Failed 并触发 synspec 策略链回退。
// 守卫 synspec_err.is_none():避免覆盖上游 fort.8/fort.55 复制失败的既有 err。
if synspec_err.is_none() {
if let Some(reason) = spec_is_valid(&spec_path) {
warn!("spec 校验失败: {}", reason);
synspec_rc = Some(1);
synspec_err = Some(reason);
}
}
}
if model_dir.join("fort.17").is_file() {
let _ = tokio::fs::copy(
@@ -642,7 +723,7 @@ impl<'a> ExecutionRunner<'a> {
let note = {
let mut notes: Vec<String> = Vec::new();
if atmo_has_nan {
notes.push("Invalidated: atmosphere contains >10% NaN lines".to_string());
notes.push("Invalidated: atmosphere contains NaN/Inf lines".to_string());
}
if let Some(ref err) = synspec_err {
notes.push(format!("synspec error: {}", err));
@@ -662,7 +743,7 @@ impl<'a> ExecutionRunner<'a> {
name: name.to_string(),
params: params.clone(),
stages: stage_summaries,
converged: final_converged,
result_valid: final_converged,
final_max_relc,
final_chmax,
seed: seed_atmos.map(|p| p.to_string_lossy().to_string()),
@@ -687,6 +768,29 @@ mod tests {
use super::snapshot_tlusty_outputs;
use crate::models::{GridAxisValue, GridPointParams};
/// Phase 6(P8):执行链按当前策略派生——seed_step 走种子热启动链,其余走冷启动链。
#[test]
fn test_default_chain_for_strategy() {
let labels = |chain: Vec<super::ChainStep>| -> Vec<String> {
chain.into_iter().map(|s| s.label).collect()
};
// seed_step → 种子热启动链(seed_nc/nl)。
assert_eq!(
labels(super::default_chain_for_strategy("seed_step")),
vec!["seed_nc".to_string(), "nl".to_string()]
);
// 其余策略(cold_run / 未知如 standard)→ 冷启动链(lte/nc/nl)。
assert_eq!(
labels(super::default_chain_for_strategy("cold_run")),
vec!["lte".to_string(), "nc".to_string(), "nl".to_string()]
);
assert_eq!(
labels(super::default_chain_for_strategy("standard")),
vec!["lte".to_string(), "nc".to_string(), "nl".to_string()],
"未知策略兜底冷启动链(synspec-only strategies[0] 等)"
);
}
#[test]
fn test_synspec_timeout_calculation() {
let long_tlusty_timeout: u64 = 7200;
+1 -1
View File
@@ -15,7 +15,7 @@ pub const MAX_GLOBAL_SEED_DISTANCE: f64 = 3.0;
/// **数据标定依据**——对历史 1191 个真实 seed_step(种子,目标)配对的成败统计:
/// - 贫金属方向(种子更富、目标往贫走,delta=目标−种子 < 0):成功率 **4254%**
/// - 富金属方向(目标更富、delta > 0):成功率仅 **311%**
/// (每个 loghe 分层该规律独立成立,he=−4 时贫方向 54% vs 富方向 3%,差 18 倍)
/// (每个 loghe 分层该规律独立成立,he=−4 时贫方向 54% vs 富方向 3%,差 18 倍)
///
/// **物理解释**:从高金属丰度的收敛解出发**减少**金属(贫方向)是稳定微扰;
/// 反过来从贫金属种子**增加**金属(富方向),新增的紫外谱线辐射驱动会破坏已建立的
+20 -37
View File
@@ -212,14 +212,9 @@ 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();
// Phase 6P8):task_type 兼容字段与 normalize_compat 整体废弃。
// 升级前须确认队列为空(docs/database_refactor_design.md §8.8)——残留
// 旧 payload 的 strategies 会按 serde default 填成默认链,无法再校正。
// 记录任务归属:claim 时写入领用方 node_id,供 report 阶段校验,
// 杜绝「节点 A 领用、节点 B 上报」的跨节点伪造结果投毒。
@@ -391,13 +386,10 @@ impl SqliteTaskQueue {
let mut stmt = conn.prepare(
"DELETE FROM task_queue WHERE workflow_name = ?1 AND status = 'pending' RETURNING task_id",
)?;
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);
}
}
let ids: Vec<String> = stmt
.query_map(params![wf_owned], |row| row.get::<_, String>(0))?
.filter_map(Result::ok)
.collect();
Ok(ids)
})
.await??;
@@ -431,7 +423,7 @@ impl SqliteTaskQueue {
#[cfg(test)]
mod tests {
use super::*;
use common::models::{GridPointParams, TaskType};
use common::models::GridPointParams;
use uuid::Uuid;
/// H1 修复:队列库迁移必须把历史遗留的 NULL workflow_name 行回填为 '__legacy__'
@@ -467,7 +459,9 @@ mod tests {
}
// 打开队列库(触发迁移:补列检查 + NULL workflow_name 回填)。
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy()).await.unwrap();
let queue = SqliteTaskQueue::new(&db_path.to_string_lossy())
.await
.unwrap();
let wf: String = {
let conn = queue.pool.get().unwrap();
@@ -503,7 +497,6 @@ mod tests {
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()),
@@ -552,7 +545,6 @@ mod tests {
task_id,
point_name: params.model_name(),
params: params.clone(),
task_type: TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 60,
workflow_name: None,
@@ -616,7 +608,6 @@ mod tests {
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()),
@@ -650,7 +641,7 @@ mod tests {
.await
.unwrap();
assert_eq!(
claim_a.map(|(p, w)| (p, w)),
claim_a,
Some((params.model_name(), Some("wf_rq".to_string()))),
"requeue 后原节点仍持归属,应放行"
);
@@ -690,7 +681,6 @@ mod tests {
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()),
@@ -803,20 +793,18 @@ 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
/// 否则旧热启动消息会被节点误当冷启动执行。
/// Phase 6P8):旧版消息兼容(normalize_compat)整体废弃。升级前确认队列为空
/// docs/database_refactor_design.md §8.8)后,旧 payload 的 task_type 键被 serde 忽略,
/// strategies 回落到默认链——出队仍正常,只是不再有 task_type 校正
#[tokio::test]
async fn test_pop_normalizes_legacy_seed_step_message() {
async fn test_pop_legacy_payload_without_task_type_correction() {
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 字段)。
// 旧版 payload 仍携带 task_type 键(已被忽略)+ 无 tlusty_config(回落默认链)。
let legacy_task_id = uuid::Uuid::new_v4();
let legacy_payload = serde_json::json!({
"task_id": legacy_task_id.to_string(),
@@ -848,16 +836,11 @@ mod tests {
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"
);
// 无 task_type 校正:strategies 为 serde default 默认链,首项 cold_run
assert_eq!(
task.tlusty_config.current_strategy("cold_run"),
"seed_step",
"current_strategy 应为 seed_step(而非默认链的 cold_run"
"cold_run",
"旧 payload 无显式 strategies,回落到默认链首项"
);
}
+132 -20
View File
@@ -1,5 +1,5 @@
use anyhow::Result;
use common::config::SynspecConfig;
use common::config::{ChainStep, SynspecInput, TlustyInput};
use common::embedded::{ensure_specific_data_files, RuntimePaths};
use common::models::{ModelSummary, TaskSpec};
use common::result_filter::is_result_worthy;
@@ -157,22 +157,49 @@ pub async fn execute_task(
// 3. slot_work_dir 已在种子下载前提前创建,种子私有副本亦已落盘于沙盒内。)
// 反序列化工作流携带的 SYNSPEC 数值参数(波长范围等)。None → runner 用硬编码默认。
let synspec_cfg: Option<SynspecConfig> = task
let synspec_cfg: Option<SynspecInput> = task
.synspec_params
.as_ref()
.and_then(|v| serde_json::from_value::<SynspecConfig>(v.clone()).ok());
.and_then(|v| serde_json::from_value::<SynspecInput>(v.clone()).ok());
let runner = ExecutionRunner::new(runtime, slot_work_dir.clone());
// 执行链来源(优先级):
// 1. TaskSpec.tlusty_chain_params(用户在 YAML `tlusty_chain:` 配置的多阶段 ChainStep
// 数组,由 scheduler 序列化注入)——非空时优先使用,使用户能细粒度控制 niter/chmax/
// metals 等阶段参数。
// 2. default_chain_for_strategy(current_strategy) 兜底——按策略名(cold_run/seed_step
// 选预设默认链(runner.rs 的 default_cold_chain / default_seed_chain)。
// 历史:Phase 6 起仅用 default 链(用户 config.chain 被忽略,是死字段);本次接通后
// 用户配置真正生效,default 链降级为兜底。旧 MQ payload(无 tlusty_chain_params 字段)
// 反序列化为 None → 回退 default 链,行为与旧版完全一致(向后兼容)。
let chain = resolve_execution_chain(
current_strategy,
&task.tlusty_chain_params,
&task.task_id.to_string(),
);
// TLUSTY 输入文件全局参数(NFREAD/ions 表/nst extra_keys 等)。
// None → runner 用代码内硬编码默认(向后兼容)。
let tlusty_input =
task.tlusty_input_params.as_ref().and_then(|v| {
match serde_json::from_value::<TlustyInput>(v.clone()) {
Ok(t) => Some(t),
Err(e) => {
warn!(
"任务 {} 的 tlusty_input_params 反序列化失败,回退默认输入: {}",
task.task_id, e
);
None
}
}
});
let summary = runner
.run_model_with_timeout(
&task.params,
// 用权威的 point_nameDB grid_points.name 列,源精度正确)作为模型名,
// 而非 task.params.model_name()(后者经 DB REAL 列回读已丢精度 "5.0"→"5")。
&task.point_name,
task.task_type.clone(),
// custom_chain 恒为 None:执行链由 task_type 决定(ColdRun→default_cold_chain、
// SeedStep→default_seed_chain),节点端不再做「缺种子回退冷启动链」的降级。
None,
current_strategy,
Some(chain),
seed_atmos_path.as_deref(),
synspec_cfg.as_ref(),
// 阶段独立配置开关(见 docs/task_engine_decoupling_design.md §5)。
@@ -180,17 +207,18 @@ pub async fn execute_task(
task.synspec_config.enabled,
task.timeout_sec,
shutdown,
tlusty_input.as_ref(),
)
.await?;
info!(
"完成计算任务 {} (网格点: {}, 收敛状态: {})",
task.task_id, task.point_name, summary.converged
"完成计算任务 {} (网格点: {}, 结果可用: {})",
task.task_id, task.point_name, summary.result_valid
);
// Read seed bytes if converged and clean
// Read seed bytes if result usable and clean
let mut seed_bytes: Option<Vec<u8>> = None;
if summary.converged && !summary.atmosphere_has_nan {
if summary.result_valid && !summary.atmosphere_has_nan {
let model_sub_dir = slot_work_dir.join(&summary.name);
let candidates = [
model_sub_dir.join(format!("{}.7", summary.name)),
@@ -350,15 +378,23 @@ pub async fn save_result_artifacts(result_dir: &Path, slot_work_dir: &Path, name
);
}
/// 归档目录不做数量上限治理:所有已算网格点的完整产物(.spec/.cont/.iden/各阶段
/// 快照/日志/种子二进制等)一律永久保留,避免 LRU 淘汰导致科学产物丢失
/// 2026-08-02 修正:撤销 1bfa240 引入的 MAX_RESULT_MODELS=200 LRU 上限)。
// 归档目录不做数量上限治理:所有已算网格点的完整产物(.spec/.cont/.iden/各阶段
// 快照/日志/种子二进制等)一律永久保留,避免 LRU 淘汰导致科学产物丢失
// 2026-08-02 修正:撤销 1bfa240 引入的 MAX_RESULT_MODELS=200 LRU 上限)。
/// `.seed_cache/` 内保留的 `.seed.7` 文件上限。超过则按 mtime 删除最旧的。
/// 典型网格内活跃种子点数量有限,8 足以覆盖常用邻域且把磁盘占用控制在 ~8 个种子文件。
const MAX_SEED_CACHE_FILES: usize = 8;
///
/// 审查修复 #N5:上限可经环境变量 `DCTS_SEED_CACHE_MAX` 覆盖(默认 8)。多工作流或密集
/// 网格下常用邻域种子可能超过 8 个,硬编码上限会导致反复从 server 下载,增加负载。
fn seed_cache_max_files() -> usize {
std::env::var("DCTS_SEED_CACHE_MAX")
.ok()
.and_then(|v| v.parse().ok())
.filter(|n: &usize| *n > 0)
.unwrap_or(8)
}
/// LRU 清理种子缓存目录:当 `.seed.7` 文件数超过 `MAX_SEED_CACHE_FILES` 时,
/// LRU 清理种子缓存目录:当 `.seed.7` 文件数超过上限(`DCTS_SEED_CACHE_MAX`,默认 8时,
/// 按 mtime 升序删除最旧的若干个,直到不超过上限。仅统计 `.seed.7`,忽略 `.tmp` 中间文件。
/// 任何 IO 错误均降级为 warn,不阻断主流程。
pub async fn cleanup_seed_cache(seed_dir: &Path) {
@@ -391,13 +427,14 @@ pub async fn cleanup_seed_cache(seed_dir: &Path) {
Err(_) => return,
};
if entries.len() <= MAX_SEED_CACHE_FILES {
let max_files = seed_cache_max_files();
if entries.len() <= max_files {
return;
}
// 按 mtime 升序(最旧在前),删除超出上限的最旧文件
entries.sort_by_key(|(mtime, _)| *mtime);
let to_remove = entries.len().saturating_sub(MAX_SEED_CACHE_FILES);
let to_remove = entries.len().saturating_sub(max_files);
for (_, path) in entries.into_iter().take(to_remove) {
match tokio::fs::remove_file(&path).await {
Ok(()) => info!("LRU 清理种子缓存文件: {}", path.display()),
@@ -411,6 +448,43 @@ pub async fn cleanup_seed_cache(seed_dir: &Path) {
}
}
/// 解析任务要执行的大气链(冷启动链 / 种子热启动链)。
///
/// 优先级(修复回归):
/// - `current_strategy == "seed_step"` → 强制 `default_seed_chain()`(seed_nc→nl)。
/// 自定义 `tlusty_chain` 是冷启动链:首步 lte 的 `ltgray=T` 会删除 fort.8、丢弃已下载的
/// 热启动种子(runner.rs 阶段 fort.8 准备逻辑)。scheduler 在派发与回退两条路径都注入
/// 同一个 `tlusty_chain`,若 seed_step 也沿用自定义链,会把种子回退退化成本地冷启动,
/// 丢失热启动语义。故种子链固定走内置默认,仅在 cold_run 等冷策略下信任用户自定义链。
/// - 其余策略 → 优先 TaskSpec.tlusty_chain_params(用户 YAML `tlusty_chain:` 配置),非空即用;
/// 为空/反序列化失败 → `default_chain_for_strategy(current_strategy)` 兜底。
fn resolve_execution_chain(
current_strategy: &str,
tlusty_chain_params: &Option<serde_json::Value>,
task_id: &str,
) -> Vec<ChainStep> {
if current_strategy == "seed_step" {
common::runner::default_seed_chain()
} else {
tlusty_chain_params
.as_ref()
.and_then(
|v| match serde_json::from_value::<Vec<ChainStep>>(v.clone()) {
Ok(c) => Some(c),
Err(e) => {
warn!(
"任务 {} 的 tlusty_chain_params 反序列化失败,回退 default 链: {}",
task_id, e
);
None
}
},
)
.filter(|c| !c.is_empty())
.unwrap_or_else(|| common::runner::default_chain_for_strategy(current_strategy))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -429,7 +503,7 @@ mod tests {
name: params.model_name(),
params,
stages: vec![],
converged: true,
result_valid: true,
final_max_relc: Some(0.0005),
final_chmax: None,
seed: None,
@@ -442,6 +516,44 @@ mod tests {
}
}
fn labels(chain: &[ChainStep]) -> Vec<String> {
chain.iter().map(|s| s.label.clone()).collect()
}
/// P1 回归防护:seed_step 即使注入自定义冷启动链,也必须强制走种子热启动默认链
/// seed_nc→nl),否则首步 lte(ltgray=T) 会删 fort.8、丢弃已下载种子,回退退化成本地冷启动。
#[test]
fn seed_step_ignores_custom_cold_chain() {
let custom = serde_json::json!([
{"label": "lte", "lte": "T", "ltgray": "T", "ilvlin": 0, "niter": 0},
{"label": "nc", "lte": "F", "ltgray": "F", "ilvlin": 0, "niter": 10},
{"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100},
]);
let chain = resolve_execution_chain("seed_step", &Some(custom), "t1");
assert_eq!(labels(&chain), vec!["seed_nc", "nl"]);
// 首步必须是非灰 LTEltgray=F),否则会删 fort.8 丢弃种子。
assert_eq!(chain[0].ltgray, "F");
}
#[test]
fn cold_run_uses_custom_chain_when_provided() {
let custom = serde_json::json!([
{"label": "lte", "lte": "T", "ltgray": "T", "ilvlin": 0, "niter": 0},
{"label": "nc", "lte": "F", "ltgray": "F", "ilvlin": 0, "niter": 10},
{"label": "nl", "lte": "F", "ltgray": "F", "ilvlin": 100, "niter": 100},
]);
let chain = resolve_execution_chain("cold_run", &Some(custom), "t2");
assert_eq!(labels(&chain), vec!["lte", "nc", "nl"]);
}
#[test]
fn cold_run_falls_back_to_default_when_no_custom_chain() {
let chain = resolve_execution_chain("cold_run", &None, "t3");
assert_eq!(labels(&chain), vec!["lte", "nc", "nl"]);
}
#[tokio::test]
async fn test_cleanup_slot_work_dir() {
let temp_dir =
+37 -8
View File
@@ -12,8 +12,14 @@ use tracing::{info, warn};
/// 失败导致 synspec 无输入"的级联场景);TLUSTY 关闭 → 归因 SYNSPECsynspec-only 任务
/// 的 converged 由 synspec_rc 决定)。
/// - `converged=true` 但 synspec 有错误/非零 rc:归因 SYNSPEC(大气已收敛、光谱失败)。
///
/// 已知盲区(审查 #N1,文档化取舍):TLUSTY 关闭 + `converged=false` 时归因 SYNSPEC
/// 但此时大气来自外部既有产物(非本任务计算),`converged=false` 更可能意味着外部大气
/// 文件损坏/NaN/缺失,而非光谱合成本身的问题。归因到 synspec 触发的 synspec 链重试对
/// 「外部大气损坏」无济于事(重试仍用同一损坏大气)。当前仍按 synspec 归因是保守选择
/// (至少触发一次重试暴露问题),运维需结合 note 里的 atmosphere_has_nan 标志甄别。
fn infer_failed_stage(task: &TaskSpec, summary: &ModelSummary) -> Option<String> {
if !summary.converged {
if !summary.result_valid {
return if task.tlusty_config.enabled {
Some("tlusty".to_string())
} else {
@@ -37,7 +43,7 @@ fn infer_failed_stage(task: &TaskSpec, summary: &ModelSummary) -> Option<String>
/// 保存 .7 作种子),但 status=Failed 让服务端弹 synspec 链重试,且错误对运维可见。
fn derive_report_status(s: &ModelSummary) -> TaskStatus {
let synspec_failed = s.synspec_error.is_some() || matches!(s.synspec_rc, Some(rc) if rc != 0);
if s.converged && !synspec_failed {
if s.result_valid && !synspec_failed {
TaskStatus::Completed
} else {
TaskStatus::Failed
@@ -66,7 +72,7 @@ pub async fn report_result(
) = match exec_res {
Ok((s, s_bytes)) => (
derive_report_status(&s),
s.converged,
s.result_valid,
s.final_max_relc,
s.atmosphere_has_nan,
s.elapsed_sec,
@@ -94,7 +100,7 @@ pub async fn report_result(
params: Some(task.params.clone()),
node_id: node_id.to_string(),
status,
converged,
result_valid: converged,
max_relc,
atmosphere_has_nan: atmo_has_nan,
elapsed_sec,
@@ -196,16 +202,16 @@ mod tests {
logn: (-4.0).into(),
logo: (-4.0).into(),
},
tlusty_config: common::models::EngineStageConfig {
tlusty_config: common::models::PhaseConfig {
enabled: tlusty_enabled,
..common::models::EngineStageConfig::default_tlusty()
..common::models::PhaseConfig::default_tlusty()
},
..TaskSpec::default()
}
}
fn mk_summary(
converged: bool,
result_valid: bool,
synspec_rc: Option<i32>,
synspec_error: Option<&str>,
) -> ModelSummary {
@@ -220,7 +226,7 @@ mod tests {
logo: (-4.0).into(),
},
stages: vec![],
converged,
result_valid,
final_max_relc: None,
final_chmax: None,
seed: None,
@@ -302,4 +308,27 @@ mod tests {
TaskStatus::Failed
);
}
/// 漏洞1修复验证(端到端判定链路):.spec 内容校验失败时,runner 同时设置
/// synspec_rc=Some(1) + synspec_error=Some("spec...")。验证 derive_report_status 和
/// infer_failed_stage 在双字段同时非空/非零时都判 synspec 失败 → Failed + 归因 synspec。
/// 这是漏洞1失败信号从 runner 一路流到 reporter 判定的关键节点。
#[test]
fn test_spec_invalid_drives_failed() {
// 模拟 spec_is_valid 命中:converged=true(大气正常)+ spec 脏(双字段)
let dirty_spec_summary =
mk_summary(true, Some(1), Some("spec 含 NaN/Inf/溢出行 (共 2 行)"));
// 任务整体判 Failed(不再被误报 Completed → 脏谱归档)
assert_eq!(
derive_report_status(&dirty_spec_summary),
TaskStatus::Failed,
"spec 校验失败(双字段)应判 Failed"
);
// 归因 synspec(触发 synspec 策略链回退)
assert_eq!(
infer_failed_stage(&mk_task(true), &dirty_spec_summary),
Some("synspec".to_string()),
"spec 校验失败应归因 synspec 以触发回退"
);
}
}
+117 -9
View File
@@ -6,9 +6,11 @@ use common::embedded::RuntimePaths;
use common::models::{NodeHeartbeatRequest, NodeHeartbeatResponse, NodeRegisterRequest, TaskSpec};
use reqwest::Client;
use serde_json::Value;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::atomic::{AtomicI32, AtomicUsize, Ordering};
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering};
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{sleep, Duration};
use tracing::{info, warn};
@@ -32,10 +34,65 @@ impl Drop for SlotGuard {
}
}
/// 后台沙盒 GC 间隔(秒):长寿命 Worker 运行期间周期清理孤儿 `task_*` 目录。
/// 孤儿来源:`cleanup_slot_work_dir` 反复失败(权限/文件锁)或任务进程异常残留。
/// 启动清理(run() 开头)只处理「上次崩溃」的残留;运行期累积靠本 GC 兜底。
/// 精确跳过 `active_tasks` 中的在途任务,故无需担心误删运行中沙盒。
const SANDBOX_GC_INTERVAL_SECS: u64 = 3600;
/// 周期清理 work_dir 下非活跃的 `task_*` 沙盒目录。
///
/// - 目录名 `task_{uuid}`,后缀精确匹配 `active_tasks`(在途任务 task_id 集合);
/// 命中的在途任务跳过,其余视为孤儿删除(best-effort,失败 warn 下轮重试)。
/// - 与启动清理(run() 开头删全部)互补:运行期若单 slot 清理反复失败,孤儿子目录
/// 不会累积写满磁盘,由本任务周期性回收。
/// - 仅在 shutdown 标志置位时退出(每 tick 检查一次),随节点优雅退出。
async fn sandbox_gc_loop(
work_dir: PathBuf,
active_tasks: Arc<Mutex<HashSet<String>>>,
shutdown: Arc<AtomicBool>,
) {
let mut ticker = tokio::time::interval(Duration::from_secs(SANDBOX_GC_INTERVAL_SECS));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
ticker.tick().await;
if shutdown.load(Ordering::Acquire) {
return;
}
let active: HashSet<String> = active_tasks.lock().await.iter().cloned().collect();
if let Ok(mut rd) = tokio::fs::read_dir(&work_dir).await {
while let Ok(Some(entry)) = rd.next_entry().await {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if is_orphan_task_dir(&name_str, &active) {
let p = entry.path();
match tokio::fs::remove_dir_all(&p).await {
Ok(_) => warn!("GC 清理孤儿沙盒: {}", p.display()),
Err(e) => warn!("GC 清理孤儿沙盒 {} 失败(下轮重试): {}", p.display(), e),
}
}
}
}
}
}
/// 判断 work_dir 下的一个条目是否为「应回收的孤儿沙盒」。
/// `task_{uuid}` 且 uuid 不在 `active_tasks`(在途任务)→ 孤儿;其它一律非孤儿。
fn is_orphan_task_dir(entry_name: &str, active: &HashSet<String>) -> bool {
match entry_name.strip_prefix("task_") {
Some(suffix) => !active.contains(suffix),
None => false,
}
}
/// 领用请求的归一化结果。
///
/// 区分「被管理员停用」与「暂无任务」:前者节点保持存活、空闲待命(拉长轮询),
/// 后者按常规节奏轮询。服务端返回 `{"status":"disabled"}` 映射为 `Disabled`。
///
/// `TaskSpec` 体积远大于空枚举项,属可接受的形态(node 端一次性消费,非热循环持有);
/// 装箱会改变所有 match 点的解构方式且无实际收益,故允许该 lint。
#[allow(clippy::large_enum_variant)]
enum ClaimOutcome {
/// 成功领用到任务。
Task(TaskSpec),
@@ -62,6 +119,12 @@ pub struct NodeWorker {
/// (见 docs/dynamic_cpu_slots_design.md §3.2)。用 `Arc<AtomicUsize>` 在心跳线程与
/// 领用线程间共享,无需 Mutex:单写者(心跳线程)单读者(领用线程),原子读写即可。
effective_max_slots: Arc<AtomicUsize>,
/// 全局优雅退出标志:信号线程、心跳线程、claim_task 任一发现需要终止时置 true,
/// 主循环据此停止领用新任务并走优雅退出(等待在途任务 ≤30s + 归档沙盒)。
///
/// 审查修复 #M5:此前心跳/claim_task 遇 token 失效直接 `std::process::exit(1)`
/// 跳过主循环的优雅退出逻辑,导致在途长任务结果静默丢失。改为置此标志让主循环感知。
shutting_down: Arc<std::sync::atomic::AtomicBool>,
}
impl NodeWorker {
@@ -73,6 +136,7 @@ impl NodeWorker {
runtime,
active_slots: Arc::new(AtomicI32::new(0)),
effective_max_slots: Arc::new(AtomicUsize::new(physical)),
shutting_down: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}
}
@@ -218,6 +282,7 @@ impl NodeWorker {
let hb_physical = self.config.max_slots;
let hb_interval = self.config.heartbeat_sec;
let hb_runtime_dir = self.config.runtime_dir.clone();
let hb_shutting_down = self.shutting_down.clone();
tokio::spawn(async move {
let sys_arc = std::sync::Arc::new(std::sync::Mutex::new(sysinfo::System::new_all()));
@@ -275,20 +340,22 @@ impl NodeWorker {
match hb_client.post(&hb_url).json(&req).send().await {
Ok(resp) => {
let status = resp.status();
// 401/403token 失效(被重发覆盖)。与 claim_task 口径统一:直接退出进程,
// 避免心跳线程持续发被拒请求刷日志、占用服务端限流计数。心跳通常比
// claim 更高频,往往先于 claim_task 发现 token 失效。
// 401/403token 失效(被重发覆盖)。与 claim_task 口径统一:
// 审查修复 #M5:原 `std::process::exit(1)` 跳过主循环优雅退出,
// 在途长任务结果静默丢失。现置 shutting_down 让主循环停止领用新任务、
// 等待在途任务完成(≤30s)并归档沙盒后再退出。
if status.as_u16() == 401 || status.as_u16() == 403 {
tracing::error!(
"节点 {} 心跳被服务端拒绝 (HTTP {})node token 已失效(已被重发覆盖)。\n\
恢复方式:把管理员重发的新 token 明文写入 {} 后重启节点,\n\
或删除该文件后重启以重新提交注册申请等待审批。\n\
进程退出,依赖编排系统重启。",
已触发优雅退出:等待在途任务完成后进程退出,依赖编排系统重启。",
hb_node_id,
status,
token_file_display(&hb_runtime_dir)
);
std::process::exit(1);
hb_shutting_down.store(true, Ordering::Release);
break;
}
// 解析心跳响应体,提取管理员配额(见 docs/dynamic_cpu_slots_design.md
// §3.2):计算生效配额并更新 effective_max_slots。反序列化失败不致命
@@ -348,7 +415,18 @@ impl NodeWorker {
}
}
let shutting_down = Arc::new(std::sync::atomic::AtomicBool::new(false));
// 运行期沙盒 GC:追踪在途任务 task_id 集合,后台周期清理孤儿 task_* 目录
//cleanup_slot_work_dir 反复失败/进程异常残留的累积治理,见 sandbox_gc_loop)。
let active_tasks: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
tokio::spawn(sandbox_gc_loop(
work_dir.clone(),
active_tasks.clone(),
self.shutting_down.clone(),
));
// 复用 self.shutting_down(结构体字段):心跳线程、claim_task、信号线程、主循环
// 共享同一标志。任一来源触发优雅退出,主循环都能感知(审查修复 #M5)。
let shutting_down = self.shutting_down.clone();
let shutdown_signal = shutting_down.clone();
// 信号处理:同时监听 SIGINTCtrl+C)与 SIGTERM。
@@ -428,6 +506,8 @@ impl NodeWorker {
let result_dir = result_dir.clone();
let slots_counter = self.active_slots.clone();
let shutdown = shutting_down.clone();
// 在途任务 task_id 集合(沙盒 GC 跳过用):spawn 前登记、任务结束移除。
let active_tasks = active_tasks.clone();
// 在 spawn 前同步自增,与容量检查紧邻成原子操作:避免
// 「检查通过 → spawn 排队 → 回循环再检查时计数尚未自增」的竞态
@@ -438,6 +518,8 @@ impl NodeWorker {
let slot_guard = SlotGuard {
counter: slots_counter.clone(),
};
// GC 活跃集登记(在 spawn 前,避免 GC 误删尚未建目录的在途任务)。
active_tasks.lock().await.insert(task.task_id.to_string());
tokio::spawn(async move {
// 把守卫移入 future,确保任务结束(含 panic)时归还 slot。
@@ -509,6 +591,9 @@ impl NodeWorker {
);
}
}
// 任务结束(含 panic/清理失败):从 GC 活跃集移除,下轮 GC 可回收
// 清理失败残留的孤儿沙盒。
active_tasks.lock().await.remove(&task.task_id.to_string());
// _slot_guard 在此作用域结束时 drop,归还活动 slot 计数。
});
}
@@ -581,11 +666,14 @@ impl NodeWorker {
"领用任务被服务端拒绝 (HTTP {}):node token 已失效(已被重发覆盖)。\n\
恢复方式:把管理员重发的新 token 明文写入 {} 后重启节点,\n\
或删除该文件后重启以重新提交注册申请等待审批。\n\
进程退出,依赖编排系统重启。",
已触发优雅退出:等待在途任务完成后进程退出,依赖编排系统重启。",
status,
token_file_display(&self.config.runtime_dir)
);
std::process::exit(1);
// 审查修复 #M5:与心跳线程口径统一,置 shutting_down 让主循环走优雅退出,
// 而非 exit(1) 跳过在途任务的结果上报与沙盒归档。
self.shutting_down.store(true, Ordering::Release);
return Ok(ClaimOutcome::Empty);
}
if status.is_server_error() {
@@ -612,3 +700,23 @@ impl NodeWorker {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 沙盒 GC 孤儿判定(#4):`task_{uuid}` 且 uuid 不在活跃集 → 孤儿;在途/非沙盒目录 → 否。
#[test]
fn orphan_gc_predicate_skips_active_and_non_sandbox() {
let active: HashSet<String> = ["task-1111-aaaa".to_string()].into_iter().collect();
// 在途任务 → 非孤儿(GC 跳过)。
assert!(!is_orphan_task_dir("task_task-1111-aaaa", &active));
// 孤儿(uuid 不在活跃集)→ 回收。
assert!(is_orphan_task_dir("task_task-2222-bbbb", &active));
// 非沙盒目录(.seed_cache / 其它)→ 永不动。
assert!(!is_orphan_task_dir(".seed_cache", &active));
assert!(!is_orphan_task_dir("result_dir", &active));
// 空活跃集:所有 task_* 都是孤儿(等价重启清理)。
assert!(is_orphan_task_dir("task_abc", &HashSet::new()));
}
}
+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 保留,避免重算");
+3 -10
View File
@@ -9,16 +9,9 @@
<!-- 字重精简:Inter 去掉未使用的 300Space Grotesk 去掉未使用的 500(仅 600/700 用于标题),
减少 2 个字体文件下载。display=swap 避免 FOIT。 -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@600;700&display=swap" rel="stylesheet">
<script>
/* 首帧前落主题,避免 prefers-color-scheme 先绘制导致深/浅闪变 (FOUC) */
(function () {
try {
var saved = localStorage.getItem('dcts_theme');
var theme = saved || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) { /* localStorage 不可用时回退媒体查询 */ }
})();
</script>
<!-- 主题初始化移为外链(public/theme-init.js):使 CSP 可移除 script-src 'unsafe-inline'。
同步脚本在首帧前执行,保持防 FOUC 语义。 -->
<script src="/theme-init.js"></script>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
+11
View File
@@ -0,0 +1,11 @@
/* prefers-color-scheme / (FOUC)
* 独立为外链文件public/ 原样拷贝到 dist/使 CSP 可移除 script-src 'unsafe-inline'
* crates/server/src/main.rs security_headers_middleware
* 必须以同步任 script <head> 中于首帧前执行Vite 保留此标签不做打包 */
(function () {
try {
var saved = localStorage.getItem('dcts_theme');
var theme = saved || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) { /* localStorage 不可用时回退媒体查询 */ }
})();
+4 -8
View File
@@ -32,11 +32,7 @@ const TOKEN_KEY = 'dcts_admin_token';
* @property {number} [occupied_slots]
* @property {number} [total_max_slots]
* @property {Array} [nodes]
* @property {object} [grid_stats] - { pending, queued, running, converged, failed }
* @property {number} [pending_points]
* @property {number} [running_points]
* @property {number} [converged_points]
* @property {number} [failed_points]
* @property {object} [grid_stats] - { pending, queued, running, completed, failed }
*/
/**
@@ -69,7 +65,7 @@ const TOKEN_KEY = 'dcts_admin_token';
* @property {number} pending
* @property {number} queued
* @property {number} running
* @property {number} converged
* @property {number} completed
* @property {number} failed
* @property {number} [eta_sec]
* @property {Array<{label:string,count:number}>} [waves]
@@ -250,8 +246,8 @@ export async function deleteWorkflowApi(name) {
return apiFetch(`/api/workflows/${encodeURIComponent(name)}`, { method: 'DELETE' });
}
export async function getWorkflowDetailApi(name) {
return apiFetch(`/api/workflows/${encodeURIComponent(name)}`);
export async function getWorkflowDetailApi(name, opts = {}) {
return apiFetch(`/api/workflows/${encodeURIComponent(name)}`, opts);
}
// ===== 工作流执行观测 API(详情页数据源) =====
+34 -31
View File
@@ -61,8 +61,8 @@ const panelState = {
status: 'idle',
description: null, // 工作流描述(回显保存,避免 null 覆盖既有描述)
configYaml: '', // 原始 YAML(未经编辑)
tlusty: defaultStage('tlusty'),
synspec: defaultStage('synspec'),
tlusty_stage: defaultStage('tlusty_stage'),
synspec_stage: defaultStage('synspec_stage'),
wired: false,
dirty: false,
collapsed: true, // 默认折叠成一行(标题+操作按钮),点「配置 ▾」展开阶段卡
@@ -80,7 +80,7 @@ function policyOptions(selected) {
}
function strategyOptions(kind, selected) {
const opts = kind === 'tlusty' ? TLUSTY_STRATEGIES : SYNSPEC_STRATEGIES;
const opts = kind === 'tlusty_stage' ? TLUSTY_STRATEGIES : SYNSPEC_STRATEGIES;
return opts.map(o =>
`<option value="${o.value}"${o.value === selected ? ' selected' : ''}>${escapeHtml(o.label)}</option>`
).join('');
@@ -88,7 +88,7 @@ function strategyOptions(kind, selected) {
/** 单个阶段卡片(TLUSTY 或 SYNSPEC)。 */
function stageCardHtml(kind, label, stage) {
const idPrefix = kind === 'tlusty' ? 'tlusty' : 'synspec';
const idPrefix = kind === 'tlusty_stage' ? 'tlusty_stage' : 'synspec_stage';
const stratRows = stage.strategies.map((s, i) => {
const opts = strategyOptions(kind, s);
return `
@@ -125,7 +125,7 @@ function stageCardHtml(kind, label, stage) {
${ICONS.plus({ size: 11 })} 添加回退策略
</button>
</div>
${kind === 'synspec'
${kind === 'synspec_stage'
? '<p class="text-hint engine-stage-note">光谱数值参数(波长范围、展宽等)在 YAML 中配置,请用「导出 YAML」编辑后重新导入。</p>'
: ''}
</div>
@@ -159,8 +159,8 @@ function panelHtml() {
<span class="wf-engine-dirty hidden" id="wf-engine-dirty">未保存</span>
</div>
<div class="wf-engine-stages${panelState.collapsed ? ' hidden' : ''}">
${stageCardHtml('tlusty', 'TLUSTY 大气结构计算', panelState.tlusty)}
${stageCardHtml('synspec', 'SYNSPEC 光谱合成', panelState.synspec)}
${stageCardHtml('tlusty_stage', 'TLUSTY 大气结构计算', panelState.tlusty_stage)}
${stageCardHtml('synspec_stage', 'SYNSPEC 光谱合成', panelState.synspec_stage)}
</div>
<div class="wf-engine-actions">
<button type="button" class="btn btn-secondary btn-sm" data-engine-action="yaml" title="查看 / 编辑完整 YAML 配置">
@@ -210,8 +210,8 @@ function showError(msg) {
/** 重新渲染某阶段的策略链列表(增删/排序后调用)。 */
function rerenderStrategyList(kind) {
const stage = kind === 'tlusty' ? panelState.tlusty : panelState.synspec;
const idPrefix = kind === 'tlusty' ? 'tlusty' : 'synspec';
const stage = kind === 'tlusty_stage' ? panelState.tlusty_stage : panelState.synspec_stage;
const idPrefix = kind === 'tlusty_stage' ? 'tlusty_stage' : 'synspec_stage';
const ul = document.querySelector(`[data-strat-list="${idPrefix}"]`);
if (!ul) return;
ul.innerHTML = stage.strategies.map((s, i) => {
@@ -236,9 +236,9 @@ function handleFieldChange(e) {
const t = e.target;
const field = t.getAttribute('data-engine-field');
const kindRaw = t.getAttribute('data-strat-kind');
// strat-kind 在 enabled/policy 用 tlusty/synspec;在 strat* 用 tlusty/synspec
const kind = kindRaw === 'tlusty' ? 'tlusty' : 'synspec';
const stage = kind === 'tlusty' ? panelState.tlusty : panelState.synspec;
// strat-kind 在 enabled/policy 用 tlusty_stage/synspec_stage;在 strat*
const kind = kindRaw === 'tlusty_stage' ? 'tlusty_stage' : 'synspec_stage';
const stage = kind === 'tlusty_stage' ? panelState.tlusty_stage : panelState.synspec_stage;
if (field === 'enabled') {
stage.enabled = t.checked;
@@ -265,9 +265,9 @@ function handleClick(e) {
const action = btn.getAttribute('data-engine-action');
if (field === 'strat-add') {
const kind = btn.getAttribute('data-strat-kind') === 'tlusty' ? 'tlusty' : 'synspec';
const stage = kind === 'tlusty' ? panelState.tlusty : panelState.synspec;
const opts = kind === 'tlusty' ? TLUSTY_STRATEGIES : SYNSPEC_STRATEGIES;
const kind = btn.getAttribute('data-strat-kind') === 'tlusty_stage' ? 'tlusty_stage' : 'synspec_stage';
const stage = kind === 'tlusty_stage' ? panelState.tlusty_stage : panelState.synspec_stage;
const opts = kind === 'tlusty_stage' ? TLUSTY_STRATEGIES : SYNSPEC_STRATEGIES;
// 默认添加白名单首项(若已含则添加下一项)。
const next = opts.find(o => !stage.strategies.includes(o.value)) || opts[0];
stage.strategies.push(next.value);
@@ -275,8 +275,8 @@ function handleClick(e) {
return;
}
if (field && field.startsWith('strat-')) {
const kind = btn.getAttribute('data-strat-kind') === 'tlusty' ? 'tlusty' : 'synspec';
const stage = kind === 'tlusty' ? panelState.tlusty : panelState.synspec;
const kind = btn.getAttribute('data-strat-kind') === 'tlusty_stage' ? 'tlusty_stage' : 'synspec_stage';
const stage = kind === 'tlusty_stage' ? panelState.tlusty_stage : panelState.synspec_stage;
const idx = parseInt(btn.getAttribute('data-strat-idx'), 10);
if (field === 'strat-remove') {
stage.strategies.splice(idx, 1);
@@ -328,12 +328,12 @@ async function saveConfig() {
}
// 阶段配置合法性校验(与服务端 save_workflow 同口径,修复审查 #4/#5):
// 双阶段全关、或启用阶段空策略链 → 拦截保存,避免保存出必然失败的任务配置。
const validationErr = validateStageConfigs(panelState.tlusty, panelState.synspec);
const validationErr = validateStageConfigs(panelState.tlusty_stage, panelState.synspec_stage);
if (validationErr) {
showError(validationErr);
return;
}
const newYaml = applyStageBlocks(panelState.configYaml, panelState.tlusty, panelState.synspec);
const newYaml = applyStageBlocks(panelState.configYaml, panelState.tlusty_stage, panelState.synspec_stage);
showError('');
try {
// 回显既有描述(修复审查 #6:旧实现硬编码 description:null 会清空工作流描述)。
@@ -415,7 +415,7 @@ async function doDelete() {
}
function exportYaml() {
const yaml = applyStageBlocks(panelState.configYaml, panelState.tlusty, panelState.synspec);
const yaml = applyStageBlocks(panelState.configYaml, panelState.tlusty_stage, panelState.synspec_stage);
if (!yaml.trim()) {
showToast('配置内容为空,无法导出', 'warning');
return;
@@ -440,9 +440,11 @@ function exportYaml() {
// ===== 刷新与挂载 =====
/** panelStatefetch + parse
* mount poll 共用此逻辑mount 调用后强制重渲染poll 仅在状态变化时更新按钮 */
async function fetchAndApplyDetail({ forceConfig }) {
const res = await getWorkflowDetailApi(panelState.name);
* mount poll 共用此逻辑mount 调用后强制重渲染poll 仅在状态变化时更新按钮
* signal 可选透传给 getWorkflowDetailApi用于详情页卸载时中断在途响应
* 避免 unmount 后异步响应仍改写 panelState */
async function fetchAndApplyDetail({ forceConfig, signal } = {}) {
const res = await getWorkflowDetailApi(panelState.name, signal ? { signal } : undefined);
const json = await res.json();
if (!json.success || !json.data) return false;
const prevStatus = panelState.status;
@@ -453,13 +455,13 @@ async function fetchAndApplyDetail({ forceConfig }) {
// mountforceConfig=true)或非编辑中(dirty=false)时同步服务端配置到本地。
if (forceConfig || !panelState.dirty) {
panelState.configYaml = json.data.config_yaml || '';
// TLUSTY 生效配置:无 `tlusty:` 块时按旧 `seed_step_fallback` 推断(与服务端
// TLUSTY 生效配置:无 `tlusty_stage:` 块时按旧 `seed_step_fallback` 推断(与服务端
// resolve_tlusty_config 同口径),避免保存时把 `seed_step_fallback: false` 静默改写。
panelState.tlusty = resolveTlustyFromYaml(panelState.configYaml);
panelState.tlusty_stage = resolveTlustyFromYaml(panelState.configYaml);
const s = parseStageFromYaml(panelState.configYaml, 'synspec_stage');
// 无 synspec_stage 块 → 兜底默认(与服务端 resolve_synspec_config 同口径),
// 避免面板停留在上一次的旧值。
panelState.synspec = s || defaultStage('synspec');
panelState.synspec_stage = s || defaultStage('synspec_stage');
}
if (prevStatus !== panelState.status) {
updateActionButtons();
@@ -474,10 +476,11 @@ async function fetchAndApplyDetail({ forceConfig }) {
}
/**
* 供详情页轮询调用仅在非 dirty 时同步配置状态变化时更新按钮可用性 */
export async function refreshEnginePanel() {
* 供详情页轮询调用仅在非 dirty 时同步配置状态变化时更新按钮可用性
* signal 可选透传给 fetchAndApplyDetail卸载时中断在途响应 unmount 后改 panelState */
export async function refreshEnginePanel(signal) {
try {
await fetchAndApplyDetail({ forceConfig: false });
await fetchAndApplyDetail({ forceConfig: false, signal });
} catch (err) {
// 静默:详情页主轮询会处理 404。
}
@@ -536,8 +539,8 @@ export async function mountEnginePanel(name, container) {
panelState.status = 'idle';
panelState.description = null;
panelState.configYaml = '';
panelState.tlusty = defaultStage('tlusty');
panelState.synspec = defaultStage('synspec');
panelState.tlusty_stage = defaultStage('tlusty_stage');
panelState.synspec_stage = defaultStage('synspec_stage');
panelState.dirty = false;
panelState.wired = false;
panelState.collapsed = true;
+2 -2
View File
@@ -144,9 +144,9 @@ export function renderWorkflows(workflows) {
// 内联进度(来自列表接口内联 stats;未启动的工作流 stats=null → 不渲染)
const s = wf.stats;
const hasProgress = s && s.total > 0;
const pct = hasProgress ? Math.min(100, Math.max(0, Math.round((s.converged / s.total) * 100))) : 0;
const pct = hasProgress ? Math.min(100, Math.max(0, Math.round((s.completed / s.total) * 100))) : 0;
const countsText = hasProgress
? `${s.converged}/${s.total} 收敛 · ${s.seed_step_converged} 种子步进 · ${s.failed} 失败 · ${s.running} 运行`
? `${s.completed}/${s.total} 完成 · ${s.seed_step_converged} 种子步进 · ${s.failed} 失败 · ${s.running} 运行`
: '';
let card = existingCards.get(name);
+2 -4
View File
@@ -31,7 +31,7 @@ grid:
logn: [-4.0]
logo: [-4.0]
chain:
tlusty_chain:
- {label: lte, lte: T, ltgray: T, ilvlin: 0, require_converged: false, niter: 0}
- {label: nc, lte: F, ltgray: F, ilvlin: 0, require_converged: false, niter: 10}
- {label: nl, lte: F, ltgray: F, ilvlin: 100, require_converged: true, niter: 100}
@@ -41,7 +41,7 @@ nworkers: 4
timeout_sec: 3600
resume: true
synspec:
synspec_input:
wstart: 3000.0
wend: 7000.0
imode: 0
@@ -49,8 +49,6 @@ synspec:
ifreq: 1
rel_cutoff: 0.0001
abs_cutoff: 0.01
results: data/seeds
`;
function showLoginError(msg) {
+11 -9
View File
@@ -114,11 +114,13 @@ export function updateUI(data) {
const stats = data.grid_stats || {};
// 后端已将 queued 从 pending 拆出(详情页需区分"未入队/排队中");
// 首页"待计算网格点"保持传统合并口径 = pending + queued。
const pending = (stats.pending ?? data.pending_points ?? 0) + (stats.queued ?? 0);
const running = stats.running ?? data.running_points ?? 0;
const converged = stats.converged ?? data.converged_points ?? 0;
const failed = stats.failed ?? data.failed_points ?? 0;
const completed = converged + failed;
const pending = (stats.pending ?? 0) + (stats.queued ?? 0);
const running = stats.running ?? 0;
// 7c 改名:后端权威键为 stats.completed(原 converged)。doneOk=成功完成,failed=失败,
// doneTotal=两者合计(所有已终止点)。
const doneOk = stats.completed ?? 0;
const failed = stats.failed ?? 0;
const doneTotal = doneOk + failed;
const valPendingTasks = document.getElementById('val-pending-tasks');
const valRunningTasks = document.getElementById('val-running-tasks');
@@ -130,13 +132,13 @@ export function updateUI(data) {
const valCompletedTasks = document.getElementById('val-completed-tasks');
const valCompletionRate = document.getElementById('val-completion-rate');
const convergedProgressFill = document.getElementById('converged-progress-fill');
if (valCompletedTasks) valCompletedTasks.textContent = completed.toLocaleString();
if (valCompletedTasks) valCompletedTasks.textContent = doneTotal.toLocaleString();
if (valCompletionRate) {
valCompletionRate.textContent = `${converged.toLocaleString()} 收敛 / ${failed.toLocaleString()}收敛`;
valCompletionRate.textContent = `${doneOk.toLocaleString()} 完成 / ${failed.toLocaleString()}完成`;
}
if (convergedProgressFill) {
const totalModels = pending + running + completed;
const pct = totalModels > 0 ? Math.min(100, Math.max(0, Math.round((converged / totalModels) * 100))) : 0;
const totalModels = pending + running + doneTotal;
const pct = totalModels > 0 ? Math.min(100, Math.max(0, Math.round((doneOk / totalModels) * 100))) : 0;
convergedProgressFill.style.width = `${pct}%`;
}
+17
View File
@@ -135,6 +135,8 @@
--accent-blueprint: light-dark(var(--color-blue-700), #38bdf8);
--accent-cyan: var(--color-blue-600);
--accent-cyan: light-dark(var(--color-blue-600), #22d3ee);
--accent-teal: #0d9488;
--accent-teal: light-dark(#0d9488, #2dd4bf);
--accent-purple: var(--color-purple-700);
--accent-purple: light-dark(var(--color-purple-700), #a78bfa);
--accent-emerald: var(--color-emerald-700);
@@ -1236,6 +1238,18 @@ body::before {
.activity-result.ok { color: var(--color-success); }
.activity-result.fail { color: var(--color-danger); }
/* 尝试历史:失败行的 synspec 错误摘要(Phase 1,解析 summary_json */
.attempt-synspec-err {
margin-top: 2px;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
color: var(--color-danger);
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity-elapsed {
color: var(--text-secondary);
white-space: nowrap;
@@ -1361,6 +1375,7 @@ body::before {
/* 状态配色(与 method-badge/status-badge 配色族一致) */
.ps-cold { fill: var(--color-success); }
.ps-seed { fill: var(--accent-purple); }
.ps-synspec { fill: var(--accent-teal); }
.ps-failed { fill: var(--color-danger); }
.ps-running { fill: var(--color-warning); }
.ps-queued { fill: var(--color-info); }
@@ -1368,6 +1383,7 @@ body::before {
/* 色带默认 fill-opacity 已定,色块需不透明 → 用 stroke 不可,故色块状态类同时设 fill */
.ps-seg.ps-cold { fill: var(--color-success); }
.ps-seg.ps-seed { fill: var(--accent-purple); }
.ps-seg.ps-synspec { fill: var(--accent-teal); }
.ps-seg.ps-failed { fill: var(--color-danger); }
.ps-seg.ps-running { fill: var(--color-warning); }
.ps-seg.ps-queued { fill: var(--color-info); }
@@ -1387,6 +1403,7 @@ body::before {
/* 状态轴取值标签染色 = 自带图例(与色块/色带配色一致) */
.ps-vlabel-cold { fill: var(--color-success); font-weight: 600; }
.ps-vlabel-seed { fill: var(--accent-purple); font-weight: 600; }
.ps-vlabel-synspec { fill: var(--accent-teal); font-weight: 600; }
.ps-vlabel-failed { fill: var(--color-danger); font-weight: 600; }
.ps-vlabel-running { fill: var(--color-warning); font-weight: 600; }
.ps-vlabel-queued { fill: var(--color-info); font-weight: 600; }
+14 -5
View File
@@ -8,8 +8,9 @@
* 解析前需规范化为 ISO所有时间解析函数共用 parseIso/parseTsMs
*
* 语义与后端契约对齐crates/server/src/db.rs
* - 网格点状态pending/queued/running/converged/failed/canceled
* - success_method收敛途径cold_run / seed_step
* - 网格点状态pending/queued/running/completed/failed/canceled7c converged 改名
* - 阶段归因列tlusty_success_method大气策略/ synspec_success_method光谱策略
* 整体归因 overallMethod(p) = tlusty ?? synspec
*/
/**
@@ -134,7 +135,9 @@ export function statusBadge(status) {
/** 网格点状态映射:[中文标签, badge class]。与后端 grid_points.status 对齐。 */
export const POINT_STATUS_MAP = {
converged: ['已收敛', 'online'],
// Phase 7a/7c'completed' 是 7c 改名后的权威值(原 'converged' TLUSTY-first 残留)——
// 实为"管线完成"(大气收敛 + 光谱合成),展示层统一标"已完成"。
completed: ['已完成', 'online'],
failed: ['失败', 'danger'],
running: ['运行中', 'warning'],
queued: ['排队中', 'info'],
@@ -147,11 +150,17 @@ export function pointStatusBadge(status) {
return `<span class="status-badge ${cls}">${cn}</span>`;
}
/** 网格点收敛途径徽章 HTMLsuccess_methodcold_run / seed_step / 策略名)。 */
/** = TLUSTY ?? 退
* 正常双阶段点 cold_run/seed_stepsynspec-only 光谱策略 standard */
export function overallMethod(p) {
return p.tlusty_success_method ?? p.synspec_success_method;
}
/** 网格点收敛途径徽章 HTMLmethodcold_run / seed_step / 策略名)。 */
export function pointMethodBadge(method) {
if (method === 'cold_run') return '<span class="method-badge cold">冷启动</span>';
if (method === 'seed_step') return '<span class="method-badge seed">种子步进</span>';
// 其它策略名(如 synspec-only 任务归因的 "standard")原样展示,让光谱归因可见。
// 其它策略名(如 synspec-only 归因的 "standard")原样展示,让光谱归因可见。
if (method) return `<span class="method-badge syn">${escapeHtml(method)}</span>`;
return '<span class="text-hint">—</span>';
}
+10 -1
View File
@@ -28,6 +28,11 @@ export function createPoller(fn, { baseMs = 5000, maxMs = 60000, maxFails = 4 }
let running = false;
let failCount = 0;
let onVisibility = null;
// 在途守卫:避免 triggerNow / visibility 与正在执行的 schedule 并发调用 fn()。
// schedule 是 async,若上一轮仍停在 await fn() 时被再次调用,两个 schedule 会
// 并发跑 fn(),导致请求翻倍且响应到达顺序不确定。已在途则直接 return——
// 在途 schedule 结束时会自己排下一轮 timer,不会漏调度。
let inFlight = false;
function clearTimer() {
if (timer) {
@@ -37,8 +42,10 @@ export function createPoller(fn, { baseMs = 5000, maxMs = 60000, maxFails = 4 }
}
async function schedule() {
if (inFlight) return;
inFlight = true;
clearTimer();
if (document.hidden) return;
if (document.hidden) { inFlight = false; return; }
let ok = true;
try {
@@ -46,6 +53,8 @@ export function createPoller(fn, { baseMs = 5000, maxMs = 60000, maxFails = 4 }
ok = !!r;
} catch (_) {
ok = false;
} finally {
inFlight = false;
}
if (ok) {
+20 -17
View File
@@ -1,4 +1,4 @@
/* 工作流阶段配置(tlusty / synspec_stage的轻量 YAML 解析与序列化
/* 工作流阶段配置(tlusty_stage / synspec_stage的轻量 YAML 解析与序列化
*
* 无依赖纯函数模块刻意从 wfEnginePanel.js 拆出使其可在 Node 原生 test
* 运行器下被单测覆盖wfEnginePanel.js 导入了浏览器侧的 toast/modal/api无法
@@ -10,9 +10,9 @@
* flow 序列与设计文档示例一致
*/
/** stage 配置默认值。 */
/** stage 配置默认值。kind 与 YAML 顶层键同名(tlusty_stage / synspec_stage)。 */
export function defaultStage(kind) {
if (kind === 'tlusty') {
if (kind === 'tlusty_stage') {
return { enabled: true, policy: 'skip_converged', strategies: ['cold_run', 'seed_step'] };
}
return { enabled: true, policy: 'skip_converged', strategies: ['standard'] };
@@ -22,14 +22,14 @@ export function defaultStage(kind) {
* - 至少一个阶段启用双关 无任何计算可执行任务必失败
* - 启用的阶段必须配置 1 个策略空链 调度无顺位可派任务必失败
* 返回错误文案校验通过返回 null */
export function validateStageConfigs(tlusty, synspec) {
if (!tlusty.enabled && !synspec.enabled) {
export function validateStageConfigs(tlusty_stage, synspec_stage) {
if (!tlusty_stage.enabled && !synspec_stage.enabled) {
return 'TLUSTY 与 SYNSPEC 阶段均被禁用:至少应启用一个计算阶段';
}
if (tlusty.enabled && (!tlusty.strategies || tlusty.strategies.length === 0)) {
if (tlusty_stage.enabled && (!tlusty_stage.strategies || tlusty_stage.strategies.length === 0)) {
return 'TLUSTY 阶段已启用但策略链为空:至少需要 1 个策略(如 cold_run';
}
if (synspec.enabled && (!synspec.strategies || synspec.strategies.length === 0)) {
if (synspec_stage.enabled && (!synspec_stage.strategies || synspec_stage.strategies.length === 0)) {
return 'SYNSPEC 阶段已启用但策略链为空:至少需要 1 个策略(如 standard';
}
return null;
@@ -38,12 +38,15 @@ export function validateStageConfigs(tlusty, synspec) {
/** `^key:`
*
* 顶层注释 0 `#`无缩进必须终止块它是独立的顶层结构不属于上一块
* 否则 `tlusty:` `synspec_stage:` 之间的顶层注释会被吞进 tlusty
* 否则 `tlusty_stage:` `synspec_stage:` 之间的顶层注释会被吞进 tlusty_stage
* 随后 replaceOrAppendBlock 覆盖时丢失/破坏 synspec_stage 审查 #4
* 缩进的注释` # ...`仍属块体块内行内注释 */
export function extractTopBlock(yaml, key) {
const lines = yaml.split('\n');
const startIdx = lines.findIndex(l => new RegExp(`^${key}:\\s*(\\S.*)?$`).test(l));
// 转义 key 中的正则元字符:key 作为导出纯函数的入参,未来可能含 `.`、`[` 等,
// 直接拼接会误匹配(如 key=`te.lusty` 的 `.` 会匹配任意字符)。按字面匹配更稳健。
const keyRe = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const startIdx = lines.findIndex(l => new RegExp(`^${keyRe}:\\s*(\\S.*)?$`).test(l));
if (startIdx < 0) return null;
// 块的缩进为 0(顶层键);块体为后续缩进 > 0 的连续行。
const blockLines = [lines[startIdx]];
@@ -98,16 +101,16 @@ export function parseStageFromYaml(yaml, key) {
return { enabled, policy, strategies };
}
/** TLUSTY **** `tlusty:`
/** TLUSTY **** `tlusty_stage:`
* `seed_step_fallback` 推断与服务端 `GridConfig::resolve_tlusty_config` 同口径
* - `seed_step_fallback: false` `[cold_run]`不回退种子步进
* - `true` / 缺省 默认链 `[cold_run, seed_step]`
*
* 用途审查 #2 修复面板编辑保存会物化 `tlusty:` 若无此推断而直接回退到
* 用途审查 #2 修复面板编辑保存会物化 `tlusty_stage:` 若无此推断而直接回退到
* `defaultStage` `[cold_run, seed_step]`会把旧 `seed_step_fallback: false`
* 的工作流静默改写为启用种子回退 */
export function resolveTlustyFromYaml(yaml) {
const block = parseStageFromYaml(yaml, 'tlusty');
const block = parseStageFromYaml(yaml, 'tlusty_stage');
if (block) return block;
// 行级查找顶层键(`^\s*seed_step_fallback` 天然排除 `#` 注释行)。
const hit = (yaml || '').split('\n')
@@ -115,7 +118,7 @@ export function resolveTlustyFromYaml(yaml) {
if (hit && /false\b/i.test(hit)) {
return { enabled: true, policy: 'skip_converged', strategies: ['cold_run'] };
}
return defaultStage('tlusty');
return defaultStage('tlusty_stage');
}
/** 把 stage 配置序列化为 YAML 块文本(flow 序列风格)。 */
@@ -126,12 +129,12 @@ export function serializeStageBlock(key, stage) {
return `${key}:\n enabled: ${stage.enabled}\n policy: ${stage.policy}\n strategies: ${strat}`;
}
/** tlusty/synspec_stage config_yaml
/** tlusty_stage/synspec_stage config_yaml
* 已存在该块 替换不存在 追加到末尾 */
export function applyStageBlocks(yaml, tlusty, synspec) {
export function applyStageBlocks(yaml, tlusty_stage, synspec_stage) {
let out = yaml;
out = replaceOrAppendBlock(out, 'tlusty', serializeStageBlock('tlusty', tlusty));
out = replaceOrAppendBlock(out, 'synspec_stage', serializeStageBlock('synspec_stage', synspec));
out = replaceOrAppendBlock(out, 'tlusty_stage', serializeStageBlock('tlusty_stage', tlusty_stage));
out = replaceOrAppendBlock(out, 'synspec_stage', serializeStageBlock('synspec_stage', synspec_stage));
return out;
}
+22 -23
View File
@@ -8,7 +8,7 @@
*/
import {
escapeHtml, parseTsMs, fmtTime, fmtDuration, statusBadge, pointMethodBadge,
escapeHtml, parseTsMs, fmtTime, fmtDuration, statusBadge, pointMethodBadge, overallMethod,
} from '../../utils/format.js';
import { fetchWorkflowPointsApi, fetchWorkflowProgressApi } from '../../api.js';
import { isAbortError, logError } from '../../utils/errors.js';
@@ -16,7 +16,7 @@ import { ICONS } from '../../utils/icons.js';
/** 分段进度条档位:顺序与计数行一致。五段常驻(空档宽度 0),原位改 width 复用 CSS 过渡。 */
const SEG_DEFS = [
['converged', 'seg-converged', '收敛'],
['completed', 'seg-converged', '完成'],
['failed', 'seg-failed', '失败'],
['running', 'seg-running', '运行'],
['queued', 'seg-queued', '排队'],
@@ -57,10 +57,10 @@ export function renderStrip(s) {
const countsEl = document.getElementById('wf-strip-counts');
if (countsEl) {
const pct = s.total ? Math.round((s.converged / s.total) * 100) : 0;
const pct = s.total ? Math.round((s.completed / s.total) * 100) : 0;
const parts = [
`${s.total} 网格点`,
`${s.converged} 收敛${s.total ? ` (${pct}%)` : ''}`,
`${s.completed} 完成${s.total ? ` (${pct}%)` : ''}`,
`${s.running} 运行`,
`${s.queued} 排队`,
`${s.pending} 待定`,
@@ -94,7 +94,7 @@ function overviewSkeleton() {
<span class="metric-sub">6 维参数笛卡尔积展开</span>
</div>
<div class="metric-card card">
<span class="metric-title">收敛</span>
<span class="metric-title">完成</span>
<span class="metric-value tabular-num" id="ov-m-converged"></span>
<span class="metric-sub" id="ov-m-converged-sub"></span>
</div>
@@ -144,9 +144,9 @@ function setOvText(id, v) {
/** 轮询原位刷新概览数值(指标卡 + 归因徽章 + 波次)。 */
function updateOverview(ctx, s) {
const pct = s.total ? Math.round((s.converged / s.total) * 100) : 0;
const pct = s.total ? Math.round((s.completed / s.total) * 100) : 0;
setOvText('ov-m-total', s.total);
setOvText('ov-m-converged', s.converged);
setOvText('ov-m-converged', s.completed);
setOvText('ov-m-converged-sub', `完成率 ${pct}%`);
setOvText('ov-m-failed', s.failed);
setOvText('ov-m-runqueue', s.running + s.queued);
@@ -161,19 +161,19 @@ function updateWaves(ctx, s) {
const el = document.getElementById('ov-waves');
if (!el) return;
const waves = s.waves || [];
const sig = waves.map(w => `${w.wave}:${w.converged}/${w.total}/${w.failed || 0}`).join('|');
const sig = waves.map(w => `${w.wave}:${w.completed}/${w.total}/${w.failed || 0}`).join('|');
if (sig === ctx.lastWavesSig) return;
ctx.lastWavesSig = sig;
el.innerHTML = waves.length > 0
? waves.map(w => {
const wp = w.total ? Math.round((w.converged / w.total) * 100) : 0;
const wp = w.total ? Math.round((w.completed / w.total) * 100) : 0;
return `
<div class="wave-row">
<span class="wave-label tabular-num">wave ${w.wave}</span>
<div class="progress-bar-track" aria-hidden="true">
<div class="progress-bar-fill progress-fill-emerald" style="width:${wp}%"></div>
</div>
<span class="wave-count tabular-num">${w.converged}/${w.total}${w.failed ? ` · ${w.failed} 失败` : ''}</span>
<span class="wave-count tabular-num">${w.completed}/${w.total}${w.failed ? ` · ${w.failed} 失败` : ''}</span>
</div>`;
}).join('')
: '<p class="text-hint">尚无波次数据(启动工作流后按难度分批生成)</p>';
@@ -206,16 +206,15 @@ async function refreshActivityFeed(ctx) {
}
feed.innerHTML = pts.map(p => {
const isNew = !prevKeys.has(keyOf(p));
const ok = p.status === 'converged';
// 归因徽章用 success_method(收敛点权威归因,能正确显示 synspec-only 的 "standard"),
// 不用 last_task_type——后者是 task_type 兼容兜底字段,synspec-only 任务被调度器固定
// 填 cold_run,会把光谱重算误标成「冷启动」。失败点 success_method 为空 → 显示 —。
const method = pointMethodBadge(p.success_method);
const ok = p.status === 'completed';
// 归因徽章用整体归因 overallMethod= tlusty 阶段策略 ?? 光谱阶段策略,能正确显示
// synspec-only 的 "standard")。失败点整体归因为空 → 显示 —。
const method = pointMethodBadge(overallMethod(p));
return `
<div class="activity-item${isNew ? ' activity-new' : ''}">
<span class="activity-point" title="${escapeHtml(p.name)}">${escapeHtml(p.name)}</span>
${method}
<span class="activity-result ${ok ? 'ok' : 'fail'}">${ok ? '收敛' : '失败'}</span>
<span class="activity-result ${ok ? 'ok' : 'fail'}">${ok ? '完成' : '失败'}</span>
<span class="activity-elapsed tabular-num" title="该次计算墙钟耗时">${fmtDuration(p.last_elapsed_sec)}</span>
<span class="activity-time tabular-num">${fmtTime(p.last_completed_at)}</span>
</div>`;
@@ -286,7 +285,7 @@ export function renderSparkline(ctx, prog) {
const x = (i) => ((tsMs[i] - tStart) / span) * W;
const y = (pct) => H - PAD - (Math.min(100, Math.max(0, pct)) / 100) * (H - PAD * 2);
const line = (key) => series.map((p, i) => `${x(i).toFixed(2)},${y(pctOf(p, key)).toFixed(2)}`).join(' ');
const areaPts = `${x(0).toFixed(2)},${H} ${line('converged')} ${x(n - 1).toFixed(2)},${H}`;
const areaPts = `${x(0).toFixed(2)},${H} ${line('completed')} ${x(n - 1).toFixed(2)},${H}`;
// 时间轴标签:窗口起点 / 中点 / 终点
const fmtAxisTime = (ms) => {
@@ -296,7 +295,7 @@ export function renderSparkline(ctx, prog) {
const axisLabels = [fmtAxisTime(tStart), fmtAxisTime(tStart + span / 2), fmtAxisTime(tEnd)];
// 签名 diff
const sig = `${n}|${tsMs[0]}|${tsMs[n - 1]}|${series[n - 1].converged}|${series[n - 1].failed}`;
const sig = `${n}|${tsMs[0]}|${tsMs[n - 1]}|${series[n - 1].completed}|${series[n - 1].failed}`;
if (sig === ctx.lastSparkSig && ctx.sparkBuilt) {
ctx.sparkHoverCtx = { tsMs, tStart, span, series, n, pctOf, x };
return;
@@ -338,7 +337,7 @@ export function renderSparkline(ctx, prog) {
crosshair.setAttribute('x1', c.x(idx));
crosshair.setAttribute('x2', c.x(idx));
crosshair.setAttribute('visibility', 'visible');
readout.textContent = `${p.ts} · 收敛 ${p.converged}/${p.total} (${c.pctOf(p, 'converged').toFixed(1)}%) · 失败 ${p.failed} · 运行 ${p.running} · 排队 ${p.queued}`;
readout.textContent = `${p.ts} · 收敛 ${p.completed}/${p.total} (${c.pctOf(p, 'completed').toFixed(1)}%) · 失败 ${p.failed} · 运行 ${p.running} · 排队 ${p.queued}`;
};
const hideCrosshair = () => {
crosshair.setAttribute('visibility', 'hidden');
@@ -399,7 +398,7 @@ export function renderSparkline(ctx, prog) {
const lineConverged = wrap.querySelector('.spark-converged');
if (area) area.setAttribute('points', areaPts);
if (lineFailed) lineFailed.setAttribute('points', line('failed'));
if (lineConverged) lineConverged.setAttribute('points', line('converged'));
if (lineConverged) lineConverged.setAttribute('points', line('completed'));
wrap.querySelectorAll('.spark-axis-label').forEach((el, i) => {
if (el.textContent !== axisLabels[i]) el.textContent = axisLabels[i];
@@ -423,13 +422,13 @@ export function renderRateLine(ctx, prog) {
: `(近 ${Math.max(1, Math.round(span * 60))} 分钟平均)`;
}
parts.push(`经验速率 ≈ +${Number(rate).toFixed(1)} 点/小时${spanTxt}`);
// ETA 用终态处理速率(converged+failed 的近 2h 平均):与「剩余 = total converged
// ETA 用终态处理速率(completed+failed 的近 2h 平均):与「剩余 = total completed
// − failed」同口径,即队列实际清空速率。仅用收敛速率会在失败较多时高估剩余时间。
const doneRate = prog.done_rate_per_hour;
const etaRate = (doneRate != null && Number.isFinite(doneRate) && doneRate > 0)
? doneRate
: rate;
const remaining = ctx.latestStats ? ctx.latestStats.total - ctx.latestStats.converged - ctx.latestStats.failed : 0;
const remaining = ctx.latestStats ? ctx.latestStats.total - ctx.latestStats.completed - ctx.latestStats.failed : 0;
if (etaRate > 0 && remaining > 0) {
parts.push(`按当前处理速率剩余 ${remaining} 点约需 ${fmtDuration((remaining / etaRate) * 3600)}`);
}
@@ -441,7 +440,7 @@ export function renderRateLine(ctx, prog) {
const banner = document.getElementById('wf-stall-banner');
if (!banner) return;
const remaining = ctx.latestStats ? ctx.latestStats.total - ctx.latestStats.converged - ctx.latestStats.failed : 0;
const remaining = ctx.latestStats ? ctx.latestStats.total - ctx.latestStats.completed - ctx.latestStats.failed : 0;
const stalled = prog.stalled_minutes;
if (stalled != null && stalled > 10 && remaining > 0 && ctx.latestStats?.status === 'running') {
banner.querySelector('.stall-banner-text').textContent =
+20 -9
View File
@@ -58,19 +58,29 @@ export function restorePsCache(ctx) {
}
}
/** / vs
/** //SYNSPEC vs vs
* 顺序即图例/轴布局/每值内分段/色带绘制层叠的唯一来源语义约定
* 已收敛最受关注置顶 进行中按任务进展方向runningqueuedpending 失败沉底
* 色带绘制按此顺序收敛色带先画底层失败最后画最上层数量少且颜色醒目 */
const PS_STATUS = ['cold', 'seed', 'running', 'queued', 'pending', 'failed'];
* 色带绘制按此顺序收敛色带先画底层失败最后画最上层数量少且颜色醒目
* synspec synspec_success_method 非空 synspec-only 任务的光谱策略 "standard"
* 属已收敛但由光谱合成阶段产出需与大气收敛区分 */
const PS_STATUS = ['cold', 'seed', 'synspec', 'running', 'queued', 'pending', 'failed'];
const PS_STATUS_LABEL = {
cold: '冷启动收敛', seed: '种子步进', failed: '失败',
cold: '冷启动收敛', seed: '种子步进', synspec: 'SYNSPEC 合成', failed: '失败',
running: '运行', queued: '排队', pending: '未开始',
};
/** 点 → 状态档位 key。 */
/** key
* 阶段归因列拆分P9后可直接区分TLUSTY 策略看 tlusty_success_method
* SYNSPEC-only 点看 synspec_success_method不再需要猜策略名 SYNSPEC_METHODS hack */
function psSlot(p) {
if (p.status === 'converged') return p.success_method === 'seed_step' ? 'seed' : 'cold';
if (p.status === 'completed') {
if (p.tlusty_success_method === 'seed_step') return 'seed';
if (p.tlusty_success_method === 'cold_run') return 'cold';
// 光谱阶段收敛(synspec-only/双阶段光谱归因落库)→ SYNSPEC 合成档。
if (p.synspec_success_method) return 'synspec';
return 'cold';
}
if (p.status === 'failed') return 'failed';
if (p.status === 'running') return 'running';
if (p.status === 'queued') return 'queued';
@@ -361,8 +371,9 @@ function renderPsConclusion(ctx, pts) {
if (!el) return;
const total = pts.length;
if (total === 0) { el.textContent = ''; return; }
const cold = pts.filter(p => p.status === 'converged' && p.success_method === 'cold_run').length;
const seed = pts.filter(p => p.status === 'converged' && p.success_method === 'seed_step').length;
const cold = pts.filter(p => p.status === 'completed' && p.tlusty_success_method === 'cold_run').length;
const seed = pts.filter(p => p.status === 'completed' && p.tlusty_success_method === 'seed_step').length;
const synspec = pts.filter(p => psSlot(p) === 'synspec').length;
const failed = pts.filter(p => p.status === 'failed').length;
const baseFail = failed / total;
@@ -385,7 +396,7 @@ function renderPsConclusion(ctx, pts) {
});
findings.sort((a, b) => b.score - a.score);
const lines = [
`冷启动 ${cold} · 种子步进 ${seed} · 失败 ${failed} / 共 ${total}`,
`冷启动 ${cold} · 种子步进 ${seed} · SYNSPEC ${synspec} · 失败 ${failed} / 共 ${total}`,
...findings.slice(0, 3).map(f => f.text),
];
el.innerHTML = lines.map(l => `<span class="ps-concl-line">${escapeHtml(l)}</span>`).join('');
+56 -9
View File
@@ -1,4 +1,4 @@
/* · +
/* · + TLUSTY
*
* 从点表查看按钮Parallel Sets 点名列表 / 单点 drill-down 进入
* 面板 DOM 挂在 ctx.pointPanelElunmount 由控制器调用 closePointPanel 清理
@@ -8,7 +8,7 @@ import { fetchPointDetailApi } from '../../api.js';
import { showToast } from '../../components/toast.js';
import { setupFocusTrap, releaseFocusTrap } from '../../components/modal.js';
import {
escapeHtml, pointStatusBadge, pointMethodBadge,
escapeHtml, pointStatusBadge, pointMethodBadge, overallMethod,
fmtRelc, fmtDur, fmtDateTime, fmtFullTs,
} from '../../utils/format.js';
import { isAbortError } from '../../utils/errors.js';
@@ -88,7 +88,7 @@ export async function openPointPanel(ctx, pointName) {
const json = await res.json();
if (!json.success || !json.data) throw new Error(json.message || '无数据');
const { point, attempts, conv } = json.data;
badges.innerHTML = `${pointStatusBadge(point.status)} ${pointMethodBadge(point.success_method)}`;
badges.innerHTML = `${pointStatusBadge(point.status)} ${pointMethodBadge(overallMethod(point))}`;
body.innerHTML = '';
body.appendChild(buildAttemptSection(attempts));
body.appendChild(buildConvSection(conv));
@@ -98,6 +98,48 @@ export async function openPointPanel(ctx, pointName) {
}
}
/** 阶段徽标:failed_stage = "tlusty"/"synspec";旧数据 NULL → 兜底 TLUSTY。 */
function attemptStageBadge(stage) {
const isSyn = stage === 'synspec';
const cls = isSyn ? 'syn' : 'cold';
const label = isSyn ? 'SYNSPEC' : 'TLUSTY';
return `<span class="method-badge ${cls}">${label}</span>`;
}
/**
* summary_jsonModelSummary推导尝试方法徽标Phase 6 起无 task_type 字段
* ModelSummary.seed 存在 种子步进热启动否则冷启动synspec-only 任务无 seed
* 标冷启动已知兜底Phase 7a 将改用策略派生口径精化
*/
function attemptMethodBadge(a) {
try {
const obj = a.summary_json ? JSON.parse(a.summary_json) : null;
if (obj && obj.seed) return '<span class="method-badge seed">种子步进</span>';
} catch {
/* 解析失败 → 按冷启动处理 */
}
return '<span class="method-badge cold">冷启动</span>';
}
/**
* summary_json 提取 synspec 错误摘要容错
* - 正常路径ModelSummary JSON synspec_error
* - 错误路径`{"error": ...}`reporter 失败上报 error 文本
* - 解析失败 / 无错误返回 null不展示
*/
function summarySynspecError(summaryJson) {
if (!summaryJson) return null;
try {
const obj = JSON.parse(summaryJson);
if (!obj || typeof obj !== 'object') return null;
if (obj.error) return String(obj.error);
if (obj.synspec_error) return String(obj.synspec_error);
} catch {
/* 解析失败 → 不展示摘要 */
}
return null;
}
function buildAttemptSection(attempts) {
const sec = document.createElement('section');
sec.className = 'point-section';
@@ -107,13 +149,18 @@ function buildAttemptSection(attempts) {
const seedHtml = a.seed_point_name
? `<a href="#" data-panel-point="${escapeHtml(a.seed_point_name)}" class="seed-link" title="跳查种子来源点">${escapeHtml(a.seed_point_name)}</a>`
: '—';
const synErr = summarySynspecError(a.summary_json);
const errLine = (!ok && synErr)
? `<div class="attempt-synspec-err" title="${escapeHtml(synErr)}">synspec: ${escapeHtml(synErr.length > 48 ? synErr.slice(0, 48) + '…' : synErr)}</div>`
: '';
const resultHtml = ok
? '<span class="activity-result ok">成功</span>'
: `<span class="activity-result fail">${escapeHtml(a.status)}</span>`;
: `<span class="activity-result fail">${escapeHtml(a.status)}</span>${errLine}`;
return `
<tr>
<td class="tabular-num">${i + 1}</td>
<td>${pointMethodBadge(a.task_type)}</td>
<td>${attemptMethodBadge(a)}</td>
<td>${attemptStageBadge(a.failed_stage)}</td>
<td>${seedHtml}</td>
<td class="tabular-num">${fmtRelc(a.max_relc)}</td>
<td>${resultHtml}</td>
@@ -121,13 +168,13 @@ function buildAttemptSection(attempts) {
<td class="tabular-num" title="${escapeHtml(fmtFullTs(a.completed_at))}">${fmtDateTime(a.completed_at)}</td>
<td class="tabular-num">${fmtDur(a.elapsed_sec)}</td>
</tr>`;
}).join('') || '<tr><td colspan="8" class="empty-cell">尚无尝试记录(点未被派发)</td></tr>';
}).join('') || '<tr><td colspan="9" class="empty-cell">尚无尝试记录(点未被派发)</td></tr>';
sec.innerHTML = `
<h3 class="point-section-title">尝试历史${list.length}</h3>
<div class="table-responsive">
<table class="data-table attempts-table">
<thead>
<tr><th scope="col">#</th><th scope="col"></th><th scope="col"></th><th scope="col">max_relc</th><th scope="col"></th><th scope="col"></th><th scope="col"></th><th scope="col"></th></tr>
<tr><th scope="col">#</th><th scope="col"></th><th scope="col"></th><th scope="col"></th><th scope="col">max_relc</th><th scope="col"></th><th scope="col"></th><th scope="col"></th><th scope="col"></th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
@@ -149,7 +196,7 @@ function buildConvSection(conv) {
sec.className = 'point-section';
if (!conv) {
sec.innerHTML = `
<h3 class="point-section-title">阶段链诊断</h3>
<h3 class="point-section-title">TLUSTY 收敛链诊断</h3>
<p class="text-hint">诊断文件不可用conv.json 缺失或解析失败</p>`;
return sec;
}
@@ -177,7 +224,7 @@ function buildConvSection(conv) {
? `<span class="activity-result fail" title="${escapeHtml(conv.synspec_error)}">${escapeHtml(conv.synspec_error)}</span>`
: '—';
sec.innerHTML = `
<h3 class="point-section-title">阶段链诊断</h3>
<h3 class="point-section-title">TLUSTY 收敛链诊断</h3>
<div class="stage-chain">${stagesHtml}</div>
<dl class="conv-meta">
<div><dt>最终 max_relc</dt><dd class="tabular-num">${conv.final_max_relc != null ? Number(conv.final_max_relc).toExponential(3) : ''}</dd></div>
+12 -8
View File
@@ -7,7 +7,7 @@
import { fetchWorkflowPointsApi } from '../../api.js';
import { showToast } from '../../components/toast.js';
import {
escapeHtml, pointStatusBadge, pointMethodBadge,
escapeHtml, pointStatusBadge, pointMethodBadge, overallMethod,
fmtRelc, fmtDur, fmtDateTime, fmtFullTs, csvEscape,
} from '../../utils/format.js';
import { isAbortError, logError } from '../../utils/errors.js';
@@ -41,13 +41,15 @@ export function renderPointsTab(ctx) {
<option value="pending">未开始</option>
<option value="queued">排队中</option>
<option value="running">运行中</option>
<option value="converged">收敛</option>
<option value="completed">完成</option>
<option value="failed">失败</option>
</select>
<select data-wf-filter="method" aria-label="按收敛手段过滤">
<option value="">全部手段</option>
<option value="cold_run">冷启动</option>
<option value="seed_step">种子步进</option>
<!-- 光谱专用点过滤器synspec-onlytlusty 禁用服务端映射 tlusty IS NULL AND synspec IS NOT NULL -->
<option value="synspec_only">SYNSPEC 专用</option>
</select>
<select data-wf-filter="wave" aria-label="按波次过滤">
<option value="">全部波次</option>${waveOpts}
@@ -125,14 +127,14 @@ function renderPointsRows(tbody, points) {
const updated = [];
points.forEach(p => {
const name = escapeHtml(p.name);
const sig = `${p.status}|${p.success_method || ''}|${p.attempt_count}`;
const sig = `${p.status}|${overallMethod(p) || ''}|${p.attempt_count}`;
let tr = existing.get(p.name);
if (tr && tr.getAttribute('data-sig') === sig) {
const cell = tr.querySelector('.col-max-relc');
const relcHtml = fmtRelc(p.last_max_relc);
if (cell && cell.innerHTML !== relcHtml) cell.innerHTML = relcHtml;
} else {
const rescued = p.status === 'converged' && p.attempt_count > 1;
const rescued = p.status === 'completed' && p.attempt_count > 1;
const html = `
<td class="col-point-name node-id" title="${name}">${name}</td>
<td class="tabular-num pt-param">${escapeHtml(p.teff)}</td>
@@ -141,7 +143,7 @@ function renderPointsRows(tbody, points) {
<td class="tabular-num pt-param">${escapeHtml(p.cno_sum)}</td>
<td class="tabular-num pt-param">${escapeHtml(p.wave)}</td>
<td>${pointStatusBadge(p.status)}</td>
<td>${pointMethodBadge(p.success_method)}</td>
<td>${pointMethodBadge(overallMethod(p))}</td>
<td class="col-max-relc tabular-num">${fmtRelc(p.last_max_relc)}</td>
<td class="tabular-num">${fmtDur(p.last_elapsed_sec)}</td>
<td class="tabular-num" title="${escapeHtml(fmtFullTs(p.last_completed_at))}">${fmtDateTime(p.last_completed_at)}</td>
@@ -215,11 +217,13 @@ export async function exportPointsCsv(ctx) {
showToast('当前过滤条件下没有可导出的网格点', 'info');
return;
}
const header = ['name','teff','logg','loghe','logc','logn','logo','cno_sum','wave','status','success_method','attempt_count','last_max_relc','last_task_type','seed_point_name','node_id','last_completed_at','last_elapsed_sec','last_error'];
// CSV 的"收敛方法"列导出整体归因 overallMethod = tlusty ?? synspecP9 拆分后派生值,
// 已含收敛归因且覆盖 synspec-only;列名 overall_method 避免重新合并两阶段值域)。
const header = ['name','teff','logg','loghe','logc','logn','logo','cno_sum','wave','status','overall_method','attempt_count','last_max_relc','seed_point_name','node_id','last_completed_at','last_elapsed_sec','last_error'];
const rows = pts.map(p => [
p.name, p.teff, p.logg, p.loghe, p.logc, p.logn, p.logo, p.cno_sum, p.wave,
p.status, p.success_method ?? '', p.attempt_count ?? '',
p.last_max_relc ?? '', p.last_task_type ?? '', p.seed_point_name ?? '',
p.status, overallMethod(p) ?? '', p.attempt_count ?? '',
p.last_max_relc ?? '', p.seed_point_name ?? '',
p.node_id ?? '', p.last_completed_at ?? '', p.last_elapsed_sec ?? '', p.last_error ?? '',
].map(csvEscape).join(','));
const csv = '\uFEFF' + [header.join(','), ...rows].join('\n');
+2 -1
View File
@@ -78,7 +78,8 @@ function startScopedPolling() {
poller = createPoller(async () => {
const statsOk = await refreshStats(ctx.name);
// 同步引擎面板的操作按钮状态(启动/暂停可用性随工作流状态变化)。
refreshEnginePanel();
// 透传 ctx.abortCtl.signal:卸载后中断在途详情响应,避免改写已丢弃的 panelState。
refreshEnginePanel(ctx.abortCtl?.signal);
if (ctx.activeTab === 'points') await refreshPoints(ctx);
return statsOk; // stats 拉取成败决定退避
}, { baseMs: 5000, maxMs: 60000 });
+19 -4
View File
@@ -10,7 +10,7 @@ import assert from 'node:assert/strict';
import {
escapeHtml, parseIso, parseTsMs, fmtTime, fmtDateTime, fmtFullTs,
fmtDuration, fmtDur, fmtRelc, csvEscape, fmtClock,
statusBadge, pointStatusBadge, pointMethodBadge,
statusBadge, pointStatusBadge, pointMethodBadge, overallMethod,
} from '../src/utils/format.js';
// ===== escapeHtml =====
@@ -142,7 +142,7 @@ test('statusBadge 覆盖全部后端工作流状态', () => {
});
test('pointStatusBadge 覆盖网格点状态', () => {
assert.equal(pointStatusBadge('converged'), '<span class="status-badge online">已收敛</span>');
assert.equal(pointStatusBadge('completed'), '<span class="status-badge online">已完成</span>');
assert.equal(pointStatusBadge('failed'), '<span class="status-badge danger">失败</span>');
assert.equal(pointStatusBadge('running'), '<span class="status-badge warning">运行中</span>');
assert.equal(pointStatusBadge('queued'), '<span class="status-badge info">排队中</span>');
@@ -150,10 +150,10 @@ test('pointStatusBadge 覆盖网格点状态', () => {
assert.equal(pointStatusBadge('???'), '<span class="status-badge secondary">未知</span>');
});
test('pointMethodBadge 识别收敛途径(与后端 success_method 契约对齐)', () => {
test('pointMethodBadge 识别收敛途径(与后端整体归因契约对齐)', () => {
assert.equal(pointMethodBadge('cold_run'), '<span class="method-badge cold">冷启动</span>');
assert.equal(pointMethodBadge('seed_step'), '<span class="method-badge seed">种子步进</span>');
// 其它策略名(synspec-only 任务归因的 success_method,如 "standard")原样展示
// 其它策略名(synspec-only 归因的光谱策略,如 "standard")原样展示
assert.equal(pointMethodBadge('standard'), '<span class="method-badge syn">standard</span>');
// 含 HTML 元字符须转义(XSS 防护)
assert.equal(
@@ -163,3 +163,18 @@ test('pointMethodBadge 识别收敛途径(与后端 success_method 契约对
assert.equal(pointMethodBadge(null), '<span class="text-hint">—</span>');
assert.equal(pointMethodBadge(''), '<span class="text-hint">—</span>');
});
test('overallMethod 派生整体归因(tlusty 阶段优先,synspec-only 退回光谱策略)', () => {
// 正常双阶段点:TLUSTY 阶段策略
assert.equal(overallMethod({ tlusty_success_method: 'seed_step' }),
'seed_step');
// synspec-only 点:tlusty 为 NULL,退回光谱策略
assert.equal(overallMethod({ tlusty_success_method: null, synspec_success_method: 'standard' }),
'standard');
// 双阶段都落库时优先大气侧
assert.equal(overallMethod({ tlusty_success_method: 'cold_run', synspec_success_method: 'standard' }),
'cold_run');
// 两者皆空 → null`??` 保留右侧 null
assert.equal(overallMethod({ tlusty_success_method: null, synspec_success_method: null }),
null);
});
+35
View File
@@ -117,6 +117,41 @@ test('triggerNow() 立即触发一次并续排', async () => {
stub.restore();
});
test('triggerNow() 与在途 schedule 不并发执行 fninFlight 守卫)', async () => {
const stub = installDocStub();
const timers = captureTimers();
let active = 0; // 当前正在执行的 fn 数(应恒 ≤1)
let maxActive = 0;
let calls = 0;
// fn 是慢异步:用 gate 控制其完成时机,确保 triggerNow 落在它在途时。
let resolveFn = null;
const fn = () => {
active++;
maxActive = Math.max(maxActive, active);
calls++;
return new Promise((resolve) => { resolveFn = resolve; });
};
const p = createPoller(fn, { baseMs: 5000 });
const startP = p.start(); // 触发首次 schedulefn 在途,停在 await
// 让 start 的 schedule 进入 fn()——通过一次微任务边界。
await Promise.resolve();
await Promise.resolve();
// 此时 fn 在途(calls=1active=1)。triggerNow 应受 inFlight 守卫直接 return
// 不再起一个新的 schedule 并发跑 fn。
const triggerP = p.triggerNow();
await Promise.resolve();
await Promise.resolve();
// 完成 fn(在途的 schedule 才会推进)。
resolveFn(true);
await Promise.all([startP, triggerP]);
active = 0; // fn 已完成
assert.equal(calls, 1, 'triggerNow 不得并发触发第二次 fninFlight 守卫)');
assert.equal(maxActive, 1, 'fn 不得并发执行(maxActive 应为 1');
p.stop();
timers.restore();
stub.restore();
});
test('退避上限 maxMs 生效(长时间连续失败不无限增长)', async () => {
const stub = installDocStub();
const timers = captureTimers();
+1 -1
View File
@@ -30,7 +30,7 @@ function ctx(name, points = [], ts = null) {
test('savePsCache 写入 JSON 并可由 restorePsCache 恢复', () => {
store.clear();
const before = Date.now();
const c = ctx('sdB_cno', [{ name: 'p1', teff: 20000, status: 'converged' }]);
const c = ctx('sdB_cno', [{ name: 'p1', teff: 20000, status: 'completed' }]);
savePsCache(c);
// 持久化 key 存在
+57 -30
View File
@@ -17,17 +17,17 @@ import {
} from '../src/utils/yamlStage.js';
test('defaultStagetlusty 默认 [cold_run, seed_step]synspec 默认 [standard]', () => {
assert.deepEqual(defaultStage('tlusty'), {
assert.deepEqual(defaultStage('tlusty_stage'), {
enabled: true, policy: 'skip_converged', strategies: ['cold_run', 'seed_step'],
});
assert.deepEqual(defaultStage('synspec'), {
assert.deepEqual(defaultStage('synspec_stage'), {
enabled: true, policy: 'skip_converged', strategies: ['standard'],
});
});
test('extractTopBlock:顶层注释(列 0 #)终止块,不吞入下一块(审查 #4)', () => {
const yaml = [
'tlusty:',
'tlusty_stage:',
' enabled: true',
' policy: skip_converged',
'# 这是顶层注释,应终止 tlusty 块',
@@ -35,7 +35,7 @@ test('extractTopBlock:顶层注释(列 0 #)终止块,不吞入下一块
' enabled: false',
' policy: force_recompute',
].join('\n');
const t = extractTopBlock(yaml, 'tlusty');
const t = extractTopBlock(yaml, 'tlusty_stage');
assert.equal(t.lines.length, 3, 'tlusty 块应只含自身 3 行,不含顶层注释与后续块');
assert.ok(!t.lines.some(l => l.includes('synspec_stage')), '不吞入 synspec_stage');
@@ -46,24 +46,51 @@ test('extractTopBlock:顶层注释(列 0 #)终止块,不吞入下一块
test('extractTopBlock:空行属块体,缩进注释属块体', () => {
const yaml = [
'tlusty:',
'tlusty_stage:',
' enabled: true',
'',
' # 缩进注释,属块体',
' policy: skip_converged',
].join('\n');
const t = extractTopBlock(yaml, 'tlusty');
const t = extractTopBlock(yaml, 'tlusty_stage');
assert.equal(t.lines.length, 5);
});
test('extractTopBlockkey 含正则元字符按字面匹配,`.` 不通配(防御性转义)', () => {
// 用不会真实出现的 `te.lusty` 验证 `.` 被转义:若未转义,`teXlusty:` 会被误匹配。
const yaml = [
'teXlusty:',
' enabled: true',
'te.lusty:',
' enabled: false',
].join('\n');
// 字面匹配 te.lusty → 命中第三行,而非被通配成 teXlusty。
const t = extractTopBlock(yaml, 'te.lusty');
assert.ok(t, '含 `.` 的 key 应能定位其自身块');
assert.equal(t.startIdx, 2, '应命中字面 te.lusty: 行,而非被通配成 teXlusty:');
assert.ok(!t.lines.some(l => l.includes('teXlusty')), '不应误匹配通配目标 teXlusty');
});
test('extractTopBlockkey 含 `+` 等其它元字符仍按字面匹配', () => {
// `+` 在正则里是量词;未转义会抛 SyntaxError 或误匹配。此处验证按字面定位。
const yaml = [
'a+b:',
' enabled: true',
].join('\n');
const t = extractTopBlock(yaml, 'a+b');
assert.ok(t, '含 `+` 的 key 应按字面匹配而不抛错');
assert.equal(t.startIdx, 0);
assert.equal(t.lines.length, 2);
});
test('parseStageFromYaml:行内注释不污染 policy 解析(审查 #5', () => {
const yaml = [
'tlusty:',
'tlusty_stage:',
' enabled: true',
' policy: skip_converged # 旧值 force_recompute 已废弃',
' strategies: [cold_run, seed_step]',
].join('\n');
const t = parseStageFromYaml(yaml, 'tlusty');
const t = parseStageFromYaml(yaml, 'tlusty_stage');
assert.equal(t.policy, 'skip_converged', '应取真值而非注释里的 force_recompute');
assert.equal(t.enabled, true);
assert.deepEqual(t.strategies, ['cold_run', 'seed_step']);
@@ -71,12 +98,12 @@ test('parseStageFromYaml:行内注释不污染 policy 解析(审查 #5',
test('parseStageFromYaml:整行注释里的 policy 不被当真值(审查 #5', () => {
const yaml = [
'tlusty:',
'tlusty_stage:',
' # policy: force_recompute <- 注释掉的旧值',
' policy: skip_failed',
' strategies: [cold_run]',
].join('\n');
const t = parseStageFromYaml(yaml, 'tlusty');
const t = parseStageFromYaml(yaml, 'tlusty_stage');
assert.equal(t.policy, 'skip_failed', '应跳过整行注释,取真值 skip_failed');
});
@@ -93,20 +120,20 @@ test('parseStageFromYamlenabled 行内注释', () => {
test('parseStageFromYaml:块式 strategies 列表', () => {
const yaml = [
'tlusty:',
'tlusty_stage:',
' enabled: true',
' policy: skip_converged',
' strategies:',
' - cold_run',
' - seed_step',
].join('\n');
const t = parseStageFromYaml(yaml, 'tlusty');
const t = parseStageFromYaml(yaml, 'tlusty_stage');
assert.deepEqual(t.strategies, ['cold_run', 'seed_step']);
});
test('parseStageFromYaml:块不存在返回 null', () => {
const yaml = 'grid:\n teff: [20000]\n';
assert.equal(parseStageFromYaml(yaml, 'tlusty'), null);
assert.equal(parseStageFromYaml(yaml, 'tlusty_stage'), null);
});
test('resolveTlustyFromYaml:无 tlusty 块时按 seed_step_fallback 推断(审查 #2 修复)', () => {
@@ -124,16 +151,16 @@ test('resolveTlustyFromYaml:无 tlusty 块时按 seed_step_fallback 推断(
// seed_step_fallback: true → 默认链 [cold_run, seed_step]。
const yamlTrue = 'seed_step_fallback: true\n';
assert.deepEqual(resolveTlustyFromYaml(yamlTrue), defaultStage('tlusty'));
assert.deepEqual(resolveTlustyFromYaml(yamlTrue), defaultStage('tlusty_stage'));
// 缺省(无该字段)→ 默认链。
const yamlAbsent = 'grid:\n teff: [20000]\n';
assert.deepEqual(resolveTlustyFromYaml(yamlAbsent), defaultStage('tlusty'));
assert.deepEqual(resolveTlustyFromYaml(yamlAbsent), defaultStage('tlusty_stage'));
});
test('resolveTlustyFromYaml:有 tlusty 块时优先块(不受旧字段干扰)', () => {
const yaml = [
'tlusty:',
'tlusty_stage:',
' enabled: true',
' policy: force_recompute',
' strategies: [cold_run]',
@@ -146,7 +173,7 @@ test('resolveTlustyFromYaml:有 tlusty 块时优先块(不受旧字段干扰
test('resolveTlustyFromYaml:注释行不误判 seed_step_fallback', () => {
const yaml = '# seed_step_fallback: false(注释,非真实配置)\ngrid:\n teff: [20000]\n';
assert.deepEqual(resolveTlustyFromYaml(yaml), defaultStage('tlusty'),
assert.deepEqual(resolveTlustyFromYaml(yaml), defaultStage('tlusty_stage'),
'注释行被忽略,按缺省推断默认链');
});
@@ -155,7 +182,7 @@ test('applyStageBlocks:替换已存在块,保留其他配置(往返保真
'grid:',
' teff: [20000]',
'timeout_sec: 7200',
'tlusty:',
'tlusty_stage:',
' enabled: true',
' policy: skip_converged',
' strategies: [cold_run]',
@@ -172,22 +199,22 @@ test('applyStageBlocks:替换已存在块,保留其他配置(往返保真
assert.match(out, /teff: \[20000\]/);
assert.match(out, /timeout_sec: 7200/);
// tlusty 块被替换为新值。
assert.match(out, /tlusty:\n enabled: false\n policy: force_recompute\n strategies: \[seed_step\]/);
assert.match(out, /tlusty_stage:\n enabled: false\n policy: force_recompute\n strategies: \[seed_step\]/);
// synspec_stage 块保留/替换正确。
assert.match(out, /synspec_stage:\n enabled: true\n policy: skip_converged\n strategies: \[standard\]/);
});
test('applyStageBlocks:块不存在时追加到末尾', () => {
const yaml = 'grid:\n teff: [20000]\n';
const out = applyStageBlocks(yaml, defaultStage('tlusty'), defaultStage('synspec'));
assert.match(out, /tlusty:/);
const out = applyStageBlocks(yaml, defaultStage('tlusty_stage'), defaultStage('synspec_stage'));
assert.match(out, /tlusty_stage:/);
assert.match(out, /synspec_stage:/);
assert.match(out, /teff: \[20000\]/, '原配置保留');
});
test('applyStageBlocks:两块间有顶层注释时不互相破坏(审查 #4 回归)', () => {
const yaml = [
'tlusty:',
'tlusty_stage:',
' enabled: true',
' policy: skip_converged',
' strategies: [cold_run, seed_step]',
@@ -200,29 +227,29 @@ test('applyStageBlocks:两块间有顶层注释时不互相破坏(审查 #4
// 修改 tlusty 不应破坏 synspec_stage(旧实现会把注释+synspec 吞进 tlusty 块后覆盖丢失)。
const out = applyStageBlocks(yaml,
{ enabled: false, policy: 'skip_converged', strategies: ['cold_run'] },
defaultStage('synspec'),
defaultStage('synspec_stage'),
);
assert.match(out, /synspec_stage:\n enabled: true/, 'synspec_stage 块必须存活');
assert.match(out, /tlusty:\n enabled: false/, 'tlusty 块已更新');
assert.match(out, /tlusty_stage:\n enabled: false/, 'tlusty 块已更新');
});
test('serializeStageBlockflow 序列输出格式', () => {
const s = serializeStageBlock('tlusty', {
const s = serializeStageBlock('tlusty_stage', {
enabled: true, policy: 'skip_converged', strategies: ['cold_run', 'seed_step'],
});
assert.equal(s, 'tlusty:\n enabled: true\n policy: skip_converged\n strategies: [cold_run, seed_step]');
assert.equal(s, 'tlusty_stage:\n enabled: true\n policy: skip_converged\n strategies: [cold_run, seed_step]');
});
test('端到端往返:解析 → 序列化 → 再解析 保持语义', () => {
const orig = [
'tlusty:',
'tlusty_stage:',
' enabled: true',
' policy: force_recompute',
' strategies: [cold_run, seed_step]',
].join('\n');
const parsed = parseStageFromYaml(orig, 'tlusty');
const serialized = serializeStageBlock('tlusty', parsed);
const reparsed = parseStageFromYaml(serialized, 'tlusty');
const parsed = parseStageFromYaml(orig, 'tlusty_stage');
const serialized = serializeStageBlock('tlusty_stage', parsed);
const reparsed = parseStageFromYaml(serialized, 'tlusty_stage');
assert.deepEqual(parsed, reparsed, '往返保真');
});
+13 -9
View File
@@ -159,10 +159,10 @@ ND=50,NLAMBD=3,VTB=2.,ISPODF=1,DDNU=50.,CNU1=6.,[CHMAX=..][,ITEK=..],NITER=<阶
### 3.3 synspec 配置(fort.55 + 谱线表)
```
fort.55 控制卡: 波长窗/展宽/截断(由 workflow YAML 的 synspec: 块或代码默认配置)
fort.55 控制卡: 波长窗/展宽/截断(由 workflow YAML 的 `synspec_input:` 块或代码默认配置)
谱线表 fort.19: data 下 gf 谱线数据(含 C/N/O 线)
```
- 代码默认波长窗为 **14001410 Å**`SynspecConfig` 默认),实际使用通常配置到
- 代码默认波长窗为 **14001410 Å**`SynspecInput` 默认),实际使用通常配置到
目标波段(如 3000-7000Å 光学波段,覆盖 C II 4267、C III 4647 等)。
- 大气来自 nl 阶段的 `.7`(复制为 fort.8)。
@@ -245,7 +245,8 @@ fort.55 控制卡: 波长窗/展宽/截断(由 workflow YAML 的 synspec: 块
> **走种子步进链时**`seed` 指向邻居 `.7``stages` 里没有 `lte`,而是
> `seed_nc``ltgray=F` 热启动,NITER=20)→ `nl`
> 注意:`conv.json` 不含 `seed_step_used` / `coldfail_backup` 布尔字段(旧版字段已移除);
> 收敛手段归因由服务端 `tasks` 表的 `task_type`cold_run / seed_step)聚合统计
> 收敛手段归因由服务端 `tasks` 表的 `tlusty_strategies[0]`cold_run / seed_step)聚合统计
> Phase 6 起 `task_type` 列已删除,策略链首项即当前执行策略的权威快照)。
每阶段记录:`converged`(是否收敛)、`max_relc`(最大相对变化)、
`worst_depth`(最差深度点)、`last_iter`(迭代次数)、`n_depths`(深度点数)、
@@ -262,11 +263,11 @@ runner 在每个阶段的循环开始/结束处计时,conv.json 里每个 stag
早期 `grid_status.json` 文件已废弃;当前状态统计由服务端 API 实时查询 DB 提供:
- `GET /api/workflows`:各工作流内联进度(total/converged/failed/running…)
- `GET /api/workflows`:各工作流内联进度(total/completed/failed/running…`completed` 由 7c 改名自 `converged`
- `GET /api/workflows/:name/stats`:单工作流统计(含 `cold_run_converged` / `seed_step_converged`
- `GET /api/workflows/:name/points`:逐点列表(可过滤 status/method/wave/q,分页)
- `GET /api/workflows/:name/progress`:进度-时间序列曲线
- 种子步进命中数 = `tasks``task_type='seed_step'``status='completed'` 的聚合
- 种子步进命中数 = `tasks``json_extract(tlusty_strategies,'$[0]')='seed_step'``status='completed'` 的聚合
前端 Dashboard 直接消费以上端点渲染(首页卡片、详情页概览/点表/平行集合分析图)。
@@ -291,14 +292,17 @@ grid:
logn: [-4, -2, -1]
logo: [-4, -2, -1]
# 共 4*2*2*3*3*3 = 432 个点
tlusty:
tlusty_stage: # TLUSTY 阶段独立配置(enabled/policy/strategies
enabled: true
policy: skip_converged
strategies: ["cold_run", "seed_step"] # 策略链:冷启动失败回退种子步进
synspec:
synspec_stage: # SYNSPEC 阶段独立配置(enabled/policy/strategies
enabled: true
wstart: 3000
wend: 7000
policy: skip_converged
strategies: ["standard"]
synspec_input: # SYNSPEC 数值参数(fort.55 波长范围等)
wstart: 3000.0
wend: 7000.0
```
### 第2步:创建并启动工作流(HTTP API / Dashboard
+43 -38
View File
@@ -110,32 +110,32 @@
pub task_id: Uuid,
pub point_name: String,
pub params: GridPointParams,
pub task_type: TaskType, // ColdRun | SeedStep(兼容字段 = strategies[0]
pub seed_point_name: Option<String>, // 步进种子点名称(如适用)
pub timeout_sec: u64,
pub workflow_name: Option<String>, // 多工作流分区键
pub wave: i32, // 难度波次
pub tlusty_config: EngineStageConfig, // TLUSTY 阶段配置(enabled/policy/strategies
pub synspec_config: EngineStageConfig, // SYNSPEC 阶段配置
pub tlusty_config: PhaseConfig, // TLUSTY 阶段配置(enabled/policy/strategies
pub synspec_config: PhaseConfig, // SYNSPEC 阶段配置
pub synspec_params: Option<serde_json::Value>, // SYNSPEC 数值参数(波长范围等)
pub atmosphere_ref: Option<String>, // 显式大气来源点(仅 SYNSPEC-only 场景)
}
```
`EngineStageConfig`(阶段独立配置,见 `task_engine_decoupling_design.md §3`):
`PhaseConfig`(阶段独立配置,见 `task_engine_decoupling_design.md §3`Phase 7b 由 `EngineStageConfig` 改名):
```rust
pub struct EngineStageConfig {
pub struct PhaseConfig {
pub enabled: bool,
pub policy: String, // skip_converged | force_recompute | skip_failed
pub policy: ResumePolicy, // skip_converged | force_recompute | skip_failed
pub strategies: Vec<String>, // 策略链:["cold_run","seed_step"],失败回退弹首项
}
```
- **TypeScript 类型声明**:
```typescript
export type TaskType = 'cold_run' | 'seed_step';
> Phase 6 起 **无 `task_type` 字段**——执行链由 `tlusty_config.strategies[0]` 派生。
> 旧 payload 若仍携带 `task_type` 键会被 serde 忽略(未知字段)。
export interface EngineStageConfig {
- **TypeScript 类型声明**Phase 6 起无 `task_type`:
```typescript
export interface PhaseConfig {
enabled: boolean;
policy: string;
strategies: string[];
@@ -145,13 +145,12 @@
task_id: string;
point_name: string;
params: GridPointParams;
task_type: TaskType;
seed_point_name?: string | null;
timeout_sec: number;
workflow_name?: string | null;
wave: number;
tlusty_config: EngineStageConfig;
synspec_config: EngineStageConfig;
tlusty_config: PhaseConfig;
synspec_config: PhaseConfig;
synspec_params?: Record<string, unknown> | null;
atmosphere_ref?: string | null;
}
@@ -172,7 +171,7 @@ Worker 节点向服务端上报的任务计算结果。
pub params: Option<GridPointParams>,
pub node_id: String,
pub status: TaskStatus, // Pending | Running | Completed | Failed | Timeout
pub converged: bool,
pub result_valid: bool, // 7b 改名(原 converged):本次结果是否可用(大气收敛/管线成功双义)
pub max_relc: Option<f64>,
pub atmosphere_has_nan: bool,
pub elapsed_sec: f64,
@@ -202,7 +201,7 @@ Worker 节点向服务端上报的任务计算结果。
params?: GridPointParams;
node_id: string;
status: TaskStatus;
converged: boolean;
result_valid: boolean; // 7b 改名(原 converged
max_relc?: number | null;
atmosphere_has_nan: boolean;
elapsed_sec: number;
@@ -311,11 +310,12 @@ Worker 节点向服务端上报的任务计算结果。
export interface WorkflowListStats {
total: number;
converged: number;
completed: number; // 7c:由 converged 改名(状态值 'completed'
failed: number;
running: number;
cold_run_converged: number;
seed_step_converged: number;
synspec_converged: number;
}
export interface WorkflowItem {
@@ -550,9 +550,10 @@ Worker 节点向服务端上报的任务计算结果。
"logn": -2.0,
"logo": -2.0
},
"task_type": "cold_run",
"seed_point_name": null,
"timeout_sec": 7200
"timeout_sec": 7200,
"tlusty_config": { "enabled": true, "policy": "skip_converged", "strategies": ["cold_run", "seed_step"] },
"synspec_config": { "enabled": true, "policy": "skip_converged", "strategies": ["standard"] }
}
}
```
@@ -656,7 +657,7 @@ Worker 节点向服务端上报的任务计算结果。
- **请求格式**: `multipart/form-data`
- Part `report`: 旧版 `conv.json` 的**原文 JSON**(映射为 `ModelSummary`,服务端解析出 `name` / `params` / `converged` / `final_max_relc`
- Part `seed_file`: 二进制数据(`.7` 大气种子文件;收敛点必传)
- Part `success_method` *(可选)*: 文本 `cold_run` / `seed_step`。由 `tools/import_results` 依据旧 `conv.json` 的 stages 是否含 `seed_nc` 判定后设置,决定导入点最终归因(缺省按 seed_step 统计)
- Part `tlusty_success_method` *(可选)*: 文本 `cold_run` / `seed_step`。由 `tools/import_results` 依据旧 `conv.json` 的 stages 是否含 `seed_nc` 判定后设置,写入导入点大气归因 `tlusty_success_method`(缺省按 seed_step 统计)
- **Query 参数**: `workflow`(可选,默认 `imported`):目标工作流名,种子导入到该工作流的 `grid_points`
- **命名保真**: `point_name` 取旧 `conv.json``name` 字段(源精度真名,如 `t20000_g5.0_...`),**逐字符**落库(磁盘目录、`grid_points.name``seeds.point_name`),与旧版 Python `gen_input5.model_name` 完全一致。
- **幂等**: `ON CONFLICT DO NOTHING` upsert `grid_points``conv.json``.7` 原子覆盖写,可重复运行。
@@ -810,10 +811,11 @@ Worker 节点向服务端上报的任务计算结果。
"pending": 210,
"queued": 60,
"running": 32,
"converged": 260,
"completed": 260,
"failed": 10,
"cold_run_converged": 200,
"seed_step_converged": 60
"seed_step_converged": 60,
"synspec_converged": 245
}
}
```
@@ -1101,12 +1103,12 @@ Worker 节点向服务端上报的任务计算结果。
"pending": 120,
"queued": 40,
"running": 8,
"converged": 261,
"completed": 261,
"failed": 3,
"cold_run_converged": 220,
"seed_step_converged": 41,
"waves": [
{ "wave": 0, "total": 108, "converged": 108, "failed": 0 }
{ "wave": 0, "total": 108, "completed": 108, "failed": 0 }
],
"avg_point_sec": 740.5,
"eta_sec": 9620.0
@@ -1114,10 +1116,10 @@ Worker 节点向服务端上报的任务计算结果。
}
```
> 收敛手段归因仅 `cold_run_converged` / `seed_step_converged` 两字段;**无独立
> `imported_converged`**——历史导入点统一按 seed_step 途径计入(`success_method` 语义见 §8.8)。
> `imported_converged`**——历史导入点统一按 seed_step 途径计入(大气归因 `tlusty_success_method` 语义见 §8.8)。
> `avg_point_sec` = `AVG(COALESCE(tasks.elapsed_sec, created_at→completed_at 时间戳差))`——
> 优先用 Worker 回报的精确墙钟(不含排队等待),历史无 `elapsed_sec` 的行回退时间戳差近似;
> `eta_sec` = `avg_point_sec × (total - converged - failed) ÷ 在线节点总槽位`(并发感知;
> `eta_sec` = `avg_point_sec × (total - completed - failed) ÷ 在线节点总槽位`(并发感知;
> 无在线节点按串行兜底);无历史数据时二者为 `null`
---
@@ -1132,8 +1134,8 @@ Worker 节点向服务端上报的任务计算结果。
| 参数 | 取值 | 默认 |
| :--- | :--- | :--- |
| `status` | `pending`/`queued`/`running`/`converged`/`failed` | 不过滤 |
| `method` | `cold_run`/`seed_step`(白名单**不含** `imported`,传入即 `400` | 不过滤 |
| `status` | `pending`/`queued`/`running`/`completed`/`failed`(旧值 `converged` 仍作兼容别名接受,服务端归一化为 `completed` | 不过滤 |
| `method` | `cold_run`/`seed_step`映射 `tlusty_success_method``synspec_only`(光谱专用点:`tlusty_success_method IS NULL AND synspec_success_method IS NOT NULL`)。白名单**不含** `imported`,传入即 `400` | 不过滤 |
| `wave` | 整数波次 | 不过滤 |
| `q` | 点名子串(LIKE 通配符已转义) | 不过滤 |
| `sort` | `wave`/`teff`/`max_relc`/`attempts`/`last_completed_at` | `wave` |
@@ -1154,11 +1156,11 @@ Worker 节点向服务端上报的任务计算结果。
"teff": 60000.0, "logg": 5.0, "loghe": -2.0,
"logc": -4.0, "logn": -4.0, "logo": -4.0,
"cno_sum": -12.0, "wave": 0,
"status": "converged",
"success_method": "seed_step",
"status": "completed",
"tlusty_success_method": "seed_step",
"synspec_success_method": "standard",
"attempt_count": 2,
"last_max_relc": 0.00043,
"last_task_type": "seed_step",
"seed_point_name": "t60000_g5.0_he2_c-4_n-4_o-4",
"node_id": "node-a1b2",
"last_completed_at": "2026-07-30 11:12:00",
@@ -1190,7 +1192,6 @@ Worker 节点向服务端上报的任务计算结果。
"attempts": [
{
"task_id": "uuid",
"task_type": "cold_run",
"seed_point_name": null,
"status": "failed",
"max_relc": 954000.0,
@@ -1198,9 +1199,12 @@ Worker 节点向服务端上报的任务计算结果。
"node_id": "node-a1b2",
"error_message": "nl stage diverged",
"created_at": "2026-07-30 09:00:00",
"completed_at": "2026-07-30 09:30:00"
"completed_at": "2026-07-30 09:30:00",
"elapsed_sec": 1800.0,
"failed_stage": "tlusty",
"summary_json": "{... ModelSummary ...}"
},
{ "task_type": "seed_step", "status": "completed", "...": "第二次尝试(救回)" }
{ "seed_point_name": "t60000_...", "status": "completed", "failed_stage": null, "...": "第二次尝试(救回)" }
],
"conv": {
"converged": true,
@@ -1242,7 +1246,7 @@ Worker 节点向服务端上报的任务计算结果。
"hours": 24,
"series": [
{ "ts": "2026-07-31 08:00:00", "total": 432, "pending": 120, "queued": 40,
"running": 8, "converged": 261, "failed": 3 }
"running": 8, "completed": 261, "failed": 3 }
],
"rate_per_hour": 12.5,
"now": "2026-07-31T09:00:00Z",
@@ -1253,9 +1257,9 @@ Worker 节点向服务端上报的任务计算结果。
}
```
> `series` 超 300 条自动降采样(首末点保留);`rate_per_hour` = 窗口首末
> converged 增量 ÷ 时长(快照不足 2 条为 null);`now` = 服务端当前 UTC 时刻(前端锚定曲线右缘);
> completed 增量 ÷ 时长(快照不足 2 条为 null);`now` = 服务端当前 UTC 时刻(前端锚定曲线右缘);
> `done_rate_per_hour` = 终态完成速率(用于 ETA);`rate_span_hours` = 速率统计实际时间跨度;
> `stalled_minutes` = 终态数(converged+failed)最后一次增长至窗口末端的分钟数(前端 >10 分钟触发停滞预警)。
> `stalled_minutes` = 终态数(completed+failed)最后一次增长至窗口末端的分钟数(前端 >10 分钟触发停滞预警)。
---
@@ -1277,11 +1281,12 @@ Worker 节点向服务端上报的任务计算结果。
"updated_at": "2026-07-30 11:00:00",
"stats": {
"total": 432,
"converged": 261,
"completed": 261,
"failed": 3,
"running": 8,
"cold_run_converged": 220,
"seed_step_converged": 41
"seed_step_converged": 41,
"synspec_converged": 248
}
}
]
+1 -1
View File
@@ -86,7 +86,7 @@ stateDiagram-v2
```
> 状态值与 [`GridPointStatus`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/common/src/models.rs#L237) 一一对应:
> `pending``queued``running``converged` / `failed`
> `pending``queued``running``completed` / `failed``completed` 由 7c 改名自 `converged`,旧值仍作兼容别名解析)
> Requeue 把 `running` 直接重置回 `pending`(不经过 `queued`),由调度器下一轮重新推入队列。
---
+23 -10
View File
@@ -38,14 +38,14 @@ erDiagram
integer wave
string status
integer attempt_count
string success_method
string tlusty_success_method
string synspec_success_method
double last_elapsed_sec
}
TASKS {
string task_id PK
string point_name
string node_id
string task_type
string seed_point_name
string status
double max_relc
@@ -64,6 +64,8 @@ erDiagram
text synspec_strategies
string atmosphere_ref
double elapsed_sec
string failed_stage
text summary_json
}
NODES {
string node_id PK
@@ -80,7 +82,6 @@ erDiagram
string node_id PK
string token_hash
datetime issued_at
integer revoked
string raw_token_pending
}
SEEDS {
@@ -103,7 +104,7 @@ erDiagram
integer pending
integer queued
integer running
integer converged
integer completed
integer failed
}
end
@@ -143,9 +144,13 @@ erDiagram
- `teff`, `logg`, `loghe`, `logc`, `logn`, `logo` (`REAL NOT NULL`)6 维物理参数。
- `cno_sum` (`REAL NOT NULL`)CNO 丰度之和(调度排序用)。
- `wave` (`INTEGER NOT NULL DEFAULT 0`):按 cno_sum 分组的批次波次(调度优先级用)。
- `status` (`VARCHAR(32)`)`pending` / `queued` / `running` / `converged` / `failed`
- `status` (`VARCHAR(32)`)`pending` / `queued` / `running` / `completed` / `failed`7c 由 `converged` 改名,M9 迁移)
- `attempt_count` (`INTEGER`):失败重试计数(仅观测用)。
- `success_method` (`VARCHAR(32)`)收敛时的成功手段 (`cold_run` 冷启动成功 / `seed_step` 种子步进成功 / `imported` 历史导入)。
- `tlusty_success_method` (`VARCHAR(32)`)**大气收敛归因**(P9 拆分)——TLUSTY 阶段以何策略收敛
(`cold_run` 冷启动成功 / `seed_step` 种子步进成功 / 策略名)TLUSTY 禁用(synspec-only)为 NULL。
Phase 6 起由 `tlusty_strategies[0]` 派生。整体归因由消费方派生(`tlusty ?? synspec`)。
- `synspec_success_method` (`VARCHAR(32)`)**光谱收敛归因**Phase 5a)——synspec 阶段以何策略收敛
(如 `standard`);TLUSTY-only 成功为 NULL。解锁"光谱以 standard 等策略收敛了多少点"的统计与过滤。
- `last_elapsed_sec` (`REAL`):最近一次尝试的墙钟耗时(详情页 ETA 估算用,兼容旧数据回退)。
> **多工作流分区(per-workflow partitioning**`grid_points``task_queue` 均按 `workflow_name` 隔离。
@@ -158,8 +163,8 @@ erDiagram
- `task_id` (`VARCHAR(128) PRIMARY KEY`):任务 IDUUID)。
- `point_name` (`TEXT NOT NULL`):网格点权威名(源精度,非 params 重推)。
- `node_id` (`TEXT`):执行节点 ID。
- `task_type` (`VARCHAR(32)`):兼容字段,等于 `tlusty_strategies[0]``cold_run` / `seed_step`),供旧节点识别。
- `seed_point_name` (`TEXT`):种子步进时注入的近邻种子点(`GET /api/seed/<name>` 下载依据)。
**双义**SYNSPEC-onlyTLUSTY 关闭)时恒为 NULL(大气来源见 `atmosphere_ref`)。
- `status` (`VARCHAR(32)`)`pending` / `claimed` / `running` / `completed` / `failed` / `timeout`
- `max_relc` (`REAL`):最终最大相对变化(收敛判据量)。
- `atmosphere_has_nan` (`BOOLEAN`):最终大气是否含 >10% NaN 行(无效化标记)。
@@ -172,6 +177,10 @@ erDiagram
`synspec_strategies` (`JSON`)、`atmosphere_ref` (`TEXT`,显式大气来源点)。
回退时弹 `*_strategies` 链首(见 `task_engine_decoupling_design.md §4.2`),policy/策略链取派发时快照。
- `elapsed_sec` (`REAL`):任务墙钟耗时(详情页 ETA 估算优先用此值)。
- `failed_stage` (`TEXT`):失败阶段归因(`"tlusty"` / `"synspec"`;旧节点/旧行 NULL → 服务端兜底按 TLUSTY 归因)。
- `summary_json` (`TEXT`):完整 `ModelSummary` JSON(含 `synspec_rc`/`synspec_error`/`synspec_sec` 与各子步骤摘要);
错误路径为 `{"error": ...}`。**全量保真**Phase 5b 起):逐次 itek 迭代诊断
`itek_history: [{iter, max_relc, n_depths}]`)随 summary_json 落库,与 conv.json 同源。
### 2.4 `nodes` (计算节点心跳与状态表)
- `node_id` (`TEXT PRIMARY KEY`):节点唯一标识(未指定 `DCTS_NODE_ID` 时自动生成 `node-<uuid>`)。
@@ -187,9 +196,11 @@ erDiagram
- `node_id` (`TEXT PRIMARY KEY`):关联 `nodes.node_id`
- `token_hash` (`TEXT NOT NULL`):节点专属 token 的 SHA-256 哈希(**不存明文**)。
- `issued_at` (`DATETIME NOT NULL`):颁发时间。
- `revoked` (`INTEGER NOT NULL DEFAULT 0`):吊销标记(重新颁发 token 时旧行吊销)。
- `raw_token_pending` (`TEXT`):暂存待确认的明文 token(颁发流程过渡用,确认后清除)。
> token 失效靠重发覆盖 `token_hash`(旧 hash 不存在 → 鉴权失败),无独立吊销标记;
> 历史 `revoked` 死列已由迁移 M4 清除(Phase 4)。
### 2.6 `seeds` (种子缓存池表)
全局共享的已收敛大气 `.7` 索引(跨工作流复用,种子步进热启动数据源)。
- `id` (`INTEGER PRIMARY KEY AUTOINCREMENT`)。
@@ -206,12 +217,14 @@ erDiagram
- `id` (`INTEGER PRIMARY KEY AUTOINCREMENT`)。
- `workflow_name` (`TEXT NOT NULL`)。
- `ts` (`DATETIME NOT NULL DEFAULT (datetime('now'))`):采样时间。
- `total` / `pending` / `queued` / `running` / `converged` / `failed` (`INTEGER NOT NULL`):该时刻各状态计数。
- `total` / `pending` / `queued` / `running` / `completed` / `failed`7c 由 `converged` 改名,M9 迁移) (`INTEGER NOT NULL`):该时刻各状态计数。
### 2.8 `task_queue` (分布式任务队列表 - `mq`)
驱动分布式抢占与超时重试的核心表,使用 SQLite `WAL` 模式确保高吞吐并发安全(详见 [`sqlite_queue.rs`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/mq/src/sqlite_queue.rs#L65))。
- `task_id` (`VARCHAR(128) PRIMARY KEY`):任务 IDUUID)。
- `payload` (`TEXT NOT NULL`):序列化的 [`TaskSpec`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/common/src/models.rs)(含 point_name / params / task_type / seed_point_name / workflow_name / tlusty_config / synspec_config 等)。
- `payload` (`TEXT NOT NULL`):序列化的 [`TaskSpec`](file:///home/fmq/program/tlusty/tl208-s54/dcts/crates/common/src/models.rs)
(含 point_name / params / seed_point_name / workflow_name / tlusty_config / synspec_config 等;
Phase 6 起无 `task_type` 字段,执行链由 `tlusty_config.strategies[0]` 派生)。
- `status` (`TEXT NOT NULL`)`pending`(就绪待领用)/ `claimed`(已被某 node 领用,计算中)。
- `created_at` (`DATETIME NOT NULL`):推入队列时间(同 `wave` 内 FIFO 排序键)。
- `claimed_at` (`DATETIME`):被领用的时间戳(`requeue_stale_tasks` 据此判定超时回投)。
+546
View File
@@ -0,0 +1,546 @@
# DCTS 数据库结构修复设计
> 本文档记录数据库结构的系统性修复方案(2026-08-05 立项)。
> 依据《docs/database.md》现状与调度/上报链路的实际数据流,覆盖迁移基础设施、
> tasks 阶段信息补全、索引、查询重写、死列清理与凭据留存决策、点级阶段化与命名语义修正。
>
> 关联文档:[database.md](./database.md)、[task_engine_decoupling_design.md](./task_engine_decoupling_design.md)、
> [dynamic_cpu_slots_design.md](./dynamic_cpu_slots_design.md)。
---
## 实施状态(2026-08-05 记录)
| Phase | 状态 | 关键改动 |
|---|---|---|
| 0 迁移基础设施 | ✅ 已实施 | `migrations.rs` + `PRAGMA user_version` 运行器,detect 守卫幂等 |
| 1 tasks 阶段信息 | ✅ 已实施 | M1 `failed_stage`/`summary_json`,结算落库 + 前端徽标 |
| 2 tasks 索引 | ✅ 已实施 | M2 `idx_tasks_wf_status_created` |
| 3 窗口函数化 | ✅ 已实施 | `ROW_NUMBER()` 重写逐点列表 + **§5.3 CTE 排序**(删 Rust sort_by |
| 4 死列清理 | ✅ 已实施 | M4 `DROP COLUMN revoked`registration_secret 留存 nodes |
| 6 删除 task_type | ✅ 已实施 | M6 `DROP COLUMN task_type`;执行链改 strategies[0] 推导 |
| 5a synspec 归因 | ✅ 已实施 | **M7**(版本号按部署序 >6`synspec_success_method` + 统计桶(含 WorkflowListStats |
| 5b 阶段状态列 | ✅ 已实施 | **M8** `tlusty_status`/`synspec_status`,结算/claim/running 同步,半失败守卫保留 tlusty 终态 |
| P9 归因拆分 | ✅ 已实施 | **M12** 新增 `tlusty_success_method` + 回填、**M13** `DROP COLUMN success_method`;整体归因改派生(前端 `tlusty ?? synspec` |
| 7a 注释+前端 | ✅ 已实施 | converged/max_relc 等注释契约;"已完成"标签;synspec 过滤器 |
| 7b 改名 | ✅ 已实施 | ChainStep/PhaseConfig/ResumePolicy/StepSummary、result_valid、trigger_tlusty_fallback、旧环境变量清理 |
| 7c converged→completed | ✅ 已实施 | **M9** 状态值迁移 + **M10** 快照列改名 + `GridPointStatus::Completed` + 前端/stats 键 |
| itek 全量保真 | ✅ 已实施 | `StepSummary.itek_history` 逐次迭代诊断随 summary_json 落库 |
| 文档同步 | ✅ 已实施 | `database.md` + `api.md` 同步新 schema/API |
> 每 Phase 合入前均通过全仓 `cargo test` + `cargo clippy -- -D warnings`
### 审查发现与修复(2026-08-05subagent 对抗性审查)
| 严重度 | 发现 | 修复 |
|---|---|---|
| 【严重】CRITICAL | 7c 的测试断言 perl `"converged"(?!:)` 误把结算阶段值 `then_some("converged")` 改成 `"completed"`,而 claim/running 守卫仍查 `'converged'`**半失败重试时 tlusty_status 被覆盖,守卫完全失效**(147 测试未捕获,因测试断言了同样的错误值) | 结算阶段值回退 `'converged'`;修正测试断言;补**生命周期测试**(结算→claim→running→重试结算全程验证守卫保留 tlusty_status |
| 【高】HIGH | claim/running 守卫同时保留了 synspec 'failed',与设计 §7.3 打开项 #2「仅 synspec 侧流转」不符(重试期间 synspec_status 停在旧 failed | claim/running 改:tlusty 保留终态守卫 + **synspec 自由流转**为 queued/running |
| 【中】MEDIUM | `mark_grid_point_imported` 不设阶段列,导入点与正常点阶段口径不一致 | 导入置 `tlusty_status='converged'`synspec 保持 NULL |
| 【中】MEDIUM | 半失败分支隐含依赖 tlusty_enabled=true(脆弱的隐式不变量) | 加 `tlusty_enabled.then_some("converged")` 防御性守卫 |
| 【轻微】LOW | stats 内部 SQL 别名 `AS converged` 仍用旧命名 | 改名 `AS completed`(内部别名,不影响 JSON |
| 【轻微】LOW | M9 detect 数据检测在"空表"边界返回已迁移(跳过) | 接受为已知局限(FROM 保留 'converged' legacy 别名兜底) |
---
## 0. 背景与问题清单
对现有 schema`crates/server/src/db.rs` `init_tables` + `crates/mq/src/sqlite_queue.rs`)与
数据流(调度 → 队列 → 节点执行 → 上报结算)的审计,确认以下结构性问题:
| 编号 | 问题 | 严重度 | 现状 |
|---|---|---|---|
| P1 | `tasks` 表阶段结果不完整:`failed_stage``summary_json`synspec_rc/error/sec 全量)上报后即丢 | 高 | 结算 UPDATE 只落 7 个标量(db.rs:1943),阶段细节仅存磁盘 conv.json |
| P2 | `tasks` 表无 `workflow_name` 单列索引,`COUNT(*) WHERE workflow_name=?` 全表扫 | 中 | 仅有 `idx_tasks_point_wf_time(point_name, workflow_name, completed_at)` |
| P3 | 逐点列表 N+1 相关子查询(每 grid_point 行执行一次取最新任务) | 中 | db.rs:2780 / 2818 `LEFT JOIN ... (SELECT ... LIMIT 1)` |
| P4 | `registration_secret`(鉴权凭据)放在 `nodes`(运行时态表),与 `token_hash` 分属两表 | 中(已决策保留) | 审批前状态,`node_credentials.token_hash NOT NULL` 约束决定其只能存 nodes;P4 仅清 `revoked` 死列 |
| P5 | `node_credentials.revoked` 死列,新代码不读写 | 低 | db.rs:513 注释确认 |
| P6 | `grid_points.status` 单值掩盖两阶段管线:半失败点(大气收敛+光谱失败)点级只有 `failed`"大气就绪"不可查询 | 高(立项) | 阶段事实仅存于 tasks 历史 |
| P7 | 迁移机制脆弱:全部列变更在 `init_tables` 内手写 `PRAGMA table_info` + `ALTER`,无版本追踪 | 中 | 已积累 9+ 块幂等 ALTER |
| P8 | `task_type` 字段冗余:与 `tlusty_strategies[0]` 恒等,靠派发时同步维护不变量;synspec-only 场景为"假值" | 低 | 已废弃兼容字段,全量删除(Phase 6) |
| P9 | TLUSTY-first 命名残留:一批标识符保留单阶段语义,两阶段管线实现时存在语义误判风险(subagent 全库审计) | 中(立项) | `converged`/`stage`/`seed_*`/旧环境变量见 Phase 7**`success_method` 值域混用列已拆分(M12/M13)** |
> **已确认不做**`tasks` 表清理/保留策略(用户决策 2026-08-05,仅加索引)。
> **已确认不做**`grid_points.id` AUTOINCREMENT 移除(一切按 `(workflow_name, name)` 访问,表重建不值当)。
**基础约束**
- 双库分工不动:主库(状态/审计)与队列库(热路径抢占)保持分离。
- 新增列一律可空,旧节点上报不破坏结算。
- bundled SQLite 3.45+libsqlite3-sys 0.28)支持 `DROP COLUMN`、窗口函数、JSON1、`UPDATE...RETURNING`
---
## 1. 总体设计原则
1. **迁移先行**:所有结构变更走 Phase 0 建立的版本化迁移,不再新增 `init_tables` 手写 ALTER 块。
2. **可独立交付**:每 Phase 独立可上线、可回滚、带测试;全量 `cargo test` 通过才合入。
3. **兼容优先**`failed_stage` 缺省兜底 `"tlusty"`(与 api/task.rs:312 回退默认一致);新列全可空。
4. **观测不塞进热路径**:阶段细节落库在结算事务内完成,不为查询便利增加运行时开销。
---
## 2. Phase 0 — 版本化迁移基础设施
### 2.1 目标
消灭 `init_tables` 内继续堆积手写 ALTER 块(P7),为 P1–P6 提供统一的迁移通道。
### 2.2 设计
引入 `PRAGMA user_version` 驱动的迁移运行器。**V0 定义为 0**(`PRAGMA user_version` 对全新库的默认值),后续编号迁移从 1 开始:
```
Database::new
├─ init_tables() 现有幂等 bootstrap:建全表 + 既有列检测补全 → 置 user_version = V0(=0)
└─ apply_migrations() 从当前 user_version 顺序应用 M1..Mn → 每个迁移独立事务
```
- **新库**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 崩启动)。
- 迁移定义集中到新文件 `crates/server/src/migrations.rs`
```rust
pub struct Migration {
pub version: u32, // > V0(=0) 的顺序号(1..N
pub name: &'static str, // 便于日志与审计
pub detect: fn(&Connection) -> Result<bool>, // 该迁移是否已应用(列/索引存在性)
pub up: &[&str], // 未应用时才执行,同事务内顺序执行
}
pub const MIGRATIONS: &[Migration] = &[ /* M1, M2, M4, M6, M7(=5a) */ ];
> **迁移编号与部署顺序约束(实施修正 2026-08-05)**:迁移版本号必须与 §11 部署顺序单调一致——
> 5a 部署在 Phase 6 之后,故其版本号取 **M7**(而非早期草案的 M5)。否则已升到 v6 的库会因
> `version <= current` 跳过 5a`synspec_success_method` 列永不创建。
pub fn current_version(conn: &Connection) -> u32; // PRAGMA user_version
pub fn apply_migrations(conn: &mut Connection) -> Result<()>;
```
- 运行器逻辑:读 `current_version` → 对每个 `version > current` 的迁移,先跑 `detect`
- `detect = true`(已应用,如新库 bootstrap 已建列)→ 仅推进 `user_version`,不执行 `up`
- `detect = false``BEGIN IMMEDIATE` → 执行 `up` SQL → `PRAGMA user_version = V``COMMIT`
- 中途失败不推进版本(进程启动时重试)。
- 与现有 `sqlite_queue.rs` 的迁移惯用法(PRAGMA table_info 检测)并存:队列库无用户版本迁移,维持现状;**仅主库**引入版本号。
### 2.3 代码改动
| 文件 | 改动 |
|---|---|
| `crates/server/src/migrations.rs` | 新增(迁移表 + 运行器) |
| `crates/server/src/db.rs` | `init_tables` 末尾置 `user_version = V0``Database::new` 在 bootstrap 后调用 `apply_migrations` |
### 2.4 测试
1. 新库全流程:`Database::new` → 版本 = 最新,全部迁移已应用。
2. 旧库升级:手工构造"缺若干列/索引"的旧 schema 库 → bootstrap + 迁移 → 断言列/索引/数据完整。
3. 迁移中断恢复:模拟 M2 中途失败 → user_version 停在 M1 → 重跑后 M2 成功。
---
## 3. Phase 1 — tasks 表阶段信息补全(P1)
### 3.1 目标
让"哪个阶段失败"`failed_stage`)与"完整阶段结果"`summary_json`)在 DB 可查,替代"只能翻磁盘 conv.json"。
### 3.2 数据流核实(关键前提)
节点上报 `TaskReport`models.rs:496-513)已携带全部所需字段:
- `converged` = **TLUSTY 大气收敛标志**reporter.rs:32 注释确认,非整管线);
- `status` = 整管线成败(`derive_report_status`:半失败 = Failedreporter.rs:38-45);
- `failed_stage` = `"tlusty"`/`"synspec"` 精确归因(`infer_failed_stage`reporter.rs:15-27);
- `summary_json` = Rust 侧 `ModelSummary` 序列化(含 `synspec_rc`/`synspec_error`/`synspec_sec` 与各子步骤摘要)。
服务端 `record_task_report`db.rs:1920)已持有 `&TaskReport`——**无需改签名**,只在结算 UPDATE 补两列。
> **保真边界(审查修正)**`ModelSummary`models.rs:599-616)与 `StageSummary`577-595**不含 conv.json 的逐次 itek 迭代数组**——该细节在 Rust serde 往返中结构性丢失,仍仅存磁盘 conv.json。落库的 summary_json 是"阶段级摘要"(含 last_iter / worst_depth / n_depths 等部分迭代诊断),非原始全量。若需完整逐次迭代入库,须给 `StageSummary``#[serde(flatten)]` 保留字段(暂缓,先文档化此边界)。
### 3.3 迁移 M1tasks 表,均在线 ADD COLUMN
```sql
ALTER TABLE tasks ADD COLUMN failed_stage TEXT; -- "tlusty" / "synspec"
ALTER TABLE tasks ADD COLUMN summary_json TEXT; -- 完整 ModelSummary 全保真
```
### 3.4 代码改动
| 位置 | 改动 |
|---|---|
| db.rs:1943 结算 UPDATE | `SET` 增加 `failed_stage = COALESCE(?10, 'tlusty')``summary_json = ?11`;绑定 `report.failed_stage` / `report.summary_json` |
| db.rs `list_point_attempts`2848 | SELECT 增加 `failed_stage``summary_json` |
| `common/src/models.rs` `AttemptRow` | 增加 `failed_stage: Option<String>``summary_json: Option<String>``#[serde(default)]` 保 API 兼容) |
| dashboard 逐尝试列表 | 阶段徽标(TLUSTY / SYNSPEC+ synspec 错误摘要(解析 summary_json |
> **错误路径**reporter.rs:85 失败上报的 `summary_json = {"error": ...}` 非 ModelSummary——前端解析必须容错(解析失败即显示错误文本),服务端仅透传不解析。
### 3.5 测试
- 全状态往返:completed / failed / timeout / 半失败(大气成+光谱败)/ synspec-only / 旧节点(failed_stage=None → 兜底 "tlusty")。
- `record_task_report` 后 tasks 行 `failed_stage``summary_json` 与上报一致。
- `list_point_attempts` 返回新字段。
- 错误路径:summary_json 为 `{"error":...}` 时前端容错展示。
---
## 4. Phase 2 — tasks 表索引(P2,仅索引,不做清理)
### 4.1 目标
消除 `workflow_name` 单列查询的全表扫。
### 4.2 迁移 M2
```sql
CREATE INDEX IF NOT EXISTS idx_tasks_wf_status_created
ON tasks(workflow_name, status, created_at);
```
> **用户决策(2026-08-05**:不做 `tasks` 清理/保留策略。`tasks` 作为完整审计日志长期保留;
> 此索引覆盖 `COUNT(*) WHERE workflow_name=?``find_stale_pending_points` 的 JOIN 侧(按 point_name+workflow_name 定位)。
> 注意:`find_stale_pending_points` 的过滤条件(`status='pending' AND created_at < ...`,无 workflow_name 前置)**不受此索引覆盖**——该查询由 JOIN 侧既有 `idx_tasks_point_wf_time` 支撑,随日志增长扫描量线性增加,属已接受的权衡。
### 4.3 测试
- `EXPLAIN QUERY PLAN` 验证 `SELECT COUNT(*) FROM tasks WHERE workflow_name=?` 走索引扫描。
- 既有 `find_stale_pending_points` / 工作流统计测试不回归。
---
## 5. Phase 3 — 逐点列表窗口函数化(P3)
### 5.1 目标
消除 `get_workflow_points` / `get_workflow_point_row` 的逐行相关子查询(数千点 → 数千次子查询)。
### 5.2 设计
用单遍 `ROW_NUMBER()` 窗口取每点最新任务,替代 `LEFT JOIN tasks ON task_id = (SELECT ... LIMIT 1)`
```sql
SELECT * FROM (
SELECT gp.name, gp.teff, ..., gp.status, gp.tlusty_success_method, gp.synspec_success_method, gp.attempt_count,
t.max_relc, t.task_type, t.seed_point_name, t.node_id,
t.completed_at, t.error_message, t.failed_stage, t.summary_json,
COALESCE(t.elapsed_sec, gp.last_elapsed_sec) AS eff_elapsed,
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 BY ... {limit} {offset}
```
外层再叠加筛选/排序/分页,保持与 `get_workflow_points` 现签名(`(total, points)` + 过滤 + 分页)一致。
`get_workflow_point_row`(单点)同样改写,外层 `WHERE workflow_name=? AND name=? AND rn=1`
### 5.3 附带优化(可选)
`claim_pending_grid_points`db.rs:1246SQL 与 Rust 侧双重排序(db.rs:1282 `list.sort_by`):
`UPDATE...RETURNING` 不保序,用 CTE 让子查询排序贯穿,删掉 Rust 侧 `sort_by`
### 5.4 测试
- 用既有测试网格样本断言新旧查询**结果集逐行等价**(含:无任务点 → LEFT JOIN 为 NULL、多点多尝试 → 取最新、`completed_at NULL` 排序规则)。
- **边界回归(审查修正)**:构造"点只有未完成任务(completed_at 全 NULL"的样本,断言 ROW_NUMBER 与相关子查询取到**同一行**——窗口函数与子查询的 NULL 排序语义须显式验证等价。
- 分页 `total` 计数不变。
---
## 6. Phase 4 — 死列清理(P5registration_secret 保留在 nodes
### 6.1 目标
清除死列 `node_credentials.revoked`P5)。**`registration_secret` 不迁移**——审查确认移动它存在两个结构性障碍(见 §6.2),且保留在 nodes 语义自洽。
> **决策**:保持两表分离不合并;`registration_secret` 保留在 `nodes`,加注释消除"凭据半吊子"的困惑。
### 6.2 为什么 registration_secret 不迁移(审查 CRITICAL#1/#2
1. **数据丢失**`register_node`db.rs:589)只 INSERT `nodes`,不建 node_credentials 行;`approve_node`db.rs:618)才建。pending 节点无 node_credentials 行,`UPDATE` 迁移会静默跳过它们,`DROP COLUMN` 后凭据永久丢失且无恢复路径。
2. **schema 约束**`token_hash TEXT NOT NULL`db.rs:518+ `idx_node_credentials_token_hash` 唯一索引——为 pending 节点 INSERT 空占位会撞唯一索引;改 nullable 需整表重建。
3. **语义**registration_secret 是**审批前**一次性凭据(注册时产生、取 token 前消费),node_credentials 是**审批后** token 状态——放 nodes 与 `status='pending_approval'` 同生命周期,自洽。
### 6.3 迁移 M4(清死列)
```sql
ALTER TABLE node_credentials DROP COLUMN revoked; -- bundled SQLite 3.45+,涉及表重建,低峰窗口
```
### 6.4 代码改动
- 仅注释:`nodes.registration_secret` 列加注释"审批前一次性凭据,唯一例外;token 哈希见 node_credentials"。
- `register_node` / `approve_node` / `check_status` / `reject_node` 全链路**零改动**。
### 6.5 测试
- `revoked` 列消失,`CREATE TABLE IF NOT EXISTS` 对新库不再创建该列。
- 注册 → 审批 → 取 token 全链路回归(registration_secret 行为不变)。
---
## 7. Phase 5 — grid_points 阶段化(立项)
### 7.1 目标
解除 P6:点级 `status` 单值无法表达两阶段管线。半失败点(大气收敛 + 光谱失败)需在点级可识别"大气就绪、光谱待重试";synspec 收敛归因需点级可查。
### 7.2 阶段语义(基于已核实的上报数据)
`record_task_report` 结算时可从报告精确推导各阶段状态:
| 上报条件 | 整体 status | tlusty_status | synspec_status |
|---|---|---|---|
| `tlusty_enabled=0` | — | NULL(不适用) | 由 `synspec_rc`/`synspec_error` 定:成功→converged,失败→failed |
| `converged=true` + synspec 成功 | converged | converged | converged |
| `converged=true` + synspec 失败(半失败) | failed | **converged** | failed |
| `converged=false`tlusty 启用) | failed | failed | pending(未运行) |
- `converged` = 大气收敛标志(reporter.rs:32);synspec 成败取自 `summary_json.synspec_rc` / `synspec_error`
- 整体 `status` 仍是调度/策略/筛选的**权威状态**,阶段列是补充可查信息,不替代它。
### 7.3 分步交付
#### 5a(先做,轻量):`synspec_success_method` 归因列
```sql
ALTER TABLE grid_points ADD COLUMN synspec_success_method TEXT;
```
结算时(settlement 事务内)设置,镜像既有大气归因的 synspec 分支逻辑(db.rs:1973-1984)。
**已实施(P9 拆分,M12/M13**:原 `success_method` 值域混用列拆为 `tlusty_success_method` + `synspec_success_method`
两个阶段列并删除原列,整体归因改由消费方派生(`tlusty ?? synspec`)。
```sql
CASE WHEN synspec 成功
THEN json_extract(tasks.synspec_strategies, '$[0]')
END
```
收益:解锁"光谱以 standard 等策略收敛了多少点"的 SQL 统计。无瞬态状态同步负担。
**统计 SQL 改动归本阶段独占**db.rs:2325-2574 加 synspec 策略桶),P7a 只做前端过滤器引用,避免两阶段重复改同一处(审查 HIGH#2)。
#### 5b(再议,完整):阶段状态列
```sql
ALTER TABLE grid_points ADD COLUMN tlusty_status TEXT; -- pending/queued/running/converged/failed
ALTER TABLE grid_points ADD COLUMN synspec_status TEXT; -- pending/running/converged/failed/not_required
```
**同步触点**(与 `grid_points.status` 的既有更新点对齐,均为机械追加列):
- `claim_pending_grid_points`db.rs:1246)→ 相关阶段列置 `queued`
- `mark_grid_point_running`db.rs:1508)→ 相关阶段列置 `running`
- `record_task_report` 结算(db.rs:1994-2004)→ 按 7.2 语义表置阶段终态;
- 回退重置(scheduler.rs `trigger_strategy_fallback`)→ 半失败重试时保持 `tlusty_status=converged`,仅重置 `synspec_status`
**打开项(5b 实施前需定)**
1. `not_required`(阶段关闭)与 `NULL`(未配置)的取值约定;
2. 半失败重试期间 `tlusty_status` 是否可被后续 claim 覆盖(建议:不可,仅 synspec 侧流转);
3. `initialize_grid` 各策略(skip_converged / skip_failed / force_recompute)如何对待阶段状态(建议:以整体 `status` 为准,阶段列只读展示)。
> 建议 5b 在 5a 上线、`summary_json` 数据积累后评估——届时"阶段状态是否需要独立列,还是 join tasks 已够"有真实数据支撑。
---
## 8. Phase 6 — 删除 task_type(全量删除,P8
### 8.1 目标与前提
消除冗余字段、synspec-only 假值坑、以及"派发时同步维护 `task_type == strategies[0]` 不变量"的负担。
**前提(用户决策 2026-08-05**:集群**无历史节点**server/node 随同一镜像统一重建,不存在旧节点反序列化兼容问题。
### 8.2 执行链改由 strategies 推导(runner + executor
现状:executor 传 `task.task_type``custom_chain` 恒为 `None`executor.rs:172-175),runner 用 task_type 选执行链(runner.rs:299-301)。
改造:executor 按 `tlusty_config.strategies[0]` 显式推导执行链并作为 `custom_chain` 传入:
```rust
// executor.rs
let chain = match task.tlusty_config.current_strategy("cold_run") {
"seed_step" => common::runner::default_seed_chain(),
_ => common::runner::default_cold_chain(),
};
runner.run_model_with_timeout(..., Some(chain), ...);
```
runner`run_model_with_timeout``task_type: TaskType` 参数改为 `current_strategy: &str`
`unwrap_or_else` 兜底改按策略串匹配;`TaskType` 枚举整体删除。
`default_cold_chain()` / `default_seed_chain()`runner.rs:16, 66)已 `pub`,无需改动。
### 8.3 models.rs
- `TaskSpec` 移除 `task_type` 字段(models.rs:398);
- 删除 `normalize_compat` 的 task_type 回填分支(models.rs:435-444);
- 删除 `TaskType` 枚举定义。
### 8.4 调度器(scheduler.rs
- 删除 `strategy_to_task_type`239-244);
- 全部 `TaskSpec { task_type: ... }` 构造(465, 776, 836)移除该字段。
### 8.5 DB 迁移 M6 + 配套查询改造
```sql
ALTER TABLE tasks DROP COLUMN task_type; -- bundled SQLite 3.45+,涉及表重建,低峰窗口
```
| 位置 | 改造 |
|---|---|
| db.rs:1979 `success_method` 归因 | `ELSE task_type``ELSE json_extract(tlusty_strategies, '$[0]')`(不变量成立时等价,从此归因全派生)。**后经 P9 拆分(M12/M13)改 `tlusty_success_method` + `synspec_success_method` 两阶段列并删原列** |
| db.rs:1777-1780 `insert_task` | 删除 `task_type_str` 解析与绑定 |
| db.rs:2848 `list_point_attempts` | SELECT 去 `task_type` |
| db.rs:1859-1866 `has_pending_tasks_for_point` | `Some(tt)` 分支 `AND task_type = ?3``AND json_extract(tlusty_strategies, '$[0]') = ?3`(生产仅传 `None`,此分支测试用) |
| models.rs `AttemptRow` / API 响应 | 去 `task_type` 字段(`#[serde(default)]` 兼容已发前端) |
### 8.6 前端
- `pointsTable.js:222``last_task_type`
- `pointPanel.js:116``task_type` 徽标;
- `overview.js:211` 注释清理。
### 8.7 测试
- 全量修正 `TaskType::ColdRun` / `TaskType::SeedStep` 引用(实际计数:scheduler.rs 18 处、db.rs 14 处 + AttemptRow 字段、api_tests.rs 20+ 处 JSON 断言、sqlite_queue.rs 7 处、runner.rs 5 处、executor.rs 2 处——编译器兜住全部,属纯工作量);
- 新增:executor 按 `strategies[0]="seed_step"` 走种子链的用例;
- `has_pending_tasks_for_point` 过滤改 `strategies[0]` 后的等价断言。
### 8.8 升级注意
队列库(`dcts_queue.db`)可能残留升级前序列化含 `task_type` 的在途 payload。新代码 serde 默认忽略未知字段可正常反序列化,但**旧 `seed_step` 消息的 `strategies` 可能被 serde default 填成完整链 `[cold_run, seed_step]` 导致误判 cold_run**——升级前确认队列为空(或停机时清空队列)。
### 8.9 收益与风险
**收益**:消除冗余列与假值坑;不再维护同步不变量;归因/过滤全部改派生口径。
**风险**runner 执行链行为需回归(seed_step 消息必须走种子链);测试改动面大但机械。
---
## 9. Phase 7 — 命名语义修正(TLUSTY-first 残留,P9
### 9.1 背景
系统最早仅 TLUSTY(SYNSPEC 默认不可配置),演进为两阶段管线后,一批标识符保留"仅 TLUSTY"语义,
实现完整计算过程时存在语义误判风险。本 Phase 由 subagent 全库审计产出(2026-08-05),
分注释契约、前端修正、改名、清理四档。
### 9.2 最危险的 5 个语义混淆
| # | 名称 | 位置 | 问题 | 处置 |
|---|---|---|---|---|
| 1 | `converged` | models.rs:603 / reporter.rs:32 / runner.rs:605-616 | TLUSTY 启用时=大气收敛;synspec-only 被重写为管线成功;读方无法凭字段名判断语义 | 7a 注释契约 + 7b 改名 |
| 2 | `StageConfig` vs `EngineStageConfig` | runner.rs / models.rs:330 | "stage"同词两层义:TLUSTY 计算链子步骤(lte/nc/nl) vs TLUSTY/SYNSPEC 管线大阶段;易把 SYNSPEC 误加进 `stages` | 7b 改名 |
| 3 | `success_method` 值域 | db.rs:1973-1984 | synspec-only 塞入策略名(standard);统计分桶(db.rs:2325-2574)与前端过滤器仅认 cold_run/seed_stepsynspec 收敛点统计丢失 | **已解决(M12/M13 拆分 + 5a 加桶)**:拆 `tlusty_success_method`/`synspec_success_method` 两阶段列,synspec-only 不再污染大气列 |
| 4 | `seed_point_name` / `seed_atmos_path` | models.rs:399 / executor.rs:70-83 | TLUSTY seed_step=热启动种子;synspec-only=光谱输入大气来源点,双义无注释 | 7a 注释 |
| 5 | `task_type` 余光 | scheduler.rs:458 | synspec-only 固定填 cold_run,前端/CSV 误标"冷启动" | Phase 6 已覆盖;7a 处理前端残留 |
### 9.3 分步实施
**7a(先做,无破坏性)——注释契约 + 前端可感知修正**
- **注释契约**(不改名):
- `converged` 双义标注(`TaskReport.converged` = "本次结果是否可用",非"大气收敛");
- `max_relc` / `final_max_relc` 标注"仅 TLUSTY 大气迭代有效"
- `cno_sum` / `wave` 标注"TLUSTY 大气金属丰度排序"
- `elapsed_sec` vs `synspec_sec` 标注包含关系(总耗时含 TLUSTY+SYNSPECsynspec_sec 是子集);
- `seed_point_name` 标注 synspec-only 双义。
- **前端可感知修正**bug 级):
- "已收敛" → "已完成"pointsTable.js:44 等所有中文标签);
- "方法"过滤器增加 synspec 策略名选项(pointsTable.js:48-51);
- CSV 列 `last_task_type``overall_method`pointsTable.js:218P9 拆分后改 `overallMethod(p) = tlusty ?? synspec` 派生值);
- `pointMethodBadge(a.task_type)` 改从 strategy 推导(pointPanel.js:116);
- "阶段链诊断" → "TLUSTY 收敛链诊断"pointPanel.js:152);
- "方法"过滤器的 synspec 选项数据源 = Phase 5a 落库的 `synspec_success_method`(统计 SQL 改动归 5a 独占,本阶段不再触碰 db.rs:2325-2574)。
**7b(Phase 6 后,纯改名,无行为变化)**
- `StageConfig``ChainStep``StageSummary``StepSummary`TLUSTY 子步骤层);
- `EngineStageConfig``PhaseConfig``StagePolicy``ResumePolicy`(管线大阶段层);
- `ModelSummary.stages` 加注释"TLUSTY iteration steps, NOT pipeline phases"
- `TaskReport.converged``result_valid`
- `trigger_seed_step_fallback` 删除(或改 `trigger_tlusty_fallback`);
- 旧环境变量回退清理:`CNO_PORT` / `CNO_SERVER_URL` / `DCTS_RESULTS_DIR` / `DCTS_ARCHIVE_DIR``GridConfig.results` 死字段。
**7c(可选,大迁移;**2026-08-05 已实施****
- `GridPointStatus::Converged``Completed`enum 改名 + DB 文本值 `'converged'``'completed'`(全链 SQL
字面量 + 数据迁移 **M9** + 快照列改名 **M10** + 前端/统计键)。`From<&str>` 保留 `'converged'`/`'done'`
为 legacy 别名(M9 前旧数据)。实施细节:M9 `UPDATE grid_points SET status='completed' WHERE status='converged'`
M10 `RENAME COLUMN workflow_progress_snapshots.converged → completed`stats 顶层键 `"converged"``"completed"`
`cold_run_converged`/`seed_step_converged`/`synspec_converged` 方法限定键保留)。
### 9.4 测试
- 7a:前端过滤/统计对 synspec 收敛点的等价断言;注释类无测试。
- 7b:改名后全量 `cargo test`(编译期即验证)+ `cargo clippy -- -D warnings`
- 7c:M9/M10 迁移专项测试(状态值迁移、快照列改名)+ 全量回归。
### 9.5 数据库列名审计结论
DB 列名绝大多数**如实反映存储内容**,无需改名;TLUSTY-first 残留集中在**列值**与**概念**层面而非列名。
逐表核实结论如下。
**需要改动的列(已由既有 Phase 覆盖)**:
| 列 | 动作 | 对应 |
|---|---|---|
| `tasks.task_type` | 删除 | Phase 6 (M6) |
| `grid_points.success_method` | **已删除**:值域混用列拆为 `tlusty_success_method` + `synspec_success_method`M12/M13);整体归因派生 `tlusty ?? synspec` | P9 拆分 |
| `tasks.max_relc` | 加注释"仅 TLUSTY 大气迭代有效";**不改名**(改名需动全链 SQL,收益低) | Phase 7a |
**列名级残留(仅一处 + 一个耦合项)**:
1. `workflow_progress_snapshots.converged` 列名:已随 7c 实施改名为 `completed`(M10)——存"管线完成点数"。
2. `grid_points.status``'converged'` 值:列名 `status` 无问题,**值**是 TLUSTY-first(暗示大气收敛,实为管线完成)。7c 已实施:值改为 `'completed'`(M9 数据迁移 + 全链 SQL 字面量 + 前端)。
**核实后无需改动的列**
- `tasks.seed_point_name`:DB 列只存 TLUSTY 热启动种子,synspec-only 派发时恒 `None`scheduler.rs:453);"双义"发生在 executor 局部变量 `seed_atmos_path`,不在 DB 列上,7a 注释即可。
- `grid_points.cno_sum` / `wave`:存的就是 TLUSTY 丰度排序量,名实相符,7a 加物理语义注释。
- `tasks.elapsed_sec` / `attempt_count` / `atmosphere_ref` / `tlusty_*` / `synspec_*` 家族:全部准确。
**判定原则**:DB 列改名成本高(迁移 + 全链 SQL + 前端 + 历史数据),仅在"列名本身误导"时才值得。本审计确认除 `snapshots.converged`(耦合项)外**无列名级误导**,其余均为列值/概念问题,走注释与派生口径解决。
---
## 10. 测试策略
沿用仓库现有惯例(db.rs / scheduler.rs 内 `#[tokio::test]` 模块 + `tests/` 集成):
1. **迁移测试**:新库全流程;旧 schema 库升级;迁移中断恢复(Phase 0)。
2. **Phase 1**`record_task_report` 全状态往返(含半失败、synspec-only、旧节点兜底)。
3. **Phase 2**:索引生效(EXPLAIN)+ 既有统计测试不回归。
4. **Phase 3**:新旧查询结果集逐行等价 + 分页计数不变。
5. **Phase 4**:新库无 `revoked` 列;注册 → 审批 → 取 token 全链路(registration_secret 行为不变)。
6. **Phase 5**:半失败点结算后 `tlusty_status=converged``synspec_status=failed`;重试期间不覆盖 `tlusty_status``synspec_success_method` 归因正确。
7. **Phase 7**7a 前端过滤/统计对 synspec 收敛点等价断言;7b 改名后编译期全量通过。
每 Phase 合入前:全量 `cargo test` + `cargo clippy -- -D warnings`
---
## 11. 部署与回滚
- **迁移安全性**P1/P2/5a 的 `ADD COLUMN` / `CREATE INDEX` 全在线,无停写窗口;
Phase 4M4)与 Phase 6M6)的 `DROP COLUMN` 涉及表重建,**部署前做手动备份**(已有每日 `backup_database` + 7 天保留兜底),在低峰窗口执行。
- **回滚**SQLite `user_version` 迁移无 down 迁移;回滚 = 从备份恢复 + 重放后续版本。`ADD COLUMN` 类变更对旧二进制无害(新列新代码写、旧代码不读)。
- **部署顺序**Phase 0 → 1 → 2 → 3 → 4 → 6 →(5a)→(5b)→(7a)→(7b),每阶段独立发版。
- Phase 6 前确认队列为空(见 §8.8);
- Phase 7a 无 schema 依赖,可与 Phase 1–4 并行实施;其统计/过滤修正与 5a 合并;
- Phase 7b 改名在 Phase 6 之后(避免与 task_type 删除的改动冲突)。
---
## 12. 决策记录
| 决策 | 结论 | 依据 |
|---|---|---|
| tasks 清理/保留策略 | **不做**(仅加索引) | 用户决策 2026-08-05:tasks 作为完整审计日志长期保留 |
| nodes / node_credentials | **不合并**registration_secret 保留在 nodes(审批前状态),仅清 `revoked` 死列 | 写频差异显著;token_hash NOT NULL + 唯一索引决定 secret 只能存 nodes(审查 CRITICAL#1/#2 |
| `node_credentials.revoked` | 删除 | 死列,新代码不读写 |
| `grid_points.id` AUTOINCREMENT | 保留 | 一切按 `(workflow_name, name)` 访问,表重建不值当 |
| Phase 5 | **立项**,先 5a 后 5b | 用户决策 2026-08-055b 待 summary_json 数据积累后评估 |
| task_type 字段 | **全量删除**Phase 6 | 用户决策 2026-08-05;集群无历史节点,随镜像统一重建,无旧节点兼容负担 |
| Phase 7 命名语义修正 | **立项**,先 7a(注释+前端)后 7b(改名),7c 已实施(2026-08-05 | 用户决策 2026-08-05subagent 全库审计确认 TLUSTY-first 命名残留 |
+20 -19
View File
@@ -24,7 +24,7 @@
> - **未实现**:§2.2/§2.4 的「重试失败点」按钮与 `POST .../points/retry` 端点
> (前后端均未实现)。
> - **方法归因**`imported` 不再作为独立收敛途径(导入点按 seed_step 统计),
> `success_method` 过滤白名单仅 `cold_run` / `seed_step`
> 大气方法过滤白名单 `cold_run` / `seed_step`(映射 `tlusty_success_method`);另有 `synspec_only` 光谱专用点过滤器(`tlusty IS NULL AND synspec IS NOT NULL`
---
@@ -35,12 +35,12 @@
| 数据 | 位置 | 说明 |
|---|---|---|
| 网格点 6 维参数、`cno_sum``wave` | `grid_points` 表(`db.rs:246-263` | 按 `workflow_name` 分区,复合唯一 `(workflow_name, name)` |
| 点状态 `pending/queued/running/converged/failed` | `grid_points.status` | `GridPointStatus``models.rs:235-243` |
| **成功手段** `cold_run` / `seed_step` / `imported` | `grid_points.success_method` | 收敛时由成功任务的 `task_type` 回填(`db.rs:1097-1101`);失败点为 NULL |
| 点状态 `pending/queued/running/completed/failed``completed` 由 7c 改名自 `converged` | `grid_points.status` | `GridPointStatus``models.rs:235-243` |
| **大气成功手段** `cold_run` / `seed_step` / `imported` | `grid_points.tlusty_success_method` | TLUSTY 阶段收敛时由成功任务的 `tlusty_strategies[0]` 派生回填(Phase 6 起 `task_type` 列已删除);TLUSTY 禁用(synspec-only)为 NULL。光谱阶段另存 `synspec_success_method`Phase 5a,取 `synspec_strategies[0]`)。整体归因由消费方派生 `tlusty ?? synspec`P9 拆分,原 `success_method` 值域混用列已删) |
| 重试次数 | `grid_points.attempt_count` | 每次回报 +1`db.rs:1089-1095` |
| 每次尝试的收敛指标 | `tasks.max_relc` | 收敛判据 `max_relc < chmax`(默认 0.001`conv_check.rs:105-109` |
| **每次尝试的种子来源** | `tasks.seed_point_name` | 派发时写入(`scheduler.rs:194-205` |
| 每次尝试的方法 / 错误 / 节点 / 完成时间 | `tasks.task_type / error_message / node_id / completed_at` | 一个点多行(每次尝试一行),工作流删除前长期保留 |
| 每次尝试的方法 / 错误 / 节点 / 完成时间 | `tasks.tlusty_strategies[0] / error_message / node_id / completed_at` | 一个点多行(每次尝试一行),工作流删除前长期保留;方法取自策略链首项(`task_type` 列已删) |
| 全局种子库 | `seeds` 表 + `data/seeds/<name>/<name>.7` | 仅收敛且无 NaN 的点入库(`task.rs:187-207` |
| **逐阶段完整诊断**lte→nc→nl / seed_nc→nl | `data/seeds/<name>/conv.json` | `ModelSummary``models.rs:376-391`):每阶段 `converged / best_max_relc / chmax / elapsed_sec`、总耗时、`synspec_sec`、所用种子。**成功与失败的点都会写**(`task.rs:178-184` |
| 按工作流的统计聚合 | `db.get_grid_summary_stats(Some(wf))` | **已实现且有单测**`db.rs:1582-1639`),但没有任何 HTTP 端点调用它 |
@@ -67,7 +67,7 @@
所以"哪些参数冷启动能成功" ≈ 低 cno_sum / 中低温区的点;高温 He-poor 区几乎全靠种子步进。
- **还有一次性失败回退**:冷启动发散且存在邻居种子时,自动以 `seed_step` 重投一次
`scheduler.rs:270-350`,受 `seed_step_fallback` 配置与 `has_seed_step_attempt` 一次性闸门约束)。
因此 `success_method='seed_step'` 的点可能是"冷启动失败后被救回"的——该归因需从 `tasks` 多行历史还原。
因此 `tlusty_success_method='seed_step'` 的点可能是"冷启动失败后被救回"的——该归因需从 `tasks` 多行历史还原。
- **第三类 `imported`**:历史 `run_grid.py` 结果导入(`db.rs:966-981`),UI 需单独呈现为"历史导入"。
- **收敛判据**:末次迭代最差深度点的最大相对修正 `max_relc < chmax`(默认 1e-3),
且大气 NaN 占比 ≤ 10%`conv_check.rs:127-149`)。
@@ -215,9 +215,9 @@
"data": {
"name": "sdB_cno", "status": "running",
"total": 432,
"pending": 120, "queued": 40, "running": 8, "converged": 261, "failed": 3,
"cold_run_converged": 220, "seed_step_converged": 41, "imported_converged": 0,
"waves": [ {"wave": 0, "total": 108, "converged": 108, "failed": 0}, ... ],
"pending": 120, "queued": 40, "running": 8, "completed": 261, "failed": 3,
"cold_run_converged": 220, "seed_step_converged": 41,
"waves": [ {"wave": 0, "total": 108, "completed": 108, "failed": 0}, ... ],
"avg_point_sec": 740.0, "eta_sec": 9600
}
}
@@ -242,11 +242,11 @@
"name": "t60000_g5.0_he-2_c-4_n-4_o-4",
"teff": 60000, "logg": 5.0, "loghe": -2, "logc": -4, "logn": -4, "logo": -4,
"cno_sum": -12, "wave": 0,
"status": "converged", "success_method": "seed_step", "attempt_count": 2,
"last_max_relc": 0.00043, "last_task_type": "seed_step",
"status": "completed", "tlusty_success_method": "seed_step", "synspec_success_method": "standard", "attempt_count": 2,
"last_max_relc": 0.00043,
"seed_point_name": "t60000_g5.0_he2_c-4_n-4_o-4",
"node_id": "node-a1b2", "last_completed_at": "2026-07-30T11:12:00Z",
"elapsed_sec": 126.4
"last_elapsed_sec": 126.4
}
]
}
@@ -256,7 +256,7 @@
- "最近一次尝试"用关联子查询取 `tasks` 最新行:
```sql
SELECT gp.*, t.max_relc AS last_max_relc, t.task_type AS last_task_type,
SELECT gp.*, t.max_relc AS last_max_relc,
t.seed_point_name, t.node_id, t.completed_at AS last_completed_at,
t.error_message AS last_error
FROM grid_points gp
@@ -283,12 +283,13 @@ LIMIT ? LIMIT OFFSET ?;
"data": {
"point": { /* 同 ② 的单点字段 */ },
"attempts": [
{"task_id": "...", "task_type": "cold_run", "seed_point_name": null,
{"task_id": "...", "seed_point_name": null, // Phase 6 起无 task_type 字段
"status": "failed", "max_relc": 954000.0, "atmosphere_has_nan": true,
"node_id": "node-a1b2", "error_message": "nl stage diverged...",
"created_at": "...", "completed_at": "..."},
{"task_id": "...", "task_type": "seed_step", "seed_point_name": "t60000_...",
"status": "completed", "max_relc": 0.00043, ...}
"failed_stage": "tlusty", "summary_json": "{...}",
"created_at": "...", "completed_at": "...", "elapsed_sec": 1800.0},
{"task_id": "...", "seed_point_name": "t60000_...", // 第二次尝试(seed_step 热启动救回)
"status": "completed", "max_relc": 0.00043, "failed_stage": null, ...}
],
"conv": { /* data/seeds/<name>/conv.json 解析后的 ModelSummary;文件不在则 null */ }
}
@@ -304,7 +305,7 @@ LIMIT ? LIMIT OFFSET ?;
请求体:`{"names": ["t80000_..."], "all_failed": true}`(二选一)。
- 前置:工作流必须 `running`(否则 400,提示"请先启动工作流"——避免重置后无调度器消费的僵尸态)。
- 逻辑:新 db 函数 `reset_failed_points(wf, names|all)`
`UPDATE grid_points SET status='pending', success_method=NULL WHERE workflow_name=? AND status='failed' [AND name IN (...)]`
`UPDATE grid_points SET status='pending', tlusty_success_method=NULL, synspec_success_method=NULL WHERE workflow_name=? AND status='failed' [AND name IN (...)]`
随后立即触发一次 `schedule_pending_tasks()`(不必等 30s tick)。
- 重试后的方法仍由调度器按种子可用性自动决定(大概率 seed_step,因为此时种子池已更丰富)——
这与领域语义一致,前端文案如实说明:"重试的点将由调度器自动选择冷启动或种子步进"。
@@ -316,7 +317,7 @@ LIMIT ? LIMIT OFFSET ?;
```json
{"name": "...", "status": "...", "description": "...",
"created_at": "...", "updated_at": "...",
"stats": {"total": 432, "converged": 261, "failed": 3, "running": 8,
"stats": {"total": 432, "completed": 261, "failed": 3, "running": 8,
"cold_run_converged": 220, "seed_step_converged": 41}}
```
@@ -332,7 +333,7 @@ LIMIT ? LIMIT OFFSET ?;
2. `StageSummary` 携带 `last_iter / worst_depth / n_depths``models.rs:364-373` 扩字段,
`runner.rs:302-317``ConvCheckResult` 已有值,只是没搬过去)→ conv.json 与点详情获得迭代数。
3. 新表 `workflow_progress_snapshots(workflow_name TEXT, ts DATETIME, pending INT, queued INT,
running INT, converged INT, failed INT)`:由 `main.rs:111-197` 的 30s 后台循环顺手写一行;
running INT, completed INT, failed INT)`:由 `main.rs:111-197` 的 30s 后台循环顺手写一行;
保留策略:仅当计数相对上次快照有变化才写;定期清理 24h 前的行。供"进度-时间"曲线与精确 ETA。
### 3.3 明确不做 / 保持现状
+37 -2
View File
@@ -12,7 +12,7 @@
# ./scripts/deploy.sh -e remote -b compose -r all --skip-assets # 跳过静态资源同步,仅更新程序代码
# ==============================================================================
set -eo pipefail
set -euo pipefail
GREEN='\033[0;32m'
BLUE='\033[0;34m'
@@ -45,6 +45,37 @@ PROFILE_FILE=""
PROFILE_LOADED=""
PASSED_CLI_ARGS=("$@")
# 在 set -u 下,以下"可选/按需出现"的变量若从未赋值,引用即报错。这里统一预置
# 空初值,保留"笔误即报错"的语义(真正的拼写错误仍会在 set -u 下被捕获),
# 仅对确实合法的可选变量放开。DEPLOY_* 来自可能未定义的 Profile/env,最易踩坑。
ACTION=""
PROFILE_ARG=""
CLI_ENV_SET=""
CLI_BACKEND_SET=""
CLI_ROLE_SET=""
CLI_ASSETS_SET=""
SYNC_ASSETS=""
# Profile 中可能定义的运行期变量(DCTS_*/LOG_*),write_remote_runtime_env 会读取。
DEPLOY_ENV="${DEPLOY_ENV:-}"
DEPLOY_BACKEND="${DEPLOY_BACKEND:-}"
DEPLOY_ROLE="${DEPLOY_ROLE:-}"
DCTS_NODE_ID="${DCTS_NODE_ID:-}"
DCTS_SERVER_URL="${DCTS_SERVER_URL:-}"
DCTS_MAX_SLOTS="${DCTS_MAX_SLOTS:-}"
DCTS_PORT="${DCTS_PORT:-}"
DCTS_ADMIN_TOKEN="${DCTS_ADMIN_TOKEN:-}"
DCTS_AUTH_DISABLE="${DCTS_AUTH_DISABLE:-}"
LOG_DIR="${LOG_DIR:-}"
DCTS_LOG="${DCTS_LOG:-}"
DCTS_HEARTBEAT_SEC="${DCTS_HEARTBEAT_SEC:-}"
# 远程 systemd 部署时父进程通过 env 传入的预构建镜像归档(可能为空)。
PREBUILT_TAR="${PREBUILT_TAR:-}"
# SSH 复用相关:setup_ssh_control() 中赋值,但 check_remote_is_windows 等函数会先读取,
# 预置空初值避免 set -u 下"使用前未定义"。
SSH_OPTS=""
SSH_CONTROL_PATH=""
SSH_REUSED_CONNECTION=""
load_profile_file() {
local pfile="$1"
if [ -f "$pfile" ]; then
@@ -856,7 +887,11 @@ elif [ "${ENV_MODE}" = "remote" ] && [ "${BACKEND_MODE}" = "compose" ]; then
win_load_cmd="${win_load_cmd}Write-Output done"
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "powershell -Command \"Set-Location '${REMOTE_DIR}'; New-Item -ItemType Directory -Force -Path 'data/logs','data/seeds','data/result','data/node_work','assets','workflows' -ErrorAction SilentlyContinue; ${win_load_cmd}; docker compose up -d --force-recreate ${SERVICES}; Start-Sleep -Seconds 3; docker compose ps\""
else
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "cd '${REMOTE_DIR}' && mkdir -p ./data ./data/logs ./data/seeds ./data/result ./data/node_work ./assets ./workflows 2>/dev/null || true && (docker run --rm -v \"\$(pwd)\":/work alpine chown -R 65532:65532 /work/data 2>/dev/null || true) && ${remote_load_cmd} && docker compose up -d --force-recreate ${SERVICES} && sleep 3 && docker compose ps"
# 远端属主修正:容器内 UID 65532(dcts) 需对 ./data 有写权限才能归档产物。
# 优先 docker run alpine chown(不依赖远端 root);失败时若远端本身是 root 登录,
# 退化用系统 chown。任一成功即可,全失败则明显告警——旧版 `|| true` 会静默吞错,
# 导致节点上线后归档一律 Permission denied、默默丢失科学产物。
ssh -p "${REMOTE_PORT}" ${SSH_OPTS} "${REMOTE_USER}@${REMOTE_IP}" "cd '${REMOTE_DIR}' && mkdir -p ./data ./data/logs ./data/seeds ./data/result ./data/node_work ./assets ./workflows 2>/dev/null || true && ( { docker run --rm -v \"\$(pwd)\":/work alpine chown -R 65532:65532 /work/data && echo CHOWN_OK; } || { [ \"\$(id -u)\" = 0 ] && chown -R 65532:65532 ./data && echo CHOWN_OK_SYS; } || { echo '!! chown 失败:远端既无 docker 也非 root,data 属主未修正,节点归档将 Permission denied' >&2; exit 1; } ) && ${remote_load_cmd} && docker compose up -d --force-recreate ${SERVICES} && sleep 3 && docker compose ps"
fi
if [ -z "${PREBUILT_TAR}" ]; then
+123 -27
View File
@@ -14,14 +14,17 @@
# ./scripts/fetch_results.sh -d /backup/dcts-salvage # 覆盖备份根目录
# ./scripts/fetch_results.sh --with-work # 连带拉取 data/work 沙盒残留
# ./scripts/fetch_results.sh --include-local # 把本机节点也复制进备份树
# ./scripts/fetch_results.sh --force # 忽略完成标记,强制重新同步
#
# 传输策略:双方均可用 rsync → 增量同步(断点续传、幂等);否则退化为 scp -r 全量
# 拷贝(Windows 节点一般无 rsync,自动走 scp)。SSH 复用 deploy.sh 的 ControlMaster
# 连接复用,多次执行不重复输密码。
# 传输策略(两类节点都走增量,重复运行只补差异、不重拷全量):
# - 双方均可用 rsync → rsync 增量(断点续传、幂等,自动补新增/变更文件)
# - 否则(典型 Windows:无 rsync)→ scp 目录级增量:先列举远端顶层网格点子目录,
# 只 scp 本地缺失的目录,已存在的不重拷(避免 win-01 的 52GB 全量重拷)
# 远端 rsync/列目录探测均用多 shell 兼容命令(POSIX/command-v/where/Get-Command、
# ls/dir/Get-ChildItem),适配 Linux + Windows(cmd/PowerShell 默认 shell)。
# SSH 复用 deploy.sh 的 ControlMaster,多次执行不重复输密码。
# ==============================================================================
set -eo pipefail
set -euo pipefail
GREEN='\033[0;32m'
BLUE='\033[0;34m'
@@ -43,7 +46,6 @@ BACKUP_ROOT="${BACKUP_ROOT:-${WORK_DIR}/data/salvage}"
ONLY_NODE=""
WITH_WORK=false
INCLUDE_LOCAL=false
FORCE=false
INTERACTIVE=false
HAD_ARGS=false
SELECTED_PROFILES=()
@@ -59,7 +61,6 @@ while [[ $# -gt 0 ]]; do
-i|--interactive) INTERACTIVE=true; shift ;;
--with-work) WITH_WORK=true; shift ;;
--include-local) INCLUDE_LOCAL=true; shift ;;
--force) FORCE=true; shift ;;
-h|--help)
sed -n '2,32p' "$0"
exit 0 ;;
@@ -89,44 +90,132 @@ rsync_remote_path() { # $1 = 远端绝对路径
esac
}
# 传输一个目录树(rsync 优先,退化 scp)
# 远端目录列表(仅顶层条目名,用于 scp 目录级增量比对)。
# 按远端路径形态选择对应的列举命令:
# - 盘符开头(如 E:/...) → Windowspowershell Get-ChildItem(兼容 cmd/PowerShell 默认 shell
# - 否则 → POSIXls -1
# 返回名字列表(每行一个),失败返回空。
remote_list_dir() { # $1=user $2=ip $3=port $4=ssh_opts $5=远端绝对目录
local u="$1" ip="$2" port="$3" opts="$4" rdir="$5"
# 统一 tr -d '\r'Windows powershell/openssh 输出带 CRLF,会让 grep -Fxq 精确匹配
# 永远失败(已同步目录每次重传)并产生带 CR 的畸形 scp 路径。
if [[ "${rdir}" =~ ^[A-Za-z]:[\\/] ]]; then
# Windows 路径:经 powershell 列举,-Name 直接返回每项一行
ssh -p "${port}" ${opts} "${u}@${ip}" \
"powershell -NoProfile -Command \"Get-ChildItem -Name -Path '${rdir}'\"" 2>/dev/null | tr -d '\r'
else
ssh -p "${port}" ${opts} "${u}@${ip}" "ls -1 '${rdir}'" 2>/dev/null | tr -d '\r'
fi
}
# scp 单个远端目录到本地,采用"先落地 .partial 再原子 mv"模式:
# 1. 目标目录 ${dest}/${d} 已存在 → 视为已同步,跳过(保持原增量语义)
# 2. 否则 scp 远端目录到 ${dest}/${d}.partial
# 3. scp 成功后 mv 为 ${dest}/${d}(同 dest 文件系统,原子语义)
# 4. scp 失败 → 清理残留 .partial,return 非零,由调用方决定是否继续
# 这样即便 scp 传输过程中断,也不会留下"伪完成"目录导致下次被跳过漏传。
scp_dir() { # $1=u $2=ip $3=port $4=ssh_opts $5=远端根目录 $6=本地目标 $7=目录名
local u="$1" ip="$2" port="$3" opts="$4" rdir="$5" dest="$6" d="$7"
local final="${dest}/${d}"
if [ -e "${final}" ]; then
return 0
fi
local tmp="${dest}/.${d}.partial"
# 兜底清理可能的历史残留(上次中断留下的半成品)
rm -rf "${tmp}"
if scp -P "${port}" ${opts} -r "${u}@${ip}:${rdir}/${d}" "${tmp}"; then
mv "${tmp}" "${final}"
else
echo -e " ${RED}[!]${NC} scp 失败: ${d},清理残留 ${d}.partial"
rm -rf "${tmp}"
return 1
fi
}
# 传输一个目录树(rsync 优先,退化 scp 目录级增量)
sync_dir() { # $1=user $2=ip $3=port $4=远端绝对目录 $5=本地目标
local u="$1" ip="$2" port="$3" rdir="$4" dest="$5"
local ssh_opts="-o ControlMaster=auto -o ControlPath=${SSH_CONTROL_DIR}/cm-${u}@${ip}:${port} -o ControlPersist=1800"
mkdir -p "$(dirname "${dest}")"
# 完成标记:已成功同步过且未 --force 时跳过
if [ -f "${dest}/.fetch-complete" ] && [ "${FORCE}" != "true" ]; then
echo -e " ${CYAN}[i]${NC} 已有完成标记 ${dest}/.fetch-complete,跳过(--force 可重拉)"
return 0
fi
mkdir -p "${dest}"
# 连通性探测(BatchMode=no 允许交互输密码)。
# 注意探测命令必须用 `echo ok` 而非 `true`Windows OpenSSH 默认 shell 是
# cmd/PowerShell没有 Unix 的 true,会导致"密码正确但仍判连接失败"。
# 探测命令用 `echo ok` 而非 `true`Windows OpenSSH 默认 shell 是 cmd/PowerShell
# 没有 Unix 的 true,会导致"密码正确但仍判连接失败"。
if ! ssh -p "${port}" -o ConnectTimeout=8 ${ssh_opts} "${u}@${ip}" "echo ok" >/dev/null 2>&1; then
echo -e " ${RED}[!] 无法连接 ${u}@${ip},跳过本节点${NC}"
return 1
fi
# rsync 可用性(本机 + 远端)
# rsync 可用性(本机 + 远端)。远端探测按平台分支:
# Windows 路径 → powershell Get-CommandPOSIX 路径 → command -v
local rs_ok=false
if command -v rsync >/dev/null 2>&1; then
if ssh -p "${port}" ${ssh_opts} "${u}@${ip}" "command -v rsync" >/dev/null 2>&1; then
local probe
if [[ "${rdir}" =~ ^[A-Za-z]:[\\/] ]]; then
probe="powershell -NoProfile -Command \"Get-Command rsync -ErrorAction SilentlyContinue | Out-Null\""
else
probe="command -v rsync"
fi
if ssh -p "${port}" ${ssh_opts} "${u}@${ip}" "${probe}" >/dev/null 2>&1; then
rs_ok=true
fi
fi
if [ "${rs_ok}" = "true" ]; then
# rsync 天然增量幂等:每次都跑,自动补齐新增/变更的文件。
echo -e " ${BLUE}[→]${NC} rsync 增量同步: ${u}@${ip}:${rdir}/ → ${dest}/"
rsync -a --partial --info=progress2 \
-e "ssh -p ${port} ${ssh_opts}" \
"${u}@${ip}:$(rsync_remote_path "${rdir}")/" "${dest}/"
else
echo -e " ${YELLOW}[→]${NC} 远端无 rsync,退化 scp 全量拷贝: ${u}@${ip}:${rdir}/ → ${dest}/"
scp -P "${port}" ${ssh_opts} -r "${u}@${ip}:${rdir}/" "${dest}/"
# 无 rsync(典型 Windows):scp 目录级增量——只拉本地缺失的网格点子目录,
# 不重复拷贝已存在的目录(避免 win-01 的 52GB 全量重拷)。
local remote_dirs existing missing
remote_dirs=$(remote_list_dir "${u}" "${ip}" "${port}" "${ssh_opts}" "${rdir}")
if [ -z "${remote_dirs}" ]; then
echo -e " ${YELLOW}[!]${NC} 远端目录 ${rdir} 为空或无法列举,跳过"
return 1
fi
existing=$(ls -1 "${dest}" 2>/dev/null)
missing=""
local d
# 远端目录名白名单校验:scp 路径会把 ${d} 直接拼进 scp 的 remote 路径,
# 这里只允许字母/数字/._-,禁止 / 与 . .. —— 防御性过滤畸形或注入目录名。
while IFS= read -r d; do
[ -n "$d" ] || continue
case "$d" in
"."|"..")
echo -e " ${YELLOW}[!]${NC} 跳过可疑远端目录名: ${d}"
continue ;;
esac
if [[ "$d" == */* ]] || ! [[ "$d" =~ ^[A-Za-z0-9._-]+$ ]]; then
echo -e " ${YELLOW}[!]${NC} 跳过不符合白名单的远端目录名: ${d}"
continue
fi
if ! grep -Fxq -- "$d" <<<"${existing}"; then
missing="${missing}${d}"$'\n'
fi
done <<<"${remote_dirs}"
local total new
total=$(printf '%s\n' "${remote_dirs}" | grep -c .)
new=$(printf '%s' "${missing}" | grep -c .)
echo -e " ${YELLOW}[→]${NC} scp 目录级增量: ${u}@${ip}:${rdir}/ 共 ${total} 个,缺失 ${new} 个 → ${dest}/"
if [ "${new}" -eq 0 ]; then
echo -e " ${GREEN}[√]${NC} 本地已是最新,无新增目录"
return 0
fi
# scp 原子写入:先落地到 .partial 临时目录,成功后同文件系统原子 mv 为最终目录名。
# 中途失败残留的 .partial 由下方 scp_dir 的 RETURN 时清理钩子统一兜底,
# 避免下次因"目录已存在"误判为已同步而跳过、留下不完整子目录。
trap 'rm -rf "${dest}"/.*.partial 2>/dev/null || true' RETURN
while IFS= read -r d; do
[ -n "$d" ] || continue
echo -e " ${YELLOW}[→]${NC} scp 新增: ${d}"
scp_dir "${u}" "${ip}" "${port}" "${ssh_opts}" "${rdir}" "${dest}" "$d"
done <<<"${missing}"
fi
touch "${dest}/.fetch-complete"
}
# 备份单个节点
@@ -172,15 +261,18 @@ backup_node() { # $1 = profile 文件
local dest_rc="${BACKUP_ROOT}/${node_id}/result"
if [ "${env_mode}" = "local" ]; then
# 本机直接拷贝,不走网络
# 本机节点:优先 rsync 增量(避免每次 cp -a 全量重拷),无 rsync 才退化 cp -a。
echo -e " ${BLUE}[→]${NC} 本机复制: ./data/result → ${dest_rc}"
mkdir -p "${dest_rc}"
cp -a ./data/result/. "${dest_rc}/"
touch "${dest_rc}/.fetch-complete"
if command -v rsync >/dev/null 2>&1; then
rsync -a --info=progress2 ./data/result/ "${dest_rc}/"
else
cp -a ./data/result/. "${dest_rc}/"
fi
else
sync_dir "${u}" "${ip}" "${port}" "${dir}/data/result" "${dest_rc}" || return 1
fi
echo -e " ${GREEN}[√]${NC} result 已同步,共 $(ls "${dest_rc}" | grep -cv '^\.fetch-complete$' || true) 个网格点子目录"
echo -e " ${GREEN}[√]${NC} result 已同步,共 $(ls -1 "${dest_rc}" 2>/dev/null | grep -c . || true) 个网格点子目录"
# 可选:一并拉取 data/work 沙盒残留(未清理的计算现场,含完整过程文件)
if [ "${WITH_WORK}" = "true" ]; then
@@ -188,9 +280,13 @@ backup_node() { # $1 = profile 文件
if [ "${env_mode}" = "local" ]; then
echo -e " ${BLUE}[→]${NC} 本机复制: ./data/work → ${dest_wk}"
mkdir -p "${dest_wk}"
cp -a ./data/work/. "${dest_wk}/" 2>/dev/null || true
if command -v rsync >/dev/null 2>&1; then
rsync -a ./data/work/ "${dest_wk}/" 2>/dev/null || true
else
cp -a ./data/work/. "${dest_wk}/" 2>/dev/null || true
fi
else
sync_dir "${u}" "${ip}" "${port}" "${dir}/data/work" "${dest_wk}" || return 1
sync_dir "${u}" "${ip}" "${port}" "${dir}/data/work" "${dest_wk}" || true
fi
fi
}
+26 -11
View File
@@ -84,8 +84,8 @@ struct CandidateResult {
src_dir: PathBuf,
/// 权威新名(来自 workflow 配置的小数精度命名)。
canonical_name: String,
/// 该点当初的收敛途径("cold_run" / "seed_step"),据 stages 是否含 seed_nc 判定,
/// 透传给服务端写入 grid_points.success_method,让导入点融入冷启动/种子步进统计。
/// 该点当初的大气收敛途径("cold_run" / "seed_step"),据 stages 是否含 seed_nc 判定,
/// 透传给服务端写入 grid_points.tlusty_success_method,让导入点融入冷启动/种子步进统计。
success_method: String,
}
@@ -256,10 +256,10 @@ fn candidate_cno_sum(c: &CandidateResult) -> f64 {
/// 从 workflow 配置派生收敛链最后阶段标签。
///
/// 旧版 run_one.py 每阶段覆盖同名 .5/.6/.err/nst,只留最后阶段的版本。迁移时需把这些
/// 裸文件归到正确的阶段标签下。标签取自配置 chain 的末段;chain 为空则回退到默认收敛链
/// 裸文件归到正确的阶段标签下。标签取自配置 tlusty_chain 的末段;为空则回退到默认收敛链
/// 的末段(default_cold_chain 末段是 nl)。
fn derive_last_stage_label(cfg: &GridConfig) -> String {
if let Some(last) = cfg.chain.last() {
if let Some(last) = cfg.tlusty_chain.last() {
return last.label.clone();
}
// chain 为空(仅 grid,无 chain 配置):回退到默认收敛链末段。
@@ -330,7 +330,21 @@ async fn scan_dir_for_results(
continue;
}
};
if !(summary.converged && !summary.atmosphere_has_nan && !atmosphere_has_nan(&seed_file)) {
if !summary.result_valid {
info!("跳过 {}: conv.json 标记未收敛 (converged=false)", name);
continue;
}
if summary.atmosphere_has_nan {
info!("跳过 {}: conv.json 标记大气含 NaN/Inf", name);
continue;
}
// 收紧阈值后(0 行容忍),对种子 .7 文件做二次校验。旧 run_grid 产物若含单行
// NaN(此前 10% 阈值放过)会被这里拒绝——记日志便于排查"为何某历史种子被拒"。
if atmosphere_has_nan(&seed_file) {
info!(
"跳过 {}: 种子 .7 文件含 NaN/Inf/溢出行(0 行容忍阈值)",
name
);
continue;
}
@@ -361,7 +375,7 @@ async fn scan_dir_for_results(
continue;
};
// 判定该历史点的收敛途径(供服务端写入 success_method,让导入点融入冷启动/种子步进统计):
// 判定该历史点的大气收敛途径(供服务端写入 tlusty_success_method,让导入点融入冷启动/种子步进统计):
// 旧 run_grid.py 流程——冷启动失败才改用 seed_step 链重跑,种子链首段 label 为 "seed_nc"。
// 故 stages 含 seed_nc → seed_step(种子步进救活);否则 → cold_run(冷启动一次收敛)。
let has_seed_nc_stage = summary.stages.iter().any(|s| s.label == "seed_nc");
@@ -680,11 +694,11 @@ async fn upload_results_to_server(
.file_name(format!("{}.7", c.canonical_name))
.mime_str("application/octet-stream")?,
)
// 收敛途径(cold_run/seed_step):服务端据此写 grid_points.success_method
// 大气收敛途径(cold_run/seed_step):服务端据此写 grid_points.tlusty_success_method
// 让导入点融入冷启动/种子步进统计而非独立 imported 分类。
// Part::text 需 'static 生命周期,克隆一份脱离 candidates 借用。
.part(
"success_method",
"tlusty_success_method",
Part::text(c.success_method.clone()).mime_str("text/plain")?,
);
@@ -961,7 +975,8 @@ mod tests {
let v: serde_json::Value = serde_json::from_str(&rewritten).unwrap();
assert_eq!(v["name"].as_str().unwrap(), "t20000_g5.0_he-2_c-4_n-4_o-4");
assert_eq!(v["params"]["logg"].as_f64().unwrap(), 5.0);
assert!(v["converged"].as_bool().unwrap());
// 旧键 "converged" 经 alias 读入 result_valid,重写后按新键名写出。
assert!(v["result_valid"].as_bool().unwrap());
assert_eq!(v["final_max_relc"].as_f64().unwrap(), 0.001);
// 格式对齐本项目计算生成的 conv.jsonrunner.rs:597 to_string_pretty(&ModelSummary)):
// pretty 多行缩进 + 字段按 ModelSummary 声明顺序(name 为首字段,非字母序)。
@@ -1016,8 +1031,8 @@ mod tests {
#[test]
fn test_derive_last_stage_label_from_chain() {
// 配置显式指定 chain → 取末段标签
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\nchain:\n - {label: lte, lte: T, ltgray: T, ilvlin: 0, require_converged: false, niter: 0}\n - {label: nc, lte: F, ltgray: F, ilvlin: 0, require_converged: false, niter: 10}\n - {label: nl_final, lte: F, ltgray: F, ilvlin: 100, require_converged: true, niter: 100}\n";
// 配置显式指定 tlusty_chain → 取末段标签
let yaml = "grid:\n teff: [20000]\n logg: [5.0]\n loghe: [-2]\n logc: [-4]\n logn: [-4]\n logo: [-4]\ntlusty_chain:\n - {label: lte, lte: T, ltgray: T, ilvlin: 0, require_converged: false, niter: 0}\n - {label: nc, lte: F, ltgray: F, ilvlin: 0, require_converged: false, niter: 10}\n - {label: nl_final, lte: F, ltgray: F, ilvlin: 100, require_converged: true, niter: 100}\n";
let cfg = GridConfig::from_yaml_str(yaml).unwrap();
assert_eq!(derive_last_stage_label(&cfg), "nl_final");
}
+41 -10
View File
@@ -33,8 +33,11 @@ grid:
# nc 步是关键:在不考虑谱线扰动下收敛 NLTE 电离平衡,给 nl 一个稳定种子。
# 跳过 ncgrey -> 完整 NLTE)会发散。
#
# 重要:不要在 nst 里设 CHMAX/ITEK,用 tlusty 默认值(CHMAX=0.001, ITEK=4)。
# 设 CHMAX=0.1 会让 nc 提前停止,给 nl 一个坏种子导致发散。
# 各 step 的隐式默认值在此显式写出(与代码兜底等价,见下):
# - metals: cno —— C/N/O 参与 NLTErunner.rs 兜底默认;缺省时 .5 输入也按此处理)
# - chmax: 0.001 —— 收敛阈值(runner.rs 兜底默认;缺省时与原兜底完全一致)
# - itek / ichang / idlte / iacc / orelax 留空 → 用 tlusty 内部默认(未结构化为可写值)。
# 注意:设 CHMAX=0.1 会让 nc 提前停止,给 nl 一个坏种子导致发散——勿调大。
#
# nc 的 NITER=10 是实测最优(tests/sdB_spectra/GUIDE.md NITER 扫描结论):
# - NITER=10 总耗时 12.4min35000K CNO 完整模型)
@@ -42,12 +45,39 @@ grid:
# - NITER=50 浪费 2.2× 时间,NITER=200/500 更浪费且无收益
# 物理上 nc(纯连续谱)缺少谱线约束,外层温度永不真正收敛——追求高 NITER
# 没意义。nl(含谱线)会自修正到正确解(流量差异 <3e-12)。
chain:
- {label: lte, lte: T, ltgray: T, ilvlin: 0, require_converged: false, niter: 0}
- {label: nc, lte: F, ltgray: F, ilvlin: 0, require_converged: false, niter: 10}
- {label: nl, lte: F, ltgray: F, ilvlin: 100, require_converged: true, niter: 100}
tlusty_chain:
- {label: lte, lte: T, ltgray: T, ilvlin: 0, require_converged: false, niter: 0, metals: cno, chmax: 0.001}
- {label: nc, lte: F, ltgray: F, ilvlin: 0, require_converged: false, niter: 10, metals: cno, chmax: 0.001}
- {label: nl, lte: F, ltgray: F, ilvlin: 100, require_converged: true, niter: 100, metals: cno, chmax: 0.001}
# ---- 种子步进回退(旧字段,保留兼容;新配置见下方 tlusty 块----
# ---- TLUSTY 输入文件全局参数(.5 + nst 的非阶段差异部分----
# 这些参数不随收敛阶段变化。此处显式声明当前生效值,作为可配置项的文档。
# 如需调整(如降 nfread 加速、换 ions 能级文件、暴露 nst 高级关键字),改本块即可。
# 删掉本块 → 走代码内硬编码默认(与下方声明完全等价)。
tlusty_input:
# .5 frequencies 块:连续频率网格点数。>0 读预设表(高精度、慢)。
# 2000 是高精度档(官方 hhe 样本用 50);降到 1000 可加速但牺牲精度。
nfread: 2000
# atoms 块每元素的 mode2=显式统计平衡(H/He/CNO),0=不参与(Li/Be/B)。
# 缺省元素走默认。abn(丰度)由 grid 的 loghe/logc/logn/logo 计算,不在此配。
atoms:
H: {mode: 2}
He: {mode: 2}
C: {mode: 2}
N: {mode: 2}
O: {mode: 2}
# ions 能级数据表(空 = 用代码默认的 H/He/C/N/O 共 23 行标准表)。
# 如需换能级文件或调整 nlevs,列出完整表(完全替换默认)。
ions: []
# nst 额外关键字(逃逸口):自由传入任意 KEY=VALUE,追加到 nst 末尾。
# 用于暴露未结构化的 nst 开关(FRCMAX/CUTBAL/TAU/NDGREY 等,见 tlusty 手册)。
# 示例(当前未启用):
# nst_extra_keys:
# - [FRCMAX, "0.01"]
# - [CUTBAL, "0.3"]
nst_extra_keys: []
# ---- 种子步进回退(旧字段,保留兼容;新配置见下方 tlusty_stage 块)----
# 冷启动失败时,自动用已收敛的邻居模型作种子,用 LTGRAY=F 热启动重试。
# 这是高温 He-poor / 富金属区收敛的关键(见 EXPERIENCE.md §5Y)。
# 失败的冷启动结果会备份到 <model>.coldfail/ 目录。
@@ -56,8 +86,9 @@ seed_step_fallback: true
# ---- 执行参数 ----
timeout_sec: 7200 # 单模型墙钟时间上限(120分钟)
# ---- 光谱合成 SYNSPEC 控制参数(旧字段,数值参数;启用开关见下方 synspec_stage----
synspec:
# ---- 光谱合成 SYNSPEC 输入参数(fort.55 数值参数;与 tlusty_input 对称----
# 启用开关见下方 synspec_stage 块。
synspec_input:
wstart: 3000.0 # 光谱波长起始点 (Å)
wend: 7000.0 # 光谱波长终止点 (Å)
imode: 0
@@ -77,7 +108,7 @@ synspec:
# 此处显式声明与旧 seed_step_fallback/synspec 块等价的默认配置,便于前端面板编辑。
# 旧字段(seed_step_fallback / synspec)保留作回退兜底,resolve 方法优先用本块。
# ====================================================================
tlusty:
tlusty_stage:
enabled: true
policy: skip_converged
strategies: [cold_run, seed_step]