fix(controller): WAL_JOB_PREASON loses job.pend_reasons on replay (bug-1692)

Last of the five field-parity gaps from the original audit (bug-1678,
bug-1687, bug-1689, bug-1690, bug-1691). set_preason()/clear_preason()
set job.pend_reasons (host/resource-based pending-job explanations), but
persist_job_hook_fun's default: arm only ever captured {jobid, state}.
Neither apply seam restored pend_reasons after a restart.

Reuses the ALREADY-EXISTING JobPendReason/HostBasedReason/ResourceBasedReason
capnp structs -- no schema change needed, unlike bug-1690's WAL_JOB_RUSAGE.
Safety-checked against bug-1690's lesson (RTTI/makeString()-backed fields
need merge-not-replace, not free-and-replace): pend_reasons is plain
malloc/strdup-backed throughout (erase_job_preason is FREEUP()-only,
add_host_preason/add_res_preason use strdup()/calloc()), so a
straightforward free-and-replace is safe here, matching bug-1678/1687's
pattern instead.

New wrinkle beyond the four prior fixes: pend_reasons has a genuine CLEAR
operation, and "trailing bytes present or absent" can't distinguish a
legitimate clear from an old-format payload -- added an explicit presence
byte (0=cleared/adopt NULL, 1=capnp bytes follow), verified by two
dedicated "clear survives restart" tests.

11 new tests in test_wal_job_preason_fact.c; RED/GREEN independently
verified (7 genuine pre-fix failures, one test's own wrong premise about
capnp's garbage-byte behavior caught and corrected during RED/GREEN
iteration). Full controller suite: 1178 tests, 2 failures -- both the
pre-existing, unrelated bug-1561 overlayfs sandbox flake. No ASAN findings.

This closes the field-complete apply parity invariant class the
xce-audit-invariants controller dimension 7 was added for.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgYRU3zDTqNGV5Jk8aiKLg
This commit is contained in:
Rongsong Shen 2026-08-25 13:14:28 +00:00
parent 1b86e04078
commit d0e5f85aa8
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
7 changed files with 1465 additions and 3 deletions

View file

@ -187,7 +187,43 @@ typedef enum {
* forward state transition -- a checkpoint report
* never changes job.state, so gating it the same
* way would make it dead code. */
WAL_JOB_PREASON = 10,
WAL_JOB_PREASON = 10, /* scheduler-reported host/resource-based pending
* reasons (job.pend_reasons, set by set_preason()/
* cleared by do_clr_preason(), job.c). Payload is
* wal_jobstate_t{jobid, state, _pad=0} for any
* entry written before bug-1692. As of bug-1692,
* persist_job_hook_fun's HOOK_DETAIL_SETPREASON/
* HOOK_DETAIL_CLRPREASON arm appends an explicit
* presence byte after the header: 0 = pend_reasons
* was genuinely cleared (adopt NULL on replay), 1
* = trailing Cap'n Proto JobPendReason bytes
* follow (adopt the decoded value). Unlike
* WAL_JOB_CKPT/WAL_JOB_RUSAGE, "trailing bytes
* present or absent" alone cannot distinguish
* "old-format payload" from "field is legitimately
* NULL", because pend_reasons -- uniquely among
* this fact-type family -- has a real CLEAR
* operation (do_clr_preason) that must survive
* replay durably, not just its SET side; the
* presence byte resolves that ambiguity. A payload
* of exactly sizeof(wal_jobstate_t) (no presence
* byte at all) leaves pend_reasons untouched on
* both apply seams, matching the pre-fix
* state-only behavior exactly. Like
* WAL_JOB_RUSAGE/WAL_JOB_CKPT, adoption on the
* apply_committed_entry() seam is NOT gated on a
* forward state transition -- set_preason()/
* do_clr_preason() never change job.state, so
* gating it the same way would make it dead code.
* Adoption is a plain free-and-replace (via the
* capnp-generated free_JobPendReason_ptr()), NOT a
* merge like WAL_JOB_RUSAGE -- job.pend_reasons
* and its nested host/resource reason arrays are
* plain calloc()/strdup() throughout (verified via
* erase_job_preason()'s FREEUP-only release, NOT
* xstring/RTTI-backed like ckptinfo/rusage's
* host_name), so no merge-not-replace hazard
* exists here. */
WAL_JOB_ARRAY_SUBMIT = 11, /* job-array-single-wal-record: one record for
* the whole array (base spec + index range),
* distinct from WAL_JOB_SUBMIT's single-job