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

@ -13,6 +13,10 @@ static xce_host_alloc_t *job_alloc_host(xce_job_alloc_t *a, int i) { return a->a
// Helpers to index into the reason pointer arrays of xce_job_preason_t.
static xce_host_reason_t *job_host_reason(xce_job_preason_t *p, int i) { return p->host_based_reasons[i]; }
static xce_res_reason_t *job_res_reason(xce_job_preason_t *p, int i) { return p->res_based_reasons[i]; }
// Helper to index into xce_alloc_t** (same CGO limitation as above) -- one
// host's per-resource allocation list, scanned for the "slots" entry.
static xce_alloc_t *host_alloc_res(xce_host_alloc_t *h, int i) { return h->allocs[i]; }
*/
import "C"
@ -32,6 +36,15 @@ func goString(s *C.char) string {
return C.GoString(s)
}
// cStringOrNil returns a C copy of s, or nil for an empty string so an unset
// field stays NULL in the C struct. The caller frees the result.
func cStringOrNil(s string) *C.char {
if s == "" {
return nil
}
return C.CString(s)
}
// cJobArrayToModels decodes a compact Data.jobArray push descriptor
// (*C.xce_job_submit_t: base jobid, array_size, one shared job_submit_data_t
// spec — the same JobSubmit shape apiserver_push_jobarray() sends,
@ -69,6 +82,7 @@ func cJobArrayToModels(jobArray *C.xce_job_submit_t) []*models.JobInfo {
execHome: goString(si.exechome),
execCWD: goString(si.execcwd),
execUser: goString(si.execuser),
exdata: goString(si.exdata),
}
}
@ -110,21 +124,56 @@ func cJobToModel(job *C.xce_job_t) *models.JobInfo {
execHome: goString(si.exechome),
execCWD: goString(si.execcwd),
execUser: goString(si.execuser),
exdata: goString(si.exdata),
}
}
r.submit = rd
}
if u := job.run_usage; u != nil {
r.runUsage = &rawRunUsage{
maxMem: float64(u.max_mem),
avgMem: float64(u.avg_mem),
usedMem: float64(u.used_mem),
cpuTime: int(u.cpu_time),
peakCPUTime: int(u.peak_cpu_time),
peakCPUDuration: int(u.peak_cpu_duration),
memEfficiency: float64(u.mem_efficiency),
avgCPUEfficiency: float64(u.avg_cpu_efficiency),
peakCPUEfficiency: float64(u.peak_cpu_efficiency),
peakCPU: float64(u.peak_cpu),
}
}
if job.allocs != nil {
n := int(job.allocs.nhosts)
for i := 0; i < n; i++ {
h := C.job_alloc_host(job.allocs, C.int(i))
if h != nil && h.host != nil {
r.execHosts = append(r.execHosts, C.GoString(h.host))
if h == nil || h.host == nil {
continue
}
// bug-1743-followup: this host's own "slots" resource
// allocation (xce_host_alloc_t.allocs[]), mirroring the C
// scheduler's own get_nslots_in_alloc()/repl_worker_alloc_
// nslots() resname scan -- defaults to 1 when absent, same as
// those.
nslots := 1
nallocs := int(h.nallocs)
for j := 0; j < nallocs; j++ {
res := C.host_alloc_res(h, C.int(j))
if res != nil && res.resname != nil && C.GoString(res.resname) == "slots" {
nslots = int(res.value)
break
}
}
r.execHosts = append(r.execHosts, rawHostSlot{host: C.GoString(h.host), slots: nslots})
}
}
// PEND reason: the scheduler pushes these via setpreason; the controller
// stores them on job->pend_reasons. Maps to models.JobInfo.PendingReason.
// A detail-only record (main_reason 0) gets its overall-reason NAME from
// the first detail row at the pure layer (rawJobToModel); the C record is
// carried through unmutated (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).
if job.pend_reasons != nil {
pr := job.pend_reasons
r.pendReason = &rawPendReason{
@ -247,6 +296,42 @@ func cHostConfigToModel(hc *C.xce_hostconfig_t) *models.HostInfo {
})
}
// cStrList converts an xce_strlist_t to a Go slice.
func cStrList(l *C.xce_strlist_t) []string {
n := int(l.count)
if n == 0 || l.data == nil {
return nil
}
out := make([]string, 0, n)
for _, p := range unsafe.Slice(l.data, n) {
if p != nil {
out = append(out, C.GoString(p))
}
}
return out
}
// cClusterConfUserGroups extracts the user groups from a cluster_config_t
// (bug-1934: the self_ug job scope resolves them).
func cClusterConfUserGroups(conf *C.cluster_config_t) []models.UserGroupInfo {
n := int(conf.nugroups)
if n == 0 || conf.ugroups == nil {
return nil
}
out := make([]models.UserGroupInfo, 0, n)
for _, gp := range unsafe.Slice(conf.ugroups, n) {
if gp == nil || gp.name == nil {
continue
}
out = append(out, models.UserGroupInfo{
Name: C.GoString(gp.name),
Members: cStrList(&gp.members),
ExMembers: cStrList(&gp.ex_members),
})
}
return out
}
// cClusterConfHostConfigs extracts host config models from a cluster_config_t.
func cClusterConfHostConfigs(conf *C.cluster_config_t) []models.HostInfo {
n := int(conf.nhconfigs)
@ -282,20 +367,19 @@ func cClusterConfToClusterInfo(conf *C.cluster_config_t) *models.ClusterInfo {
// modelJobToSubmit converts a Go JobInfo to C xce_job_submit_t heap structs.
// The caller must free all returned C memory by calling freeJobSubmit.
func modelJobToSubmit(job *models.JobInfo) (*C.xce_job_submit_t, *C.job_submit_data_t, *C.job_startinfo_t) {
rsi := submitStartInfo(job)
si := (*C.job_startinfo_t)(C.calloc(1, C.sizeof_job_startinfo_t))
si.cmdline = C.CString(job.Command)
if job.LaunchType != "" {
si.launch_type = C.CString(job.LaunchType)
}
if job.PreExecCmd != "" {
si.job_pre_exec = C.CString(job.PreExecCmd)
}
if job.PostExecCmd != "" {
si.job_post_exec = C.CString(job.PostExecCmd)
}
// bug-1500: carry the exec user on the start_info. Without this the worker
// receives a NULL execuser and runs every job as "nobody".
si.execuser = C.CString(resolveExecUser(job))
si.cmdline = C.CString(rsi.cmdline)
// bug-1500/bug-1780 (same fix both sides): carry the exec user on the
// start_info. Without it the worker receives a NULL/empty execuser and
// runs every job as "nobody".
si.execuser = cStringOrNil(rsi.execUser)
si.launch_type = cStringOrNil(rsi.launchType)
si.job_pre_exec = cStringOrNil(rsi.preExec)
si.job_post_exec = cStringOrNil(rsi.postExec)
si.exechome = cStringOrNil(rsi.execHome)
si.execcwd = cStringOrNil(rsi.execCWD)
si.exdata = cStringOrNil(rsi.exdata)
sd := (*C.job_submit_data_t)(C.calloc(1, C.sizeof_job_submit_data_t))
sd.queue = C.CString(job.QueueName)
@ -338,10 +422,13 @@ func modelJobToSubmit(job *models.JobInfo) (*C.xce_job_submit_t, *C.job_submit_d
func freeJobSubmit(js *C.xce_job_submit_t, sd *C.job_submit_data_t, si *C.job_startinfo_t) {
if si != nil {
C.free(unsafe.Pointer(si.cmdline))
C.free(unsafe.Pointer(si.execuser))
C.free(unsafe.Pointer(si.launch_type))
C.free(unsafe.Pointer(si.job_pre_exec))
C.free(unsafe.Pointer(si.job_post_exec))
C.free(unsafe.Pointer(si.execuser))
C.free(unsafe.Pointer(si.exechome))
C.free(unsafe.Pointer(si.execcwd))
C.free(unsafe.Pointer(si.exdata))
C.free(unsafe.Pointer(si))
}
if sd != nil {