fix(apiserver): detail-only PEND reason promotion was unreachable on the C path

cJobToModel pre-filled main_reason_name with pendReasonName(main_reason),
so a detail-only record (main_reason 0, the allocator's ordinary shortfall
shape) arrived at rawJobToModel named "unknown(0)" — and the promotion that
derives the overall reason from the first detail row requires an EMPTY
name. The promotion's own tests passed because they build rawJobData
directly; the live C path never hit it. Found by the advisor P0
verification: jobs whose res_reasons named no_resource read
main_reason_name=unknown(0) over the API.

main_reason 0 now leaves the name empty for the promotion to fill; a
non-zero main reason keeps its mapped name unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-10-19 07:07:02 +00:00
parent 4db1aa8aef
commit 3e3b6d7947
2 changed files with 54 additions and 1 deletions

View file

@ -201,9 +201,19 @@ func cJobToModel(job *C.xce_job_t) *models.JobInfo {
// baselines encode main_reason=0 detail-only PENDs).
if job.pend_reasons != nil {
pr := job.pend_reasons
// main_reason 0 is detail-only (the allocator's ordinary shortfall
// shape): leave the NAME empty so rawJobToModel's promotion fills it
// from the first detail row. Pre-filling pendReasonName(0) here
// ("unknown(0)") made the promotion unreachable on the live C path —
// the advisor P0 verification caught jobs whose res_reasons named the
// real cause while main_reason_name read unknown(0).
mainName := ""
if pr.main_reason != 0 {
mainName = pendReasonName(int(pr.main_reason))
}
r.pendReason = &rawPendReason{
MainReason: int(pr.main_reason),
MainReasonName: pendReasonName(int(pr.main_reason)),
MainReasonName: mainName,
}
for i := 0; i < int(pr.nhost_reasons); i++ {
hr := C.job_host_reason(pr, C.int(i))

View file

@ -1656,3 +1656,46 @@ func TestFormatJobID_ArrayChild(t *testing.T) {
t.Errorf("FormatJobID(array) = %q, want %q", got, want)
}
}
// The C path (cJobToModel) must NOT pre-fill main_reason_name for a
// detail-only record: pendReasonName(0) = "unknown(0)" made the pure-layer
// promotion unreachable on the live path (advisor P0 verification finding —
// jobs whose res_reasons named the real cause while main_reason_name read
// unknown(0)). This pins the shape cJobToModel now hands to rawJobToModel:
// main_reason 0 with an EMPTY name (res detail rows as the C record carried
// them) must promote no_resource, and a non-zero main reason keeps its name.
func TestPendReasonShape_CPathDetailOnlyPromotes(t *testing.T) {
// what cJobToModel builds for main_reason 0 since the fix: name empty
r := rawJobData{
jobID: 11,
state: 1,
pendReason: &rawPendReason{
MainReason: 0,
MainReasonName: "", // cJobToModel leaves it for the promotion
ResReasons: []rawResReason{{Resource: "limit=limit1", Host: "", Reason: 2}},
},
}
info := rawJobToModel(r)
var decoded struct {
MainReason int `json:"main_reason"`
MainReasonName string `json:"main_reason_name"`
}
if err := json.Unmarshal([]byte(info.PendingReason.String), &decoded); err != nil {
t.Fatalf("PendingReason invalid JSON: %v", err)
}
if decoded.MainReasonName != "no_resource" {
t.Errorf("detail-only via C shape: main_reason_name = %q, want promoted no_resource", decoded.MainReasonName)
}
// non-zero main reason keeps the mapped name (promotion must not touch it)
r2 := r
r2.pendReason = &rawPendReason{MainReason: 6, MainReasonName: "queue_closed"}
info2 := rawJobToModel(r2)
var d2 struct {
MainReasonName string `json:"main_reason_name"`
}
_ = json.Unmarshal([]byte(info2.PendingReason.String), &d2)
if d2.MainReasonName != "queue_closed" {
t.Errorf("explicit main reason overwritten: got %q, want queue_closed", d2.MainReasonName)
}
}