Build modules: no cap of their own; a step's threads are its part of the node's one share (ADR 0553, issue 1806)

Review round 1: a cap per module let three build modules take three shares
of the node. The share is now one node value held by the node-engine's
yielding slice; each step divides it by every build slot of the node, from the
composer's words, or assumes every build seat at its own capacity without them.
This commit is contained in:
2026-10-26 14:22:05 +01:00
parent 21583d14ad
commit dcdf4238c7
16 changed files with 67 additions and 89 deletions
+14 -16
View File
@@ -5,15 +5,15 @@ import (
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// The processors a step may take (novox/hq issue 1806). The kernel holds the module's runtime to idle priority and to
// its node's share of the processors (the setting cpu-cap, written into its unit by the node-engine); inside that
// bound a step starts no more threads than its part of it, so the steps running at once do not wait in the kernel's
// throttle: GOMAXPROCS, which sets every go command's -p, the compiler's concurrency, a test binary's processors and
// -parallel.
// The processors a step may take (novox/hq ADR 0553, issue 1806). The node-engine holds this module's runtime, with
// every other build module's of the node, in one slice at idle priority under the node's one share of its processors;
// inside that bound a step starts no more threads than its part of it: the share divided by every build slot of the
// node, from the words the composer gives (buildstep.NodeLimits), so the steps running at once do not wait in the
// kernel's throttle.
// cpuLimits is what one step is held to now: the cap from the settings file, the machine's processors and the node's
// capacity, read at every call like the capacity, so a change takes effect at the next step.
func (m *Module) cpuLimits(say func(string, ...any)) buildcpu.Limits {
// cpuLimits is what one step is held to now. Without the composer's words, it assumes every build seat of the node
// beside it at this module's own capacity.
func (m *Module) cpuLimits() buildcpu.Limits {
slots := 1
switch {
case m.Capacity != nil:
@@ -21,19 +21,17 @@ func (m *Module) cpuLimits(say func(string, ...any)) buildcpu.Limits {
case m.Settings != "":
slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
}
if slots < 1 {
slots = 1
}
return buildstep.NodeLimits(m.Settings, m.Node, slots, say)
return buildstep.NodeLimits(slots)
}
// threadEnv is the environment every go command of a step is given for its threads.
// threadEnv is the environment every go command of a step is given for its threads: GOMAXPROCS sets its -p, the compiler's
// concurrency, a test binary's processors and -parallel.
func (m *Module) threadEnv() []string {
return buildcpu.Env(m.cpuLimits(nil).Threads)
return buildcpu.Env(m.cpuLimits().Threads)
}
// cpuStatus is what the status tool says of it: the limits a step is given, and what the kernel holds this runtime
// to, read from its cgroup rather than assumed.
// to (its unit and its slice), read from its cgroup rather than assumed.
func (m *Module) cpuStatus() map[string]any {
return map[string]any{"limits": m.cpuLimits(m.Say), "in-force": buildcpu.ReadInForce()}
return map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()}
}
@@ -49,16 +49,21 @@ func TestEveryStepEnvironmentSaysGotoolchainLocal(t *testing.T) {
t.Errorf("%s: GOMAXPROCS said %d times, want once: %v", name, procs, env)
}
}
// Three steps at once under shanks' default half of its processors share them: each gets a third of the half.
settings := filepath.Join(t.TempDir(), "settings.json")
os.WriteFile(settings, []byte(`{"capacity":"shanks=3","cpu-cap":"shanks=50"}`), 0o600)
m = &Module{Cache: t.TempDir(), Node: "shanks", Settings: settings}
l := m.cpuLimits(nil)
if want := buildcpu.Threads(50, runtime.NumCPU(), 3); l.Percent != 50 || l.Slots != 3 || l.Threads != want ||
!strings.Contains(strings.Join(m.goEnv(), " "), "GOMAXPROCS="+strconv.Itoa(want)) {
// Three slots of its own and no words from the composer: every build seat beside it at three, at the default
// share; with the composer's words, its part of the node's share (ADR 0553 §4).
t.Setenv("MESH_BUILD_CPU_SHARE", "")
t.Setenv("MESH_BUILD_SLOTS", "")
m = &Module{Cache: t.TempDir(), Node: "shanks", Capacity: func() int { return 3 }}
if l := m.cpuLimits(); l.Percent != 50 || l.Slots != 9 || l.Threads != buildcpu.Threads(50, runtime.NumCPU(), 9) {
t.Errorf("without words: %+v", l)
}
t.Setenv("MESH_BUILD_CPU_SHARE", "50")
t.Setenv("MESH_BUILD_SLOTS", "4")
want := buildcpu.Threads(50, runtime.NumCPU(), 4)
if l := m.cpuLimits(); l.Slots != 4 || !strings.Contains(strings.Join(m.goEnv(), " "), "GOMAXPROCS="+strconv.Itoa(want)) {
t.Errorf("limits %+v, goEnv %v; want %d threads", l, m.goEnv(), want)
}
if st := m.cpuStatus(); st["limits"].(buildcpu.Limits) != l {
if st := m.cpuStatus(); st["limits"].(buildcpu.Limits) != m.cpuLimits() {
t.Errorf("the status says %v", st)
}
}
+1 -2
View File
@@ -1,4 +1,3 @@
{
"capacity": "shanks=3,ace=2,g14=0,novox=0",
"cpu-cap": "shanks=50,g14=50,ace=75"
"capacity": "shanks=3,ace=2,g14=0,novox=0"
}
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildgo
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026124909-76deab6c67c2
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026132005-852ba81c5af1
github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026124909-76deab6c67c2 h1:DugsX7h46z+1FOiNhtDE43Vf1WInz2vgwi0mn/1VZDg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026124909-76deab6c67c2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026132005-852ba81c5af1 h1:6O9WKnlVR1uHV5DDQueHGbMAQzfVS06J7DBKjUPBG4E=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026132005-852ba81c5af1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
+1 -7
View File
@@ -29,12 +29,6 @@
"description": "How many asks of the Go build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.",
"pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*",
"default": "shanks=3,ace=2,g14=0,novox=0"
},
"cpu-cap": {
"type": "string",
"description": "The most of each node's processors this module's builds may take, as node=percent pairs separated by commas, such as shanks=50,ace=75 (novox/hq issue 1806); a node not named takes 50. The node-engine holds the module's runtime to it as a CPU quota, at idle priority, and each build step starts no more threads than its part of it.",
"pattern": "|[a-z0-9_-]+=([1-9]|[1-9][0-9]|100)%?(,[a-z0-9_-]+=([1-9]|[1-9][0-9]|100)%?)*",
"default": "shanks=50,g14=50,ace=75"
}
},
"tools": [
@@ -112,7 +106,7 @@
"path": "${dir:state}/settings.json",
"mode": "0600",
"owner": "mesh-build-go",
"takes-config": ["capacity", "cpu-cap"],
"takes-config": ["capacity"],
"merge": "json",
"template": "files/settings.json"
},
@@ -5,14 +5,15 @@ import (
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// The processors a step may take (novox/hq issue 1806). The kernel holds the module's runtime to idle priority and to
// its node's share of the processors (the setting cpu-cap, written into its unit by the node-engine); inside that
// bound a step starts no more threads than its part of it, so the steps running at once do not wait in the kernel's
// throttle.
// The processors a step may take (novox/hq ADR 0553, issue 1806). The node-engine holds this module's runtime, with
// every other build module's of the node, in one slice at idle priority under the node's one share of its processors;
// inside that bound a step starts no more threads than its part of it: the share divided by every build slot of the
// node, from the words the composer gives (buildstep.NodeLimits), so the steps running at once do not wait in the
// kernel's throttle.
// cpuLimits is what one step is held to now: the cap from the settings file, the machine's processors and the node's
// capacity, read at every call like the capacity, so a change takes effect at the next step.
func (m *Module) cpuLimits(say func(string, ...any)) buildcpu.Limits {
// cpuLimits is what one step is held to now. Without the composer's words, it assumes every build seat of the node
// beside it at this module's own capacity.
func (m *Module) cpuLimits() buildcpu.Limits {
slots := 1
switch {
case m.Capacity != nil:
@@ -20,20 +21,17 @@ func (m *Module) cpuLimits(say func(string, ...any)) buildcpu.Limits {
case m.Settings != "":
slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
}
if slots < 1 {
slots = 1
}
return buildstep.NodeLimits(m.Settings, m.Node, slots, say)
return buildstep.NodeLimits(slots)
}
// threadEnv is the environment buildah is given for its threads (it is a Go program). What a build's RUN lines start
// runs in the container's own environment, inside the runtime's cgroup: the quota and the nice level bind it.
// runs in the container's own environment, inside the runtime's cgroup: the slice's quota and the nice level bind it.
func (m *Module) threadEnv() []string {
return buildcpu.Env(m.cpuLimits(nil).Threads)
return buildcpu.Env(m.cpuLimits().Threads)
}
// cpuStatus is what the status tool says of it: the limits a step is given, and what the kernel holds this runtime
// to, read from its cgroup rather than assumed.
// to (its unit and its slice), read from its cgroup rather than assumed.
func (m *Module) cpuStatus() map[string]any {
return map[string]any{"limits": m.cpuLimits(m.Say), "in-force": buildcpu.ReadInForce()}
return map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()}
}
+1 -2
View File
@@ -1,4 +1,3 @@
{
"capacity": "shanks=3,ace=2,g14=0,novox=0",
"cpu-cap": "shanks=50,g14=50,ace=75"
"capacity": "shanks=3,ace=2,g14=0,novox=0"
}
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildimage
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026124909-76deab6c67c2
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026132005-852ba81c5af1
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026124909-76deab6c67c2 h1:DugsX7h46z+1FOiNhtDE43Vf1WInz2vgwi0mn/1VZDg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026124909-76deab6c67c2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026132005-852ba81c5af1 h1:6O9WKnlVR1uHV5DDQueHGbMAQzfVS06J7DBKjUPBG4E=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026132005-852ba81c5af1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+1 -7
View File
@@ -24,12 +24,6 @@
"description": "How many asks of the image build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.",
"pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*",
"default": "shanks=3,ace=2,g14=0,novox=0"
},
"cpu-cap": {
"type": "string",
"description": "The most of each node's processors this module's builds may take, as node=percent pairs separated by commas, such as shanks=50,ace=75 (novox/hq issue 1806); a node not named takes 50. The node-engine holds the module's runtime to it as a CPU quota, at idle priority, and each build step starts no more threads than its part of it.",
"pattern": "|[a-z0-9_-]+=([1-9]|[1-9][0-9]|100)%?(,[a-z0-9_-]+=([1-9]|[1-9][0-9]|100)%?)*",
"default": "shanks=50,g14=50,ace=75"
}
},
"tools": [
@@ -83,7 +77,7 @@
"path": "${dir:state}/settings.json",
"mode": "0600",
"owner": "mesh-build-image",
"takes-config": ["capacity", "cpu-cap"],
"takes-config": ["capacity"],
"merge": "json",
"template": "files/settings.json"
},
@@ -5,14 +5,15 @@ import (
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// The processors a step may take (novox/hq issue 1806). The kernel holds the module's runtime to idle priority and to
// its node's share of the processors (the setting cpu-cap, written into its unit by the node-engine); inside that
// bound a step starts no more threads than its part of it, so the steps running at once do not wait in the kernel's
// throttle.
// The processors a step may take (novox/hq ADR 0553, issue 1806). The node-engine holds this module's runtime, with
// every other build module's of the node, in one slice at idle priority under the node's one share of its processors;
// inside that bound a step starts no more threads than its part of it: the share divided by every build slot of the
// node, from the words the composer gives (buildstep.NodeLimits), so the steps running at once do not wait in the
// kernel's throttle.
// cpuLimits is what one step is held to now: the cap from the settings file, the machine's processors and the node's
// capacity, read at every call like the capacity, so a change takes effect at the next step.
func (m *Module) cpuLimits(say func(string, ...any)) buildcpu.Limits {
// cpuLimits is what one step is held to now. Without the composer's words, it assumes every build seat of the node
// beside it at this module's own capacity.
func (m *Module) cpuLimits() buildcpu.Limits {
slots := 1
switch {
case m.Capacity != nil:
@@ -20,20 +21,17 @@ func (m *Module) cpuLimits(say func(string, ...any)) buildcpu.Limits {
case m.Settings != "":
slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
}
if slots < 1 {
slots = 1
}
return buildstep.NodeLimits(m.Settings, m.Node, slots, say)
return buildstep.NodeLimits(slots)
}
// threadEnv is the environment every command of a step is given for its threads: GOMAXPROCS for esbuild and every Go
// program, UV_THREADPOOL_SIZE for Node's worker pool.
func (m *Module) threadEnv() []string {
return buildcpu.Env(m.cpuLimits(nil).Threads)
return buildcpu.Env(m.cpuLimits().Threads)
}
// cpuStatus is what the status tool says of it: the limits a step is given, and what the kernel holds this runtime
// to, read from its cgroup rather than assumed.
// to (its unit and its slice), read from its cgroup rather than assumed.
func (m *Module) cpuStatus() map[string]any {
return map[string]any{"limits": m.cpuLimits(m.Say), "in-force": buildcpu.ReadInForce()}
return map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()}
}
+1 -2
View File
@@ -1,4 +1,3 @@
{
"capacity": "shanks=3,ace=2,g14=0,novox=0",
"cpu-cap": "shanks=50,g14=50,ace=75"
"capacity": "shanks=3,ace=2,g14=0,novox=0"
}
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildnode
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026124909-76deab6c67c2
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026132005-852ba81c5af1
github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026124909-76deab6c67c2 h1:DugsX7h46z+1FOiNhtDE43Vf1WInz2vgwi0mn/1VZDg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026124909-76deab6c67c2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026132005-852ba81c5af1 h1:6O9WKnlVR1uHV5DDQueHGbMAQzfVS06J7DBKjUPBG4E=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026132005-852ba81c5af1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
+1 -7
View File
@@ -25,12 +25,6 @@
"description": "How many asks of the Node build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.",
"pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*",
"default": "shanks=3,ace=2,g14=0,novox=0"
},
"cpu-cap": {
"type": "string",
"description": "The most of each node's processors this module's builds may take, as node=percent pairs separated by commas, such as shanks=50,ace=75 (novox/hq issue 1806); a node not named takes 50. The node-engine holds the module's runtime to it as a CPU quota, at idle priority, and each build step starts no more threads than its part of it.",
"pattern": "|[a-z0-9_-]+=([1-9]|[1-9][0-9]|100)%?(,[a-z0-9_-]+=([1-9]|[1-9][0-9]|100)%?)*",
"default": "shanks=50,g14=50,ace=75"
}
},
"tools": [
@@ -105,7 +99,7 @@
"path": "${dir:state}/settings.json",
"mode": "0600",
"owner": "mesh-build-node",
"takes-config": ["capacity", "cpu-cap"],
"takes-config": ["capacity"],
"merge": "json",
"template": "files/settings.json"
},