myxce/scheduler-hardening-plan.md
lei.chen 78a7bae5ed docs: scheduler rejoin hardening plan (phased, P0-P4)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 04:09:49 +00:00

225 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Scheduler 强化计划(rejoin 路径与状态管理)
> 生成时间:2026-10-29。方法:rejoin 全链路源码精读(controller join 处理 → scheduler 接收/合并 → 每轮状态重推导 → 线程契约 → 快照重建先例 → 回放验证基建)+ buglog/测试套件盘点。
> 范围:只针对 scheduler daemon 的强化。执行方式:分阶段(Phase 0-4),每阶段独立 TDD 红→绿 + t1 活体验证后推进。
> 前提事实:buglog 1971-1980 全部 fixed,当前零 open bug —— 本计划是**结构性加固**(把补丁堆固化成不变量),不是修 bug。
---
## 0. 问题定位:为什么 rejoin 是最弱的一环
### 0.1 现状:热合并 + 补丁堆
scheduler 与 controller 断连后重连,走 in-place rejoin:`xcecli_join(HostRole_scheduler)` → controller 全量推送(configs→workers→jobs,sched.c:798-800 定序,commit 水位门 egress.c)→ scheduler 把推送**逐条 MERGE 进活体状态**(core_events.c JOBS_PUSH/WORKERS_PUSH/RESVALS_FULL)。
这个合并语义是历史上 bug 最密集的区域,已积累 14 个修复:
| bug | 合并点 | 修的是什么 |
|---|---|---|
| 1513 | config 应用时机 | handler 线程内联应用 config 与 job 事件竞态 → stash 后轮间应用 |
| 1568 | WORKERS_PUSH | 重建改 upsert(保 resInUse 等派生量) |
| 1569 | QUEUE_UPDATE | picker 的 on_queue_update 回调漏触发 |
| 1714 | JOBS_PUSH | 重复 jobid 去重 |
| 1734 | JOBS_PUSH | 重复 jobid 从丢弃改合并 |
| 1738 | JOBS_PUSH | 终态不可回退(controller ground truth) |
| 1739 | JOBS_PUSH | RUN 重投递必须带真实 allocs(幻引用拒绝) |
| 1751 | begin_session | reset 跳过 UNAVAIL/RECONCILING worker |
| 1799/1801 | reset/charge 对称 | 两者跳过条件不对称 → resInUse 每轮净增一次满额(活体见过 used=26/22 slots) |
| 1814 | rusage-delta | 第三条 resInUse 变异路径同样必须跳过 RECONCILING |
| 1841 | RECONCILING 清位 | resolve 后必须通知,否则 scheduler 侧冻结永久化 |
| 1846 | config push | 选举周期内 hlimits 重复积累 |
| 1852 | 事件序 | 记录先于应用(回放保真) |
| 1860 | JOBS_PUSH | 非终态以 controller 为准(允许状态回归) |
| 1884/1886 | config 重载 | core 持有 config 所有权;队列原地更新 |
**每个修复都是对"增量合并一个本应是全量快照的推送"这个根本形态的局部补丁。** 形态不改,第 15 个 bug 只是时间问题。
### 0.2 目标形态:代码库里已有两次示范
1. **snapshot_load.c(485 行)**——"全量状态源 → 单路径原子重建"的完整先例:config→groups→resources→shared resvals→queues→workers→users→projects→jobs 一次建齐,最后 `sched_account_jobs` 从零记账一次(幂等)。没有任何 merge 分支。
2. **core_session.c 的 reset+re-derive(bug-1799 模式)**——每轮 `begin_session` 无条件清 sharedInUse/resInUse/cache/host_eval_ctx,然后 `sched_account_jobs` 对每个 RUN 作业重新充电。派生状态**从不存活、永远重推导**。
**核心论点:rejoin 应该从"热合并"重定义为"原子丢弃派生状态 + 以 controller 推送为全量状态源重建,状态级等价 fresh join"。** 合并语义只保留给真正的增量(健康会话中的 JOB_UPDATE/WORKER_UPDATE/RESVALS delta),全量同步走重建。
### 0.3 历史注记
`scheduler-primary-lifecycle`(controller 拉起 scheduler 子进程)spec 已 superseded,继任 `xce-node-manager` spec(requirements/design 已批)tasks 从未生成、零实现。本计划针对**现役 in-place rejoin 路径**,node-manager 保持休眠方向不作依赖。
---
## Phase 0 — 结构性回归锁死(零行为变更,纯测试)
> 目标:把三个"靠人肉记住"的跨文件契约变成会报警的测试。全是最便宜的一档,先行锁住地基,Phase 2 动刀时才有参照系。
### 0.1 RECONCILING 三路互斥测试
**契约**:所有触碰 resInUse 的路径——(a) `core_reset_workers`(core_session.c,reset 跳过)、(b) `sched_account_jobs`/`sched_update_account`(resource.c,充电跳过)、(c) `sched_reconcile_host_resource_usage`(rusage-delta,bug-1814)——对 UNAVAIL/RECONCILING worker 的跳过条件必须逐字对称,否则就是 bug-1799 复发。
**现状缺口**:`test_host_unavail_reconciling.c` 只断言 job state,`test_core_session.c` 只测 reset 跳过本身;没有任何测试同时走三条路径断言账目不变。
**做法**:新测试 `test_res_account_symmetry.c`——构造含 UNAVAIL 与 RECONCILING worker 的 core + RUN 作业 → 连跑 3 轮 begin_session/account/rusage-delta → 断言所有 worker 的 resInUse 与 sharedInUse 恒等(不多充一分)。反证组:对称破坏注入(临时改一处 skip)时测试必须红。
### 0.2 rejoin 触发条件契约测试
**契约**(scheduler.c:1871-1889):`sched_check_pending_rejoin` 在 (a) `sched_needs_rejoin` flag 置位 或 (b) `xcecli_is_connection_broken()`(bug-1760 零探测判式)任一成立时发起 join;两条路都要覆盖。
**做法**:扩展现有 `test_scheduler_connect.c`(它已有 connect/retry 骨架)补两个用例:flag-only 触发、broken-only 触发,断言 `xcecli_join` 恰好调一次且 flag 清除。
### 0.3 reset+charge 对称性活体测试
与 0.1 呼应但独立:同一 core 连续两轮 session(中间无事件),断言账目零增长。这是 bug-1799 的最小复现形状——当时活体上跑了两周才被看见,这个测试让它变成 CI 秒杀。
**验收**:`/xce-build scheduler` 全绿;三个新测试在注入破坏时各自变红(手工验证一次后还原)。
**工作量**:1 个会话(纯 testsuite/,不碰 src)。
---
## Phase 1 — bug-1521 线程契约固化(防回归收尾)
> 目标:bug-1521(picker 线程写 `job->schedobj->reason` 与 allocator 线程 MOVE_PTR 竞态 → 堆损坏)的实质修复已在 91c3fb7f 落地——所有 reason 挂接收敛到 allocator 线程。但这个契约今天只活在代码注释和 commit message 里,没有任何机制阻止新人 reintroduce。
**契约内容**:
1. `obj->reason`/`job->schedobj->reason` 的**所有权写入**(calloc/attach/MOVE_PTR)只允许发生在 allocator 线程上下文:scheduler.c:335-340、:426-431(`sched_make_primary_allocation`/`sched_make_compound_alloc_` 内的 `free_PendReason_ptr` + `MOVE_PTR`)。
2. picker 线程(sched_election.c:136 `sched_job_picker`)只推 jobid 到 jbuf(容量 1 rendezvous 是确定性不变量,永不动)。
3. `limit.c:295` 的 `ctx.reason` MOVE_PTR 是 dead-by-construction(上游永远不挂 reason),保留 NULL check 形状但注释已写明。
**做法**(两层):
1. **结构扫描测试**(仿 `test_picker_pendreason_factory_audit.c` 的固定文件清单同线判定的成熟形态):扫描 `obj->reason`/`schedobj->reason` 的写入点清单是封闭集合,新增写入点必须显式更新清单并注释线程归属——清单外出现写入即红。
2. **steering 文档固化**:在 `.kiro/steering/` 新增或并入现有文件一节"reason 所有权与线程归属",把 91c3fb7f 的纪律写成规范(谁分配、谁 free、哪个线程、MOVE_PTR 语义)。
**验收**:结构测试对人为注入的 picker 线程写入变红;`/xce-build scheduler` 全绿。
**工作量**:1 个会话。
---
## Phase 2 — rejoin 重建工程(本计划核心)
> 目标:把 (re)join 时的全量推送从"逐条 merge 进活体"改为"一次性原子重建",状态级等价 fresh join。这是唯一动行为的阶段,也是收益最大的一档——0.2 节那 14 个修复所守护的合并语义,从此只服务真正的增量事件。
### 2.1 语义定义(先定契约再写码)
**状态三分法**(重建正确性的关键):
| 状态类别 | rejoin 时怎么处理 | 依据 |
|---|---|---|
| **Ground truth(controller 推送来的)**:config/groups/queues/resources/shared resvals/workers 状态+resvals/jobs 状态+allocs | **丢弃本地副本,从推送全量重建** | controller 是唯一 ground truth(bug-1860 已确立此原则,重建只是把它执行彻底) |
| **派生状态**:sharedInUse / per-worker resInUse / unsatisfiable cache / host_eval_ctx / sel_eval_ctx | **无条件丢弃**,重建后由 begin_session + sched_account_jobs 重推导 | bug-1799 模式的推广:派生量从不存活 |
| **插件自有状态**:limit plugin 的 privdata->limits(随 config 重建)/ privdata->instances(vres 用量实例,跨 config 重载存活) | limits 随重建 config 重构;instances 走既有 `rebind_instances_` 保活 | bug-1884 已验证实例跨重载绑定的正确性 |
**时序契约**(消除半新半旧窗口):
1. 断连检测(`sched_on_reconnect` 置 flag,不许 I/O——客户端库 failover lock 下回调的既有约束不动)。
2. `xcecli_join` 成功 → 进入 **REBUILD_PENDING**:此间正常轮继续跑(可用性优先,调度本来就跑在本地副本上),但 **本会话内到达的 stash config / pend_updates 全部挂起不入 core**——重建即将整体替换它们,先应用再重建是白费且引入窗口。
3. 推送三件套(configs→workers→jobs)**全部到齐**(顺序固定,jobs 到达即视为完整)→ 在轮间(与 sched_apply_pending_config 同一位置)调用新的 `xce_core_rebuild_from_push(...)` 原子重建。
4. 重建完成 → 清 REBUILD_PENDING → 后续增量恢复既有 merge 路径。
**新 core 入口**:`xce_core_rebuild_from_push(core, config*, workers*, jobs*, shared_resvals*)`——内部完全镜像 snapshot_load.c 的加载序(config→groups→plugin config_cb→config 指针交换+configver++→resources→sharedInst→queues→workers→jobs→`sched_account_jobs` 一次)。**放新文件 `core_rebuild.c`**(800 行上限纪律;snapshot_load.c 的 485 行是形参)。
**边界情形**(设计时逐条过):
- **在途 dispatch**:断连时已决策未 commit 的 dispatch——请求随连接死掉,controller 从未 commit,重建后该 job 回 PEND 重新调度。正确行为,无需特判。
- **join 后到达的 dispatch commit**:重建后 controller 侧 hook(sched.c notify, HOOK_AFTER_EVENT)推来的 JOB_UPDATE 落到增量 merge 路径——RUN 带真实 allocs(bug-1739 契约不变)。
- **commit 水位扣留的作业**:submit_lsn > committed 的 job 不在 push 里,随后 commit 时经 JOB_NEW hook 补达——Phase 4.3 补测试锁行为。
- **DONE 作业**:check_job_ 是透传(sched.c:241),push 含终态作业;重建照收(60s GC 既有路径清理)。
- **重建失败**(分配失败等):fail loud—— loud ERROR + 保持断连前状态 + 重新 join 重试(对齐 controller 的 fail-loud 纪律;绝不半重建)。
### 2.2 实施步骤(TDD)
| 步 | 内容 | 红→绿 |
|---|---|---|
| 2.2.1 | `test_rejoin_rebuild_equivalence.c` 骨架:同一推送序列分别灌入 (A) 全新 core(fresh join 基准)与 (B) 带脏状态(既有 jobs/workers/账目/cache)的 core 走重建入口 → **全状态相等断言**(jobs 字段级、workers 状态+resvals、queues、sharedInst、派生量从零重推导后的账目)。先红(入口不存在) | 红 |
| 2.2.2 | 实现 `core_rebuild.c` 的 `xce_core_rebuild_from_push`(镜像 snapshot_load.c 序) | 2.2.1 绿 |
| 2.2.2b | REBUILD_PENDING 状态机测试:pending 期间 stash/pend_updates 不入 core、到齐触发重建、重建后增量恢复 | 红→绿 |
| 2.2.3 | scheduler.c join 路径接线(flag → REBUILD_PENDING → 重建 → 恢复);scheduler.c 已 2121 行**不许再长**,join 状态机部分顺势拆出到 `sched_rejoin.c`(机会性拆分,符合 800 行纪律) | 集成绿 |
| 2.2.4 | 既有 merge 回归全部重跑(test_core_events/test_config_push_deferred/test_scheduler_decision_redelivery 等)——证明增量路径零回归 | 全绿 |
| 2.2.5 | t1 活体验收:作业提交→RUN→kill / 断连(kill controller 侧连接或 iptables 断)→rejoin→状态核对(xceadmin/xcecli 双向)→全部 daemon 健康 | 活体过 |
### 2.3 非目标(明确不做)
- 不改健康会话中的增量 merge 语义(14 个修复原样保留服务增量路径)。
- 不动 jbuf 容量 1 rendezvous、不动 core 零 controller I/O 隔离纪律。
- 不重开 process-lifecycle 设计(node-manager 保持休眠)。
**风险与回退**:重建入口与 merge 路径并存,接线上用配置开关(env var,默认新路径)过渡一个版本;活体回归异常时一键切回 merge 路径。观察一个版本后删开关。
**工作量**:3-4 个会话(本计划最大单阶段;建议走一轮 kiro spec——涉及 core 状态机与 HA 收敛语义,符合 CLAUDE.md "touching HA/persistence → full workflow" 约定)。
---
## Phase 3 — 等价性验证基建(回放层)
> 目标:Phase 2 的白盒等价测试证明"重建 ≡ fresh join"在受控输入下成立;Phase 3 让它对**任意真实会话**可离线证明,并成为此后一切 rejoin 改动的验收门。
### 3.1 录制层:rejoin 边界事件
recording.c 现录 events/decisions/周期快照。补录 **rejoin 边界标记**(断连点、join 点、重建点)进 `.replay` 流(`src/schema/controller.capnp` 加字段,录制/解码双侧同步)。sim 侧 `--sim-rejoin` 播到边界时执行与 live 相同的重建逻辑。
### 3.2 验证层:双判定
扩展 sim_verify.c(现只做决策序列结构等价,job_alloc_eq + PEND 主原因 bug-1845):
1. **决策等价**:rejoin 会话与"删除断连段的干净会话"决策序列一致(断连段内允许差异——那段本来就跑在 stale 副本上,收敛发生在重建点之后)。
2. **状态等价**:重建点后的周期快照与 fresh-join 基准快照逐字段比对(状态级契约——这正是 steering 里 driver-equivalence 要求的 state-level 而非 decision-level)。
### 3.3 散点回归整合
把 test_config_push_deferred(bug-1846)、test_core_events(bug-1860/1734)、test_scheduler_decision_redelivery/publish_retry 的 rejoin 邻接用例收拢为 `testsuite/rejoin/` 专项套件(meson 子目录),一个入口跑全部 rejoin 语义。
**验收**:一段含断连的真实 t1 录制,离线 `--sim-verify --sim-rejoin` 通过;注入状态分歧(人工改录制)能报出首个分歧字段。
**工作量**:2 个会话。
---
## Phase 4 — 边界补强(独立小项,可穿插)
### 4.1 资源定义变更的 live 应用(bug-1854 遗留)
现状:RES_APPLY_CHANGED 只 WARN "restart to pick it up"。Phase 2 的重建入口落地后,资源定义重载有了干净的执行点——重启 rejoin 或 config push 后的显式重建请求即可应用新定义。决策点:config push 侧触发 vs 仅 rejoin 时应用(推荐前者,controller config_repl.c:350/451 已有 re-push 钩子位)。补测试 + 删除 WARN。
### 4.2 RECONCILING 清位通知链回归覆盖
bug-1841 已修(resync_resolve_round → persist_notify_worker_status_updated)。补 controller 侧测试:resolve 必发通知、WORKERS_PUSH 到达 scheduler 后冻结解除。防止"修了但没锁"。
### 4.3 commit 水位扣留作业的补达路径测试
join 时 submit_lsn > committed 被扣留的作业,commit 后经 JOB_NEW hook 补达 scheduler。全链白盒测试(egress 扣留 → commit → notify → scheduler 入表可调度)。
### 4.4 pending_reason "-"(独立遗留,不属 rejoin 但顺手)
CLI 显示 "-" 的显示链缺口(P1 遗留)。若 Phase 2/3 期间发现根因顺手修,不单独立项。
**工作量**:每项 0.5-1 个会话。
---
## 执行顺序与依赖
```
Phase 0 ──► Phase 1 ──► Phase 2 ──► Phase 3
│ │
│ └── 4.1/4.2/4.3 可在 Phase 2 后任意穿插
└── Phase 0 的对称性测试是 Phase 2 重建正确性的参照系,
顺序不可倒置
```
| 阶段 | 性质 | 预估 | 依赖 |
|---|---|---|---|
| Phase 0 | 纯测试 | 1 会话 | 无 |
| Phase 1 | 测试+文档 | 1 会话 | 无(可与 0 并行) |
| Phase 2 | 核心工程 | 3-4 会话 | 0(参照系)、1(线程纪律先锁死,重建引入新触点) |
| Phase 3 | 验证基建 | 2 会话 | 2(重建语义先落地才有东西可验证) |
| Phase 4 | 边界 | 穿插 | 2(4.1 依赖重建入口) |
**每阶段统一验收**:TDD 红→绿留痕;`/xce-build scheduler`(涉 controller 则 + controller)全绿;t1 活体(作业提交→运行→结束全流程 + 所有 daemon 健康——precommit 纪律);提交推 myxce。
---
## 附:本计划引用的关键代码位
- controller 推送序:`src/controller/sched.c:792-848`(on_scheduler_join)、`src/controller/egress.c`(commit 门)、`src/controller/job.c:1927-1979`(dispatch commit)
- scheduler 接收/合并:`src/scheduler/src/handler.c:151-213`(stash/enqueue)、`src/scheduler/src/core_events.c:690-1110`(JOBS_PUSH 合并语义全表)、`src/scheduler/src/driver.c:700-960`(translate/checkpoint)
- 派生状态重推导:`src/scheduler/src/core_session.c`(reset 全家)、`src/scheduler/src/resource.c:120-510`(unsatisfy_ 公式 + 三路 skip)
- 重建先例:`src/scheduler/src/snapshot_load.c:53-485`
- 线程契约:`src/scheduler/src/sched_election.c:85-417`、`src/scheduler/src/scheduler.c:300-448`
- 回放验证:`src/scheduler/src/recording*.c`、`sim_verify.c`