feat: P1-1 handle resource value push via OPCode_notify

Add OPCode_notify handler in go_push_handler top-level switch
(not inside handlePush) to receive resource value updates from
the controller via xce_notify_data_t/schedUpdateResvals.

- Add 8 CGO union accessors for xce_val_t (which, i32, i64,
  u32, u64, fval, str, bool) since CGO cannot access union fields
- Add handleNotifyData() dispatching NotifyData_data_schedUpdateResvals
  events, iterating xce_resval_list_t and calling cResvalToDistribution
- Add cResvalToDistribution in convert.go with 7 separate switch
  cases for Value_u_* union discriminants (no combining i32/i64)
- Add rawResvalData + rawResvalToDistribution pure-Go version
  in convert_pure.go for testability without CGO
- Add 10 TDD tests covering all 7 value types, nil val guard,
  unknown discriminant, and boolean false
- All 42 backend tests passing
This commit is contained in:
Rongsong Shen 2026-04-17 13:54:03 +00:00
parent dc029a6d14
commit bc3103a6d5
5 changed files with 365 additions and 23 deletions

View file

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

View file

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

View file

@ -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
}

View file

@ -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
}

View file

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