feat: P0-1e expand handleConfigPush with host config and cluster dispatch

Wires the remaining cluster_config_t data into the notification pipeline:
- dispatchConfigPush() extracted as a pure-Go helper (CGO-free, testable)
  that drives OnQueueUpdated, OnResourceUpdated, OnHostConfigUpdated, and
  OnClusterUpdated from a single config push event
- handleConfigPush() delegates to dispatchConfigPush via the new helper,
  eliminating the previous inline loops
- OnHostConfigUpdated and OnClusterUpdated added to NotificationHandler
  interface and implemented with partial-merge semantics in DefaultHandler
- rawHostConfigData/rawHostConfigToModel and rawClusterData/rawClusterToModel
  added to convert_pure.go with their CGO extraction counterparts in convert.go

Safety fixes from code review:
- OnHostUpdated, OnHostConfigUpdated, OnClusterUpdated now copy *incoming to
  a local merged value before mutation, closing the TOCTOU window between
  store.Get and store.Put under concurrent pushes
- cClusterConfQueues and cClusterConfResources gain nil-pointer guards
  matching the existing guard in cClusterConfHostConfigs
- handleConfigPush gains an explicit gHandler nil check
- cClusterConfToClusterInfo returns nil on empty clustername (prevents phantom
  empty-key write to store.Clusters); handleConfigPush logs a warning when nil
- 36 unit tests passing (CGO_ENABLED=0)
This commit is contained in:
Rongsong Shen 2026-04-16 11:25:03 +00:00
parent 00c4472d6c
commit 893812e7aa
8 changed files with 850 additions and 11 deletions

View file

@ -0,0 +1,117 @@
# API Server Backend Enhancement — Task Tracker
## Status Legend
- [x] Completed
- [ ] Pending
- [>] In Progress (blocked)
---
## P0-1: Handle Cluster Config Push
- [x] **#1 P0-1a: Add HostConfig + Cluster fields to models**
- `models/clusters.go` — Added `HeartbeatInterval`, `Vcpus`, `InactiveHeartbeatCount` to `ClusterInfo`
- `models/hosts.go` — Changed `HostStateStr(0)` from `"OK"` to `"UNKNOWN"`
- `models/clusters_test.go` — New: verifies new fields serialize + existing fields unchanged
- `models/hosts_test.go` — New: verifies `HostStateStr` for zero, flags, non-flag, combined
- All 15 tests passing
- [x] **#2 P0-1b: Add pure Go conversion functions + TDD tests**
- `backend/convert_pure.go` — Added `rawHostConfigData` + `rawHostConfigToModel()`, `rawClusterData` + `rawClusterToModel()`
- `backend/convert_test.go` — 8 new table-driven tests: zero-value ints, empty strings, normal values, worker/config-sourced field isolation
- `TODO(dispatchwindow)` comment added per plan spec
- `DisptWindow` guard test added to catch accidental future CGO layer changes
- All 26 backend tests passing
- [x] **#3 P0-1c: Add CGO conversion layer in convert.go**
- `backend/convert.go` — Added `cHostConfigToModel()`, `cClusterConfHostConfigs()`, `cClusterConfToClusterInfo()`
- `cResdefToModel` intentionally skipped — `xce_resdef_t` doesn't exist in C codebase and `ResourceInfo` has no store/consumer (YAGNI); existing `cResourceToModel` → `ResourceDefinitionInfo` already handles config push
- `cClusterConfResources` already existed — no change needed
- CGO compilation verified passing
- All unit tests still passing
- [x] **#4 P0-1d: Implement handlers with partial merge + TDD**
- `backend/notification.go` — Added `OnClusterUpdated(cluster *models.ClusterInfo)` and `OnHostConfigUpdated(host *models.HostInfo)` to `NotificationHandler` interface
- `backend/handler.go` — Implemented partial-merge `OnClusterUpdated` (preserves Version, Status, ControllerName, NumServers, Resources, HostTypes, HostModels, ClusterAdmins), `OnHostConfigUpdated` (preserves HostStatus, CPUFactor, NumCPUs, NumCores, NumThreads, MaxMem, MaxSwap, MaxTmp, NumDisk, NumRun, NumSSusp, NumUSusp, NumDone, Topology, SchedStop), fixed `OnHostUpdated` (preserves HostModel, HostType, MaxSlot, DisptWindow via two-value `Get()`)
- `backend/handler_test.go` — 6 new tests: new host, partial merge for each of OnHostConfigUpdated, OnClusterUpdated, OnHostUpdated
- All backend tests passing (32 total)
- [x] **#5 P0-1e: Expand handleConfigPush** (unblocked)
- `backend/clib.go` — Expand `handleConfigPush()` to call `cClusterConfHostConfigs()` and `cClusterConfToClusterInfo()`
- Call `gHandler.OnHostConfigUpdated()` per host config, `gHandler.OnClusterUpdated()` for cluster settings
---
## P0-2: Host Control via Backend
- [ ] **#6 P0-2a: C-side plumbing for host control**
- `schema/controller.capnp` — Add `HostControl` struct + `hostctrlmsg @21`
- `include/typemap.h` — Add `xce_hostctrl_t* hostctrlmsg;` to body union
- `include/client.h` — Add `xcecli_hostctrl()` declaration
- `include/xceapi.h` — Add `xce_hostctrl()` declaration
- `include/request.h` — Add `xce_hostctrl_req()` declaration
- `lib/client.c` — Implement `xcecli_hostctrl()`
- `lib/xceapi.c` — Implement `xce_hostctrl()` with `cbuf_get()` wait
- `lib/request.c` — Implement `xce_hostctrl_req()` (validate HOST_OPEN/HOST_CLOSE only)
- `lib/helper.c` — Add `Request_body_hostctrlmsg` case to `xce_request_body()` + `xce_request_msg_name()`
- `controller/cluster.c` — Add `OPCode_hostcontrol` case → `do_hostcontrol_op()`
- `controller/worker.c` — Implement `do_hostcontrol_op()` calling `worker_ctrl()`
- `controller/include/cluster.h` — Declare `do_hostcontrol_op()`
- Regenerate capnp: `ninja -C build`
- [>] **#7 P0-2b: Implement Go-side ControlHost + fix allFlag race** (blocked by #6)
- `backend/clib.go` — Replace `ControlHost()` stub with real implementation via `xcecli_hostctrl()`
- `controllers/hosts.go` — Fix data race: remove `var allFlag bool`, return from `preCheckHostControlRequest` as local
- `controllers/queues.go` — Same allFlag data race fix (lines 534-536 / 465)
- [ ] **#8 P0-2c: Register routes + RBAC for host control/select**
- `router/routers.go` — `POST /xce/v1/hosts/control` → RBAC `host:control` (cluster_admin)
- `router/routers.go` — `POST /xce/v1/hosts/select` → RBAC `host:view` (all authenticated)
- `config/rbac.yaml` — Add `host:control` and `host:view` action entries
- [ ] **#9 P0-2d: Update stub.go with new function stubs**
- `backend/stub.go` — Keep `ControlHost` stub (return `errNoCGO`), add `StartHeartbeat(ctx context.Context){}`, `SetHeartbeatInterval(secs int){}`, `EvalSelectForHost(...)` returning `errNoCGO`, `Cleanup(){}`
- Add `"context"` to import block
---
## 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
---
## P1-2: Host Resource Requirement Select
- [ ] **#11 P1-2a: C select expression parser (PEG + eval)**
- `include/resreq.h` — Replace empty `xce_resreq_select_` with expression tree types, add enums, add extern declarations
- `lib/xce_select.peg` — New PEG grammar for select boolean expressions (right-recursive)
- `lib/resreq.c` — Implement `parse_select()`, `eval_select()`, `free_select()`; update `kill_primary_resreq()`
- `lib/meson.build` — Add `xce_select.peg` to explicit source list
- [>] **#12 P1-2b: Go EvalSelectForHost + FilterHostByResReqSelect** (blocked by #11)
- `backend/clib.go` — Add `EvalSelectForHost()` using `cgo.Handle` for callback context, add `#include "resreq.h"`, extern declarations, `xce_select_destroy` helper
- `controllers/hosts.go` — Implement `FilterHostByResReqSelect`, update `preCheckHostRequest()` limit from 4094→1024 bytes
---
## P1-3: Heartbeat Keep-Alive
- [ ] **#13 P1-3: Heartbeat keep-alive implementation**
- `backend/clib.go` — Add `heartbeatIntervalSecs int32` (default 30), `SetHeartbeatInterval()`, `heartbeatFn` var, `StartHeartbeat(ctx context.Context)`
- `backend/handler.go` — `OnClusterUpdated` calls `SetHeartbeatInterval(cluster.HeartbeatInterval)` after store update
- `cmd/main.go` — Call `backend.StartHeartbeat(ctx)` after `backend.RegisterToBackend(...)`
---
## Final
- [>] **#14 Build and test verification** (blocked by all others)
- `make test` in `src/frontend/` passes
- `ninja -C build` for C components passes
- RBAC verification
- Input safety verification (1024-byte limit, 256-host limit)

View file

@ -213,6 +213,9 @@ func handleDataset(d *C.dataset_t) C.int {
// handleConfigPush processes a cluster configuration push. // handleConfigPush processes a cluster configuration push.
func handleConfigPush(cfgdata *C.xce_configdata_t) C.int { func handleConfigPush(cfgdata *C.xce_configdata_t) C.int {
if gHandler == nil {
return 0
}
if cfgdata.data_which != C.ConfigData_data_clusterconfig { if cfgdata.data_which != C.ConfigData_data_clusterconfig {
return 0 return 0
} }
@ -222,14 +225,16 @@ func handleConfigPush(cfgdata *C.xce_configdata_t) C.int {
return 0 return 0
} }
for _, q := range cClusterConfQueues(conf) { cluster := cClusterConfToClusterInfo(conf)
qi := q if cluster == nil {
gHandler.OnQueueUpdated(&qi) logger.Warn("handleConfigPush: cluster_config_t has empty clustername; skipping OnClusterUpdated")
}
for _, r := range cClusterConfResources(conf) {
ri := r
gHandler.OnResourceUpdated(&ri)
} }
dispatchConfigPush(gHandler,
cClusterConfQueues(conf),
cClusterConfResources(conf),
cClusterConfHostConfigs(conf),
cluster,
)
return 0 return 0
} }

View file

@ -107,7 +107,7 @@ func cResourceToModel(res *C.xce_resource_t) *models.ResourceDefinitionInfo {
// cClusterConfQueues extracts queue models from a cluster_config_t. // cClusterConfQueues extracts queue models from a cluster_config_t.
func cClusterConfQueues(conf *C.cluster_config_t) []models.QueueInfo { func cClusterConfQueues(conf *C.cluster_config_t) []models.QueueInfo {
n := int(conf.nqueues) n := int(conf.nqueues)
if n == 0 { if n == 0 || conf.queues == nil {
return nil return nil
} }
qps := unsafe.Slice(conf.queues, n) qps := unsafe.Slice(conf.queues, n)
@ -123,7 +123,7 @@ func cClusterConfQueues(conf *C.cluster_config_t) []models.QueueInfo {
// cClusterConfResources extracts resource definition models from a cluster_config_t. // cClusterConfResources extracts resource definition models from a cluster_config_t.
func cClusterConfResources(conf *C.cluster_config_t) []models.ResourceDefinitionInfo { func cClusterConfResources(conf *C.cluster_config_t) []models.ResourceDefinitionInfo {
n := int(conf.nres) n := int(conf.nres)
if n == 0 { if n == 0 || conf.resources == nil {
return nil return nil
} }
rps := unsafe.Slice(conf.resources, n) rps := unsafe.Slice(conf.resources, n)
@ -136,6 +136,48 @@ func cClusterConfResources(conf *C.cluster_config_t) []models.ResourceDefinition
return out return out
} }
// cHostConfigToModel extracts C xce_hostconfig_t fields and maps them to HostInfo.
func cHostConfigToModel(hc *C.xce_hostconfig_t) *models.HostInfo {
return rawHostConfigToModel(rawHostConfigData{
name: goString(hc.name),
hostmodel: goString(hc.hostmodel),
hosttype: goString(hc.hosttype),
mxj: int(hc.mxj),
})
}
// cClusterConfHostConfigs extracts host config models from a cluster_config_t.
func cClusterConfHostConfigs(conf *C.cluster_config_t) []models.HostInfo {
n := int(conf.nhconfigs)
if n == 0 || conf.hconfigs == nil {
return nil
}
hps := unsafe.Slice(conf.hconfigs, n)
out := make([]models.HostInfo, 0, n)
for _, hp := range hps {
if hp != nil {
out = append(out, *cHostConfigToModel(hp))
}
}
return out
}
// cClusterConfToClusterInfo extracts C cluster_config_t cluster-level fields and maps them to ClusterInfo.
// Returns nil when clustername is empty so dispatchConfigPush skips the OnClusterUpdated call.
// C struct field names are snake_case (clustername, heartbeat_interval, etc.) — not Cap'n Proto names.
func cClusterConfToClusterInfo(conf *C.cluster_config_t) *models.ClusterInfo {
name := goString(conf.clustername)
if name == "" {
return nil
}
return rawClusterToModel(rawClusterData{
name: name,
heartbeatInterval: int(conf.heartbeat_interval),
vcpus: int(conf.vcpus),
inactiveHeartbeatCount: int(conf.inactive_heartbeat_count),
})
}
// modelJobToSubmit converts a Go JobInfo to C xce_job_submit_t heap structs. // modelJobToSubmit converts a Go JobInfo to C xce_job_submit_t heap structs.
// The caller must free all returned C memory by calling freeJobSubmit. // The caller must free all returned C memory by calling freeJobSubmit.
func modelJobToSubmit(job *models.JobInfo) (*C.xce_job_submit_t, *C.job_submit_data_t, *C.job_startinfo_t) { func modelJobToSubmit(job *models.JobInfo) (*C.xce_job_submit_t, *C.job_submit_data_t, *C.job_startinfo_t) {

View file

@ -231,3 +231,53 @@ func rawResourceToModel(r rawResourceData) *models.ResourceDefinitionInfo {
Interval: r.interval, Interval: r.interval,
} }
} }
// rawHostConfigData mirrors the C xce_hostconfig_t fields used by the Go model.
// Only fields that map to models.HostInfo are included:
// - name → HostName, hostmodel → HostModel, hosttype → HostType, mxj → MaxSlot
//
// resources (xce_config_t**) and thresholds (xce_res_threshold_t**) have no HostInfo field and are excluded.
// dispatchwindow (xce_timewindow_config_t*) is deferred — DisptWindow is left as empty string.
type rawHostConfigData struct {
name string
hostmodel string
hosttype string
mxj int
}
// rawHostConfigToModel maps a rawHostConfigData to a models.HostInfo.
// Config-sourced fields only; worker-sourced fields remain zero.
func rawHostConfigToModel(r rawHostConfigData) *models.HostInfo {
return &models.HostInfo{
HostName: r.name,
HostModel: r.hostmodel,
HostType: r.hosttype,
MaxSlot: r.mxj,
// TODO(dispatchwindow): xce_hostconfig_t.dispatchwindow is xce_timewindow_config_t*
// (nsegs int + segs[] with open/close time fields). Serializing it requires a Go
// mirror struct and json.Marshal. Deferred: no API endpoint currently consumes
// DisptWindow. Implement when /xce/v1/hosts returns dispatch window data.
}
}
// 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
inactiveHeartbeatCount int
}
// rawClusterToModel maps a rawClusterData to a models.ClusterInfo.
// Config-sourced fields only; built-in fields remain zero.
func rawClusterToModel(r rawClusterData) *models.ClusterInfo {
return &models.ClusterInfo{
ClusterName: r.name,
HeartbeatInterval: r.heartbeatInterval,
Vcpus: r.vcpus,
InactiveHeartbeatCount: r.inactiveHeartbeatCount,
}
}

View file

@ -1,6 +1,7 @@
package backend package backend
import ( import (
"apiserver/models"
"testing" "testing"
"time" "time"
) )
@ -387,3 +388,200 @@ func TestIsArrayJob(t *testing.T) {
t.Errorf("array child with idx=0 should still be an array job") t.Errorf("array child with idx=0 should still be an array job")
} }
} }
func TestRawHostConfigToModel(t *testing.T) {
tests := []struct {
name string
raw rawHostConfigData
want models.HostInfo
}{
{
name: "normal values",
raw: rawHostConfigData{
name: "node01",
hostmodel: "x86_64",
hosttype: "LINUX64",
mxj: 8,
},
want: models.HostInfo{
HostName: "node01",
HostModel: "x86_64",
HostType: "LINUX64",
MaxSlot: 8,
},
},
{
name: "zero values for integer fields",
raw: rawHostConfigData{
name: "node02",
hostmodel: "modelB",
hosttype: "linux",
mxj: 0,
},
want: models.HostInfo{
HostName: "node02",
HostModel: "modelB",
HostType: "linux",
MaxSlot: 0,
},
},
{
name: "empty strings for name/hostmodel/hosttype",
raw: rawHostConfigData{
name: "",
hostmodel: "",
hosttype: "",
mxj: 4,
},
want: models.HostInfo{
HostName: "",
HostModel: "",
HostType: "",
MaxSlot: 4,
},
},
{
name: "worker-sourced fields remain zero",
raw: rawHostConfigData{
name: "node03",
hostmodel: "modelC",
hosttype: "CENTOS7",
mxj: 16,
},
want: models.HostInfo{
HostName: "node03",
HostModel: "modelC",
HostType: "CENTOS7",
MaxSlot: 16,
HostStatus: 0,
CPUFactor: 0,
MaxMem: 0,
NumRun: 0,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := rawHostConfigToModel(tt.raw)
if got.HostName != tt.want.HostName {
t.Errorf("HostName: got %q, want %q", got.HostName, tt.want.HostName)
}
if got.HostModel != tt.want.HostModel {
t.Errorf("HostModel: got %q, want %q", got.HostModel, tt.want.HostModel)
}
if got.HostType != tt.want.HostType {
t.Errorf("HostType: got %q, want %q", got.HostType, tt.want.HostType)
}
if got.MaxSlot != tt.want.MaxSlot {
t.Errorf("MaxSlot: got %d, want %d", got.MaxSlot, tt.want.MaxSlot)
}
// Verify worker-sourced fields stay zero
if got.HostStatus != tt.want.HostStatus {
t.Errorf("HostStatus: got %d, want %d (should be zero, worker-sourced)", got.HostStatus, tt.want.HostStatus)
}
if got.CPUFactor != tt.want.CPUFactor {
t.Errorf("CPUFactor: got %f, want %f (should be zero, worker-sourced)", got.CPUFactor, tt.want.CPUFactor)
}
if got.MaxMem != tt.want.MaxMem {
t.Errorf("MaxMem: got %d, want %d (should be zero, worker-sourced)", got.MaxMem, tt.want.MaxMem)
}
if got.NumRun != tt.want.NumRun {
t.Errorf("NumRun: got %d, want %d (should be zero, worker-sourced)", got.NumRun, tt.want.NumRun)
}
// DisptWindow is deferred — must be empty string, not accidentally set
if got.DisptWindow != "" {
t.Errorf("DisptWindow: got %q, want empty string (deferred field)", got.DisptWindow)
}
})
}
}
func TestRawClusterToModel(t *testing.T) {
tests := []struct {
name string
raw rawClusterData
want models.ClusterInfo
}{
{
name: "normal values",
raw: rawClusterData{
name: "mycluster",
heartbeatInterval: 30,
vcpus: 16,
inactiveHeartbeatCount: 3,
},
want: models.ClusterInfo{
ClusterName: "mycluster",
HeartbeatInterval: 30,
Vcpus: 16,
InactiveHeartbeatCount: 3,
},
},
{
name: "empty cluster name maps through unchanged (guard is in CGO layer)",
raw: rawClusterData{
name: "",
heartbeatInterval: 60,
vcpus: 8,
inactiveHeartbeatCount: 5,
},
want: models.ClusterInfo{
ClusterName: "",
HeartbeatInterval: 60,
Vcpus: 8,
InactiveHeartbeatCount: 5,
},
},
{
name: "built-in fields remain zero",
raw: rawClusterData{
name: "test",
heartbeatInterval: 45,
vcpus: 32,
inactiveHeartbeatCount: 2,
},
want: models.ClusterInfo{
ClusterName: "test",
HeartbeatInterval: 45,
Vcpus: 32,
InactiveHeartbeatCount: 2,
Version: "",
Status: 0,
ControllerName: "",
NumServers: 0,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := rawClusterToModel(tt.raw)
if got.ClusterName != tt.want.ClusterName {
t.Errorf("ClusterName: got %q, want %q", got.ClusterName, tt.want.ClusterName)
}
if got.HeartbeatInterval != tt.want.HeartbeatInterval {
t.Errorf("HeartbeatInterval: got %d, want %d", got.HeartbeatInterval, tt.want.HeartbeatInterval)
}
if got.Vcpus != tt.want.Vcpus {
t.Errorf("Vcpus: got %d, want %d", got.Vcpus, tt.want.Vcpus)
}
if got.InactiveHeartbeatCount != tt.want.InactiveHeartbeatCount {
t.Errorf("InactiveHeartbeatCount: got %d, want %d", got.InactiveHeartbeatCount, tt.want.InactiveHeartbeatCount)
}
// Verify built-in/sourced fields stay zero (not overwritten by config push)
if got.Version != tt.want.Version {
t.Errorf("Version: got %q, want %q (should be empty, built-in-sourced)", got.Version, tt.want.Version)
}
if got.Status != tt.want.Status {
t.Errorf("Status: got %d, want %d (should be zero, built-in-sourced)", got.Status, tt.want.Status)
}
if got.ControllerName != tt.want.ControllerName {
t.Errorf("ControllerName: got %q, want %q (should be empty, built-in-sourced)", got.ControllerName, tt.want.ControllerName)
}
if got.NumServers != tt.want.NumServers {
t.Errorf("NumServers: got %d, want %d (should be zero, built-in-sourced)", got.NumServers, tt.want.NumServers)
}
})
}
}

View file

@ -24,8 +24,66 @@ func (h *DefaultHandler) OnQueueUpdated(queue *models.QueueInfo) {
store.Queues.Put(queue.QueueName, *queue) store.Queues.Put(queue.QueueName, *queue)
} }
func (h *DefaultHandler) OnHostUpdated(host *models.HostInfo) { // OnHostUpdated merges worker-sourced fields into an existing host entry.
store.Hosts.Put(host.HostName, *host) // Config-sourced fields (HostModel, HostType, MaxSlot, DisptWindow) are
// preserved from the prior entry if one exists.
func (h *DefaultHandler) OnHostUpdated(incoming *models.HostInfo) {
merged := *incoming
if existing, ok := store.Hosts.Get(merged.HostName); ok {
merged.HostModel = existing.HostModel
merged.HostType = existing.HostType
merged.MaxSlot = existing.MaxSlot
merged.DisptWindow = existing.DisptWindow
}
store.Hosts.Put(merged.HostName, merged)
}
// OnHostConfigUpdated merges config-sourced fields into an existing host entry.
// Worker-sourced fields (HostStatus, CPUFactor, NumCPUs, MaxMem, MaxSwap,
// MaxTmp, NumDisk, NumRun, NumSSusp, NumUSusp, NumDone) are preserved from
// the prior entry if one exists.
// NumCores, NumThreads, Topology, SchedStop are preserved here but currently
// have no write path in rawWorkerData — they remain zero until a future worker
// push source populates them (analogous to the DisptWindow deferral).
func (h *DefaultHandler) OnHostConfigUpdated(incoming *models.HostInfo) {
merged := *incoming
if existing, ok := store.Hosts.Get(merged.HostName); ok {
merged.HostStatus = existing.HostStatus
merged.CPUFactor = existing.CPUFactor
merged.NumCPUs = existing.NumCPUs
merged.NumCores = existing.NumCores
merged.NumThreads = existing.NumThreads
merged.MaxMem = existing.MaxMem
merged.MaxSwap = existing.MaxSwap
merged.MaxTmp = existing.MaxTmp
merged.NumDisk = existing.NumDisk
merged.NumRun = existing.NumRun
merged.NumSSusp = existing.NumSSusp
merged.NumUSusp = existing.NumUSusp
merged.NumDone = existing.NumDone
merged.Topology = existing.Topology
merged.SchedStop = existing.SchedStop
}
store.Hosts.Put(merged.HostName, merged)
}
// OnClusterUpdated merges config-sourced fields into an existing cluster entry.
// Built-in fields (Version, Status, ControllerName, NumServers, Resources,
// HostTypes, HostModels, ClusterAdmins) are preserved from the prior entry
// if one exists.
func (h *DefaultHandler) OnClusterUpdated(incoming *models.ClusterInfo) {
merged := *incoming
if existing, ok := store.Clusters.Get(merged.ClusterName); ok {
merged.Version = existing.Version
merged.Status = existing.Status
merged.ControllerName = existing.ControllerName
merged.NumServers = existing.NumServers
merged.Resources = existing.Resources
merged.HostTypes = existing.HostTypes
merged.HostModels = existing.HostModels
merged.ClusterAdmins = existing.ClusterAdmins
}
store.Clusters.Put(merged.ClusterName, merged)
} }
func (h *DefaultHandler) OnResourceUpdated(res *models.ResourceDefinitionInfo) { func (h *DefaultHandler) OnResourceUpdated(res *models.ResourceDefinitionInfo) {
@ -36,3 +94,25 @@ func (h *DefaultHandler) OnResourceLocationUpdated(res *models.ResourceDistribut
key := res.ResourceName + ":" + res.Location key := res.ResourceName + ":" + res.Location
store.ResourceLocations.Put(key, *res) store.ResourceLocations.Put(key, *res)
} }
// dispatchConfigPush delivers converted config-push data to the handler (CGO-free, for testability).
func dispatchConfigPush(
handler NotificationHandler,
queues []models.QueueInfo,
resources []models.ResourceDefinitionInfo,
hostConfigs []models.HostInfo,
cluster *models.ClusterInfo,
) {
for _, q := range queues {
handler.OnQueueUpdated(&q)
}
for _, r := range resources {
handler.OnResourceUpdated(&r)
}
for _, hc := range hostConfigs {
handler.OnHostConfigUpdated(&hc)
}
if cluster != nil {
handler.OnClusterUpdated(cluster)
}
}

View file

@ -0,0 +1,345 @@
package backend
import (
"apiserver/models"
"apiserver/store"
"testing"
)
// resetStores reinitializes all global stores for test isolation.
func resetStores() {
store.InitStores()
}
func TestOnHostConfigUpdated_NewHost(t *testing.T) {
resetStores()
h := DefaultHandler{}
host := &models.HostInfo{
HostName: "node01",
HostModel: "x86_64",
HostType: "LINUX64",
MaxSlot: 8,
}
h.OnHostConfigUpdated(host)
got, ok := store.Hosts.Get("node01")
if !ok {
t.Fatal("host node01 should exist in store after OnHostConfigUpdated")
}
if got.HostModel != "x86_64" {
t.Errorf("HostModel: got %q, want %q", got.HostModel, "x86_64")
}
if got.HostType != "LINUX64" {
t.Errorf("HostType: got %q, want %q", got.HostType, "LINUX64")
}
if got.MaxSlot != 8 {
t.Errorf("MaxSlot: got %d, want 8", got.MaxSlot)
}
}
func TestOnHostConfigUpdated_PartialMerge(t *testing.T) {
resetStores()
h := DefaultHandler{}
// Simulate a prior worker push that set worker-sourced fields.
store.Hosts.Put("node01", models.HostInfo{
HostName: "node01",
HostStatus: 0x00010000, // UNAVAIL
CPUFactor: 2.5,
NumCPUs: 32,
MaxMem: 131072,
NumRun: 5,
})
// Config push arrives — should merge config fields, preserve worker fields.
h.OnHostConfigUpdated(&models.HostInfo{
HostName: "node01",
HostModel: "modelA",
HostType: "CENTOS7",
MaxSlot: 16,
})
got, ok := store.Hosts.Get("node01")
if !ok {
t.Fatal("host node01 should exist after merge")
}
// Config-sourced fields taken from the push.
if got.HostModel != "modelA" {
t.Errorf("HostModel: got %q, want %q", got.HostModel, "modelA")
}
if got.HostType != "CENTOS7" {
t.Errorf("HostType: got %q, want %q", got.HostType, "CENTOS7")
}
if got.MaxSlot != 16 {
t.Errorf("MaxSlot: got %d, want 16", got.MaxSlot)
}
// Worker-sourced fields preserved from the prior entry.
if got.HostStatus != 0x00010000 {
t.Errorf("HostStatus: got 0x%08X, want 0x00010000 (should be preserved)", got.HostStatus)
}
if got.CPUFactor != 2.5 {
t.Errorf("CPUFactor: got %f, want 2.5 (should be preserved)", got.CPUFactor)
}
if got.NumCPUs != 32 {
t.Errorf("NumCPUs: got %d, want 32 (should be preserved)", got.NumCPUs)
}
if got.MaxMem != 131072 {
t.Errorf("MaxMem: got %d, want 131072 (should be preserved)", got.MaxMem)
}
if got.NumRun != 5 {
t.Errorf("NumRun: got %d, want 5 (should be preserved)", got.NumRun)
}
}
func TestOnClusterUpdated_NewCluster(t *testing.T) {
resetStores()
h := DefaultHandler{}
cluster := &models.ClusterInfo{
ClusterName: "mycluster",
HeartbeatInterval: 30,
Vcpus: 16,
InactiveHeartbeatCount: 3,
}
h.OnClusterUpdated(cluster)
got, ok := store.Clusters.Get("mycluster")
if !ok {
t.Fatal("cluster mycluster should exist in store after OnClusterUpdated")
}
if got.HeartbeatInterval != 30 {
t.Errorf("HeartbeatInterval: got %d, want 30", got.HeartbeatInterval)
}
if got.Vcpus != 16 {
t.Errorf("Vcpus: got %d, want 16", got.Vcpus)
}
if got.InactiveHeartbeatCount != 3 {
t.Errorf("InactiveHeartbeatCount: got %d, want 3", got.InactiveHeartbeatCount)
}
}
func TestOnClusterUpdated_PartialMerge(t *testing.T) {
resetStores()
h := DefaultHandler{}
// Simulate built-in data loaded at startup.
store.Clusters.Put("mycluster", models.ClusterInfo{
ClusterName: "mycluster",
Version: "10.1.0",
Status: 0x01, // CONNECTED
ControllerName: "ctrl01",
NumServers: 3,
})
// Config push arrives — should merge config fields, preserve built-in fields.
h.OnClusterUpdated(&models.ClusterInfo{
ClusterName: "mycluster",
HeartbeatInterval: 45,
Vcpus: 64,
InactiveHeartbeatCount: 5,
})
got, ok := store.Clusters.Get("mycluster")
if !ok {
t.Fatal("cluster mycluster should exist after merge")
}
// Config-sourced fields from the push.
if got.HeartbeatInterval != 45 {
t.Errorf("HeartbeatInterval: got %d, want 45", got.HeartbeatInterval)
}
if got.Vcpus != 64 {
t.Errorf("Vcpus: got %d, want 64", got.Vcpus)
}
if got.InactiveHeartbeatCount != 5 {
t.Errorf("InactiveHeartbeatCount: got %d, want 5", got.InactiveHeartbeatCount)
}
// Built-in fields preserved.
if got.Version != "10.1.0" {
t.Errorf("Version: got %q, want %q (should be preserved)", got.Version, "10.1.0")
}
if got.Status != 0x01 {
t.Errorf("Status: got 0x%02X, want 0x01 (should be preserved)", got.Status)
}
if got.ControllerName != "ctrl01" {
t.Errorf("ControllerName: got %q, want %q (should be preserved)", got.ControllerName, "ctrl01")
}
if got.NumServers != 3 {
t.Errorf("NumServers: got %d, want 3 (should be preserved)", got.NumServers)
}
}
func TestOnHostUpdated_PartialMerge(t *testing.T) {
resetStores()
h := DefaultHandler{}
// Simulate a prior config push that set config-sourced fields.
store.Hosts.Put("node01", models.HostInfo{
HostName: "node01",
HostModel: "x86_64",
HostType: "LINUX64",
MaxSlot: 8,
DisptWindow: "always",
})
// Worker push arrives — should merge worker fields, preserve config fields.
h.OnHostUpdated(&models.HostInfo{
HostName: "node01",
HostStatus: 3,
CPUFactor: 1.8,
NumCPUs: 16,
MaxMem: 65536,
NumRun: 2,
})
got, ok := store.Hosts.Get("node01")
if !ok {
t.Fatal("host node01 should exist after merge")
}
// Worker-sourced fields from the push.
if got.HostStatus != 3 {
t.Errorf("HostStatus: got %d, want 3", got.HostStatus)
}
if got.CPUFactor != 1.8 {
t.Errorf("CPUFactor: got %f, want 1.8", got.CPUFactor)
}
if got.NumCPUs != 16 {
t.Errorf("NumCPUs: got %d, want 16", got.NumCPUs)
}
if got.MaxMem != 65536 {
t.Errorf("MaxMem: got %d, want 65536", got.MaxMem)
}
if got.NumRun != 2 {
t.Errorf("NumRun: got %d, want 2", got.NumRun)
}
// Config-sourced fields preserved.
if got.HostModel != "x86_64" {
t.Errorf("HostModel: got %q, want %q (should be preserved)", got.HostModel, "x86_64")
}
if got.HostType != "LINUX64" {
t.Errorf("HostType: got %q, want %q (should be preserved)", got.HostType, "LINUX64")
}
if got.MaxSlot != 8 {
t.Errorf("MaxSlot: got %d, want 8 (should be preserved)", got.MaxSlot)
}
if got.DisptWindow != "always" {
t.Errorf("DisptWindow: got %q, want %q (should be preserved)", got.DisptWindow, "always")
}
}
func TestOnHostUpdated_NewHost(t *testing.T) {
resetStores()
h := DefaultHandler{}
// No prior entry in store — first push is from worker.
h.OnHostUpdated(&models.HostInfo{
HostName: "node05",
HostStatus: 1,
CPUFactor: 3.0,
})
got, ok := store.Hosts.Get("node05")
if !ok {
t.Fatal("host node05 should exist after first push")
}
if got.HostStatus != 1 {
t.Errorf("HostStatus: got %d, want 1", got.HostStatus)
}
}
func TestDispatchConfigPush_CallsOnHostConfigUpdatedForEachHost(t *testing.T) {
resetStores()
h := &DefaultHandler{}
hosts := []models.HostInfo{
{HostName: "node01", HostModel: "x86_64", HostType: "LINUX64", MaxSlot: 4},
{HostName: "node02", HostModel: "arm64", HostType: "LINUX64", MaxSlot: 8},
}
dispatchConfigPush(h, nil, nil, hosts, nil)
got1, ok1 := store.Hosts.Get("node01")
if !ok1 {
t.Fatal("node01 should be in store after dispatchConfigPush")
}
if got1.HostModel != "x86_64" {
t.Errorf("node01 HostModel: got %q, want x86_64", got1.HostModel)
}
if got1.MaxSlot != 4 {
t.Errorf("node01 MaxSlot: got %d, want 4", got1.MaxSlot)
}
got2, ok2 := store.Hosts.Get("node02")
if !ok2 {
t.Fatal("node02 should be in store after dispatchConfigPush")
}
if got2.HostModel != "arm64" {
t.Errorf("node02 HostModel: got %q, want arm64", got2.HostModel)
}
if got2.MaxSlot != 8 {
t.Errorf("node02 MaxSlot: got %d, want 8", got2.MaxSlot)
}
}
func TestDispatchConfigPush_CallsOnClusterUpdated(t *testing.T) {
resetStores()
h := &DefaultHandler{}
cluster := &models.ClusterInfo{
ClusterName: "mycluster",
HeartbeatInterval: 30,
Vcpus: 64,
InactiveHeartbeatCount: 5,
}
dispatchConfigPush(h, nil, nil, nil, cluster)
got, ok := store.Clusters.Get("mycluster")
if !ok {
t.Fatal("mycluster should be in store after dispatchConfigPush")
}
if got.HeartbeatInterval != 30 {
t.Errorf("HeartbeatInterval: got %d, want 30", got.HeartbeatInterval)
}
if got.Vcpus != 64 {
t.Errorf("Vcpus: got %d, want 64", got.Vcpus)
}
if got.InactiveHeartbeatCount != 5 {
t.Errorf("InactiveHeartbeatCount: got %d, want 5", got.InactiveHeartbeatCount)
}
}
func TestDispatchConfigPush_NilClusterIsSkipped(t *testing.T) {
resetStores()
h := &DefaultHandler{}
dispatchConfigPush(h, nil, nil, nil, nil)
if n := store.Clusters.Count(); n != 0 {
t.Errorf("Clusters store should be empty when cluster is nil, got %d entries", n)
}
}
func TestDispatchConfigPush_EmptySlicesNoOp(t *testing.T) {
resetStores()
h := &DefaultHandler{}
dispatchConfigPush(h,
[]models.QueueInfo{},
[]models.ResourceDefinitionInfo{},
[]models.HostInfo{},
nil,
)
if n := store.Hosts.Count(); n != 0 {
t.Errorf("Hosts store should be empty, got %d entries", n)
}
if n := store.Queues.Count(); n != 0 {
t.Errorf("Queues store should be empty, got %d entries", n)
}
if n := store.Clusters.Count(); n != 0 {
t.Errorf("Clusters store should be empty, got %d entries", n)
}
}

View file

@ -11,6 +11,8 @@ type NotificationHandler interface {
OnJobDeleted(jobid uint64) OnJobDeleted(jobid uint64)
OnQueueUpdated(queue *models.QueueInfo) OnQueueUpdated(queue *models.QueueInfo)
OnHostUpdated(host *models.HostInfo) OnHostUpdated(host *models.HostInfo)
OnHostConfigUpdated(host *models.HostInfo)
OnClusterUpdated(cluster *models.ClusterInfo)
OnResourceUpdated(res *models.ResourceDefinitionInfo) OnResourceUpdated(res *models.ResourceDefinitionInfo)
OnResourceLocationUpdated(res *models.ResourceDistributionInfo) OnResourceLocationUpdated(res *models.ResourceDistributionInfo)
} }