fix(apiserver): map pidinfo from job start into the job model (interactive_job_url)
The worker packs the interactive session URL (ttyd/XJOB) into the start
report's exdata as {"url": ...}; the controller stores it on
job->job.pidinfo->exdata and the capnp Job wire carries pidinfo (pidinfo@9,
JobPIDInfo.exdata@3) — but apiserver's cJobToModel never mapped pidinfo, so
models.JobInfo.InteractiveJobURL (declared only in the query-field whitelist
JobAdditionalFields) had no producer and interactive_job_url was permanently
empty in API responses. xcecli's waitForInteractiveURL/openBrowser flow could
never fire.
- models.JobInfo: add InteractiveJobURL (json interactive_job_url, matching
the JobAdditionalFields whitelist name).
- convert_pure.go: rawPidInfo (pids/pgids/exdata) + interactiveURLFromExdata;
rawJobToModel marshals pids/pgids into PIDInfo (pid_info is a JSONBJFields
entry, parsed by processJobData) and promotes the exdata URL to
InteractiveJobURL.
- convert.go: cJobToModel extracts job.pidinfo via pidinfo_pid/pidinfo_pgid
cgo helpers (CGO cannot index the C arrays directly).
Cluster-verified end to end on t1 (fresh apiserver deployed): tty job
(xcecli -I --tty equivalent, exdata {"interactive":1}) now returns
interactive_job_url=http://<worker>:9000 and a populated pid_info; non-tty
jobs return ""/no pid_info. All daemons healthy, zero errors in window.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
f661987a60
commit
74121eb72e
4 changed files with 159 additions and 1 deletions
|
|
@ -17,6 +17,10 @@ static xce_res_reason_t *job_res_reason(xce_job_preason_t *p, int i) { return p-
|
|||
// 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]; }
|
||||
|
||||
// Helpers to index into job_pidinfo_t's pid/pgid arrays (same CGO limitation).
|
||||
static unsigned int pidinfo_pid(job_pidinfo_t *p, int i) { return p->pids[i]; }
|
||||
static unsigned int pidinfo_pgid(job_pidinfo_t *p, int i) { return p->pgids[i]; }
|
||||
*/
|
||||
import "C"
|
||||
|
||||
|
|
@ -198,6 +202,22 @@ func cJobToModel(job *C.xce_job_t) *models.JobInfo {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Pidinfo: the worker's start report — pids/pgids plus exdata, whose
|
||||
// {"url": ...} JSON is the interactive session URL (ttyd/XJOB). The
|
||||
// capnp Job wire carries pidinfo (Job.pidinfo@9 / JobPIDInfo.exdata@3)
|
||||
// but the conversion dropped it, so interactive_job_url never reached
|
||||
// the API and xcecli's browser flow could never fire.
|
||||
if job.pidinfo != nil {
|
||||
pi := job.pidinfo
|
||||
p := &rawPidInfo{exdata: goString(pi.exdata)}
|
||||
for i := 0; i < int(pi.npids); i++ {
|
||||
p.pids = append(p.pids, int(C.pidinfo_pid(pi, C.int(i))))
|
||||
}
|
||||
for i := 0; i < int(pi.npgids); i++ {
|
||||
p.pgids = append(p.pgids, int(C.pidinfo_pgid(pi, C.int(i))))
|
||||
}
|
||||
r.pidInfo = p
|
||||
}
|
||||
return rawJobToModel(r)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -526,6 +526,17 @@ type rawRunUsage struct {
|
|||
peakCPU float64
|
||||
}
|
||||
|
||||
// rawPidInfo mirrors the C job_pidinfo_t fields used by the Go model —
|
||||
// the worker-reported process bookkeeping a job start carries. Exdata is
|
||||
// the worker's start_exdata JSON; for interactive jobs (TTY/XJOB/DISPLAY,
|
||||
// xce_job_type in src/include/xce/types.h) the worker packs the session
|
||||
// URL into it as {"url": ...} (generate_exdata, xcecd_exdata.c).
|
||||
type rawPidInfo struct {
|
||||
pids []int
|
||||
pgids []int
|
||||
exdata string
|
||||
}
|
||||
|
||||
// rawJobData mirrors the C xce_job_t fields used by the Go model.
|
||||
type rawJobData struct {
|
||||
jobID int64
|
||||
|
|
@ -538,7 +549,8 @@ type rawJobData struct {
|
|||
submit *rawSubmitData
|
||||
execHosts []rawHostSlot
|
||||
pendReason *rawPendReason
|
||||
runUsage *rawRunUsage // nil until the job's first usage report
|
||||
runUsage *rawRunUsage // nil until the job's first usage report
|
||||
pidInfo *rawPidInfo // nil until the worker's start report
|
||||
}
|
||||
|
||||
// rawWorkerData mirrors the C xce_worker_t fields used by the Go model.
|
||||
|
|
@ -574,6 +586,26 @@ type rawResourceData struct {
|
|||
interval int
|
||||
}
|
||||
|
||||
// interactiveURLFromExdata extracts the interactive session URL from a job
|
||||
// start's exdata JSON. The worker packs it as {"url": ...}
|
||||
// (generate_exdata, xcecd_exdata.c); anything else — non-JSON, other keys,
|
||||
// an empty/non-string url — yields "". The URL reaches the apiserver ONLY
|
||||
// via this path: job_pidinfo_t.exdata rides the capnp Job wire (pidinfo@9)
|
||||
// but was previously dropped by the conversion, leaving the API's
|
||||
// interactive_job_url permanently empty and xcecli's openBrowser flow dead.
|
||||
func interactiveURLFromExdata(exdata string) string {
|
||||
if exdata == "" {
|
||||
return ""
|
||||
}
|
||||
var obj struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(exdata), &obj); err != nil {
|
||||
return ""
|
||||
}
|
||||
return obj.URL
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
|
@ -676,6 +708,20 @@ func rawJobToModel(r rawJobData) *models.JobInfo {
|
|||
info.AvgCPUEfficiency = u.avgCPUEfficiency
|
||||
info.PeakCPUEfficiency = u.peakCPUEfficiency
|
||||
}
|
||||
if p := r.pidInfo; p != nil {
|
||||
// pid_info is a JSONBJFields entry: processJobData parses the JSONB
|
||||
// string before the row leaves the API, so clients see an object
|
||||
// with pids/pgids. The interactive URL is promoted to its own
|
||||
// top-level field — it is what xcecli's waitForInteractiveURL and
|
||||
// the --browse flow consume.
|
||||
if data, err := json.Marshal(struct {
|
||||
Pids []int `json:"pids,omitempty"`
|
||||
Pgids []int `json:"pgids,omitempty"`
|
||||
}{Pids: p.pids, Pgids: p.pgids}); err == nil {
|
||||
info.PIDInfo = models.Jsonb{String: string(data)}
|
||||
}
|
||||
info.InteractiveJobURL = interactiveURLFromExdata(p.exdata)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -523,6 +523,91 @@ func TestPendReasonName(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// Interactive URL: a worker start report's exdata {"url": ...} must surface
|
||||
// as JobInfo.InteractiveJobURL (the field xcecli's waitForInteractiveURL
|
||||
// polls and the --browse flow opens). Before this mapping the capnp wire
|
||||
// carried pidinfo.exdata but the conversion dropped it, so the API's
|
||||
// interactive_job_url was permanently empty.
|
||||
func TestRawJobToModel_InteractiveURLFromPidInfoExdata(t *testing.T) {
|
||||
r := rawJobData{
|
||||
jobID: 11,
|
||||
state: 2, // JOB_STAT_RUN
|
||||
pidInfo: &rawPidInfo{
|
||||
pids: []int{4321},
|
||||
pgids: []int{4320},
|
||||
exdata: `{"url": "http://10.0.1.5:7681"}`,
|
||||
},
|
||||
}
|
||||
|
||||
info := rawJobToModel(r)
|
||||
|
||||
if want := "http://10.0.1.5:7681"; info.InteractiveJobURL != want {
|
||||
t.Errorf("InteractiveJobURL: got %q, want %q", info.InteractiveJobURL, want)
|
||||
}
|
||||
var decoded struct {
|
||||
Pids []int `json:"pids"`
|
||||
Pgids []int `json:"pgids"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(info.PIDInfo.String), &decoded); err != nil {
|
||||
t.Fatalf("PIDInfo should be valid JSON, got err: %v", err)
|
||||
}
|
||||
if len(decoded.Pids) != 1 || decoded.Pids[0] != 4321 {
|
||||
t.Errorf("PIDInfo.pids: got %v, want [4321]", decoded.Pids)
|
||||
}
|
||||
if len(decoded.Pgids) != 1 || decoded.Pgids[0] != 4320 {
|
||||
t.Errorf("PIDInfo.pgids: got %v, want [4320]", decoded.Pgids)
|
||||
}
|
||||
}
|
||||
|
||||
// A normal (non-interactive) job's start report carries no exdata at all:
|
||||
// no URL, and an empty pids/pgids payload marshals no keys at all.
|
||||
func TestRawJobToModel_NonInteractivePidInfo(t *testing.T) {
|
||||
r := rawJobData{
|
||||
jobID: 12,
|
||||
state: 2,
|
||||
pidInfo: &rawPidInfo{exdata: ""},
|
||||
}
|
||||
|
||||
info := rawJobToModel(r)
|
||||
|
||||
if info.InteractiveJobURL != "" {
|
||||
t.Errorf("InteractiveJobURL: got %q, want empty for a non-interactive job", info.InteractiveJobURL)
|
||||
}
|
||||
if info.PIDInfo.String != "{}" {
|
||||
t.Errorf("PIDInfo: got %q, want {}", info.PIDInfo.String)
|
||||
}
|
||||
}
|
||||
|
||||
// Malformed exdata must yield "" — never an error path that breaks the
|
||||
// whole job row (the exdata is worker-supplied JSON, only the URL key is
|
||||
// meaningful here).
|
||||
func TestInteractiveURLFromExdata_Malformed(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"not json",
|
||||
`{"other": 1}`,
|
||||
`{"url": 42}`,
|
||||
}
|
||||
for _, ex := range cases {
|
||||
if got := interactiveURLFromExdata(ex); got != "" {
|
||||
t.Errorf("interactiveURLFromExdata(%q): got %q, want empty", ex, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No pidinfo at all (PEND job, or a start report that predates the exdata
|
||||
// plumbing): URL and PIDInfo stay empty.
|
||||
func TestRawJobToModel_NilPidInfo(t *testing.T) {
|
||||
info := rawJobToModel(rawJobData{jobID: 13, state: 1})
|
||||
|
||||
if info.InteractiveJobURL != "" {
|
||||
t.Errorf("InteractiveJobURL: got %q, want empty without pidinfo", info.InteractiveJobURL)
|
||||
}
|
||||
if info.PIDInfo.String != "" {
|
||||
t.Errorf("PIDInfo: got %q, want empty without pidinfo", info.PIDInfo.String)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawJobToModel_Timestamps(t *testing.T) {
|
||||
r := rawJobData{
|
||||
jobID: 1,
|
||||
|
|
|
|||
|
|
@ -573,6 +573,13 @@ type JobInfo struct {
|
|||
PendingReason Jsonb `json:"pending_reason"` //change type, JSONB as []byte
|
||||
PIDInfo Jsonb `json:"pid_info"` // JSONB as []byte
|
||||
JobRusage Jsonb `json:"job_rusage"` // JSONB as []byte
|
||||
// InteractiveJobURL is the ttyd/XJOB session URL the worker reported at
|
||||
// job start (job_pidinfo_t.exdata's {"url": ...} JSON, extracted by the
|
||||
// backend conversion). Empty for non-interactive jobs and for jobs whose
|
||||
// start report predates the exdata plumbing. InteractiveJobURL in
|
||||
// JobAdditionalFields (the query-field whitelist) must stay in sync with
|
||||
// this name.
|
||||
InteractiveJobURL string `json:"interactive_job_url"`
|
||||
}
|
||||
|
||||
type JobExecHosts struct {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue