myxce/scheduler-hardening-plan.md
lei.chen 5269f8a5c4 docs(hardening): Phase 0 complete - 0.1/0.3 landed, 0.2 closed as already covered
Phase 0 of the scheduler hardening plan is done:
- 0.1 (three-path skip symmetry): landed as test_res_account_symmetry.c
  (b20caf8d), mutation-injection red-proven.
- 0.2 (rejoin trigger contract): audit found the contract fully covered
  by test_scheduler_decision_publish_retry.c's five rejoin tests plus
  test_scheduler_connect.c's no-io callback scan -- documented in the
  plan instead of adding duplicate tests.
- 0.3 (two-round zero drift): folded into 0.1's
  test_two_rounds_zero_drift_with_reconciling_worker, same shape.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 04:46:48 +00:00

19 KiB
Raw Blame History

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)时测试必须红。

执行结果(2026-10-30):✅ 已落地(commit b20caf8d)。三个用例全绿,注入反证完成 (移除 resource.c bug-1801 skip → 2.5→4.5,每轮 +1 charge,正是 bug-1799 形状)。 链接形态:foreach-minus-TU-list(同 sched_trace_error_paths_test),真实 plugin.c TU 供 plugin_* 符号、真实 scheduler.c TU 供 sched_account_jobs。

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 清除。

执行结果(2026-10-30):审计发现契约已被既有测试完整锁定,无需新测试—— test_scheduler_decision_publish_retry.c 的 5 个 rejoin 用例(flag-only 组合重试 test_rejoin_composition_retries_across_publish_calls、无发布直调 test_check_pending_rejoin_invoked_directly_without_a_publish、broken-only 被动触发 test_check_pending_rejoin_triggers_on_passive_broken_signal、健康零探测 test_check_pending_rejoin_no_op_when_healthy_and_no_flag、main.c 无条件调用源码扫描 test_main_loop_checks_pending_rejoin_unconditionally)加上 test_scheduler_connect.c 的 test_rejoin_callback_body_has_no_io_or_xcecli_calls(回调无 I/O 锁死)覆盖了 本节契约的全部断言,含 flag 清除语义。0.2 就此关闭,不再新增重复测试。

0.3 reset+charge 对称性活体测试

与 0.1 呼应但独立:同一 core 连续两轮 session(中间无事件),断言账目零增长。这是 bug-1799 的最小复现形状——当时活体上跑了两周才被看见,这个测试让它变成 CI 秒杀。

执行结果(2026-10-30):✅ 并入 0.1 落地——test_res_account_symmetry.c 的 test_two_rounds_zero_drift_with_reconciling_worker 即此形状(两轮无事件,账目 恒等),且额外断言了 shared 池与 host 池的逐轮恒等,两节目标由同一测试闭合。

验收:/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