The composer gives build modules the node's share: idle, capped builds (issue 1806, ADR 0553/0554, change B) #95

Merged
mesh-tools merged 2 commits from fix/build-cpu-yields-compose into main 2026-10-26 14:31:14 +00:00
3 changed files with 131 additions and 0 deletions
+14
View File
@@ -311,6 +311,17 @@ func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
if words, err = r.readsWords(m, words, with); err != nil {
return nil, err
}
// A build seat's holder gives way on its node, and its steps are told their part of the node's share
// (novox/hq ADR 0553).
yielding, yieldWords := r.yieldsOf(m, with)
if len(yieldWords) > 0 {
if words == nil {
words = map[string]string{}
}
for k, v := range yieldWords {
words[k] = v
}
}
if len(words) > 0 {
body, err := json.Marshal(map[string]map[string]string{m.Module: words})
if err != nil {
@@ -329,6 +340,9 @@ func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
process[HealthField] = m.RuntimeHealth
healthInto(process, m, with)
}
if yielding != nil {
process["yields"] = yielding
}
if err := artifactsInto(process, RuntimeModule, with); err != nil {
return nil, err
}
+48
View File
@@ -498,6 +498,54 @@ func TestAModuleOfItsOwnAccountIsServedByARuntimeOfItsOwn(t *testing.T) {
if _, told := env[RuntimeOperatorAccount]; told {
t.Error("a runtime of a module's own account is told the operator's account")
}
if _, yields := own["yields"]; yields {
t.Errorf("telegram holds no build seat and yields: %v", own["yields"])
}
// The build seats' holders yield (ADR 0553): idle, under the node's one share from the node-engine's build-cpu-share,
// and their steps are told it.
builder := func(name, seat string) Manifest {
b := aToolsModule(t, name, "tools/index.js")
b.RunsAs, b.SecretsOwner = name, name
b.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/" + name + "/broker"}}
b.Claims = []Claim{{Name: seat}}
return b
}
gob, img := builder("gob", "node-build-go"), builder("img", "node-build-image")
with.Needed["gob"], with.Needed["img"] = map[string]string{"broker": "own"}, map[string]string{"broker": "own"}
for _, c := range []struct {
settings SettingsBy
share string
}{
{nil, "50"},
{SettingsBy{"mesh-host": {{From: "anchor", Values: map[string]any{"build-cpu-share": float64(75)}}}}, "75"},
{SettingsBy{"mesh-host": {{From: "anchor", Values: map[string]any{"build-cpu-share": float64(0)}}}}, "50"},
} {
with.Settings = c.settings
runtimes, err := Resolution{Node: "anchor", Modules: []Manifest{gob, img, telegram, goRuntime}}.ownRuntimes(with)
if err != nil {
t.Fatal(err)
}
if len(runtimes) != 3 {
t.Fatalf("%d runtimes of their own, want gob's, img's and telegram's", len(runtimes))
}
for _, p := range runtimes {
name := fmt.Sprint(p["name"])
words := map[string]map[string]string{}
_ = json.Unmarshal([]byte(p["env"].(map[string]string)[RuntimeToolEnv]), &words)
mod := strings.TrimSuffix(name, "-runtime")
if mod == "telegram" {
if p["yields"] != nil || words[mod]["MESH_BUILD_CPU_SHARE"] != "" {
t.Errorf("telegram yields under %v: %v %v", c.settings, p["yields"], words)
}
continue
}
if got := fmt.Sprint(p["yields"]); got != "map[cpu-share:"+c.share+" priority:idle]" ||
words[mod]["MESH_BUILD_CPU_SHARE"] != c.share {
t.Errorf("%s under %v yields %s with words %v; want share %s", name, c.settings, got, words[mod], c.share)
}
}
}
with.Settings = nil
// Without the machine's runtime to run it from, it is refused in words.
if _, err := (Resolution{Node: "anchor", Modules: []Manifest{telegram}}).ownRuntimes(with); err == nil {
t.Error("a module of its own account composed without a runtime program")
+69
View File
@@ -0,0 +1,69 @@
package catalogue
import (
"strconv"
"git.novox.be/novox/mesh-sdk/go/buildcpu"
)
// yieldsOf is how the runtime of its own a module is served by gives way on this node (novox/hq ADR 0553), or nil when
// it does not, and the word its steps size their threads from:
//
// - a module holding a seat that yields (Seat.Yields, the build seats) runs at idle priority under the node's one share
// of its processors: the node-engine's own config build-cpu-share (buildcpu.ShareSetting) as this node's layers give
// it, its default where none does. Every yielding module of the node carries the same share, so the node-engine's
// one yielding slice has one quota (ADR 0553 §2, §3).
// - its word: that share (buildcpu.ShareWord), which a step divides by its own module's capacity (ADR 0553 §4).
//
// Never a refusal: a share out of range is the default, so a mistyped value bounds the builds at the default and never
// stops the node's whole composition.
func (r Resolution) yieldsOf(m Manifest, with Rendering) (map[string]any, map[string]string) {
if !yields(m) {
return nil, nil
}
engine := Manifest{Module: engineModule}
for _, e := range r.Modules {
if e.Module == engineModule {
engine = e
}
}
share := buildcpu.DefaultPercent
for _, s := range Effective(engine, with.Settings[engineModule]) {
if s.Key == buildcpu.ShareSetting {
if n, ok := percentOf(s.Value); ok {
share = n
}
}
}
return map[string]any{"priority": "idle", "cpu-share": share},
map[string]string{buildcpu.ShareWord: strconv.Itoa(share)}
}
// yields is whether a module claims a seat that yields.
func yields(m Manifest) bool {
for _, c := range m.Claims {
if s, ok := SeatNamed(c.Name); ok && s.Yields {
return true
}
}
return false
}
// percentOf is a setting's value as a percent from 1 to 100: a number, or a string holding one.
func percentOf(v any) (int, bool) {
var n int
switch x := v.(type) {
case float64:
n = int(x)
case int:
n = x
case string:
var err error
if n, err = strconv.Atoi(x); err != nil {
return 0, false
}
default:
return 0, false
}
return n, n >= 1 && n <= 100
}