fix(apiserver): restart carries only the options a job gets (bug-1968)

RestartJobByID rebuilt the new job from the old job's row:
- it accepted asked_hosts and copied asked hosts, I/O paths and out_dir,
  which no job gets (refused at submit since bug-1936); restart now
  refuses asked_hosts with 400 and no longer carries those over;
- it read the JSONB columns chkpnt_info/extend_data as strings, but rows
  keep models.Jsonb, so env/limits were lost and a checkpointed job never
  restarted from its checkpoint; new rowJSON() reads both;
- it read "res_req" (the column is "resreq"), losing the resource
  requirement, and never copied exec_home.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-10-10 14:23:16 +00:00
parent 801d26db06
commit 6a2c69f8bd
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
2 changed files with 158 additions and 45 deletions

View file

@ -1522,6 +1522,131 @@ func TestJobHandler_RestartNamedJob(t *testing.T) {
}
}
// bug-1968: restart, like submit (bug-1936), refuses an option the restarted
// job would never get: asked hosts (-m) have no field in the controller's
// submit data. Nothing is submitted.
func TestJobHandler_RestartRejectsAskedHosts(t *testing.T) {
f := newHandlerFixture(t)
ord := handlerTestOrdinaryBase + 50<<15
f.seedRestartJob(ord, "sleep 1")
before := len(f.submitted)
w := f.do(http.MethodPost, "/xce/v1/jobs/"+strconv.FormatUint(ord, 10)+"/restart",
map[string]interface{}{"asked_hosts": "hostA"})
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400; body: %s", w.Code, w.Body.String())
}
if len(f.submitted) != before {
t.Errorf("%d submits, want none", len(f.submitted)-before)
}
}
// bug-1968: the restarted job is rebuilt from the old job's row. Options no
// job can get (asked hosts, begin/term time, I/O paths, out_dir; refused at
// submit since bug-1936) must not be carried over, while the options the job
// does get -- extended data (env, limits), cwd and exec home -- must be,
// whether or not the old job was checkpointed.
func TestJobHandler_RestartCarriesOnlyHonouredOptions(t *testing.T) {
f := newHandlerFixture(t)
ord := handlerTestOrdinaryBase + 51<<15
exdata := `{"sub_env_vars":{"A":"1"},"limits":{"run_limit":60}}`
f.seedJob(models.JobInfo{
Unique_JobID: int64(ord),
JobID: int64(backend.ExtractArrayBaseID(ord)),
JobName: "r",
Command: "sleep 1",
JobStatus: models.JOBSTAT["DONE"],
CWD: "/data/run",
ExecHome: "/home/u",
ResReq: "rusage[mem=100]",
ExtendData: models.Jsonb{String: exdata},
AskedHosts: "hostA",
BeginTime: models.CustomTime{Time: time.Unix(1700000000, 0)},
TermTime: models.CustomTime{Time: time.Unix(1800000000, 0)},
InFile: "/in",
OutFile: "/out",
ErrFile: "/err",
OutDir: "/outdir",
})
before := len(f.submitted)
w := f.restart(strconv.FormatUint(ord, 10))
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("status = %d; body: %s", w.Code, w.Body.String())
}
if len(f.submitted) != before+1 {
t.Fatalf("%d submits, want 1", len(f.submitted)-before)
}
job := f.submitted[before].Job
dropped := map[string]bool{
"asked_hosts": job.AskedHosts != "",
"begin_time": !job.BeginTime.Time.IsZero(),
"term_time": !job.TermTime.Time.IsZero(),
"in_file": job.InFile != "",
"out_file": job.OutFile != "",
"err_file": job.ErrFile != "",
"out_dir": job.OutDir != "",
}
for field, kept := range dropped {
if kept {
t.Errorf("restarted job kept %s, which no job gets", field)
}
}
if job.ExtendData.String != exdata {
t.Errorf("ExtendData = %q, want %q", job.ExtendData.String, exdata)
}
if job.CWD != "/data/run" {
t.Errorf("CWD = %q, want /data/run", job.CWD)
}
if job.ExecHome != "/home/u" {
t.Errorf("ExecHome = %q, want /home/u", job.ExecHome)
}
if job.ResReq != "rusage[mem=100]" {
t.Errorf("ResReq = %q, want rusage[mem=100]", job.ResReq)
}
}
// bug-1968: a checkpointed job restarts from its checkpoint: the restart
// command replaces the command and the checkpointed pid reaches the job as
// XCE_JOB_CHECKPOINT_PID next to the old job's environment. The stored
// chkpnt_info/extend_data are models.Jsonb, which the restart never read.
func TestJobHandler_RestartFromCheckpoint(t *testing.T) {
f := newHandlerFixture(t)
ord := handlerTestOrdinaryBase + 52<<15
f.seedJob(models.JobInfo{
Unique_JobID: int64(ord),
JobID: int64(backend.ExtractArrayBaseID(ord)),
JobName: "r",
Command: "sleep 1",
JobStatus: models.JOBSTAT["EXIT"],
ChkpntInfo: models.Jsonb{String: `{"restart_command":"criu restore","ckpt_pid":4321}`},
ExtendData: models.Jsonb{String: `{"sub_env_vars":{"A":"1"}}`},
})
before := len(f.submitted)
w := f.restart(strconv.FormatUint(ord, 10))
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("status = %d; body: %s", w.Code, w.Body.String())
}
if len(f.submitted) != before+1 {
t.Fatalf("%d submits, want 1", len(f.submitted)-before)
}
job := f.submitted[before].Job
if job.Command != "criu restore" {
t.Errorf("Command = %q, want the checkpoint's restart command", job.Command)
}
var ex struct {
Env map[string]string `json:"sub_env_vars"`
}
if err := json.Unmarshal([]byte(job.ExtendData.String), &ex); err != nil {
t.Fatalf("ExtendData %q: %v", job.ExtendData.String, err)
}
if ex.Env["A"] != "1" || ex.Env["XCE_JOB_CHECKPOINT_PID"] != "4321" {
t.Errorf("sub_env_vars = %v, want A=1 and XCE_JOB_CHECKPOINT_PID=4321", ex.Env)
}
}
// bug-1936: the job handed to the backend carries the cwd and the extended
// data under the key names and units the worker's parse_exdata()/
// parse_limits() read (run_limit in seconds, memory limits in MB,