Build modules run at most as many steps as their node's build CPU gives, and say a check's phases

Each of mesh-build-go, -node and -image declares cores-per-step (6, 2, 2) and hands it to the
SDK puller, which bounds the configured capacity by floor(slice quota cores / cores-per-step),
read live; thread limits and the status tools follow the effective capacity (issue 1806's
follow-up: three Go checks on shanks' 12-core slice starved past the 30-minute step bound).
mesh-build-go says its lint and test phases with the ask id (issue 1890).
This commit is contained in:
2026-10-26 19:55:31 +01:00
parent da29f77b10
commit 245143d948
21 changed files with 102 additions and 27 deletions
@@ -84,9 +84,13 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
// The capacity's own change wakes the puller (issue 1887): the runtime's watch of the config value as mesh-config
// resolved it; the settings file is read only when that watch cannot start. A test's Capacity is its own.
var watch func(string, func(stdio.ProvisionChange) error) error
// And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice
// gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity.
var cores func() float64
if capacity == nil {
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
watch = buildstep.WatchConfigServed
cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskGo], m.Say)
}
ask := m.askSteps(steps)
var load func(string) ([]byte, error)
@@ -97,7 +101,7 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
}
}
return &buildstep.Puller{Source: source, Language: delivery.AskGo, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load,
Capacity: capacity, WatchConfig: watch, Logf: m.Say, Has: m.Has,
Capacity: capacity, WatchConfig: watch, CoresPerStep: cores, Logf: m.Say, Has: m.Has,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
if err := m.Check(ctx); err != nil {
return nil, err
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"os"
"slices"
"strings"
@@ -125,12 +126,21 @@ func TestManifestClaimsTheGoBuildSeatAndReadsItsCapacity(t *testing.T) {
raw, _ := os.ReadFile(moduleDir + "/module.json")
var doc struct {
Config map[string]struct {
Default string `json:"default"`
Default json.RawMessage `json:"default"`
} `json:"config"`
}
_ = json.Unmarshal(raw, &doc)
if doc.Config[buildstep.CapacitySetting].Default != buildstep.DefaultCapacities {
t.Errorf("the capacity's default = %q, want %q", doc.Config[buildstep.CapacitySetting].Default, buildstep.DefaultCapacities)
// Issue 1806's follow-up: the cores a step needs is a setting of its own, in the settings file, with the SDK's default.
if cores := string(doc.Config[buildstep.CoresPerStepSetting].Default); cores != fmt.Sprint(buildstep.DefaultCoresPerStep[delivery.AskGo]) {
t.Errorf("the cores-per-step default = %s, want %g", cores, buildstep.DefaultCoresPerStep[delivery.AskGo])
}
if got := buildstep.NodeCoresPerStep(moduleDir+"/files/settings.json", 0, nil)(); got != buildstep.DefaultCoresPerStep[delivery.AskGo] {
t.Errorf("the settings template holds cores-per-step %g", got)
}
var capacity string
_ = json.Unmarshal(doc.Config[buildstep.CapacitySetting].Default, &capacity)
if capacity != buildstep.DefaultCapacities {
t.Errorf("the capacity's default = %q, want %q", capacity, buildstep.DefaultCapacities)
}
settings := resource(t, m, "settings")
if settings["path"] != "${dir:state}/settings.json" {
@@ -15,6 +15,8 @@ import (
func (m *Module) cpuLimits() buildcpu.Limits {
slots := 1
switch {
case m.Held != nil:
slots = m.Held.CapacityNow() // the capacity the puller judges by, bounded by the node's build slice
case m.Capacity != nil:
slots = m.Capacity()
case m.Settings != "":
@@ -32,5 +34,9 @@ func (m *Module) threadEnv() []string {
// cpuStatus is what the status tool says of it: the limits a step is given, and what the kernel holds this runtime
// 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(), "in-force": buildcpu.ReadInForce()}
out := map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()}
if m.Held != nil {
out["capacity"] = m.Held.CapacityDetail() // the capacity judged by and why (issue 1806's follow-up)
}
return out
}
@@ -14,6 +14,7 @@ import (
"strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
@@ -132,6 +133,9 @@ func (m *Module) lint(ctx context.Context, run forgekey.Runner, tree, d string,
// lintAll lints each directory, up to m.parallel() at once like the level's tests; the failures join in directory order
// and the error is the first directory's, so the answer does not depend on which ended first (issue 1930).
func (m *Module) lintAll(ctx context.Context, run forgekey.Runner, tree string, dirs []string, env func(d string) []string) ([]Failure, error) {
// The ask's phases say it (issue 1890): lint of the directories, then their tests.
buildstep.Phase(ctx, fmt.Sprintf("unit: lint of %d directories", len(dirs)))
defer buildstep.Phase(ctx, fmt.Sprintf("unit: tests of %d directories", len(dirs)))
type answer struct {
failed []Failure
err error
+2 -1
View File
@@ -1,3 +1,4 @@
{
"capacity": "shanks=3,ace=2,g14=0,novox=0"
"capacity": "shanks=3,ace=2,g14=0,novox=0",
"cores-per-step": 6
}
+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.20261026142629-ff81bdf8cc8f
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026185254-02f50bd9d642
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.20261026142629-ff81bdf8cc8f h1:GYLCvz2x+JNUOTbXzyh2QwzX0sTzvRlXR7lGocWjk3A=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026185254-02f50bd9d642 h1:mSBxF9KgIJyNL0gOKYq5naWewUj2yazU/tGOh12Tze0=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026185254-02f50bd9d642/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=
+7 -1
View File
@@ -29,6 +29,12 @@
"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"
},
"cores-per-step": {
"type": "number",
"description": "How many processors one Go step of this module needs (a full check of a large repository takes what it is given: three at about 4 cores each ran past the 30-minute step bound on 2026-10-26). The capacity above is an upper bound: on each node the module runs at most floor(the cores of its build slice's CPU quota / this) steps at once, the quota read live from its cgroup (issue 1806), so a lowered build CPU share never starves its steps. Above 0.",
"minimum": 0.1,
"default": 6
}
},
"tools": [
@@ -107,7 +113,7 @@
"path": "${dir:state}/settings.json",
"mode": "0600",
"owner": "mesh-build-go",
"takes-config": ["capacity"],
"takes-config": ["capacity", "cores-per-step"],
"merge": "json",
"template": "files/settings.json"
},
@@ -40,9 +40,13 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
// The capacity's own change wakes the puller (issue 1887): the runtime's watch of the config value as mesh-config
// resolved it; the settings file is read only when that watch cannot start. A test's Capacity is its own.
var watch func(string, func(stdio.ProvisionChange) error) error
// And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice
// gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity.
var cores func() float64
if capacity == nil {
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
watch = buildstep.WatchConfigServed
cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskImage], m.Say)
}
ask := m.askSteps(steps)
var load func(string) ([]byte, error)
@@ -50,7 +54,7 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
load = func(key string) ([]byte, error) { return m.ReadPauses.Get(key) }
}
return &buildstep.Puller{Source: source, Language: delivery.AskImage, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load,
Capacity: capacity, WatchConfig: watch, Logf: m.Say,
Capacity: capacity, WatchConfig: watch, CoresPerStep: cores, Logf: m.Say,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
if err := m.Check(ctx); err != nil {
return nil, err
@@ -15,6 +15,8 @@ import (
func (m *Module) cpuLimits() buildcpu.Limits {
slots := 1
switch {
case m.Held != nil:
slots = m.Held.CapacityNow() // the capacity the puller judges by, bounded by the node's build slice
case m.Capacity != nil:
slots = m.Capacity()
case m.Settings != "":
@@ -32,5 +34,9 @@ func (m *Module) threadEnv() []string {
// cpuStatus is what the status tool says of it: the limits a step is given, and what the kernel holds this runtime
// 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(), "in-force": buildcpu.ReadInForce()}
out := map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()}
if m.Held != nil {
out["capacity"] = m.Held.CapacityDetail() // the capacity judged by and why (issue 1806's follow-up)
}
return out
}
+2 -1
View File
@@ -1,3 +1,4 @@
{
"capacity": "shanks=3,ace=2,g14=0,novox=0"
"capacity": "shanks=3,ace=2,g14=0,novox=0",
"cores-per-step": 2
}
+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.20261026142629-ff81bdf8cc8f
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026185254-02f50bd9d642
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.20261026142629-ff81bdf8cc8f h1:GYLCvz2x+JNUOTbXzyh2QwzX0sTzvRlXR7lGocWjk3A=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026185254-02f50bd9d642 h1:mSBxF9KgIJyNL0gOKYq5naWewUj2yazU/tGOh12Tze0=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026185254-02f50bd9d642/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=
+7 -1
View File
@@ -24,6 +24,12 @@
"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"
},
"cores-per-step": {
"type": "number",
"description": "How many processors one image step of this module needs (buildah, whose RUN lines are mostly fetches). The capacity above is an upper bound: on each node the module runs at most floor(the cores of its build slice's CPU quota / this) steps at once, the quota read live from its cgroup (issue 1806), so a lowered build CPU share never starves its steps. Above 0.",
"minimum": 0.1,
"default": 2
}
},
"tools": [
@@ -78,7 +84,7 @@
"path": "${dir:state}/settings.json",
"mode": "0600",
"owner": "mesh-build-image",
"takes-config": ["capacity"],
"takes-config": ["capacity", "cores-per-step"],
"merge": "json",
"template": "files/settings.json"
},
@@ -48,9 +48,13 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
// The capacity's own change wakes the puller (issue 1887): the runtime's watch of the config value as mesh-config
// resolved it; the settings file is read only when that watch cannot start. A test's Capacity is its own.
var watch func(string, func(stdio.ProvisionChange) error) error
// And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice
// gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity.
var cores func() float64
if capacity == nil {
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
watch = buildstep.WatchConfigServed
cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskNode], m.Say)
}
ask := m.askSteps(steps)
var load func(string) ([]byte, error)
@@ -58,7 +62,7 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
load = func(key string) ([]byte, error) { return m.ReadPauses.Get(key) }
}
return &buildstep.Puller{Source: source, Language: delivery.AskNode, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load,
Capacity: capacity, WatchConfig: watch, Logf: m.Say,
Capacity: capacity, WatchConfig: watch, CoresPerStep: cores, Logf: m.Say,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
if err := m.Prepare(ctx); err != nil {
return nil, err
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"testing"
@@ -83,12 +84,21 @@ func TestManifestTakesTheBuildCapacity(t *testing.T) {
raw, _ := os.ReadFile(moduleDir + "/module.json")
var doc struct {
Config map[string]struct {
Default string `json:"default"`
Default json.RawMessage `json:"default"`
} `json:"config"`
}
_ = json.Unmarshal(raw, &doc)
if doc.Config[buildstep.CapacitySetting].Default != buildstep.DefaultCapacities {
t.Errorf("the capacity's default = %q", doc.Config[buildstep.CapacitySetting].Default)
// Issue 1806's follow-up: the cores a step needs is a setting of its own, in the settings file, with the SDK's default.
if cores := string(doc.Config[buildstep.CoresPerStepSetting].Default); cores != fmt.Sprint(buildstep.DefaultCoresPerStep[delivery.AskNode]) {
t.Errorf("the cores-per-step default = %s, want %g", cores, buildstep.DefaultCoresPerStep[delivery.AskNode])
}
if got := buildstep.NodeCoresPerStep(moduleDir+"/files/settings.json", 0, nil)(); got != buildstep.DefaultCoresPerStep[delivery.AskNode] {
t.Errorf("the settings template holds cores-per-step %g", got)
}
var capacity string
_ = json.Unmarshal(doc.Config[buildstep.CapacitySetting].Default, &capacity)
if capacity != buildstep.DefaultCapacities {
t.Errorf("the capacity's default = %q", capacity)
}
if resource(t, m, "settings")["path"] != "${dir:state}/settings.json" {
t.Error("no settings file")
@@ -15,6 +15,8 @@ import (
func (m *Module) cpuLimits() buildcpu.Limits {
slots := 1
switch {
case m.Held != nil:
slots = m.Held.CapacityNow() // the capacity the puller judges by, bounded by the node's build slice
case m.Capacity != nil:
slots = m.Capacity()
case m.Settings != "":
@@ -32,5 +34,9 @@ func (m *Module) threadEnv() []string {
// cpuStatus is what the status tool says of it: the limits a step is given, and what the kernel holds this runtime
// 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(), "in-force": buildcpu.ReadInForce()}
out := map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()}
if m.Held != nil {
out["capacity"] = m.Held.CapacityDetail() // the capacity judged by and why (issue 1806's follow-up)
}
return out
}
+2 -1
View File
@@ -1,3 +1,4 @@
{
"capacity": "shanks=3,ace=2,g14=0,novox=0"
"capacity": "shanks=3,ace=2,g14=0,novox=0",
"cores-per-step": 2
}
+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.20261026142629-ff81bdf8cc8f
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026185254-02f50bd9d642
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.20261026142629-ff81bdf8cc8f h1:GYLCvz2x+JNUOTbXzyh2QwzX0sTzvRlXR7lGocWjk3A=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026185254-02f50bd9d642 h1:mSBxF9KgIJyNL0gOKYq5naWewUj2yazU/tGOh12Tze0=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026185254-02f50bd9d642/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=
+7 -1
View File
@@ -25,6 +25,12 @@
"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"
},
"cores-per-step": {
"type": "number",
"description": "How many processors one Node step of this module needs (tsc, esbuild and a test runner; a median step takes 15 s). The capacity above is an upper bound: on each node the module runs at most floor(the cores of its build slice's CPU quota / this) steps at once, the quota read live from its cgroup (issue 1806), so a lowered build CPU share never starves its steps. Above 0.",
"minimum": 0.1,
"default": 2
}
},
"tools": [
@@ -100,7 +106,7 @@
"path": "${dir:state}/settings.json",
"mode": "0600",
"owner": "mesh-build-node",
"takes-config": ["capacity"],
"takes-config": ["capacity", "cores-per-step"],
"merge": "json",
"template": "files/settings.json"
},