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

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