From 5753123798ffdd7eb25c63a3672befefe421eeb9 Mon Sep 17 00:00:00 2001 From: "lei.chen" Date: Sat, 15 Aug 2026 10:46:47 +0000 Subject: [PATCH] xcecli: fix job list "cannot unmarshal pending_reason" + verbose fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apiserver now returns pending_reason as an object ({main_reason, main_reason_name, host_reasons, res_reasons}) since the PEND-reason work. xcecli's QueryJob.PendingReason was a plain string, so every job list failed with "cannot unmarshal object into string". Two fixes: - xcecli/models/jobs.go: PendingReason is now a string-backed type with a custom UnmarshalJSON that accepts null / empty string / plain string (older API) / the object form (current API, rendered as readable text — LSF bjobs -p style: "match_fail; host t1: match_fail; ..."). Unknown objects fall back to the raw bytes instead of erroring, so a job list can never fail on an unexpected pending_reason shape. - apiserver/models/jobs.go: JobAdditionalFields gains PendingReason so the fields= query (xcecli `jobs list -l`) no longer rejects pending_reason with "unsupported query field". - xcecli/common/output/page_print_jobs.go: adapt the verbose printer to the new type. Tests: xcecli/models/jobs_test.go — 6 cases (null / empty / plain string / object / within QueryJob / unknown object). xcecli 9 packages + apiserver models/controllers/utils all pass. Verified on the test cluster: `xcecli jobs list` (no unmarshal error) and `xcecli jobs list -l` show "PENDING REASONS: host_unavail; host t1: host_unavail; host t2: match_fail; host t3: match_fail". Co-Authored-By: Claude --- src/frontend/apiserver/models/jobs.go | 1 + .../xcecli/common/output/page_print_jobs.go | 4 +- src/frontend/xcecli/models/jobs.go | 93 +++++++++++++++++- src/frontend/xcecli/models/jobs_test.go | 95 +++++++++++++++++++ 4 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 src/frontend/xcecli/models/jobs_test.go diff --git a/src/frontend/apiserver/models/jobs.go b/src/frontend/apiserver/models/jobs.go index 8a5a969..c4c1b0b 100644 --- a/src/frontend/apiserver/models/jobs.go +++ b/src/frontend/apiserver/models/jobs.go @@ -613,6 +613,7 @@ type JobAdditionalFields struct { Pids []int `json:"pids"` // List of process IDs associated with the job Pgids []int `json:"pgids"` // List of process group IDs associated with the job InteractiveJobURL string `json:"interactive_job_url"` + PendingReason Jsonb `json:"pending_reason"` // PEND reason (scheduler-set, JSONB object) } type JobSubmitRequest struct { diff --git a/src/frontend/xcecli/common/output/page_print_jobs.go b/src/frontend/xcecli/common/output/page_print_jobs.go index 487936f..4158947 100644 --- a/src/frontend/xcecli/common/output/page_print_jobs.go +++ b/src/frontend/xcecli/common/output/page_print_jobs.go @@ -320,9 +320,9 @@ func printJobsVerboseOutput(jobs *models.JobsResponse, filter []string) { // (3) print the pending reason for jobs waiting to be dispatched if job.JobStatus == "PEND" || job.JobStatus == "PSUSP" { - if job.PendingReason != "" { + if string(job.PendingReason) != "" { fmt.Fprintf(w, " PENDING REASONS:\n") - fmt.Fprintf(w, " %s\n", job.PendingReason) + fmt.Fprintf(w, " %s\n", string(job.PendingReason)) } } diff --git a/src/frontend/xcecli/models/jobs.go b/src/frontend/xcecli/models/jobs.go index 321f7b1..912f3e3 100755 --- a/src/frontend/xcecli/models/jobs.go +++ b/src/frontend/xcecli/models/jobs.go @@ -3,6 +3,7 @@ package models import ( "encoding/json" "fmt" + "strings" ) const ( @@ -162,7 +163,7 @@ type QueryJob struct { ExceptList string `json:"except_list"` RLimits64 []int64 `json:"rlimits64"` SubEnvVars string `json:"sub_env_vars"` - PendingReason string `json:"pending_reason"` + PendingReason PendingReason `json:"pending_reason"` JobStartInfo string `json:"job_start_info"` JobExecInfo string `json:"job_exec_info"` CoreLimit float64 `json:"core_limit"` @@ -523,6 +524,96 @@ func (e *ExitStatusValue) UnmarshalJSON(data []byte) error { return nil } +// pendingReasonJSON mirrors the apiserver's object-valued pending_reason +// (rawPendReason in backend/convert_pure.go). xcecli displays it as text, so +// this type is not exposed directly — it exists only to format the object. +type pendingReasonJSON struct { + MainReason int `json:"main_reason"` + MainReasonName string `json:"main_reason_name"` + HostReasons []struct { + Host string `json:"host"` + Reason int `json:"reason"` + ReasonName string `json:"reason_name"` + } `json:"host_reasons"` + ResReasons []struct { + Resource string `json:"resource"` + Host string `json:"host"` + Reason int `json:"reason"` + ReasonName string `json:"reason_name"` + } `json:"res_reasons"` +} + +// formatPendingReason renders the object-valued pending_reason as human text +// (LSF bjobs -p style), matching the schedulerclient/mcp-server summary. +func formatPendingReason(pr *pendingReasonJSON) string { + if pr == nil { + return "" + } + var lines []string + if pr.MainReasonName != "" { + lines = append(lines, pr.MainReasonName) + } else if pr.MainReason != 0 { + lines = append(lines, fmt.Sprintf("reason code %d", pr.MainReason)) + } + for _, h := range pr.HostReasons { + lines = append(lines, fmt.Sprintf("host %s: %s", h.Host, h.ReasonName)) + } + for _, r := range pr.ResReasons { + hostPart := "" + if r.Host != "" { + hostPart = " on " + r.Host + } + lines = append(lines, fmt.Sprintf("resource %s%s: %s", r.Resource, hostPart, + r.ReasonName)) + } + return strings.Join(lines, "; ") +} + +// UnmarshalJSON accepts pending_reason in any of the forms the API has served: +// - null / empty → "" +// - a plain JSON string → as-is (older API format) +// - an object → {main_reason, main_reason_name, host_reasons, +// res_reasons} (current API format) — rendered +// as human-readable text +// A bare object previously broke the `string` field with "cannot unmarshal +// object into string" — see xcecli job list. +func (p *PendingReason) UnmarshalJSON(data []byte) error { + s := string(data) + if s == "null" || s == "" { + *p = "" + return nil + } + if len(s) >= 2 && s[0] == '"' { + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + *p = PendingReason(str) + return nil + } + // object form (or anything else) — try to format it + var pr pendingReasonJSON + if err := json.Unmarshal(data, &pr); err != nil { + // not an object we can parse — fall back to the raw bytes + *p = PendingReason(s) + return nil + } + // A JSON object with none of the pending-reason fields set is not a real + // PEND reason (e.g. {"foo":1}) — surface the raw bytes rather than an + // empty string that would silently hide the actual value. + if pr.MainReason == 0 && pr.MainReasonName == "" && len(pr.HostReasons) == 0 && + len(pr.ResReasons) == 0 { + *p = PendingReason(s) + return nil + } + *p = PendingReason(formatPendingReason(&pr)) + return nil +} + +// PendingReason is a string that additionally tolerates the object-valued +// pending_reason returned by the current apiserver. +type PendingReason string + // JobOutputResponse represents the response from the job output API. type JobOutputResponse struct { Code int `json:"code"` diff --git a/src/frontend/xcecli/models/jobs_test.go b/src/frontend/xcecli/models/jobs_test.go new file mode 100644 index 0000000..e68d0b3 --- /dev/null +++ b/src/frontend/xcecli/models/jobs_test.go @@ -0,0 +1,95 @@ +package models + +import ( + "encoding/json" + "testing" +) + +func TestPendingReasonUnmarshalNull(t *testing.T) { + var p PendingReason + if err := json.Unmarshal([]byte("null"), &p); err != nil { + t.Fatalf("null: unexpected error: %v", err) + } + if p != "" { + t.Fatalf("null: want empty string, got %q", string(p)) + } +} + +func TestPendingReasonUnmarshalEmptyString(t *testing.T) { + var p PendingReason + if err := json.Unmarshal([]byte(`""`), &p); err != nil { + t.Fatalf("empty string: unexpected error: %v", err) + } + if p != "" { + t.Fatalf("empty string: want empty, got %q", string(p)) + } +} + +func TestPendingReasonUnmarshalPlainString(t *testing.T) { + var p PendingReason + if err := json.Unmarshal([]byte(`"host t1 unavailable"`), &p); err != nil { + t.Fatalf("plain string: unexpected error: %v", err) + } + if p != "host t1 unavailable" { + t.Fatalf("plain string: want passthrough, got %q", string(p)) + } +} + +func TestPendingReasonUnmarshalObject(t *testing.T) { + obj := `{ + "main_reason": 3, + "main_reason_name": "match_fail", + "host_reasons": [ + {"host": "t1", "reason": 3, "reason_name": "match_fail"}, + {"host": "t2", "reason": 3, "reason_name": "match_fail"} + ], + "res_reasons": [ + {"resource": "mem", "host": "t1", "reason": 9, "reason_name": "host_unavail"} + ] + }` + var p PendingReason + if err := json.Unmarshal([]byte(obj), &p); err != nil { + t.Fatalf("object: unexpected error: %v", err) + } + s := string(p) + if s != "match_fail; host t1: match_fail; host t2: match_fail; resource mem on t1: host_unavail" { + t.Fatalf("object: unexpected formatted text %q", s) + } +} + +func TestPendingReasonUnmarshalWithinQueryJob(t *testing.T) { + // Full QueryJob row as the apiserver returns it (OrderMap "Data" wrapper). + row := `{ + "Data": { + "job_id": 123, + "job_name": "test", + "job_status": "PEND", + "pending_reason": { + "main_reason": 8, + "main_reason_name": "time_window", + "host_reasons": [], + "res_reasons": [] + } + } + }` + var j QueryJob + if err := json.Unmarshal([]byte(row), &j); err != nil { + t.Fatalf("QueryJob unmarshal: unexpected error: %v", err) + } + if string(j.PendingReason) != "time_window" { + t.Fatalf("QueryJob: want formatted pending_reason %q, got %q", + "time_window", string(j.PendingReason)) + } +} + +func TestPendingReasonUnmarshalUnknownObject(t *testing.T) { + // An object we don't recognise should not fail the whole job list — it + // falls back to the raw bytes rather than erroring. + var p PendingReason + if err := json.Unmarshal([]byte(`{"foo": 1}`), &p); err != nil { + t.Fatalf("unknown object: unexpected error: %v", err) + } + if string(p) != `{"foo": 1}` { + t.Fatalf("unknown object: want raw fallback, got %q", string(p)) + } +}