myxce/src/frontend/apiserver/backend/convert_pure.go
lei.chen 4e894d2184 merge: sync Rongsong's scheduler-framework into chatbot
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>
2026-08-25 12:33:16 +00:00

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,
}
}