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:
@@ -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 3–11% success vs 42–54% 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
@@ -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,必须 > 0(conv_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 为 true(NaN 比较恒 false),
|
||||
// 应拒绝 NaN;改写为 `chmax <= 0.0` 会漏掉 NaN(NaN<=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=2,Li/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:连续频率网格点数。
|
||||
/// 取值 >0 → 从预设频率表读取(高精度、慢);≤0 → 由 frmin/frmax 自动生成对数网格(快)。
|
||||
/// 默认 2000(gen_input5.rs 原硬编码值)。
|
||||
#[serde(default = "default_nfread")]
|
||||
pub nfread: i32,
|
||||
|
||||
/// atoms 块特定元素的 mode/modpf 覆盖。
|
||||
/// key = 元素符号(`"H"`/`"He"`/`"C"`/`"N"`/`"O"`/`"Li"`/`"Be"`/`"B"`)。
|
||||
/// 缺省的元素走代码默认 mode(H/He/CNO=2,Li/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_params,executor 反序列化后透传给 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_params,executor 反序列化后
|
||||
/// 透传给 runner → make_input5 / generate_nst_content。
|
||||
#[serde(default)]
|
||||
pub tlusty_input: Option<TlustyInput>,
|
||||
/// SYNSPEC 输入文件(fort.55)的数值参数。与 `tlusty_input` 语义对称。
|
||||
/// 由 scheduler 序列化进 TaskSpec.synspec_params,executor 反序列化后透传给 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/result(Phase 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())
|
||||
|
||||
@@ -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` 光谱文件内容是否有效。
|
||||
///
|
||||
/// # 背景(漏洞 1,P0)
|
||||
/// 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.6(TLUSTY stdout 日志)提取失败诊断提示。
|
||||
///
|
||||
/// # 背景(漏洞 5,P2)
|
||||
/// 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-2(depth2)、iter2 最深 max_relc=5.0E-4(depth1)。
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 block:H/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(¶ms, "F", "F", "cno", 100);
|
||||
let input5 = make_input5(¶ms, "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(¶ms, "T", "T", "", 100);
|
||||
let input5 = make_input5(¶ms, "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=2,Li/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(¶ms, "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(¶ms, "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(¶ms, "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
@@ -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-only(TLUSTY 关闭)时 = 光谱输入大气来源点(由 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-only(TLUSTY 关闭)被重写为管线成功。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-only(TLUSTY 关闭)被 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 6(P8)删除 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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/IELCOR(input_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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 6(P8)起取代废弃的 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 6(P8):`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_name(DB 的 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 start(lte 阶段不迭代,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;
|
||||
|
||||
@@ -15,7 +15,7 @@ pub const MAX_GLOBAL_SEED_DISTANCE: f64 = 3.0;
|
||||
/// **数据标定依据**——对历史 1191 个真实 seed_step(种子,目标)配对的成败统计:
|
||||
/// - 贫金属方向(种子更富、目标往贫走,delta=目标−种子 < 0):成功率 **42–54%**
|
||||
/// - 富金属方向(目标更富、delta > 0):成功率仅 **3–11%**
|
||||
/// (每个 loghe 分层该规律独立成立,he=−4 时贫方向 54% vs 富方向 3%,差 18 倍)
|
||||
/// (每个 loghe 分层该规律独立成立,he=−4 时贫方向 54% vs 富方向 3%,差 18 倍)
|
||||
///
|
||||
/// **物理解释**:从高金属丰度的收敛解出发**减少**金属(贫方向)是稳定微扰;
|
||||
/// 反过来从贫金属种子**增加**金属(富方向),新增的紫外谱线辐射驱动会破坏已建立的
|
||||
|
||||
@@ -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 6(P8):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 6(P8):旧版消息兼容(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
@@ -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_name(DB 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"]);
|
||||
// 首步必须是非灰 LTE(ltgray=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 =
|
||||
|
||||
@@ -12,8 +12,14 @@ use tracing::{info, warn};
|
||||
/// 失败导致 synspec 无输入"的级联场景);TLUSTY 关闭 → 归因 SYNSPEC(synspec-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
@@ -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/403:token 失效(被重发覆盖)。与 claim_task 口径统一:直接退出进程,
|
||||
// 避免心跳线程持续发被拒请求刷日志、占用服务端限流计数。心跳通常比
|
||||
// claim 更高频,往往先于 claim_task 发现 token 失效。
|
||||
// 401/403:token 失效(被重发覆盖)。与 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();
|
||||
|
||||
// 信号处理:同时监听 SIGINT(Ctrl+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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -46,7 +46,11 @@ pub async fn claim_task(
|
||||
// 旧版在途任务 payload 无 workflow_name(None)→ 归一到主库迁移回填的
|
||||
// '__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_points(ON 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,
|
||||
})),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
// 同事务原子标记 queued(IMMEDIATE 已持有写锁,SELECT→UPDATE 间无竞态窗口)。
|
||||
// 阶段列(5b)同步:tlusty_status 守卫保留既有终态(半失败重试时 tlusty_status 已
|
||||
// converged 不被覆盖);synspec_status 自由流转为 queued(重试进行中可见——设计
|
||||
// §7.3 打开项 #2 "仅 synspec 侧流转",审查修正:原实现连 synspec 'failed' 也保留,
|
||||
// 与设计意图不符)。
|
||||
if !ids.is_empty() {
|
||||
// 审查修复 #M6:SQLite 单语句参数上限 999,limit=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)同步置 running:tlusty_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_points(dashboard 全局概览用)。
|
||||
/// - `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
@@ -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_secret(H8)并插入待审批状态。
|
||||
// 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 → online(offline 节点复活)
|
||||
// - 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:生成随机明文 token,DB 存其 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 查询前的缓存 generation,miss 落 DB。
|
||||
// generation 用于回填时的 TOCTOU 终极防护:若 DB 查询与回填之间发生过
|
||||
// invalidate(reissue),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。在 issue(token 轮换使旧 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)
|
||||
}
|
||||
}
|
||||
@@ -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_path,point_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(¶ms, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
}
|
||||
@@ -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 列已删除,策略链首项即"当前执行策略"的权威快照。
|
||||
///
|
||||
/// 为何计数 pending(2026-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):
|
||||
/// 读取指定网格点最近一条**已上报**(非 pending)tasks 行的 `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 回退」的 TOCTOU(2026-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。
|
||||
// 现区分 QueryReturnedNoRows(grid_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_method:TLUSTY 阶段以何策略收敛。tlusty_enabled=1 时取
|
||||
// `tlusty_strategies[0]`(cold_run/seed_step);TLUSTY 禁用(synspec-only)为 NULL。
|
||||
// - synspec_success_method(5a):光谱阶段以何策略收敛。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_extract(rusqlite 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#1:7c 曾误
|
||||
// 把这里改成 '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],
|
||||
)?
|
||||
};
|
||||
|
||||
// 审查修复 #S1:workflow 完成 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?
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,5 @@
|
||||
pub mod api;
|
||||
pub mod cors;
|
||||
pub mod db;
|
||||
pub mod migrations;
|
||||
pub mod scheduler;
|
||||
|
||||
@@ -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();
|
||||
// CSP:default-src 'self';放行 Google Fonts(index.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:; \
|
||||
|
||||
@@ -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] = &[
|
||||
// M1(Phase 1,P1):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",
|
||||
],
|
||||
},
|
||||
// M2(Phase 2,P2):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)"],
|
||||
},
|
||||
// M4(Phase 4,P5):清除 node_credentials 死列 revoked。
|
||||
// 新代码不读写它;registration_secret 保留在 nodes(审查 CRITICAL#1/#2:pending 节点无
|
||||
// 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"],
|
||||
},
|
||||
// M6(Phase 6,P8):删除 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"],
|
||||
},
|
||||
// M7(Phase 5a,P6):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"],
|
||||
},
|
||||
// M8(Phase 5b,P6):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",
|
||||
],
|
||||
},
|
||||
// M9(Phase 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'"],
|
||||
},
|
||||
// M10(Phase 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"],
|
||||
},
|
||||
// M11(H1 活锁修复):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"],
|
||||
},
|
||||
// M12(P9 命名拆分):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_method:TLUSTY 阶段策略(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_method(M12 已回填 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 bootstrap:tasks 是含全部迁移引用列的最新形态(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());
|
||||
// M11:grid_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);
|
||||
}
|
||||
|
||||
/// M9(Phase 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()]
|
||||
);
|
||||
// M10:snapshots 列改名。
|
||||
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());
|
||||
// 重跑:M1(version 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_method(TLUSTY 阶段策略)+ 既有 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);
|
||||
}
|
||||
}
|
||||
+361
-157
File diff suppressed because it is too large
Load Diff
@@ -266,6 +266,17 @@ async fn test_l2_node_token_issue_reissue_flow() {
|
||||
);
|
||||
// 旧 token 仍失效(已被覆盖)
|
||||
assert!(db.find_node_by_token(&token).await.is_none());
|
||||
|
||||
// M2:registration_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 后的新 token(M2 一次性凭据)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 验证中间件对 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. 最近尝试 JOIN:rescued 双尝试取最新(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
@@ -9,16 +9,9 @@
|
||||
<!-- 字重精简:Inter 去掉未使用的 300,Space 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>
|
||||
|
||||
@@ -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 不可用时回退媒体查询 */ }
|
||||
})();
|
||||
@@ -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(详情页数据源) =====
|
||||
|
||||
@@ -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() {
|
||||
// ===== 刷新与挂载 =====
|
||||
|
||||
/** 把一次拉取的详情数据填入 panelState(fetch + 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 }) {
|
||||
// mount(forceConfig=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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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}%`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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/canceled(7c 由 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>`;
|
||||
}
|
||||
|
||||
/** 网格点收敛途径徽章 HTML(success_method:cold_run / seed_step / 策略名)。 */
|
||||
/** 整体归因 = TLUSTY 阶段策略 ?? 光谱阶段策略(阶段归因列为空时退回另一侧)。
|
||||
* 正常双阶段点 → cold_run/seed_step;synspec-only 点 → 光谱策略(如 standard)。 */
|
||||
export function overallMethod(p) {
|
||||
return p.tlusty_success_method ?? p.synspec_success_method;
|
||||
}
|
||||
|
||||
/** 网格点收敛途径徽章 HTML(method:cold_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>';
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -58,19 +58,29 @@ export function restorePsCache(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态轴档位(收敛拆冷启动/种子步进,物理意义不同:稳定区 vs 救回区)。
|
||||
/** 状态轴档位(收敛拆冷启动/种子步进/SYNSPEC,物理意义不同:稳定区 vs 救回区 vs 光谱重算)。
|
||||
* 顺序即图例/轴布局/每值内分段/色带绘制层叠的唯一来源,语义约定:
|
||||
* 已收敛(最受关注)置顶 → 进行中按任务进展方向(running→queued→pending)→ 失败沉底。
|
||||
* 色带绘制按此顺序:收敛色带先画(底层),失败最后画(最上层,数量少且颜色醒目)。 */
|
||||
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('');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* 工作流详情页 · 网格点详情滑入面板(尝试历史 + 阶段链诊断)
|
||||
/* 工作流详情页 · 网格点详情滑入面板(尝试历史 + TLUSTY 收敛链诊断)
|
||||
*
|
||||
* 从点表「查看」按钮、Parallel Sets 点名列表 / 单点 drill-down 进入。
|
||||
* 面板 DOM 挂在 ctx.pointPanelEl(unmount 由控制器调用 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_json(ModelSummary)推导尝试方法徽标(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>
|
||||
|
||||
@@ -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-only:tlusty 禁用,服务端映射 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 ?? synspec(P9 拆分后派生值,
|
||||
// 已含收敛归因且覆盖 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');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -117,6 +117,41 @@ test('triggerNow() 立即触发一次并续排', async () => {
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('triggerNow() 与在途 schedule 不并发执行 fn(inFlight 守卫)', 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(); // 触发首次 schedule(fn 在途,停在 await)
|
||||
// 让 start 的 schedule 进入 fn()——通过一次微任务边界。
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
// 此时 fn 在途(calls=1,active=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 不得并发触发第二次 fn(inFlight 守卫)');
|
||||
assert.equal(maxActive, 1, 'fn 不得并发执行(maxActive 应为 1)');
|
||||
p.stop();
|
||||
timers.restore();
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('退避上限 maxMs 生效(长时间连续失败不无限增长)', async () => {
|
||||
const stub = installDocStub();
|
||||
const timers = captureTimers();
|
||||
|
||||
@@ -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 存在
|
||||
|
||||
@@ -17,17 +17,17 @@ import {
|
||||
} from '../src/utils/yamlStage.js';
|
||||
|
||||
test('defaultStage:tlusty 默认 [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('extractTopBlock:key 含正则元字符按字面匹配,`.` 不通配(防御性转义)', () => {
|
||||
// 用不会真实出现的 `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('extractTopBlock:key 含 `+` 等其它元字符仍按字面匹配', () => {
|
||||
// `+` 在正则里是量词;未转义会抛 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('parseStageFromYaml:enabled 行内注释', () => {
|
||||
|
||||
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('serializeStageBlock:flow 序列输出格式', () => {
|
||||
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
@@ -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 线)
|
||||
```
|
||||
- 代码默认波长窗为 **1400–1410 Å**(`SynspecConfig` 默认),实际使用通常配置到
|
||||
- 代码默认波长窗为 **1400–1410 Å**(`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
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
@@ -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`):任务 ID(UUID)。
|
||||
- `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-only(TLUSTY 关闭)时恒为 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`):任务 ID(UUID)。
|
||||
- `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` 据此判定超时回投)。
|
||||
|
||||
@@ -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-05,subagent 对抗性审查)
|
||||
|
||||
| 严重度 | 发现 | 修复 |
|
||||
|---|---|---|
|
||||
| 【严重】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`:半失败 = Failed,reporter.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 迁移 M1(tasks 表,均在线 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:1246)SQL 与 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 — 死列清理(P5;registration_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_step,synspec 收敛点统计丢失 | **已解决(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+SYNSPEC,synspec_sec 是子集);
|
||||
- `seed_point_name` 标注 synspec-only 双义。
|
||||
- **前端可感知修正**(bug 级):
|
||||
- "已收敛" → "已完成"(pointsTable.js:44 等所有中文标签);
|
||||
- "方法"过滤器增加 synspec 策略名选项(pointsTable.js:48-51);
|
||||
- CSV 列 `last_task_type` → `overall_method`(pointsTable.js:218,P9 拆分后改 `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 4(M4)与 Phase 6(M6)的 `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-05;5b 待 summary_json 数据积累后评估 |
|
||||
| task_type 字段 | **全量删除**(Phase 6) | 用户决策 2026-08-05;集群无历史节点,随镜像统一重建,无旧节点兼容负担 |
|
||||
| Phase 7 命名语义修正 | **立项**,先 7a(注释+前端)后 7b(改名),7c 已实施(2026-08-05) | 用户决策 2026-08-05;subagent 全库审计确认 TLUSTY-first 命名残留 |
|
||||
@@ -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
@@ -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
@@ -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:/...) → Windows:powershell Get-ChildItem(兼容 cmd/PowerShell 默认 shell)
|
||||
# - 否则 → POSIX:ls -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-Command;POSIX 路径 → 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
|
||||
}
|
||||
|
||||
@@ -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.json(runner.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
@@ -33,8 +33,11 @@ grid:
|
||||
# nc 步是关键:在不考虑谱线扰动下收敛 NLTE 电离平衡,给 nl 一个稳定种子。
|
||||
# 跳过 nc(grey -> 完整 NLTE)会发散。
|
||||
#
|
||||
# 重要:不要在 nst 里设 CHMAX/ITEK,用 tlusty 默认值(CHMAX=0.001, ITEK=4)。
|
||||
# 设 CHMAX=0.1 会让 nc 提前停止,给 nl 一个坏种子导致发散。
|
||||
# 各 step 的隐式默认值在此显式写出(与代码兜底等价,见下):
|
||||
# - metals: cno —— C/N/O 参与 NLTE(runner.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.4min(35000K 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 块每元素的 mode:2=显式统计平衡(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]
|
||||
|
||||
Reference in New Issue
Block a user