From 6ac8c5c2291402bc3c96a16c1bf6073cb900f67a Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 14:42:20 +0100 Subject: [PATCH] =?UTF-8?q?The=20composer=20gives=20a=20build=20seat's=20h?= =?UTF-8?q?older=20its=20node's=20one=20share,=20the=20node-engine's=20bui?= =?UTF-8?q?ld-cpu-share=20(ADR=200553=20=C2=A73,=20=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second half of ADR 0553, merged only once the node-engine that decodes yields runs on every node (§5). The share is the node-engine's own config as the node's layers give it; each step divides it by its own module's capacity. A share out of range bounds builds at the default and never refuses the node's composition. A change of the share recomposes the build runtimes, which restart once. --- go/catalogue/runtime.go | 14 ++++++++ go/catalogue/runtime_test.go | 48 +++++++++++++++++++++++++ go/catalogue/yields.go | 69 ++++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 go/catalogue/yields.go diff --git a/go/catalogue/runtime.go b/go/catalogue/runtime.go index b0139c8..46deb00 100644 --- a/go/catalogue/runtime.go +++ b/go/catalogue/runtime.go @@ -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 } diff --git a/go/catalogue/runtime_test.go b/go/catalogue/runtime_test.go index 16c02b4..edc3a41 100644 --- a/go/catalogue/runtime_test.go +++ b/go/catalogue/runtime_test.go @@ -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") diff --git a/go/catalogue/yields.go b/go/catalogue/yields.go new file mode 100644 index 0000000..cd9bbfa --- /dev/null +++ b/go/catalogue/yields.go @@ -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 +}