xcecli: fix job list "cannot unmarshal pending_reason" + verbose fields

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 <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-08-15 10:46:47 +00:00
parent 9d7628ef3f
commit 5753123798
4 changed files with 190 additions and 3 deletions

View file

@ -613,6 +613,7 @@ type JobAdditionalFields struct {
Pids []int `json:"pids"` // List of process IDs associated with the job 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 Pgids []int `json:"pgids"` // List of process group IDs associated with the job
InteractiveJobURL string `json:"interactive_job_url"` InteractiveJobURL string `json:"interactive_job_url"`
PendingReason Jsonb `json:"pending_reason"` // PEND reason (scheduler-set, JSONB object)
} }
type JobSubmitRequest struct { type JobSubmitRequest struct {

View file

@ -320,9 +320,9 @@ func printJobsVerboseOutput(jobs *models.JobsResponse, filter []string) {
// (3) print the pending reason for jobs waiting to be dispatched // (3) print the pending reason for jobs waiting to be dispatched
if job.JobStatus == "PEND" || job.JobStatus == "PSUSP" { if job.JobStatus == "PEND" || job.JobStatus == "PSUSP" {
if job.PendingReason != "" { if string(job.PendingReason) != "" {
fmt.Fprintf(w, " PENDING REASONS:\n") fmt.Fprintf(w, " PENDING REASONS:\n")
fmt.Fprintf(w, " %s\n", job.PendingReason) fmt.Fprintf(w, " %s\n", string(job.PendingReason))
} }
} }

View file

@ -3,6 +3,7 @@ package models
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
) )
const ( const (
@ -162,7 +163,7 @@ type QueryJob struct {
ExceptList string `json:"except_list"` ExceptList string `json:"except_list"`
RLimits64 []int64 `json:"rlimits64"` RLimits64 []int64 `json:"rlimits64"`
SubEnvVars string `json:"sub_env_vars"` SubEnvVars string `json:"sub_env_vars"`
PendingReason string `json:"pending_reason"` PendingReason PendingReason `json:"pending_reason"`
JobStartInfo string `json:"job_start_info"` JobStartInfo string `json:"job_start_info"`
JobExecInfo string `json:"job_exec_info"` JobExecInfo string `json:"job_exec_info"`
CoreLimit float64 `json:"core_limit"` CoreLimit float64 `json:"core_limit"`
@ -523,6 +524,96 @@ func (e *ExitStatusValue) UnmarshalJSON(data []byte) error {
return nil 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. // JobOutputResponse represents the response from the job output API.
type JobOutputResponse struct { type JobOutputResponse struct {
Code int `json:"code"` Code int `json:"code"`

View file

@ -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))
}
}