myxce/docs/Job-Finish-Robustness-设计文档.md
lei.chen d3a92f1033 fix(controller,worker): dual-state worker loss + resend actual job state (bug-1509 P3)
Phase 3 (B1 dual-state + B2): a job never lingers as RUN when its worker
is lost.

Dual-state B1 (user-approved): RDHUP (worker TCP dropped = confirmed dead)
marks its RUN jobs EXIT immediately (mark_worker_jobs_exit). Heartbeat
timeout (unknown) keeps them UNKWN+TRANSIENT awaiting reconnect
reconciliation; the TRANSIENT TTL GC is the backstop. handle_worker_leave
now calls mark_worker_jobs_exit (was mark_worker_jobs_unknown in Phase 2).

B2 (worker side): resend_job_status now checks the recovered process is
alive (kill(pid,0)) before re-reporting RUN — a dead process is reported
finished so the controller converges to EXIT instead of a stale RUN. And
resend_job_status runs on EVERY rejoin (checking_connection), not just
startup, so a reconnecting worker's jobs are reconciled (clears UNKWN via
the existing notify_jobstart claim path).

Design: docs/Job-Finish-Robustness-设计文档.md §4.4 (dual-state decision).

Unit test: worker RDHUP marks jobs EXIT (not UNKWN).
2026-08-06 06:49:24 +00:00

21 KiB
Raw Permalink Blame History

作业终态可靠性设计(Job Finish Robustness)

  • 状态:v2(已含 agent review 结论,2026-08-06)
  • 日期:2026-08-06
  • 关联问题:作业在 controller 端永久停留 RUN(如 noexec-test/newcfg-test/verify-fix)

0. Review 结论(agent review 2026-08-06,结论:approve with changes)

10 条代码断言全部核实为真。发现的必改项、强建议项、边界情况见本节;对应修订已回写进正文各节(§3/§4/§7/§9/§10 用 🔧 标注)。

0.1 必改项

ID 严重度 问题 修订
A1 HIGH record_job_finish_fact 文档要求调用方持 job 的 RTTI_LOCK_WRITE(job.c:1737-1743),但 Part A 在 apply_committed_entry 取到 j 后未加锁(persist.c:3517-3521)。job_ctrl/scheduler 的直接 mutator 不受 apply_pump_lock 串行化 → 数据竞争。 🔧 §3.4:take + record 包进 lock(j, RTTI_LOCK_WRITE)
B2 HIGH transient_since 未持久化到 WAL(WAL_JOB_UPDATE 只带 wal_jobstate_t{jobid,state,_pad})。重启后 B1 标记的作业恢复为 RUN|UNKWN|TRANSIENT 但 transient_since=0 → job_should_retain 永不 GC。 🔧 §4.1/§4.3:B3 重标为显式持久化机制(每次重启重设 transient_since),并明确该机制为兜底;Phase 2 起在 WAL 携带 transient_since(新字段/条目)
B3 MED/HIGH resend_job_status 只在 worker 启动时调用(xcecd.c:522),join/rejoin 路径不重触发。分区后重连的 worker 不再上报运行清单 → UNKWN 作业被 grace 误杀。 🔧 §4.2/§4.4:每次 join/rejoin 都调用(修复后的)resend_job_status

0.2 强建议项

ID 严重度 问题 修订
B1 HIGH grace 窗口把"仅分区"(TCP half-open,作业仍在跑)的 worker 的作业 EXIT;终态后 worker 的 RUNNING 认领被 job_state_is_backward 拒绝 → 孤儿进程 + 永久错误终态。 ✅ 已定双态(§4.4):RDHUP(确认死亡)→ 立即 EXIT;心跳超时(未知)→ UNKWN 等重连核对,不 EXIT(TRANSIENT TTL 兜底)。孤儿风险已接受
A3 MED/HIGH unknown 注册表进程内、不跨 failover:primary 登记 finish 后 failover,新 primary 重放 submit(PEND)但 finish 丢失 → PEND 永久卡住(B3 只覆盖 RUN)。 🔧 §3.6:Phase 1 文档化为残留风险;后续持久化 deferred finish 或把 B3/B4 扩展到 PEND
B4 MED sqlite 不存 exit code,resend 硬编码 exit_code=0;存的 pid 是 xcecd-worker 子进程(非作业命令树);MPI/多进程作业需 pgid 级存活。 🔧 §4.2:上报前从 systemd unit 状态 / reaped wait status 推导 exit code;用存储的 job_status 作提示

0.3 其他 / 边界

  • A4:FINISH-only 注册是范围裁剪——START 对 fast-job 同样会被丢弃(作业永不显示 RUN、start_time=0、无 WAL_JOB_START)。本次接受,后果记录在 §3.5。
  • A5:TTL 必须 > 最大 commit→apply 延迟,防合法 deferred finish 被清扫;注册表需设容量上限防 bogus-finish 洪泛。
  • B5:B1 在 handle_worker_leave 持 worker 锁时扫 jobmgr 有锁序反转风险 → 先放 worker 锁再扫 jobmgr。
  • B6:B3 重启全标 UNKWN 后,apiserver 渲染为 "TRANSIENT"(可接受);但 xcecli_jobcontrol 对 allocs==NULL 的 UNKWN 作业的行为需验证(Phase 2)。
  • M1:已有残留 RUN 作业的迁移路径 = B3 重启重标 + B4/B2 重报自愈,需显式测试。
  • M2:standby catch-up 应用 Part A 的 WAL_JOB_SUBMIT 时注册表为空 → 无操作(安全);但须只在权威 primary 上追加/消费(failover 窗口防护)。
  • M3:作业完成后 worker 才重启:DB 行已是 WORKER_DONE → get_running_workers(筛 RUNNING)不包含它 → 丢失的 finish 不被重发。这是 §7.6 已承认的局限。
  • M4:重复 finish:作业实体化后重复 finish 走终态→终态 no-op(record_job_finish_fact peek,job.c:1744-1747)——安全。
  • M5:grace EXIT 的作业 exit code 未设置,与真实失败不可区分(apply_job_fail_transition 非 post 阶段设裸 EXIT)——可接受。

1. 问题与目标

1.1 症状

若干作业已提交、运行、并在 worker 端确实执行完毕,但 controller 端的作业状态永久停留在 RUN。即使多次重启 controller/worker 也依旧出现(因为状态已持久化在 WAL,重启后回放恢复成 RUN)。

1.2 根因

worker 上报作业完成(finish 通知)后,controller 未能把作业收敛到终态(DONE/EXIT)。两个子场景:

  • 场景 A(controller 收到但丢弃):finish 到达 controller 时,作业尚未实体化(submit 已 commit、但 apply_committed_entry 尚未 restore_job 进 jobmgr)。on_job_finish(src/controller/job.c:1777)在 key_find 找不到作业时仅 ERROR("can not find job %ld") 直接返回——finish 被丢弃,无 WAL 追加、无重试、无补救。
    • 触发窗口:作业构造被延迟到 apply 时(apply_committed_entry 的 WAL_JOB_SUBMIT case,commit 之后才 restore_job)。短作业(如 sleep 10)极易在"commit → apply"窗口内跑完并上报 finish。
  • 场景 B(传输中丢失):finish 在网络上丢失,或 worker 崩溃/失联,controller 从未收到。worker 不会重发(worker 认为作业已完成并已从自身跟踪中移除)。

1.3 目标

任何情况下,作业最终收敛到终态(DONE/EXIT),不再出现"永远 RUN"。方案分 A、B 两部分,分别覆盖两个子场景。


2. 现状分析(已核实)

2.1 状态机已具备所需状态(但未接线)

状态 值 语义 当前使用
JOB_STAT_UNKWN 0x10000 worker 失联,作业状态未知 C 代码零使用
JOB_STAT_RUNKWN 0x8000 作业状态未知(与远程集群失去联系) 零使用
JOB_STAT_TRANSIENT 0x400 failover 后状态不确定,等待 worker 重连 persist.c 中仅有读取(GC 判断)
  • apiserver 已支持显示 UNKWN/RUNKWN/TRANSIENT(src/frontend/apiserver/models/jobs_test.go 有映射断言)。
  • job_should_retain(src/controller/persist.c:6730)对 TRANSIENT 作业:transient_since 超时后不保留(自动 GC)。但 TRANSIENT 从未被设置,故该机制永不触发。

2.2 worker 失联处理缺作业标记

  • handle_worker_leave(src/controller/worker.c:693)→ mark_worker_unavail(worker.c:661):只清 ep、置 XCE_WORKER_UNAVAIL、重置 host attrs/resvals/slots。不碰该 worker 的 RUN 作业。
  • apiserver_worker_dead 与 sched_worker_dead 均有 /* FIXME: need to handle job status in this worker */(apiserver.c、sched.c)。
  • 无 worker 存活监控:worker 失联仅靠 TCP RDHUP 触发 handle_worker_leave。静默失联(网络分区、TCP 不断)检测不到。

2.3 worker 侧重连上报(已存在,但有缺陷)

  • worker 运行作业时把 (pid, status, job_id, job_status) 写入 sqlite(/run/xcecd.db,src/worker/src/xcecd_db.c,DBUS_MANAGER 构建启用)。
  • worker 崩溃后重启:xcecd.c:502 xcecd_db_get_running_workers 恢复 worker_list → xcecd.c:522 resend_job_status(xcecd_worker_utils.c:447)逐一对 WORKER_RUNNING 的 worker 调 xcecd_reply_startjob(即 xcecli_notify_jobstart)重报 RUNNING。
  • 缺陷:resend_job_status 盲目上报 JOB_RUNNING(不查进程死活、job_pid=0),导致已死的作业也被重报为 RUNNING。

2.4 finish 处理链路(已核实正确,缺口在丢弃)

worker → OPCode_notify(jobFinishInfo) → do_notify(cluster.c:130) → on_job_finish
  → key_find(jobmgr) 
  → 找到: record_job_finish_fact → 追加 WAL_JOB_FINISH → commit → apply → apply_job_update_transition → DONE/EXIT ✓
  → 找不到: ERROR "can not find job" → 丢弃 ✗(无重试)
  • apply_job_update_transition(job.c:1895)对 RUN/PEND → DONE/EXIT 判定为非 backward(job_state_is_backward 在 job.c:43,仅当 cur 是终态且 next 非终态才 backward)——转换守卫无误。
  • 分发路由(do_notify)、接收(on_job_finish)、WAL 追加(record_job_finish_fact)均正确;缺口仅在"作业未实体化时丢弃"。

2.5 重启后 exec host(allocs)丢失

  • xce_job_t.allocs(含 exec host)不持久化到 WAL;controller 重启后 j->job.allocs == NULL。因此重启后 controller 无法将 RUN 作业映射到具体 worker。
  • 设计含义:Part B 采用 worker-push 模型(worker 上报自己的运行作业),不依赖 exec-host 映射。

3. Part A:Unknown Finish 注册表(controller 端,修复"收到但丢弃")

3.1 目标

场景 A 下,worker 的 finish 不再被丢弃:controller 暂存它,等作业实体化后补应用。

3.2 数据结构(src/controller/job.c,仿照 fetchfile_pending 模式)

typedef struct unknown_finish {
    uint64_t              jobid;
    xce_jobfinish_t       finish;   /* 深拷贝,本表持有 */
    struct unknown_finish* next;
} unknown_finish_t;

static pthread_mutex_t   g_unknown_finish_mu = PTHREAD_MUTEX_INITIALIZER;
static unknown_finish_t* g_unknown_finish_list = NULL;

配套辅助函数:unknown_finish_register()、unknown_finish_take(jobid)(取出并摘除)、unknown_finish_sweep_ttl()(清理超时未实体化的条目,TTL 建议 60s,防 submit 永不被 apply 时泄漏)。

3.3 on_job_finish 改动(job.c:1762)

  • 作业找到 → 现路径不变(record_job_finish_fact)。
  • 作业未找到 → 深拷贝 xce_jobfinish_t 登记到 g_unknown_finish_list,INFO 日志("finish for un-materialized job %ld queued in unknown-finish registry"),return 0(controller 已接受责任,不视为错误)。不追加 WAL。

3.4 submit apply 改动(apply_committed_entry WAL_JOB_SUBMIT case,persist.c:3488,restore_job 之后)

  1. restore_job 已把作业实体化进 jobmgr。
  2. unknown_finish_take(jobid):若有该 jobid 的 finish → 取出。
  3. 🔧 持作业锁:lock(j, RTTI_LOCK_WRITE) 包住 take + record_job_finish_fact(A1:该函数要求调用方持 job 锁,job.c:1737-1743;apply pump 锁不串行化 job_ctrl/scheduler 的直接 mutator)。
  4. 用取出的 finish 调 record_job_finish_fact(c, j, &finish):追加 WAL_JOB_FINISH(持久化),之后该条目经 commit + apply 收敛作业到 DONE/EXIT。
  5. unlock(j),释放 finish 拷贝。

顺序正确性:finish 的 WAL 条目在 submit 的 WAL 之后追加,apply 顺序保证 job 先实体化(PEND/RUN)再收敛终态。apply_job_update_transition 对 PEND → DONE 亦非 backward,安全。

并发/重入(🔧 A2 修订):submit apply 在 apply pump 的 walk 中(持 apply_pump_lock);record_job_finish_fact 内部 persist_submit_job_fact 取 p->lock(不同锁,无死锁)。但注意:RETAIN 模式的 commit_submit 立即返回、不推进 committed_lsn(commit.c:497-506),追加的 WAL_JOB_FINISH 需等 majority ack 后由后续 pump kick 应用——不在同一 pass 内应用。pump kick 是异步池 vtask(commit.c:834, persist.c:4415-4433),无自死锁。实现需补:从 apply 内追加的锁序/重入分析 + 回归测试(A2)。

A3 残留风险(🔧 文档化):unknown 注册表是进程内的,不跨 failover。primary 登记 finish 后 failover,新 primary 重放 submit(作业 PEND)但 finish 丢失 → 作业 PEND 卡住(B3 只覆盖 RUN,不救 PEND)。commit→apply 窗口很小,概率低;Phase 1 接受为残留风险,后续实现持久化 deferred finish 或把 B3/B4 扩展到 PEND。

3.5 通用化(可选扩展)

START/FAIL/STOP/RESUME 等 worker 事实通知存在同样的"作业未实体化"竞态。可将注册表泛化为 jobid → 事件队列(event_type + payload)。本次实现仅覆盖 FINISH(对应本 bug),其余留作后续。

🔧 A4 范围裁剪后果(已接受):FINISH-only 注册下,fast-job 的 START 仍会被丢弃(同 "can not find job" 路径,job.c:1671-1674)→ 作业从不显示 RUN、start_time=0、回放时 PEND→DONE。对本次 bug 无影响(终态正确),但会计/展示不完整。后续扩展注册 START 等。


4. Part B:Worker 存活监控 + 重连核对(修复"传输丢失 / worker 失联")

4.1 B1:Worker 存活监控

  • 每 worker 增加 ts_last_recv 字段(worker_t),在任何入站 worker 消息上更新(update_job_rusages、do_notify、rusage 等;worker 每 ~10s 发一次 rusage)。
  • controller 增加周期清扫(delaymgr/vtaskmgr 定时任务):now - ts_last_recv > WORKER_LIVENESS_TIMEOUT(建议 30~60s,约 3 个更新周期)的 worker 标记 dead,走 handle_worker_leave 路径。
  • 关键改动:handle_worker_leave/mark_worker_unavail 中,遍历该 worker 的 RUN 作业(运行时 job.allocs 可用,能定位),逐一对 mark_job_update(c, j, state|JOB_STAT_UNKWN|JOB_STAT_TRANSIENT) 并设 transient_since = now。填补两处 FIXME: need to handle job status in this worker。
  • 🔧 B2(transient_since 持久化):WAL_JOB_UPDATE 只带 wal_jobstate_t{jobid,state,_pad},不持久化 transient_since。因此 B1 的 transient_since = now 在 controller 重启后会丢(恢复成 RUN|UNKWN|TRANSIENT 但 transient_since=0 → job_should_retain 永不 GC,persist.c:6736-6741)。Phase 1 的兜底是 B3 每次重启重标(重设 transient_since);Phase 2 起在 WAL 携带 transient_since(新字段/条目)真正持久化。
  • 🔧 B5(锁序):handle_worker_leave 持 lock(w, RTTI_LOCK_WRITE)(worker.c:721-727)时扫 jobmgr 有锁序反转风险 → 先放 worker 锁再扫 jobmgr。

4.2 B2:修复 worker 侧重连上报(resend_job_status)

  • 现状:xcecd_worker_utils.c:447 resend_job_status 对恢复的 worker 盲目报 JOB_RUNNING(job_pid=0,不查进程)。
  • 改动:上报前用 process_manager 查每个 job 的进程实际状态:
    • 进程活着 → xcecd_reply_startjob(报 RUN)
    • 进程已死 / systemd unit 不存在 → 直接调 xcecli_notify_jobfinish(报 DONE/EXIT),不再依赖后续轮询的二次纠正。
  • 🔧 B3(每次 join 都上报):resend_job_status 不能只在启动调用(xcecd.c:522),必须在每次 join/rejoin(xcecd_join_cluster)成功后调用——否则分区后重连的 worker 不再上报,UNKWN 作业被 grace 误杀。
  • 🔧 B4(exit code 精度):sqlite 不存 exit code(resend 硬编码 exit_code=0),且存的 pid 是 xcecd-worker 子进程而非作业命令树。上报前从 systemd unit 状态 / reaped wait status 推导真实 exit code,并用存储的 job_status 作提示;MPI/多进程作业需 pgid 级存活检查。

4.3 B3:Controller 重启处理

  • persist_recover 回放完成后,把所有 RUN 状态的作业置 JOB_STAT_UNKWN + JOB_STAT_TRANSIENT + transient_since = now(重启后与 worker 连通性未知)。
  • 解决"重启后 RUN 作业(无 allocs)永远卡住"的问题;后续由 B4 重连核对收敛。

4.4 B4:重连核对(worker 上报 → controller 交叉核对)【已定双态】

双态决策(2026-08-06,用户确认方案 3):

  • RDHUP(TCP 断开,确认死亡) → mark_worker_jobs_exit:该 worker 的 RUN 作业立即置 EXIT(终态,finish_time/exit_info 设置)。不等待核对。
  • 心跳超时(未知) → mark_worker_jobs_unknown:RUN 作业置 UNKWN+TRANSIENT,等重连核对,不 EXIT;worker 永不回来则由 TRANSIENT TTL(transient_job_timeout_s 默认 300s)GC 兜底。
  • 认领路径(已天然工作):worker(重)连上报运行作业(notify_jobstart)→ WAL_JOB_START apply → apply_job_update_transition 设 state=RUN,自动清除 UNKWN/TRANSIENT。

worker 侧重连上报(B2):

  • resend_job_status 每次 join/rejoin 都调用(不只启动),上报运行作业清单。
  • 上报前用 kill(pid,0) 查进程实际状态:活着 → 报 RUN;已死 → 报 finish(EXIT),不再盲目报 RUN。
  • 🔧 B1 已按上述双态解决(不再有"grace 窗口误杀"——未知态不 EXIT)。遗留孤儿风险(RDHUP→EXIT 但 worker 侧 systemd unit 可能仍在跑):终态后 worker 认领被 job_state_is_backward 拒绝(job.c:43-46),孤儿进程最终自行结束或由后续清理。已接受(用户决策)。

5. 状态模型与转换

状态 设置时机 清除时机
RUN submit apply + START / B4 确认存活 finish 应用
UNKWN(+TRANSIENT) B1 worker dead、B3 controller 重启 B4 确认存活 → RUN;finish → 终态;TTL → GC
DONE/EXIT finish 应用(A 或 B) 保留期后 GC

转换表:

  • RUN/UNKWN → DONE/EXIT:finish 应用(Part A 注册表补应用 / 正常路径 / B2 worker 上报)。
  • UNKWN → RUN:B4 重连确认存活。
  • UNKWN → EXIT:B4 grace 窗口无人认领。
  • UNKWN → GC:job_should_retain 的 transient_job_timeout_s 到期(兜底)。

6. 协议改动

  • 无新 opcode。Part A 复用现有 WAL_JOB_FINISH;Part B 复用现有 notify_jobstart/notify_jobfinish 协议与 join 流程。本设计全部复用现有机制,只是正确接线 + 补缺失路径。

7. 边界情况

  1. 作业完成与 worker 死亡竞态:UNKWN 标记基于陈旧 RUN 状态是安全的,重连核对纠正。
  2. worker 也重启(进程丢失):B2 上报查进程 → 报 finish/EXIT。
  3. 短暂网络抖动:worker 在 grace 窗口内回来 → 核对恢复 RUN;TTL 足够长不误 GC。
  4. 多 worker 重连风暴:核对上报批量处理/限流。
  5. submit 永不被 apply:unknown 注册表条目 TTL 清扫。
  6. 已知局限(Phase 2 候选):worker 存活且 finish 在传输中丢失、无重连无失联的场景——A 覆盖"controller 见过",B 覆盖"worker 失联";纯"传输丢失且 worker 一直健康"需 worker 周期上报运行清单或 rusage 携带作业状态。

8. 测试计划

8.1 Part A 单测(testsuite/test_*)

  • on_job_finish 作业未实体化 → 注册到 unknown-finish 表 → 断言条目存在、return 0。
  • apply_committed_entry WAL_JOB_SUBMIT 应用后 → 查表取 finish → 断言作业收敛 DONE/EXIT、WAL_JOB_FINISH 追加。
  • 模拟 fast-job:提交 WAL 已 commit 但作业未 apply 时收到 finish → 断言不丢。

8.2 Part B 单测

  • mark_worker_unavail → 该 worker 的 RUN 作业置 UNKWN + TRANSIENT + transient_since。
  • persist_recover 回放后 RUN 作业 → UNKWN。
  • worker 上报运行清单 → controller 清 UNKWN;无人认领 → grace 后 EXIT。
  • worker 端 resend_job_status 对进程已死的作业报 finish 而非 RUN。

8.3 集成测试(集群验证)

  • kill worker(作业运行中)→ controller 标记作业 UNKWN → 重启 worker → 核对:作业恢复 RUN(进程存活)或 EXIT(进程已丢)。
  • controller 重启(作业 RUN 中)→ 全部 RUN 置 UNKWN → worker 重连 → 核对收敛。
  • 完整作业验证:submit → RUN → DONE,peek 正常,所有 daemon 健康。

9. 实施顺序

Phase 内容 风险 验证
Phase 1 Part A:unknown finish 注册表 + 单测 低(controller 端独立改动) 单测 + 完整作业验证
Phase 2 B1+B3:worker 存活监控 + handle_worker_leave/recover 置 UNKWN + 单测 中(新增定时任务) 单测 + kill worker 集成
Phase 3 B2+B4:resend_job_status 修复 + 重连核对 + 集成测试 中(worker 端改动) 集成测试 + 完整验证

10. 改动点清单

文件 函数 改动
src/controller/job.c on_job_finish 作业未找到 → 登记 unknown-finish(替代丢弃)
src/controller/job.c 新增 unknown_finish_* 注册/取出/TTL 清扫
src/controller/persist.c apply_committed_entry WAL_JOB_SUBMIT restore_job 后查 unknown-finish 补应用(🔧 A1:持 job RTTI_LOCK_WRITE)
src/controller/worker.c mark_worker_unavail/handle_worker_leave 遍历该 worker RUN 作业置 UNKWN+TRANSIENT
src/controller/worker.c worker_t 新增 ts_last_recv
src/controller/* 周期清扫任务 worker 存活监控
src/controller/persist.c persist_recover 回放后 RUN 作业置 UNKWN+TRANSIENT
src/worker/src/xcecd_worker_utils.c resend_job_status 查进程实际状态后上报
src/worker/src/* process_manager 查询接口 供 B2 查进程死活
src/controller/job.c 重连核对处理 清 UNKWN / grace 后 EXIT