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>
19 KiB
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 目标形态:代码库里已有两次示范
- snapshot_load.c(485 行)——"全量状态源 → 单路径原子重建"的完整先例:config→groups→resources→shared resvals→queues→workers→users→projects→jobs 一次建齐,最后
sched_account_jobs从零记账一次(幂等)。没有任何 merge 分支。 - 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。
契约内容:
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)。- picker 线程(sched_election.c:136
sched_job_picker)只推 jobid 到 jbuf(容量 1 rendezvous 是确定性不变量,永不动)。 limit.c:295的ctx.reasonMOVE_PTR 是 dead-by-construction(上游永远不挂 reason),保留 NULL check 形状但注释已写明。
做法(两层):
- 结构扫描测试(仿
test_picker_pendreason_factory_audit.c的固定文件清单同线判定的成熟形态):扫描obj->reason/schedobj->reason的写入点清单是封闭集合,新增写入点必须显式更新清单并注释线程归属——清单外出现写入即红。 - 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 已验证实例跨重载绑定的正确性 |
时序契约(消除半新半旧窗口):
- 断连检测(
sched_on_reconnect置 flag,不许 I/O——客户端库 failover lock 下回调的既有约束不动)。 xcecli_join成功 → 进入 REBUILD_PENDING:此间正常轮继续跑(可用性优先,调度本来就跑在本地副本上),但 本会话内到达的 stash config / pend_updates 全部挂起不入 core——重建即将整体替换它们,先应用再重建是白费且引入窗口。- 推送三件套(configs→workers→jobs)全部到齐(顺序固定,jobs 到达即视为完整)→ 在轮间(与 sched_apply_pending_config 同一位置)调用新的
xce_core_rebuild_from_push(...)原子重建。 - 重建完成 → 清 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):
- 决策等价:rejoin 会话与"删除断连段的干净会话"决策序列一致(断连段内允许差异——那段本来就跑在 stale 副本上,收敛发生在重建点之后)。
- 状态等价:重建点后的周期快照与 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