feat(job-array-one-based-index): control by identity validates first and dispatches once (task 7.4)
ControlJobByID parses every selection with ParseIdentitySelection before any store access (first rejection: 400, nothing dispatched), collects the jobs to act on de-duplicated by unique_job_id plus one not-found result per missing number per selection (carrying the would-be element ID), validates each job once after the loop and dispatches each accepted job once. foundJob gains an explicit-index flag so <array-id>[0] matches only element 0 and never falls back to the whole array or an ordinary job. Fixes bug-1899 (earlier selections were re-dispatched for every later one). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c3962e8f61
commit
4020740208
4 changed files with 475 additions and 241 deletions
|
|
@ -976,3 +976,261 @@ func TestJobHandler_GetJobByIDsSelections(t *testing.T) {
|
|||
assert400(t, "5.14", f.getJobs("64-0-1-0"), "wider than allowed")
|
||||
assert400(t, "5.5", f.getJobs(X+"[3-1]"), "reversed")
|
||||
}
|
||||
|
||||
// ========== Control by identity (task 7.4, bug-1899) ==========
|
||||
|
||||
// controlItem is one accepted or failed item of a control reply.
|
||||
type controlItem struct {
|
||||
UniqueJobID uint64
|
||||
HasUnique bool
|
||||
JobID uint64
|
||||
ArrayIdx int
|
||||
Msg string
|
||||
}
|
||||
|
||||
// controlReply is the data of a 202 control reply.
|
||||
type controlReply struct {
|
||||
Status string
|
||||
Accepted []controlItem
|
||||
Failed []controlItem
|
||||
}
|
||||
|
||||
// decodeControlReply decodes a successful (202) control reply.
|
||||
func decodeControlReply(t *testing.T, w *httptest.ResponseRecorder) controlReply {
|
||||
t.Helper()
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(w.Body.Bytes()))
|
||||
dec.UseNumber()
|
||||
var raw struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
Accepted []map[string]interface{} `json:"accepted"`
|
||||
Failed []map[string]interface{} `json:"failed"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := dec.Decode(&raw); err != nil {
|
||||
t.Fatalf("decode control reply: %v; body: %s", err, w.Body.String())
|
||||
}
|
||||
if raw.Code != 202 {
|
||||
t.Fatalf("code = %d, want 202; body: %s", raw.Code, w.Body.String())
|
||||
}
|
||||
num := func(m map[string]interface{}, k string) (uint64, bool) {
|
||||
n, ok := m[k].(json.Number)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
v, err := strconv.ParseUint(n.String(), 10, 64)
|
||||
if err != nil {
|
||||
// job_id/unique_job_id are int64 on the wire; keep the bit pattern
|
||||
s, err2 := strconv.ParseInt(n.String(), 10, 64)
|
||||
if err2 != nil {
|
||||
t.Fatalf("%s %q: %v", k, n, err)
|
||||
}
|
||||
v = uint64(s)
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
conv := func(in []map[string]interface{}) []controlItem {
|
||||
out := make([]controlItem, 0, len(in))
|
||||
for _, m := range in {
|
||||
var it controlItem
|
||||
it.UniqueJobID, it.HasUnique = num(m, "unique_job_id")
|
||||
it.JobID, _ = num(m, "job_id")
|
||||
idx, _ := num(m, "array_idx")
|
||||
it.ArrayIdx = int(idx)
|
||||
it.Msg, _ = m["msg"].(string)
|
||||
out = append(out, it)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return controlReply{Status: raw.Data.Status, Accepted: conv(raw.Data.Accepted), Failed: conv(raw.Data.Failed)}
|
||||
}
|
||||
|
||||
// itemIDs returns the unique_job_id of every item, in reply order.
|
||||
func itemIDs(t *testing.T, items []controlItem) []uint64 {
|
||||
t.Helper()
|
||||
ids := make([]uint64, 0, len(items))
|
||||
for _, it := range items {
|
||||
if !it.HasUnique {
|
||||
t.Errorf("result item without unique_job_id: %+v", it)
|
||||
}
|
||||
ids = append(ids, it.UniqueJobID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// assertExactIDs checks got equals want element by element (order kept).
|
||||
func assertExactIDs(t *testing.T, name string, got, want []uint64) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("%s: %v, want %v", name, got, want)
|
||||
return
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("%s: %v, want %v", name, got, want)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertSeeded fails the test unless every id is in store.Jobs, so an
|
||||
// act-on-no-job assertion cannot pass on an empty store.
|
||||
func assertSeeded(t *testing.T, ids ...uint64) {
|
||||
t.Helper()
|
||||
for _, id := range ids {
|
||||
if !store.Jobs.Has(id) {
|
||||
t.Fatalf("fixture row %d missing from store.Jobs", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// controlByIDEscaped posts a control action for a selection list, escaping
|
||||
// the path segment.
|
||||
func (f *handlerFixture) controlByIDEscaped(jobids, action string) *httptest.ResponseRecorder {
|
||||
f.t.Helper()
|
||||
return f.controlByID(url.PathEscape(jobids), action)
|
||||
}
|
||||
|
||||
// TestJobHandler_ControlTwoSelectionsDispatchOnce (bug-1899): a request with
|
||||
// two selections dispatches each selected element exactly once and lists
|
||||
// each once; the same holds for two ordinary jobs.
|
||||
func TestJobHandler_ControlTwoSelectionsDispatchOnce(t *testing.T) {
|
||||
f := newHandlerFixture(t)
|
||||
X := handlerTestArrayBase + 30<<15
|
||||
Y := handlerTestArrayBase + 31<<15
|
||||
f.seedArrayElements(X, "foo", "RUN", 1, 2)
|
||||
f.seedArrayElements(Y, "bar", "RUN", 1, 2)
|
||||
A := handlerTestOrdinaryBase + 1
|
||||
B := handlerTestOrdinaryBase + 2
|
||||
f.seedOrdinaryJob(A, "a", "RUN")
|
||||
f.seedOrdinaryJob(B, "b", "RUN")
|
||||
|
||||
x1, y2 := backend.MakeElementID(X, 1), backend.MakeElementID(Y, 2)
|
||||
r := decodeControlReply(t, f.controlByIDEscaped(jobIDString(X)+"[1],"+jobIDString(Y)+"[2]", "kill"))
|
||||
assertExactIDs(t, "X[1],Y[2] dispatched", f.dispatchedIDs(), []uint64{x1, y2})
|
||||
assertExactIDs(t, "X[1],Y[2] accepted", itemIDs(t, r.Accepted), []uint64{x1, y2})
|
||||
if len(r.Failed) != 0 {
|
||||
t.Errorf("X[1],Y[2] failed items: %+v", r.Failed)
|
||||
}
|
||||
|
||||
f.dispatched = nil
|
||||
r = decodeControlReply(t, f.controlByIDEscaped(jobIDString(A)+","+jobIDString(B), "kill"))
|
||||
assertExactIDs(t, "A,B dispatched", f.dispatchedIDs(), []uint64{A, B})
|
||||
assertExactIDs(t, "A,B accepted", itemIDs(t, r.Accepted), []uint64{A, B})
|
||||
|
||||
// a job selected by two selections is acted on once (de-duplicated by
|
||||
// unique_job_id)
|
||||
f.dispatched = nil
|
||||
x2 := backend.MakeElementID(X, 2)
|
||||
r = decodeControlReply(t, f.controlByIDEscaped(jobIDString(X)+","+jobIDString(X)+"[2]", "kill"))
|
||||
assertExactIDs(t, "X,X[2] dispatched", f.dispatchedIDs(), []uint64{x1, x2})
|
||||
assertExactIDs(t, "X,X[2] accepted", itemIDs(t, r.Accepted), []uint64{x1, x2})
|
||||
}
|
||||
|
||||
// TestJobHandler_ControlRangeReportsMissing (5.3): X[1-3] with element 2
|
||||
// missing dispatches elements 1 and 3 and reports 2 as not found, carrying
|
||||
// its would-be element ID.
|
||||
func TestJobHandler_ControlRangeReportsMissing(t *testing.T) {
|
||||
f := newHandlerFixture(t)
|
||||
X := handlerTestArrayBase + 32<<15
|
||||
f.seedArrayElements(X, "foo", "RUN", 1, 3, 4)
|
||||
x1, x2, x3 := backend.MakeElementID(X, 1), backend.MakeElementID(X, 2), backend.MakeElementID(X, 3)
|
||||
|
||||
r := decodeControlReply(t, f.controlByIDEscaped(jobIDString(X)+"[1-3]", "kill"))
|
||||
assertExactIDs(t, "dispatched", f.dispatchedIDs(), []uint64{x1, x3})
|
||||
assertExactIDs(t, "accepted", itemIDs(t, r.Accepted), []uint64{x1, x3})
|
||||
assertExactIDs(t, "failed", itemIDs(t, r.Failed), []uint64{x2})
|
||||
if len(r.Failed) == 1 {
|
||||
it := r.Failed[0]
|
||||
if it.Msg != "job not found" || it.JobID != X || it.ArrayIdx != 2 {
|
||||
t.Errorf("not-found item = %+v, want job_id %d, array_idx 2, msg \"job not found\"", it, X)
|
||||
}
|
||||
}
|
||||
if r.Status != "partially_accepted" {
|
||||
t.Errorf("status = %q, want partially_accepted", r.Status)
|
||||
}
|
||||
|
||||
// a range on an identity no array has: every number not found, no job
|
||||
f.dispatched = nil
|
||||
Z := handlerTestArrayBase + 33<<15
|
||||
r = decodeControlReply(t, f.controlByIDEscaped(jobIDString(Z)+"[1-2]", "kill"))
|
||||
if got := f.dispatchedIDs(); len(got) != 0 {
|
||||
t.Errorf("Z[1-2] dispatched %v, want none", got)
|
||||
}
|
||||
assertExactIDs(t, "Z[1-2] failed", itemIDs(t, r.Failed),
|
||||
[]uint64{backend.MakeElementID(Z, 1), backend.MakeElementID(Z, 2)})
|
||||
}
|
||||
|
||||
// TestJobHandler_ControlElementZeroMissing (5.4): X[0] with no element 0
|
||||
// acts on no job -- not on the array, not on an ordinary job with identity
|
||||
// X -- and reports element 0 as not found.
|
||||
func TestJobHandler_ControlElementZeroMissing(t *testing.T) {
|
||||
f := newHandlerFixture(t)
|
||||
X := handlerTestArrayBase + 34<<15
|
||||
elems := f.seedArrayElements(X, "foo", "RUN", 1, 2)
|
||||
// an ordinary job sharing the number X (array_idx 0)
|
||||
f.seedOrdinaryJob(X, "ordinary", "RUN")
|
||||
assertSeeded(t, uint64(elems[0].Unique_JobID), uint64(elems[1].Unique_JobID), X)
|
||||
|
||||
for _, sel := range []string{"[0]", "[0-0]"} {
|
||||
f.dispatched = nil
|
||||
r := decodeControlReply(t, f.controlByIDEscaped(jobIDString(X)+sel, "kill"))
|
||||
if got := f.dispatchedIDs(); len(got) != 0 {
|
||||
t.Errorf("X%s dispatched %v, want none", sel, got)
|
||||
}
|
||||
if len(r.Accepted) != 0 {
|
||||
t.Errorf("X%s accepted %+v, want none", sel, r.Accepted)
|
||||
}
|
||||
assertExactIDs(t, "X"+sel+" failed", itemIDs(t, r.Failed), []uint64{backend.MakeElementID(X, 0)})
|
||||
if len(r.Failed) == 1 && r.Failed[0].Msg != "job not found" {
|
||||
t.Errorf("X%s failed msg = %q, want \"job not found\"", sel, r.Failed[0].Msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJobHandler_ControlPreUpgradeElementZero (8.2, 8.3): a pre-upgrade
|
||||
// element 0 is dispatched alone, also when the same request selects another
|
||||
// element of that array, and is reported with its own unique_job_id.
|
||||
func TestJobHandler_ControlPreUpgradeElementZero(t *testing.T) {
|
||||
f := newHandlerFixture(t)
|
||||
X := handlerTestArrayBase + 35<<15
|
||||
f.seedArrayElements(X, "old", "RUN", 0, 1, 2)
|
||||
x0, x2 := backend.MakeElementID(X, 0), backend.MakeElementID(X, 2)
|
||||
|
||||
r := decodeControlReply(t, f.controlByIDEscaped(jobIDString(X)+"[0]", "kill"))
|
||||
assertExactIDs(t, "X[0] dispatched", f.dispatchedIDs(), []uint64{x0})
|
||||
assertExactIDs(t, "X[0] accepted", itemIDs(t, r.Accepted), []uint64{x0})
|
||||
|
||||
f.dispatched = nil
|
||||
r = decodeControlReply(t, f.controlByIDEscaped(jobIDString(X)+"[0],"+jobIDString(X)+"[2]", "kill"))
|
||||
assertExactIDs(t, "X[0],X[2] dispatched", f.dispatchedIDs(), []uint64{x0, x2})
|
||||
assertExactIDs(t, "X[0],X[2] accepted", itemIDs(t, r.Accepted), []uint64{x0, x2})
|
||||
}
|
||||
|
||||
// TestJobHandler_ControlValidThenInvalid (Terms): a valid selection followed
|
||||
// by an invalid one rejects the whole command with 400 and dispatches
|
||||
// nothing, without "not found" items.
|
||||
func TestJobHandler_ControlValidThenInvalid(t *testing.T) {
|
||||
f := newHandlerFixture(t)
|
||||
X := handlerTestArrayBase + 36<<15
|
||||
elems := f.seedArrayElements(X, "foo", "RUN", 1, 2)
|
||||
assertSeeded(t, uint64(elems[0].Unique_JobID))
|
||||
|
||||
cases := []struct{ sel, want string }{
|
||||
{jobIDString(X) + "[1]," + jobIDString(X) + "[40000]", "element numbers must be at most"},
|
||||
{jobIDString(X) + "[1]," + jobIDString(X) + "[3-1]", "reversed"},
|
||||
{jobIDString(X) + "[1]," + jobIDString(X) + "[a]", "invalid job identity or element selection"},
|
||||
{jobIDString(X) + "[1]," + jobIDString(uint64(elems[1].Unique_JobID)), "must be named as"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
f.dispatched = nil
|
||||
assert400(t, c.sel, f.controlByIDEscaped(c.sel, "kill"), c.want)
|
||||
if got := f.dispatchedIDs(); len(got) != 0 {
|
||||
t.Errorf("%s: dispatched %v, want none", c.sel, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue