diff --git a/src/frontend/apiserver/store/query.go b/src/frontend/apiserver/store/query.go index 508dbc45..6e00d927 100644 --- a/src/frontend/apiserver/store/query.go +++ b/src/frontend/apiserver/store/query.go @@ -101,7 +101,16 @@ func FilterRows(rows []utils.OrderMap, conditions []utils.Condition) []utils.Ord for _, row := range rows { match := true for _, cond := range conditions { - if IsBitmaskField(cond.Column) { + // bug-1680: IsBitmaskField alone is not enough to route into the + // bitmask path -- OpEQ/OpNE are the only two operator shapes + // buildIntCondition's in/notin bitmask branch ever produces. + // Any other operator on job_status/host_status (e.g. OpNOTNULL, + // from xcecli's -a flag building `{job_status:notnull:}`) must + // fall through to the generic evaluator below, which already + // handles it correctly -- MatchesBitmaskCondition's own switch + // only knows OpEQ/OpNE and silently returns false (never + // matching) for anything else. + if IsBitmaskField(cond.Column) && (cond.Operator == utils.OpEQ || cond.Operator == utils.OpNE) { bitmask, ok := cond.Value.Value.(int64) if !ok { match = false diff --git a/src/frontend/apiserver/store/query_test.go b/src/frontend/apiserver/store/query_test.go new file mode 100644 index 00000000..bdd9dc7a --- /dev/null +++ b/src/frontend/apiserver/store/query_test.go @@ -0,0 +1,63 @@ +package store + +import ( + "testing" + + "apiserver/utils" +) + +// bug-1680: FilterRows unconditionally routes any condition on a bitmask +// field (job_status/host_status) through MatchesBitmaskCondition, regardless +// of the condition's actual operator. MatchesBitmaskCondition only knows how +// to evaluate OpEQ/OpNE (the only shapes buildIntCondition's in/notin bitmask +// branch ever produces) and silently returns false for anything else -- +// including OpNOTNULL, the shape xcecli's `-a` flag produces via +// `{job_status:notnull:}`. The result: `-a` matches zero rows, every time, +// regardless of how many jobs actually exist. +func TestFilterRows_JobStatusNotNull_MatchesRealRows(t *testing.T) { + rows := []utils.OrderMap{ + {Fields: []string{"job_status"}, Data: map[string]interface{}{"job_status": int64(1)}}, // PEND + {Fields: []string{"job_status"}, Data: map[string]interface{}{"job_status": int64(2)}}, // RUN + {Fields: []string{"job_status"}, Data: map[string]interface{}{"job_status": int64(8)}}, // DONE + } + // Mirrors exactly what buildIntCondition's null/notnull scalar path + // produces for `{job_status:notnull:}` (types.go): Operator: OpNOTNULL, + // Value: {TypeInt, 0} -- the 0 is a placeholder, never meant to be + // interpreted as a bitmask. + cond := utils.Condition{ + Column: "job_status", + Operator: utils.OpNOTNULL, + Value: utils.Value{Type: utils.TypeInt, Value: int64(0)}, + } + + result := FilterRows(rows, []utils.Condition{cond}) + + if len(result) != len(rows) { + t.Errorf("FilterRows with job_status:notnull = %d rows, want %d (every row has a non-nil job_status)", len(result), len(rows)) + } +} + +// Regression coverage: the actual bitmask shape (OpEQ/OpNE, produced by +// buildIntCondition's in/notin branch) must keep working exactly as before. +func TestFilterRows_JobStatusBitmaskEQ_StillWorks(t *testing.T) { + rows := []utils.OrderMap{ + {Fields: []string{"job_status"}, Data: map[string]interface{}{"job_status": int64(1)}}, // PEND + {Fields: []string{"job_status"}, Data: map[string]interface{}{"job_status": int64(2)}}, // RUN + } + // Mirrors buildIntCondition's bitmask branch for `{job_status:in:(PEND)}`: + // op = OpNE, Value = the OR'd bitmask of the requested status names. + cond := utils.Condition{ + Column: "job_status", + Operator: utils.OpNE, + Value: utils.Value{Type: utils.TypeInt, Value: int64(1)}, + } + + result := FilterRows(rows, []utils.Condition{cond}) + + if len(result) != 1 { + t.Fatalf("FilterRows with job_status bitmask EQ = %d rows, want 1", len(result)) + } + if result[0].Data["job_status"] != int64(1) { + t.Errorf("matched row job_status = %v, want 1 (PEND)", result[0].Data["job_status"]) + } +}