Full merge of origin/scheduler-framework (scheduler-core restructure,
failover-adoption, WAL_JOB_DISPATCH, apiserver backend health/heartbeat,
build-time versioning, bug-1673/1676/1678/1679/1680).
Conflict resolution:
- scheduler: adopt Rongsong's scheduler-core architecture (driver loop,
queue project/picker fields, resource bug-1569 fix)
- worker/controller_connector.c: keep Rongsong's reconnect/rejoin
structure, PORT our bug-1509 resend_job_status onto the rejoin success
path (his reconnect didn't re-report job state)
- apiserver backend: take Rongsong's health/heartbeat (supersedes our
e505066 circuit breaker)
- group.c: keep our bug-1513 comment
- misc: take theirs for additive fixes (go_build versioning,
test_group.c, persist.c RUSAGE_UPDATE, FilterRows bug-1680)
NOTE: our scheduler fixes (bug-1494 family dispatch-fail requeue,
PEND-reason classification) are NOT in Rongsong's branch — needs
verification/porting onto scheduler-core (separate follow-up).
Co-Authored-By: Claude <noreply@anthropic.com>
503 lines
16 KiB
Go
503 lines
16 KiB
Go
package backend
|
|
|
|
import (
|
|
"apiserver/models"
|
|
"encoding/json"
|
|
"time"
|
|
"fmt"
|
|
)
|
|
|
|
// resolveExecUser returns the user a job should run as: an explicit exec_user
|
|
// wins; otherwise the job runs as the submitting user. bug-1500: the submit
|
|
// conversion previously dropped this entirely, so the worker received a NULL
|
|
// execuser and ran every job as "nobody". Plain Go so it is unit-testable
|
|
// without cgo — keep it OUT of the cgo-only convert.go.
|
|
func resolveExecUser(job *models.JobInfo) string {
|
|
execUser := job.ExecUser
|
|
if execUser == "" {
|
|
execUser = job.SubmitUser
|
|
}
|
|
return execUser
|
|
}
|
|
|
|
// jobCtrlDiscriminant maps a models.JOB_* action to the capnp JobControl union
|
|
// discriminant carried in xce_jobctrl_t.action: JobControl_u_siginfo (0) for
|
|
// signal-based actions (kill/stop/resume), JobControl_u_ckptoption (1) for
|
|
// checkpoint. The models.JOB_* constants are a DIFFERENT enum — using them as
|
|
// the discriminant made the controller's job_ctrl() select the wrong union
|
|
// branch (or none) and reject every control request with -EINVAL (bug-1490).
|
|
// Returns -1 for an unknown action (clib.go never calls ControlJob with one).
|
|
func jobCtrlDiscriminant(action int) int {
|
|
switch action {
|
|
case models.JOB_STOP, models.JOB_RESUME, models.JOB_KILL:
|
|
return 0 // JobControl_u_siginfo
|
|
case models.JOB_CHKPNT:
|
|
return 1 // JobControl_u_ckptoption
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// JobID bit layout constants — mirrors src/include/config.h.
|
|
//
|
|
// |63 62|61 56|55 52|51 20|19 15|14 0|
|
|
// | TAG | CLUSTER_ID | APISERVER_ID| TIMESTAMP | SEQ_HIGH | ARRAY_IDX |
|
|
// | 2 bits | 6 bits | 4 bits | 32 bits | 5 bits | 15 bits |
|
|
const (
|
|
nTagBits = 2
|
|
tagShift = 62
|
|
nClusterIDBits = 6
|
|
clusterIDShift = 56
|
|
nApiserverIDBits = 4
|
|
apiserverIDShift = 52
|
|
nTimestampBits = 32
|
|
timestampShift = 20
|
|
nArrayIdxBits = 15
|
|
sequenceHighShift = 15 // mirrors SEQUENCE_HIGH_SHIFT in config.h (== N_ARRAY_IDX_BITS)
|
|
nSequenceBits = 20 // N_JOBID_BITS(64) - N_CLUSTER_ID_BITS(6) - N_APISERVER_ID_BITS(4) - N_TIMESTAMP_BITS(32) - N_TAG_BITS(2) = 20
|
|
tagJobArray = 0x01 // mirrors TAG_JOB_ARRAY in config.h
|
|
)
|
|
|
|
// extractBits extracts a bit field from id at the given shift position.
|
|
// Mirrors EXTRACT_BITS(id, shift, bits) in src/include/utils.h.
|
|
func extractBits(id uint64, shift, bits int) uint64 {
|
|
mask := uint64((1 << bits) - 1)
|
|
return (id >> shift) & mask
|
|
}
|
|
|
|
// extractIDTag extracts the 2-bit tag field (bits 62-63).
|
|
// Mirrors EXTRACT_ID_TAG in src/include/utils.h.
|
|
func extractIDTag(id uint64) uint64 {
|
|
return extractBits(id, tagShift, nTagBits)
|
|
}
|
|
|
|
// ExtractArrayBaseID extracts the base (family) job ID from a 64-bit encoded
|
|
// jobid. Mirrors C's array_jobid() in src/lib/utils.c and EXTRACT_ARRAY_BASEID
|
|
// in src/include/utils.h.
|
|
// - For non-array jobs (tag=0x00): returns id unchanged.
|
|
// - For array children (tag=0x01): returns the shared base ID (tag=0, idx=0).
|
|
func ExtractArrayBaseID(id uint64) uint64 {
|
|
if extractIDTag(id) == 0x00 {
|
|
return id
|
|
}
|
|
clusterID := extractBits(id, clusterIDShift, nClusterIDBits)
|
|
apiserverID := extractBits(id, apiserverIDShift, nApiserverIDBits)
|
|
timestamp := extractBits(id, timestampShift, nTimestampBits)
|
|
// EXTRACT_ARRAY_JOB: high bits of sequence (mirrors config.h SEQUENCE_HIGH_SHIFT)
|
|
arrayJob := extractBits(id, sequenceHighShift, nSequenceBits-nArrayIdxBits) << sequenceHighShift
|
|
return (clusterID << clusterIDShift) |
|
|
(apiserverID << apiserverIDShift) |
|
|
(timestamp << timestampShift) |
|
|
arrayJob // tag=0, idx=0
|
|
}
|
|
|
|
// ExtractArrayIdx extracts the array index from a 64-bit encoded jobid.
|
|
// Mirrors C's array_idx() in src/lib/utils.c and EXTRACT_ARRAY_IDX in
|
|
// src/include/utils.h.
|
|
// Returns 0 for non-array jobs (tag=0x00).
|
|
func ExtractArrayIdx(id uint64) int {
|
|
if extractIDTag(id) == 0x00 {
|
|
return 0
|
|
}
|
|
return int(extractBits(id, 0, nArrayIdxBits))
|
|
}
|
|
|
|
// IsArrayJob reports whether the 64-bit encoded jobid belongs to an array job
|
|
// (tag == TAG_JOB_ARRAY, 0x01). Returns false for non-array jobs (tag=0x00).
|
|
func IsArrayJob(id uint64) bool {
|
|
return extractIDTag(id) == tagJobArray
|
|
}
|
|
|
|
// FormatJobID formats a packed 64-bit job ID as a human-readable string,
|
|
// matching the C fmt_jobid() output in src/lib/utils.c.
|
|
// - Regular job: "{cluster}-{apiserver}-{timestamp}-{sequence}"
|
|
// - Array child: "{cluster}-{apiserver}-{timestamp}-{base}[{idx}]"
|
|
func FormatJobID(uniqueJobID int64) string {
|
|
id := uint64(uniqueJobID)
|
|
clusterID := extractBits(id, clusterIDShift, nClusterIDBits)
|
|
apiserverID := extractBits(id, apiserverIDShift, nApiserverIDBits)
|
|
timestamp := extractBits(id, timestampShift, nTimestampBits)
|
|
|
|
if extractIDTag(id) == tagJobArray {
|
|
arrayBase := extractBits(id, sequenceHighShift, nSequenceBits-nArrayIdxBits) << uint(sequenceHighShift)
|
|
arrayIdx := extractBits(id, 0, nArrayIdxBits)
|
|
return fmt.Sprintf("%d-%d-%d-%d[%d]",
|
|
clusterID, apiserverID, timestamp, arrayBase, arrayIdx)
|
|
}
|
|
sequence := extractBits(id, 0, nSequenceBits)
|
|
return fmt.Sprintf("%d-%d-%d-%d",
|
|
clusterID, apiserverID, timestamp, sequence)
|
|
}
|
|
|
|
// WorkerDefaultOutputPath returns the output file path the worker uses by default
|
|
// for the given job ID and task index, matching uv_proc.c make_job_output_path().
|
|
func WorkerDefaultOutputPath(uniqueJobID int64, taskIdx int) string {
|
|
return fmt.Sprintf("/tmp/job_%s_task%d.out", FormatJobID(uniqueJobID), taskIdx)
|
|
}
|
|
|
|
// rawStartInfo mirrors the C job_startinfo_t fields used by the Go model.
|
|
type rawStartInfo struct {
|
|
cmdline string
|
|
launchType string
|
|
preExec string
|
|
postExec string
|
|
execHome string
|
|
execCWD string
|
|
execUser string
|
|
}
|
|
|
|
// rawSubmitData mirrors the C job_submit_data_t fields used by the Go model.
|
|
type rawSubmitData struct {
|
|
queue string
|
|
user string
|
|
jobName string
|
|
projectName string
|
|
userGroup string
|
|
jobDesc string
|
|
resReq string
|
|
minTasks int
|
|
maxTasks int
|
|
startInfo *rawStartInfo
|
|
fromHost string
|
|
}
|
|
|
|
// rawHostReason mirrors the C xce_host_reason_t (one host-level PEND reason).
|
|
type rawHostReason struct {
|
|
Host string `json:"host"`
|
|
Reason int `json:"reason"`
|
|
ReasonName string `json:"reason_name"`
|
|
}
|
|
|
|
// rawResReason mirrors the C xce_res_reason_t (one resource-level PEND reason).
|
|
type rawResReason struct {
|
|
Resource string `json:"resource"`
|
|
Host string `json:"host"`
|
|
Reason int `json:"reason"`
|
|
ReasonName string `json:"reason_name"`
|
|
}
|
|
|
|
// rawPendReason mirrors the C xce_job_preason_t attached to a PEND job by the
|
|
// controller (scheduler → setpreason → job->pend_reasons). JSON-tagged so the
|
|
// marshaled value lands in models.JobInfo.PendingReason as a JSONB object —
|
|
// processJobData's JSONBJFields pass then surfaces it in the API response.
|
|
type rawPendReason struct {
|
|
MainReason int `json:"main_reason"`
|
|
MainReasonName string `json:"main_reason_name"`
|
|
HostReasons []rawHostReason `json:"host_reasons,omitempty"`
|
|
ResReasons []rawResReason `json:"res_reasons,omitempty"`
|
|
}
|
|
|
|
// pendReasonName maps a PREASON_* main_reason code to a readable name. The
|
|
// codes are the PEND reason bitfield of xce_job_preason_t (src/include/
|
|
// preason.h). Unknown codes are reported verbatim so a new scheduler reason
|
|
// never silently disappears.
|
|
func pendReasonName(code int) string {
|
|
switch code {
|
|
case 1: // PREASON_BAD_RESREQ
|
|
return "bad_resreq"
|
|
case 2: // PREASON_NO_RESOURCE
|
|
return "no_resource"
|
|
case 3: // PREASON_MATCH_FAIL
|
|
return "match_fail"
|
|
case 4: // PREASON_LIMIT_GLOBAL
|
|
return "limit_global"
|
|
case 5: // PREASON_LIMIT_HOST
|
|
return "limit_host"
|
|
case 6: // PREASON_QUEUE_CLOSED
|
|
return "queue_closed"
|
|
case 7: // PREASON_QUEUE_DRAINING
|
|
return "queue_draining"
|
|
case 8: // PREASON_TIME_WINDOW
|
|
return "time_window"
|
|
case 9: // PREASON_HOST_UNAVAIL
|
|
return "host_unavail"
|
|
default:
|
|
return fmt.Sprintf("unknown(%d)", code)
|
|
}
|
|
}
|
|
|
|
// rawJobData mirrors the C xce_job_t fields used by the Go model.
|
|
type rawJobData struct {
|
|
jobID int64
|
|
state int
|
|
runTime int
|
|
subTime int64
|
|
queueTime int64
|
|
startTime int64
|
|
finishTime int64
|
|
submit *rawSubmitData
|
|
execHosts []string
|
|
pendReason *rawPendReason
|
|
}
|
|
|
|
// rawWorkerData mirrors the C xce_worker_t fields used by the Go model.
|
|
type rawWorkerData struct {
|
|
name string
|
|
status int
|
|
hasAttrs bool
|
|
hostType string
|
|
hostModel string
|
|
cpuFactor float64
|
|
maxCPUs int
|
|
maxMem int
|
|
maxSwap int
|
|
maxTmp int
|
|
nDisks int
|
|
resvals []models.ResourceDistributionInfo
|
|
}
|
|
|
|
// rawQueueData mirrors the C xce_queue_t fields used by the Go model.
|
|
type rawQueueData struct {
|
|
name string
|
|
status int
|
|
priority int
|
|
}
|
|
|
|
// rawResourceData mirrors the C xce_resource_t fields used by the Go model.
|
|
type rawResourceData struct {
|
|
name string
|
|
desc string
|
|
valType int
|
|
ordType int
|
|
flags int
|
|
interval int
|
|
}
|
|
|
|
// rawJobToModel maps a rawJobData to a models.JobInfo.
|
|
// This pure-Go function contains the field-mapping logic and is tested without CGO.
|
|
func rawJobToModel(r rawJobData) *models.JobInfo {
|
|
raw := uint64(r.jobID)
|
|
info := &models.JobInfo{
|
|
Unique_JobID: r.jobID,
|
|
JobID: int64(ExtractArrayBaseID(raw)),
|
|
ArrayIdx: ExtractArrayIdx(raw),
|
|
JobStatus: r.state,
|
|
RunTime: r.runTime,
|
|
}
|
|
if r.submit != nil {
|
|
s := r.submit
|
|
info.QueueName = s.queue
|
|
info.SubmitUser = s.user
|
|
info.JobName = s.jobName
|
|
info.ProjectName = s.projectName
|
|
info.UserGroup = s.userGroup
|
|
info.JobDescription = s.jobDesc
|
|
info.ResReq = s.resReq
|
|
info.NumProcessors = s.minTasks
|
|
info.MaxNumProcessors = s.maxTasks
|
|
info.FromHost = s.fromHost
|
|
if s.startInfo != nil {
|
|
si := s.startInfo
|
|
info.Command = si.cmdline
|
|
info.LaunchType = si.launchType
|
|
info.PreExecCmd = si.preExec
|
|
info.PostExecCmd = si.postExec
|
|
info.ExecHome = si.execHome
|
|
info.ExecCWD = si.execCWD
|
|
info.ExecUser = si.execUser
|
|
}
|
|
}
|
|
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"`
|
|
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)}
|
|
}
|
|
if r.pendReason != nil {
|
|
if data, err := json.Marshal(r.pendReason); err == nil {
|
|
info.PendingReason = models.Jsonb{String: string(data)}
|
|
}
|
|
}
|
|
return info
|
|
}
|
|
|
|
// rawJobArrayToModels expands one compact (base jobid, array_size, one
|
|
// shared spec) array descriptor into N models.JobInfo entries locally, one
|
|
// per index in [0, array_size), using base_jobid + i arithmetic — the same
|
|
// arithmetic the controller's restore_job_array() (job.c) and the
|
|
// scheduler's sched_on_job_array() (handler.c) use to derive each
|
|
// element's identity (job-array-single-wal-record task 5.4, Req 6.2, 6.5).
|
|
// A freshly-received array element mirrors sched_on_job_array's
|
|
// make_array_element: JOB_STAT_PEND, receive-time sub_time — the compact
|
|
// wire descriptor carries no per-element timestamps to recover.
|
|
// Returns nil for a zero array_size or a nil submit (nothing to expand).
|
|
// Pure Go (no CGO) — cJobArrayToModels (convert.go) is the CGO adapter
|
|
// that decodes a *C.xce_job_submit_t into the rawSubmitData this consumes.
|
|
func rawJobArrayToModels(baseJobID uint64, arraySize uint64, submit *rawSubmitData) []*models.JobInfo {
|
|
if arraySize == 0 || submit == nil {
|
|
return nil
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
out := make([]*models.JobInfo, 0, arraySize)
|
|
for i := uint64(0); i < arraySize; i++ {
|
|
r := rawJobData{
|
|
jobID: int64(baseJobID + i),
|
|
state: models.JOBSTAT["PEND"],
|
|
subTime: now,
|
|
submit: submit,
|
|
}
|
|
out = append(out, rawJobToModel(r))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// rawWorkerToModel maps a rawWorkerData to a models.HostInfo.
|
|
func rawWorkerToModel(r rawWorkerData) *models.HostInfo {
|
|
info := &models.HostInfo{
|
|
HostName: r.name,
|
|
HostStatus: r.status,
|
|
ResourceValues: r.resvals,
|
|
}
|
|
if r.hasAttrs {
|
|
info.HostType = r.hostType
|
|
info.HostModel = r.hostModel
|
|
info.CPUFactor = r.cpuFactor
|
|
info.NumCPUs = r.maxCPUs
|
|
info.MaxMem = r.maxMem
|
|
info.MaxSwap = r.maxSwap
|
|
info.MaxTmp = r.maxTmp
|
|
info.NumDisk = r.nDisks
|
|
}
|
|
return info
|
|
}
|
|
|
|
// rawQueueToModel maps a rawQueueData to a models.QueueInfo.
|
|
func rawQueueToModel(r rawQueueData) *models.QueueInfo {
|
|
return &models.QueueInfo{
|
|
QueueName: r.name,
|
|
QueueStatus: r.status,
|
|
Priority: r.priority,
|
|
}
|
|
}
|
|
|
|
// rawResourceToModel maps a rawResourceData to a models.ResourceDefinitionInfo.
|
|
func rawResourceToModel(r rawResourceData) *models.ResourceDefinitionInfo {
|
|
return &models.ResourceDefinitionInfo{
|
|
ResourceName: r.name,
|
|
Description: r.desc,
|
|
ValueType: r.valType,
|
|
OrderType: r.ordType,
|
|
Flag: r.flags,
|
|
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.
|
|
}
|
|
}
|
|
|
|
// rawResvalData mirrors the C xce_resval_t fields used by the Go model.
|
|
// IsDynamic is always true for push-sourced values (controller sends live measurements).
|
|
type rawResvalData struct {
|
|
name string
|
|
host string
|
|
valWhich int // mirrors C xce_val_t.which (Value_u_* discriminant)
|
|
i32 int32
|
|
i64 int64
|
|
u32 uint32
|
|
u64 uint64
|
|
fval float64
|
|
sval string
|
|
bval bool
|
|
valNil bool // true when the C xce_val_t* was NULL
|
|
}
|
|
|
|
// rawResvalToDistribution maps a rawResvalData to a models.ResourceDistributionInfo.
|
|
// Returns nil when valNil is true (C val pointer was NULL) or when valWhich
|
|
// is an unknown discriminant — callers should skip nil results.
|
|
func rawResvalToDistribution(r rawResvalData) *models.ResourceDistributionInfo {
|
|
rd := &models.ResourceDistributionInfo{
|
|
ResourceName: r.name,
|
|
Location: r.host,
|
|
IsDynamic: true, // resource values pushed from workers are always dynamic
|
|
}
|
|
if r.valNil {
|
|
return nil
|
|
}
|
|
switch r.valWhich {
|
|
case 0: // Value_u_ivalue32
|
|
rd.ValueType = int(models.NUMERIC)
|
|
rd.NumericValue = float64(r.i32)
|
|
case 2: // Value_u_ivalue64
|
|
rd.ValueType = int(models.NUMERIC)
|
|
rd.NumericValue = float64(r.i64)
|
|
case 1: // Value_u_uvalue32
|
|
rd.ValueType = int(models.NUMERIC)
|
|
rd.NumericValue = float64(r.u32)
|
|
case 3: // Value_u_uvalue64
|
|
rd.ValueType = int(models.NUMERIC)
|
|
rd.NumericValue = float64(r.u64)
|
|
case 5: // Value_u_fvalue
|
|
rd.ValueType = int(models.NUMERIC)
|
|
rd.NumericValue = r.fval
|
|
case 4: // Value_u_svalue
|
|
rd.ValueType = int(models.STRING)
|
|
rd.StringValue = r.sval
|
|
case 6: // Value_u_bvalue
|
|
rd.ValueType = int(models.BOOLEAN)
|
|
rd.BooleanValue = r.bval
|
|
default:
|
|
return nil
|
|
}
|
|
return rd
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|