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:
parent
9d7628ef3f
commit
5753123798
4 changed files with 190 additions and 3 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
95
src/frontend/xcecli/models/jobs_test.go
Normal file
95
src/frontend/xcecli/models/jobs_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue