feat(job-array-one-based-index): apiserver backend ID helpers and array ID wrapper (task 5.1)

GenerateArrayJobID wraps libxce idgen_array (first element, index 1;
one-element arrays included) with a non-cgo stub; pure helpers
ParseHumanJobIDStrict (written identity with exact field-width checks,
uint64 overflow reported as a width error), IsArrayCapableKey and
MakeElementID (bit-identical to libxce jobsel_element_id).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-10-01 11:29:11 +00:00
parent 47b9938120
commit b88e352616
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
7 changed files with 260 additions and 3 deletions

View file

@ -94,7 +94,7 @@
- _Depends: 1.1, 1.2_
- [ ] 5. API server foundation
- [ ] 5.1 Add backend ID helpers and the array ID wrapper
- [x] 5.1 Add backend ID helpers and the array ID wrapper
- Wrap the libxce array allocator for the API server, with a non-cgo stub that returns an error
- Add strict written-identity parsing with field-width checks, an array-capable key test and element-ID composition as pure helpers
- Done when the backend unit tests pass for the strict parser (width rejections), the array-capable test and element composition, and both the cgo and non-cgo builds compile
@ -243,3 +243,4 @@
- 2.2: two extra XCE_TESTING seams exist (`jobsubmit_test_release_hook`, `jobsubmit_inflight_try_add_for_test`); the controller test fixture has no WAL, so async commit-failure runs synchronously inside handle_jobsubmit — reuse it in 2.3. Stale comment at `src/controller/testsuite/test_commit_barrier_reply.c:1458` (claims release via jobsubmit_async_ctx_release) — fix in 2.3.
- 4.1: after `jobsub -a N`, `$jobid` is the array identity, so `expect-job $jobid` currently reports not found; 4.3 must make it behave per 4.7/4.9 (not-found hint naming `<array-id>[k]`, or exact-ID match). Nit for 4.3: the `refused` early return in `cmd_jobsub` sits between the bug-786 ack comment and the code it describes — move it above the comment.
- 4.3: the controller acks `setpend` (`do_set_preason`, job.c ~3869-3880) and `jobrun` (`handle_jobdispatch`, job.c ~2246-2258) with status 0 before doing the work, so those two harness commands can never report not-found; 4.7/5.13 for them depend on a controller change outside this spec's boundary. `jobinfo`'s not-found is `rc == 0` with no row (`do_reply_joblist`), not -ENOENT.
- 5.1: Go helpers: `ParseHumanJobIDStrict(s) (uint64, error)` returns sentinel `ErrJobIDSyntax` / `ErrJobIDWidth` (callers trim and split `[...]` first; no whitespace accepted); `MakeElementID(key uint64, k int) uint64`; `IsArrayCapableKey(key)`; `GenerateArrayJobID(c, a, n)` (non-cgo stub returns errNoCGO).

View file

@ -346,6 +346,25 @@ func GenerateJobID(clusterID, apiserverID int, arraySize int) (uint64, error) {
return uint64(jobID), nil
}
// GenerateArrayJobID calls the C idgen_array() to allocate a new array of n
// elements (1 <= n <= 32767, numbered from 1) and returns the first element's
// ID (tag TAG_JOB_ARRAY, index 1); ExtractArrayBaseID of it is the array ID.
// n == 1 is a one-element array, not an ordinary job (job-array-one-based-index
// 1.1, 1.2). Errors on a bad n or when the sequence block would overflow.
func GenerateArrayJobID(clusterID, apiserverID int, n int) (uint64, error) {
if n < 1 {
return 0, fmt.Errorf("idgen_array: invalid array size %d", n)
}
idCtxOnce.Do(initIDGen)
var first C.uint64_t
rc := C.idgen_array(&gIDCtx, C.size_t(clusterID), C.size_t(apiserverID),
C.size_t(n), &first)
if rc < 0 {
return 0, fmt.Errorf("idgen_array failed: %d", int(rc))
}
return uint64(first), nil
}
// Cleanup releases C resources. Call at process shutdown.
func Cleanup() {
C.idgen_ctx_fini(&gIDCtx)

View file

@ -303,3 +303,33 @@ func TestValidateResreqSyntax(t *testing.T) {
}
}
}
// job-array-one-based-index 1.1/1.2: GenerateArrayJobID wraps libxce
// idgen_array and returns the first element (index 1) of an array whose
// identity is array-capable, for n = 1 (one-element array) and n > 1.
func TestGenerateArrayJobID_FirstElementIsIndexOne(t *testing.T) {
for _, n := range []int{1, 3, 32767} {
first, err := GenerateArrayJobID(1, 2, n)
if err != nil {
t.Fatalf("GenerateArrayJobID(1, 2, %d): err=%v", n, err)
}
if !IsArrayJob(first) || ExtractArrayIdx(first) != 1 {
t.Errorf("n=%d: first=%#x, want tagged element with index 1", n, first)
}
key := ExtractArrayBaseID(first)
if !IsArrayCapableKey(key) || MakeElementID(key, 1) != first {
t.Errorf("n=%d: array identity %#x not array-capable or not the first element's", n, key)
}
if c, a := extractBits(first, clusterIDShift, nClusterIDBits), extractBits(first, apiserverIDShift, nApiserverIDBits); c != 1 || a != 2 {
t.Errorf("n=%d: cluster/apiserver = %d/%d, want 1/2", n, c, a)
}
}
}
func TestGenerateArrayJobID_RejectsBadSize(t *testing.T) {
for _, n := range []int{0, -1, 32768} {
if id, err := GenerateArrayJobID(1, 2, n); err == nil {
t.Errorf("GenerateArrayJobID(1, 2, %d): got (%#x, nil), want an error", n, id)
}
}
}

View file

@ -3,10 +3,11 @@ package backend
import (
"apiserver/models"
"encoding/json"
"errors"
"fmt"
"regexp"
"strconv"
"time"
"fmt"
)
// JobID bit layout constants — mirrors src/include/config.h.
@ -132,6 +133,66 @@ func ParseHumanJobID(s string) (uint64, bool) {
sequence, true
}
// Errors returned by ParseHumanJobIDStrict. Callers map them to their own
// messages: ErrJobIDSyntax is the written-form syntax rule, ErrJobIDWidth the
// field-width rule (job-array-one-based-index 5.14).
var (
ErrJobIDSyntax = errors.New("job ID is not of the form cluster-apiserver-timestamp-sequence")
ErrJobIDWidth = errors.New("job ID field out of range")
)
// ParseHumanJobIDStrict parses a written job identity "c-a-ts-seq" (decimal
// digits only, leading zeros allowed, no sign, no whitespace, no "[...]")
// into its packed, untagged ID. Unlike ParseHumanJobID it checks every field
// against its bit width -- cluster <= 63, apiserver <= 15, timestamp < 2^32,
// sequence < 2^20 -- so an oversized field is rejected (ErrJobIDWidth)
// instead of spilling into a neighbouring field. A field too large even for
// uint64 is also ErrJobIDWidth. Anything not of that shape is ErrJobIDSyntax.
func ParseHumanJobIDStrict(s string) (uint64, error) {
m := humanJobIDPattern.FindStringSubmatch(s)
if m == nil {
return 0, ErrJobIDSyntax
}
widths := [4]int{nClusterIDBits, nApiserverIDBits, nTimestampBits, nSequenceBits}
var f [4]uint64
for i, w := range widths {
v, err := strconv.ParseUint(m[i+1], 10, 64)
if err != nil {
if errors.Is(err, strconv.ErrRange) {
return 0, ErrJobIDWidth
}
return 0, ErrJobIDSyntax
}
if v >= uint64(1)<<w {
return 0, ErrJobIDWidth
}
f[i] = v
}
return (f[0] << clusterIDShift) |
(f[1] << apiserverIDShift) |
(f[2] << timestampShift) |
f[3], nil
}
// arrayIdxMask covers the 15-bit array index field (bits 0-14).
const arrayIdxMask = uint64(1)<<nArrayIdxBits - 1
// IsArrayCapableKey reports whether key can name an array (an "<array-id>"
// in "<array-id>[k]"): tag 0 and the low 15 (index) bits clear, i.e. the
// sequence is a multiple of 32768 (job-array-one-based-index 5.8).
func IsArrayCapableKey(key uint64) bool {
return extractIDTag(key) == 0 && key&arrayIdxMask == 0
}
// MakeElementID composes the ID of element k of the array identified by key:
// key's cluster, apiserver, timestamp and sequence-high bits, tag
// TAG_JOB_ARRAY and index k (masked to 15 bits). key's own tag and index bits
// are ignored. Mirrors jobsel_element_id() in src/lib/utils.c.
func MakeElementID(key uint64, k int) uint64 {
base := key &^ (uint64(1<<nTagBits-1)<<tagShift | arrayIdxMask)
return uint64(tagJobArray)<<tagShift | base | uint64(k)&arrayIdxMask
}
// WorkerDefaultOutputPath returns the output file path the worker uses by default
// for the given job ID and task index, matching uv_proc.c make_job_output_path().
func WorkerDefaultOutputPath(uniqueJobID int64, taskIdx int) string {

View file

@ -3,6 +3,7 @@ package backend
import (
"apiserver/models"
"encoding/json"
"errors"
"fmt"
"testing"
)
@ -99,6 +100,137 @@ func TestParseHumanJobID_RejectsGarbage(t *testing.T) {
}
}
// job-array-one-based-index 5.14: the strict written-identity parser
// accepts c-a-ts-seq only when every field fits its bit width.
func TestParseHumanJobIDStrict_AcceptsMaxWidths(t *testing.T) {
s := fmt.Sprintf("63-15-%d-%d", uint64(1)<<32-1, uint64(1)<<20-1)
got, err := ParseHumanJobIDStrict(s)
if err != nil {
t.Fatalf("ParseHumanJobIDStrict(%q): err=%v, want nil", s, err)
}
if want := makeJobID(63, 15, uint64(1)<<32-1, 0, uint64(1)<<20-1); got != want {
t.Errorf("ParseHumanJobIDStrict(%q): got %d, want %d", s, got, want)
}
}
func TestParseHumanJobIDStrict_RoundTripsWithFormatJobID(t *testing.T) {
packed := makeJobID(1, 2, 1788768547, 0, 3<<sequenceHighShift)
s := FormatJobID(int64(packed))
got, err := ParseHumanJobIDStrict(s)
if err != nil || got != packed {
t.Errorf("ParseHumanJobIDStrict(%q): got (%d, %v), want (%d, nil)", s, got, err, packed)
}
}
func TestParseHumanJobIDStrict_AcceptsLeadingZeros(t *testing.T) {
got, err := ParseHumanJobIDStrict("01-002-0003-00042")
if want := makeJobID(1, 2, 3, 0, 42); err != nil || got != want {
t.Errorf("ParseHumanJobIDStrict(leading zeros): got (%d, %v), want (%d, nil)", got, err, want)
}
}
func TestParseHumanJobIDStrict_RejectsClusterTooWide(t *testing.T) {
if _, err := ParseHumanJobIDStrict("64-0-1-0"); !errors.Is(err, ErrJobIDWidth) {
t.Errorf("cluster 64: err=%v, want ErrJobIDWidth", err)
}
}
func TestParseHumanJobIDStrict_RejectsApiserverTooWide(t *testing.T) {
if _, err := ParseHumanJobIDStrict("1-16-1-0"); !errors.Is(err, ErrJobIDWidth) {
t.Errorf("apiserver 16: err=%v, want ErrJobIDWidth", err)
}
}
func TestParseHumanJobIDStrict_RejectsTimestampTooWide(t *testing.T) {
s := fmt.Sprintf("1-0-%d-0", uint64(1)<<32)
if _, err := ParseHumanJobIDStrict(s); !errors.Is(err, ErrJobIDWidth) {
t.Errorf("timestamp 2^32: err=%v, want ErrJobIDWidth", err)
}
}
func TestParseHumanJobIDStrict_RejectsSequenceTooWide(t *testing.T) {
s := fmt.Sprintf("1-0-1-%d", uint64(1)<<20)
if _, err := ParseHumanJobIDStrict(s); !errors.Is(err, ErrJobIDWidth) {
t.Errorf("sequence 2^20: err=%v, want ErrJobIDWidth", err)
}
}
// A field too large for uint64 must be a width error, not a silent wrap
// (the ParseUint error is not discarded).
func TestParseHumanJobIDStrict_RejectsUint64Overflow(t *testing.T) {
if _, err := ParseHumanJobIDStrict("1-0-99999999999999999999999-0"); !errors.Is(err, ErrJobIDWidth) {
t.Errorf("timestamp > uint64: err=%v, want ErrJobIDWidth", err)
}
}
func TestParseHumanJobIDStrict_RejectsBadSyntax(t *testing.T) {
for _, s := range []string{"", "73933253805867008", "1-2-3", "1-2-3-4-5",
"1-2-3-4[1]", " 1-2-3-4", "1-2-3-4 ", "+1-2-3-4", "1--2-3", "a-b-c-d"} {
if _, err := ParseHumanJobIDStrict(s); !errors.Is(err, ErrJobIDSyntax) {
t.Errorf("ParseHumanJobIDStrict(%q): err=%v, want ErrJobIDSyntax", s, err)
}
}
}
// 5.8: an array identity has tag 0 and its low 15 bits clear.
func TestIsArrayCapableKey_AlignedUntagged(t *testing.T) {
if !IsArrayCapableKey(makeJobID(1, 0, 100, 0, 5<<sequenceHighShift)) {
t.Error("aligned untagged key: got false, want true")
}
if !IsArrayCapableKey(makeJobID(1, 0, 100, 0, 0)) {
t.Error("sequence 0 key: got false, want true")
}
}
func TestIsArrayCapableKey_RejectsMisaligned(t *testing.T) {
if IsArrayCapableKey(makeJobID(1, 0, 100, 0, 5<<sequenceHighShift|1)) {
t.Error("key with low bit set: got true, want false")
}
if IsArrayCapableKey(makeJobID(1, 0, 100, 0, 1<<14)) {
t.Error("key with bit 14 set: got true, want false")
}
}
func TestIsArrayCapableKey_RejectsTagged(t *testing.T) {
if IsArrayCapableKey(makeJobID(1, 0, 100, tagJobArray, 5<<sequenceHighShift)) {
t.Error("tagged key: got true, want false")
}
}
func TestMakeElementID_ComposesTaggedElement(t *testing.T) {
key := makeJobID(3, 2, 1788768547, 0, 7<<sequenceHighShift)
got := MakeElementID(key, 5)
if want := makeJobID(3, 2, 1788768547, tagJobArray, 7<<sequenceHighShift|5); got != want {
t.Fatalf("MakeElementID: got %#x, want %#x", got, want)
}
if !IsArrayJob(got) || ExtractArrayIdx(got) != 5 || ExtractArrayBaseID(got) != key {
t.Errorf("MakeElementID(%#x, 5) = %#x does not round-trip (tag/idx/base)", key, got)
}
if s, want := FormatJobID(int64(got)), FormatJobID(int64(key))+"[5]"; s != want {
t.Errorf("FormatJobID(element): got %q, want %q", s, want)
}
}
func TestMakeElementID_MaxIndexAndZero(t *testing.T) {
key := makeJobID(1, 0, 100, 0, 1<<sequenceHighShift)
if got := ExtractArrayIdx(MakeElementID(key, 32767)); got != 32767 {
t.Errorf("element 32767: idx=%d, want 32767", got)
}
el0 := MakeElementID(key, 0)
if !IsArrayJob(el0) || ExtractArrayIdx(el0) != 0 {
t.Errorf("element 0: got %#x, want tagged with idx 0", el0)
}
}
// Mirrors jobsel_element_id: the key's own tag/index bits never leak in.
func TestMakeElementID_IgnoresKeyIndexAndTagBits(t *testing.T) {
clean := makeJobID(1, 0, 100, 0, 2<<sequenceHighShift)
dirty := clean | uint64(tagJobArray)<<tagShift | 9
if got, want := MakeElementID(dirty, 4), MakeElementID(clean, 4); got != want {
t.Errorf("MakeElementID(dirty key): got %#x, want %#x", got, want)
}
}
func TestRawJobToModel_BasicFields(t *testing.T) {
r := rawJobData{
jobID: 123,

View file

@ -21,6 +21,11 @@ func GenerateJobID(clusterID, apiserverID int, arraySize int) (uint64, error) {
return 0, errNoCGO
}
// GenerateArrayJobID stub — real implementation in clib.go (requires CGO).
func GenerateArrayJobID(clusterID, apiserverID int, n int) (uint64, error) {
return 0, errNoCGO
}
// SubmitJob stub — real implementation in clib.go (requires CGO).
func SubmitJob(job *models.JobInfo, arraySize int) error { return errNoCGO }

View file

@ -56,4 +56,13 @@ func TestRegisterToBackend_StubWithPeers(t *testing.T) {
if err != errNoCGO {
t.Errorf("expected errNoCGO with peers configured, got %v", err)
}
}
}
// TestGenerateArrayJobID_StubErrNoCGO verifies the no-CGO stub for
// GenerateArrayJobID returns errNoCGO and no ID (job-array-one-based-index 5.1).
func TestGenerateArrayJobID_StubErrNoCGO(t *testing.T) {
id, err := GenerateArrayJobID(1, 1, 3)
if err != errNoCGO || id != 0 {
t.Errorf("GenerateArrayJobID stub: got (%d, %v), want (0, errNoCGO)", id, err)
}
}