merge: sync Rongsong's scheduler-framework (c5b172c7..3442a57d, 781 commits)

Merge origin/scheduler-framework into merge-sf-20260903 (97 commits on
ours: bug-1490/1494/1498/1509/1513/1521/1522 fixes, PEND-reason P1,
scheduler-agent Phase 2, bug-924 owner-pid shim).

Bookkeeping excluded per policy: .kiro/, .wolf/, CHANGES.md, reports/
(92 files dropped); .claude/ + CLAUDE.md keep ours.

Key theirs-side changes absorbed:
- bug-1938/1960: QUEUE_STAT_CLOSED no longer gates dispatch (closed
  queue refuses new submissions only; pending jobs still run) --
  supersedes our QUEUE_CLOSED preason on the dispatch path;
  sched_queue_status_reason drops that branch
- bug-1846: config push now stashes and applies between scheduling
  rounds (sched_stash_config/sched_apply_pending_config), applied at
  the same main-thread round boundary that preserves bug-1513
  serialization
- bug-1760: sched_check_pending_rejoin exposed +
  xcecli_is_connection_broken public predicate
- bug-1936: execuser/exechome/execcwd/exdata carried on submission
  (superset of our bug-1500 execuser fallback; our job-typed
  resolveExecUser removed as dead code)
- bug-1945~1962: apiserver auth hardening (HS256 pinning, default-
  secret rejection, logout revocation, bcrypt, RBAC) -- our API-key
  branch folded into controllers.ParseTokenClaims so both coexist
- bug-1736: rawRunUsage job run-usage fields in the Go model
- xce_test relocated src/controller/testsuite/test.c ->
  src/harness/test.c; controller suite split into
  controller_test.c + fixtures; their clitest fixtures spawn xce_test
  mid-suite under `meson test -j`

Ours preserved across the merge:
- PEND-reason P1 (codes 1-9, preason.h, job_preason machinery) --
  gate re-ported into their relocated allocator (sched_election.c)
  after plugin_job_election, with attach on the allocator thread
  only (bug-1521 race discipline)
- PEND-reason P0 main_reason semantics RECONCILED to theirs: the
  merge initially re-carried our first-detail-add-promotes-main_reason
  fallback in add_job_preason (4e2ac86), but their whole trace/verify
  stack treats "detail-only => main_reason 0" as a contract --
  sched_trace_capture counts main_reason as its own occurrence
  ("Expected 2 Was 3" in test_trace_capture), the outcome renderer
  prints "main reason: none" for detail-only PENDs (test_repl_trace_e2e),
  and the committed .replay/.sim_verify baselines encode main_reason=0
  (test_repl_trace_baseline byte compare, test_trace_equivalence
  off-vs-traced). The fallback is dropped from the shared lib;
  the user-visible "overall reason" survives as an API-layer view:
  rawJobToModel (convert_pure.go) promotes the first detail row to
  main_reason_name when main_reason is 0 (main_reason VALUE marshals
  unchanged). add_job_preason keeps only the NULL-reasons guard
- bug-1498: sched_job_is_ready rejects non-PEND/suspended jobs
- bug-1490/1781: xce_jobctrl_t.action as capnp union discriminant
  (their jobCtrlUnionDiscriminant, same semantics)
- xcecli typed PendingReason field (53c00834), bug-1522 reconnect
  resend, xcecli_force_reconnect + their is_connection_broken both
  exported in client.h/libclient
- t1/t2/t3 test-node hostconfig in src/configs/hosts.yaml (bug-1494),
  /xce/v1/events + /xce/v1/apikeys whitelist routes (agent Phase 2)
- lib ASAN_OPTIONS=detect_leaks=0 test env + their oom-test block
- drop-audit: our bug-1504 fix covered by their bug-1680 (no action)

bug-924 follow-up (replaces the 67f3f6ad scratch-dir wipe): the
take-theirs audit's one "drop" was re-evaluated and REVERTED instead of
re-applied -- a global `find /tmp -name 'xce_*' -exec rm -rf` at suite
start/exit races the LIVE /tmp/xce_* fixtures of sibling suites under
`meson test -j` (harness_unit_test's clitest fixtures spawn xce_test as
a child mid-suite; the wipe destroyed the parent's fixtures ->
test_test_cli failures). Coverage instead:
- mkdtemp_clean shim gains mkdtemp_clean_mark/sweep_since
  (testsuite_support/mkdtemp_clean.[ch])
- new case_scratch_sweep.[ch]: aggregate runners (controller_test.c,
  harness_test.c -- hundreds of cases per process) call
  case_scratch_sweep_begin/_end_ok from setUp/tearDown; a PASSED case's
  dirs are swept immediately in tearDown (after engine reaping), FAILED
  cases keep their dirs for post-mortem; XCE_MKDTEMP_KEEP_ALL=1
  disables. One full controller_unit_test run was observed to leak 12G+
  to ENOSPC without this, cascading into unrelated "Expected Non-NULL"
  failures
- harness_unit_test_deps links the shim library as well

Test reconciliations on adopt:
- core-ready-gates: sched_queue_is_ready now gates only
  INACT_ADMIN|INACT_WINDOW (bug-1938/1960); CLOSED queues elect --
  covered end-to-end by test_queue_admin_control.c
- test_worker_resync: the 4 restart-replay RUN assertions predate the
  bug-1509 B3 marking (8858afed) landing on the same lineage; replayed
  RUN jobs now assert RUN|UNKWN|TRANSIENT (TWR_ASSERT_REPLAYED_RUN);
  terminal DONE/EXIT assertions unchanged
- convert_pure.go: our job-typed resolveExecUser deleted (superseded
  by their bug-1780/1936 two-arg version)
- test_preason: the 3 P0-promotion assertions reverted to the
  detail-keeps-main-0 contract; new tests cover limit-code detail
  rows and explicit-main-wins-over-later-details; apiserver
  convert_test.go gains the detail-only name-promotion cases

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-10-14 05:17:03 +00:00
commit 0c8607345e
663 changed files with 139848 additions and 13614 deletions

View file

@ -2,24 +2,16 @@ package backend
import (
"apiserver/models"
"apiserver/utils"
"encoding/json"
"time"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
// 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
@ -128,12 +120,255 @@ func FormatJobID(uniqueJobID int64) string {
clusterID, apiserverID, timestamp, sequence)
}
// humanJobIDPattern matches a plain human-readable job ID, "{cluster}-
// {apiserver}-{timestamp}-{sequence}" (no array-index brackets).
var humanJobIDPattern = regexp.MustCompile(`^(\d+)-(\d+)-(\d+)-(\d+)$`)
// ParseHumanJobID parses a plain human-readable job ID (as produced by
// FormatJobID, brackets already stripped by the caller) back into the
// packed numeric job ID the job_id/unique_job_id DB columns store. Returns
// ok=false if s doesn't match that shape (e.g. it's already numeric).
//
// Deliberately does not itself handle an "[idx]" array-child suffix:
// controllers/jobs.go's parseArrayName already splits that off (for the
// API's own array-index filter syntax) before this ever runs, leaving a
// plain quad whose 4th number is the packed ID's low sequence bits with
// tag=0 -- true for a real non-array job's own ID, and also true for an
// array child's dash-quad prefix (FormatJobID's arrayBase is those same
// bits already shifted into position with tag/idx zeroed, i.e. exactly
// ExtractArrayBaseID(original)).
func ParseHumanJobID(s string) (uint64, bool) {
m := humanJobIDPattern.FindStringSubmatch(s)
if m == nil {
return 0, false
}
clusterID, _ := strconv.ParseUint(m[1], 10, 64)
apiserverID, _ := strconv.ParseUint(m[2], 10, 64)
timestamp, _ := strconv.ParseUint(m[3], 10, 64)
sequence, _ := strconv.ParseUint(m[4], 10, 64)
return (clusterID << clusterIDShift) |
(apiserverID << apiserverIDShift) |
(timestamp << timestampShift) |
sequence, true
}
// Errors returned by ParseHumanJobIDStrict. Callers map them to their own
// messages: ErrJobIDSyntax is the written-form syntax rule, ErrJobIDWidth the
// field-width rule (job-array-one-based-index 5.14).
var (
ErrJobIDSyntax = errors.New("job ID is not of the form cluster-apiserver-timestamp-sequence")
ErrJobIDWidth = errors.New("job ID field out of range")
)
// ParseHumanJobIDStrict parses a written job identity "c-a-ts-seq" (decimal
// digits only, leading zeros allowed, no sign, no whitespace, no "[...]")
// into its packed, untagged ID. Unlike ParseHumanJobID it checks every field
// against its bit width -- cluster <= 63, apiserver <= 15, timestamp < 2^32,
// sequence < 2^20 -- so an oversized field is rejected (ErrJobIDWidth)
// instead of spilling into a neighbouring field. A field too large even for
// uint64 is also ErrJobIDWidth. Anything not of that shape is ErrJobIDSyntax.
func ParseHumanJobIDStrict(s string) (uint64, error) {
m := humanJobIDPattern.FindStringSubmatch(s)
if m == nil {
return 0, ErrJobIDSyntax
}
widths := [4]int{nClusterIDBits, nApiserverIDBits, nTimestampBits, nSequenceBits}
var f [4]uint64
for i, w := range widths {
v, err := strconv.ParseUint(m[i+1], 10, 64)
if err != nil {
if errors.Is(err, strconv.ErrRange) {
return 0, ErrJobIDWidth
}
return 0, ErrJobIDSyntax
}
if v >= uint64(1)<<w {
return 0, ErrJobIDWidth
}
f[i] = v
}
return (f[0] << clusterIDShift) |
(f[1] << apiserverIDShift) |
(f[2] << timestampShift) |
f[3], nil
}
// arrayIdxMask covers the 15-bit array index field (bits 0-14).
const arrayIdxMask = uint64(1)<<nArrayIdxBits - 1
// IsArrayCapableKey reports whether key can name an array (an "<array-id>"
// in "<array-id>[k]"): tag 0 and the low 15 (index) bits clear, i.e. the
// sequence is a multiple of 32768 (job-array-one-based-index 5.8).
func IsArrayCapableKey(key uint64) bool {
return extractIDTag(key) == 0 && key&arrayIdxMask == 0
}
// MakeElementID composes the ID of element k of the array identified by key:
// key's cluster, apiserver, timestamp and sequence-high bits, tag
// TAG_JOB_ARRAY and index k (masked to 15 bits). key's own tag and index bits
// are ignored. Mirrors jobsel_element_id() in src/lib/utils.c.
func MakeElementID(key uint64, k int) uint64 {
base := key &^ (uint64(1<<nTagBits-1)<<tagShift | arrayIdxMask)
return uint64(tagJobArray)<<tagShift | base | uint64(k)&arrayIdxMask
}
// 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)
}
// jobCtrlUnionDiscriminant returns the value xce_jobctrl_t.action must carry
// for the given job_ctrl_type_t action (models.JOB_STOP/RESUME/KILL/CHKPNT):
// the capnp JobControl_u_which union discriminant (JobControl_u_siginfo=0,
// JobControl_u_ckptoption=1 -- src/lib/controller.capnp.h), NOT the action
// code itself. xce_jobctrl_req() (src/lib/request.c) switches on this field
// and rejects anything else with -EINVAL; ControlJob (clib.go) previously
// stored the raw action code there instead, so kill(3)/resume(2) always hit
// that -EINVAL default case (gitea issue #45) while stop(1) happened to
// collide with ckptoption(1) and read through the wrong union member.
func jobCtrlUnionDiscriminant(action int) int {
if action == models.JOB_CHKPNT {
return 1 // JobControl_u_ckptoption
}
return 0 // JobControl_u_siginfo (stop/resume/kill all signal the job)
}
// jobCtrlArgs is what ControlJob (clib.go) puts into xce_jobctrl_t's union
// branch: Signo/Force fill xce_signal_t (stop/resume/kill), CkptKill/Method
// fill xce_ckpt_options_t (checkpoint).
type jobCtrlArgs struct {
Signo int
Force int
Grace int // seconds; > 0: graceful kill/stop sequence (bug-1957)
CkptKill int
Method string
}
// jobCtrlParams maps a job action and ControlJob's content argument to the
// values sent to the controller. content is either the JSON-encoded
// models.JobControlEvent the job control handlers pass (controllers/jobs.go)
// or, for older callers, a bare signal name or number; empty means the
// action's default signal.
//
// bug-1940: content used to be looked up only as a signal name, so the JSON
// event always missed and every kill sent TERM with force 0, and checkpoint
// kill/method were never set. An unknown signal is now an error instead of a
// silent fallback to the default.
func jobCtrlParams(action int, content string) (jobCtrlArgs, error) {
var (
args jobCtrlArgs
signo int
forceKill bool
)
content = strings.TrimSpace(content)
if strings.HasPrefix(content, "{") {
var evt models.JobControlEvent
if err := json.Unmarshal([]byte(content), &evt); err != nil {
return args, fmt.Errorf("invalid job control content: %w", err)
}
if evt.Signal != 0 && !isKnownSignal(evt.Signal) {
return args, fmt.Errorf("unsupported signal: %d", evt.Signal)
}
signo, forceKill, args.Method = evt.Signal, evt.ForceKill, evt.Method
} else if content != "" {
n, err := parseSignal(content)
if err != nil {
return args, err
}
signo = n
}
switch action {
case models.JOB_STOP, models.JOB_RESUME, models.JOB_KILL:
if signo == 0 {
signo = defaultActionSignal(action)
// bug-1957: a plain kill or stop is graceful, as LSF's
// TERMINATE and SUSPEND actions; a force kill is immediate
if action == models.JOB_STOP || (action == models.JOB_KILL && !forceKill) {
args.Grace = jobTerminateInterval()
}
}
args.Signo = signo
if action == models.JOB_KILL && forceKill {
args.Force = 1
}
case models.JOB_CHKPNT:
if forceKill {
args.CkptKill = 1
}
}
return args, nil
}
// defaultActionSignal is the signal a stop/resume/kill sends when the caller
// gave none.
//
// bug-1957: kill and stop name the sequence's last signal (SIGKILL,
// SIGSTOP); with a grace period the worker sends SIGINT/SIGTERM or SIGTSTP
// first, so the job can clean up or prepare to stop.
func defaultActionSignal(action int) int {
switch action {
case models.JOB_STOP:
return models.JobSignalMapTable["STOP"] // 19
case models.JOB_RESUME:
return models.JobSignalMapTable["CONT"] // 18
}
return models.JobSignalMapTable["KILL"] // 9
}
// defaultJobTerminateInterval is LSF's JOB_TERMINATE_INTERVAL default.
const defaultJobTerminateInterval = 10
// jobTerminateInterval is job.terminate_interval, or the default when it is
// unset or 0.
func jobTerminateInterval() int {
if utils.JobYamlConfig != nil && utils.JobYamlConfig.TerminateInterval > 0 {
return utils.JobYamlConfig.TerminateInterval
}
return defaultJobTerminateInterval
}
// isKnownSignal reports whether n is one of models.JobSignalMapTable's values.
func isKnownSignal(n int) bool {
for _, v := range models.JobSignalMapTable {
if v == n {
return true
}
}
return false
}
// parseSignal accepts a signal number or name (case-insensitive, with or
// without the SIG prefix) and returns its number.
func parseSignal(s string) (int, error) {
if n, err := strconv.Atoi(s); err == nil {
if !isKnownSignal(n) {
return 0, fmt.Errorf("unsupported signal: %s", s)
}
return n, nil
}
name := strings.TrimPrefix(strings.ToUpper(s), "SIG")
if n, ok := models.JobSignalMapTable[name]; ok {
return n, nil
}
return 0, fmt.Errorf("unsupported signal: %s", s)
}
// resolveExecUser returns the effective exec user for a job submission: the
// caller-specified ExecUser if present, otherwise the submitting user
// (bug-1780: an unset execuser in the startjob request reaches xcecd as
// empty, which controller_connector.c defaults to the literal Linux user
// 'nobody' -- a user with no home/shell that then fails to exec the job).
func resolveExecUser(execUser, submitUser string) string {
if execUser != "" {
return execUser
}
return submitUser
}
// rawStartInfo mirrors the C job_startinfo_t fields used by the Go model.
type rawStartInfo struct {
cmdline string
@ -143,6 +378,55 @@ type rawStartInfo struct {
execHome string
execCWD string
execUser string
exdata string
}
// submitStartInfo maps a Go JobInfo to the job_startinfo_t fields a
// submission carries to the controller (and from there, unchanged, to the
// worker's startjob request). modelJobToSubmit (convert.go) is the CGO
// adapter that copies the result into the C struct.
//
// bug-1936: exdata (env, limits, transfers, interactive mode, built by the
// submit handler into ExtendData), the exec cwd and the exec home were
// never set, so none of them reached the worker.
func submitStartInfo(job *models.JobInfo) rawStartInfo {
execCWD := job.ExecCWD
if execCWD == "" {
execCWD = job.CWD
}
return rawStartInfo{
cmdline: job.Command,
launchType: job.LaunchType,
preExec: job.PreExecCmd,
postExec: job.PostExecCmd,
execHome: job.ExecHome,
execCWD: execCWD,
execUser: resolveExecUser(job.ExecUser, job.SubmitUser),
exdata: job.ExtendData.String,
}
}
// maxExdataLen is the longest exdata the worker can take: xcecd hands it to
// xcecd_worker in job_assignment_t.exdata[EXDATA_LENGTH] (16 KiB,
// src/worker/include/uv_ipc.h), NUL included.
const maxExdataLen = 16*1024 - 1
// ValidateExdata checks a job's extended data before it is submitted: the
// worker's parse_exdata() fails the job at launch on anything but a JSON
// object, and a longer string than maxExdataLen would be cut short on its
// way to the job process.
func ValidateExdata(exdata string) error {
if exdata == "" {
return nil
}
if len(exdata) > maxExdataLen {
return fmt.Errorf("extended job data is %d bytes, more than the limit of %d", len(exdata), maxExdataLen)
}
var obj map[string]interface{}
if err := json.Unmarshal([]byte(exdata), &obj); err != nil {
return errors.New("extended job data must be a JSON object")
}
return nil
}
// rawSubmitData mirrors the C job_submit_data_t fields used by the Go model.
@ -180,10 +464,10 @@ type rawResReason struct {
// 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"`
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
@ -215,6 +499,33 @@ func pendReasonName(code int) string {
}
}
// rawHostSlot mirrors one xce_host_alloc_t's host name plus its "slots"
// resource allocation count (xce_host_alloc_t.allocs[], scanned for
// resname=="slots" -- mirrors the C scheduler's own get_nslots_in_alloc()/
// repl_worker_alloc_nslots() convention, defaulting to 1 when no explicit
// slots entry is present).
type rawHostSlot struct {
host string
slots int
}
// rawRunUsage mirrors the C xce_job_runusage_t job-level fields used by the
// Go model (bug-1736): accumulated CPU time and memory, and the CPU peak and
// efficiency values the controller computes with LSF bjobs -l definitions
// (efficiencies in percent, memory in MB as the worker reports it).
type rawRunUsage struct {
maxMem float64
avgMem float64
usedMem float64
cpuTime int
peakCPUTime int
peakCPUDuration int
memEfficiency float64
avgCPUEfficiency float64
peakCPUEfficiency float64
peakCPU float64
}
// rawJobData mirrors the C xce_job_t fields used by the Go model.
type rawJobData struct {
jobID int64
@ -225,8 +536,9 @@ type rawJobData struct {
startTime int64
finishTime int64
submit *rawSubmitData
execHosts []string
execHosts []rawHostSlot
pendReason *rawPendReason
runUsage *rawRunUsage // nil until the job's first usage report
}
// rawWorkerData mirrors the C xce_worker_t fields used by the Go model.
@ -293,7 +605,9 @@ func rawJobToModel(r rawJobData) *models.JobInfo {
info.PostExecCmd = si.postExec
info.ExecHome = si.execHome
info.ExecCWD = si.execCWD
info.CWD = si.execCWD // the submit cwd is sent as the exec cwd (bug-1936)
info.ExecUser = si.execUser
info.ExtendData = models.Jsonb{String: si.exdata}
}
}
info.SubmitTime = r.subTime
@ -311,16 +625,57 @@ func rawJobToModel(r rawJobData) *models.JobInfo {
}
slots := make([]hostSlot, len(r.execHosts))
for i, h := range r.execHosts {
slots[i] = hostSlot{HostName: h, NumSlots: 1}
slots[i] = hostSlot{HostName: h.host, NumSlots: h.slots}
}
data, _ := json.Marshal(execHostsJSON{HostSlots: slots, NumHosts: len(slots)})
info.ExecHosts = models.Jsonb{String: string(data)}
}
if r.pendReason != nil {
// main_reason is only set by an explicit add_job_preason(reasons,
// code, NULL, NULL) (which erases the detail lists); the allocator's
// ordinary shortfalls attach per-host/per-res DETAIL rows and leave
// main_reason 0. A detail-only record promotes its first detail row
// to main_reason_name here — the API-visible "overall reason"
// (pend-reason P0: main_reason_name used to read unknown(0)) — while
// the C record's main_reason value itself marshals unchanged (the
// scheduler-framework trace/verify stack counts main_reason as its
// own occurrence and its committed .replay baselines encode
// main_reason=0 detail-only PENDs, so the promotion stays an
// API-layer view).
if r.pendReason.MainReasonName == "" && r.pendReason.MainReason != 0 {
r.pendReason.MainReasonName = pendReasonName(r.pendReason.MainReason)
} else if r.pendReason.MainReasonName == "" && r.pendReason.MainReason == 0 {
for _, h := range r.pendReason.HostReasons {
if h.Reason != 0 {
r.pendReason.MainReasonName = pendReasonName(h.Reason)
break
}
}
if r.pendReason.MainReasonName == "" {
for _, rr := range r.pendReason.ResReasons {
if rr.Reason != 0 {
r.pendReason.MainReasonName = pendReasonName(rr.Reason)
break
}
}
}
}
if data, err := json.Marshal(r.pendReason); err == nil {
info.PendingReason = models.Jsonb{String: string(data)}
}
}
if u := r.runUsage; u != nil {
info.MaxMem = u.maxMem
info.AvgMem = u.avgMem
info.UsedMem = u.usedMem
info.CPUTime = u.cpuTime
info.PeakCPU = u.peakCPU
info.PeakCPUTime = u.peakCPUTime
info.PeakCPUDuration = float64(u.peakCPUDuration)
info.MemEfficiency = u.memEfficiency
info.AvgCPUEfficiency = u.avgCPUEfficiency
info.PeakCPUEfficiency = u.peakCPUEfficiency
}
return info
}