diff --git a/src/docs/apiserver-backend-tasks.md b/src/docs/apiserver-backend-tasks.md index feeb62b..1b41246 100644 --- a/src/docs/apiserver-backend-tasks.md +++ b/src/docs/apiserver-backend-tasks.md @@ -85,11 +85,14 @@ ## P1-1: Handle Resource Value Push -- [ ] **#10 P1-1: Handle resource value push (OPCode_notify)** - - `backend/clib.go` — Add CGO val helpers to preamble (`xce_val_which`, `xce_val_i32`, etc.) - - `backend/clib.go` — Add `OPCode_notify` case at top-level switch in `go_push_handler()` (NOT inside `handlePush()`) - - `backend/clib.go` — Add `handleNotifyData()` for `NotifyData_data_schedUpdateResvals` - - `backend/convert.go` — Add `cResvalToDistribution()` — switch on `Value_u_*` constants, all 7 cases separate +- [x] **#10 P1-1: Handle resource value push (OPCode_notify)** + - `backend/clib.go` — Added 8 CGO val union helpers (`xce_val_which`, `xce_val_i32`, `i64`, `u32`, `u64`, `fval`, `str`, `bool`) + - `backend/clib.go` — Added `OPCode_notify` case at top-level switch in `go_push_handler()` (NOT inside `handlePush()`) + - `backend/clib.go` — Added `handleNotifyData()` for `NotifyData_data_schedUpdateResvals` + - `backend/convert.go` — Added `cResvalToDistribution()` — switches on `Value_u_*` constants, all 7 cases separate + - `backend/convert_pure.go` — Added `rawResvalData` struct + `rawResvalToDistribution()` pure-Go version + - `backend/convert_test.go` — 10 TDD tests covering all 7 value types, nil val, unknown discriminant, boolean false + - All 42 backend tests passing (`CGO_ENABLED=0 go test ./backend/ -v`) --- diff --git a/src/frontend/apiserver/backend/clib.go b/src/frontend/apiserver/backend/clib.go index 64b0a43..70c7a5e 100644 --- a/src/frontend/apiserver/backend/clib.go +++ b/src/frontend/apiserver/backend/clib.go @@ -48,6 +48,16 @@ static void jobctrl_set_siginfo(xce_jobctrl_t *ctrl, xce_signal_t *sig) { static void jobctrl_set_ckptoption(xce_jobctrl_t *ctrl, xce_ckpt_options_t *ckpt) { ctrl->u.ckptoption = ckpt; } + +// xce_val_t union accessors — CGO cannot access C union fields by name. +static int xce_val_which(xce_val_t *v) { return v->which; } +static int32_t xce_val_i32(xce_val_t *v) { return v->u.ivalue32; } +static int64_t xce_val_i64(xce_val_t *v) { return v->u.ivalue64; } +static uint32_t xce_val_u32(xce_val_t *v) { return v->u.uvalue32; } +static uint64_t xce_val_u64(xce_val_t *v) { return v->u.uvalue64; } +static double xce_val_fval(xce_val_t *v) { return v->u.fvalue; } +static char* xce_val_str(xce_val_t *v) { return v->u.svalue; } +static int xce_val_bool(xce_val_t *v) { return v->u.bvalue; } */ import "C" @@ -160,6 +170,15 @@ func go_push_handler(req *C.xce_request_t, _ unsafe.Pointer) C.int { switch req.opcode { case C.OPCode_push: return handlePush(req) + case C.OPCode_notify: + if req.body_which != C.Request_body_notifymsg { + return 0 + } + nd := (*C.xce_notify_data_t)(unsafe.Pointer(C.xce_request_body(req))) + if nd == nil { + return 0 + } + return handleNotifyData(nd) } return 0 } @@ -238,6 +257,36 @@ func handleConfigPush(cfgdata *C.xce_configdata_t) C.int { return 0 } +// handleNotifyData processes an OPCode_notify request. +// xce_notify_data_t uses 'event' (not 'data_which') as its union discriminant. +func handleNotifyData(nd *C.xce_notify_data_t) C.int { + if nd == nil { + return 0 + } + switch nd.event { + case C.NotifyData_data_schedUpdateResvals: + rvl := (*C.xce_resval_list_t)(C.xce_notify_data_ptr(nd)) + if rvl == nil { + return 0 + } + n := int(rvl.count) + if n == 0 || rvl.vals == nil { + return 0 + } + vals := unsafe.Slice(rvl.vals, n) + for _, vp := range vals { + if vp == nil { + continue + } + rd := cResvalToDistribution(vp) + if rd != nil { + gHandler.OnResourceLocationUpdated(rd) + } + } + } + return 0 +} + // SubmitJob sends a job submission to the backend via xcecli_jobsubmit. // arraySize=1 for normal jobs, >1 for array jobs. // The store is updated only when the backend pushes the resulting OnJobCreated diff --git a/src/frontend/apiserver/backend/convert.go b/src/frontend/apiserver/backend/convert.go index 97ca3e2..7d5c671 100644 --- a/src/frontend/apiserver/backend/convert.go +++ b/src/frontend/apiserver/backend/convert.go @@ -10,7 +10,10 @@ import "C" import ( "apiserver/models" + logger "apiserver/utils" "unsafe" + + "go.uber.org/zap" ) // goString safely converts a C string to a Go string, handling nil. @@ -250,3 +253,52 @@ func freeJobSubmit(js *C.xce_job_submit_t, sd *C.job_submit_data_t, si *C.job_st C.free(unsafe.Pointer(js)) } } + +// cResvalToDistribution converts a C xce_resval_t pointer to a Go ResourceDistributionInfo. +// Returns nil if vp.val is NULL (nil guard prevents CGO panic). +// vp.val is a *C.xce_val_t pointer field — pass directly, no &. +func cResvalToDistribution(vp *C.xce_resval_t) *models.ResourceDistributionInfo { + rd := &models.ResourceDistributionInfo{ + ResourceName: C.GoString(vp.name), + Location: C.GoString(vp.host), + IsDynamic: true, // resource values pushed from workers are always dynamic + } + // Guard: vp.val may be NULL if the controller sends a resval with no value set. + // Dereferencing NULL in xce_val_which() would segfault in C. + if vp.val == nil { + logger.Warn("resval with nil value, skipping", + zap.String("resource", rd.ResourceName), + zap.String("host", rd.Location)) + return nil + } + switch C.xce_val_which(vp.val) { + case C.Value_u_ivalue32: + rd.ValueType = int(models.NUMERIC) + rd.NumericValue = float64(C.xce_val_i32(vp.val)) + case C.Value_u_ivalue64: + rd.ValueType = int(models.NUMERIC) + rd.NumericValue = float64(C.xce_val_i64(vp.val)) + case C.Value_u_uvalue32: + rd.ValueType = int(models.NUMERIC) + rd.NumericValue = float64(C.xce_val_u32(vp.val)) + case C.Value_u_uvalue64: + rd.ValueType = int(models.NUMERIC) + rd.NumericValue = float64(C.xce_val_u64(vp.val)) + case C.Value_u_fvalue: + rd.ValueType = int(models.NUMERIC) + rd.NumericValue = C.xce_val_fval(vp.val) // C.double → float64, no explicit cast needed + case C.Value_u_svalue: + rd.ValueType = int(models.STRING) + rd.StringValue = C.GoString(C.xce_val_str(vp.val)) + case C.Value_u_bvalue: + rd.ValueType = int(models.BOOLEAN) + rd.BooleanValue = C.xce_val_bool(vp.val) != 0 + default: + logger.Warn("unknown xce_val_t discriminant, skipping", + zap.Int("which", int(C.xce_val_which(vp.val))), + zap.String("resource", rd.ResourceName), + zap.String("host", rd.Location)) + return nil + } + return rd +} diff --git a/src/frontend/apiserver/backend/convert_pure.go b/src/frontend/apiserver/backend/convert_pure.go index f171ef0..ce9eb11 100644 --- a/src/frontend/apiserver/backend/convert_pure.go +++ b/src/frontend/apiserver/backend/convert_pure.go @@ -13,16 +13,16 @@ import ( const ( nTagBits = 2 tagShift = 62 - nClusterIDBits = 6 - clusterIDShift = 56 - nApiserverIDBits = 4 - apiserverIDShift = 52 - nTimestampBits = 32 - timestampShift = 20 - nArrayIdxBits = 15 - sequenceHighShift = 15 // mirrors SEQUENCE_HIGH_SHIFT in config.h - nSequenceBits = 30 // mirrors N_SEQUENCE_BITS in config.h - tagJobArray = 0x01 // mirrors TAG_JOB_ARRAY in config.h + nClusterIDBits = 6 + clusterIDShift = 56 + nApiserverIDBits = 4 + apiserverIDShift = 52 + nTimestampBits = 32 + timestampShift = 20 + nArrayIdxBits = 15 + sequenceHighShift = 15 // mirrors SEQUENCE_HIGH_SHIFT in config.h + nSequenceBits = 30 // mirrors N_SEQUENCE_BITS in config.h + tagJobArray = 0x01 // mirrors TAG_JOB_ARRAY in config.h ) // extractBits extracts a bit field from id at the given shift position. @@ -260,14 +260,70 @@ func rawHostConfigToModel(r rawHostConfigData) *models.HostInfo { } } +// rawResvalData mirrors the C xce_resval_t fields used by the Go model. +// IsDynamic is always true for push-sourced values (controller sends live measurements). +type rawResvalData struct { + name string + host string + valWhich int // mirrors C xce_val_t.which (Value_u_* discriminant) + i32 int32 + i64 int64 + u32 uint32 + u64 uint64 + fval float64 + sval string + bval bool + valNil bool // true when the C xce_val_t* was NULL +} + +// rawResvalToDistribution maps a rawResvalData to a models.ResourceDistributionInfo. +// Returns nil when valNil is true (C val pointer was NULL) or when valWhich +// is an unknown discriminant — callers should skip nil results. +func rawResvalToDistribution(r rawResvalData) *models.ResourceDistributionInfo { + rd := &models.ResourceDistributionInfo{ + ResourceName: r.name, + Location: r.host, + IsDynamic: true, // resource values pushed from workers are always dynamic + } + if r.valNil { + return nil + } + switch r.valWhich { + case 0: // Value_u_ivalue32 + rd.ValueType = int(models.NUMERIC) + rd.NumericValue = float64(r.i32) + case 2: // Value_u_ivalue64 + rd.ValueType = int(models.NUMERIC) + rd.NumericValue = float64(r.i64) + case 1: // Value_u_uvalue32 + rd.ValueType = int(models.NUMERIC) + rd.NumericValue = float64(r.u32) + case 3: // Value_u_uvalue64 + rd.ValueType = int(models.NUMERIC) + rd.NumericValue = float64(r.u64) + case 5: // Value_u_fvalue + rd.ValueType = int(models.NUMERIC) + rd.NumericValue = r.fval + case 4: // Value_u_svalue + rd.ValueType = int(models.STRING) + rd.StringValue = r.sval + case 6: // Value_u_bvalue + rd.ValueType = int(models.BOOLEAN) + rd.BooleanValue = r.bval + default: + return nil + } + return rd +} + // rawClusterData mirrors the C cluster_config_t fields used by the Go model. // Only config-sourced fields are included (heartbeat_interval, vcpus, inactive_heartbeat_count). // Built-in/sourced fields (Version, Status, ControllerName, etc.) come from LoadBuiltinData // and are not part of the config push conversion. type rawClusterData struct { name string - heartbeatInterval int - vcpus int + heartbeatInterval int + vcpus int inactiveHeartbeatCount int } diff --git a/src/frontend/apiserver/backend/convert_test.go b/src/frontend/apiserver/backend/convert_test.go index b0bed19..3eaa847 100644 --- a/src/frontend/apiserver/backend/convert_test.go +++ b/src/frontend/apiserver/backend/convert_test.go @@ -497,6 +497,188 @@ func TestRawHostConfigToModel(t *testing.T) { } } +func TestRawResvalToDistribution_FloatValue(t *testing.T) { + r := rawResvalData{ + name: "r1m", + host: "node01", + valWhich: 5, // Value_u_fvalue + fval: 3.14, + } + got := rawResvalToDistribution(r) + if got == nil { + t.Fatal("expected non-nil result for fvalue") + } + if got.ResourceName != "r1m" { + t.Errorf("ResourceName: got %q, want %q", got.ResourceName, "r1m") + } + if got.Location != "node01" { + t.Errorf("Location: got %q, want %q", got.Location, "node01") + } + if !got.IsDynamic { + t.Errorf("IsDynamic: got false, want true") + } + if got.ValueType != int(models.NUMERIC) { + t.Errorf("ValueType: got %d, want %d (NUMERIC)", got.ValueType, models.NUMERIC) + } + if got.NumericValue != 3.14 { + t.Errorf("NumericValue: got %f, want 3.14", got.NumericValue) + } +} + +func TestRawResvalToDistribution_StringValue(t *testing.T) { + r := rawResvalData{ + name: "type", + host: "node02", + valWhich: 4, // Value_u_svalue + sval: "LINUX64", + } + got := rawResvalToDistribution(r) + if got == nil { + t.Fatal("expected non-nil result for svalue") + } + if got.ValueType != int(models.STRING) { + t.Errorf("ValueType: got %d, want %d (STRING)", got.ValueType, models.STRING) + } + if got.StringValue != "LINUX64" { + t.Errorf("StringValue: got %q, want %q", got.StringValue, "LINUX64") + } +} + +func TestRawResvalToDistribution_BooleanValue(t *testing.T) { + r := rawResvalData{ + name: "available", + host: "node03", + valWhich: 6, // Value_u_bvalue + bval: true, + } + got := rawResvalToDistribution(r) + if got == nil { + t.Fatal("expected non-nil result for bvalue") + } + if got.ValueType != int(models.BOOLEAN) { + t.Errorf("ValueType: got %d, want %d (BOOLEAN)", got.ValueType, models.BOOLEAN) + } + if !got.BooleanValue { + t.Errorf("BooleanValue: got false, want true") + } +} + +func TestRawResvalToDistribution_Int32Value(t *testing.T) { + r := rawResvalData{ + name: "slots", + host: "node04", + valWhich: 0, // Value_u_ivalue32 + i32: 16, + } + got := rawResvalToDistribution(r) + if got == nil { + t.Fatal("expected non-nil result for ivalue32") + } + if got.ValueType != int(models.NUMERIC) { + t.Errorf("ValueType: got %d, want %d (NUMERIC)", got.ValueType, models.NUMERIC) + } + if got.NumericValue != 16 { + t.Errorf("NumericValue: got %f, want 16", got.NumericValue) + } +} + +func TestRawResvalToDistribution_Int64Value(t *testing.T) { + r := rawResvalData{ + name: "bigcounter", + host: "node05", + valWhich: 2, // Value_u_ivalue64 + i64: 9223372036854775807, // max int64 + } + got := rawResvalToDistribution(r) + if got == nil { + t.Fatal("expected non-nil result for ivalue64") + } + if got.ValueType != int(models.NUMERIC) { + t.Errorf("ValueType: got %d, want %d (NUMERIC)", got.ValueType, models.NUMERIC) + } + if got.NumericValue != float64(int64(9223372036854775807)) { + t.Errorf("NumericValue: got %f, want max int64 as float64", got.NumericValue) + } +} + +func TestRawResvalToDistribution_Uint32Value(t *testing.T) { + r := rawResvalData{ + name: "uint32res", + host: "node06", + valWhich: 1, // Value_u_uvalue32 + u32: 4294967295, // max uint32 + } + got := rawResvalToDistribution(r) + if got == nil { + t.Fatal("expected non-nil result for uvalue32") + } + if got.ValueType != int(models.NUMERIC) { + t.Errorf("ValueType: got %d, want %d", got.ValueType, models.NUMERIC) + } + if got.NumericValue != float64(uint32(4294967295)) { + t.Errorf("NumericValue: got %f, want 4294967295", got.NumericValue) + } +} + +func TestRawResvalToDistribution_Uint64Value(t *testing.T) { + r := rawResvalData{ + name: "uint64res", + host: "node07", + valWhich: 3, // Value_u_uvalue64 + u64: 18446744073709551615, // max uint64 + } + got := rawResvalToDistribution(r) + if got == nil { + t.Fatal("expected non-nil result for uvalue64") + } + if got.ValueType != int(models.NUMERIC) { + t.Errorf("ValueType: got %d, want %d", got.ValueType, models.NUMERIC) + } +} + +func TestRawResvalToDistribution_NilVal(t *testing.T) { + r := rawResvalData{ + name: "nilres", + host: "node08", + valNil: true, + } + got := rawResvalToDistribution(r) + if got != nil { + t.Errorf("expected nil for nil val, got %+v", got) + } +} + +func TestRawResvalToDistribution_UnknownDiscriminant(t *testing.T) { + r := rawResvalData{ + name: "unkres", + host: "node09", + valWhich: 99, // unknown + } + got := rawResvalToDistribution(r) + if got != nil { + t.Errorf("expected nil for unknown discriminant, got %+v", got) + } +} + +func TestRawResvalToDistribution_BooleanFalse(t *testing.T) { + r := rawResvalData{ + name: "available", + host: "node10", + valWhich: 6, // Value_u_bvalue + bval: false, + } + got := rawResvalToDistribution(r) + if got == nil { + t.Fatal("expected non-nil result for bvalue false") + } + if got.ValueType != int(models.BOOLEAN) { + t.Errorf("ValueType: got %d, want %d (BOOLEAN)", got.ValueType, models.BOOLEAN) + } + if got.BooleanValue { + t.Errorf("BooleanValue: got true, want false") + } +} + func TestRawClusterToModel(t *testing.T) { tests := []struct { name string @@ -506,9 +688,9 @@ func TestRawClusterToModel(t *testing.T) { { name: "normal values", raw: rawClusterData{ - name: "mycluster", - heartbeatInterval: 30, - vcpus: 16, + name: "mycluster", + heartbeatInterval: 30, + vcpus: 16, inactiveHeartbeatCount: 3, }, want: models.ClusterInfo{ @@ -536,9 +718,9 @@ func TestRawClusterToModel(t *testing.T) { { name: "built-in fields remain zero", raw: rawClusterData{ - name: "test", - heartbeatInterval: 45, - vcpus: 32, + name: "test", + heartbeatInterval: 45, + vcpus: 32, inactiveHeartbeatCount: 2, }, want: models.ClusterInfo{