rusage collection (merge cb0c7fe9 regression, root cause of 0 counters):
- collect_job_rusages builds the systemd unit name with
make_job_sd_unit_name (four-segment fmt_jobid_ex form) instead of a bare
%lu of the packed uint64, so the D-Bus property lookups hit the real
per-job unit again. E2E on t1: DONE job reports max_mem/avg_mem/cpu_time
non-zero (previously all 0).
XCE_JOBID written identity:
- exdata sets XCE_JOBID via fmt_jobid (four-segment
cluster-apiserver-timestamp-seq) instead of a bare packed decimal;
XCE_JOBINDEX handling unchanged.
- xrun parses XCE_JOBID with parse_jobid (accepts the four-segment form);
a bare decimal no longer round-trips through the written identity.
- E2E on t1: job sees XCE_JOBID=<submit id>; xrun remote-start round-trip
to another host matches the job.
select WARN (silent degradation):
- parse_resreq_primary WARNs when a select references an unregistered
resource ("...references an unregistered resource -- dropping the select
and skipping any rusage[] clause of this primary") instead of failing
silently; scheduler log shows the WARN on t1 and the job schedules
without the constraint.
CLI env handling (unit-tested; subEnvVars is not flag-bound so this is
not reachable from the production CLI):
- getEnvironmentVariables keeps B=b=c (split at first '=') and warns +
drops a bare key or an '=value' fragment instead of silently mis-parsing.
Docs:
- src/docs/xce.md: document the dead-clause semantics (select/rusage
primary drop behavior) matching the WARN.
bug-1972 (test-only UAF): test_job_live_gc asserted job destruction by
reading refcount from the freed job; rewrite the assertion to observe
destruction without touching freed memory. controller_unit_test is green
again (1354 tests).
Regenerate 3 repl_trace_baseline fixtures whose baselines shifted with the
select-WARN logging; replay tests green.
Spec naming: external-license-resources design.md/brief.md fix the
self-referential daemon-name note (old drafts said xceextresd/extresd).
Verified on t1: rusage_burn job (counters non-zero in DONE), echo_env job
(four-segment XCE_JOBID), select[nosuchres] job (WARN + schedules),
xrun t2 remote-start round-trip, CLI env unit tests, xcelib 762 /
controller 1354 / worker exdata 8+11 / sched repl_trace 4+14+4+10+17
tests green.
Co-Authored-By: Claude Code <noreply@anthropic.com>
378 lines
12 KiB
Go
378 lines
12 KiB
Go
package jobs
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"xcecli/common/format"
|
|
"xcecli/common/request"
|
|
"xcecli/config"
|
|
"xcecli/models"
|
|
)
|
|
|
|
// bug-1676: BuildJobRequest asserts our own hostname as FromHost rather than
|
|
// leaving it for the server to derive from the connection (spoofable via
|
|
// X-Forwarded-For, and only ever an IP, never a hostname).
|
|
func TestBuildJobRequest_SetsFromHostToLocalHostname(t *testing.T) {
|
|
js := NewJobSubmitter(nil)
|
|
js.command = "sleep 1"
|
|
|
|
req, err := js.BuildJobRequest()
|
|
if err != nil {
|
|
t.Fatalf("BuildJobRequest: %v", err)
|
|
}
|
|
|
|
wantHost, hostErr := os.Hostname()
|
|
if hostErr != nil {
|
|
t.Skipf("os.Hostname() unavailable in this environment: %v", hostErr)
|
|
}
|
|
if req.FromHost != wantHost {
|
|
t.Errorf("FromHost = %q, want local hostname %q", req.FromHost, wantHost)
|
|
}
|
|
}
|
|
|
|
// submitServer answers every submit with the given response data.
|
|
func submitServer(t *testing.T, data models.JobSubmitData) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(models.JobSubmitResponse{Code: 0, Msg: "ok", Data: data})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
// The submit path logs through config.Log, which only main initialises.
|
|
if config.Log == nil {
|
|
config.Log = zap.NewNop()
|
|
t.Cleanup(func() { config.Log = nil })
|
|
}
|
|
return srv
|
|
}
|
|
|
|
// An accepted array submission prints the array identity in written form,
|
|
// without an element number, and the server's element count (3.1, 3.2); the
|
|
// count is mirrored in the client model for scripted use.
|
|
func TestSubmitOutput_ArrayIdentityAndCount(t *testing.T) {
|
|
srv := submitServer(t, models.JobSubmitData{
|
|
JobID: int64(testArrayKey),
|
|
Message: fmt.Sprintf("Job <%d> is submitted to queue <normal> (3 elements).", testArrayKey),
|
|
ArraySize: 3,
|
|
})
|
|
js := NewJobSubmitter(nil)
|
|
js.command = "sleep 1"
|
|
req, err := js.BuildJobRequest()
|
|
if err != nil {
|
|
t.Fatalf("BuildJobRequest: %v", err)
|
|
}
|
|
var resp *models.JobSubmitResponse
|
|
out, err := captureStdout(t, func() error {
|
|
var e error
|
|
resp, e = submitAndReport(request.NewAPIClient(srv.URL), "tok", req)
|
|
return e
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("submitAndReport: %v", err)
|
|
}
|
|
want := "Job <" + testArrayWritten + "> is submitted to queue <normal> (3 elements).\n"
|
|
if out != want {
|
|
t.Fatalf("output = %q, want %q", out, want)
|
|
}
|
|
if resp.Data.ArraySize != 3 {
|
|
t.Errorf("ArraySize = %d, want 3", resp.Data.ArraySize)
|
|
}
|
|
}
|
|
|
|
// An ordinary submission prints exactly as before (3.3): no count added.
|
|
func TestSubmitOutput_OrdinaryUnchanged(t *testing.T) {
|
|
id := format.MakeJobID(0, 0, 1700000000, 0, 7)
|
|
srv := submitServer(t, models.JobSubmitData{
|
|
JobID: int64(id),
|
|
Message: fmt.Sprintf("Job <%d> is submitted to queue <normal>.", id),
|
|
})
|
|
js := NewJobSubmitter(nil)
|
|
js.command = "sleep 1"
|
|
req, err := js.BuildJobRequest()
|
|
if err != nil {
|
|
t.Fatalf("BuildJobRequest: %v", err)
|
|
}
|
|
var resp *models.JobSubmitResponse
|
|
out, err := captureStdout(t, func() error {
|
|
var e error
|
|
resp, e = submitAndReport(request.NewAPIClient(srv.URL), "tok", req)
|
|
return e
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("submitAndReport: %v", err)
|
|
}
|
|
want := "Job <0-0-1700000000-7> is submitted to queue <normal>.\n"
|
|
if out != want {
|
|
t.Fatalf("output = %q, want %q", out, want)
|
|
}
|
|
if resp.Data.ArraySize != 0 {
|
|
t.Errorf("ArraySize = %d, want 0", resp.Data.ArraySize)
|
|
}
|
|
}
|
|
|
|
// Submit help describes the array forms, numbering from 1 and the limit (9.1).
|
|
func TestSubmitHelpShowsArrayForms(t *testing.T) {
|
|
long := NewSubmitJobsCmd(nil).Long
|
|
for _, s := range []string{
|
|
"-J 'name[1-N]'", "-J 'name[1]'", "start at 1",
|
|
"set by the API server", "default 1000", "32767",
|
|
} {
|
|
if !strings.Contains(long, s) {
|
|
t.Errorf("submit help lacks %q", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// getEnvironmentVariables must keep entries whose value contains '=' (the
|
|
// apiserver splits at the FIRST '=', so A=b=c is A -> "b=c") -- dropping
|
|
// them used to be silent data loss. A nameless or '='-less entry cannot
|
|
// become a map key, so it is warned about and dropped.
|
|
func TestGetEnvironmentVariables_KeepsMultiEqualSign(t *testing.T) {
|
|
js := NewJobSubmitter(nil)
|
|
js.subEnvVars = []string{"A=1", "B=b=c", "C=", "=novalue", "novalue"}
|
|
|
|
r, w, pipeErr := os.Pipe()
|
|
if pipeErr != nil {
|
|
t.Fatalf("pipe: %v", pipeErr)
|
|
}
|
|
old := os.Stdout
|
|
os.Stdout = w
|
|
done := make(chan string)
|
|
go func() {
|
|
var buf bytes.Buffer
|
|
_, _ = io.Copy(&buf, r)
|
|
done <- buf.String()
|
|
}()
|
|
got := js.getEnvironmentVariables()
|
|
os.Stdout = old
|
|
_ = w.Close()
|
|
warn := <-done
|
|
|
|
want := []string{"A=1", "B=b=c", "C="}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("env = %q, want %q", got, want)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Errorf("env[%d] = %q, want %q", i, got[i], want[i])
|
|
}
|
|
}
|
|
if !strings.Contains(warn, "=novalue") || !strings.Contains(warn, "warning:") {
|
|
t.Errorf("warning output = %q, want it to name the nameless entry", warn)
|
|
}
|
|
if !strings.Contains(warn, "novalue") {
|
|
t.Errorf("warning output = %q, want it to name the '='-less entry", warn)
|
|
}
|
|
}
|
|
|
|
// Attach marks the terminal-attach kind of TTY job so the worker may reap
|
|
// ttyd when no client ever connects; --browse keeps the web-viewed
|
|
// semantics and must not set it.
|
|
func TestBuildJobRequest_AttachFlagMatrix(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
interactive bool
|
|
useTTY bool
|
|
useBrowse bool
|
|
useXJob bool
|
|
wantInter int
|
|
wantAttach int
|
|
}{
|
|
{"batch job", false, false, false, false, 0, 0},
|
|
{"tty attach (bare -I)", true, true, false, false, 1, 1},
|
|
{"tty browse", true, true, true, false, 1, 0},
|
|
{"xjob", true, false, false, true, 2, 0},
|
|
{"display (interactive=3)", true, false, false, false, 3, 0},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
js := NewJobSubmitter(nil)
|
|
js.command = "sleep 1"
|
|
js.interactive = tc.interactive
|
|
js.useTTY = tc.useTTY
|
|
js.useBrowse = tc.useBrowse
|
|
js.useXJob = tc.useXJob
|
|
req, err := js.BuildJobRequest()
|
|
if err != nil {
|
|
t.Fatalf("BuildJobRequest: %v", err)
|
|
}
|
|
if req.Interactive != tc.wantInter {
|
|
t.Errorf("Interactive = %d, want %d", req.Interactive, tc.wantInter)
|
|
}
|
|
if req.Attach != tc.wantAttach {
|
|
t.Errorf("Attach = %d, want %d", req.Attach, tc.wantAttach)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// jobStateServer answers the per-job status query with the states fed in
|
|
// sequence, holding the last one forever; every request's arrival time is
|
|
// recorded so tests can assert the poll cadence.
|
|
type jobStateServer struct {
|
|
srv *httptest.Server
|
|
states chan string
|
|
mu sync.Mutex
|
|
pollAts []time.Time
|
|
// urlQueries counts requests carrying fields=interactive_job_url.
|
|
urlQueries int
|
|
}
|
|
|
|
func newJobStateServer(t *testing.T, states ...string) *jobStateServer {
|
|
t.Helper()
|
|
js := &jobStateServer{states: make(chan string, len(states))}
|
|
for _, s := range states {
|
|
js.states <- s
|
|
}
|
|
held := ""
|
|
js.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
js.mu.Lock()
|
|
js.pollAts = append(js.pollAts, time.Now())
|
|
if r.URL.Query().Get("fields") == "interactive_job_url" {
|
|
js.urlQueries++
|
|
}
|
|
js.mu.Unlock()
|
|
select {
|
|
case held = <-js.states:
|
|
default:
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(models.JobsResponse{
|
|
Code: 0,
|
|
Data: struct {
|
|
Count int `json:"count"`
|
|
Rows []models.QueryJob `json:"rows"`
|
|
Page_token string `json:"page_token"`
|
|
}{Rows: []models.QueryJob{{JobStatus: held, InteractiveJobURL: "http://h:9000"}}},
|
|
})
|
|
}))
|
|
t.Cleanup(js.srv.Close)
|
|
return js
|
|
}
|
|
|
|
func (js *jobStateServer) pollCount() int {
|
|
js.mu.Lock()
|
|
defer js.mu.Unlock()
|
|
return len(js.pollAts)
|
|
}
|
|
|
|
// waitForInteractiveURL fast-polls the PEND phase (500ms) so a normally
|
|
// dispatched job attaches within ~a second, then backs off to 2s so a job
|
|
// waiting on a long queue doesn't hold 2 qps against the server forever.
|
|
func TestWaitForInteractiveURL_PendFastThenBackoff(t *testing.T) {
|
|
if config.Log == nil {
|
|
config.Log = zap.NewNop()
|
|
t.Cleanup(func() { config.Log = nil })
|
|
}
|
|
// PEND through the fast window (11 polls at t=0..5.0s) plus one backoff
|
|
// round, then RUN+URL: the total wait is 10*0.5 + 2 + ~0 = ~7s.
|
|
states := make([]string, 11)
|
|
for i := range states {
|
|
states[i] = "PEND"
|
|
}
|
|
states = append(states, "RUN")
|
|
js := newJobStateServer(t, states...)
|
|
cli := NewJobSubmitter(nil)
|
|
start := time.Now()
|
|
url, err := cli.waitForInteractiveURL(request.NewAPIClient(js.srv.URL), "tok", "123")
|
|
elapsed := time.Since(start)
|
|
if err != nil {
|
|
t.Fatalf("waitForInteractiveURL: %v", err)
|
|
}
|
|
if url != "http://h:9000" {
|
|
t.Errorf("url = %q, want http://h:9000", url)
|
|
}
|
|
// ~7s expected; under 6s means the backoff never happened (all fast),
|
|
// over 9s means the fast window was skipped (all 2s).
|
|
if elapsed < 6*time.Second || elapsed > 9*time.Second {
|
|
t.Errorf("elapsed = %v, want ~7s (10 fast intervals then a 2s backoff)", elapsed)
|
|
}
|
|
if n := js.pollCount(); n != 13 {
|
|
t.Errorf("polls = %d, want 13 (12 status + 1 url query)", n)
|
|
}
|
|
}
|
|
|
|
// RUN-before-URL polls at the 500ms run cadence: the start-report push
|
|
// normally closes the race in well under a second, so a 2s wait here would
|
|
// add that much dead time to every interactive submit.
|
|
func TestWaitForInteractiveURL_RunRaceUsesFastInterval(t *testing.T) {
|
|
if config.Log == nil {
|
|
config.Log = zap.NewNop()
|
|
t.Cleanup(func() { config.Log = nil })
|
|
}
|
|
// First poll sees RUN with the URL already missing (the state server
|
|
// only exposes a URL once the state flips), second sees it present.
|
|
// We emulate the missing-URL round by answering the first url query
|
|
// with an empty URL: swap in a custom server.
|
|
seen := 0
|
|
emptyURL := true
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
url := ""
|
|
if !emptyURL {
|
|
url = "http://h:9000"
|
|
}
|
|
row := models.QueryJob{JobStatus: "RUN", InteractiveJobURL: url}
|
|
if r.URL.Query().Get("fields") == "interactive_job_url" {
|
|
seen++
|
|
if seen == 1 {
|
|
row.InteractiveJobURL = ""
|
|
}
|
|
emptyURL = row.InteractiveJobURL != ""
|
|
}
|
|
_ = json.NewEncoder(w).Encode(models.JobsResponse{
|
|
Code: 0,
|
|
Data: struct {
|
|
Count int `json:"count"`
|
|
Rows []models.QueryJob `json:"rows"`
|
|
Page_token string `json:"page_token"`
|
|
}{Rows: []models.QueryJob{row}},
|
|
})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
cli := NewJobSubmitter(nil)
|
|
start := time.Now()
|
|
url, err := cli.waitForInteractiveURL(request.NewAPIClient(srv.URL), "tok", "123")
|
|
elapsed := time.Since(start)
|
|
if err != nil {
|
|
t.Fatalf("waitForInteractiveURL: %v", err)
|
|
}
|
|
if url != "http://h:9000" {
|
|
t.Errorf("url = %q, want http://h:9000", url)
|
|
}
|
|
if elapsed > 1200*time.Millisecond {
|
|
t.Errorf("elapsed = %v, want ~0.5s (run cadence, not the old 2s)", elapsed)
|
|
}
|
|
}
|
|
|
|
// A suspended interactive job must not fail the wait the way a terminal
|
|
// state does: the allocation is held and `xce jobs resume` may follow, so
|
|
// the loop keeps waiting (backed off) and picks up the URL when the job is
|
|
// running again.
|
|
func TestWaitForInteractiveURL_SuspendedJobKeepsWaiting(t *testing.T) {
|
|
if config.Log == nil {
|
|
config.Log = zap.NewNop()
|
|
t.Cleanup(func() { config.Log = nil })
|
|
}
|
|
js := newJobStateServer(t, "USUSP", "RUN")
|
|
cli := NewJobSubmitter(nil)
|
|
url, err := cli.waitForInteractiveURL(request.NewAPIClient(js.srv.URL), "tok", "123")
|
|
if err != nil {
|
|
t.Fatalf("waitForInteractiveURL: %v", err)
|
|
}
|
|
if url != "http://h:9000" {
|
|
t.Errorf("url = %q, want http://h:9000", url)
|
|
}
|
|
}
|