fix front end issues

This commit is contained in:
Rongsong Shen 2026-05-01 21:22:45 +08:00
parent 177cf45af5
commit d7afd72e5b
18 changed files with 345 additions and 61 deletions

View file

@ -266,6 +266,27 @@ func handleNotifyData(nd *C.xce_notify_data_t) C.int {
return 0
}
switch nd.event {
case C.NotifyData_data_updateJob:
jp := (*C.xce_job_t)(C.xce_notify_data_ptr(nd))
if jp == nil {
return 0
}
gHandler.OnJobUpdated(cJobToModel(jp))
case C.NotifyData_data_jobFinishInfo:
fi := (*C.xce_jobfinish_t)(C.xce_notify_data_ptr(nd))
if fi == nil {
return 0
}
gHandler.OnJobFinished(uint64(fi.jobid), int(fi.exit_code))
case C.NotifyData_data_jobFailInfo:
ff := (*C.job_failinfo_t)(C.xce_notify_data_ptr(nd))
if ff == nil {
return 0
}
gHandler.OnJobFinished(uint64(ff.jobid), int(ff.code))
case C.NotifyData_data_schedUpdateResvals:
rvl := (*C.xce_resval_list_t)(C.xce_notify_data_ptr(nd))
if rvl == nil {

View file

@ -6,6 +6,9 @@ package backend
#include <stdlib.h>
#include "client.h"
#include "val_helpers.h"
// Helper to index into xce_host_alloc_t** (CGO cannot dereference C pointer-to-pointer arrays).
static xce_host_alloc_t *job_alloc_host(xce_job_alloc_t *a, int i) { return a->allocs[i]; }
*/
import "C"
@ -63,6 +66,15 @@ func cJobToModel(job *C.xce_job_t) *models.JobInfo {
}
r.submit = rd
}
if job.allocs != nil {
n := int(job.allocs.nhosts)
for i := 0; i < n; i++ {
h := C.job_alloc_host(job.allocs, C.int(i))
if h != nil && h.host != nil {
r.execHosts = append(r.execHosts, C.GoString(h.host))
}
}
}
return rawJobToModel(r)
}

View file

@ -2,6 +2,7 @@ package backend
import (
"apiserver/models"
"encoding/json"
"time"
)
@ -110,6 +111,7 @@ type rawJobData struct {
startTime int64
finishTime int64
submit *rawSubmitData
execHosts []string
}
// rawWorkerData mirrors the C xce_worker_t fields used by the Go model.
@ -189,6 +191,22 @@ func rawJobToModel(r rawJobData) *models.JobInfo {
if r.finishTime > 0 {
info.EndTime = time.Unix(r.finishTime, 0)
}
if len(r.execHosts) > 0 {
type hostSlot struct {
HostName string `json:"host_name"`
NumSlots int `json:"num_slots"`
}
type execHostsJSON struct {
HostSlots []hostSlot `json:"host_slots"`
NumHosts int `json:"num_hosts"`
}
slots := make([]hostSlot, len(r.execHosts))
for i, h := range r.execHosts {
slots[i] = hostSlot{HostName: h, NumSlots: 1}
}
data, _ := json.Marshal(execHostsJSON{HostSlots: slots, NumHosts: len(slots)})
info.ExecHosts = models.Jsonb{String: string(data)}
}
return info
}

View file

@ -20,6 +20,19 @@ func (h *DefaultHandler) OnJobDeleted(jobid uint64) {
store.Jobs.Delete(jobid)
}
func (h *DefaultHandler) OnJobFinished(jobid uint64, exitCode int) {
existing, ok := store.Jobs.Get(jobid)
if !ok {
return
}
if exitCode == 0 {
existing.JobStatus = models.JOBSTAT["DONE"]
} else {
existing.JobStatus = models.JOBSTAT["EXIT"]
}
store.Jobs.Put(jobid, existing)
}
func (h *DefaultHandler) OnQueueUpdated(queue *models.QueueInfo) {
store.Queues.Put(queue.QueueName, *queue)
}
@ -38,14 +51,14 @@ func (h *DefaultHandler) OnHostUpdated(incoming *models.HostInfo) {
store.Hosts.Put(merged.HostName, merged)
}
// OnHostConfigUpdated applies config-sourced fields onto the existing host entry.
// All other fields (worker-sourced) are preserved from the prior entry.
// NumCores, NumThreads, Topology, SchedStop have no current write path in
// rawWorkerData and remain zero until a future push source populates them
// (analogous to the DisptWindow deferral).
// OnHostConfigUpdated applies config-sourced fields onto an existing worker-registered host.
// Hosts not yet registered by a worker are skipped — config entries like "default" (which sets
// cluster-wide defaults) or hosts whose daemons haven't started must not appear in the host list.
func (h *DefaultHandler) OnHostConfigUpdated(incoming *models.HostInfo) {
existing, _ := store.Hosts.Get(incoming.HostName)
existing.HostName = incoming.HostName
existing, ok := store.Hosts.Get(incoming.HostName)
if !ok {
return
}
existing.HostModel = incoming.HostModel
existing.HostType = incoming.HostType
existing.MaxSlot = incoming.MaxSlot

View file

@ -11,31 +11,39 @@ func resetStores() {
store.InitStores()
}
func TestOnHostConfigUpdated_NewHost(t *testing.T) {
func TestOnHostConfigUpdated_UnregisteredHostIgnored(t *testing.T) {
// Config-only entries (e.g. "default", hosts whose daemons haven't started)
// must not be inserted into the Hosts store. Only OnHostUpdated (worker push) creates entries.
resetStores()
h := DefaultHandler{}
host := &models.HostInfo{
h.OnHostConfigUpdated(&models.HostInfo{
HostName: "node01",
HostModel: "x86_64",
HostType: "LINUX64",
MaxSlot: 8,
}
})
h.OnHostConfigUpdated(host)
_, ok := store.Hosts.Get("node01")
if ok {
t.Error("OnHostConfigUpdated must not create a new host entry; only workers can register hosts")
}
}
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_DefaultPseudoHostIgnored(t *testing.T) {
// The "default" host is a cluster-wide config sentinel, never a real worker.
resetStores()
h := DefaultHandler{}
h.OnHostConfigUpdated(&models.HostInfo{
HostName: "default",
HostType: "x86_64-linux-gnu",
MaxSlot: -1,
})
_, ok := store.Hosts.Get("default")
if ok {
t.Error("OnHostConfigUpdated must not create an entry for the 'default' pseudo-host")
}
}
@ -249,20 +257,27 @@ func TestOnHostUpdated_NewHost(t *testing.T) {
}
}
func TestDispatchConfigPush_CallsOnHostConfigUpdatedForEachHost(t *testing.T) {
func TestDispatchConfigPush_MergesConfigOntoRegisteredHosts(t *testing.T) {
// Config push should merge onto hosts already registered by workers, and
// silently skip any host not yet in the store (e.g. "default", offline workers).
resetStores()
h := &DefaultHandler{}
// Simulate worker registration arriving before config push.
store.Hosts.Put("node01", models.HostInfo{HostName: "node01", NumCPUs: 32})
store.Hosts.Put("node02", models.HostInfo{HostName: "node02", NumCPUs: 64})
hosts := []models.HostInfo{
{HostName: "node01", HostModel: "x86_64", HostType: "LINUX64", MaxSlot: 4},
{HostName: "node02", HostModel: "arm64", HostType: "LINUX64", MaxSlot: 8},
{HostName: "default", HostModel: "x86_64", HostType: "LINUX64", MaxSlot: -1},
}
dispatchConfigPush(h, nil, nil, hosts, nil)
got1, ok1 := store.Hosts.Get("node01")
if !ok1 {
t.Fatal("node01 should be in store after dispatchConfigPush")
t.Fatal("node01 should remain in store after dispatchConfigPush")
}
if got1.HostModel != "x86_64" {
t.Errorf("node01 HostModel: got %q, want x86_64", got1.HostModel)
@ -270,10 +285,13 @@ func TestDispatchConfigPush_CallsOnHostConfigUpdatedForEachHost(t *testing.T) {
if got1.MaxSlot != 4 {
t.Errorf("node01 MaxSlot: got %d, want 4", got1.MaxSlot)
}
if got1.NumCPUs != 32 {
t.Errorf("node01 NumCPUs: got %d, want 32 (worker field must be preserved)", got1.NumCPUs)
}
got2, ok2 := store.Hosts.Get("node02")
if !ok2 {
t.Fatal("node02 should be in store after dispatchConfigPush")
t.Fatal("node02 should remain in store after dispatchConfigPush")
}
if got2.HostModel != "arm64" {
t.Errorf("node02 HostModel: got %q, want arm64", got2.HostModel)
@ -281,6 +299,13 @@ func TestDispatchConfigPush_CallsOnHostConfigUpdatedForEachHost(t *testing.T) {
if got2.MaxSlot != 8 {
t.Errorf("node02 MaxSlot: got %d, want 8", got2.MaxSlot)
}
if _, ok := store.Hosts.Get("default"); ok {
t.Error("'default' pseudo-host must not be inserted by config push")
}
if n := store.Hosts.Count(); n != 2 {
t.Errorf("Hosts store should have exactly 2 entries, got %d", n)
}
}
func TestDispatchConfigPush_CallsOnClusterUpdated(t *testing.T) {

View file

@ -20,11 +20,14 @@ func resetIntegrationStores() {
// ========== §1. P0-1: Cluster Config Push ==========
// --- 1.1 P0: Config push populates all stores in a single call ---
// --- 1.1 P0: Config push populates queues/resources/clusters; hosts require prior worker registration ---
func TestIntegration_ConfigPushPopulatesAllStores(t *testing.T) {
resetIntegrationStores()
h := &DefaultHandler{}
// Worker registers node01 before config push arrives.
store.Hosts.Put("node01", models.HostInfo{HostName: "node01", NumCPUs: 8})
queues := []models.QueueInfo{{QueueName: "normal", Priority: 30}}
resources := []models.ResourceDefinitionInfo{{ResourceName: "mem", Description: "memory", ValueType: 1}}
hosts := []models.HostInfo{{HostName: "node01", HostModel: "x86_64"}}
@ -40,10 +43,13 @@ func TestIntegration_ConfigPushPopulatesAllStores(t *testing.T) {
}
got, ok := store.Hosts.Get("node01")
if !ok {
t.Fatal("Hosts store should contain 'node01'")
t.Fatal("Hosts store should contain 'node01' (pre-registered by worker)")
}
if got.HostModel != "x86_64" {
t.Errorf("node01 HostModel = %q, want x86_64", got.HostModel)
t.Errorf("node01 HostModel = %q, want x86_64 (config merged)", got.HostModel)
}
if got.NumCPUs != 8 {
t.Errorf("node01 NumCPUs = %d, want 8 (worker field preserved)", got.NumCPUs)
}
gotC, ok := store.Clusters.Get("c1")
if !ok {
@ -394,32 +400,36 @@ func TestIntegration_CrossFeature_HeartbeatAndClusterData(t *testing.T) {
}
}
// --- 5.2 P1: Config push + host visibility in same host ---
func TestIntegration_CrossFeature_ConfigPushAndHostVisible(t *testing.T) {
// --- 5.2 P1: Config push alone must NOT create host entries ---
func TestIntegration_CrossFeature_ConfigPushDoesNotCreateHost(t *testing.T) {
// Hosts like "default" (cluster-wide defaults) and offline workers must not appear
// in the host store. Only OnHostUpdated (worker registration) creates entries.
resetIntegrationStores()
h := &DefaultHandler{}
dispatchConfigPush(h, nil, nil, []models.HostInfo{
{HostName: "node01", HostModel: "x86_64"},
{HostName: "default", HostType: "x86_64-linux-gnu", MaxSlot: -1},
}, nil)
got, ok := store.Hosts.Get("node01")
if !ok {
t.Fatal("node01 should exist after config push")
if store.Hosts.Has("node01") {
t.Error("node01 must not be created by config push alone — only worker push registers hosts")
}
if got.HostModel != "x86_64" {
t.Errorf("HostModel = %q, want x86_64", got.HostModel)
if store.Hosts.Has("default") {
t.Error("'default' pseudo-host must not be created by config push")
}
if !store.Hosts.Has("node01") {
t.Error("store.Hosts.Has('node01') should be true — config-push data is visible in the host store")
if n := store.Hosts.Count(); n != 0 {
t.Errorf("Hosts store must be empty after config-only push, got %d entries", n)
}
}
// --- 5.3 P1: Resource push after host config push — same host ---
func TestIntegration_CrossFeature_ResourceAfterHostConfig(t *testing.T) {
// --- 5.3 P1: Resource push and host registration are independent ---
func TestIntegration_CrossFeature_ResourceAfterWorkerRegistration(t *testing.T) {
// Worker registers first, then resource push and config push arrive in any order.
resetIntegrationStores()
h := &DefaultHandler{}
h.OnHostUpdated(&models.HostInfo{HostName: "node01", HostStatus: 0, NumCPUs: 8})
h.OnHostConfigUpdated(&models.HostInfo{HostName: "node01", HostModel: "x86_64"})
h.OnResourceLocationUpdated(&models.ResourceDistributionInfo{
ResourceName: "mem", Location: "node01", NumericValue: 65536.0,
@ -428,10 +438,10 @@ func TestIntegration_CrossFeature_ResourceAfterHostConfig(t *testing.T) {
gotHost, ok := store.Hosts.Get("node01")
if !ok {
t.Fatal("node01 should exist in Hosts store")
t.Fatal("node01 should exist in Hosts store after worker registration")
}
if gotHost.HostModel != "x86_64" {
t.Errorf("HostModel = %q, want x86_64 (should be preserved)", gotHost.HostModel)
t.Errorf("HostModel = %q, want x86_64 (config merged)", gotHost.HostModel)
}
gotRes, ok := store.ResourceLocations.Get("mem:node01")
@ -442,36 +452,38 @@ func TestIntegration_CrossFeature_ResourceAfterHostConfig(t *testing.T) {
t.Errorf("NumericValue = %f, want 65536.0", gotRes.NumericValue)
}
// Host config should not have been overwritten by resource push
// Resource push must not have overwritten host config
gotHost2, _ := store.Hosts.Get("node01")
if gotHost2.HostModel != "x86_64" {
t.Errorf("HostModel after resource push = %q, want x86_64 (unchanged)", gotHost2.HostModel)
}
}
// --- 5.4 P1: Worker push after config push preserves config fields ---
func TestIntegration_CrossFeature_WorkerPreservesConfigFields(t *testing.T) {
// --- 5.4 P1: Config push onto registered host preserves worker fields ---
func TestIntegration_CrossFeature_ConfigPreservesWorkerFields(t *testing.T) {
// Canonical order: worker registers first, then config push merges config fields.
// Worker-sourced fields (HostStatus, CPUFactor, NumCPUs) must be preserved.
resetIntegrationStores()
h := &DefaultHandler{}
h.OnHostConfigUpdated(&models.HostInfo{HostName: "node01", HostModel: "x86_64", MaxSlot: 8})
h.OnHostUpdated(&models.HostInfo{HostName: "node01", HostStatus: 3, CPUFactor: 2.5, NumCPUs: 16})
h.OnHostConfigUpdated(&models.HostInfo{HostName: "node01", HostModel: "x86_64", MaxSlot: 8})
got, ok := store.Hosts.Get("node01")
if !ok {
t.Fatal("node01 should exist after both pushes")
}
if got.HostModel != "x86_64" {
t.Errorf("HostModel = %q, want x86_64 (config preserved)", got.HostModel)
t.Errorf("HostModel = %q, want x86_64 (config applied)", got.HostModel)
}
if got.MaxSlot != 8 {
t.Errorf("MaxSlot = %d, want 8 (config preserved)", got.MaxSlot)
t.Errorf("MaxSlot = %d, want 8 (config applied)", got.MaxSlot)
}
if got.HostStatus != 3 {
t.Errorf("HostStatus = %d, want 3 (worker updated)", got.HostStatus)
t.Errorf("HostStatus = %d, want 3 (worker field preserved)", got.HostStatus)
}
if got.CPUFactor != 2.5 {
t.Errorf("CPUFactor = %f, want 2.5 (worker updated)", got.CPUFactor)
t.Errorf("CPUFactor = %f, want 2.5 (worker field preserved)", got.CPUFactor)
}
}

View file

@ -9,6 +9,9 @@ type NotificationHandler interface {
OnJobCreated(job *models.JobInfo)
OnJobUpdated(job *models.JobInfo)
OnJobDeleted(jobid uint64)
// OnJobFinished is called when the controller reports job completion or
// dispatch failure. exitCode==0 → DONE, non-zero → EXIT.
OnJobFinished(jobid uint64, exitCode int)
OnQueueUpdated(queue *models.QueueInfo)
OnHostUpdated(host *models.HostInfo)
OnHostConfigUpdated(host *models.HostInfo)

View file

@ -600,9 +600,9 @@ func formatQueueStatus(state int64) string {
status = "open"
}
if state&int64(models.QUEUESTAT["INACTIVE_ADM"]) != 0 { // INACT_WINDOW
if state&int64(models.QUEUESTAT["INACTIVE_ADM"]) != 0 { // INACT_ADMIN
status += ":inactive_adm"
} else if state&int64(models.QUEUESTAT["INACTIVE_WIN"]) != 0 { // INACT_ADMIN
} else if state&int64(models.QUEUESTAT["INACTIVE_WIN"]) != 0 { // INACT_WINDOW
status += ":inactive_win"
} else {
status += ":active"

View file

@ -18,9 +18,6 @@ var HOSTSTAT = map[string]int{
}
func HostStateStr(state int) string {
if state == 0 {
return "UNKNOWN"
}
if (state & HOSTSTAT["UNAVAIL"]) != 0 {
return "UNAVAIL"
} else if (state & HOSTSTAT["CLOSED"]) != 0 {

View file

@ -9,9 +9,9 @@ func TestHostStateStr(t *testing.T) {
want string
}{
{
name: "zero value returns UNKNOWN when no worker has reported",
name: "zero value (no bad flags set) returns OK — C status=0 means healthy",
state: 0,
want: "UNKNOWN",
want: "OK",
},
{
name: "UNAVAIL flag",

View file

@ -70,8 +70,9 @@ var JOBSTAT = map[string]int{
"SSUSP": 0x40,
"PDONE": 0x80,
"PERR": 0x100,
"WAIT": 0x200,
"RUNKWN": 0x8000,
"UNKNOWN": 0x1000,
"UNKNOWN": 0x10000, // C JOB_STAT_UNKWN: worker lost contact with controller
}
var LAUNCHTYPE = map[string]int{
@ -90,12 +91,16 @@ func JobStateStr(state int) string {
return "USUSP"
} else if (state & JOBSTAT["SSUSP"]) != 0 {
return "SSUSP"
} else if (state & JOBSTAT["RUNKWN"]) != 0 {
return "RUNKWN"
}
return "RUN"
} else if (state & JOBSTAT["EXIT"]) != 0 {
return "EXIT"
} else if (state & JOBSTAT["DONE"]) != 0 {
return "DONE"
} else if (state & JOBSTAT["UNKNOWN"]) != 0 {
return "UNKWN"
}
return "ERR"
}
@ -245,6 +250,16 @@ func (jsb *Jsonb) IsZero() bool {
return jsb.String == ""
}
func (jsb Jsonb) MarshalJSON() ([]byte, error) {
if jsb.String == "" {
return []byte(`null`), nil
}
if json.Valid([]byte(jsb.String)) {
return []byte(jsb.String), nil
}
return json.Marshal(jsb.String)
}
func (tm *CustomTime) Tm() time.Time {
return tm.Time
}

View file

@ -0,0 +1,99 @@
package models
import "testing"
func TestJobStateStr(t *testing.T) {
tests := []struct {
name string
state int
want string
}{
{
name: "PEND",
state: JOBSTAT["PEND"],
want: "PEND",
},
{
name: "PSUSP (PEND+PSUSP)",
state: JOBSTAT["PEND"] | JOBSTAT["PSUSP"],
want: "PSUSP",
},
{
name: "RUN",
state: JOBSTAT["RUN"],
want: "RUN",
},
{
name: "USUSP (RUN+USUSP)",
state: JOBSTAT["RUN"] | JOBSTAT["USUSP"],
want: "USUSP",
},
{
name: "SSUSP (RUN+SSUSP)",
state: JOBSTAT["RUN"] | JOBSTAT["SSUSP"],
want: "SSUSP",
},
{
name: "RUNKWN (RUN+RUNKWN — lost contact with remote cluster)",
state: JOBSTAT["RUN"] | JOBSTAT["RUNKWN"],
want: "RUNKWN",
},
{
name: "EXIT",
state: JOBSTAT["EXIT"],
want: "EXIT",
},
{
name: "DONE",
state: JOBSTAT["DONE"],
want: "DONE",
},
{
name: "UNKNOWN (0x10000 — worker lost contact with controller)",
state: JOBSTAT["UNKNOWN"],
want: "UNKWN",
},
{
name: "zero state returns ERR",
state: 0,
want: "ERR",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := JobStateStr(tt.state)
if got != tt.want {
t.Errorf("JobStateStr(0x%X) = %q, want %q", tt.state, got, tt.want)
}
})
}
}
func TestJobStatMapValues(t *testing.T) {
// Verify bitmask values match C JOB_STAT_* defines in src/include/xce/types.h.
expected := map[string]int{
"PEND": 0x01,
"RUN": 0x02,
"EXIT": 0x04,
"DONE": 0x08,
"PSUSP": 0x10,
"USUSP": 0x20,
"SSUSP": 0x40,
"PDONE": 0x80,
"PERR": 0x100,
"WAIT": 0x200,
"RUNKWN": 0x8000,
"UNKNOWN": 0x10000,
}
for key, want := range expected {
got, ok := JOBSTAT[key]
if !ok {
t.Errorf("JOBSTAT[%q] missing", key)
continue
}
if got != want {
t.Errorf("JOBSTAT[%q] = 0x%X, want 0x%X", key, got, want)
}
}
}