diff --git a/src/docs/external-resources-user-manual.md b/src/docs/external-resources-user-manual.md new file mode 100644 index 00000000..cbd07f98 --- /dev/null +++ b/src/docs/external-resources-user-manual.md @@ -0,0 +1,864 @@ +# 外部资源(License + elim)用户手册 + +> 适用版本:working 分支(含 E2 Phase 1-3 与 elim 采集器,2026-10-28)。 +> 目标读者:需要接入外部 license 池或自定义主机资源,并对其做调度与测试的 +> 集群管理员和测试工程师。 +> 配套设计文档:`.kiro/specs/external-license-resources/design.md`。 + +--- + +## 目录 + +1. [概念与架构](#1-概念与架构) +2. [集群侧资源声明](#2-集群侧资源声明) +3. [配置构建与下发](#3-配置构建与下发) +4. [全局腿:xceesd 部署](#4-全局腿xceesd-部署) +5. [采集脚本契约](#5-采集脚本契约) +6. [主机腿:elim 采集器](#6-主机腿elim-采集器) +7. [策略层:limits 配额](#7-策略层limits-配额) +8. [作业提交](#8-作业提交) +9. [运维观测](#9-运维观测) +10. [故障处理](#10-故障处理) +11. [测试指南(E2E 配方)](#11-测试指南e2e-配方) +12. [已知限制](#12-已知限制) + +--- + +## 1. 概念与架构 + +### 1.1 两条腿 + +外部资源有两条独立的采集路径,共享同一套资源模型和记账规则: + +``` + ┌────────────────────────────────────────────────┐ + │ 调度器(准入判定) │ + │ EXTERNAL: total ≥ uncomit + wanted │ + │ 其他: total ≥ inuse + uncomit + wanted │ + └────────────┬───────────────────────────────────┘ + │ RESVALS_DELTA + │ (host=="" → sharedInst 全局池) + ┌───────────────┴───────────────┐ + │ │ + ┌──────────▼──────────┐ ┌──────────▼──────────┐ + │ 全局腿(license 池) │ │ 主机腿(elim 指标) │ + │ xceesd daemon │ │ xcecd -c external_ │ + │ controller 角色扩展 │ │ resources │ + │ (extres) │ │ worker resource_sync│ + └──────────┬──────────┘ └──────────┬──────────┘ + │ 脚本采集 │ 每主机脚本采集 + ▼ ▼ + license 服务器/任何来源 本机 GPU/任何本机量 +``` + +- **全局腿**:`xceesd` 是一个独立 daemon,跑在一台机器上,周期执行若干采集 + 脚本,把**集群级 free 值**(如剩余 license 数)推给 controller。它以 + `extres` 角色 join,推送落点是无 host 的共享实例(`sharedInst`),也就是 + "一个资源、一个全局池"。 +- **主机腿**:`xcecd`(worker 节点上的 daemon)支持 + `external_resources` 配置节,在每个主机本地执行脚本,把结果注册为自定义 + load index,走 worker 既有的 15 秒 load-index 上报链,落点是**每台主机各自 + 的资源值**("一个资源、每主机一个池")。 + +同一个资源名(如 `extlic`)只能选一条腿——声明位置(`resourcemap` 的 +`location`)决定它落在哪条腿上。 + +### 1.2 EXTERNAL 语义与准入公式 + +带 `location: [external]` 声明的资源打上 `EXTERNAL` flag(0x10)。它与普通 +动态资源的关键区别在**记账规则**: + +- 普通资源(含 elim 落地的动态资源):调度器按 + `total ≥ inuse + uncomit + wanted` 判准入——`inuse` 从节点上报值反推。 +- EXTERNAL 资源:**跳过 inuse**,只判 `total ≥ uncomit + wanted`。 + +原因:xceesd 推上来的已经是 license 服务器扣减后的 **free 值**(还剩多少 +可用),不是 total。作业占用的部分在 license 服务器侧已经体现为 free 值 +下降,调度器再算一遍 inuse 会双扣。脚本返回 free 值是该模型的核心约定。 + +实现位置:`src/scheduler/src/resource.c` `unsatisfy_()`(EXTERNAL 分支只做 +`total ≥ uncomit + wanted`);标记:`r->flags & XCE_RESF_EXTERNAL`。 + +**跨作业记账分工**(重要):EXTERNAL 池的"作业之间互斥"由**外部服务器的 +自由值**承担——作业 A 占 3 个 license,服务器自由值降到 1,调度器下一轮 +看到的就是 1。调度器自己的账本(`sharedInUse`)对 EXTERNAL 资源**记了但 +准入时不看**。两个推论: + +- 自由值的推送周期是暴露窗口:同一个调度轮内连发多个申请同一 EXTERNAL + 资源的作业,可能在自由值还没降下来时都获准入(真实 license 服务器会在 + checkout 时兜底拒绝,见 §11.2 licjob 的 exit 99)。生产上要么接受这个 + 窗口,要么在提交脚本里隔开(≥ poll_interval)。 +- §7 的 limit vres 是**非 EXTERNAL** 的 GLOBAL 资源,走完整 + `inuse + uncomit + wanted` 公式,配额在调度器本地即时扣减——不依赖 + 推送,同一轮内也严格生效。 + +### 1.3 失败语义(fail-open 采集 + fail-conservative 调度) + +- **采集侧 fail-open**:脚本一轮失败(超时、无输出、输出不可解析)时, + xceesd/elim 保留上一个好值继续报,并打一次 WARN;不主动把池子清零引发 + 误停摆。elim 的特例:**从未成功过**的 elim 源失败时返回 "0"(池子从零 + 开始),**有过旧值**的 elim 源失败时返回 ""(解析为 0,值归零)——详见 + §6.4。 +- **stale 升级**:持续失败超过 `stale_limit`(xceesd 默认 150 秒)后,每轮 + 追加 STALE WARN;`xceadmin extres status` 里对应源标记 `"stale": true`。 + 从未有过值的源不判 stale。 +- **调度侧 fail-conservative**:xceesd 未上线(或还没推过值)时,全局池读 + 0,申请该资源的作业 **PEND**(pending reason `NO_RESOURCE`,detail 带 + 资源名),而不是放行。宁可排队,不超发。 +- **配置不播种**:`resources.yaml` 里 EXTERNAL 资源**不写 value**——写了也 + 会被忽略(`seed_sharedinst_from_config` 对 EXTERNAL 跳过)。池值只来自 + daemon,避免配置里的幻影容量掩盖 daemon 掉线。 + +### 1.4 与 LSF 的路线差异(背景) + +LSF 的 `bld`(License Scheduler daemon)+ `elim` 是"采集器 → 专门 license +分发器 → 分布式记账"路线;XCE 的路线是把 license free 值直接当作一种**全局 +共享资源值**灌进统一资源模型,复用既有的 resval 推送链、准入判式和 limit +策略层。因此 fairshare/reserve 等策略对 license 资源天然生效(它们作用在 +作业选择层,与资源来源无关);池容量 itself 的分发由 free 值 + EXTERNAL +记账规则完成。当前版本暂未做 license 级 fairshare(按项目对池子的历史用量 +再分配),如需要可配 per-project/per-queue 配额近似实现(§7)。 + +--- + +## 2. 集群侧资源声明 + +### 2.1 resources.yaml + +全局腿资源在 `resources.yaml` 声明,关键三件事:`type: numeric`、 +`resourcemap` 指到 `external`、**不写 value**: + +```yaml +resources: + - name: extlic + description: externally reported license pool + type: numeric + interval: 0 + consumable: true + order: decrease + resourcemap: + - location: [external] # → SHARED|GLOBAL|EXTERNAL (0x1C) +``` + +各字段含义: + +| 字段 | 必填 | 说明 | +|------|------|------| +| `name` | ✅ | 资源名,调度/提交时引用 | +| `type` | ✅ | numeric(外部资源均为数值型) | +| `consumable` | ❌ | true = 计量消耗型(license 用 true) | +| `resourcemap[].location` | ✅ | `[external]` = 全局腿;`[all]` = SHARED\|GLOBAL 非 EXTERNAL;`[default]` = 普通动态资源 | +| `interval` | ❌ | 解析但当前无消费方(见 §12) | +| `release` | ❌ | 动态资源回收到 worker 池的标记(普通动态资源用) | + +JSON schema(`src/configs/schema/resources.schema.json`)强制 `name`/`type` +必填,resourcemap 项允许 `value`/`location` 数组。 + +**常见错误**: + +- 在 EXTERNAL 资源的 resourcemap 里写 `value:` —— 没有意义,seed 阶段会跳 + 过(§1.3),且容易误以为配置兜了底。 +- `location` 写 `[default]` 却想让 xceesd 推值 —— 推送会被 opupd 的 + "SHARED 即收"守卫拒绝(extres 角色推的值只收 SHARED 资源),见 §10.4。 + +### 2.2 Flag 语义速查 + +flags 在 `src/xceadmin/src/xcecommon/mapping.py` 生成,bit 含义: + +| Flag | Bit | 含义 | 何时打上 | +|------|-----|------|----------| +| BUILTIN | 0x01 | 内置资源 | resources.yaml 里 `builtin: true` | +| DYNAMIC | 0x02 | 动态(有 resourcemap/location) | 声明了 resourcemap | +| GLOBAL | 0x04 | 全局共享池 | location 含 all/external | +| SHARED | 0x08 | 值来自共享实例(sharedInst) | location 含 all/external | +| EXTERNAL | 0x10 | 外部 daemon 推 free 值 | location 含 external | +| RELEASE | 0x20 | 作业结束值回收到来源 | `release: true` | +| CONSUMABLE | 0x80 | 计量消耗型 | `consumable: true` | +| LIMIT | 0x100 | 限额派生的虚拟资源 | limits 实例化自动打 | + +`location: [all]` 与 `location: [external]` 的区别就是有没有 EXTERNAL 位, +决定准入公式走哪条分支(§1.2)。 + +### 2.3 主机腿资源声明 + +elim 落地的资源用普通动态资源声明(location 用 `[all]` 或 `[default]`), +**值来自每台主机的 elim 脚本**: + +```yaml +resources: + - name: dynres1 + description: per-host dynamic resource from elim + type: numeric + interval: 0 + resourcemap: + - location: [all] # 每主机都有各自的值 +``` + +> ⚠️ 主机腿资源**不要**加 `consumable: true` + 依赖调度器算 inuse——elim 推 +> 的同样是"free 值"(脚本返回的数直接就是该主机的可用量),调度器把它当作 +> 该主机的资源值参与 `sched_check_host_satisfy_` 的 host 腿判定。申请值的 +> 扣减由调度器 uncomit 跟踪(同普通 host 资源)。 + +--- + +## 3. 配置构建与下发 + +用 `xceadmin` 把 YAML 构建成 cluster.bin 并推给 controller: + +```bash +# 1. 构建(CONFIGS_DIR 下可放 7 类 YAML:xce/resources/queues/users/ +# hosts/scaler/limits——hosts.yaml 承载 hosts+hostgroups 两个键; +# 缺哪个文件该节为空不算错,但 push 会把对应配置清掉,实践上给全套) +xceadmin config build /path/to/configs -o cluster.bin + +# 2. 预览(探针,不落账) +xceadmin config push cluster.bin --dry-run + +# 3. 推送(注意:push/dump 的参数是 build 产物 cluster.bin,不是 YAML 目录) +xceadmin config push cluster.bin +``` + +- controller 地址:`--controller host:port` 显式指定,或环境变量 + `XCE_CONTROLLER_PEERS` > `XCEADMIN_CONTROLLER` > 默认 `localhost:9991`。 +- 推送后**必须重启 apiserver** 才能读到新定义(apiserver 的定义 store 只在 + join 时的 config push 里建立;这是已知运维约束)。 +- 单节点集群 `push` 可能报 `ackedCount == 0` 误报(push 实际成功,以 + controller 日志 gen/tag 为准)——不要因此反复重推。 +- 验证: + +```bash +xceadmin config dump cluster.bin # 看 build 产物 +# resources 段的 extlic 行应长这样(真实输出): +# name=extlic type=numeric order=decrease desc='...' consumable=True +# release=False external=True shared=true distributions=[external]=(reported) +# external=True 只在 EXTERNAL 资源上出现;absent 即普通资源 +# limits 段能看到 resname=extlic value=3.0 per_project=True 之类 +``` + +- 调度器对资源定义的感知:config push 激活后调度器会被 re-push,但**老版本 + 调度器需重启才拾取资源新定义**;生产升级路径保持"push 后重启 scheduler + + apiserver"最稳。 + +--- + +## 4. 全局腿:xceesd 部署 + +### 4.1 esd.yaml + +xceesd 的配置文件(默认 `/etc/xce/esd.yaml`): + +```yaml +# 必填二选一:单 controller 或 peers 列表 +controller: "172.31.1.30:9990" +# peers: "172.31.1.30:9990,172.31.1.31:9990" + +# 可选 +poll_interval: 10 # 采集+推送轮询间隔(秒),≤0 视为 30 +stale_limit: 60 # 超过此秒数未成功升级为 STALE(秒) +status_file: /tmp/e2e/esd-status.json # 状态 JSON 落点 + +resources: + - name: extlic # 必填,须与 resources.yaml 声明一致 + collector: script # 必填,当前唯一类型 + command: /tmp/e2e/fake_lic.sh extlic # 必填,采集命令 + interval: 10 # 可选:解析但当前 poll 循环未消费(见 §12) + stale_limit: 60 # 可选:源级覆盖 daemon 级 +``` + +字段约束(`src/esd/src/esd_config.c`): + +| 字段 | 必填 | 约束 | +|------|------|------| +| `controller` | 与 peers 二选一 | `host:port`,port 1-65535 | +| `peers` | 与 controller 二选一 | 逗号分隔 `host:port` | +| `poll_interval` | ❌ | ≤0 → 30(在 main.c 兜底) | +| `stale_limit` | ❌ | 默认 150 | +| `status_file` | ❌ | 默认 `/var/run/xce/esd-status.json` | +| `resources[].name` | ✅ | | +| `resources[].collector` | ✅ | 当前只有 `script` | +| `resources[].command` | ✅ | | +| `resources[].interval` | ❌ | **解析但未消费**(§12) | +| `resources[].stale_limit` | ❌ | 覆盖 daemon 默认 | + +未知键忽略;文件不存在 → `-ENOENT`;结构非法 → `-EINVAL`;均 exit 1。 + +### 4.2 启动与 CLI + +```bash +xceesd -c /etc/xce/esd.yaml -l /tmp/xceesd.log -v +``` + +| 选项 | 默认 | 说明 | +|------|------|------| +| `-c` / `--config` | `/etc/xce/esd.yaml` | 配置文件 | +| `-l` / `--logfile` | `/tmp/xceesd.log` | 日志(16MB × 4 轮转) | +| `-v` / `--verbose` | off | debug 日志 | +| `--script-timeout` | 10000 (ms) | 单次脚本采集的看门狗上限 | + +SIGINT/SIGTERM 优雅退出。 + +### 4.3 运行时行为 + +1. **join**:以 `extres` 角色 join controller(默认第一次连上的端点)。 +2. **每轮 poll**:对每个 source 执行脚本 → 解析 → 与上次值比较 + (**debounce**:值没变就不推,controller 侧 LAST_UPDATED 也不刷新——这 + 是特性不是 bug,`extres status` 的 age 才是新鲜度真值)。 +3. **stale 判定**:`now - last_ok_time > stale_limit` → STALE WARN + + status JSON 置 `stale: true`。 +4. **连接断开**:断开标志置位后每轮强制推一次(debounce 旁路),唤醒重连。 +5. **重连/rejoin**(bug-1978 修复后语义):重连成功先**重新 join**(新端点 + 的新连接不 join 直接推会被拒 `-EPERM`),然后**全量重推**所有源(不管 + 值有没有变)——controller 重启或网络抖动后池子自动恢复,无需人工干预。 +6. **status JSON**:每轮原子写(tmp + rename),字段见 §9.1。 + +### 4.4 部署约束 + +- **xceesd 必须与它的采集脚本同主机**(脚本路径按本机解析;license 场景 + 通常意味着 xceesd 部署在能访问 license 服务器的主机上)。 +- **当前支持单 xceesd 实例**(无 HA/多实例去重),见 §12。 +- 当前版本全局腿**只支持"一个资源名 = 一个全局池"**。若需要"部分主机共享 + 一个池"(host 子集池),见 §12。 + +--- + +## 5. 采集脚本契约 + +xceesd 和 elim 共用同一套脚本解析逻辑(`src/esd/src/esd_script.c` 与 +`src/worker/src/elim.c`),契约如下: + +### 5.1 输出格式 + +stdout 打印**一行**,两种形式都接受: + +``` +extlic 3 # " " +3 # 裸值 +``` + +解析器是 token 跳跃式:把 stdout 按 token 拆,跳过非数字 token 找到第一个 +`strtod` 可解析的段作为值——所以 `value is 3` 也行,`extlic 3` 和 +`3 extlic` 都能解析。stderr 丢弃(xceesd)或 /dev/null(elim),不影响 +结果。无输出 / 输出无数字 → 采集失败(isnan),走 fail-open 路径。 + +### 5.2 运行环境 + +- 由 daemon 用 `posix_spawn("/bin/sh -c ")` 执行(**禁 fork**, + daemon 多线程环境);脚本继承 daemon 的环境变量。 +- **看门狗**:脚本超过 `--script-timeout`(默认 10 秒)被 SIGKILL,按失败 + 处理。轮询切片 100ms(poll(2)),daemon 主循环不阻塞。 +- **脚本返回的是 free 值**(还剩多少可用),不是 total、不是 inuse——见 + §1.2。 + +### 5.3 脚本编写要点 + +```bash +#!/bin/sh +# 例:从 license 服务器拿剩余 license 数。 +# 失败路径务必输出一个保守值(如 0)或什么都不输出—— +# 输出垃圾数字会污染池子。 +free=$(query_license_server extlic 2>/dev/null) || exit 1 +echo "extlic $free" +``` + +- 脚本自身要快(看门狗 10s);license 服务器查询建议加超时。 +- 失败时 `exit 1` 且**不输出数字**,比输出错误猜测值安全(fail-open 会保 + 旧值,不会拿错误值覆盖)。 +- 别往 stdout 打任何诊断信息(日志去 stderr)。 + +--- + +## 6. 主机腿:elim 采集器 + +### 6.1 配置 + +worker 节点上的 `/etc/xce/xcecd.yaml`(或 `-c` 指定的文件): + +```yaml +external_resources: + - name: dynres1 + command: /opt/xce-denix/bin/elim-dynres1.sh + interval: 30 # 秒;0(或省略)= 每个 send tick 都重跑脚本 +``` + +- `name`/`command` 必填;`interval` 可选,默认 0。 +- 文件不存在或缺必填字段:xcecd **启动即退出**(fail-loud-at-load, + stderr 报 `-ENOENT`/`-EINVAL`)——坏配置不会静默空跑。 +- 上限 `ELIM_MAX_SOURCES = 16` 个源/主机。 + +### 6.2 与内置 load index 的关系 + +elim 源注册进 worker `resource_sync` 的 load-index 注册表(与 11 个内置 +指标 r15s/r1m/r15m/ut/pg/ls/it/swap/mem/tmp/io 同一张表),随既有的 +15-tick(15 秒)上报链推给 controller。注册顺序上先保证注册表容量就绪 +(`resource_sync_ensure_builtin_load_indices`),避免 capacity-0 拒绝。 + +**名字冲突保护**: + +- 撞内置名(`mem`/`r15s`/`tmp`/…)→ `-EEXIST`,xcecd 启动报错退出。 +- 撞已注册的 elim 名 → 同样 `-EEXIST`。 + +### 6.3 interval 缓存语义 + +elim 的 `interval` 与 xceesd 的**不同**:它是**脚本重跑的门控**,不是推送 +节奏: + +- `interval: 0`:每次收集(每 15s tick)都重跑脚本。 +- `interval: N`(>0):距上次成功执行不足 N 秒时,直接复用缓存的脚本 + 返回值,跳过执行;推送节奏仍固定 15s tick。 + +即 elim 源的**上报节奏恒为 15s**,`interval` 只控制脚本执行频率(省脚本 +开销)。 + +### 6.4 失败语义(与 xceesd 的差异) + +elim 的 fail-open 路径有两档(`elim_getter_run`): + +| 场景 | getter 返回 | 生效值 | +|------|-------------|--------| +| 从未成功过 + 本轮失败 | `"0"` | 0(池子从零开始) | +| 有过旧值 + 本轮失败 | `""` | 0(**值归零**,非保旧值) | +| 从未成功过 + 从未失败 | 无值(不注册前不收集) | — | + +> ⚠️ 注意:elim 失败时**值归零**而非保旧值——这与 xceesd 的"保旧值 + +> STALE"不同。主机腿的语义是"这台主机的量现在拿不到,就当没有"。调度侧 +> 效果是申请该主机资源的作业 PEND(fail-conservative),不会超发。 +> interval>0 的源在缓存窗口内的"失败"其实不会发生(根本没跑脚本);一旦 +> 窗口过后重跑失败,缓存值即被置零。 + +--- + +## 7. 策略层:limits 配额 + +外部资源可以进 limits.yaml 做按项目/队列的配额(E2 Phase 3): + +```yaml +limits: + - name: extlic_share + projects: + members: [projA, projB] + resname: extlic + value: 3.0 # projA+projB 合计最多 3 个 + per_project: true # → 每项目各 3 个 + + - name: extlic_qdist + queues: + members: [normal, night] + resname: extlic + value: 4.0 + per_queue: true # → 每队列各 4 个 +``` + +机制:limit 实例化为一个**虚拟资源**(vres),名字格式 +`limit=[/u=][/q=][/p=]`(只有对应 per_* 打开的 +段会出现),flags = `LIMIT|CONSUMABLE|RELEASE|SHARED|GLOBAL`。作业提交时 +按其 project/queue 挂上对应 vres 的 rusage 需求,调度时同样走 GLOBAL 池 +准入判定。 + +- 超限时 PEND,pending reason: + - 全局 limit 超限 → `LIMIT_GLOBAL`(值 4) + - 底层资源不足 → `NO_RESOURCE`(值 2) +- 项目不在任何 limit 的 `projects.members` 里 → **不受该 limit 约束**,只 + 受底层池容量约束(某项目免疫配额是特性)。 +- `limits.yaml` 的 `projects.members` 不做 catalog 校验(无 projects.yaml + 强校验),写错项目名不会在 build 时报错——测试时注意。 + +schema(`src/configs/schema/limits.schema.json`):`name`/`resname`/`value` +必填;`percent` 0-1;`per_project`/`per_queue` 等布尔。 + +--- + +## 8. 作业提交 + +### 8.1 基本语法 + +```bash +# 全局 license 池 +xce jobs submit -q normal -P projA -R "rusage[extlic=1]" -- sleep 3600 + +# 主机 elim 资源 +xce jobs submit -R "rusage[dynres1=15]" -- sleep 3600 + +# 多资源、per 类型 +xce jobs submit -R "rusage[extlic=1, mem=1024:per_host]" -- sleep 3600 +``` + +### 8.2 ⚠️ 必须用方括号 + +`rusage[...]` 的方括号是强制的。圆括号 `rusage(extlic=1)` 会走 select 语法的 +回退解析路径被吞掉,导致整个提交请求硬失败(bug-1887 家族)。手册级规则: +**永远 `rusage[...]`**。 + +### 8.3 per 类型语义 + +`rusage[res=N:per_TYPE]` 的 TYPE: + +| per 类型 | 语义 | 值计算 | +|----------|------|--------| +| `per_task`(默认) | 每任务槽 | N × nslots | +| `per_job` | 每作业一份 | N | +| `per_host` | 每主机一份 | N × nhosts | + +省略 per 类型时继承 caller 默认(`PER_DEFAULT` = per_task)——即 +`-R "rusage[extlic=1]"` 单槽作业申请 1,4 槽作业申请 4。 + +`duration=N[s|m|h]` 和 `decay=0|1` 目前**可解析但只进指纹/展示,不参与 +准入算术**(§12)。 + +### 8.4 pending reason 解读 + +- `NO_RESOURCE` (2):底层池不够(xceesd 没推值/池被占满),detail 会带资源名。 +- `LIMIT_GLOBAL` (4):limit vres 配额超限。 +- CLI 当前 pending_reason 列可能显示 `-`(展示链缺口,§12);pending + reason 的完整内容(reason 值 + detail 资源名)在 apiserver JSON + (`GET /xce/v1/jobs` 的 pending_reason 字段)与调度器日志里可见。 + +--- + +## 9. 运维观测 + +### 9.1 xceadmin extres status + +```bash +xceadmin extres status +``` + +读本机 status JSON(默认 `/var/run/xce/esd-status.json`,或 esd.yaml 的 +`status_file`)。**该命令只读本机文件**——多节点部署时要 ssh 到 xceesd +所在节点执行(§12)。 + +输出解读(真实渲染格式,`_format_extres_status`): + +``` +daemon: xceesd (pid 12345) +started at: 2026-10-28T10:00:00 (up 3600s) +pushes: 350 ok, 2 failed +sources: + extlic: value=3 age=12s +``` + +- `value=V age=Ns`:V = 最后成功采集的值;age = 距该值的秒数。 +- `-- STALE` 后缀:超过 stale_limit,源值得怀疑(xceesd 还在推旧值,调度 + 侧继续放行——这时要人工介入看脚本/license 服务器)。 +- `extlic: no value yet`:daemon 起来后从未成功采到值(池子视为 0,作业 + PEND)。 +- `sources: (none configured)`:esd.yaml 没配 resources。 +- `pushes: N failed` 持续增长:controller 连接问题(对照 §10.2/§10.3)。 +- 另有 `--status-file PATH` 选项覆盖默认路径。 + +status JSON 完整字段:`daemon`/`pid`/`started_at`/`generated_at`/ +`pushes_ok`/`pushes_fail`/`sources[{name, last_value, last_ok_time, stale}]`。 + +### 9.2 资源定义与值观测 + +```bash +# 定义(-l/--verbose 显示 detail 列) +xce resources definition list -l +# RESOURCE: extlic +# TYPE ORDER INTERVAL LAST_UPDATED BUILTIN DYNAMIC RELEASE CONSUMABLE +# Numeric Decr 0 Oct 28 14:04 false false false true +# LAST_UPDATED:"-" = 从未推过;EXTERNAL 资源随推送刷新(debounce:值没变 +# 不推,稳定值下 LAST_UPDATED 也长期不动——新鲜度以 extres status 的 +# age 为准);elim 资源定义 LAST_UPDATED 一般也是 "-"(值走 distribution) + +# 分布(-t static=配置声明的静态值,-t dynamic=live 推送值;全局资源的 +# LOCATION 列显示 "-",主机资源显示主机名) +xce resources distribution list -t dynamic +# dynres1 22 t2 +# extlic 4 - + +# 主机视角(每个主机一节的 CURRENT LOAD 表只渲染 12 个内置列 +# r15s..slots——elim 自定义指标**不**出现在这个表里;主机腿的值 +# 用上面的 distribution list -t dynamic 看主机名落点) +xce hosts list -a +``` + +--- + +## 10. 故障处理 + +### 10.1 xceesd 掉线(进程挂/主机挂) + +**现象**:作业 PEND,pending reason `NO_RESOURCE`(detail 带资源名); +`extres status` 读不到(文件陈旧)或 pushes 不再增长。 + +**语义**:xceesd 掉线后没有新值进来,但**旧值仍在**(controller 的 +sharedInst 不会因 daemon 掉线自动清零)。调度器会继续按旧值放行作业—— +这是有意的 fail-open:license 服务器真实状态未知时,按最后已知值走。 +风险窗口靠观测面发现:`extres status` 的 age 持续增长、超过 stale_limit +后 STALE 标记出现(status 文件本身随最后一轮 poll 冻结,不会再更新)。 + +**处理**: + +```bash +# 1. 到 xceesd 主机看进程/日志 +ps aux | grep [x]ceesd +tail -50 /tmp/xceesd.log + +# 2. 重启(会自动 rejoin + 全量重推,池子自动恢复) +xceesd -c /etc/xce/esd.yaml -l /tmp/xceesd.log & +``` + +### 10.2 controller 重启 + +xceesd 检测到连接断开会自动 re-join + 全量重推(bug-1978 修复的语义), +**无需重启 xceesd**。确认方式:`extres status` 的 pushes ok 继续增长。 + +**但调度器要重启**:调度器 rejoin 对 sharedInst 的重建有已知问题 +(stale join 卡死),controller 重启后按标准流程**调度器随 controller 一并 +重启**(既有运维约束,不特属于本特性)。 + +### 10.3 config push 之后 + +1. **重启 apiserver**(定义 store 只吃 join 时 config push)。 +2. 调度器激活 re-push 会刷新 live 定义,但稳妥起见**重启 scheduler**。 +3. xceesd **无需**重启(它推值走的是 live 通道,与定义 store 无关); + 若新资源是第一次出现,xceesd 下一次推送即落账。 + +### 10.4 推送被拒(opupd -EPERM) + +`/var/log` 下 controller 日志出现 extres 值被拒:通常是 esd.yaml 里的 +`name` 与 resources.yaml 声明的 location 不匹配——extres 角色推的值只收 +**SHARED** 资源("SHARED 即收"守卫)。检查:resources.yaml 是否 +`location: [external]` 或 `[all]`,config push + apiserver/scheduler 重启 +是否做完。 + +### 10.5 采集脚本挂死 + +看门狗 10 秒 SIGKILL,按失败处理,走 fail-open(保旧值 + WARN)。若脚本 +持续挂死,stale_limit 后升级 STALE。排查:脚本是否在等 license 服务器 +响应无超时——**给脚本内部的网络调用加超时**,别依赖看门狗兜底。 + +--- + +## 11. 测试指南(E2E 配方) + +以下配方在 t1/t2/t3 测试集群实测通过(2026-10-27/28)。用假脚本即可做全 +链路验证,不依赖真实 license 服务器。 + +### 11.1 准备 + +```bash +mkdir -p /tmp/e2e +``` + +**假 license 脚本** `/tmp/e2e/fake_lic.sh`——用"活 pid marker 文件池"模拟 +license 占用:`held.` marker 的进程活着 = 一份 license 被持有;进程 +死了(TERM/KILL/exit 任何方式)`/proc/` 消失,下次读取自动回收, +计数永不漂移。TOTAL=4: + +```bash +#!/bin/bash +# Fake license collector. Pool = marker files held.; a license is held +# iff its pid is alive. Death (TERM/KILL/exit) releases automatically, so the +# count can never drift. Stale markers self-clean on every read. +DIR=/tmp/e2e/licpool +TOTAL=4 +mkdir -p "$DIR" +free=$TOTAL +for f in "$DIR"/held.*; do + [ -e "$f" ] || continue + pid=${f##*.} + if [ -d "/proc/$pid" ]; then + free=$((free-1)) # alive (works cross-user; kill -0 confuses EPERM with ESRCH) + else + rm -f "$f" # gone -> clean up marker + fi +done +echo "extlic $free" +``` + +**占坑 wrapper** `/tmp/e2e/licjob.sh`——作业本体包一层:占一份 license +(lock 目录互斥 + `/proc` 活性清扫),占不到 exit 99,占到了就执行真正的 +作业命令。作业进程死亡 = marker 自动失效,**无需 trap 清理**: + +```bash +#!/bin/bash +# Fake license checkout: hold 1 license for the duration of "$@". +# The license IS a pid-keyed marker file; holding ends when this process dies +# (any way, incl. SIGKILL). No trap needed — accounting is liveness-based. +DIR=/tmp/e2e/licpool +TOTAL=4 +mkdir -p "$DIR" +while ! mkdir "$DIR.lock" 2>/dev/null; do sleep 0.2; done +for f in "$DIR"/held.*; do + [ -e "$f" ] || continue + pid=${f##*.} + [ -d "/proc/$pid" ] || rm -f "$f" # /proc dir gone = truly dead (works cross-user; +done # kill -0 cannot distinguish EPERM from ESRCH) +count=$(ls "$DIR"/held.* 2>/dev/null | wc -l) +if [ "$count" -ge "$TOTAL" ]; then + rmdir "$DIR.lock" + echo "licjob: no license available (free=0)" >&2 + exit 99 +fi +if ! echo $$ > "$DIR/held.$$" 2>/dev/null; then + rmdir "$DIR.lock" + echo "licjob: cannot write marker in $DIR (perm?)" >&2 + exit 99 +fi +rmdir "$DIR.lock" +echo "licjob: checked out 1 license (free now $((TOTAL - count - 1)))" +"$@" +exit $? +``` + +用法是**包住作业命令**:`-- /tmp/e2e/licjob.sh sleep 3600`(licjob 占 1 份 +license,然后跑 `sleep 3600`;licjob 进程活着多久,license 持有多久)。 + +> ⚠️ 两个实测过的坑: +> 1. 活性检查用 `/proc/` 目录存在性而非 `kill -0`——后者在跨用户 +> (worker 以别的 uid 跑作业)时把 EPERM 当活着,死 marker 永不回收。 +> 2. 不要用 SIGSTOP 冻结作业进程来"保持占用"——冻结进程的 marker 照样被 +> 认活,但 kill 回收语义就测不到了;回收测试直接 kill。另外 SIGSTOP +> 冻结 status JSON 文件本身会让 `extres status` 读到假 false。 + +**esd.yaml 测试样例** `/tmp/e2e/esd.yaml`: + +```yaml +controller: "172.31.1.30:9990" +poll_interval: 10 +stale_limit: 60 +status_file: /tmp/e2e/esd-status.json +resources: + - name: extlic + collector: script + command: /tmp/e2e/fake_lic.sh extlic +``` + +### 11.2 场景 1:全局 license 池 E2E + +前置:resources.yaml 声明 `extlic`(§2.1),config push + 重启 apiserver/ +scheduler,xceesd 跑在 t2(脚本同主机)。 + +**记账模型先读一遍**(配方为什么这样设计):调度器侧的账与 fake 池的账是 +两本—— + +- 调度器侧:EXTERNAL 只看推送来的 total ≥ uncomit + wanted。`uncomit` 是 + 每次分配尝试的暂存簿(作业派发即清),RUN 作业的占用**不**进调度器准入 + ——靠自由值下降体现(§1.2 跨作业记账分工)。 +- fake 池侧:licjob 进程活着 → 1 个 marker → free 降 1。**每个 licjob + 进程占 1 份,不管 rusage 申请多少**。 + +所以用 rusage[extlic=3] 的作业时:调度器第一轮 total=4 ≥ 3 放行,之后 +推送值降到 3(1 个 marker),后续作业按 3 算。想要"精确对账"的场景用 +rusage[extlic=1] 的作业最干净(调度器扣 1、marker 扣 1,两本账一致)。 + +```bash +# 1. 池子在线 +ssh t2 'xceadmin extres status' +# 期望:value=4 (TOTAL),age 正常,无 STALE + +# 2. 大块申请 + 超额(两本账的暴露窗口演示) +xce jobs submit -q normal -P projA -R "rusage[extlic=3]" -- /tmp/e2e/licjob.sh sleep 3600 +# 期望:RUN(第一轮 total=4 ≥ 0+3) +xce jobs submit -q normal -P projA -R "rusage[extlic=2]" -- /tmp/e2e/licjob.sh sleep 3600 +# 紧跟着提交(自由值还没降):total=4 ≥ 0+2 → RUN 也可能拿到; +# 但 fake 池只剩 4-1-1=2,licjob 拿不到 marker 会 exit 99 → 作业 EXIT。 +# 这正是真实 license 服务器 checkout 失败的等价物。 +# 隔一个 poll_interval 再提交,则看到 total=3(marker 已入账), +# 3 ≥ 0+2 → RUN(还能容纳);free 再降到 2。 + +# 3. 占满 → PEND(最干净的对账场景:4 个 rusage[extlic=1]) +for i in 1 2 3 4; do + xce jobs submit -q normal -P projA -R "rusage[extlic=1]" -- /tmp/e2e/licjob.sh sleep 600 + sleep 12 # ≥ poll_interval,让每次占用都先落到推送值里 +done +# 期望:4 个 RUN,free 一路 3→2→1→0 +xce jobs submit -q normal -P projA -R "rusage[extlic=1]" -- /tmp/e2e/licjob.sh sleep 600 +# 期望:PEND,reason NO_RESOURCE,detail 带 extlic + +# 4. 回填(再准入) +# kill 一个 RUN 作业 → marker 回收 → free=1 → 第 5 个作业 RUN + +# 5. 恒超额 +xce jobs submit -q normal -P projA -R "rusage[extlic=5]" -- /tmp/e2e/licjob.sh sleep 60 +# 期望:永远 PEND(TOTAL=4 < 5) +``` + +### 11.3 场景 2:elim 主机腿 E2E + +前置:`/opt/xce-denix/bin/elim-dynres1.sh`(按主机名给值:t1=11/t2=22/ +t3=33),xcecd.yaml 配 `external_resources`,三节点都重启 xcecd。 + +```bash +# 1. 落值 +xce hosts list -a # t2 的 dynres1 列应显示 22 + +# 2. 定向申请 +xce jobs submit -R "rusage[dynres1=15]" -- sleep 300 +# 期望:RUN 且落 t2(t1=11/t3=33 不够 15) + +# 3. 超额 PEND + kill 回填 RUN(同场景 1 步骤 3-4) +``` + +### 11.4 场景 3:per-project 配额 E2E + +前置:limits.yaml 配 `extlic_share`(projA/projB,per_project,value 3), +config push + 重启 apiserver/scheduler。注意 limit vres 是**非 EXTERNAL** +的 GLOBAL 资源,配额在调度器本地即时扣减,无需等推送——同一轮内连发也 +严格生效(§1.2)。底层池 TOTAL=4 够用(每个 licjob 占 1 marker,3 个 +RUN 作业占 3 < 4,不会被池子先卡住)。 + +```bash +# projA 连发 4 个单 license 作业 +for i in 1 2 3 4; do + xce jobs submit -q normal -P projA -R "rusage[extlic=1]" -- /tmp/e2e/licjob.sh sleep 600 +done +# 期望:前 3 个 RUN,第 4 个 PEND(LIMIT_GLOBAL,detail 带 +# vres 名 limit=extlic_share/p=projA) + +# kill 一个 → 第 4 个 RUN(配额再准入) + +# projB 免疫性 +xce jobs submit -q normal -P projB -R "rusage[extlic=1]" -- /tmp/e2e/licjob.sh sleep 600 +# 期望:RUN(projB 有自己的 3 个配额,与 projA 用量无关) +``` + +### 11.5 回归清单 + +提交相关改动前跑: + +```bash +# C 侧单元/白盒(meson test 名:"esd core test" / "worker elim test" / +# "worker resource_sync test";调度器 suite 在 src/scheduler) +ninja -C build.dbg +meson test -C build.dbg 'esd core test' 'worker elim test' 'worker resource_sync test' + +# Go 侧 +cd src/frontend && make test # 含 config dump external 渲染等 +``` + +相关测试文件:`src/esd/testsuite/test_esd_core.c`(配置/解析/stale/重连 +flag/status 写)、`src/worker/testsuite/test_elim.c`(配置/解析/注册表/ +看门狗/interval 缓存)、调度器侧 EXTERNAL 判式与 limit vres 白盒测试。 + +--- + +## 12. 已知限制 + +| # | 限制 | 说明 | +|---|------|------| +| 1 | 单 xceesd 实例 | 无 HA、无多实例去重;xceesd 挂了池子停在旧值(fail-open),观测靠 STALE | +| 2 | 无手动覆盖入口 | 没有改池值的 REST/CLI(调试时只能改脚本输出) | +| 3 | host 子集池不支持 | 全局池是全集群一份;"t1/t2 共享 5 个 license、t3 另有 2 个"需拆成两个资源名 | +| 4 | esd per-source `interval` 未消费 | esd_config 解析了它,但 poll 循环只用 daemon 级 `poll_interval`——每轮都跑脚本。想省脚本开销就调大 poll_interval | +| 5 | `duration`/`decay` 不参与准入 | rusage 语法可解析,当前只进作业指纹/展示 | +| 6 | FlagValue.ToJSON 无 external 键 | apiserver JSON 里看不到 EXTERNAL bit;用 `xceadmin config dump` 的 `external=True` 判断 | +| 7 | extres status 仅本机 | `xceadmin extres status` 读本机 JSON 文件;多节点时需 ssh 到 xceesd 节点 | +| 8 | pending_reason CLI 显示 `-` | CLI 侧 pending reason 展示链有缺口(已知遗留),用 describe/JSON 看 | +| 9 | 调度器 stale join | controller 重启后调度器 rejoin 有已知问题,标准流程是调度器随 controller 重启 | +| 10 | limits 项目名无校验 | limits.yaml 的 `projects.members` 不做 catalog 校验,拼写错误静默不生效 | + +--- + +## 附:快速上手 checklist + +```bash +# 1. resources.yaml 声明 extlic(location: [external],无 value) +# 2. xceadmin config build && config push +# 3. 重启 apiserver + scheduler +# 4. 部署 xceesd(esd.yaml)到 license 采集主机,脚本同主机 +# 5. xceadmin extres status 确认 value 非 "no value yet" +# 6. xceadmin config dump 确认 external=True +# 7. xce jobs submit -R "rusage[extlic=1]" -- sleep 60 → RUN +```