Merge branch 'persistent_ha' into scheduler

This commit is contained in:
Rongsong Shen 2026-05-12 10:52:46 +00:00
commit a07a74a943
106 changed files with 18528 additions and 637 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,4 +7,10 @@ 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
}

View file

@ -3,7 +3,6 @@ package backend
import (
"apiserver/models"
"encoding/json"
"time"
)
// JobID bit layout constants — mirrors src/include/config.h.
@ -179,18 +178,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"`

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

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

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

View file

@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
jobfmt "xcecli/common/format"
"xcecli/models"
)
@ -96,7 +95,7 @@ func (p *ArrayElementsPagePrinter) initColumnWidths(jobs *models.JobsResponse) {
case models.HEADER_JOB_NAME:
width = len(job.JobName)
case models.HEADER_JOB_SUBMIT_TIME:
width = len(job.SubmitTime)
width = len(fmtUnixTime(job.SubmitTime))
}
if !p.wideFormat && width > 15 {
@ -166,7 +165,7 @@ func (p *ArrayElementsPagePrinter) formatRows(jobs *models.JobsResponse, truncat
mainRow = appendOrDefault(mainRow, FormatStringField(job.FromHost, width, truncatFlag, true), "-")
case models.HEADER_JOB_EXEC_HOST:
var execHosts models.ExecHosts
if err := json.Unmarshal([]byte(job.ExecHosts.String()), &execHosts); err == nil && len(execHosts.HostSlots) > 0 {
if err := json.Unmarshal([]byte(job.ExecHosts.String()), &execHosts); err == nil && len(execHosts.HostSlots) > 0 && execHosts.HostSlots[0].HostName != "" {
firstHost := fmt.Sprintf("%d*%s", execHosts.HostSlots[0].NumSlots, execHosts.HostSlots[0].HostName)
mainRow = appendOrDefault(mainRow, FormatStringField(firstHost, width, truncatFlag, true), "-")
@ -190,21 +189,7 @@ func (p *ArrayElementsPagePrinter) formatRows(jobs *models.JobsResponse, truncat
case models.HEADER_JOB_NAME:
mainRow = appendOrDefault(mainRow, FormatStringField(job.JobName, width, truncatFlag, true), "-")
case models.HEADER_JOB_SUBMIT_TIME:
// remove the timezone part and parse the time
timeStr := strings.Split(job.SubmitTime, " ")[0:2]
timeStrWithoutTZ := strings.Join(timeStr, " ")
if submitTime, err := time.Parse("2006-01-02 15:04:05", timeStrWithoutTZ); err == nil {
month := submitTime.Format("Jan")
displayTime := fmt.Sprintf("%s %02d %02d:%02d",
month,
submitTime.Day(),
submitTime.Hour(),
submitTime.Minute())
mainRow = appendOrDefault(mainRow, FormatStringField(displayTime, width, truncatFlag, true), "-")
} else {
mainRow = appendOrDefault(mainRow, fmt.Sprintf("%"+align+"*s", width, "-"), "-")
}
mainRow = appendOrDefault(mainRow, FormatStringField(fmtUnixTime(job.SubmitTime), width, truncatFlag, true), "-")
}
}
rows = append(rows, mainRow)

View file

@ -98,7 +98,7 @@ func (p *JobsPagePrinter) initColumnWidths(jobs *models.JobsResponse) {
case models.HEADER_JOB_NAME:
width = len(job.JobName)
case models.HEADER_JOB_SUBMIT_TIME:
width = len(job.SubmitTime)
width = len(fmtUnixTime(job.SubmitTime))
case models.HEADER_JOB_CORE_LIMIT:
width = len(fmt.Sprintf("%f", job.CoreLimit))
case models.HEADER_JOB_MEM_LIMIT:
@ -179,7 +179,7 @@ func (p *JobsPagePrinter) formatRows(jobs *models.JobsResponse, truncatFlag bool
case models.HEADER_JOB_EXEC_HOST:
var execHosts models.ExecHosts
if err := json.Unmarshal([]byte(job.ExecHosts.String()), &execHosts); err == nil && len(execHosts.HostSlots) > 0 {
if err := json.Unmarshal([]byte(job.ExecHosts.String()), &execHosts); err == nil && len(execHosts.HostSlots) > 0 && execHosts.HostSlots[0].HostName != "" {
firstHost := fmt.Sprintf("%d*%s", execHosts.HostSlots[0].NumSlots, execHosts.HostSlots[0].HostName)
mainRow = appendOrDefault(mainRow,
FormatStringField(firstHost, width, truncatFlag, false), "-")
@ -201,11 +201,7 @@ func (p *JobsPagePrinter) formatRows(jobs *models.JobsResponse, truncatFlag bool
mainRow = appendOrDefault(mainRow, fmt.Sprintf("%"+align+"*s", width, "-"), "-")
}
case models.HEADER_JOB_SUBMIT_TIME:
if submitTime, err := parseJobTime(job.SubmitTime); err == nil {
mainRow = appendOrDefault(mainRow, FormatStringField(formatJobTime(submitTime), width, truncatFlag, true), "-")
} else {
mainRow = appendOrDefault(mainRow, fmt.Sprintf("%"+align+"*s", width, "-"), "-")
}
mainRow = appendOrDefault(mainRow, FormatStringField(fmtUnixTime(job.SubmitTime), width, truncatFlag, true), "-")
// normal fields
case models.HEADER_JOB_NAME:
@ -223,9 +219,9 @@ func (p *JobsPagePrinter) formatRows(jobs *models.JobsResponse, truncatFlag bool
case models.HEADER_JOB_PROJECT_NAME:
mainRow = appendOrDefault(mainRow, FormatStringField(job.ProjectName, width, truncatFlag, true), "-")
case models.HEADER_JOB_START_TIME:
mainRow = appendOrDefault(mainRow, FormatStringField(job.StartTime, width, truncatFlag, true), "-")
mainRow = appendOrDefault(mainRow, FormatStringField(fmtUnixTime(job.StartTime), width, truncatFlag, true), "-")
case models.HEADER_JOB_END_TIME:
mainRow = appendOrDefault(mainRow, FormatStringField(job.EndTime, width, truncatFlag, true), "-")
mainRow = appendOrDefault(mainRow, FormatStringField(fmtUnixTime(job.EndTime), width, truncatFlag, true), "-")
case models.HEADER_JOB_CWD:
mainRow = appendOrDefault(mainRow, FormatStringField(job.CWD, width, truncatFlag, true), "-")
case models.HEADER_JOB_LAUNCH_TYPE:
@ -303,7 +299,7 @@ func printJobsVerboseOutput(jobs *models.JobsResponse, filter []string) {
// (2) print the submit time info
submitLine := ""
if submitTime, err := parseJobTime(job.SubmitTime); err == nil {
if submitTime, ok := unixToTime(job.SubmitTime); ok {
submitLine = submitLine + fmt.Sprintf("%s %s %02d:%02d:%02d: ",
submitTime.Format("Jan"), submitTime.Format("02"),
submitTime.Hour(), submitTime.Minute(), submitTime.Second())
@ -322,9 +318,20 @@ func printJobsVerboseOutput(jobs *models.JobsResponse, filter []string) {
}
PrintWrappedLine(w, submitLine, 80, 18)
// (3) print the start time info
// (3) print the pending reason for jobs waiting to be dispatched
if job.JobStatus == "PEND" || job.JobStatus == "PSUSP" {
if job.PendingReason != "" {
fmt.Fprintf(w, " PENDING REASONS:\n")
fmt.Fprintf(w, " %s\n", job.PendingReason)
}
}
// (4) print the start time info — skip for jobs that haven't started yet
startLine := ""
if startTime, err := parseJobTime(job.StartTime); err == nil {
if job.JobStatus == "PEND" || job.JobStatus == "PSUSP" {
goto printEndLine
}
if startTime, ok := unixToTime(job.StartTime); ok {
month := startTime.Format("Jan")
day := startTime.Format("02")
exec_hostname := ""
@ -380,10 +387,11 @@ func printJobsVerboseOutput(jobs *models.JobsResponse, filter []string) {
}
PrintWrappedLine(w, startLine, 80, 18)
printEndLine:
// (4) print end line
endLine := ""
if job.JobStatus == "DONE" || job.JobStatus == "EXIT" {
if endTime, err := parseJobTime(job.EndTime); err == nil {
if endTime, ok := unixToTime(job.EndTime); ok {
endLine = endLine + fmt.Sprintf("%s %s %02d:%02d:%02d: ",
endTime.Format("Jan"), endTime.Format("02"),
endTime.Hour(), endTime.Minute(), endTime.Second())

View file

@ -5,17 +5,23 @@ import (
"time"
)
// parseJobTime parses a job time string supporting RFC3339 and space-separated formats.
func parseJobTime(s string) (time.Time, error) {
if s == "" {
return time.Time{}, fmt.Errorf("empty time string")
// unixToTime converts a Unix-second timestamp to time.Time.
// Returns (time, true) for ts > 0; (zero, false) for unset/zero values.
func unixToTime(ts int64) (time.Time, bool) {
if ts <= 0 {
return time.Time{}, false
}
for _, f := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05", "2006-01-02 15:04:05 MST"} {
if t, err := time.Parse(f, s); err == nil {
return t, nil
}
return time.Unix(ts, 0), true
}
// fmtUnixTime formats a Unix timestamp as "Jan 02 15:04" for table display.
// Returns "-" for zero/unset values.
func fmtUnixTime(ts int64) string {
t, ok := unixToTime(ts)
if !ok {
return "-"
}
return time.Time{}, fmt.Errorf("cannot parse time: %q", s)
return formatJobTime(t)
}
// formatJobTime formats a time.Time as "Jan 02 15:04" for table display.

View file

@ -3,8 +3,6 @@ package models
import (
"encoding/json"
"fmt"
"time"
)
const (
@ -32,6 +30,7 @@ const (
HEADER_JOB_LAUNCH_TYPE = "LAUNCH_TYPE"
HEADER_JOB_NUM_PROCESSORS = "NUM_PROCESSORS"
HEADER_JOB_INTERACTIVE_URL = "INTERACTIVE_URL"
HEADER_JOB_PENDING_REASON = "PENDING_REASON"
// second line
HEADER_JOB_R15S = "r15s"
@ -114,10 +113,10 @@ type QueryJob struct {
TermTime string `json:"term_time"`
UserPriority int `json:"user_priority"`
DependCond string `json:"depend_cond"`
SubmitTime string `json:"submit_time"`
ReserveTime time.Time `json:"reserve_time"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
SubmitTime int64 `json:"submit_time"`
ReserveTime int64 `json:"reserve_time"`
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
JobRusage string `json:"job_rusage"`
MaxMem float64 `json:"max_mem"`
AvgMem float64 `json:"avg_mem"`
@ -210,6 +209,7 @@ var JobFieldsMap = map[string]string{
HEADER_JOB_LAUNCH_TYPE: "launch_type",
HEADER_JOB_NUM_PROCESSORS: "num_processors",
HEADER_JOB_INTERACTIVE_URL: "interactive_job_url",
HEADER_JOB_PENDING_REASON: "pending_reason",
//job_filter.HEADER_JOB_PRIORITY: "job_priority",
}
@ -258,16 +258,23 @@ func (e ExecHosts) String() string {
// UnmarshalJSON customizes the JSON parsing method, compatible with both string "-" and structure formats
func (e *ExecHosts) UnmarshalJSON(data []byte) error {
// JSON null means no allocation — treat as empty
if string(data) == "null" {
e.HostSlots = []HostSlot{}
e.NumHosts = 0
return nil
}
// First try to parse as a string
var str string
if err := json.Unmarshal(data, &str); err == nil {
// If the string is "-", return an empty ExecHosts
if str == "-" {
// Empty string or "-" both mean no hosts
if str == "" || str == "-" {
e.HostSlots = []HostSlot{}
e.NumHosts = 0
return nil
}
// If it's any other string, treat it as a single hostname
// Any other non-empty string is a single hostname
e.HostSlots = []HostSlot{{HostName: str, NumSlots: 1}}
e.NumHosts = 1
return nil