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

@ -1,6 +1,7 @@
package backend
import (
"apiserver/models"
"testing"
"time"
)
@ -387,3 +388,200 @@ func TestIsArrayJob(t *testing.T) {
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)
}
})
}
}