myxce/src/frontend/apiserver/store/query.go
lei.chen 057274094d fix(apiserver): apply CompConditions in ExecuteQuery so job-by-id actually filters (bug-1490 adjacent)
GetJobByIDs (GET /xce/v1/jobs/:jobids) built its job_id filter via
ConvertToNewFilterArray into QueryOptions.CompConditions, but ExecuteQuery
only ever applied opts.Conditions and silently dropped CompConditions. Every
per-id job query therefore returned the ENTIRE job table, and the MCP
scheduler_get_job tool returned the first job in the store regardless of the
requested id (observed: get_job(73930445842022400) -> "post-clean").

Fix: add FilterCompConditions (per-field AND, OR across ConditionArrays,
main-condition AND OR(sub-conditions) within an array) and run it in
ExecuteQuery after the flat filter; extract the bitmask-aware per-row match
into rowMatchesCondition shared by FilterRows and FilterCompConditions.

Verified via the MCP server: scheduler_get_job now returns the requested job
(mcp-e2e, EXIT), and the full submit->hold->release->cancel->get cycle still
works.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 08:40:50 +00:00

434 lines
11 KiB
Go

package store
import (
"apiserver/utils"
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"strings"
)
const PER_PAGE_LIMIT = 100
// ExecuteQuery runs the full filter → sort → paginate → select pipeline.
func ExecuteQuery(rows []utils.OrderMap, opts *utils.QueryOptions, fields []string) ([]utils.OrderMap, string) {
if opts == nil {
return SelectFields(rows, fields), ""
}
// 1. Filter — both the flat AND-conditions AND the compound (per-field OR,
// array-aware) conditions. bug-1490-adjacent: CompConditions used to be
// silently dropped here, so GET /xce/v1/jobs/:jobids returned the whole
// job table (the mcp scheduler_get_job tool never saw the requested job).
if len(opts.Conditions) > 0 {
rows = FilterRows(rows, opts.Conditions)
}
if len(opts.CompConditions) > 0 {
rows = FilterCompConditions(rows, opts.CompConditions)
}
// 2. Sort
if len(opts.Orders) > 0 {
SortRows(rows, opts.Orders)
}
// 3. Paginate
limit := opts.Limit
if limit <= 0 {
limit = PER_PAGE_LIMIT
}
rows, nextToken := PaginateRows(rows, limit, opts.Bookmark, opts.Orders)
// 4. Select fields
rows = SelectFields(rows, fields)
return rows, nextToken
}
// MatchesCondition evaluates a single filter condition against a row.
func MatchesCondition(row utils.OrderMap, cond utils.Condition) bool {
fieldVal := row.Data[cond.Column]
switch cond.Operator {
case utils.OpEQ:
return compareValues(fieldVal, cond.Value.Value) == 0
case utils.OpNE:
return compareValues(fieldVal, cond.Value.Value) != 0
case utils.OpLT:
return compareValues(fieldVal, cond.Value.Value) < 0
case utils.OpLTE:
return compareValues(fieldVal, cond.Value.Value) <= 0
case utils.OpGT:
return compareValues(fieldVal, cond.Value.Value) > 0
case utils.OpGTE:
return compareValues(fieldVal, cond.Value.Value) >= 0
case utils.OpIN:
return valueInList(fieldVal, cond.Value.Value)
case utils.OpNOTIN:
return !valueInList(fieldVal, cond.Value.Value)
case utils.OpLIKE:
return matchLike(fieldVal, cond.Value.Value)
case utils.OpNOTLIKE:
return !matchLike(fieldVal, cond.Value.Value)
case utils.OpBETWEEN:
return valueBetween(fieldVal, cond.Value.Value)
case utils.OpISNULL:
return fieldVal == nil
case utils.OpNOTNULL:
return fieldVal != nil
}
return false
}
// MatchesBitmaskCondition handles bitmask fields (job_status, host_status).
// For bitmask EQ: (val & mask) != 0 means the status is set.
// For bitmask NE: (val & mask) == 0 means the status is NOT set.
// null/notnull are handled on the raw field value (bug-1504: job_status
// notnull previously fell through to `return false`, so `jobs list -a`
// filtered out every job).
func MatchesBitmaskCondition(row utils.OrderMap, field string, bitmask int64, op utils.OperatorType) bool {
fieldVal := row.Data[field]
switch op {
case utils.OpISNULL:
return fieldVal == nil
case utils.OpNOTNULL:
return fieldVal != nil
}
val := toInt64(fieldVal)
result := val & bitmask
switch op {
case utils.OpNE: // bitmask "eq" is stored as OpNE (val & mask != 0)
return result != 0
case utils.OpEQ: // bitmask "ne" is stored as OpEQ (val & mask == 0)
return result == 0
}
return false
}
// IsBitmaskField returns true for fields that use bitmask-based filtering.
func IsBitmaskField(field string) bool {
return field == "job_status" || field == "host_status"
}
// rowMatchesCondition evaluates a single condition against a row, dispatching
// bitmask fields (job_status/host_status) to MatchesBitmaskCondition and all
// others to MatchesCondition.
func rowMatchesCondition(row utils.OrderMap, cond utils.Condition) bool {
if IsBitmaskField(cond.Column) {
bitmask, ok := cond.Value.Value.(int64)
if !ok {
return false
}
return MatchesBitmaskCondition(row, cond.Column, bitmask, cond.Operator)
}
return MatchesCondition(row, cond)
}
// FilterRows applies all conditions (AND logic) to rows.
func FilterRows(rows []utils.OrderMap, conditions []utils.Condition) []utils.OrderMap {
result := make([]utils.OrderMap, 0, len(rows))
for _, row := range rows {
match := true
for _, cond := range conditions {
if !rowMatchesCondition(row, cond) {
match = false
break
}
}
if match {
result = append(result, row)
}
}
return result
}
// FilterCompConditions applies compound conditions — the per-field OR structure
// built by ConvertToNewFilterArray for array-aware queries (e.g. job_id with
// array_idx sub-ranges in GET /xce/v1/jobs/:jobids, or job_name filters in
// GET /xce/v1/jobs). A row must match EVERY field key present (AND across
// fields); within a field, the row matches if ANY of its ConditionArrays
// matches. A ConditionArray matches when its MainCondition holds AND either it
// has no SubORConditions or any sub-condition holds.
func FilterCompConditions(rows []utils.OrderMap, comp utils.CompCondition) []utils.OrderMap {
if len(comp) == 0 {
return rows
}
result := make([]utils.OrderMap, 0, len(rows))
for _, row := range rows {
match := true
for _, arrays := range comp {
fieldMatch := false
for _, arr := range arrays {
if !rowMatchesCondition(row, arr.MainCondition) {
continue
}
if len(arr.SubORConditions) == 0 {
fieldMatch = true
break
}
for _, sub := range arr.SubORConditions {
if rowMatchesCondition(row, sub) {
fieldMatch = true
break
}
}
if fieldMatch {
break
}
}
if !fieldMatch {
match = false
break
}
}
if match {
result = append(result, row)
}
}
return result
}
// SortRows sorts by multiple fields with asc/desc.
func SortRows(rows []utils.OrderMap, orders []utils.SortOrder) {
sort.SliceStable(rows, func(i, j int) bool {
for _, ord := range orders {
cmp := compareValues(rows[i].Data[ord.Column], rows[j].Data[ord.Column])
if cmp == 0 {
continue
}
if ord.Ascending {
return cmp < 0
}
return cmp > 0
}
return false
})
}
// PaginateRows applies limit + page_token (bookmark-based pagination).
func PaginateRows(rows []utils.OrderMap, limit int, pageToken string, orders []utils.SortOrder) ([]utils.OrderMap, string) {
startIdx := 0
if pageToken != "" {
startIdx = findPageTokenIndex(rows, pageToken, orders)
}
if limit <= 0 {
limit = PER_PAGE_LIMIT
}
endIdx := startIdx + limit
if endIdx > len(rows) {
endIdx = len(rows)
}
if startIdx >= len(rows) {
return []utils.OrderMap{}, ""
}
result := rows[startIdx:endIdx]
nextToken := ""
if endIdx < len(rows) {
nextToken = encodePageToken(result[len(result)-1], orders)
}
return result, nextToken
}
// SelectFields projects only requested fields from rows.
func SelectFields(rows []utils.OrderMap, fields []string) []utils.OrderMap {
if len(fields) == 0 {
return rows
}
result := make([]utils.OrderMap, len(rows))
for i, row := range rows {
newData := make(map[string]interface{})
for _, f := range fields {
if v, ok := row.Data[f]; ok {
newData[f] = v
}
}
result[i] = utils.OrderMap{Fields: fields, Data: newData}
}
return result
}
// --- Helper functions ---
// compareValues does type-aware comparison. Returns -1, 0, or 1.
func compareValues(a, b interface{}) int {
if a == nil && b == nil {
return 0
}
if a == nil {
return -1
}
if b == nil {
return 1
}
// Try int64 comparison
aInt, aIsInt := toInt64Safe(a)
bInt, bIsInt := toInt64Safe(b)
if aIsInt && bIsInt {
if aInt < bInt {
return -1
}
if aInt > bInt {
return 1
}
return 0
}
// Try float64 comparison
aFloat, aIsFloat := toFloat64Safe(a)
bFloat, bIsFloat := toFloat64Safe(b)
if aIsFloat && bIsFloat {
if aFloat < bFloat {
return -1
}
if aFloat > bFloat {
return 1
}
return 0
}
// String comparison
aStr := fmt.Sprintf("%v", a)
bStr := fmt.Sprintf("%v", b)
if aStr < bStr {
return -1
}
if aStr > bStr {
return 1
}
return 0
}
func toInt64(v interface{}) int64 {
val, _ := toInt64Safe(v)
return val
}
func toInt64Safe(v interface{}) (int64, bool) {
switch val := v.(type) {
case int64:
return val, true
case int:
return int64(val), true
case int32:
return int64(val), true
case uint64:
return int64(val), true
case float64:
return int64(val), true
}
return 0, false
}
func toFloat64Safe(v interface{}) (float64, bool) {
switch val := v.(type) {
case float64:
return val, true
case float32:
return float64(val), true
case int64:
return float64(val), true
case int:
return float64(val), true
}
return 0, false
}
// valueInList checks if val is in a list of Values.
func valueInList(val interface{}, list interface{}) bool {
switch l := list.(type) {
case []utils.Value:
for _, item := range l {
if compareValues(val, item.Value) == 0 {
return true
}
}
case []interface{}:
for _, item := range l {
if compareValues(val, item) == 0 {
return true
}
}
}
return false
}
// matchLike implements SQL LIKE with Go string matching.
func matchLike(fieldVal, pattern interface{}) bool {
fieldStr := fmt.Sprintf("%v", fieldVal)
patternStr := fmt.Sprintf("%v", pattern)
// If pattern contains SQL wildcards, translate them
if strings.Contains(patternStr, "%") {
// Simple wildcard matching
if strings.HasPrefix(patternStr, "%") && strings.HasSuffix(patternStr, "%") {
return strings.Contains(strings.ToLower(fieldStr), strings.ToLower(strings.Trim(patternStr, "%")))
}
if strings.HasPrefix(patternStr, "%") {
return strings.HasSuffix(strings.ToLower(fieldStr), strings.ToLower(strings.TrimPrefix(patternStr, "%")))
}
if strings.HasSuffix(patternStr, "%") {
return strings.HasPrefix(strings.ToLower(fieldStr), strings.ToLower(strings.TrimSuffix(patternStr, "%")))
}
}
// Default: substring match (contains)
return strings.Contains(strings.ToLower(fieldStr), strings.ToLower(patternStr))
}
// valueBetween checks if val is between the first two elements of a Value array.
func valueBetween(val interface{}, rangeVal interface{}) bool {
switch r := rangeVal.(type) {
case []utils.Value:
if len(r) >= 2 {
return compareValues(val, r[0].Value) >= 0 && compareValues(val, r[1].Value) <= 0
}
}
return false
}
// Page token encoding/decoding using base64 JSON.
func encodePageToken(lastRow utils.OrderMap, orders []utils.SortOrder) string {
tokenData := make(map[string]interface{})
for _, ord := range orders {
if v, ok := lastRow.Data[ord.Column]; ok {
tokenData[ord.Column] = v
}
}
jsonBytes, err := json.Marshal(tokenData)
if err != nil {
return ""
}
return base64.URLEncoding.EncodeToString(jsonBytes)
}
func findPageTokenIndex(rows []utils.OrderMap, pageToken string, orders []utils.SortOrder) int {
jsonBytes, err := base64.URLEncoding.DecodeString(pageToken)
if err != nil {
return 0
}
var tokenData map[string]interface{}
if err := json.Unmarshal(jsonBytes, &tokenData); err != nil {
return 0
}
for i, row := range rows {
match := true
for _, ord := range orders {
tokenVal, ok := tokenData[ord.Column]
if !ok {
continue
}
if compareValues(row.Data[ord.Column], tokenVal) != 0 {
match = false
break
}
}
if match {
return i + 1 // Start after the matched row
}
}
return 0
}