--- id: "bug-1414" status: "fixed" status_updated: "2026-09-24" status_note: "" component: "controller" severity: "unknown" timestamp: "2026-07-04T18:00:00.000Z" last_seen: "2026-09-19T09:00:00Z" occurrences: 1 files: ["src/controller/job.c", "src/controller/persist.c", "src/include/wal_types.h", "src/include/persist.h", "src/controller/include/cluster.h"] tags: ["raft-apply-decoupling", "task-6.1", "round-2", "job-state-corruption", "merge-staleness", "deferred-apply", "wal-wire-format", "wal_jobstate_t", "_pad-discriminator"] related: ["bug-1413"] legacy: true --- # bug-1414: raft-apply-decoupling task 6.1 round 1 REJECTED: FINISH (LSN N) then a POST_EXEC-stage FAIL (LSN… ## Description raft-apply-decoupling task 6.1 round 1 REJECTED: FINISH (LSN N) then a POST_EXEC-stage FAIL (LSN N+1) for the same job, with FAIL received before FINISH applies, corrupts the job to RUN|EXIT|PERR (0x106) instead of the correct DONE|PERR (0x108) once both apply in LSN order -- DONE is silently lost and a stray RUN bit appears. ## Environment _Not recorded (migrated from buglog.json)._ ## Reproduction _Not recorded (migrated from buglog.json)._ ## Root cause record_job_fail_fact (job.c) and record_job_update_fact (postdone) precomputed a FINAL absolute state at RECEIPT time (scratch-copy merge for FAIL, inline backstop+PDONE merge for postdone) and baked it into wal_jobstate_t.state -- correct only when receipt and apply are the same event. Once task 6.1 deferred apply past the commit barrier, a second in-flight fact for the same job (e.g. FINISH) could apply BETWEEN this fact's receipt and its own apply, making the receipt-time snapshot stale by the time apply_committed_entry's generic apply_job_update_transition() did its blind absolute overwrite -- silently clobbering whatever the other fact had just applied. ## Fix Repurposed wal_jobstate_t._pad (previously always-zero, unused) as a discriminator: _pad==0 still means legacy absolute state (job_ctrl's mark_job_fail/mark_job_update live paths, which mutate+capture under one lock atomically -- no staleness risk, unchanged); a nonzero _pad (WAL_JOB_FAIL_STAGE_FLAG|stage for FAIL, WAL_JOB_UPDATE_POSTDONE_MARK for postdone) marks the entry for apply-time recomputation. apply_committed_entry (persist.c) now dispatches WAL_JOB_FAIL/WAL_JOB_UPDATE on this bit, calling the REAL apply_job_fail_transition() / new apply_job_postdone_transition() (job.c) against the job's ACTUAL current state at apply time instead of a stale baked value. New persist_submit_job_fact_ex(p,type,jobid,state,pad) primitive added; persist_submit_job_fact becomes a pad=0 thin wrapper so zero existing call sites needed changes. Also fixed 2 pre-existing test_commit_barrier_reply.c tests that asserted the old wire format directly (cbrp_count_jobstate_entries reading state bits) -- extended the helper to a _ex variant exposing _pad, updated assertions to check the new marker plus a direct call to the guard core on a scratch job_t. New regression test test_apply_pump_fail_racing_finish_merges_against_current_state in test_apply_pump.c drives FINISH then FAIL through the real receipt handlers with both entries durably appended but unapplied, then a single covering ACK; RED-confirmed byte-for-byte (Expected 264 Was 262) against the reverted round-1 code, GREEN after the fix. ## Verification _Not recorded (migrated from buglog.json)._ ## References - Related: bug-1413