diff --git a/src/frontend/apiserver/models/jobs.go b/src/frontend/apiserver/models/jobs.go index 8a5a969c..c4c1b0b6 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 487936f2..41589472 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 321f7b17..912f3e34 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 00000000..e68d0b30 --- /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)) + } +}