forked from lei.chen/myxce
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>
This commit is contained in:
parent
3d1560d69c
commit
673f2eb792
9 changed files with 135 additions and 10 deletions
|
|
@ -916,3 +916,44 @@ func TestResolveExecUser(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatJobID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jobID int64
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "real job from t1",
|
||||
jobID: 73930964699447296, // cluster=1 apiserver=0 ts=1786585485 seq=0
|
||||
want: "1-0-1786585485-0",
|
||||
},
|
||||
{
|
||||
name: "pre-cluster1 job",
|
||||
// cluster=0 apiserver=7 ts=1786416651 seq=138313
|
||||
jobID: int64(uint64(0)<<56 | uint64(7)<<52 | uint64(1786416651)<<20 | uint64(138313)),
|
||||
want: "0-7-1786416651-138313",
|
||||
},
|
||||
{
|
||||
name: "zero",
|
||||
jobID: 0,
|
||||
want: "0-0-0-0",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := FormatJobID(tt.jobID); got != tt.want {
|
||||
t.Errorf("FormatJobID(%d) = %q, want %q", tt.jobID, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatJobID_ArrayChild(t *testing.T) {
|
||||
// Array child: tag 0x01 set, seq split into arrayjob(high)<<15 and idx(low).
|
||||
// cluster=1 apiserver=0 ts=1786585485, arrayjob=3<<15, idx=5.
|
||||
jobID := int64(uint64(1)<<56 | uint64(0)<<52 | uint64(1786585485)<<20 | uint64(0x01)<<62 | uint64(3)<<15 | uint64(5))
|
||||
if got, want := FormatJobID(jobID), "1-0-1786585485-98304[5]"; got != want {
|
||||
t.Errorf("FormatJobID(array) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ permissions:
|
|||
- hostgroup:control # add
|
||||
- usergroup:view # view usergroup info
|
||||
- resource:view # view resource info
|
||||
- event:subscribe # subscribe to the live job-event SSE stream (scheduler-agent Phase 2)
|
||||
- event:view # subscribe to the live job-event SSE stream (scheduler-agent Phase 2)
|
||||
- ar:view # view advanced reservation info
|
||||
- ar:submit # submit advanced reservation
|
||||
- ar:control # delete, modify, create
|
||||
|
|
@ -171,7 +171,7 @@ roles:
|
|||
- action: cluster:view
|
||||
domain:
|
||||
- type: all
|
||||
- action: event:subscribe
|
||||
- action: event:view
|
||||
domain:
|
||||
- type: all
|
||||
|
||||
|
|
@ -208,6 +208,6 @@ roles:
|
|||
- action: job:control
|
||||
domain:
|
||||
- type: all
|
||||
- action: event:subscribe
|
||||
- action: event:view
|
||||
domain:
|
||||
- type: all
|
||||
|
|
|
|||
|
|
@ -1515,9 +1515,10 @@ func (j JobController) CreateJob(ctx *gin.Context) {
|
|||
logger.Info("Job submission successfully from ", zap.String("client_ip", ctx.ClientIP()), zap.Int64("jobID", primaryJobID))
|
||||
|
||||
response := models.JobSubmitResponse{
|
||||
Message: fmt.Sprintf("Job <%d> is submitted to default queue <%s>.", primaryJobID, req.QueueName),
|
||||
JobID: primaryJobID,
|
||||
QueueName: req.QueueName,
|
||||
Message: fmt.Sprintf("Job <%d> is submitted to default queue <%s>.", primaryJobID, req.QueueName),
|
||||
JobID: primaryJobID,
|
||||
JobIDHuman: backend.FormatJobID(primaryJobID),
|
||||
QueueName: req.QueueName,
|
||||
}
|
||||
ReturnSuccess(ctx, common.CREATED, common.SUCCESS_MSG, response)
|
||||
}
|
||||
|
|
@ -3144,6 +3145,28 @@ func jobArrayIdx(row utils.OrderMap) int {
|
|||
func processJobData(rows []utils.OrderMap, pagetoken string) utils.QueryResult {
|
||||
logger.Debug("Enter processJobData() function", zap.Any("rows", rows))
|
||||
for _, row := range rows {
|
||||
// Human-readable job ID (cluster-apiserver-timestamp-seq) so clients
|
||||
// (MCP server, CLI) can cross-reference with the xce_test/xcecli dash
|
||||
// format, which parses only that form. Data correctness lives here,
|
||||
// not in the clients. FormatJobID is the apiserver's own C fmt_jobid
|
||||
// equivalent (backend.FormatJobID).
|
||||
if uid, exist := row.Data["unique_job_id"]; exist {
|
||||
var jobID int64
|
||||
switch v := uid.(type) {
|
||||
case int64:
|
||||
jobID = v
|
||||
case int:
|
||||
jobID = int64(v)
|
||||
case int32:
|
||||
jobID = int64(v)
|
||||
case float64:
|
||||
jobID = int64(v)
|
||||
}
|
||||
if jobID != 0 {
|
||||
row.Data["job_id_human"] = backend.FormatJobID(jobID)
|
||||
}
|
||||
}
|
||||
|
||||
if v, exist := row.Data["job_status"]; exist {
|
||||
if v != nil {
|
||||
jobStatus, _ := utils.IntOrInt64(v)
|
||||
|
|
|
|||
57
src/frontend/apiserver/controllers/processjobdata_test.go
Normal file
57
src/frontend/apiserver/controllers/processjobdata_test.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"apiserver/utils"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func init() {
|
||||
utils.Log = zap.NewNop()
|
||||
}
|
||||
|
||||
// TestProcessJobData_AddsJobIDHuman verifies every job row gets a
|
||||
// job_id_human field (cluster-apiserver-timestamp-seq) so clients can
|
||||
// cross-reference with the xce_test/xcecli dash format.
|
||||
func TestProcessJobData_AddsJobIDHuman(t *testing.T) {
|
||||
rows := []utils.OrderMap{
|
||||
{
|
||||
Fields: []string{"job_id", "unique_job_id", "job_status"},
|
||||
Data: map[string]interface{}{
|
||||
"job_id": int64(73930964699447296),
|
||||
"unique_job_id": int64(73930964699447296),
|
||||
"job_status": int64(4), // DONE
|
||||
},
|
||||
},
|
||||
}
|
||||
res := processJobData(rows, "")
|
||||
if len(res.Rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(res.Rows))
|
||||
}
|
||||
got, ok := res.Rows[0].Data["job_id_human"]
|
||||
if !ok {
|
||||
t.Fatal("job_id_human missing from processed row")
|
||||
}
|
||||
if got != "1-0-1786585485-0" {
|
||||
t.Errorf("job_id_human = %v, want 1-0-1786585485-0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessJobData_NoJobIDHumanWhenZero(t *testing.T) {
|
||||
rows := []utils.OrderMap{
|
||||
{
|
||||
Fields: []string{"job_id", "unique_job_id", "job_status"},
|
||||
Data: map[string]interface{}{
|
||||
"job_id": int64(0),
|
||||
"unique_job_id": int64(0),
|
||||
"job_status": int64(1),
|
||||
},
|
||||
},
|
||||
}
|
||||
res := processJobData(rows, "")
|
||||
if _, ok := res.Rows[0].Data["job_id_human"]; ok {
|
||||
t.Error("job_id_human should not be set for zero job id")
|
||||
}
|
||||
}
|
||||
|
|
@ -695,6 +695,7 @@ type ShellLimit struct {
|
|||
|
||||
type JobSubmitResponse struct {
|
||||
JobID int64 `json:"jobid"`
|
||||
JobIDHuman string `json:"job_id_human"` // cluster-apiserver-timestamp-seq, for xce_test/xcecli cross-reference
|
||||
QueueName string `json:"queue_name"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ func Router() *gin.Engine {
|
|||
eventsHandler := &controllers.EventsController{}
|
||||
// Server-Sent Events stream of job lifecycle events. Long-lived
|
||||
// connection; keep-alive comments are sent when idle.
|
||||
eventsApi.GET("", controllers.RequirePermission("event:subscribe"), eventsHandler.SubscribeJobs)
|
||||
eventsApi.GET("", controllers.RequirePermission("event:view"), eventsHandler.SubscribeJobs)
|
||||
}
|
||||
|
||||
// ################ case: resources route
|
||||
|
|
|
|||
|
|
@ -355,6 +355,7 @@ func (hc *HTTPClient) getJobs(req *resty.Request, url string, filter JobFilter)
|
|||
numProcessors := j.rowInt("num_processors")
|
||||
urmJob := &URMJob{
|
||||
ID: strconv.FormatInt(j.rowInt64("job_id"), 10),
|
||||
HumanID: j.rowStr("job_id_human"),
|
||||
Name: j.rowStr("job_name"),
|
||||
Status: urmStatus,
|
||||
RawStatus: rawStatus,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const (
|
|||
// URMJob is the unified representation of a scheduler job.
|
||||
type URMJob struct {
|
||||
ID string `json:"id"`
|
||||
HumanID string `json:"human_id"` // controller dash form (cluster-apiserver-ts-seq), set by the apiserver
|
||||
Name string `json:"name"`
|
||||
Status JobStatus `json:"status"`
|
||||
RawStatus string `json:"raw_status"` // backend-native status string
|
||||
|
|
|
|||
|
|
@ -36,9 +36,10 @@ type JobSpec struct {
|
|||
|
||||
// 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
|
||||
QueueName string `json:"queue_name"`
|
||||
Message string `json:"message"`
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue