xcecli hosts list showed NUM_RUN/NUM_SSUSP/NUM_USUSP as always 0 -- the fields existed on HostInfo and were displayed, but nothing ever assigned them. processHostData now derives per-host slot usage from the live job store (exec_hosts + job_status), counting occupied slots so the numbers are comparable to MAX_SLOT. Partial fix for gitea#46 / bug-1743: the separate "dispatch stops at 8/16" question needs the reporter's live limits.yaml/pend-reason to confirm a usergroup/queue slot limit, not addressed here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y4M5MJM2zK6nU43da267Ku
199 lines
5.8 KiB
Go
199 lines
5.8 KiB
Go
package controllers
|
|
|
|
import (
|
|
"apiserver/models"
|
|
"apiserver/store"
|
|
"apiserver/utils"
|
|
"sync"
|
|
"testing"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func resetHostControlState() {
|
|
store.InitStores()
|
|
utils.Log = zap.NewNop()
|
|
}
|
|
|
|
// TestPreCheckHostControlRequest_AllFlag verifies that when hostname is ["all"],
|
|
// the returned bool is true.
|
|
func TestPreCheckHostControlRequest_AllFlag(t *testing.T) {
|
|
resetHostControlState()
|
|
|
|
req := &models.HostControlRequest{
|
|
ControlAction: "close",
|
|
Hostname: []string{"all"},
|
|
}
|
|
|
|
allFlag, err := preCheckHostControlRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !allFlag {
|
|
t.Error("expected allFlag=true when hostname is [\"all\"]")
|
|
}
|
|
}
|
|
|
|
// TestPreCheckHostControlRequest_NotAll verifies that when hostname is a
|
|
// specific host, the returned bool is false.
|
|
func TestPreCheckHostControlRequest_NotAll(t *testing.T) {
|
|
resetHostControlState()
|
|
|
|
req := &models.HostControlRequest{
|
|
ControlAction: "open",
|
|
Hostname: []string{"node01"},
|
|
}
|
|
|
|
allFlag, err := preCheckHostControlRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if allFlag {
|
|
t.Error("expected allFlag=false when hostname is [\"node01\"]")
|
|
}
|
|
}
|
|
|
|
// TestPreCheckHostControlRequest_NoRace verifies that concurrent calls to
|
|
// preCheckHostControlRequest do not share state via a package-level variable.
|
|
func TestPreCheckHostControlRequest_NoRace(t *testing.T) {
|
|
t.Parallel()
|
|
resetHostControlState()
|
|
|
|
var wg sync.WaitGroup
|
|
const goroutines = 50
|
|
|
|
results := make([]bool, goroutines)
|
|
errors := make([]error, goroutines)
|
|
|
|
for i := 0; i < goroutines; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
var req models.HostControlRequest
|
|
if idx%2 == 0 {
|
|
req = models.HostControlRequest{
|
|
ControlAction: "close",
|
|
Hostname: []string{"all"},
|
|
}
|
|
} else {
|
|
req = models.HostControlRequest{
|
|
ControlAction: "open",
|
|
Hostname: []string{"node01"},
|
|
}
|
|
}
|
|
results[idx], errors[idx] = preCheckHostControlRequest(&req)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
for i := 0; i < goroutines; i++ {
|
|
if errors[i] != nil {
|
|
t.Errorf("goroutine %d: unexpected error: %v", i, errors[i])
|
|
continue
|
|
}
|
|
if i%2 == 0 && !results[i] {
|
|
t.Errorf("goroutine %d: expected allFlag=true for [\"all\"]", i)
|
|
}
|
|
if i%2 != 0 && results[i] {
|
|
t.Errorf("goroutine %d: expected allFlag=false for [\"node01\"]", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPreCheckHostControlRequest_InvalidAction verifies that an unsupported
|
|
// action returns an error.
|
|
func TestPreCheckHostControlRequest_InvalidAction(t *testing.T) {
|
|
resetHostControlState()
|
|
|
|
req := &models.HostControlRequest{
|
|
ControlAction: "invalid_action",
|
|
Hostname: []string{"node01"},
|
|
}
|
|
|
|
_, err := preCheckHostControlRequest(req)
|
|
if err == nil {
|
|
t.Error("expected error for invalid action, got nil")
|
|
}
|
|
}
|
|
|
|
// TestPreCheckHostControlRequest_CommentTooLong verifies that a comment
|
|
// exceeding 512 characters returns an error.
|
|
func TestPreCheckHostControlRequest_CommentTooLong(t *testing.T) {
|
|
resetHostControlState()
|
|
|
|
longComment := make([]byte, 513)
|
|
for i := range longComment {
|
|
longComment[i] = 'a'
|
|
}
|
|
|
|
req := &models.HostControlRequest{
|
|
ControlAction: "close",
|
|
Hostname: []string{"node01"},
|
|
Comment: string(longComment),
|
|
}
|
|
|
|
_, err := preCheckHostControlRequest(req)
|
|
if err == nil {
|
|
t.Error("expected error for comment exceeding 512 chars, got nil")
|
|
}
|
|
}
|
|
|
|
// TestProcessHostData_ComputesSlotUsageFromJobs is a regression guard for
|
|
// bug-1743 (gitea #46): models.HostInfo.NumRun/.NumSSusp/.NumUSusp exist in
|
|
// the schema and are already shown by `xcecli hosts list`, but nothing in
|
|
// the apiserver ever assigned them -- they were permanently stuck at Go's
|
|
// zero value, so `hosts list` always showed NUM_RUN=0 regardless of how many
|
|
// jobs were actually running on a host. processHostData must now derive
|
|
// these counts from store.Jobs' live exec_hosts/job_status data (the same
|
|
// JSONB shape backend/convert_pure.go's job-conversion path populates:
|
|
// {"host_slots":[{"host_name":..,"num_slots":N}],"num_hosts":N}), counting
|
|
// slots (not just job count) so the numbers are comparable to max_slot.
|
|
func TestProcessHostData_ComputesSlotUsageFromJobs(t *testing.T) {
|
|
resetHostControlState()
|
|
|
|
store.Hosts.Put("test01", models.HostInfo{
|
|
HostName: "test01",
|
|
NumCPUs: 16,
|
|
MaxSlot: 16,
|
|
})
|
|
|
|
putJob := func(id int64, execHosts string, status int) {
|
|
store.Jobs.Put(uint64(id), models.JobInfo{
|
|
Unique_JobID: id,
|
|
JobID: id,
|
|
JobStatus: status,
|
|
ExecHosts: models.Jsonb{String: execHosts},
|
|
})
|
|
}
|
|
|
|
// 6 plain RUN jobs, 1 slot each on test01.
|
|
for i := int64(1); i <= 6; i++ {
|
|
putJob(i, `{"host_slots":[{"host_name":"test01","num_slots":1}],"num_hosts":1}`, models.JOBSTAT["RUN"])
|
|
}
|
|
// 1 user-suspended job (RUN|USUSP) -- must count as USUSP, not RUN.
|
|
putJob(7, `{"host_slots":[{"host_name":"test01","num_slots":1}],"num_hosts":1}`, models.JOBSTAT["RUN"]|models.JOBSTAT["USUSP"])
|
|
// 1 system-suspended job (RUN|SSUSP) using 2 slots on test01.
|
|
putJob(8, `{"host_slots":[{"host_name":"test01","num_slots":2}],"num_hosts":1}`, models.JOBSTAT["RUN"]|models.JOBSTAT["SSUSP"])
|
|
// 1 PEND job with no exec host yet -- must not be counted anywhere.
|
|
putJob(9, "", models.JOBSTAT["PEND"])
|
|
// 1 job on a different host -- must not count toward test01.
|
|
putJob(10, `{"host_slots":[{"host_name":"other-host","num_slots":1}],"num_hosts":1}`, models.JOBSTAT["RUN"])
|
|
|
|
rows := store.StoreToOrderMaps(store.Hosts)
|
|
result := processHostData(rows, "")
|
|
|
|
if result.Count != 1 {
|
|
t.Fatalf("expected 1 host row, got %d", result.Count)
|
|
}
|
|
row := result.Rows[0]
|
|
|
|
if got := row.Data["num_run"]; got != 6 {
|
|
t.Errorf("num_run = %v, want 6", got)
|
|
}
|
|
if got := row.Data["num_ssusp"]; got != 2 {
|
|
t.Errorf("num_ssusp = %v, want 2 (2-slot SSUSP job)", got)
|
|
}
|
|
if got := row.Data["num_ususp"]; got != 1 {
|
|
t.Errorf("num_ususp = %v, want 1", got)
|
|
}
|
|
}
|