Merge branch 'working' into job_peek_lchen

job_peek_lchen: Add fetchfile feature (job output/error peek proxy)
working: HA/Raft infrastructure (consensus, replication, config mgmt)

Conflict resolution summary:
- controller.capnp: fetchfile @41, fetchfilereply @18, fetchfilemsg @30
- cluster.c: bifurcated OPCode_reply for fetchfile + config-push ACK
- job.c: kept full fetchfile pending-request subsystem (~183 lines)
- request.c: kept both xce_fetchfile_req() and XCE_RETRY_DEFAULTS
- helper.c: added missing preVoteRequest/preVoteReply mappings
- All 15 new test registrations (test_cbuf.c + 14 HA tests)
This commit is contained in:
Lei Chen 2026-07-22 13:57:25 +08:00
commit 3a2094a461
529 changed files with 345174 additions and 5048 deletions

View file

@ -76,6 +76,30 @@ var idCtxOnce sync.Once
// Must be set via StartNotificationListener before RegisterToBackend.
var gHandler NotificationHandler
func tryConnect(host string, port int, from, key string) error {
cAddr := C.CString(host)
cFrom := C.CString(from)
cKey := C.CString(key)
defer func() {
C.free(unsafe.Pointer(cAddr))
C.free(unsafe.Pointer(cFrom))
C.free(unsafe.Pointer(cKey))
}()
var cConfig C.xce_client_config_t
cConfig.nmasters = 1
cConfig.masters[0] = cAddr
cConfig.master_ports[0] = C.int(port)
cConfig.from = cFrom
cConfig.key = cKey
rc := C.xce_backend_init(&cConfig, &gCtx)
if rc < 0 {
return fmt.Errorf("xce_backend_init failed on %s:%d: %d", host, port, int(rc))
}
return nil
}
// RegisterToBackend initialises the XCE client library and joins as an API
// server. StartNotificationListener should be called first so that push
// events have a handler ready before the first push can arrive.
@ -89,27 +113,31 @@ func RegisterToBackend(config *BackendConfig) error {
key = "keepsecret$"
}
cAddr := C.CString(config.Address)
cFrom := C.CString(from)
cKey := C.CString(key)
defer func() {
C.free(unsafe.Pointer(cAddr))
C.free(unsafe.Pointer(cFrom))
C.free(unsafe.Pointer(cKey))
}()
var cConfig C.xce_client_config_t
cConfig.nmasters = 1
cConfig.masters[0] = cAddr
cConfig.master_ports[0] = C.int(config.Port)
cConfig.from = cFrom
cConfig.key = cKey
rc := C.xce_backend_init(&cConfig, &gCtx)
if rc < 0 {
return fmt.Errorf("xce_backend_init failed: %d", int(rc))
if len(config.Peers) > 0 {
var lastErr error
for _, peer := range config.Peers {
host, port, err := parsePeer(peer)
if err != nil {
logger.Warn("Skipping invalid peer", zap.String("peer", peer), zap.Error(err))
lastErr = err
continue
}
if err := tryConnect(host, port, from, key); err != nil {
logger.Warn("Candidate unreachable, trying next",
zap.String("host", host), zap.Int("port", port), zap.Error(err))
lastErr = err
continue
}
logger.Info("Backend connection established (HA)",
zap.String("host", host), zap.Int("port", port))
return nil
}
return fmt.Errorf("all HA candidates failed: %w", lastErr)
}
if err := tryConnect(config.Address, config.Port, from, key); err != nil {
return err
}
logger.Info("Backend connection established",
zap.String("address", config.Address),
zap.Int("port", config.Port))

View file

@ -7,6 +7,12 @@ type BackendConfig struct {
Port int // controller port (e.g. 9990)
From string // identity sent to controller (default: "xce_apiserver")
Key string // authentication key
// Peers lists additional controller candidates for HA mode in "host:port"
// format. When non-empty, RegisterToBackend tries each candidate in order
// and connects to whichever accepts the join as primary. Address+Port
// serve as a single-host fallback when Peers is empty.
Peers []string
}
// FetchFileResult holds the data returned by FetchFile.

View file

@ -80,8 +80,9 @@ func cJobToModel(job *C.xce_job_t) *models.JobInfo {
// cWorkerToModel extracts C xce_worker_t fields into a rawWorkerData and maps it to HostInfo.
func cWorkerToModel(worker *C.xce_worker_t) *models.HostInfo {
workerName := goString(worker.name)
r := rawWorkerData{
name: goString(worker.name),
name: workerName,
status: int(worker.status),
}
if worker.attrs != nil {
@ -96,6 +97,16 @@ func cWorkerToModel(worker *C.xce_worker_t) *models.HostInfo {
r.maxTmp = int(a.maxtmp)
r.nDisks = int(a.ndisks)
}
n := int(worker.nresvals_to_sched)
if n > 0 {
entries := unsafe.Slice(worker.resvals_to_sched, n)
r.resvals = make([]models.ResourceDistributionInfo, 0, n)
for _, nv := range entries {
if rd := cNameValToWorkerResval(workerName, nv); rd != nil {
r.resvals = append(r.resvals, *rd)
}
}
}
return rawWorkerToModel(r)
}
@ -189,7 +200,7 @@ func cClusterConfToClusterInfo(conf *C.cluster_config_t) *models.ClusterInfo {
return rawClusterToModel(rawClusterData{
name: name,
heartbeatInterval: int(conf.heartbeat_interval),
vcpus: int(conf.vcpus),
vcpus: 0, // vcpus moved to myconf_t (--vcpus CLI flag); not in cluster_config_t
inactiveHeartbeatCount: int(conf.inactive_heartbeat_count),
})
}
@ -267,6 +278,55 @@ func freeJobSubmit(js *C.xce_job_submit_t, sd *C.job_submit_data_t, si *C.job_st
}
}
// cNameValToWorkerResval converts a C xce_name_val_t pointer to a ResourceDistributionInfo.
// workerName is set as Location. Returns nil if nv or nv.val is nil.
func cNameValToWorkerResval(workerName string, nv *C.xce_name_val_t) *models.ResourceDistributionInfo {
if nv == nil {
return nil
}
rd := &models.ResourceDistributionInfo{
ResourceName: C.GoString(nv.name),
Location: workerName,
IsDynamic: true,
}
if nv.val == nil {
logger.Warn("worker resval with nil value, skipping",
zap.String("resource", rd.ResourceName),
zap.String("host", workerName))
return nil
}
switch C.xce_val_which(nv.val) {
case C.Value_u_ivalue32:
rd.ValueType = int(models.NUMERIC)
rd.NumericValue = float64(C.xce_val_i32(nv.val))
case C.Value_u_ivalue64:
rd.ValueType = int(models.NUMERIC)
rd.NumericValue = float64(C.xce_val_i64(nv.val))
case C.Value_u_uvalue32:
rd.ValueType = int(models.NUMERIC)
rd.NumericValue = float64(C.xce_val_u32(nv.val))
case C.Value_u_uvalue64:
rd.ValueType = int(models.NUMERIC)
rd.NumericValue = float64(C.xce_val_u64(nv.val))
case C.Value_u_fvalue:
rd.ValueType = int(models.NUMERIC)
rd.NumericValue = float64(C.xce_val_fval(nv.val))
case C.Value_u_svalue:
rd.ValueType = int(models.STRING)
rd.StringValue = C.GoString(C.xce_val_str(nv.val))
case C.Value_u_bvalue:
rd.ValueType = int(models.BOOLEAN)
rd.BooleanValue = C.xce_val_bool(nv.val) != 0
default:
logger.Warn("unknown xce_val_t discriminant in worker resval, skipping",
zap.Int("which", int(C.xce_val_which(nv.val))),
zap.String("resource", rd.ResourceName),
zap.String("host", workerName))
return nil
}
return rd
}
// 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 &.

View file

@ -4,7 +4,6 @@ import (
"apiserver/models"
"encoding/json"
"fmt"
"time"
)
// JobID bit layout constants — mirrors src/include/config.h.
@ -155,6 +154,7 @@ type rawWorkerData struct {
maxSwap int
maxTmp int
nDisks int
resvals []models.ResourceDistributionInfo
}
// rawQueueData mirrors the C xce_queue_t fields used by the Go model.
@ -207,18 +207,10 @@ func rawJobToModel(r rawJobData) *models.JobInfo {
info.ExecUser = si.execUser
}
}
if r.subTime > 0 {
info.SubmitTime = time.Unix(r.subTime, 0)
}
if r.queueTime > 0 {
info.QueueTime = time.Unix(r.queueTime, 0)
}
if r.startTime > 0 {
info.StartTime = time.Unix(r.startTime, 0)
}
if r.finishTime > 0 {
info.EndTime = time.Unix(r.finishTime, 0)
}
info.SubmitTime = r.subTime
info.QueueTime = r.queueTime
info.StartTime = r.startTime
info.EndTime = r.finishTime
if len(r.execHosts) > 0 {
type hostSlot struct {
HostName string `json:"host_name"`
@ -241,8 +233,9 @@ func rawJobToModel(r rawJobData) *models.JobInfo {
// rawWorkerToModel maps a rawWorkerData to a models.HostInfo.
func rawWorkerToModel(r rawWorkerData) *models.HostInfo {
info := &models.HostInfo{
HostName: r.name,
HostStatus: r.status,
HostName: r.name,
HostStatus: r.status,
ResourceValues: r.resvals,
}
if r.hasAttrs {
info.HostType = r.hostType

View file

@ -3,7 +3,6 @@ package backend
import (
"apiserver/models"
"testing"
"time"
)
func TestRawJobToModel_BasicFields(t *testing.T) {
@ -81,17 +80,17 @@ func TestRawJobToModel_Timestamps(t *testing.T) {
info := rawJobToModel(r)
if info.SubmitTime != time.Unix(1700000000, 0) {
t.Errorf("SubmitTime mismatch")
if info.SubmitTime != 1700000000 {
t.Errorf("SubmitTime: got %d, want 1700000000", info.SubmitTime)
}
if info.QueueTime != time.Unix(1700000010, 0) {
t.Errorf("QueueTime mismatch")
if info.QueueTime != 1700000010 {
t.Errorf("QueueTime: got %d, want 1700000010", info.QueueTime)
}
if info.StartTime != time.Unix(1700000020, 0) {
t.Errorf("StartTime mismatch")
if info.StartTime != 1700000020 {
t.Errorf("StartTime: got %d, want 1700000020", info.StartTime)
}
if info.EndTime != time.Unix(1700000030, 0) {
t.Errorf("EndTime mismatch")
if info.EndTime != 1700000030 {
t.Errorf("EndTime: got %d, want 1700000030", info.EndTime)
}
}

View file

@ -0,0 +1,24 @@
package backend
import (
"fmt"
"net"
"strconv"
)
// parsePeer splits a "host:port" string. Uses net.SplitHostPort so IPv6
// bracket notation (e.g. "[::1]:9990") is handled correctly.
func parsePeer(peer string) (host string, port int, err error) {
h, portStr, err := net.SplitHostPort(peer)
if err != nil {
return "", 0, fmt.Errorf("invalid peer %q: %w", peer, err)
}
if h == "" {
return "", 0, fmt.Errorf("invalid peer %q: empty host", peer)
}
port, err = strconv.Atoi(portStr)
if err != nil || port <= 0 || port > 65535 {
return "", 0, fmt.Errorf("invalid port in peer %q", peer)
}
return h, port, nil
}

View file

@ -0,0 +1,95 @@
package backend
import "testing"
func TestParsePeer(t *testing.T) {
tests := []struct {
name string
peer string
wantHost string
wantPort int
wantError bool
}{
{
name: "valid simple host:port",
peer: "ctrl01:9990",
wantHost: "ctrl01",
wantPort: 9990,
},
{
name: "valid IPv4",
peer: "192.168.1.10:9990",
wantHost: "192.168.1.10",
wantPort: 9990,
},
{
name: "valid FQDN",
peer: "ctrl01.cluster.local:9990",
wantHost: "ctrl01.cluster.local",
wantPort: 9990,
},
{
name: "min valid port",
peer: "host:1",
wantHost: "host",
wantPort: 1,
},
{
name: "max valid port",
peer: "host:65535",
wantHost: "host",
wantPort: 65535,
},
{
name: "missing colon",
peer: "ctrl019990",
wantError: true,
},
{
name: "port zero",
peer: "ctrl01:0",
wantError: true,
},
{
name: "port out of range",
peer: "ctrl01:65536",
wantError: true,
},
{
name: "non-numeric port",
peer: "ctrl01:abc",
wantError: true,
},
{
name: "colon at start (empty host)",
peer: ":9990",
wantError: true,
},
{
name: "empty string",
peer: "",
wantError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
host, port, err := parsePeer(tc.peer)
if tc.wantError {
if err == nil {
t.Errorf("parsePeer(%q): expected error, got host=%q port=%d", tc.peer, host, port)
}
return
}
if err != nil {
t.Fatalf("parsePeer(%q): unexpected error: %v", tc.peer, err)
}
if host != tc.wantHost {
t.Errorf("host: got %q, want %q", host, tc.wantHost)
}
if port != tc.wantPort {
t.Errorf("port: got %d, want %d", port, tc.wantPort)
}
})
}
}

View file

@ -29,4 +29,31 @@ func TestEvalSelectForHost_StubErrNoCGO(t *testing.T) {
func TestCleanup_StubNoOp(t *testing.T) {
// Should not panic and completes immediately
Cleanup()
}
// TestRegisterToBackend_StubSingleHost verifies the stub returns errNoCGO
// for a single-host BackendConfig (no Peers).
func TestRegisterToBackend_StubSingleHost(t *testing.T) {
err := RegisterToBackend(&BackendConfig{
Address: "ctrl01",
Port: 9990,
From: "xce_apiserver",
Key: "keepsecret$",
})
if err != errNoCGO {
t.Errorf("expected errNoCGO, got %v", err)
}
}
// TestRegisterToBackend_StubWithPeers verifies the stub returns errNoCGO
// even when Peers are configured (HA mode), since CGO is not available.
func TestRegisterToBackend_StubWithPeers(t *testing.T) {
err := RegisterToBackend(&BackendConfig{
Peers: []string{"ctrl01:9990", "ctrl02:9990"},
From: "xce_apiserver",
Key: "keepsecret$",
})
if err != errNoCGO {
t.Errorf("expected errNoCGO with peers configured, got %v", err)
}
}

View file

@ -66,6 +66,7 @@ func main() {
Port: utils.BackendYamlConfig.Port,
From: utils.BackendYamlConfig.From,
Key: utils.BackendYamlConfig.Key,
Peers: utils.BackendYamlConfig.Peers,
}
if err := backend.RegisterToBackend(bcfg); err != nil {
logger.Error("Failed to connect to backend", zap.Error(err))

View file

@ -11,6 +11,9 @@ config_file:
- username: admin
password: admin
roles: [cluster_admin]
- username: rshen
password: Letmein
roles: [cluster_admin]
- username: user1
password: user1
roles: [basic_user]

View file

@ -536,21 +536,16 @@ func validateHost(ctx *gin.Context, control int, hostnames []string) (successHos
}
func isHostControlActionAllowed(actionType int, status int) bool {
isClosed := (status&models.HOSTSTAT["CLOSED"]) != 0 ||
(status&models.HOSTSTAT["CLOSED_BY_ADMIN"]) != 0
switch actionType {
case 1: // open strictly
if status&models.HOSTSTAT["CLOSED"] != 0 {
return true
}
case 1: // open
return isClosed
case 2: // close
if (status & models.HOSTSTAT["CLOSED"]) != 0 {
return false
} else {
return true
}
return !isClosed
default:
return false
}
return false
}
func preCheckHostControlRequest(req *models.HostControlRequest) (bool, error) {
logger.Debug("Enter preCheckHostControlRequest")

View file

@ -1535,6 +1535,13 @@ func preCheckJobRequest(req *models.JobSubmitRequest) ([]int, error) {
if req.QueueName == "" {
req.QueueName = "normal"
} else {
// Reject submissions to the reserved internal recovery queue.
if req.QueueName == "lost_and_found" {
logger.Error("Job submission to reserved queue rejected",
zap.String("queue_name", req.QueueName))
return nil, fmt.Errorf(
"cannot submit jobs to 'lost_and_found' — this is an internal recovery queue")
}
//validate queue_name whether exists
queueInfo, ok := store.Queues.Get(req.QueueName)
if !ok {

View file

@ -0,0 +1,76 @@
package controllers
import (
"apiserver/models"
"apiserver/store"
"apiserver/utils"
"strings"
"testing"
"go.uber.org/zap"
)
func resetJobSubmitState() {
store.InitStores()
utils.Log = zap.NewNop()
}
// TestPreCheckJobRequest_LostAndFound verifies that submitting to the
// "lost_and_found" queue is rejected with a clear error message.
func TestPreCheckJobRequest_LostAndFound(t *testing.T) {
resetJobSubmitState()
req := &models.JobSubmitRequest{
QueueName: "lost_and_found",
}
_, err := preCheckJobRequest(req)
if err == nil {
t.Fatal("expected error when submitting to lost_and_found queue, got nil")
}
if !strings.Contains(err.Error(), "lost_and_found") {
t.Errorf("error message should mention 'lost_and_found', got: %v", err)
}
if !strings.Contains(err.Error(), "internal recovery queue") {
t.Errorf("error message should mention 'internal recovery queue', got: %v", err)
}
}
// TestPreCheckJobRequest_NormalQueue verifies that a normal (non-reserved)
// queue name does not trigger the reserved-name rejection.
func TestPreCheckJobRequest_NormalQueue(t *testing.T) {
resetJobSubmitState()
// Seed a valid open queue in the store.
store.Queues.Put("normal", models.QueueInfo{
QueueName: "normal",
QueueStatus: 0, // open
})
req := &models.JobSubmitRequest{
QueueName: "normal",
}
_, err := preCheckJobRequest(req)
// Should not be rejected for the reserved-name check; may fail for other
// reasons (e.g. missing JobName validation), but not with our new error.
if err != nil && strings.Contains(err.Error(), "internal recovery queue") {
t.Errorf("normal queue should not be rejected with reserved-name error: %v", err)
}
}
// TestPreCheckJobRequest_EmptyQueue verifies that an empty queue name is
// allowed (defaults to "normal") and is not confused with reserved names.
func TestPreCheckJobRequest_EmptyQueue(t *testing.T) {
resetJobSubmitState()
req := &models.JobSubmitRequest{
QueueName: "",
}
// Empty queue name sets default — should not trigger reserved-name error.
_, err := preCheckJobRequest(req)
if err != nil && strings.Contains(err.Error(), "internal recovery queue") {
t.Errorf("empty queue should not trigger reserved-name error: %v", err)
}
}

View file

@ -1,7 +1,5 @@
package models
import "time"
const (
CONTROL_JOB = 1
CONTROL_HOST = 2
@ -11,9 +9,9 @@ const (
)
type ActionControl struct {
TargetType int `json:"target_type"`
ActionType int `json:"action_type"`
Content Jsonb `json:"content"`
SubmitTime time.Time `json:"submit_time"`
Submitter string `json:"submitter"`
TargetType int `json:"target_type"`
ActionType int `json:"action_type"`
Content Jsonb `json:"content"`
SubmitTime int64 `json:"submit_time"`
Submitter string `json:"submitter"`
}

View file

@ -10,16 +10,19 @@ const (
var SupportedHostActions = []string{"open", "close"}
var HOSTSTAT = map[string]int{
"UNAVAIL": 0x00010000,
"LOCKEDU": 0x00020000,
"LOCKEDW": 0x00040000,
"BUSY": 0x00080000,
"CLOSED": 0x00100000,
"UNAVAIL": 0x00010000,
"LOCKEDU": 0x00020000,
"LOCKEDW": 0x00040000,
"BUSY": 0x00080000,
"CLOSED": 0x00100000,
"CLOSED_BY_ADMIN": 0x00400000,
}
func HostStateStr(state int) string {
if (state & HOSTSTAT["UNAVAIL"]) != 0 {
return "UNAVAIL"
} else if (state & HOSTSTAT["CLOSED_BY_ADMIN"]) != 0 {
return "CLOSED_BY_ADMIN"
} else if (state & HOSTSTAT["CLOSED"]) != 0 {
return "CLOSED"
} else if (state & HOSTSTAT["BUSY"]) != 0 {
@ -86,8 +89,9 @@ type HostInfo struct {
NumUSusp int `json:"num_ususp"`
//NumRsv int `json:"num_rsv"`
NumDone int `json:"num_done"`
Topology string `json:"topology"` // JSONB
SchedStop string `json:"sched_stop"` // JSONB
Topology string `json:"topology"` // JSONB
SchedStop string `json:"sched_stop"` // JSONB
ResourceValues []ResourceDistributionInfo `json:"resource_values"` // per-worker resource values
//RealLoad string `json:"real_load"` // JSONB
}

View file

@ -23,6 +23,11 @@ func TestHostStateStr(t *testing.T) {
state: HOSTSTAT["CLOSED"],
want: "CLOSED",
},
{
name: "CLOSED_BY_ADMIN flag",
state: HOSTSTAT["CLOSED_BY_ADMIN"],
want: "CLOSED_BY_ADMIN",
},
{
name: "BUSY flag",
state: HOSTSTAT["BUSY"],
@ -48,6 +53,11 @@ func TestHostStateStr(t *testing.T) {
state: HOSTSTAT["UNAVAIL"] | HOSTSTAT["CLOSED"],
want: "UNAVAIL", // UNAVAIL takes priority
},
{
name: "combined CLOSED+CLOSED_BY_ADMIN flags",
state: HOSTSTAT["CLOSED"] | HOSTSTAT["CLOSED_BY_ADMIN"],
want: "CLOSED_BY_ADMIN", // CLOSED_BY_ADMIN overrides generic CLOSED
},
}
for _, tt := range tests {

View file

@ -61,18 +61,19 @@ var JobSignalMapTable = map[string]int{
}
var JOBSTAT = 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, // C JOB_STAT_UNKWN: worker lost contact with controller
"PEND": 0x01,
"RUN": 0x02,
"EXIT": 0x04,
"DONE": 0x08,
"PSUSP": 0x10,
"USUSP": 0x20,
"SSUSP": 0x40,
"PDONE": 0x80,
"PERR": 0x100,
"WAIT": 0x200,
"TRANSIENT": 0x400, // C JOB_STAT_TRANSIENT: job state uncertain after failover
"RUNKWN": 0x8000,
"UNKNOWN": 0x10000, // C JOB_STAT_UNKWN: worker lost contact with controller
}
var LAUNCHTYPE = map[string]int{
@ -81,7 +82,10 @@ var LAUNCHTYPE = map[string]int{
}
func JobStateStr(state int) string {
if (state & JOBSTAT["PEND"]) != 0 {
// Check TRANSIENT first — it takes priority after failover
if (state & JOBSTAT["TRANSIENT"]) != 0 {
return "TRANSIENT"
} else if (state & JOBSTAT["PEND"]) != 0 {
if (state & JOBSTAT["PSUSP"]) != 0 {
return "PSUSP"
}
@ -485,7 +489,7 @@ type JobInfo struct {
ArrayIdx int `json:"array_idx"` //new
SubmitUser string `json:"submit_user"`
FromHost string `json:"from_host"`
SubmitTime time.Time `json:"submit_time"`
SubmitTime int64 `json:"submit_time"`
JobStatus int `json:"job_status"`
JobName string `json:"job_name"`
Command string `json:"command"`
@ -531,10 +535,10 @@ type JobInfo struct {
Tags Jsonb `json:"tags"` // new, JSONB as []byte
EffectiveResReq string `json:"effective_resreq"` //new
EffectiveUserGroup string `json:"effective_user_group"` //new
QueueTime time.Time `json:"queue_time"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
ReserveTime time.Time `json:"reserve_time"`
QueueTime int64 `json:"queue_time"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
ReserveTime int64 `json:"reserve_time"`
Duration int `json:"duration"`
CPUTime int `json:"cpu_time"`
ExitStatus Jsonb `json:"exit_status"` //change type, JSONB as []byte
@ -585,12 +589,12 @@ type JobAdditionalFields struct {
JobID int64 `json:"job_id"`
SubmitUser string `json:"submit_user"`
JobStatus int `json:"job_status"`
SubmitTime time.Time `json:"submit_time"`
FromHost string `json:"from_host"`
ExecHosts string `json:"exec_hosts"`
SubmitTime int64 `json:"submit_time"`
FromHost string `json:"from_host"`
ExecHosts string `json:"exec_hosts"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
JobRusage Jsonb `json:"job_rusage"`
MaxMem float64 `json:"max_mem"`
AvgMem float64 `json:"avg_mem"`

View file

@ -58,6 +58,16 @@ func TestJobStateStr(t *testing.T) {
state: 0,
want: "ERR",
},
{
name: "TRANSIENT (0x400 — job uncertain after failover)",
state: 0x400,
want: "TRANSIENT",
},
{
name: "TRANSIENT with other bits set",
state: 0x400 | 0x01, // TRANSIENT + PEND
want: "TRANSIENT",
},
}
for _, tt := range tests {
@ -73,18 +83,19 @@ func TestJobStateStr(t *testing.T) {
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,
"PEND": 0x01,
"RUN": 0x02,
"EXIT": 0x04,
"DONE": 0x08,
"PSUSP": 0x10,
"USUSP": 0x20,
"SSUSP": 0x40,
"PDONE": 0x80,
"PERR": 0x100,
"WAIT": 0x200,
"TRANSIENT": 0x400,
"RUNKWN": 0x8000,
"UNKNOWN": 0x10000,
}
for key, want := range expected {
got, ok := JOBSTAT[key]

View file

@ -13,10 +13,15 @@ import (
)
type BackendConfig struct {
Address string `yaml:"address"`
Port int `yaml:"port"`
From string `yaml:"from"`
Key string `yaml:"key"`
Address string `yaml:"address"`
Port int `yaml:"port"`
From string `yaml:"from"`
Key string `yaml:"key"`
// Peers lists additional controller candidates for HA mode in "host:port"
// format. When non-empty, RegisterToBackend tries each candidate in order
// and connects to whichever accepts the join as primary. Address+Port
// serve as a single-host fallback when Peers is empty.
Peers []string `yaml:"peers"`
}
type AppServerConfig struct {
@ -250,6 +255,7 @@ func InitConf() error {
Port: viper.GetInt("backend.port"),
From: viper.GetString("backend.from"),
Key: viper.GetString("backend.key"),
Peers: viper.GetStringSlice("backend.peers"),
}
return nil