Files
mesh-delivery/modules/mesh-delivery-view/internal/view/usage_test.go
T
jschoubben 6d60953f98 A running try names its asks and where each stands; build_usage says a seat's CPU-bound capacity
The checker wrote a try's asks only at its end, so a 31-minute try could not be told queued from
running (issue 1890). The try's record now names each ask as it is put, then the node that took
it, its state and the phase its step says, written on the step key's own changes. build_usage
names a running try's asks and takes a build module's capacity.<node> (configured bounded by its
node's build CPU, issue 1806's follow-up) when it is current with mesh-config.
2026-10-26 19:58:39 +01:00

216 lines
13 KiB
Go

package view
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"testing"
"time"
)
// usageNow is the fixture's clock.
var usageNow = time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC)
// at is the fixture's time hh:mm on its day.
func at(hm string) time.Time {
t, _ := time.Parse("2006-01-02 15:04", "2026-10-26 "+hm)
return t.UTC()
}
// stepID is a step id made at t, as buildstep.NewID makes it, with suffix as its six hex digits.
func stepID(t time.Time, suffix string) string { return strconv.FormatInt(t.UnixMilli(), 36) + suffix }
func states(m map[string]string) StateRead {
return StateRead{
Keys: func() ([]string, error) {
var k []string
for key := range m {
k = append(k, key)
}
return k, nil
},
Get: func(key string) (json.RawMessage, error) {
if v, ok := m[key]; ok {
return json.RawMessage(v), nil
}
return nil, nil
},
}
}
func step(node, id, tool, state string, started, ended time.Time) string {
e := ""
if !ended.IsZero() {
e = `,"ended":"` + ended.Format(time.RFC3339) + `"`
}
return fmt.Sprintf(`{"v":1,"id":%q,"tool":%q,"node":%q,"state":%q,"started":%q%s}`, id, tool, node, state, started.Format(time.RFC3339), e)
}
// usageFixture is four nodes: shanks builds Go at capacity 2 (two asks running, one left running, a try's ask, a check,
// a recheck of the morning and a hand step ended); ace holds the Go seat at capacity 2 and is paused; g14 holds it at
// capacity 0; the Node seat on shanks has no resolved capacity and takes the modules' default.
func usageFixture() UsageReader {
running := `{"v":1,"phase":"running","health":"ok"}`
tryAsk := stepID(at("11:30"), "a00003")
goSteps := map[string]string{}
add := func(node, id, tool, state string, started, ended time.Time) {
goSteps[node+"."+id] = step(node, id, tool, state, started, ended)
}
add("shanks", stepID(at("11:50"), "a00001"), "delivery ask", "running", at("11:50"), time.Time{})
add("shanks", stepID(at("11:55"), "a00002"), "check ask", "running", at("11:55"), time.Time{})
add("shanks", tryAsk, "check ask", "passed", at("11:30"), at("11:40"))
add("shanks", stepID(at("11:20"), "a00004"), "check ask", "failed", at("11:20"), at("11:26"))
add("shanks", stepID(at("06:00"), "a00005"), "recheck ask", "passed", at("06:00"), at("06:10"))
add("shanks", stepID(at("10:00"), "a00006"), "check ask", "running", at("10:00"), time.Time{})
add("shanks", stepID(at("11:45"), "a00007"), "build_go_test", "passed", at("11:45"), at("11:46"))
// Two keys a build module ended for being left running: their end is when it noticed, so neither is finished work.
goSteps["shanks."+stepID(at("08:00"), "a00008")] = `{"v":1,"id":"` + stepID(at("08:00"), "a00008") + `","tool":"check ask","node":"shanks","state":"failed","started":"2026-10-26T08:00:00Z","ended":"2026-10-26T11:59:00Z","reason":"the build module restarted while it ran"}`
goSteps["shanks."+stepID(at("09:00"), "a00009")] = `{"v":1,"id":"` + stepID(at("09:00"), "a00009") + `","tool":"delivery ask","node":"shanks","state":"failed","started":"2026-10-26T09:00:00Z","ended":"2026-10-26T11:58:00Z","reason":"past the step bound: its asker stopped waiting at 2026-10-26T09:30:00Z"}`
goSteps["shanks.notastepkey"] = `{}` // a key that is no step id is passed over
return UsageReader{
Now: func() time.Time { return usageNow },
Haves: states(map[string]string{"shanks.mesh-build-go": running, "shanks.mesh-build-node": running, "ace.mesh-build-go": running,
"g14.mesh-build-go": running, "g14.mesh-build-node": running, "shanks.node": `{}`, "shanks.mesh-delivery-view": running}),
Resolved: states(map[string]string{
"shanks.mesh-build-go.capacity": `{"node":"shanks","module":"mesh-build-go","key":"capacity","value":"shanks=2,ace=2,g14=0,novox=0","source":"mesh"}`,
"ace.mesh-build-go.capacity": `{"node":"ace","module":"mesh-build-go","key":"capacity","value":"shanks=2,ace=2,g14=0,novox=0","source":"mesh"}`,
"g14.mesh-build-go.capacity": `{"node":"g14","module":"mesh-build-go","key":"capacity","value":"shanks=2,ace=2,g14=0,novox=0","source":"mesh"}`,
"shanks.mesh-config.other": `{"value":"x"}`,
"g14.mesh-build-node.capacity": `{"node":"g14","module":"mesh-build-node","key":"capacity","source":"unreadable"}`,
}),
Steps: map[string]StateRead{"go": states(goSteps), "node": states(map[string]string{}), "image": states(map[string]string{})},
Pauses: map[string]StateRead{"go": states(map[string]string{"paused.ace": `{"v":1,"paused":true,"by":"lead@shanks","at":"2026-10-26T09:00:00Z"}`}), "node": states(nil), "image": states(nil)},
Tries: states(map[string]string{
"try-" + strconv.FormatInt(at("11:29").UnixNano(), 10): `{"id":"try-1","repository":"novox/mesh-sdk","head":"h","trunk":"t","state":"pass","asks":["` + tryAsk + `"]}`,
"try-" + strconv.FormatInt(at("11:58").UnixNano(), 10): `{"id":"try-2","repository":"novox/mesh-delivery","head":"h","trunk":"t","state":"running","started":"2026-10-26T11:58:00Z"}`,
}),
Backlog: func() ([]BacklogRow, error) {
return []BacklogRow{{Language: "go", Kind: "check", Waiting: 3, Held: 2, OldestSecs: 240}}, nil
},
}
}
func seatOf(t *testing.T, a UsageAnswer, node, seat string) SeatUsage {
t.Helper()
for _, n := range a.Nodes {
for _, s := range n.Seats {
if n.Node == node && s.Seat == seat {
return s
}
}
}
t.Fatalf("no seat %s on %s in %+v", seat, node, a.Nodes)
return SeatUsage{}
}
// build_usage answers, from the states alone, what the operator asked in six calls: per node its seats, capacity,
// eligibility, the asks running by kind and the ones left running, the last hour and day, and mesh-wide the backlog.
func TestBuildUsageAnswersEveryNodesUseFromTheStates(t *testing.T) {
a := usageFixture().Usage()
if testing.Verbose() {
out, _ := json.MarshalIndent(a, "", " ")
t.Logf("build_usage answers:\n%s", out)
}
sg := seatOf(t, a, "shanks", "node-build-go")
eligible := map[string]string{}
for _, n := range a.Nodes {
eligible[n.Node] = n.Eligible
}
for _, c := range []struct {
what string
got, want any
}{
{"nodes, novox named by the default capacity", len(a.Nodes), 4},
{"eligible", eligible, map[string]string{"shanks": "eligible", "ace": "not eligible", "g14": "not eligible", "novox": "not eligible"}},
{"a resolved key with no readable value: the puller uses 0", seatOf(t, a, "g14", "node-build-node").WhyNot, "its capacity on g14 cannot be read, so its puller uses 0"},
{"and is said so", seatOf(t, a, "g14", "node-build-node").CapacityFrom, "mesh-config's value for mesh-build-node on g14 cannot be read (mesh-config resolves it as unreadable), so its puller uses 0"},
{"novox why not", seatOf(t, a, "novox", "node-build-go").WhyNot, "mesh-build-go is not on novox"},
{"novox capacity, from the module's value resolved elsewhere", seatOf(t, a, "novox", "node-build-go").CapacityFrom, "mesh-config, resolved for mesh-build-go on ace (mesh-build-go is not assigned to novox)"},
{"ended by restart or bound, apart", len(sg.EndedStale), 2},
{"ace why not", seatOf(t, a, "ace", "node-build-go").WhyNot, "paused (node-build-go.resume takes asks again)"},
{"g14 why not", seatOf(t, a, "g14", "node-build-go").WhyNot, "its capacity on g14 is 0"},
{"shanks capacity", sg.Capacity, 2},
{"shanks node seat takes the default", seatOf(t, a, "shanks", "node-build-node").Capacity, 3},
{"running", sg.Running.Total, 2},
{"running by kind", sg.Running.ByKind, map[string]int{"delivery": 1, "check": 1}},
{"running steps, the hand step's ended", len(sg.Steps), 2},
{"left running", len(sg.LeftRunning), 1},
{"hour started", sg.LastHour.Started, 5}, // delivery, check, try, check, hand
{"hour finished", sg.LastHour.Finished, 3}, // try 600 s, check 360 s, hand 60 s
{"hour failed", sg.LastHour.Failed, 1},
{"hour total", sg.LastHour.TotalSecs, int64(1020)},
{"hour median", sg.LastHour.MedianSecs, int64(360)},
{"hour try", sg.LastHour.ByKind["try"], KindWindow{Finished: 1, TotalSecs: 600, MedianSecs: 600}},
{"hour hand", sg.LastHour.ByKind["hand"], KindWindow{Finished: 1, TotalSecs: 60, MedianSecs: 60}},
{"hour at capacity", sg.LastHour.AtCapacitySecs, int64(300)}, // 11:55-12:00, two asks of two
{"hour at capacity share", sg.LastHour.AtCapacityShare, 0.083},
{"day finished", sg.LastDay.Finished, 4},
{"day recheck", sg.LastDay.ByKind["recheck"].Finished, 1},
{"day at capacity", sg.LastDay.AtCapacitySecs, int64(300)},
{"backlog", a.Backlog, []BacklogRow{{Language: "go", Kind: "check", Waiting: 3, Held: 2, OldestSecs: 240}}},
{"tries running", len(a.TriesRunning), 1},
{"said", a.Nodes[0].Said[:1], []string{"node-build-go: not eligible, paused (node-build-go.resume takes asks again); 0 of capacity 2 running; last hour 0 finished, median 0 s, at capacity 0% of it; last day 0 finished, median 0 s, at capacity 0.0% of it"}},
{"said shanks", a.Nodes[3].Said[0], "node-build-go: eligible; 2 of capacity 2 running (check 1, delivery 1), 1 left running; last hour 3 finished, median 360 s, at capacity 8% of it; last day 4 finished, median 360 s, at capacity 0.3% of it"},
{"cpu says where it is", strings.Contains(a.Nodes[0].CPU, "/disk-load.cpu_top"), true},
} {
if fmt.Sprint(c.got) != fmt.Sprint(c.want) {
t.Errorf("%s = %v, want %v", c.what, c.got, c.want)
}
}
if !strings.Contains(sg.LeftRunning[0].Why, "past the step bound") {
t.Errorf("left running says %q", sg.LeftRunning[0].Why)
}
// Nothing readable: each state is named in a note, the backlog's refusal too, and nothing is said held.
none := UsageReader{Now: func() time.Time { return usageNow }, Backlog: func() ([]BacklogRow, error) { return nil, errors.New("no socket") }}.Usage()
notes := strings.Join(none.Notes, " | ")
for _, want := range []string{"have is not readable", "capacity unknown to this tool: mesh-config's resolved values are not readable", "steps of mesh-build-go are not readable", "tries are not readable", "did not answer: no socket"} {
if !strings.Contains(notes, want) {
t.Errorf("the notes %q do not say %q", notes, want)
}
}
if none.Backlog == nil {
t.Errorf("nothing readable answered no backlog list")
}
// mesh-config unreadable to this tool: a seat that holds its module is not answered (never said not eligible), and its
// node's verdict is not known; a seat known not eligible on other grounds (paused) stays so.
u := usageFixture()
u.Resolved = StateRead{}
blind := u.Usage()
sb := seatOf(t, blind, "shanks", "node-build-go")
verdict := map[string]string{}
for _, n := range blind.Nodes {
verdict[n.Node] = n.Eligible
}
if sb.EligibleKnown || sb.Eligible != nil || !strings.HasPrefix(sb.WhyNot, "not known: ") || !strings.HasPrefix(sb.CapacityFrom, "capacity unknown to this tool: ") {
t.Errorf("unknown capacity: known %v eligible %v why %q from %q", sb.EligibleKnown, sb.Eligible, sb.WhyNot, sb.CapacityFrom)
}
if verdict["shanks"] != "not known" || verdict["ace"] != "not eligible" {
t.Errorf("verdicts with mesh-config unreadable = %v, want shanks not known, ace not eligible (paused)", verdict)
}
// Issue 1806's follow-up: the module on shanks says it judges by 1 of mesh-config's 2 (its build CPU bounds it); the
// seat says the module's capacity and why. And issue 1890: a running try that names its asks is said with them.
u = usageFixture()
u.Pauses["go"] = states(map[string]string{"capacity.shanks": `{"v":1,"effective":1,"configured":2,"quota-from":"the cpu.max of /meshyield.slice","quota-cores":6,"cores-per-step":6,"by-cpu":1,"why":"1, bounded by the CPU","at":"2026-10-26T11:00:00Z"}`,
"capacity.ace": `{"v":1,"effective":2,"configured":3,"why":"stale: mesh-config says 2 now"}`})
u.Tries = states(map[string]string{"try-" + strconv.FormatInt(at("11:58").UnixNano(), 10): `{"id":"try-2","repository":"novox/mesh-delivery","head":"h","trunk":"t","state":"running","asks":["` + tryAsk0 + `"],"steps":[{"ask":"` + tryAsk0 + `","node":"shanks","state":"running","phase":"unit: tests of 3 packages"}]}`})
bound := u.Usage()
sc := seatOf(t, bound, "shanks", "node-build-go")
if sc.Capacity != 1 || sc.Configured != 2 || !strings.HasPrefix(sc.CapacityFrom, "1, bounded by the CPU, as mesh-build-go on shanks judges it") {
t.Errorf("a seat its CPU bounds: capacity %d of %d, from %q", sc.Capacity, sc.Configured, sc.CapacityFrom)
}
if sa := seatOf(t, bound, "ace", "node-build-go"); sa.Capacity != 2 || sa.Configured != 0 {
t.Errorf("a module's word older than mesh-config's value is not taken: %+v", sa)
}
if len(bound.TriesRunning) != 1 || len(bound.TriesRunning[0].Asks) != 1 || !strings.Contains(string(bound.TriesRunning[0].Steps), "unit: tests") ||
strings.Contains(strings.Join(bound.Notes, " | "), "name no ask yet") {
t.Errorf("a running try naming its asks: %+v, notes %v", bound.TriesRunning, bound.Notes)
}
}
var tryAsk0 = stepID(at("11:58"), "a00010")