myxce/src/frontend/apiserver/backend/convert_test.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

959 lines
28 KiB
Go

package backend
import (
"apiserver/models"
"testing"
)
// TestJobCtrlDiscriminant is a regression test for bug-1490: backend.ControlJob
// used to set xce_jobctrl_t.action to the models.JOB_* constant
// (JOB_STOP=1/JOB_RESUME=2/JOB_KILL=3/JOB_CHKPNT=8), but that field is the
// capnp JobControl union DISCRIMINANT (JobControl_u_siginfo=0,
// JobControl_u_ckptoption=1). The controller's job_ctrl() interpreted the wrong
// union branch (or none) and rejected every control request with -EINVAL, so
// hold/release/cancel never reached the job.
func TestJobCtrlDiscriminant(t *testing.T) {
cases := []struct {
name string
action int
want int
}{
{"stop is a signal action", models.JOB_STOP, 0}, // JobControl_u_siginfo
{"resume is a signal action", models.JOB_RESUME, 0},
{"kill is a signal action", models.JOB_KILL, 0},
{"checkpoint is a ckptoption action", models.JOB_CHKPNT, 1}, // JobControl_u_ckptoption
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := jobCtrlDiscriminant(tc.action); got != tc.want {
t.Errorf("jobCtrlDiscriminant(%d) = %d, want %d", tc.action, got, tc.want)
}
})
}
}
func TestRawJobToModel_BasicFields(t *testing.T) {
r := rawJobData{
jobID: 123,
state: 2, // JOB_STAT_RUN
runTime: 42,
submit: &rawSubmitData{
queue: "normal",
user: "alice",
jobName: "myjob",
minTasks: 1,
maxTasks: 4,
startInfo: &rawStartInfo{
cmdline: "sleep 60",
},
},
}
info := rawJobToModel(r)
if info.Unique_JobID != 123 {
t.Errorf("Unique_JobID: got %d, want 123", info.Unique_JobID)
}
if info.JobID != 123 {
t.Errorf("JobID: got %d, want 123", info.JobID)
}
if info.QueueName != "normal" {
t.Errorf("QueueName: got %q, want %q", info.QueueName, "normal")
}
if info.SubmitUser != "alice" {
t.Errorf("SubmitUser: got %q, want %q", info.SubmitUser, "alice")
}
if info.JobName != "myjob" {
t.Errorf("JobName: got %q, want %q", info.JobName, "myjob")
}
if info.Command != "sleep 60" {
t.Errorf("Command: got %q, want %q", info.Command, "sleep 60")
}
if info.JobStatus != 2 {
t.Errorf("JobStatus: got %d, want 2", info.JobStatus)
}
if info.NumProcessors != 1 {
t.Errorf("NumProcessors: got %d, want 1", info.NumProcessors)
}
if info.MaxNumProcessors != 4 {
t.Errorf("MaxNumProcessors: got %d, want 4", info.MaxNumProcessors)
}
if info.RunTime != 42 {
t.Errorf("RunTime: got %d, want 42", info.RunTime)
}
}
func TestRawJobToModel_NilSubmit(t *testing.T) {
r := rawJobData{jobID: 99, state: 1 /* JOB_STAT_PEND */}
info := rawJobToModel(r)
if info.Unique_JobID != 99 {
t.Errorf("Unique_JobID: got %d, want 99", info.Unique_JobID)
}
if info.QueueName != "" {
t.Errorf("QueueName should be empty for nil submit, got %q", info.QueueName)
}
}
func TestRawJobToModel_Timestamps(t *testing.T) {
r := rawJobData{
jobID: 1,
subTime: 1700000000,
queueTime: 1700000010,
startTime: 1700000020,
finishTime: 1700000030,
}
info := rawJobToModel(r)
if info.SubmitTime != 1700000000 {
t.Errorf("SubmitTime: got %d, want 1700000000", info.SubmitTime)
}
if info.QueueTime != 1700000010 {
t.Errorf("QueueTime: got %d, want 1700000010", info.QueueTime)
}
if info.StartTime != 1700000020 {
t.Errorf("StartTime: got %d, want 1700000020", info.StartTime)
}
if info.EndTime != 1700000030 {
t.Errorf("EndTime: got %d, want 1700000030", info.EndTime)
}
}
func TestRawWorkerToModel_BasicFields(t *testing.T) {
r := rawWorkerData{
name: "node01",
status: 3, // XCE_WORKER_UNAVAIL
hasAttrs: true,
hostType: "linux",
hostModel: "x86_64",
cpuFactor: 1.5,
maxCPUs: 16,
maxMem: 65536,
maxSwap: 32768,
maxTmp: 1024,
nDisks: 2,
}
info := rawWorkerToModel(r)
if info.HostName != "node01" {
t.Errorf("HostName: got %q, want %q", info.HostName, "node01")
}
if info.HostType != "linux" {
t.Errorf("HostType: got %q, want %q", info.HostType, "linux")
}
if info.HostModel != "x86_64" {
t.Errorf("HostModel: got %q, want %q", info.HostModel, "x86_64")
}
if info.NumCPUs != 16 {
t.Errorf("NumCPUs: got %d, want 16", info.NumCPUs)
}
if info.MaxMem != 65536 {
t.Errorf("MaxMem: got %d, want 65536", info.MaxMem)
}
if info.NumDisk != 2 {
t.Errorf("NumDisk: got %d, want 2", info.NumDisk)
}
if info.HostStatus != 3 {
t.Errorf("HostStatus: got %d, want 3", info.HostStatus)
}
}
func TestRawWorkerToModel_NoAttrs(t *testing.T) {
r := rawWorkerData{name: "node02", status: 1, hasAttrs: false}
info := rawWorkerToModel(r)
if info.HostName != "node02" {
t.Errorf("HostName: got %q, want node02", info.HostName)
}
if info.HostType != "" {
t.Errorf("HostType should be empty when hasAttrs=false, got %q", info.HostType)
}
if info.NumCPUs != 0 {
t.Errorf("NumCPUs should be 0 when hasAttrs=false, got %d", info.NumCPUs)
}
}
func TestRawQueueToModel_BasicFields(t *testing.T) {
r := rawQueueData{
name: "normal",
status: 1, // QUEUE_STAT_OPEN
priority: 30,
}
info := rawQueueToModel(r)
if info.QueueName != "normal" {
t.Errorf("QueueName: got %q, want %q", info.QueueName, "normal")
}
if info.Priority != 30 {
t.Errorf("Priority: got %d, want 30", info.Priority)
}
if info.QueueStatus != 1 {
t.Errorf("QueueStatus: got %d, want 1", info.QueueStatus)
}
}
func TestRawResourceToModel_BasicFields(t *testing.T) {
r := rawResourceData{
name: "mem",
desc: "memory resource",
valType: 1, // XCE_VAL_NUMERIC
flags: 4, // XCE_RESF_CONSUMABLE
interval: 60,
}
info := rawResourceToModel(r)
if info.ResourceName != "mem" {
t.Errorf("ResourceName: got %q, want %q", info.ResourceName, "mem")
}
if info.Description != "memory resource" {
t.Errorf("Description: got %q, want %q", info.Description, "memory resource")
}
if info.ValueType != 1 {
t.Errorf("ValueType: got %d, want 1", info.ValueType)
}
if info.Flag != 4 {
t.Errorf("Flag: got %d, want 4", info.Flag)
}
if info.Interval != 60 {
t.Errorf("Interval: got %d, want 60", info.Interval)
}
}
// makeJobID mirrors the C MAKE_JOBID macro in src/include/utils.h.
// Used to construct known 64-bit job IDs for testing.
func makeJobID(cluster, apiserver, timestamp, tag, sequence uint64) uint64 {
return (cluster << clusterIDShift) |
(apiserver << apiserverIDShift) |
(timestamp << timestampShift) |
(tag << tagShift) |
sequence
}
func TestExtractBits(t *testing.T) {
// 0b1111 shifted left by 4 = 0b11110000 = 240
id := uint64(0xF0)
if got := extractBits(id, 4, 4); got != 0xF {
t.Errorf("extractBits(0xF0, 4, 4): got %d, want 15", got)
}
}
func TestExtractIDTag_NonArray(t *testing.T) {
id := makeJobID(1, 0, 1700000000, 0, 5)
if tag := extractIDTag(id); tag != 0x00 {
t.Errorf("tag for non-array job: got %d, want 0", tag)
}
}
func TestExtractIDTag_Array(t *testing.T) {
id := makeJobID(1, 0, 1700000000, tagJobArray, 5)
if tag := extractIDTag(id); tag != tagJobArray {
t.Errorf("tag for array job: got %d, want %d", tag, tagJobArray)
}
}
func TestExtractArrayBaseID_NonArray(t *testing.T) {
// Non-array job: base ID should equal the original ID
id := makeJobID(1, 0, 1700000000, 0, 5)
base := ExtractArrayBaseID(id)
if base != id {
t.Errorf("non-array base ID should equal original: got %d, want %d", base, id)
}
idx := ExtractArrayIdx(id)
if idx != 0 {
t.Errorf("non-array index should be 0: got %d", idx)
}
}
func TestExtractArrayBaseID_ArrayChild(t *testing.T) {
// Array job: cluster=1, apiserver=0, timestamp=1700000000
// The sequence for array jobs has the "job number" in high bits.
// sequence = jobNumber << sequenceHighShift + arrayIdx
// e.g. jobNumber=2, arrayIdx=3 → sequence = 2<<15 + 3 = 65539
jobNumber := uint64(2)
arrayIdx := uint64(3)
sequence := jobNumber<<sequenceHighShift + arrayIdx
id := makeJobID(1, 0, 1700000000, tagJobArray, sequence)
base := ExtractArrayBaseID(id)
// Base ID should have tag=0, same cluster/apiserver/timestamp, and arrayJob in sequence
expectedBase := makeJobID(1, 0, 1700000000, 0, jobNumber<<sequenceHighShift)
if base != expectedBase {
t.Errorf("array base ID mismatch: got %d, want %d", base, expectedBase)
}
idx := ExtractArrayIdx(id)
if idx != int(arrayIdx) {
t.Errorf("array index: got %d, want %d", idx, arrayIdx)
}
}
func TestExtractArrayIdx_MaxIndex(t *testing.T) {
// Maximum array index = 32767 (2^15 - 1)
maxIdx := uint64((1 << nArrayIdxBits) - 1)
sequence := uint64(1)<<sequenceHighShift + maxIdx
id := makeJobID(1, 0, 1700000000, tagJobArray, sequence)
idx := ExtractArrayIdx(id)
if idx != int(maxIdx) {
t.Errorf("max array index: got %d, want %d", idx, maxIdx)
}
}
func TestExtractArrayBaseID_RoundTrip(t *testing.T) {
// ExtractArrayBaseID and ExtractArrayIdx together should preserve enough
// information to reconstruct the original ID.
// Reconstruction requires re-encoding with tag=TAG_JOB_ARRAY.
jobNumber := uint64(7)
arrayIdx := uint64(42)
sequence := jobNumber<<sequenceHighShift + arrayIdx
id := makeJobID(1, 0, 1700000000, tagJobArray, sequence)
base := ExtractArrayBaseID(id)
idx := ExtractArrayIdx(id)
// Reconstruct: base ID sequence + array index, with array tag set
reconstructedSeq := (extractBits(base, sequenceHighShift, nSequenceBits-nArrayIdxBits) << sequenceHighShift) + uint64(idx)
reconstructed := makeJobID(
extractBits(base, clusterIDShift, nClusterIDBits),
extractBits(base, apiserverIDShift, nApiserverIDBits),
extractBits(base, timestampShift, nTimestampBits),
tagJobArray,
reconstructedSeq,
)
if reconstructed != id {
t.Errorf("round-trip failed: reconstructed %d, want %d", reconstructed, id)
}
}
func TestRawJobToModel_NonArrayJob(t *testing.T) {
// Non-array job: JobID == Unique_JobID, ArrayIdx == 0
id := makeJobID(1, 0, 1700000000, 0, 5)
r := rawJobData{jobID: int64(id), state: 2}
info := rawJobToModel(r)
if info.Unique_JobID != int64(id) {
t.Errorf("Unique_JobID: got %d, want %d", info.Unique_JobID, id)
}
if info.JobID != int64(id) {
t.Errorf("JobID for non-array should equal Unique_JobID: got %d, want %d", info.JobID, id)
}
if info.ArrayIdx != 0 {
t.Errorf("ArrayIdx for non-array should be 0: got %d", info.ArrayIdx)
}
}
func TestRawJobToModel_ArrayChild(t *testing.T) {
// Array child: JobID == baseID, ArrayIdx == arrayIdx
jobNumber := uint64(2)
arrayIdx := uint64(5)
sequence := jobNumber<<sequenceHighShift + arrayIdx
id := makeJobID(1, 0, 1700000000, tagJobArray, sequence)
baseID := makeJobID(1, 0, 1700000000, 0, jobNumber<<sequenceHighShift)
r := rawJobData{jobID: int64(id), state: 2}
info := rawJobToModel(r)
if info.Unique_JobID != int64(id) {
t.Errorf("Unique_JobID: got %d, want %d", info.Unique_JobID, id)
}
if info.JobID != int64(baseID) {
t.Errorf("JobID for array child should be base ID: got %d, want %d", info.JobID, baseID)
}
if info.ArrayIdx != int(arrayIdx) {
t.Errorf("ArrayIdx for array child: got %d, want %d", info.ArrayIdx, arrayIdx)
}
}
func TestRawJobToModel_ArrayChildrenShareBaseID(t *testing.T) {
// All array children should share the same base JobID
jobNumber := uint64(3)
baseID := makeJobID(1, 0, 1700000000, 0, jobNumber<<sequenceHighShift)
for idx := 0; idx < 5; idx++ {
sequence := jobNumber<<sequenceHighShift + uint64(idx)
id := makeJobID(1, 0, 1700000000, tagJobArray, sequence)
r := rawJobData{jobID: int64(id)}
info := rawJobToModel(r)
if info.JobID != int64(baseID) {
t.Errorf("array child %d: JobID %d should equal base ID %d", idx, info.JobID, baseID)
}
if info.ArrayIdx != idx {
t.Errorf("array child %d: ArrayIdx got %d, want %d", idx, info.ArrayIdx, idx)
}
if info.Unique_JobID != int64(id) {
t.Errorf("array child %d: Unique_JobID should be unique per child", idx)
}
}
}
// TestExtractArrayBaseID_LargeTimestamp verifies that ExtractArrayBaseID does not
// leak timestamp bits into the extracted sequence when the timestamp field has
// large values. This would fail with nSequenceBits=30 (extracts 15 bits from
// position 15, bleeding into the timestamp at bits 20-51) but passes with the
// correct nSequenceBits=20 (extracts 5 bits from position 15).
func TestExtractArrayBaseID_LargeTimestamp(t *testing.T) {
jobNumber := uint64(7)
arrayIdx := uint64(3)
sequence := jobNumber<<sequenceHighShift + arrayIdx
// Use a typical Unix-epoch timestamp (bits 20-51 should stay in timestamp)
id := makeJobID(1, 0, 1714560000, tagJobArray, sequence)
base := ExtractArrayBaseID(id)
// The base ID must have the same cluster, apiserver, and timestamp —
// no timestamp bits should leak into the sequence field.
gotCluster := extractBits(base, clusterIDShift, nClusterIDBits)
gotApiserver := extractBits(base, apiserverIDShift, nApiserverIDBits)
gotTimestamp := extractBits(base, timestampShift, nTimestampBits)
if gotCluster != 1 {
t.Errorf("cluster ID leaked: got %d, want 1", gotCluster)
}
if gotApiserver != 0 {
t.Errorf("apiserver ID leaked: got %d, want 0", gotApiserver)
}
if gotTimestamp != 1714560000 {
t.Errorf("timestamp corrupted: got %d, want 1714560000", gotTimestamp)
}
// The base ID's sequence should be just jobNumber<<sequenceHighShift (idx=0, tag=0)
expectedBase := makeJobID(1, 0, 1714560000, 0, jobNumber<<sequenceHighShift)
if base != expectedBase {
t.Errorf("base ID mismatch with large timestamp: got %d, want %d", base, expectedBase)
}
}
func TestIsArrayJob(t *testing.T) {
// Non-array job (tag=0x00): IsArrayJob returns false
nonArrayID := makeJobID(1, 0, 1700000000, 0, 5)
if IsArrayJob(nonArrayID) {
t.Errorf("non-array job should not be an array job")
}
// Array child (tag=0x01): IsArrayJob returns true
arrayID := makeJobID(1, 0, 1700000000, tagJobArray, 5)
if !IsArrayJob(arrayID) {
t.Errorf("array job child should be an array job")
}
// Array child with idx=0: IsArrayJob returns true
jobNumber := uint64(2)
sequence := jobNumber<<sequenceHighShift + 0 // arrayIdx=0
arrayIdx0ID := makeJobID(1, 0, 1700000000, tagJobArray, sequence)
if !IsArrayJob(arrayIdx0ID) {
t.Errorf("array child with idx=0 should still be an array job")
}
}
func TestRawHostConfigToModel(t *testing.T) {
tests := []struct {
name string
raw rawHostConfigData
want models.HostInfo
}{
{
name: "normal values",
raw: rawHostConfigData{
name: "node01",
hostmodel: "x86_64",
hosttype: "LINUX64",
mxj: 8,
},
want: models.HostInfo{
HostName: "node01",
HostModel: "x86_64",
HostType: "LINUX64",
MaxSlot: 8,
},
},
{
name: "zero values for integer fields",
raw: rawHostConfigData{
name: "node02",
hostmodel: "modelB",
hosttype: "linux",
mxj: 0,
},
want: models.HostInfo{
HostName: "node02",
HostModel: "modelB",
HostType: "linux",
MaxSlot: 0,
},
},
{
name: "empty strings for name/hostmodel/hosttype",
raw: rawHostConfigData{
name: "",
hostmodel: "",
hosttype: "",
mxj: 4,
},
want: models.HostInfo{
HostName: "",
HostModel: "",
HostType: "",
MaxSlot: 4,
},
},
{
name: "worker-sourced fields remain zero",
raw: rawHostConfigData{
name: "node03",
hostmodel: "modelC",
hosttype: "CENTOS7",
mxj: 16,
},
want: models.HostInfo{
HostName: "node03",
HostModel: "modelC",
HostType: "CENTOS7",
MaxSlot: 16,
HostStatus: 0,
CPUFactor: 0,
MaxMem: 0,
NumRun: 0,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := rawHostConfigToModel(tt.raw)
if got.HostName != tt.want.HostName {
t.Errorf("HostName: got %q, want %q", got.HostName, tt.want.HostName)
}
if got.HostModel != tt.want.HostModel {
t.Errorf("HostModel: got %q, want %q", got.HostModel, tt.want.HostModel)
}
if got.HostType != tt.want.HostType {
t.Errorf("HostType: got %q, want %q", got.HostType, tt.want.HostType)
}
if got.MaxSlot != tt.want.MaxSlot {
t.Errorf("MaxSlot: got %d, want %d", got.MaxSlot, tt.want.MaxSlot)
}
// Verify worker-sourced fields stay zero
if got.HostStatus != tt.want.HostStatus {
t.Errorf("HostStatus: got %d, want %d (should be zero, worker-sourced)", got.HostStatus, tt.want.HostStatus)
}
if got.CPUFactor != tt.want.CPUFactor {
t.Errorf("CPUFactor: got %f, want %f (should be zero, worker-sourced)", got.CPUFactor, tt.want.CPUFactor)
}
if got.MaxMem != tt.want.MaxMem {
t.Errorf("MaxMem: got %d, want %d (should be zero, worker-sourced)", got.MaxMem, tt.want.MaxMem)
}
if got.NumRun != tt.want.NumRun {
t.Errorf("NumRun: got %d, want %d (should be zero, worker-sourced)", got.NumRun, tt.want.NumRun)
}
// DisptWindow is deferred — must be empty string, not accidentally set
if got.DisptWindow != "" {
t.Errorf("DisptWindow: got %q, want empty string (deferred field)", got.DisptWindow)
}
})
}
}
func TestRawResvalToDistribution_FloatValue(t *testing.T) {
r := rawResvalData{
name: "r1m",
host: "node01",
valWhich: 5, // Value_u_fvalue
fval: 3.14,
}
got := rawResvalToDistribution(r)
if got == nil {
t.Fatal("expected non-nil result for fvalue")
}
if got.ResourceName != "r1m" {
t.Errorf("ResourceName: got %q, want %q", got.ResourceName, "r1m")
}
if got.Location != "node01" {
t.Errorf("Location: got %q, want %q", got.Location, "node01")
}
if !got.IsDynamic {
t.Errorf("IsDynamic: got false, want true")
}
if got.ValueType != int(models.NUMERIC) {
t.Errorf("ValueType: got %d, want %d (NUMERIC)", got.ValueType, models.NUMERIC)
}
if got.NumericValue != 3.14 {
t.Errorf("NumericValue: got %f, want 3.14", got.NumericValue)
}
}
func TestRawResvalToDistribution_StringValue(t *testing.T) {
r := rawResvalData{
name: "type",
host: "node02",
valWhich: 4, // Value_u_svalue
sval: "LINUX64",
}
got := rawResvalToDistribution(r)
if got == nil {
t.Fatal("expected non-nil result for svalue")
}
if got.ValueType != int(models.STRING) {
t.Errorf("ValueType: got %d, want %d (STRING)", got.ValueType, models.STRING)
}
if got.StringValue != "LINUX64" {
t.Errorf("StringValue: got %q, want %q", got.StringValue, "LINUX64")
}
}
func TestRawResvalToDistribution_BooleanValue(t *testing.T) {
r := rawResvalData{
name: "available",
host: "node03",
valWhich: 6, // Value_u_bvalue
bval: true,
}
got := rawResvalToDistribution(r)
if got == nil {
t.Fatal("expected non-nil result for bvalue")
}
if got.ValueType != int(models.BOOLEAN) {
t.Errorf("ValueType: got %d, want %d (BOOLEAN)", got.ValueType, models.BOOLEAN)
}
if !got.BooleanValue {
t.Errorf("BooleanValue: got false, want true")
}
}
func TestRawResvalToDistribution_Int32Value(t *testing.T) {
r := rawResvalData{
name: "slots",
host: "node04",
valWhich: 0, // Value_u_ivalue32
i32: 16,
}
got := rawResvalToDistribution(r)
if got == nil {
t.Fatal("expected non-nil result for ivalue32")
}
if got.ValueType != int(models.NUMERIC) {
t.Errorf("ValueType: got %d, want %d (NUMERIC)", got.ValueType, models.NUMERIC)
}
if got.NumericValue != 16 {
t.Errorf("NumericValue: got %f, want 16", got.NumericValue)
}
}
func TestRawResvalToDistribution_Int64Value(t *testing.T) {
r := rawResvalData{
name: "bigcounter",
host: "node05",
valWhich: 2, // Value_u_ivalue64
i64: 9223372036854775807, // max int64
}
got := rawResvalToDistribution(r)
if got == nil {
t.Fatal("expected non-nil result for ivalue64")
}
if got.ValueType != int(models.NUMERIC) {
t.Errorf("ValueType: got %d, want %d (NUMERIC)", got.ValueType, models.NUMERIC)
}
if got.NumericValue != float64(int64(9223372036854775807)) {
t.Errorf("NumericValue: got %f, want max int64 as float64", got.NumericValue)
}
}
func TestRawResvalToDistribution_Uint32Value(t *testing.T) {
r := rawResvalData{
name: "uint32res",
host: "node06",
valWhich: 1, // Value_u_uvalue32
u32: 4294967295, // max uint32
}
got := rawResvalToDistribution(r)
if got == nil {
t.Fatal("expected non-nil result for uvalue32")
}
if got.ValueType != int(models.NUMERIC) {
t.Errorf("ValueType: got %d, want %d", got.ValueType, models.NUMERIC)
}
if got.NumericValue != float64(uint32(4294967295)) {
t.Errorf("NumericValue: got %f, want 4294967295", got.NumericValue)
}
}
func TestRawResvalToDistribution_Uint64Value(t *testing.T) {
r := rawResvalData{
name: "uint64res",
host: "node07",
valWhich: 3, // Value_u_uvalue64
u64: 18446744073709551615, // max uint64
}
got := rawResvalToDistribution(r)
if got == nil {
t.Fatal("expected non-nil result for uvalue64")
}
if got.ValueType != int(models.NUMERIC) {
t.Errorf("ValueType: got %d, want %d", got.ValueType, models.NUMERIC)
}
}
func TestRawResvalToDistribution_NilVal(t *testing.T) {
r := rawResvalData{
name: "nilres",
host: "node08",
valNil: true,
}
got := rawResvalToDistribution(r)
if got != nil {
t.Errorf("expected nil for nil val, got %+v", got)
}
}
func TestRawResvalToDistribution_UnknownDiscriminant(t *testing.T) {
r := rawResvalData{
name: "unkres",
host: "node09",
valWhich: 99, // unknown
}
got := rawResvalToDistribution(r)
if got != nil {
t.Errorf("expected nil for unknown discriminant, got %+v", got)
}
}
func TestRawResvalToDistribution_BooleanFalse(t *testing.T) {
r := rawResvalData{
name: "available",
host: "node10",
valWhich: 6, // Value_u_bvalue
bval: false,
}
got := rawResvalToDistribution(r)
if got == nil {
t.Fatal("expected non-nil result for bvalue false")
}
if got.ValueType != int(models.BOOLEAN) {
t.Errorf("ValueType: got %d, want %d (BOOLEAN)", got.ValueType, models.BOOLEAN)
}
if got.BooleanValue {
t.Errorf("BooleanValue: got true, want false")
}
}
// --- job-array-single-wal-record task 5.4: rawJobArrayToModels ---
//
// Sibling to cJobToModel's local-expansion contract: given one compact
// (base jobid, array_size, shared spec) descriptor, expand it into N
// models.JobInfo entries using base_jobid + i arithmetic — the same
// arithmetic the controller's restore_job_array() and the scheduler's
// sched_on_job_array() use to derive each element's identity (Req 6.2,
// 6.5). Pure Go (no CGO), mirrors rawJobToModel's existing split.
func TestRawJobArrayToModels_ExpandsNElementsWithCorrectIdentity(t *testing.T) {
jobNumber := uint64(9)
baseJobID := makeJobID(1, 0, 1700000000, tagJobArray, jobNumber<<sequenceHighShift)
// ExtractArrayBaseID's reconstruction always carries tag=0 (see
// TestRawJobToModel_ArrayChild's identical precedent) — it is NOT
// byte-identical to baseJobID itself, whose tag bits are tagJobArray.
wantBaseID := makeJobID(1, 0, 1700000000, 0, jobNumber<<sequenceHighShift)
submit := &rawSubmitData{
queue: "normal",
user: "alice",
jobName: "arrjob",
minTasks: 1,
maxTasks: 1,
}
got := rawJobArrayToModels(baseJobID, 5, submit)
if len(got) != 5 {
t.Fatalf("expected 5 elements, got %d", len(got))
}
for i, info := range got {
wantUnique := int64(baseJobID + uint64(i))
if info.Unique_JobID != wantUnique {
t.Errorf("element %d: Unique_JobID got %d, want %d", i, info.Unique_JobID, wantUnique)
}
if info.JobID != int64(wantBaseID) {
t.Errorf("element %d: JobID (array base) got %d, want %d", i, info.JobID, wantBaseID)
}
if info.ArrayIdx != i {
t.Errorf("element %d: ArrayIdx got %d, want %d", i, info.ArrayIdx, i)
}
if info.QueueName != "normal" {
t.Errorf("element %d: QueueName got %q, want normal", i, info.QueueName)
}
if info.SubmitUser != "alice" {
t.Errorf("element %d: SubmitUser got %q, want alice", i, info.SubmitUser)
}
}
}
func TestRawJobArrayToModels_ZeroArraySizeReturnsNil(t *testing.T) {
got := rawJobArrayToModels(1000, 0, &rawSubmitData{queue: "normal"})
if got != nil {
t.Errorf("expected nil for array_size=0, got %d elements", len(got))
}
}
func TestRawJobArrayToModels_NilSubmitReturnsNil(t *testing.T) {
got := rawJobArrayToModels(1000, 5, nil)
if got != nil {
t.Errorf("expected nil for nil submit, got %d elements", len(got))
}
}
func TestRawClusterToModel(t *testing.T) {
tests := []struct {
name string
raw rawClusterData
want models.ClusterInfo
}{
{
name: "normal values",
raw: rawClusterData{
name: "mycluster",
heartbeatInterval: 30,
vcpus: 16,
inactiveHeartbeatCount: 3,
},
want: models.ClusterInfo{
ClusterName: "mycluster",
HeartbeatInterval: 30,
Vcpus: 16,
InactiveHeartbeatCount: 3,
},
},
{
name: "empty cluster name maps through unchanged (guard is in CGO layer)",
raw: rawClusterData{
name: "",
heartbeatInterval: 60,
vcpus: 8,
inactiveHeartbeatCount: 5,
},
want: models.ClusterInfo{
ClusterName: "",
HeartbeatInterval: 60,
Vcpus: 8,
InactiveHeartbeatCount: 5,
},
},
{
name: "built-in fields remain zero",
raw: rawClusterData{
name: "test",
heartbeatInterval: 45,
vcpus: 32,
inactiveHeartbeatCount: 2,
},
want: models.ClusterInfo{
ClusterName: "test",
HeartbeatInterval: 45,
Vcpus: 32,
InactiveHeartbeatCount: 2,
Version: "",
Status: 0,
ControllerName: "",
NumServers: 0,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := rawClusterToModel(tt.raw)
if got.ClusterName != tt.want.ClusterName {
t.Errorf("ClusterName: got %q, want %q", got.ClusterName, tt.want.ClusterName)
}
if got.HeartbeatInterval != tt.want.HeartbeatInterval {
t.Errorf("HeartbeatInterval: got %d, want %d", got.HeartbeatInterval, tt.want.HeartbeatInterval)
}
if got.Vcpus != tt.want.Vcpus {
t.Errorf("Vcpus: got %d, want %d", got.Vcpus, tt.want.Vcpus)
}
if got.InactiveHeartbeatCount != tt.want.InactiveHeartbeatCount {
t.Errorf("InactiveHeartbeatCount: got %d, want %d", got.InactiveHeartbeatCount, tt.want.InactiveHeartbeatCount)
}
// Verify built-in/sourced fields stay zero (not overwritten by config push)
if got.Version != tt.want.Version {
t.Errorf("Version: got %q, want %q (should be empty, built-in-sourced)", got.Version, tt.want.Version)
}
if got.Status != tt.want.Status {
t.Errorf("Status: got %d, want %d (should be zero, built-in-sourced)", got.Status, tt.want.Status)
}
if got.ControllerName != tt.want.ControllerName {
t.Errorf("ControllerName: got %q, want %q (should be empty, built-in-sourced)", got.ControllerName, tt.want.ControllerName)
}
if got.NumServers != tt.want.NumServers {
t.Errorf("NumServers: got %d, want %d (should be zero, built-in-sourced)", got.NumServers, tt.want.NumServers)
}
})
}
}
// bug-1500: the submit conversion must carry an exec user; explicit exec_user
// wins, otherwise the job runs as the submitting user (not "nobody").
func TestResolveExecUser(t *testing.T) {
cases := []struct {
name string
execUser string
submitUser string
want string
}{
{"explicit exec_user wins", "lchen", "admin", "lchen"},
{"empty exec_user falls back to submit user", "", "admin", "admin"},
{"both empty stays empty", "", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
job := &models.JobInfo{SubmitUser: tc.submitUser, ExecUser: tc.execUser}
if got := resolveExecUser(job); got != tc.want {
t.Errorf("resolveExecUser: got %q, want %q", got, tc.want)
}
})
}
}
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)
}
}