Since the build CPU cap (issue 1806) shanks' mesh-build-go kept capacity 3 inside a 12-core slice: three full checks got about 4 cores each and ran past the 30-minute step bound (issue 1890). The puller now judges by min(configured, floor(slice quota cores / cores-per-step)), the quota read live from its cgroup, says it on change and writes capacity.<node> to the pauses state. A run's phases (fetch and merge, digest, tests) are logged with the ask id and written to its running step key; Asker.Wait hands every step value to a WithStepSeen hook.
142 lines
6.1 KiB
Go
142 lines
6.1 KiB
Go
package buildstep
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/buildcpu"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// **A build seat's capacity follows the CPU its node gives builds** (issue 1806's follow-up; issue 1890, 2026-10-26
|
|
// 19:41: under the 50 % cap shanks' mesh-build-go kept capacity 3, three full mesh-catalog checks shared 12 cores, and
|
|
// each step ran past the 30-minute step bound with no test run). The configured capacity is an upper bound; the node's
|
|
// build slice bounds it again by the cores a step needs: floor(the slice's quota in cores / cores-per-step), one at
|
|
// least. The quota is read live from the cgroup at every judgement, so a changed cap is followed at the next take
|
|
// without a restart, and the result is said (the seat's current verb, the status tools, `capacity.<node>` in the pauses
|
|
// state that the delivery view's build_usage reads).
|
|
|
|
// CoresPerStepSetting is a build module's setting of the processors one of its steps needs: a number above zero, the
|
|
// same on every node (a step's need is the module's, not the node's). Each build module declares it with its own default
|
|
// (DefaultCoresPerStep).
|
|
const CoresPerStepSetting = "cores-per-step"
|
|
|
|
// DefaultCoresPerStep are the build modules' defaults by language, from measured steps (2026-10-26): a full mesh-catalog
|
|
// check on mesh-build-go takes what it is given (three at once took 965 % of shanks' 1200 % slice, about 4 cores each,
|
|
// and ran past the 30-minute step bound), so a Go step is given 6, half again what starved; a Node step (tsc, esbuild, a
|
|
// test runner: median 15 s) and an image build (buildah, its RUN lines mostly fetches) need 2.
|
|
var DefaultCoresPerStep = map[string]float64{delivery.AskGo: 6, delivery.AskNode: 2, delivery.AskImage: 2}
|
|
|
|
// Quota is the build slice's CPU quota as this process's cgroup holds it, in percent of one processor (1200 is 12
|
|
// cores), and where it was read: the cgroup's cpu.max (buildcpu.ReadInForce, the tighter of its unit's and its slice's),
|
|
// else the share the composer gave (buildcpu.ShareWord) over the machine's processors, else 0 and why none is known.
|
|
func Quota() (percent int, from string) {
|
|
f := buildcpu.ReadInForce()
|
|
if f.Quota > 0 {
|
|
path := f.Slice.Path
|
|
if path == "" {
|
|
path = f.Cgroup
|
|
}
|
|
return f.Quota, "the cpu.max of " + path
|
|
}
|
|
if n, err := strconv.Atoi(strings.TrimSpace(os.Getenv(buildcpu.ShareWord))); err == nil && n >= 1 && n <= 100 {
|
|
return buildcpu.Quota(n, runtime.NumCPU()), fmt.Sprintf("the share %d%% the composer gave (%s), of %d processors; no cpu.max is read", n, buildcpu.ShareWord, runtime.NumCPU())
|
|
}
|
|
return 0, "no CPU quota: neither a cpu.max on its cgroup nor a share from the composer"
|
|
}
|
|
|
|
// EffectiveCapacity is the capacity a seat's holder judges by, from its configured capacity, its node's quota (percent of
|
|
// one processor, 0 when none is known) and the cores one of its steps needs. Without a quota it is the configured one;
|
|
// with one, min(configured, floor(quota cores / coresPerStep)), and one at least while the configured one is above
|
|
// zero. A coresPerStep of zero or less is taken as 1.
|
|
func EffectiveCapacity(configured, quotaPercent int, quotaFrom string, coresPerStep float64) delivery.Capacity {
|
|
if coresPerStep <= 0 || math.IsNaN(coresPerStep) || math.IsInf(coresPerStep, 0) {
|
|
coresPerStep = 1
|
|
}
|
|
c := delivery.Capacity{Configured: configured, Effective: configured, QuotaFrom: quotaFrom, CoresPerStep: coresPerStep}
|
|
switch {
|
|
case configured <= 0:
|
|
c.Effective = 0
|
|
c.Why = fmt.Sprintf("its configured capacity is %d, so it pulls nothing", configured)
|
|
return c
|
|
case quotaPercent <= 0:
|
|
c.Why = fmt.Sprintf("its configured capacity %d; %s, so the CPU bounds nothing", configured, quotaFrom)
|
|
return c
|
|
}
|
|
c.QuotaCores = float64(quotaPercent) / 100
|
|
c.ByCPU = int(math.Floor(c.QuotaCores/coresPerStep + 1e-9))
|
|
if c.ByCPU < 1 {
|
|
c.ByCPU = 1
|
|
}
|
|
byCPU := fmt.Sprintf("%g cores of the build slice (%s) / %g cores a step = %d", c.QuotaCores, quotaFrom, coresPerStep, c.ByCPU)
|
|
if c.ByCPU < configured {
|
|
c.Effective = c.ByCPU
|
|
c.Why = fmt.Sprintf("%d, bounded by the CPU: %s, under its configured %d", c.Effective, byCPU, configured)
|
|
} else {
|
|
c.Why = fmt.Sprintf("%d, its configured capacity; the CPU allows %s", configured, byCPU)
|
|
}
|
|
return c
|
|
}
|
|
|
|
// NodeCoresPerStep is the module's cores-per-step, read from its settings file at path at every call so a change is
|
|
// followed at the next judgement; fallback when there is no path, no value or one that is not a number above zero, said
|
|
// once per distinct fault through logf.
|
|
func NodeCoresPerStep(path string, fallback float64, logf func(string, ...any)) func() float64 {
|
|
said := ""
|
|
say := func(why string) {
|
|
if logf != nil && why != said {
|
|
said = why
|
|
logf("%s, so a step is taken to need %g cores", why, fallback)
|
|
}
|
|
}
|
|
return func() float64 {
|
|
if path == "" {
|
|
return fallback
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
say(fmt.Sprintf("the setting %s cannot be read: %v", CoresPerStepSetting, err))
|
|
return fallback
|
|
}
|
|
var s map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
|
say(fmt.Sprintf("the settings file %s is not JSON: %v", path, err))
|
|
return fallback
|
|
}
|
|
v, ok := s[CoresPerStepSetting]
|
|
if !ok {
|
|
return fallback // not declared yet (a module before its manifest names it): the default, nothing to say
|
|
}
|
|
n, err := coresOf(v)
|
|
if err != nil {
|
|
say(fmt.Sprintf("the setting %s %s: %v", CoresPerStepSetting, v, err))
|
|
return fallback
|
|
}
|
|
said = ""
|
|
return n
|
|
}
|
|
}
|
|
|
|
// coresOf reads a cores-per-step value: a JSON number or a string holding one, above zero.
|
|
func coresOf(v json.RawMessage) (float64, error) {
|
|
var n float64
|
|
if err := json.Unmarshal(v, &n); err != nil {
|
|
var s string
|
|
if err := json.Unmarshal(v, &s); err != nil {
|
|
return 0, fmt.Errorf("is neither a number nor a string")
|
|
}
|
|
if n, err = strconv.ParseFloat(strings.TrimSpace(s), 64); err != nil {
|
|
return 0, fmt.Errorf("is not a number")
|
|
}
|
|
}
|
|
if n <= 0 || math.IsNaN(n) || math.IsInf(n, 0) {
|
|
return 0, fmt.Errorf("is not above zero")
|
|
}
|
|
return n, nil
|
|
}
|