// Package buildcpu is how much of a build node's processors its builds may take, and how they yield to everything else // there (novox/hq issue 1806; the operator, 2026-10-26: "limit CPU usage on our build nodes so the total cpu usage never // impacts the normal workings of our nodes"). // // Three parts, each with one owner: // // - **Priority.** A build seat's holder (catalogue.Seat.Yields) runs at the lowest CPU and IO priority there is: the // composer gives its runtime process declaration.Yields, and the node-engine writes it into the unit as // CPUWeight=idle, IOWeight=1, Nice=19 and the best-effort IO class at its lowest level. Interactive and service // work always comes first; builds take the cycles nothing else wants. // - **The cap.** The build modules' setting `cpu-cap` (Setting) gives each node the share of its processors its builds // may take at most, as `=` pairs. The composer turns this node's share into Yields.CPUShare, and the // node-engine into CPUQuota for the machine's processor count: a hard bound, held by the kernel. // - **Threads.** Inside that bound a step starts no more threads than its part of it (Threads): GOMAXPROCS, `go // test -p`, the Node test runner's concurrency. A step with more runnable threads than its quota only waits in the // kernel's throttle, and its timeouts with it. package buildcpu import ( "fmt" "os" "runtime" "sort" "strconv" "strings" ) // Setting is the build modules' setting of each node's processor cap: `=` pairs separated by commas, // such as `shanks=50,ace=75`; a percent sign after the number is allowed. Each build module declares it with Defaults // as its default. const Setting = "cpu-cap" // Defaults is the setting's default: the operator's two desktops (shanks, g14) keep half their processors for // themselves at all times, and ace, a server, builds on three quarters. A node it does not name has DefaultPercent. const Defaults = "shanks=50,g14=50,ace=75" // DefaultPercent is the cap of a node the setting does not name. const DefaultPercent = 50 // Parse reads the setting's pairs. A percent outside 1 to 100 is refused: 0 would stop every build without pausing the // seat (the capacity setting says that, with 0 asks), and more than 100 is no cap. func Parse(s string) (map[string]int, error) { out := map[string]int{} for _, pair := range strings.Split(s, ",") { pair = strings.TrimSpace(pair) if pair == "" { continue } node, value, ok := strings.Cut(pair, "=") node = strings.TrimSpace(node) value = strings.TrimSuffix(strings.TrimSpace(value), "%") n, err := strconv.Atoi(strings.TrimSpace(value)) if !ok || node == "" || err != nil || n < 1 || n > 100 { return nil, fmt.Errorf("the processor cap %q is not =", pair) } if _, dup := out[node]; dup { return nil, fmt.Errorf("the processor cap names %s twice", node) } out[node] = n } return out, nil } // Of is node's cap in percent of its processors: DefaultPercent for a node the setting does not name. func Of(setting, node string) (int, error) { caps, err := Parse(setting) if err != nil { return 0, err } if n, ok := caps[node]; ok { return n, nil } return DefaultPercent, nil } // Format is a cap map in the setting's own words, sorted by node. func Format(caps map[string]int) string { nodes := make([]string, 0, len(caps)) for n := range caps { nodes = append(nodes, n) } sort.Strings(nodes) parts := make([]string, 0, len(nodes)) for _, n := range nodes { parts = append(parts, fmt.Sprintf("%s=%d", n, caps[n])) } return strings.Join(parts, ",") } // Quota is the unit's CPUQuota for a share of a machine with cores processors: systemd counts a quota in percent of // one processor, so half of 24 is 1200%. At least 100%, one processor, so a cap on a small machine never stops a step. func Quota(percent, cores int) int { q := percent * cores if q < 100 { q = 100 } return q } // Threads is how many threads one step may run at once: its part of the cap when slots steps run beside it, at least // one. Rounded down, so the steps together stay inside the quota. func Threads(percent, cores, slots int) int { if slots < 1 { slots = 1 } if percent < 1 || percent > 100 { percent = 100 } t := cores * percent / 100 / slots if t < 1 { t = 1 } return t } // Limits is what one step is held to on this node, as a status tool says it. type Limits struct { // Percent is the node's cap in percent of its processors, and Setting the words it came from. Percent int `json:"cap-percent"` Setting string `json:"cap-setting"` // Cores is the machine's processor count. Cores int `json:"cores"` // Slots is the node's build capacity: steps at once. Slots int `json:"slots"` // Threads is what each step is given (Threads). Threads int `json:"threads-per-step"` } // LimitsFor is the limits of a step on node, from the cap setting and the node's capacity. A setting that cannot be read // gives the default cap and says why in the error, so a step still runs with a bound rather than none. func LimitsFor(setting, node string, slots int) (Limits, error) { l := Limits{Setting: setting, Cores: runtime.NumCPU(), Slots: slots} p, err := Of(setting, node) if err != nil { p = DefaultPercent } l.Percent = p l.Threads = Threads(p, l.Cores, slots) return l, err } // Env is the environment a step's commands are given so they start no more threads than they may: GOMAXPROCS for every // Go program (the compiler, a test binary, esbuild, buildah), UV_THREADPOOL_SIZE for Node's worker pool. Returned as // KEY=value words to append to a command's environment. func Env(threads int) []string { if threads < 1 { threads = 1 } t := strconv.Itoa(threads) return []string{"GOMAXPROCS=" + t, "UV_THREADPOOL_SIZE=" + t} } // WithEnv is env with Env(threads) in place of any GOMAXPROCS or UV_THREADPOOL_SIZE it carried. func WithEnv(env []string, threads int) []string { out := make([]string, 0, len(env)+2) for _, kv := range env { if strings.HasPrefix(kv, "GOMAXPROCS=") || strings.HasPrefix(kv, "UV_THREADPOOL_SIZE=") { continue } out = append(out, kv) } return append(out, Env(threads)...) } // InForce is what the kernel holds this process to, read rather than assumed (evidence over assertion): its nice // level, its cgroup, the cgroup's CPU weight, idle mark and quota. A field the machine does not answer is left empty // and Unread says which. type InForce struct { Nice int `json:"nice"` Cgroup string `json:"cgroup,omitempty"` CPUIdle string `json:"cpu-idle,omitempty"` CPUWeight string `json:"cpu-weight,omitempty"` // CPUMax is the cgroup's cpu.max: " ", or "max" with no quota. CPUMax string `json:"cpu-max,omitempty"` // Quota is CPUMax as a percent of one processor, 0 with none. Quota int `json:"quota-percent,omitempty"` IOWeight string `json:"io-weight,omitempty"` Unread []string `json:"unread,omitempty"` } // cgroupRoot is where the unified hierarchy is mounted. A variable so a test can point it elsewhere. var cgroupRoot = "/sys/fs/cgroup" // procSelfCgroup is this process's cgroup file. A variable so a test can point it elsewhere. var procSelfCgroup = "/proc/self/cgroup" // ReadInForce reads what the kernel holds this process to. func ReadInForce() InForce { var f InForce f.Nice = nice() raw, err := os.ReadFile(procSelfCgroup) if err != nil { f.Unread = append(f.Unread, "cgroup: "+err.Error()) return f } for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { if strings.HasPrefix(line, "0::") { f.Cgroup = strings.TrimPrefix(line, "0::") } } if f.Cgroup == "" { f.Unread = append(f.Unread, "cgroup: not on the unified hierarchy") return f } dir := cgroupRoot + f.Cgroup read := func(name string) string { b, err := os.ReadFile(dir + "/" + name) if err != nil { f.Unread = append(f.Unread, name+": "+err.Error()) return "" } return strings.TrimSpace(string(b)) } f.CPUIdle = read("cpu.idle") f.CPUWeight = read("cpu.weight") f.CPUMax = read("cpu.max") f.IOWeight = read("io.weight") f.Quota = quotaOf(f.CPUMax) return f } // quotaOf is a cpu.max value as a percent of one processor: "1200000 100000" is 1200; "max ..." or nothing is 0. func quotaOf(cpuMax string) int { fields := strings.Fields(cpuMax) if len(fields) != 2 || fields[0] == "max" { return 0 } q, err1 := strconv.Atoi(fields[0]) p, err2 := strconv.Atoi(fields[1]) if err1 != nil || err2 != nil || p <= 0 { return 0 } return q * 100 / p } // procSelfStat is this process's stat file. A variable so a test can point it elsewhere. var procSelfStat = "/proc/self/stat" // nice is this process's nice level from its stat file (the 19th field, counted after the command's closing // parenthesis, which may itself hold spaces); 0 when it cannot be read. func nice() int { raw, err := os.ReadFile(procSelfStat) if err != nil { return 0 } s := string(raw) i := strings.LastIndex(s, ")") if i < 0 { return 0 } fields := strings.Fields(s[i+1:]) // After the command: state is field 3, so nice (field 19) is index 16. if len(fields) < 17 { return 0 } n, _ := strconv.Atoi(fields[16]) return n }