docs: scheduler rejoin hardening plan (phased, P0-P4)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-10-30 04:09:49 +00:00
parent a437eff2c9
commit 78a7bae5ed

225
scheduler-hardening-plan.md Normal file
View file

@ -0,0 +1,225 @@
# 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`