myxce/src/frontend/schedulerclient/write.go
lei.chen 673f2eb792 feat(apiserver): surface human-readable job ID (fmt_jobid form) in responses
The MCP/apiserver returned the raw 64-bit job ID (e.g. 73930964699447296)
while xce_test/xcecli display the dash form (e.g. 1-0-1786585485-0) that
parse_jobid accepts. A user taking the numeric form into xce_test fails to
parse it. The apiserver is the data-correctness owner (MCP only reads), so
it now emits the controller's fmt_jobid form alongside the numeric ID:

- models: JobSubmitResponse gains job_id_human; job list rows gain
  job_id_human (processJobData), using the existing backend.FormatJobID.
- schedulerclient: URMJob.HumanID + SubmitJobResult.JobIDHuman parse it.
- Tests: backend.FormatJobID (real t1 job, pre-cluster1 job, array child);
  processJobData adds job_id_human.

Also rename the SSE permission event:subscribe → event:view so mcp_readonly
stays strictly *:view (the read-only invariant test enforces).

Verified live on t1: scheduler_submit returns job_id_human 1-0-1786586474-0
and scheduler_get_job returns human_id.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-13 02:01:52 +00:00

225 lines
9.1 KiB
Go

package schedulerclient
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
)
// ── Write types (Phase 1) ────────────────────────────────────────────────────
// JobSpec is a unified job submission specification. Canonical units are
// WalltimeSeconds (seconds) and MemoryBytes (bytes); the HTTP layer converts
// them to the apiserver's wire format ("HH:MM" and "<N> MB").
type JobSpec struct {
Name string `json:"name,omitempty"` // job_name; array syntax "myjob[1-10]" is passed through
Command string `json:"command"` // required
Queue string `json:"queue,omitempty"` // empty → apiserver defaults to "normal"
Project string `json:"project,omitempty"` // project_name
CWD string `json:"cwd,omitempty"` // working directory
OutFile string `json:"out_file,omitempty"` // standard output file
ErrFile string `json:"err_file,omitempty"` // standard error file
Env []string `json:"env,omitempty"` // "KEY=VALUE"
NumProcessors int `json:"num_processors,omitempty"` // number of processors
MaxNumProcessors int `json:"maxnum_processors,omitempty"`
GpuReq string `json:"gpureq,omitempty"` // gpureq resource string
WalltimeSeconds int `json:"walltime_seconds,omitempty"` // run_limit → "HH:MM"
MemoryBytes int64 `json:"memory_bytes,omitempty"` // mem_limit → "<MB> MB"
Exclusive bool `json:"exclusive,omitempty"`
Rerunable bool `json:"rerunable,omitempty"`
DependCond string `json:"depend_cond,omitempty"` // dependency condition string
JobDescription string `json:"description,omitempty"`
}
// SubmitJobResult is the apiserver's response to a successful job submission.
type SubmitJobResult struct {
JobID int64 `json:"jobid"` // NB: lowercase field name in the apiserver response
JobIDHuman string `json:"job_id_human"` // cluster-apiserver-timestamp-seq (usable in xce_test/xcecli)
QueueName string `json:"queue_name"`
Message string `json:"message"`
}
// JobControlRequest is the body sent to POST /xce/v1/jobs/:jobids/control.
type JobControlRequest struct {
Action string `json:"action"` // kill | stop | resume | checkpoint
Signal string `json:"signal,omitempty"`
Comment string `json:"comment,omitempty"`
ForceKill bool `json:"force_kill,omitempty"`
Method string `json:"method,omitempty"`
}
// ControlResult is the apiserver's per-item control response.
type ControlResult struct {
Status string `json:"status"` // accepted | failed | partially_accepted | no_items_processed
Accepted []ControlItem `json:"accepted"`
Failed []ControlItem `json:"failed"`
}
// ControlItem reports the outcome for one job (or array element).
type ControlItem struct {
UniqueJobID int64 `json:"unique_job_id"`
JobID int64 `json:"job_id"`
ArrayIdx int `json:"array_idx"`
Msg string `json:"msg"`
}
// apiJobSubmitRequest mirrors the subset of apiserver models.JobSubmitRequest
// JSON tags we send. Zero-valued fields are omitted (omitempty) so the
// apiserver applies its own defaults (e.g. queue_name → "normal").
type apiJobSubmitRequest struct {
JobName string `json:"job_name,omitempty"`
Command string `json:"command"`
QueueName string `json:"queue_name,omitempty"`
ProjectName string `json:"project_name,omitempty"`
CWD string `json:"cwd,omitempty"`
OutFile string `json:"out_file,omitempty"`
ErrFile string `json:"err_file,omitempty"`
Env []string `json:"env,omitempty"`
NumProcessors int `json:"num_processors,omitempty"`
MaxNumProcessors int `json:"maxnum_processors,omitempty"`
Gpureq string `json:"gpureq,omitempty"`
Exclusive bool `json:"exclusive,omitempty"`
Rerunable bool `json:"rerunable,omitempty"`
DependCond string `json:"depend_cond,omitempty"`
JobDescription string `json:"job_description,omitempty"`
RunLimit string `json:"run_limit,omitempty"` // "HH:MM" (bare ints are MINUTES — never sent)
MemLimit string `json:"mem_limit,omitempty"` // "<N> MB"
}
// jobSpecToAPI converts a JobSpec into the wire request. run_limit is rendered
// as "H:MM" and mem_limit as "<MB> MB" using the package's decimal
// mbToBytes convention (1 MB = 1_000_000 bytes), so "4 GB" → 4e9 bytes →
// "4000 MB" → CustomLimit parses cleanly.
func jobSpecToAPI(s JobSpec) apiJobSubmitRequest {
req := apiJobSubmitRequest{
JobName: s.Name,
Command: s.Command,
QueueName: s.Queue,
ProjectName: s.Project,
CWD: s.CWD,
OutFile: s.OutFile,
ErrFile: s.ErrFile,
Env: s.Env,
NumProcessors: s.NumProcessors,
MaxNumProcessors: s.MaxNumProcessors,
Gpureq: s.GpuReq,
Exclusive: s.Exclusive,
Rerunable: s.Rerunable,
DependCond: s.DependCond,
JobDescription: s.JobDescription,
}
if s.WalltimeSeconds > 0 {
req.RunLimit = formatWalltime(s.WalltimeSeconds)
}
if s.MemoryBytes > 0 {
req.MemLimit = formatMemory(s.MemoryBytes)
}
return req
}
// formatWalltime renders seconds as "H:MM", matching the apiserver's
// CustomInt.String() output. Sub-minute seconds are truncated (walltime
// granularity is minutes).
func formatWalltime(secs int) string {
return fmt.Sprintf("%d:%02d", secs/3600, (secs%3600)/60)
}
// formatMemory renders bytes as "<N> MB" using the package's decimal
// mbToBytes convention. Rounds up so sub-MB amounts don't become "0 MB".
func formatMemory(bytes int64) string {
mb := bytes / mbToBytes
if bytes%mbToBytes != 0 {
mb++
}
return strconv.Itoa(int(mb)) + " MB"
}
// ── shared POST helper ───────────────────────────────────────────────────────
// doPost performs an authenticated POST with a JSON body and decodes the
// envelope. expectedCode is the envelope code on success (201 create, 202
// control). NB: ReturnSuccess always uses HTTP 200; the logical status is the
// envelope's code field. ReturnError sets both HTTP status and envelope code
// to the error code.
func (hc *HTTPClient) doPost(ctx context.Context, url string, body any, expectedCode int) (*apiEnvelope, error) {
req := hc.rc.R().SetContext(ctx).
SetHeader("Authorization", hc.token).
SetHeader("Content-Type", "application/json")
var env apiEnvelope
resp, err := req.SetBody(body).SetResult(&env).Post(url)
if err != nil {
return nil, fmt.Errorf("post %s: %w", url, err)
}
if resp.StatusCode() != http.StatusOK {
// resty does not decode non-2xx bodies into SetResult, so read the raw
// body (the apiserver's error envelope) for a useful message.
return nil, fmt.Errorf("post %s: HTTP %d: %s", url, resp.StatusCode(), resp.String())
}
if env.Code != expectedCode {
return nil, fmt.Errorf("post %s: api error %d: %s", url, env.Code, env.Msg)
}
return &env, nil
}
// ── write method implementations ─────────────────────────────────────────────
func (hc *HTTPClient) SubmitJob(ctx context.Context, spec JobSpec) (*SubmitJobResult, error) {
if strings.TrimSpace(spec.Command) == "" {
return nil, fmt.Errorf("submit job: command is required")
}
url := hc.baseURL + "/xce/v1/jobs/create"
env, err := hc.doPost(ctx, url, jobSpecToAPI(spec), http.StatusCreated)
if err != nil {
return nil, fmt.Errorf("submit job: %w", err)
}
var res SubmitJobResult
if err := json.Unmarshal(env.Data, &res); err != nil {
return nil, fmt.Errorf("submit job: parse result: %w", err)
}
return &res, nil
}
// controlJob sends a job-control action and returns a descriptive error unless
// every requested item was accepted.
func (hc *HTTPClient) controlJob(ctx context.Context, id, action string) error {
if strings.TrimSpace(id) == "" {
return fmt.Errorf("%s job: job id is required", action)
}
url := hc.baseURL + "/xce/v1/jobs/" + id + "/control"
env, err := hc.doPost(ctx, url, JobControlRequest{Action: action}, http.StatusAccepted)
if err != nil {
return fmt.Errorf("%s job %s: %w", action, id, err)
}
var res ControlResult
if err := json.Unmarshal(env.Data, &res); err != nil {
return fmt.Errorf("%s job %s: parse result: %w", action, id, err)
}
switch res.Status {
case "accepted":
return nil
case "no_items_processed":
return fmt.Errorf("%s job %s: no matching job found", action, id)
default: // "failed" or "partially_accepted"
return fmt.Errorf("%s job %s: status=%s failed=%+v", action, id, res.Status, res.Failed)
}
}
func (hc *HTTPClient) CancelJob(ctx context.Context, id string) error {
return hc.controlJob(ctx, id, "kill")
}
func (hc *HTTPClient) HoldJob(ctx context.Context, id string) error {
return hc.controlJob(ctx, id, "stop")
}
func (hc *HTTPClient) ReleaseJob(ctx context.Context, id string) error {
return hc.controlJob(ctx, id, "resume")
}