fix(apiserver): populate hosts-list slot usage counts

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
This commit is contained in:
Rongsong Shen 2026-09-01 06:37:37 +00:00
parent 343639fc7e
commit 728496bea0
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
3 changed files with 155 additions and 0 deletions

View file

@ -137,3 +137,63 @@ func TestPreCheckHostControlRequest_CommentTooLong(t *testing.T) {
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)
}
}