Build seats yield to one shared slice per node; the composer half waits for the engines (ADR 0553, issue 1806)
Review round 1: the decoder type ships before any composer can emit it, so a routine SDK bump never sends yields to an engine that refuses them; the composer half moves to a later change. Every yielding process of a node goes into one top-level slice, meshyield.slice, so the three build modules share one quota instead of taking one each; the share is one node value, node-tools' build-cpu-share, and each step's threads divide it by every build slot of the node (words from the composer, a conservative fallback without them).
This commit is contained in:
+97
-127
@@ -1,94 +1,50 @@
|
|||||||
// Package buildcpu is how much of a build node's processors its builds may take, and how they yield to everything else
|
// Package buildcpu is how much of a build node's processors its builds may take, and how they yield to everything else
|
||||||
// there (novox/hq issue 1806; the operator, 2026-10-26: "limit CPU usage on our build nodes so the total cpu usage never
|
// there (novox/hq ADR 0553, issue 1806; the operator, 2026-10-26: "limit CPU usage on our build nodes so the total cpu
|
||||||
// impacts the normal workings of our nodes").
|
// usage never impacts the normal workings of our nodes ... We have 3 build agents so they should share the cpu power").
|
||||||
//
|
//
|
||||||
// Three parts, each with one owner:
|
// Three parts, each with one owner:
|
||||||
//
|
//
|
||||||
// - **Priority.** A build seat's holder (catalogue.Seat.Yields) runs at the lowest CPU and IO priority there is: the
|
// - **Priority.** A build seat's holder (catalogue.Seat.Yields) runs at the lowest priority there is: the composer
|
||||||
// composer gives its runtime process declaration.Yields, and the node-engine writes it into the unit as
|
// gives its runtime process declaration.Yields, and the node-engine places every yielding process of the node in
|
||||||
// CPUWeight=idle, IOWeight=1, Nice=19 and the best-effort IO class at its lowest level. Interactive and service
|
// one slice (declaration.YieldSlice) whose CPU weight is idle, with Nice=19 for every process it starts.
|
||||||
// work always comes first; builds take the cycles nothing else wants.
|
// Interactive and service work always comes first; builds take the cycles nothing else wants.
|
||||||
// - **The cap.** The build modules' setting `cpu-cap` (Setting) gives each node the share of its processors its builds
|
// - **One cap per node, shared.** The node's share of its processors is one value, the node-tools runtime's setting
|
||||||
// may take at most, as `<node>=<percent>` pairs. The composer turns this node's share into Yields.CPUShare, and the
|
// `build-cpu-share` (ShareSetting), set per node through the node's settings layer. The node-engine writes it as the
|
||||||
// node-engine into CPUQuota for the machine's processor count: a hard bound, held by the kernel.
|
// slice's CPUQuota: one bound the kernel holds for all the node's build modules together, never one per module.
|
||||||
// - **Threads.** Inside that bound a step starts no more threads than its part of it (Threads): GOMAXPROCS, `go
|
// - **Threads.** Inside that bound a step starts no more threads than its part of it (Threads): the node's share
|
||||||
// test -p`, the Node test runner's concurrency. A step with more runnable threads than its quota only waits in the
|
// divided by every build slot of the node, across all its build modules (the composer hands both as words,
|
||||||
// kernel's throttle, and its timeouts with it.
|
// ShareWord and SlotsWord). GOMAXPROCS sets every go command's -p, the compiler's concurrency, a test binary's
|
||||||
|
// processors and -parallel.
|
||||||
package buildcpu
|
package buildcpu
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Setting is the build modules' setting of each node's processor cap: `<node>=<percent>` pairs separated by commas,
|
// ShareSetting is the node-tools runtime's setting of the node's processor share for its builds, in percent of the
|
||||||
// such as `shanks=50,ace=75`; a percent sign after the number is allowed. Each build module declares it with Defaults
|
// machine's processors, 1 to 100: one value per node, set through the node's settings layer (novox/hq ADR 0553 §3).
|
||||||
// as its default.
|
const ShareSetting = "build-cpu-share"
|
||||||
const Setting = "cpu-cap"
|
|
||||||
|
|
||||||
// Defaults is the setting's default: the operator's two desktops (shanks, g14) keep half their processors for
|
// DefaultPercent is the share where nothing sets one: half the machine, so a desktop keeps half its processors for
|
||||||
// themselves at all times, and ace, a server, builds on three quarters. A node it does not name has DefaultPercent.
|
// itself whatever builds.
|
||||||
const Defaults = "shanks=50,g14=50,ace=75"
|
|
||||||
|
|
||||||
// DefaultPercent is the cap of a node the setting does not name.
|
|
||||||
const DefaultPercent = 50
|
const DefaultPercent = 50
|
||||||
|
|
||||||
// Parse reads the setting's pairs. A percent outside 1 to 100 is refused: 0 would stop every build without pausing the
|
// BuildSeats is how many seats yield (catalogue.Seat.Yields): one build module per language. A step that is told no
|
||||||
// seat (the capacity setting says that, with 0 asks), and more than 100 is no cap.
|
// node-wide slot count assumes every one of them runs its full capacity beside it.
|
||||||
func Parse(s string) (map[string]int, error) {
|
const BuildSeats = 3
|
||||||
out := map[string]int{}
|
|
||||||
for _, pair := range strings.Split(s, ",") {
|
|
||||||
pair = strings.TrimSpace(pair)
|
|
||||||
if pair == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
node, value, ok := strings.Cut(pair, "=")
|
|
||||||
node = strings.TrimSpace(node)
|
|
||||||
value = strings.TrimSuffix(strings.TrimSpace(value), "%")
|
|
||||||
n, err := strconv.Atoi(strings.TrimSpace(value))
|
|
||||||
if !ok || node == "" || err != nil || n < 1 || n > 100 {
|
|
||||||
return nil, fmt.Errorf("the processor cap %q is not <node>=<percent of its processors, 1 to 100>", pair)
|
|
||||||
}
|
|
||||||
if _, dup := out[node]; dup {
|
|
||||||
return nil, fmt.Errorf("the processor cap names %s twice", node)
|
|
||||||
}
|
|
||||||
out[node] = n
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Of is node's cap in percent of its processors: DefaultPercent for a node the setting does not name.
|
// The words the composer gives every yielding module (novox/hq ADR 0553 §4): the node's share, and every build slot
|
||||||
func Of(setting, node string) (int, error) {
|
// of the node across all its yielding modules.
|
||||||
caps, err := Parse(setting)
|
const (
|
||||||
if err != nil {
|
ShareWord = "MESH_BUILD_CPU_SHARE"
|
||||||
return 0, err
|
SlotsWord = "MESH_BUILD_SLOTS"
|
||||||
}
|
)
|
||||||
if n, ok := caps[node]; ok {
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
return DefaultPercent, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format is a cap map in the setting's own words, sorted by node.
|
// Quota is a CPUQuota for a share of a machine with cores processors: systemd counts a quota in percent of one
|
||||||
func Format(caps map[string]int) string {
|
// processor, so half of 24 is 1200%. At least 100%, one processor, so a share on a small machine never stops a step.
|
||||||
nodes := make([]string, 0, len(caps))
|
|
||||||
for n := range caps {
|
|
||||||
nodes = append(nodes, n)
|
|
||||||
}
|
|
||||||
sort.Strings(nodes)
|
|
||||||
parts := make([]string, 0, len(nodes))
|
|
||||||
for _, n := range nodes {
|
|
||||||
parts = append(parts, fmt.Sprintf("%s=%d", n, caps[n]))
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quota is the unit's CPUQuota for a share of a machine with cores processors: systemd counts a quota in percent of
|
|
||||||
// one processor, so half of 24 is 1200%. At least 100%, one processor, so a cap on a small machine never stops a step.
|
|
||||||
func Quota(percent, cores int) int {
|
func Quota(percent, cores int) int {
|
||||||
q := percent * cores
|
q := percent * cores
|
||||||
if q < 100 {
|
if q < 100 {
|
||||||
@@ -97,14 +53,14 @@ func Quota(percent, cores int) int {
|
|||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
// Threads is how many threads one step may run at once: its part of the cap when slots steps run beside it, at least
|
// Threads is how many threads one step may run at once: its part of the share when slots steps may run on the node
|
||||||
// one. Rounded down, so the steps together stay inside the quota.
|
// at once, at least one. Rounded down, so the steps together stay inside the quota.
|
||||||
func Threads(percent, cores, slots int) int {
|
func Threads(percent, cores, slots int) int {
|
||||||
if slots < 1 {
|
if slots < 1 {
|
||||||
slots = 1
|
slots = 1
|
||||||
}
|
}
|
||||||
if percent < 1 || percent > 100 {
|
if percent < 1 || percent > 100 {
|
||||||
percent = 100
|
percent = DefaultPercent
|
||||||
}
|
}
|
||||||
t := cores * percent / 100 / slots
|
t := cores * percent / 100 / slots
|
||||||
if t < 1 {
|
if t < 1 {
|
||||||
@@ -115,28 +71,36 @@ func Threads(percent, cores, slots int) int {
|
|||||||
|
|
||||||
// Limits is what one step is held to on this node, as a status tool says it.
|
// Limits is what one step is held to on this node, as a status tool says it.
|
||||||
type Limits struct {
|
type Limits struct {
|
||||||
// Percent is the node's cap in percent of its processors, and Setting the words it came from.
|
// Percent is the node's share in percent of its processors; From says where it was read: the composer's word,
|
||||||
Percent int `json:"cap-percent"`
|
// or the default.
|
||||||
Setting string `json:"cap-setting"`
|
Percent int `json:"share-percent"`
|
||||||
|
From string `json:"share-from"`
|
||||||
// Cores is the machine's processor count.
|
// Cores is the machine's processor count.
|
||||||
Cores int `json:"cores"`
|
Cores int `json:"cores"`
|
||||||
// Slots is the node's build capacity: steps at once.
|
// Slots is every build slot of the node, across all its build modules; SlotsFrom where it was read.
|
||||||
Slots int `json:"slots"`
|
Slots int `json:"node-slots"`
|
||||||
|
SlotsFrom string `json:"slots-from"`
|
||||||
// Threads is what each step is given (Threads).
|
// Threads is what each step is given (Threads).
|
||||||
Threads int `json:"threads-per-step"`
|
Threads int `json:"threads-per-step"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LimitsFor is the limits of a step on node, from the cap setting and the node's capacity. A setting that cannot be read
|
// FromWords is the limits of a step from the words the composer gave this process (ShareWord, SlotsWord). Where a word
|
||||||
// gives the default cap and says why in the error, so a step still runs with a bound rather than none.
|
// is absent or not a number: the default share, and own × BuildSeats slots. A module told nothing assumes every build
|
||||||
func LimitsFor(setting, node string, slots int) (Limits, error) {
|
// module of the node runs its full capacity beside it, so the steps together never take more than the default share.
|
||||||
l := Limits{Setting: setting, Cores: runtime.NumCPU(), Slots: slots}
|
func FromWords(getenv func(string) string, own int) Limits {
|
||||||
p, err := Of(setting, node)
|
if own < 1 {
|
||||||
if err != nil {
|
own = 1
|
||||||
p = DefaultPercent
|
|
||||||
}
|
}
|
||||||
l.Percent = p
|
l := Limits{Percent: DefaultPercent, From: "default", Cores: runtime.NumCPU(), Slots: own * BuildSeats,
|
||||||
l.Threads = Threads(p, l.Cores, slots)
|
SlotsFrom: "own capacity × " + strconv.Itoa(BuildSeats) + " build seats"}
|
||||||
return l, err
|
if n, err := strconv.Atoi(strings.TrimSpace(getenv(ShareWord))); err == nil && n >= 1 && n <= 100 {
|
||||||
|
l.Percent, l.From = n, ShareWord
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(getenv(SlotsWord))); err == nil && n >= 1 {
|
||||||
|
l.Slots, l.SlotsFrom = n, SlotsWord
|
||||||
|
}
|
||||||
|
l.Threads = Threads(l.Percent, l.Cores, l.Slots)
|
||||||
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
// Env is the environment a step's commands are given so they start no more threads than they may: GOMAXPROCS for every
|
// Env is the environment a step's commands are given so they start no more threads than they may: GOMAXPROCS for every
|
||||||
@@ -150,32 +114,30 @@ func Env(threads int) []string {
|
|||||||
return []string{"GOMAXPROCS=" + t, "UV_THREADPOOL_SIZE=" + t}
|
return []string{"GOMAXPROCS=" + t, "UV_THREADPOOL_SIZE=" + t}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithEnv is env with Env(threads) in place of any GOMAXPROCS or UV_THREADPOOL_SIZE it carried.
|
// InForce is what the kernel holds this process to, read rather than assumed (evidence over assertion): its nice
|
||||||
func WithEnv(env []string, threads int) []string {
|
// level, its cgroup (the unit) and the cgroup's parent (the slice the node-engine placed it in), each one's CPU weight,
|
||||||
out := make([]string, 0, len(env)+2)
|
// idle mark and quota. A field the machine does not answer is left empty and Unread says which.
|
||||||
for _, kv := range env {
|
//
|
||||||
if strings.HasPrefix(kv, "GOMAXPROCS=") || strings.HasPrefix(kv, "UV_THREADPOOL_SIZE=") {
|
// The IO weight is read but binds only where the disk's scheduler honours it (bfq, or the io.cost controller); under
|
||||||
continue
|
// the scheduler `none`, the usual one for NVMe, it does nothing, and the CPU bound is what holds.
|
||||||
}
|
type InForce struct {
|
||||||
out = append(out, kv)
|
Nice int `json:"nice"`
|
||||||
}
|
Cgroup string `json:"cgroup,omitempty"`
|
||||||
return append(out, Env(threads)...)
|
Unit Group `json:"unit"`
|
||||||
|
Slice Group `json:"slice"`
|
||||||
|
// Quota is the tighter of the two groups' cpu.max as a percent of one processor, 0 with none.
|
||||||
|
Quota int `json:"quota-percent,omitempty"`
|
||||||
|
Unread []string `json:"unread,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// InForce is what the kernel holds this process to, read rather than assumed (evidence over assertion): its nice
|
// Group is one cgroup's controls.
|
||||||
// level, its cgroup, the cgroup's CPU weight, idle mark and quota. A field the machine does not answer is left empty
|
type Group struct {
|
||||||
// and Unread says which.
|
Path string `json:"path,omitempty"`
|
||||||
type InForce struct {
|
|
||||||
Nice int `json:"nice"`
|
|
||||||
Cgroup string `json:"cgroup,omitempty"`
|
|
||||||
CPUIdle string `json:"cpu-idle,omitempty"`
|
CPUIdle string `json:"cpu-idle,omitempty"`
|
||||||
CPUWeight string `json:"cpu-weight,omitempty"`
|
CPUWeight string `json:"cpu-weight,omitempty"`
|
||||||
// CPUMax is the cgroup's cpu.max: "<quota µs> <period µs>", or "max" with no quota.
|
// CPUMax is the cgroup's cpu.max: "<quota µs> <period µs>", or "max" with no quota.
|
||||||
CPUMax string `json:"cpu-max,omitempty"`
|
CPUMax string `json:"cpu-max,omitempty"`
|
||||||
// Quota is CPUMax as a percent of one processor, 0 with none.
|
IOWeight string `json:"io-weight,omitempty"`
|
||||||
Quota int `json:"quota-percent,omitempty"`
|
|
||||||
IOWeight string `json:"io-weight,omitempty"`
|
|
||||||
Unread []string `json:"unread,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cgroupRoot is where the unified hierarchy is mounted. A variable so a test can point it elsewhere.
|
// cgroupRoot is where the unified hierarchy is mounted. A variable so a test can point it elsewhere.
|
||||||
@@ -184,6 +146,9 @@ var cgroupRoot = "/sys/fs/cgroup"
|
|||||||
// procSelfCgroup is this process's cgroup file. A variable so a test can point it elsewhere.
|
// procSelfCgroup is this process's cgroup file. A variable so a test can point it elsewhere.
|
||||||
var procSelfCgroup = "/proc/self/cgroup"
|
var procSelfCgroup = "/proc/self/cgroup"
|
||||||
|
|
||||||
|
// procSelfStat is this process's stat file. A variable so a test can point it elsewhere.
|
||||||
|
var procSelfStat = "/proc/self/stat"
|
||||||
|
|
||||||
// ReadInForce reads what the kernel holds this process to.
|
// ReadInForce reads what the kernel holds this process to.
|
||||||
func ReadInForce() InForce {
|
func ReadInForce() InForce {
|
||||||
var f InForce
|
var f InForce
|
||||||
@@ -202,20 +167,28 @@ func ReadInForce() InForce {
|
|||||||
f.Unread = append(f.Unread, "cgroup: not on the unified hierarchy")
|
f.Unread = append(f.Unread, "cgroup: not on the unified hierarchy")
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
dir := cgroupRoot + f.Cgroup
|
read := func(path string) Group {
|
||||||
read := func(name string) string {
|
g := Group{Path: path}
|
||||||
b, err := os.ReadFile(dir + "/" + name)
|
value := func(name string) string {
|
||||||
if err != nil {
|
b, err := os.ReadFile(cgroupRoot + path + "/" + name)
|
||||||
f.Unread = append(f.Unread, name+": "+err.Error())
|
if err != nil {
|
||||||
return ""
|
f.Unread = append(f.Unread, path+"/"+name+": "+err.Error())
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
g.CPUIdle, g.CPUWeight, g.CPUMax, g.IOWeight = value("cpu.idle"), value("cpu.weight"), value("cpu.max"), value("io.weight")
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
f.Unit = read(f.Cgroup)
|
||||||
|
if i := strings.LastIndex(f.Cgroup, "/"); i > 0 {
|
||||||
|
f.Slice = read(f.Cgroup[:i])
|
||||||
|
}
|
||||||
|
for _, q := range []int{quotaOf(f.Unit.CPUMax), quotaOf(f.Slice.CPUMax)} {
|
||||||
|
if q > 0 && (f.Quota == 0 || q < f.Quota) {
|
||||||
|
f.Quota = q
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(b))
|
|
||||||
}
|
}
|
||||||
f.CPUIdle = read("cpu.idle")
|
|
||||||
f.CPUWeight = read("cpu.weight")
|
|
||||||
f.CPUMax = read("cpu.max")
|
|
||||||
f.IOWeight = read("io.weight")
|
|
||||||
f.Quota = quotaOf(f.CPUMax)
|
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,9 +206,6 @@ func quotaOf(cpuMax string) int {
|
|||||||
return q * 100 / p
|
return q * 100 / p
|
||||||
}
|
}
|
||||||
|
|
||||||
// procSelfStat is this process's stat file. A variable so a test can point it elsewhere.
|
|
||||||
var procSelfStat = "/proc/self/stat"
|
|
||||||
|
|
||||||
// nice is this process's nice level from its stat file (the 19th field, counted after the command's closing
|
// nice is this process's nice level from its stat file (the 19th field, counted after the command's closing
|
||||||
// parenthesis, which may itself hold spaces); 0 when it cannot be read.
|
// parenthesis, which may itself hold spaces); 0 when it cannot be read.
|
||||||
func nice() int {
|
func nice() int {
|
||||||
|
|||||||
@@ -4,77 +4,72 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The cap's words, a node's share, the unit quota and the threads a step is given, in one table (issue 1806).
|
// The node's share as a quota, the threads one step is given across every build slot of the node, and the words the
|
||||||
func TestCapQuotaAndThreads(t *testing.T) {
|
// composer hands, in one table (ADR 0553, issue 1806).
|
||||||
|
func TestShareQuotaAndThreads(t *testing.T) {
|
||||||
for _, c := range []struct {
|
for _, c := range []struct {
|
||||||
name, setting, node string
|
name string
|
||||||
cores, slots int
|
percent, cores, slots int
|
||||||
percent, quota int
|
quota, threads int
|
||||||
threads int
|
|
||||||
refused bool
|
|
||||||
}{
|
}{
|
||||||
{name: "default for shanks", setting: Defaults, node: "shanks", cores: 24, slots: 3, percent: 50, quota: 1200, threads: 4},
|
{"shanks: half of 24 across 9 slots", 50, 24, 9, 1200, 1},
|
||||||
{name: "default for ace", setting: Defaults, node: "ace", cores: 24, slots: 2, percent: 75, quota: 1800, threads: 9},
|
{"shanks: half of 24 across 4 slots", 50, 24, 4, 1200, 3},
|
||||||
{name: "default for g14", setting: Defaults, node: "g14", cores: 16, slots: 1, percent: 50, quota: 800, threads: 8},
|
{"ace: three quarters of 24 across 6 slots", 75, 24, 6, 1800, 3},
|
||||||
{name: "a node not named", setting: Defaults, node: "novox", cores: 8, slots: 1, percent: DefaultPercent, quota: 400, threads: 4},
|
{"g14: half of 16 across 1 slot", 50, 16, 1, 800, 8},
|
||||||
{name: "percent sign and spaces", setting: " shanks = 25% ", node: "shanks", cores: 24, slots: 3, percent: 25, quota: 600, threads: 2},
|
{"one processor at least", 10, 2, 4, 100, 1},
|
||||||
{name: "one processor at least", setting: "tiny=10", node: "tiny", cores: 2, slots: 4, percent: 10, quota: 100, threads: 1},
|
{"no slots counts as one", 100, 4, 0, 400, 4},
|
||||||
{name: "zero slots counts as one", setting: "x=100", node: "x", cores: 4, slots: 0, percent: 100, quota: 400, threads: 4},
|
{"a share out of range is the default", 0, 8, 1, 0, 4},
|
||||||
{name: "zero refused", setting: "shanks=0", node: "shanks", refused: true},
|
|
||||||
{name: "over a hundred refused", setting: "shanks=150", node: "shanks", refused: true},
|
|
||||||
{name: "no number refused", setting: "shanks", node: "shanks", refused: true},
|
|
||||||
{name: "twice refused", setting: "a=1,a=2", node: "a", refused: true},
|
|
||||||
} {
|
} {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
if c.quota != 0 {
|
||||||
p, err := Of(c.setting, c.node)
|
if q := Quota(c.percent, c.cores); q != c.quota {
|
||||||
if c.refused {
|
t.Errorf("%s: Quota = %d, want %d", c.name, q, c.quota)
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("Of(%q) = %d, want refused", c.setting, p)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
}
|
||||||
t.Fatal(err)
|
if th := Threads(c.percent, c.cores, c.slots); th != c.threads {
|
||||||
}
|
t.Errorf("%s: Threads = %d, want %d", c.name, th, c.threads)
|
||||||
if p != c.percent {
|
}
|
||||||
t.Fatalf("percent = %d, want %d", p, c.percent)
|
|
||||||
}
|
|
||||||
if q := Quota(p, c.cores); q != c.quota {
|
|
||||||
t.Errorf("Quota = %d, want %d", q, c.quota)
|
|
||||||
}
|
|
||||||
if th := Threads(p, c.cores, c.slots); th != c.threads {
|
|
||||||
t.Errorf("Threads = %d, want %d", th, c.threads)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
caps, _ := Parse("ace=75,shanks=50%")
|
words := map[string]string{ShareWord: "75", SlotsWord: "6"}
|
||||||
if got := Format(caps); got != "ace=75,shanks=50" {
|
l := FromWords(func(k string) string { return words[k] }, 2)
|
||||||
t.Errorf("Format = %q", got)
|
if l.Percent != 75 || l.From != ShareWord || l.Slots != 6 || l.SlotsFrom != SlotsWord || l.Threads != Threads(75, runtime.NumCPU(), 6) {
|
||||||
|
t.Errorf("from the words: %+v", l)
|
||||||
}
|
}
|
||||||
if got := WithEnv([]string{"A=1", "GOMAXPROCS=24"}, 3); !reflect.DeepEqual(got, []string{"A=1", "GOMAXPROCS=3", "UV_THREADPOOL_SIZE=3"}) {
|
// Told nothing (or nonsense), a module assumes every build seat beside it at its own capacity, at the default share.
|
||||||
t.Errorf("WithEnv = %q", got)
|
words = map[string]string{ShareWord: "250", SlotsWord: "many"}
|
||||||
|
if l := FromWords(func(k string) string { return words[k] }, 3); l.Percent != DefaultPercent || l.Slots != 3*BuildSeats || l.From != "default" {
|
||||||
|
t.Errorf("without usable words: %+v", l)
|
||||||
|
}
|
||||||
|
if got := Env(0); !reflect.DeepEqual(got, []string{"GOMAXPROCS=1", "UV_THREADPOOL_SIZE=1"}) {
|
||||||
|
t.Errorf("Env = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the kernel holds a process to is read from its cgroup and stat files, not assumed.
|
// What the kernel holds a process to is read from its unit's and its slice's cgroup and its stat file, not assumed.
|
||||||
func TestReadInForce(t *testing.T) {
|
func TestReadInForce(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
cg := filepath.Join(dir, "cg")
|
cg := filepath.Join(dir, "cg")
|
||||||
unit := filepath.Join(cg, "system.slice", "mesh-build-go-runtime.service")
|
slice := filepath.Join(cg, "meshyield.slice")
|
||||||
|
unit := filepath.Join(slice, "mesh-build-go-runtime.service")
|
||||||
if err := os.MkdirAll(unit, 0o755); err != nil {
|
if err := os.MkdirAll(unit, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for name, v := range map[string]string{"cpu.idle": "1\n", "cpu.weight": "0\n", "cpu.max": "1200000 100000\n", "io.weight": "default 1\n"} {
|
for at, files := range map[string]map[string]string{
|
||||||
if err := os.WriteFile(filepath.Join(unit, name), []byte(v), 0o644); err != nil {
|
slice: {"cpu.idle": "1\n", "cpu.weight": "0\n", "cpu.max": "1200000 100000\n", "io.weight": "default 1\n"},
|
||||||
t.Fatal(err)
|
unit: {"cpu.idle": "0\n", "cpu.weight": "100\n", "cpu.max": "max 100000\n", "io.weight": "default 100\n"},
|
||||||
|
} {
|
||||||
|
for name, v := range files {
|
||||||
|
if err := os.WriteFile(filepath.Join(at, name), []byte(v), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self := filepath.Join(dir, "cgroup")
|
self := filepath.Join(dir, "cgroup")
|
||||||
stat := filepath.Join(dir, "stat")
|
stat := filepath.Join(dir, "stat")
|
||||||
_ = os.WriteFile(self, []byte("0::/system.slice/mesh-build-go-runtime.service\n"), 0o644)
|
_ = os.WriteFile(self, []byte("0::/meshyield.slice/mesh-build-go-runtime.service\n"), 0o644)
|
||||||
// A command with a space and a parenthesis in it, then nice 19 in field 19.
|
// A command with a space and a parenthesis in it, then nice 19 in field 19.
|
||||||
_ = os.WriteFile(stat, []byte("42 (mesh (build) go) S 1 42 42 0 -1 4194560 1 0 0 0 0 0 0 0 39 19 8 0 1 2 3\n"), 0o644)
|
_ = os.WriteFile(stat, []byte("42 (mesh (build) go) S 1 42 42 0 -1 4194560 1 0 0 0 0 0 0 0 39 19 8 0 1 2 3\n"), 0o644)
|
||||||
cgroupRoot, procSelfCgroup, procSelfStat = cg, self, stat
|
cgroupRoot, procSelfCgroup, procSelfStat = cg, self, stat
|
||||||
@@ -83,12 +78,13 @@ func TestReadInForce(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
got := ReadInForce()
|
got := ReadInForce()
|
||||||
want := InForce{Nice: 19, Cgroup: "/system.slice/mesh-build-go-runtime.service", CPUIdle: "1", CPUWeight: "0",
|
want := InForce{Nice: 19, Cgroup: "/meshyield.slice/mesh-build-go-runtime.service",
|
||||||
CPUMax: "1200000 100000", Quota: 1200, IOWeight: "default 1"}
|
Unit: Group{Path: "/meshyield.slice/mesh-build-go-runtime.service", CPUIdle: "0", CPUWeight: "100",
|
||||||
|
CPUMax: "max 100000", IOWeight: "default 100"},
|
||||||
|
Slice: Group{Path: "/meshyield.slice", CPUIdle: "1", CPUWeight: "0", CPUMax: "1200000 100000",
|
||||||
|
IOWeight: "default 1"},
|
||||||
|
Quota: 1200}
|
||||||
if !reflect.DeepEqual(got, want) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("ReadInForce = %+v\nwant %+v", got, want)
|
t.Fatalf("ReadInForce = %+v\nwant %+v", got, want)
|
||||||
}
|
}
|
||||||
if q := quotaOf("max 100000"); q != 0 {
|
|
||||||
t.Errorf("no quota read as %d", q)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,18 +37,17 @@ func TestNodeCapacity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue 1806: a step's limits come from the cap in the same settings file, read at every call; a cap that cannot be
|
// ADR 0553 §4: a step's limits come from the composer's words; without them, every build seat at the module's own
|
||||||
// read still bounds the step, at the default.
|
// capacity at the default share.
|
||||||
func TestNodeLimits(t *testing.T) {
|
func TestNodeLimits(t *testing.T) {
|
||||||
file := filepath.Join(t.TempDir(), "settings.json")
|
t.Setenv("MESH_BUILD_CPU_SHARE", "")
|
||||||
os.WriteFile(file, []byte(`{"capacity":"shanks=3","cpu-cap":"shanks=25"}`), 0o600)
|
t.Setenv("MESH_BUILD_SLOTS", "")
|
||||||
if l := NodeLimits(file, "shanks", 3, nil); l.Percent != 25 || l.Slots != 3 || l.Threads < 1 {
|
if l := NodeLimits(3); l.Percent != 50 || l.Slots != 9 {
|
||||||
t.Errorf("from the file: %+v", l)
|
t.Errorf("without words: %+v", l)
|
||||||
}
|
}
|
||||||
for _, body := range []string{`{"capacity":"shanks=3"}`, `{"cpu-cap":"shanks=0"}`, `not json`} {
|
t.Setenv("MESH_BUILD_CPU_SHARE", "75")
|
||||||
os.WriteFile(file, []byte(body), 0o600)
|
t.Setenv("MESH_BUILD_SLOTS", "5")
|
||||||
if l := NodeLimits(file, "shanks", 1, nil); l.Percent != 50 {
|
if l := NodeLimits(3); l.Percent != 75 || l.Slots != 5 || l.Threads < 1 {
|
||||||
t.Errorf("%s gives %d%%, want the default 50", body, l.Percent)
|
t.Errorf("from the words: %+v", l)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-31
@@ -1,40 +1,15 @@
|
|||||||
package buildstep
|
package buildstep
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/buildcpu"
|
"git.novox.be/novox/mesh-sdk/go/buildcpu"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NodeLimits is what one step on node is held to (novox/hq issue 1806): the node's processor cap from the module's
|
// NodeLimits is what one step of a build module is held to (novox/hq ADR 0553 §4): the node's processor share and every
|
||||||
// settings file at path (buildcpu.Setting, its default when the file does not set it), the machine's processors, and
|
// build slot of the node, from the words the composer gives the module (buildcpu.ShareWord, buildcpu.SlotsWord), and the
|
||||||
// the node's capacity, so the steps that run at once start no more threads together than the cap gives. Read at every
|
// machine's processors. A module told neither assumes every build seat beside it at its own capacity, own, so its steps
|
||||||
// call, like the capacity, so a change takes effect at the next step. A file or a setting that cannot be read gives the
|
// stay within the default share with the others'.
|
||||||
// default cap and says why: a step still runs with a bound, never without one.
|
func NodeLimits(own int) buildcpu.Limits {
|
||||||
func NodeLimits(path, node string, slots int, logf func(string, ...any)) buildcpu.Limits {
|
return buildcpu.FromWords(os.Getenv, own)
|
||||||
setting := buildcpu.Defaults
|
|
||||||
if path != "" {
|
|
||||||
if raw, err := os.ReadFile(path); err != nil {
|
|
||||||
sayTo(logf, "the processor cap cannot be read, so %s builds under the default cap: %v", node, err)
|
|
||||||
} else {
|
|
||||||
var s map[string]any
|
|
||||||
if err := json.Unmarshal(raw, &s); err != nil {
|
|
||||||
sayTo(logf, "the settings file %s is not JSON, so %s builds under the default cap: %v", path, node, err)
|
|
||||||
} else if v, ok := s[buildcpu.Setting].(string); ok {
|
|
||||||
setting = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
l, err := buildcpu.LimitsFor(setting, node, slots)
|
|
||||||
if err != nil {
|
|
||||||
sayTo(logf, "%v, so %s builds under the default cap of %d%%", err, node, buildcpu.DefaultPercent)
|
|
||||||
}
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
|
|
||||||
func sayTo(logf func(string, ...any), format string, args ...any) {
|
|
||||||
if logf != nil {
|
|
||||||
logf(format, args...)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ func TestAStoreRowWithoutAProtocolKeepsTheCompiledOne(t *testing.T) {
|
|||||||
t.Cleanup(func() { UseSeats(was) })
|
t.Cleanup(func() { UseSeats(was) })
|
||||||
UseSeats([]Seat{{Name: "node-build-go", Scope: ScopeNode, Decision: "row"}})
|
UseSeats([]Seat{{Name: "node-build-go", Scope: ScopeNode, Decision: "row"}})
|
||||||
got, ok := SeatNamed("node-build-go")
|
got, ok := SeatNamed("node-build-go")
|
||||||
if !ok || len(got.Accepts) == 0 || !got.PulledByKind {
|
if !ok || len(got.Accepts) == 0 || !got.PulledByKind || !got.Yields {
|
||||||
t.Fatalf("the Go build seat lost what it accepts when loaded from the store: %+v", got)
|
t.Fatalf("the Go build seat lost what it accepts when loaded from the store: %+v", got)
|
||||||
}
|
}
|
||||||
if got.Decision != "row" {
|
if got.Decision != "row" {
|
||||||
|
|||||||
@@ -329,14 +329,6 @@ func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
|
|||||||
process[HealthField] = m.RuntimeHealth
|
process[HealthField] = m.RuntimeHealth
|
||||||
healthInto(process, m, with)
|
healthInto(process, m, with)
|
||||||
}
|
}
|
||||||
// A build seat's holder gives way to everything else on its node (novox/hq issue 1806).
|
|
||||||
yields, err := r.yieldsOf(m, with)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if yields != nil {
|
|
||||||
process["yields"] = yields
|
|
||||||
}
|
|
||||||
if err := artifactsInto(process, RuntimeModule, with); err != nil {
|
if err := artifactsInto(process, RuntimeModule, with); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,34 +498,6 @@ func TestAModuleOfItsOwnAccountIsServedByARuntimeOfItsOwn(t *testing.T) {
|
|||||||
if _, told := env[RuntimeOperatorAccount]; told {
|
if _, told := env[RuntimeOperatorAccount]; told {
|
||||||
t.Error("a runtime of a module's own account is told the operator's account")
|
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"])
|
|
||||||
}
|
|
||||||
// A build seat's holder yields: idle priority, under this node's share of its cpu-cap, the default where no layer
|
|
||||||
// sets one (issue 1806).
|
|
||||||
builder := telegram
|
|
||||||
builder.Claims = []Claim{{Name: "node-build-go"}}
|
|
||||||
for _, c := range []struct {
|
|
||||||
layers []Layer
|
|
||||||
share int
|
|
||||||
}{
|
|
||||||
{nil, 50},
|
|
||||||
{[]Layer{{From: "anchor", Values: map[string]any{"cpu-cap": "anchor=30,other=90"}}}, 30},
|
|
||||||
} {
|
|
||||||
with.Settings = SettingsBy{"telegram": c.layers}
|
|
||||||
runtimes, err := Resolution{Node: "anchor", Modules: []Manifest{builder, goRuntime}}.ownRuntimes(with)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := fmt.Sprint(runtimes[0]["yields"]); got != fmt.Sprintf("map[cpu-share:%d priority:idle]", c.share) {
|
|
||||||
t.Errorf("a build seat's holder under %v yields %s", c.layers, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
with.Settings = SettingsBy{"telegram": {{From: "anchor", Values: map[string]any{"cpu-cap": "anchor=0"}}}}
|
|
||||||
if _, err := (Resolution{Node: "anchor", Modules: []Manifest{builder, goRuntime}}).ownRuntimes(with); err == nil {
|
|
||||||
t.Error("a cap of 0 percent was composed")
|
|
||||||
}
|
|
||||||
with.Settings = nil
|
|
||||||
// Without the machine's runtime to run it from, it is refused in words.
|
// 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 {
|
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")
|
t.Error("a module of its own account composed without a runtime program")
|
||||||
|
|||||||
@@ -49,10 +49,10 @@ type Seat struct {
|
|||||||
// order Accepts lists them (novox/hq ADR 0525 §2, ADR 0536 §4): the build seats'. Compiled, never stored, like
|
// order Accepts lists them (novox/hq ADR 0525 §2, ADR 0536 §4): the build seats'. Compiled, never stored, like
|
||||||
// Receives.
|
// Receives.
|
||||||
PulledByKind bool
|
PulledByKind bool
|
||||||
// Yields says the seat's holder gives way to everything else on its node (novox/hq issue 1806): its runtime runs at
|
// Yields says the seat's holder gives way to everything else on its node (novox/hq ADR 0553): its runtime runs at
|
||||||
// the lowest CPU and IO priority and under its node's processor cap, the holder's setting `cpu-cap`
|
// idle priority in the node's one yielding slice, under the node's one processor share (buildcpu.ShareSetting) that
|
||||||
// (buildcpu.Setting). The build seats': a build must never slow the operator's desktop. Compiled, never stored, like
|
// every yielding holder of the node shares. The build seats': a build must never slow the operator's desktop.
|
||||||
// Receives.
|
// Compiled, never stored, like Receives.
|
||||||
Yields bool
|
Yields bool
|
||||||
// Serves carries each verb in full — name, description, schema — because a role's tools are the
|
// Serves carries each verb in full — name, description, schema — because a role's tools are the
|
||||||
// mesh's to define and an agent's to call (novox/hq ADR 0132, design 33 §2).
|
// mesh's to define and an agent's to call (novox/hq ADR 0132, design 33 §2).
|
||||||
@@ -261,11 +261,11 @@ var defaultSeats = append([]Seat{
|
|||||||
// its holder taking nothing, kept on the module's own pause key (issue 1887's design limit 2). They replace
|
// its holder taking nothing, kept on the module's own pause key (issue 1887's design limit 2). They replace
|
||||||
// node-build-agent, retired with build-agent (ADR 0524 §5).
|
// node-build-agent, retired with build-agent (ADR 0524 §5).
|
||||||
{Name: "node-build-go", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Go"),
|
{Name: "node-build-go", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Go"),
|
||||||
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536"},
|
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"},
|
||||||
{Name: "node-build-node", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Node"),
|
{Name: "node-build-node", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Node"),
|
||||||
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536"},
|
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"},
|
||||||
{Name: "node-build-image", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("image"),
|
{Name: "node-build-image", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("image"),
|
||||||
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536"},
|
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"},
|
||||||
// **The mesh's resolvers** (novox/hq ADR 0194, 0196, 0223): every node's internal domain, and
|
// **The mesh's resolvers** (novox/hq ADR 0194, 0196, 0223): every node's internal domain, and
|
||||||
// every node and container asks them and nothing else. Replicated since ADR 0223: held on more
|
// every node and container asks them and nothing else. Replicated since ADR 0223: held on more
|
||||||
// than one machine, each answering the same names from the same roster, and every machine's
|
// than one machine, each answering the same names from the same roster, and every machine's
|
||||||
@@ -502,6 +502,7 @@ func UseSeats(s []Seat) {
|
|||||||
row.Paths = d.Paths
|
row.Paths = d.Paths
|
||||||
row.Replicated = d.Replicated
|
row.Replicated = d.Replicated
|
||||||
row.PulledByKind = d.PulledByKind
|
row.PulledByKind = d.PulledByKind
|
||||||
|
row.Yields = d.Yields // never stored (ADR 0553)
|
||||||
row.Serves = optionalAsCompiled(row.Serves, d.Serves)
|
row.Serves = optionalAsCompiled(row.Serves, d.Serves)
|
||||||
}
|
}
|
||||||
merged = append(merged, row)
|
merged = append(merged, row)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/buildcpu"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Defends novox/hq ADR 0110: a seat is a module assignment from a closed set.
|
// Defends novox/hq ADR 0110: a seat is a module assignment from a closed set.
|
||||||
@@ -324,6 +326,9 @@ func TestNodeBuildAgentIsRetiredForTheBuildSeats(t *testing.T) {
|
|||||||
if !ok || s.Scope != ScopeNode || !s.PulledByKind || strings.Join(s.Accepts, ",") != "delivery,check,recheck" {
|
if !ok || s.Scope != ScopeNode || !s.PulledByKind || strings.Join(s.Accepts, ",") != "delivery,check,recheck" {
|
||||||
t.Fatalf("%s = %+v", name, s)
|
t.Fatalf("%s = %+v", name, s)
|
||||||
}
|
}
|
||||||
|
if !s.Yields {
|
||||||
|
t.Errorf("%s does not yield (ADR 0553)", name)
|
||||||
|
}
|
||||||
var verbs []string
|
var verbs []string
|
||||||
for _, v := range s.Serves {
|
for _, v := range s.Serves {
|
||||||
verbs = append(verbs, v.Name)
|
verbs = append(verbs, v.Name)
|
||||||
@@ -332,4 +337,14 @@ func TestNodeBuildAgentIsRetiredForTheBuildSeats(t *testing.T) {
|
|||||||
t.Errorf("%s serves %v", name, verbs)
|
t.Errorf("%s serves %v", name, verbs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The build seats are the seats that yield, as many as a step told nothing assumes beside it (ADR 0553 §4).
|
||||||
|
yielding := 0
|
||||||
|
for _, s := range Seats() {
|
||||||
|
if s.Yields {
|
||||||
|
yielding++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if yielding != buildcpu.BuildSeats {
|
||||||
|
t.Errorf("%d seats yield, and buildcpu.BuildSeats says %d", yielding, buildcpu.BuildSeats)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"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 issue 1806), or nil
|
|
||||||
// when it does not: a module holding a seat that yields (Seat.Yields, the build seats) runs at idle priority and under
|
|
||||||
// this node's share of its setting `cpu-cap` (buildcpu.Setting), its default when no layer sets it.
|
|
||||||
//
|
|
||||||
// In the node-engine's words (declaration.Yields), which it writes into the unit: the kernel holds the bound, not the
|
|
||||||
// module.
|
|
||||||
func (r Resolution) yieldsOf(m Manifest, with Rendering) (map[string]any, error) {
|
|
||||||
yields := false
|
|
||||||
for _, c := range m.Claims {
|
|
||||||
if s, ok := SeatNamed(c.Name); ok && s.Yields {
|
|
||||||
yields = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !yields {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
setting := buildcpu.Defaults
|
|
||||||
for _, s := range Effective(m, with.Settings[m.Module]) {
|
|
||||||
if s.Key == buildcpu.Setting {
|
|
||||||
v, ok := s.Value.(string)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("%s's %s is %v, not <node>=<percent> pairs (novox/hq issue 1806)",
|
|
||||||
m.Module, buildcpu.Setting, s.Value)
|
|
||||||
}
|
|
||||||
setting = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
share, err := buildcpu.Of(setting, r.Node)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s's %s: %w (novox/hq issue 1806)", m.Module, buildcpu.Setting, err)
|
|
||||||
}
|
|
||||||
return map[string]any{"priority": "idle", "cpu-share": share}, nil
|
|
||||||
}
|
|
||||||
@@ -917,22 +917,30 @@ type Process struct {
|
|||||||
// timing, judged by the node-engine beside liveness. Absent: judged alive or not, and nothing more.
|
// timing, judged by the node-engine beside liveness. Absent: judged alive or not, and nothing more.
|
||||||
Health *Health `json:"health,omitempty"`
|
Health *Health `json:"health,omitempty"`
|
||||||
|
|
||||||
// Yields says this process gives way to everything else on its machine (novox/hq issue 1806): the
|
// Yields says this process gives way to everything else on its machine (novox/hq ADR 0553, issue 1806): the
|
||||||
// runtime of a build seat's holder, whose builds must never slow the operator's desktop. Absent: the
|
// runtime of a build seat's holder, whose builds must never slow the operator's desktop. Absent: the machine's
|
||||||
// machine's defaults. For a process that stays up.
|
// defaults. For a process that stays up.
|
||||||
Yields *Yields `json:"yields,omitempty"`
|
Yields *Yields `json:"yields,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// YieldIdle is the one priority a process yields at: the lowest CPU and IO priority there is.
|
// YieldIdle is the one priority a process yields at: the lowest there is.
|
||||||
const YieldIdle = "idle"
|
const YieldIdle = "idle"
|
||||||
|
|
||||||
// Yields is how a process gives way, written into its unit by the node-engine (novox/hq issue 1806).
|
// YieldSlice is the one slice the node-engine places every yielding process of its node in (novox/hq ADR 0553 §2): a
|
||||||
|
// slice at the top of the hierarchy, beside the operator's user slice and the system's, so its idle weight and its
|
||||||
|
// quota bind against everything else on the machine. No dash in its name: a dash makes a slice a child of the slice
|
||||||
|
// named before it, where its weight would count only against its siblings.
|
||||||
|
const YieldSlice = "meshyield.slice"
|
||||||
|
|
||||||
|
// Yields is how a process gives way, written by the node-engine (novox/hq ADR 0553).
|
||||||
//
|
//
|
||||||
// **Priority** "idle": CPUWeight=idle (its cgroup runs only on cycles nothing else wants), IOWeight=1,
|
// **Priority** "idle": the process runs in YieldSlice, whose CPUWeight is idle (it runs only on cycles nothing else
|
||||||
// Nice=19 for every process it starts, and the best-effort IO class at its lowest level (never the idle IO
|
// wants) and whose IOWeight is 1, with Nice=19 and the lowest best-effort IO level for every process it starts. The IO
|
||||||
// class, which a busy disk starves for good). **CPUShare** is the most of the machine's processors it may
|
// controls bind only where the disk's scheduler honours them (bfq, or io.cost for the weight); under `none`, the
|
||||||
// take, in percent: the node-engine writes CPUQuota for the processors the machine has, so the bound is
|
// usual one for NVMe, they do nothing, and the CPU bound is what holds. **CPUShare** is the most of the machine's
|
||||||
// the kernel's. 0 is no cap.
|
// processors the slice may take, in percent, shared by every yielding process of the node together: the node-engine
|
||||||
|
// writes it as the slice's CPUQuota for the processors the machine has, so the bound is the kernel's. Every yielding
|
||||||
|
// process of one node carries the same share: the composer gives the node's one value (ADR 0553 §3). 0 is no cap.
|
||||||
type Yields struct {
|
type Yields struct {
|
||||||
Priority string `json:"priority,omitempty"`
|
Priority string `json:"priority,omitempty"`
|
||||||
CPUShare int `json:"cpu-share,omitempty"`
|
CPUShare int `json:"cpu-share,omitempty"`
|
||||||
|
|||||||
Reference in New Issue
Block a user