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:
parent
9dbb75adb3
commit
a9c8044165
9 changed files with 529 additions and 39 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue