apiserver: derive array stats from live job store; fix CLI stats decode

type=array_stats (xce jobs list -A) read store.ArrayJobs, which has no
production writer, so it always answered zero rows. Stats are now derived
at query time by scanning store.Jobs and aggregating per array base ID
(controllers/arraystats.go), mirroring hosts.go's computeHostSlotUsage
precedent: counters can never drift from the element rows.

- GetJobsInfo/GetJobByIDs array_stats branches run the derived rows
  through the same ExecuteQuery filter/sort/paginate pipeline as store
  queries; unique_job_id rides in the field list so processJobData can
  annotate job_id_human (SelectFields drops unlisted fields).
- processJobData: new rowInt64 helper (uint64 bit-preserved + float64/
  json.Number tolerant) replaces brittle bare type assertions for
  unique_job_id/array_idx reads; dead code removed (StackLimit
  wrong-source copy, unreachable index error).
- QueryData(TableArrayJob) now returns a guard error instead of always
  answering zero rows from the never-written store.
- xcecli: ArrayJobStatsData got UnmarshalJSON via UnmarshalOrderMapRow —
  the server's OrderMap wrapper ({Fields,Data}) decoded to all-zero
  fields, which is why table mode showed 0/- rows against live arrays.
- analysis.md CL5 marked fixed with the real root cause.

Cluster-verified on t1/t2/t3: submit→PEND→RUN→DONE per element, kill
path, job_id filter, and per-status counters all live; all daemons
healthy. Unit suites: apiserver + xcecli green (CGO_ENABLED=0).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-10-20 05:22:20 +00:00
parent 9dbb75adb3
commit a9c8044165
9 changed files with 529 additions and 39 deletions

View file

@ -0,0 +1,101 @@
package controllers
import (
"apiserver/backend"
"apiserver/models"
"apiserver/store"
"apiserver/utils"
"sort"
)
// Array statistics rows (type=array_stats, `xce jobs list -A`) are derived
// from the live job store at query time, mirroring the bug-1743 pattern for
// HostInfo.NumRun: store.ArrayJobs has no production writer (the controller's
// array push and every single-job update land in store.Jobs only), so a
// store-backed ArrayJobInfo would stay empty forever. Deriving per query keeps
// a single source of truth -- the counters can never drift from the element
// rows the other views show.
//
// The array identity (base job ID, name, user, queue, ...) comes from each
// element's own fields; every element of an array carries the same submit spec
// (the controller expands one shared descriptor), so the first element seen
// for a base ID supplies them.
// arrayAgg accumulates one array's counters while scanning store.Jobs.
type arrayAgg struct {
info models.ArrayJobInfo
}
// deriveArrayJobStats scans store.Jobs and aggregates every array element row
// into one models.ArrayJobInfo per array base job ID, sorted by job_id
// (the stats view's default sort). Ordinary (non-array) jobs are skipped.
func deriveArrayJobStats() []models.ArrayJobInfo {
aggs := make(map[int64]*arrayAgg)
for _, job := range store.Jobs.List() {
raw := uint64(job.Unique_JobID)
if !backend.IsArrayJob(raw) {
continue
}
base := int64(backend.ExtractArrayBaseID(raw))
agg, ok := aggs[base]
if !ok {
agg = &arrayAgg{info: models.ArrayJobInfo{
JobID: base,
ArrayJobName: job.JobName,
SubmitUser: job.SubmitUser,
QueueName: job.QueueName,
UserGroup: job.UserGroup,
JobGroup: job.JobGroup,
JobDescription: job.JobDescription,
JobName: job.JobName,
ProjectName: job.ProjectName,
LicenseProject: job.LicenseProject,
SLA: job.SLA,
}}
aggs[base] = agg
}
agg.info.NumJobs++
// Classify through JobStateStr so the counters always agree with the
// status string every other view shows (suspend states are stored as
// RUN|USUSP / RUN|SSUSP / PEND|PSUSP combinations there).
switch models.JobStateStr(job.JobStatus) {
case "USUSP":
agg.info.NumUSusp++
case "SSUSP":
agg.info.NumSSusp++
case "PSUSP":
agg.info.NumPSusp++
case "RUN":
agg.info.NumRun++
case "PEND":
agg.info.NumPend++
case "EXIT":
agg.info.NumExit++
case "DONE":
agg.info.NumDone++
}
}
out := make([]models.ArrayJobInfo, 0, len(aggs))
for _, agg := range aggs {
out = append(out, agg.info)
}
sort.Slice(out, func(i, j int) bool { return out[i].JobID < out[j].JobID })
return out
}
// arrayStatsToOrderMaps converts derived stats rows to the OrderMap shape
// QueryData hands to ExecuteQuery (mirrors store.StoreToOrderMaps).
func arrayStatsToOrderMaps(rows []models.ArrayJobInfo) []utils.OrderMap {
result := make([]utils.OrderMap, 0, len(rows))
for _, row := range rows {
m := store.StructToOrderMap(row)
// unique_job_id = the base ID, so processJobData's job_id_human
// annotation (which reads unique_job_id) covers stats rows the same
// way it covers ordinary job rows: `jobs list -A` rows carry the
// dash-quad human ID every other view shows.
m.Data["unique_job_id"] = row.JobID
result = append(result, m)
}
return result
}

View file

@ -0,0 +1,160 @@
package controllers
import (
"testing"
"apiserver/backend"
"apiserver/models"
"apiserver/store"
"apiserver/utils"
"go.uber.org/zap"
)
func init() {
utils.Log = zap.NewNop()
}
// Array statistics (type=array_stats, `xce jobs list -A`) are derived from
// the live job store -- store.ArrayJobs has no production writer, so a
// store-backed read always answered zero rows. These tests pin the derived
// counters, identity fields and the human-ID annotation.
// arrayStatsBase / arrayStatsBase2 are array-capable base IDs (tag 0, low 15
// bits clear), as generateArrayJobID hands out.
var arrayStatsBase = []uint64{
uint64(3)<<40 | uint64(1)<<20,
uint64(4)<<40 | uint64(1)<<20,
}
// mkArrayElement builds an array element row as the controller's push path
// stores it: unique_job_id = MakeElementID(base, idx), job_id = base.
func mkArrayElement(base uint64, idx int, status string) models.JobInfo {
uid := backend.MakeElementID(base, idx)
return models.JobInfo{
Unique_JobID: int64(uid),
JobID: int64(base),
ArrayIdx: idx,
JobStatus: models.JOBSTAT[status],
JobName: "arrv",
SubmitUser: "alice",
QueueName: "normal",
}
}
func TestDeriveArrayJobStats_CountsPerStatus(t *testing.T) {
store.InitStores()
// Array base[0]: 2 DONE, 1 RUN, 1 PEND.
for _, e := range []struct {
idx int
status string
}{
{1, "DONE"}, {2, "DONE"}, {3, "RUN"}, {4, "PEND"},
} {
store.Jobs.Put(uint64(mkArrayElement(arrayStatsBase[0], e.idx, e.status).Unique_JobID),
mkArrayElement(arrayStatsBase[0], e.idx, e.status))
}
// Array base[1]: 1 EXIT.
store.Jobs.Put(uint64(mkArrayElement(arrayStatsBase[1], 1, "EXIT").Unique_JobID),
mkArrayElement(arrayStatsBase[1], 1, "EXIT"))
// An ordinary job must not appear in the stats.
store.Jobs.Put(999, models.JobInfo{Unique_JobID: 999, JobID: 999,
JobStatus: models.JOBSTAT["RUN"], JobName: "plain"})
rows := deriveArrayJobStats()
if len(rows) != 2 {
t.Fatalf("expected 2 array rows (ordinary job excluded), got %d: %+v", len(rows), rows)
}
first := rows[0]
if first.JobID != int64(arrayStatsBase[0]) {
t.Fatalf("rows sorted by job_id: first = %d, want %d", first.JobID, arrayStatsBase[0])
}
if first.NumJobs != 4 || first.NumDone != 2 || first.NumRun != 1 || first.NumPend != 1 {
t.Errorf("array base[0] counters: jobs=%d done=%d run=%d pend=%d, want 4/2/1/1",
first.NumJobs, first.NumDone, first.NumRun, first.NumPend)
}
second := rows[1]
if second.JobID != int64(arrayStatsBase[1]) || second.NumJobs != 1 || second.NumExit != 1 {
t.Errorf("array base[1] counters: jobs=%d exit=%d, want 1/1", second.NumJobs, second.NumExit)
}
}
func TestDeriveArrayJobStats_IdentityFromFirstElement(t *testing.T) {
store.InitStores()
e := mkArrayElement(arrayStatsBase[0], 2, "RUN")
e.UserGroup = "ug1"
e.ProjectName = "proj"
store.Jobs.Put(uint64(e.Unique_JobID), e)
// A later element with different identity fields must not win (elements
// of one array share the submit spec; first-seen supplies the identity).
e2 := mkArrayElement(arrayStatsBase[0], 1, "DONE")
e2.SubmitUser = "bob"
store.Jobs.Put(uint64(e2.Unique_JobID), e2)
rows := deriveArrayJobStats()
if len(rows) != 1 {
t.Fatalf("expected 1 array row, got %d", len(rows))
}
got := rows[0]
if got.SubmitUser != "alice" || got.UserGroup != "ug1" || got.ProjectName != "proj" {
t.Errorf("identity fields: user=%q ug=%q proj=%q, want alice/ug1/proj",
got.SubmitUser, got.UserGroup, got.ProjectName)
}
}
func TestDeriveArrayJobStats_SuspendSubStates(t *testing.T) {
store.InitStores()
for _, e := range []struct {
idx int
status int
}{
// Suspend states are stored as RUN|USUSP / PEND|PSUSP
// combinations (JobStateStr's classification; the controller
// ORs the suspend bit onto the base state). SSUSP currently has
// no producer, so it is covered by the USUSP case symmetrically.
{1, models.JOBSTAT["RUN"] | models.JOBSTAT["USUSP"]},
{2, models.JOBSTAT["RUN"] | models.JOBSTAT["SSUSP"]},
} {
ele := mkArrayElement(arrayStatsBase[0], e.idx, "RUN")
ele.JobStatus = e.status
store.Jobs.Put(uint64(ele.Unique_JobID), ele)
}
psusp := mkArrayElement(arrayStatsBase[0], 3, "PEND")
psusp.JobStatus = models.JOBSTAT["PEND"] | models.JOBSTAT["PSUSP"]
store.Jobs.Put(uint64(psusp.Unique_JobID), psusp)
rows := deriveArrayJobStats()
if len(rows) != 1 {
t.Fatalf("expected 1 array row, got %d", len(rows))
}
got := rows[0]
if got.NumJobs != 3 || got.NumSSusp != 1 || got.NumUSusp != 1 || got.NumPSusp != 1 ||
got.NumRun != 0 || got.NumPend != 0 {
t.Errorf("suspend counters: jobs=%d ssusp=%d ususp=%d psusp=%d run=%d pend=%d",
got.NumJobs, got.NumSSusp, got.NumUSusp, got.NumPSusp, got.NumRun, got.NumPend)
}
}
func TestArrayStatsToOrderMaps_AnnotatesHumanID(t *testing.T) {
stats := []models.ArrayJobInfo{{JobID: int64(arrayStatsBase[0]), NumJobs: 2, NumDone: 2}}
rows := arrayStatsToOrderMaps(stats)
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
// The conversion carries unique_job_id (the array base ID); processJobData
// turns it into job_id_human at the HTTP layer, mirroring element rows.
got, ok := rows[0].Data["unique_job_id"]
if !ok {
t.Fatal("unique_job_id missing from stats row")
}
if got != int64(arrayStatsBase[0]) {
t.Errorf("unique_job_id = %v, want %d", got, arrayStatsBase[0])
}
if got, _ := rows[0].Data["num_jobs"].(int); got != 2 {
t.Errorf("num_jobs = %v, want 2", rows[0].Data["num_jobs"])
}
}

View file

@ -325,7 +325,10 @@ func (j JobController) GetJobsInfo(ctx *gin.Context) {
// ---- check fields ----
// get fields and do check, if not set, use default display columns
if typeValue == "array_stats" {
fields = models.ARRAY_JOB_DEFAULT_COLUMN
// unique_job_id must ride along in the field list: ExecuteQuery's
// SelectFields drops anything not requested, and processJobData
// annotates job_id_human from it.
fields = append(append([]string{}, models.ARRAY_JOB_DEFAULT_COLUMN...), "unique_job_id")
} else {
if fieldsStr != "" {
fields = utils.ParseFields(fieldsStr)
@ -478,7 +481,13 @@ func (j JobController) GetJobsInfo(ctx *gin.Context) {
var jqueryReply []utils.OrderMap // Declare jqueryReply
pagetoken := "" // Initialize pagetoken with an empty string or appropriate value
if typeValue == "array_stats" {
jqueryReply, pagetoken, err = services.QueryData(services.TableArrayJob, queryOpts, fields)
// Stats rows are derived from the live job store (arraystats.go):
// store.ArrayJobs has no production writer, so the query used to
// always answer zero rows (`xce jobs list -A` → "no job found").
jqueryReply = arrayStatsToOrderMaps(deriveArrayJobStats())
// Pagination over derived rows: run the same filter → sort →
// paginate → select pipeline the store path would.
jqueryReply, pagetoken = store.ExecuteQuery(jqueryReply, queryOpts, fields)
} else {
jqueryReply, pagetoken, err = services.QueryData(services.TableJobInfo, queryOpts, fields)
}
@ -684,7 +693,8 @@ func (j JobController) GetJobByIDs(ctx *gin.Context) {
var fields []string
if typeValue == "array_stats" {
fields = models.ARRAY_JOB_DEFAULT_COLUMN
// same unique_job_id requirement as GetJobsInfo's array_stats path
fields = append(append([]string{}, models.ARRAY_JOB_DEFAULT_COLUMN...), "unique_job_id")
} else {
if fieldsStr != "" {
fields = utils.ParseFields(fieldsStr)
@ -765,7 +775,11 @@ func (j JobController) GetJobByIDs(ctx *gin.Context) {
var jqueryReply []utils.OrderMap // Declare jqueryReply
if typeValue == "array_stats" {
jqueryReply, page_token, err = services.QueryData(services.TableArrayJob, queryOpts, fields)
// Stats rows are derived from the live job store
// (arraystats.go): store.ArrayJobs has no production writer,
// so the query used to always answer zero rows.
jqueryReply = arrayStatsToOrderMaps(deriveArrayJobStats())
jqueryReply, page_token = store.ExecuteQuery(jqueryReply, queryOpts, fields)
} else {
jqueryReply, page_token, err = services.QueryData(services.TableJobInfo, queryOpts, fields)
}
@ -1531,9 +1545,6 @@ func preCheckJobRequest(req *models.JobSubmitRequest) (n int, isArray bool, err
if req.ShellLimit.DataLimit.MB != 0 {
req.DataLimit = req.ShellLimit.DataLimit
}
if req.ShellLimit.StackLimit.MB != 0 {
req.StackLimit = req.ShellLimit.DataLimit
}
if req.ShellLimit.StackLimit.MB != 0 {
req.StackLimit = req.ShellLimit.StackLimit
}
@ -1844,9 +1855,6 @@ func createArrayName(jobName string) (arrayName string, indices []int, slimit in
if err != nil || index < 1 {
return "", nil, -1, fmt.Errorf("index must be a positive integer")
}
if err != nil {
return "", nil, -1, fmt.Errorf("invalid index value: %s", part)
}
if _, exists := seenIndices[index]; !exists {
seenIndices[index] = struct{}{}
indices = append(indices, index)
@ -3015,12 +3023,23 @@ func normalizeJobStatus(v any) (int, bool) {
// produces as int, panicking every control request into an HTTP 500 before it
// reached the controller.
func jobArrayIdx(row utils.OrderMap) int {
if v, ok := row.Data["array_idx"]; ok {
if n, ok := normalizeJobInt64(v); ok {
return int(n)
}
n, _ := rowInt64(row, "array_idx")
return int(n)
}
// rowInt64 reads an integer column of a store row as int64, tolerating every
// numeric representation the store or a JSON decode path produces (int,
// int32, int64, float64, json.Number; uint64 bit-pattern preserved, as
// rowUint64 does). Returns ok=false when the column is absent or not numeric.
func rowInt64(row utils.OrderMap, col string) (int64, bool) {
v, ok := row.Data[col]
if !ok {
return 0, false
}
return 0
if u, isUint64 := v.(uint64); isUint64 {
return int64(u), true
}
return normalizeJobInt64(v)
}
func processJobData(rows []utils.OrderMap, pagetoken string) utils.QueryResult {
@ -3031,21 +3050,13 @@ func processJobData(rows []utils.OrderMap, pagetoken string) utils.QueryResult {
// format, which parses only that form. Data correctness lives here,
// not in the clients. FormatJobID is the apiserver's own C fmt_jobid
// equivalent (backend.FormatJobID).
if uid, exist := row.Data["unique_job_id"]; exist {
var jobID int64
switch v := uid.(type) {
case int64:
jobID = v
case int:
jobID = int64(v)
case int32:
jobID = int64(v)
case float64:
jobID = int64(v)
}
if jobID != 0 {
row.Data["job_id_human"] = backend.FormatJobID(jobID)
}
if jobID, ok := rowInt64(row, "unique_job_id"); ok && jobID != 0 {
// Human-readable job ID (cluster-apiserver-timestamp-seq) so
// clients (MCP server, CLI) can cross-reference with the
// xce_test/xcecli dash format, which parses only that form. Data
// correctness lives here, not in the clients. FormatJobID is the
// apiserver's own C fmt_jobid equivalent (backend.FormatJobID).
row.Data["job_id_human"] = backend.FormatJobID(jobID)
}
if v, exist := row.Data["job_status"]; exist {
@ -3059,16 +3070,16 @@ func processJobData(rows []utils.OrderMap, pagetoken string) utils.QueryResult {
if jobName, exist := row.Data["job_name"]; exist {
// Use IsArrayJob to detect array children, which covers
// the first child (idx=0) that the old array_idx > 0 check missed.
var isArray bool
if uid, ok := row.Data["unique_job_id"].(int64); ok {
// The ID read goes through the tolerant rowInt64 (bug-1490's
// lesson: a bare .(int64) assertion silently skips the row when
// the store or a JSON decode path produced another numeric
// representation).
isArray := false
if uid, ok := rowInt64(row, "unique_job_id"); ok {
isArray = backend.IsArrayJob(uint64(uid))
}
if isArray {
if arrayIdx, ok := row.Data["array_idx"].(int); ok {
row.Data["job_name"] = fmt.Sprintf("%s[%d]", jobName, arrayIdx)
} else if arrayIdx64, ok := row.Data["array_idx"].(int64); ok {
row.Data["job_name"] = fmt.Sprintf("%s[%d]", jobName, arrayIdx64)
}
row.Data["job_name"] = fmt.Sprintf("%s[%d]", jobName, jobArrayIdx(row))
} else {
row.Data["job_name"] = jobName
}

View file

@ -973,6 +973,111 @@ func assert400(t *testing.T, name string, w *httptest.ResponseRecorder, want str
}
}
// arrayStatsRows decodes the rows of a type=array_stats list reply.
func arrayStatsRows(t *testing.T, w *httptest.ResponseRecorder) []map[string]interface{} {
t.Helper()
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
dec := json.NewDecoder(bytes.NewReader(w.Body.Bytes()))
dec.UseNumber()
var raw struct {
Data struct {
Count int `json:"count"`
Rows []struct {
Data map[string]interface{} `json:"Data"`
} `json:"rows"`
} `json:"data"`
}
if err := dec.Decode(&raw); err != nil {
t.Fatalf("decode array_stats reply: %v; body: %s", err, w.Body.String())
}
rows := make([]map[string]interface{}, 0, len(raw.Data.Rows))
for _, r := range raw.Data.Rows {
rows = append(rows, r.Data)
}
if raw.Data.Count != len(rows) {
t.Errorf("count = %d, rows = %d", raw.Data.Count, len(rows))
}
return rows
}
// TestJobHandler_ArrayStatsDerivedFromJobStore covers `xce jobs list -A`
// (type=array_stats): rows are derived from the live job store, one per
// array, with counters matching the elements' states and a job_id_human
// annotation (store.ArrayJobs has no production writer).
func TestJobHandler_ArrayStatsDerivedFromJobStore(t *testing.T) {
f := newHandlerFixture(t)
s := seedQueryJobs(f)
x, y := s.X, s.Y
X := backend.FormatJobID(int64(x))
// Move element 2 of X to DONE so its counters must shift accordingly
// (seedArrayElements stored all four as RUN).
elem2 := backend.MakeElementID(x, 2)
got, _ := store.Jobs.Get(elem2)
got.JobStatus = models.JOBSTAT["DONE"]
store.Jobs.Put(elem2, got)
// GetJobsInfo?type=array_stats lists every seeded array with counters.
w := f.do(http.MethodGet, "/xce/v1/jobs?type=array_stats", nil)
rows := arrayStatsRows(t, w)
if len(rows) != 4 {
t.Fatalf("array_stats rows = %d, want 4 (arrays X, Y, Z, W)", len(rows))
}
byJobID := map[uint64]map[string]interface{}{}
for _, row := range rows {
n, ok := row["job_id"].(json.Number)
if !ok {
t.Fatalf("stats row without job_id: %v", row)
}
id, _ := strconv.ParseUint(n.String(), 10, 64)
byJobID[id] = row
}
xRow := byJobID[x]
if xRow == nil {
t.Fatalf("array X missing from stats: %v", rows)
}
// X has elements 1,2,3,4: 3 RUN + 1 DONE after the overwrite.
for field, want := range map[string]float64{
"num_jobs": 4, "num_run": 3, "num_done": 1,
"num_pend": 0, "num_exit": 0,
} {
n, _ := xRow[field].(json.Number)
if got, _ := n.Float64(); got != want {
t.Errorf("X.%s = %v, want %v", field, n.String(), want)
}
}
if human, _ := xRow["job_id_human"].(string); human != X {
t.Errorf("X.job_id_human = %q, want %q", human, X)
}
yRow := byJobID[y]
if yRow == nil {
t.Fatalf("array Y missing from stats: %v", rows)
}
if n, _ := yRow["num_jobs"].(json.Number); n.String() != "2" {
t.Errorf("Y.num_jobs = %v, want 2", yRow["num_jobs"])
}
// job_id filter (the ARRAY_JOB_SUPPORT_FILTERS path) narrows to one row.
w = f.do(http.MethodGet, "/xce/v1/jobs?type=array_stats&filter="+
url.QueryEscape("{job_id:eq:"+X+"}"), nil)
rows = arrayStatsRows(t, w)
if len(rows) != 1 {
t.Fatalf("filtered array_stats rows = %d, want 1", len(rows))
}
// GetJobByIDs?type=array_stats answers the same derived row.
w = f.do(http.MethodGet, "/xce/v1/jobs/"+url.PathEscape(X)+"?type=array_stats", nil)
rows = arrayStatsRows(t, w)
if len(rows) != 1 {
t.Fatalf("GetJobByIDs array_stats rows = %d, want 1; body: %s", len(rows), w.Body.String())
}
}
// TestJobHandler_QuerySelections covers the GetJobsInfo rows of the design's
// Testing Strategy (Query).
func TestJobHandler_QuerySelections(t *testing.T) {

View file

@ -3,6 +3,7 @@ package controllers
import (
"testing"
"apiserver/backend"
"apiserver/utils"
"go.uber.org/zap"
@ -55,3 +56,66 @@ func TestProcessJobData_NoJobIDHumanWhenZero(t *testing.T) {
t.Error("job_id_human should not be set for zero job id")
}
}
// The array-child [idx] name annotation must survive every numeric
// representation a store row or JSON decode path can produce. The old code
// asserted .(int64)/.(int) and silently left the suffix off for anything
// else (bug-1490's lesson, this time as a silent wrong value).
func TestProcessJobData_ArrayNameSuffix_NumericRepresentations(t *testing.T) {
// 1-0-1786585485-0[2]: an array-capable base with the array tag and
// index 2 packed in, as backend.MakeElementID composes it.
uid := int64(backend.MakeElementID(uint64(1)<<40|uint64(1)<<20, 2))
for name, tc := range map[string]struct {
uid any
idx any
}{
"int64/int": {uid, int(2)},
"int64/int64": {uid, int64(2)},
"float64/int": {float64(uid), int(2)},
"float64/int64": {float64(uid), int64(2)},
"float64/float": {float64(uid), float64(2)},
} {
rows := []utils.OrderMap{
{
Fields: []string{"unique_job_id", "job_name"},
Data: map[string]interface{}{
"unique_job_id": tc.uid,
"job_name": "arrv",
},
},
}
// array_idx rides the row when present (store path) and defaults to
// jobArrayIdx's 0 when absent (some JSON decode paths).
rows[0].Data["array_idx"] = tc.idx
res := processJobData(rows, "")
if got, _ := res.Rows[0].Data["job_name"].(string); got != "arrv[2]" {
t.Errorf("%s: job_name = %q, want arrv[2]", name, got)
}
}
}
// uint64 unique_job_id (a representation rowUint64 handles elsewhere) must
// still be recognized as an array child and formatted as a human ID.
func TestProcessJobData_Uint64UniqueJobID(t *testing.T) {
uid := backend.MakeElementID(uint64(1)<<40|uint64(1)<<20, 3)
rows := []utils.OrderMap{
{
Fields: []string{"unique_job_id", "job_name", "array_idx"},
Data: map[string]interface{}{
"unique_job_id": uid,
"job_name": "arrv",
"array_idx": uint64(3),
},
},
}
res := processJobData(rows, "")
row := res.Rows[0]
if got, _ := row.Data["job_name"].(string); got != "arrv[3]" {
t.Errorf("job_name = %v, want arrv[3]", row.Data["job_name"])
}
want := backend.FormatJobID(int64(uid))
if got, _ := row.Data["job_id_human"].(string); got != want {
t.Errorf("job_id_human = %v, want %v", row.Data["job_id_human"], want)
}
}

View file

@ -40,7 +40,11 @@ func QueryData(tableName string, queryOpts *utils.QueryOptions, fields []string)
case TableClusterInfo:
rows = store.StoreToOrderMaps(store.Clusters)
case TableArrayJob:
rows = store.StoreToOrderMaps(store.ArrayJobs)
// Array stats (type=array_stats) are derived from the live job store
// by the controllers package (arraystats.go) -- store.ArrayJobs has
// no production writer, so reading it here always answered zero rows.
// This case stays reachable only if a future caller forgets that.
return nil, "", fmt.Errorf("array stats are derived by the caller; QueryData(TableArrayJob) is not supported")
case TableJobExecHosts:
rows = store.StoreToOrderMaps(store.JobExecHosts)
case TableRolePermission:

View file

@ -152,7 +152,7 @@ None of these exist in the current codebase. The Swagger UI will show completely
| **CL2** | TLS verification disabled | `request.go` `SetRootCAs` | `InsecureSkipVerify: true` always set; `loadRootCAs` is defined but never called |
| **CL3** | Token header space inconsistency | `request.go` | `SubmitJob` uses `" "+token`; all other methods use `""+token` |
| **CL4** | No retry on transient errors | `request.go` | Single attempt; network blips cause immediate failure |
| **CL5** | `xce jobs` array stats has no display | `cmd/jobs/list.go` | `?type=array_stats` is sent but output formatter doesn't handle `ArrayJobStatsData` |
| **CL5** | ~~`xce jobs` array stats has no display~~ **FIXED** | `cmd/jobs/list.go` | Display path exists (`ArrayJobsPagePrinter`); the real gap was server-side — `?type=array_stats` read `store.ArrayJobs` which has no writer and always answered zero rows. Stats are now derived from the live job store (controllers/arraystats.go). |
---

View file

@ -507,6 +507,19 @@ type ArrayJobStatsData struct {
NumPSusp int `json:"num_psusp"`
}
// UnmarshalJSON decodes both the OrderMap wire format the server sends for
// store-backed rows ({"Fields":[...],"Data":{...}}) and a bare object, so
// array_stats replies decode past the wrapper instead of landing all-zero.
func (s *ArrayJobStatsData) UnmarshalJSON(data []byte) error {
type alias ArrayJobStatsData
var a alias
if err := UnmarshalOrderMapRow(data, &a); err != nil {
return err
}
*s = ArrayJobStatsData(a)
return nil
}
// job array statistics response data structure
type ArrayJobsStatsResponse struct {
Code int `json:"code"`

View file

@ -93,3 +93,35 @@ func TestPendingReasonUnmarshalUnknownObject(t *testing.T) {
t.Fatalf("unknown object: want raw fallback, got %q", string(p))
}
}
func TestArrayJobStatsData_UnmarshalOrderMapWrapper(t *testing.T) {
// The server sends store rows as OrderMap wrappers; without the custom
// UnmarshalJSON every stats field decoded to zero (CL5's "no display"
// symptom: xce jobs list -A showed 0/- rows against live arrays).
row := `{"Fields":["job_id","array_job_name","submit_user","num_jobs"],
"Data":{"job_id":73937137023582208,
"array_job_name":"arrv",
"submit_user":"alice",
"num_jobs":4,
"num_done":1,
"num_run":3}}`
var s ArrayJobStatsData
if err := json.Unmarshal([]byte(row), &s); err != nil {
t.Fatalf("ArrayJobStatsData unmarshal: %v", err)
}
if s.JobID != 73937137023582208 || s.ArrayJobName != "arrv" || s.SubmitUser != "alice" ||
s.NumJobs != 4 || s.NumDone != 1 || s.NumRun != 3 {
t.Fatalf("decoded stats: %+v", s)
}
}
func TestArrayJobStatsData_UnmarshalBareObject(t *testing.T) {
// Bare objects (no wrapper) must keep decoding as before.
var s ArrayJobStatsData
if err := json.Unmarshal([]byte(`{"job_id":7,"num_pend":2}`), &s); err != nil {
t.Fatalf("bare object unmarshal: %v", err)
}
if s.JobID != 7 || s.NumPend != 2 {
t.Fatalf("decoded stats: %+v", s)
}
}