Build seats yield: idle priority and a per-node processor cap on their runtimes, threads per step within it (issue 1806)

The operator's desktop slows while it builds: the Go build runtime took 1650% of
shanks' 24 processors with nothing holding it back. A build seat's holder now
runs its runtime at idle priority under its node's cpu-cap share (declaration
Yields, composed from Seat.Yields and the module's cpu-cap setting), and
buildcpu/buildstep give each step its part of that cap in threads.
This commit is contained in:
2026-10-26 13:37:00 +01:00
parent b4cd95e7c4
commit 04902ff24a
10 changed files with 553 additions and 3 deletions
+258
View File
@@ -0,0 +1,258 @@
// 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
// impacts the normal workings of our nodes").
//
// 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
// composer gives its runtime process declaration.Yields, and the node-engine writes it into the unit as
// CPUWeight=idle, IOWeight=1, Nice=19 and the best-effort IO class at its lowest level. 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
// may take at most, as `<node>=<percent>` pairs. The composer turns this node's share into Yields.CPUShare, and the
// node-engine into CPUQuota for the machine's processor count: a hard bound, held by the kernel.
// - **Threads.** Inside that bound a step starts no more threads than its part of it (Threads): GOMAXPROCS, `go
// test -p`, the Node test runner's concurrency. A step with more runnable threads than its quota only waits in the
// kernel's throttle, and its timeouts with it.
package buildcpu
import (
"fmt"
"os"
"runtime"
"sort"
"strconv"
"strings"
)
// Setting is the build modules' setting of each node's processor cap: `<node>=<percent>` pairs separated by commas,
// such as `shanks=50,ace=75`; a percent sign after the number is allowed. Each build module declares it with Defaults
// as its default.
const Setting = "cpu-cap"
// Defaults is the setting's default: the operator's two desktops (shanks, g14) keep half their processors for
// themselves at all times, and ace, a server, builds on three quarters. A node it does not name has DefaultPercent.
const Defaults = "shanks=50,g14=50,ace=75"
// DefaultPercent is the cap of a node the setting does not name.
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
// seat (the capacity setting says that, with 0 asks), and more than 100 is no cap.
func Parse(s string) (map[string]int, error) {
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.
func Of(setting, node string) (int, error) {
caps, err := Parse(setting)
if err != nil {
return 0, err
}
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.
func Format(caps map[string]int) string {
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 {
q := percent * cores
if q < 100 {
q = 100
}
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
// one. Rounded down, so the steps together stay inside the quota.
func Threads(percent, cores, slots int) int {
if slots < 1 {
slots = 1
}
if percent < 1 || percent > 100 {
percent = 100
}
t := cores * percent / 100 / slots
if t < 1 {
t = 1
}
return t
}
// Limits is what one step is held to on this node, as a status tool says it.
type Limits struct {
// Percent is the node's cap in percent of its processors, and Setting the words it came from.
Percent int `json:"cap-percent"`
Setting string `json:"cap-setting"`
// Cores is the machine's processor count.
Cores int `json:"cores"`
// Slots is the node's build capacity: steps at once.
Slots int `json:"slots"`
// Threads is what each step is given (Threads).
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
// gives the default cap and says why in the error, so a step still runs with a bound rather than none.
func LimitsFor(setting, node string, slots int) (Limits, error) {
l := Limits{Setting: setting, Cores: runtime.NumCPU(), Slots: slots}
p, err := Of(setting, node)
if err != nil {
p = DefaultPercent
}
l.Percent = p
l.Threads = Threads(p, l.Cores, slots)
return l, err
}
// Env is the environment a step's commands are given so they start no more threads than they may: GOMAXPROCS for every
// Go program (the compiler, a test binary, esbuild, buildah), UV_THREADPOOL_SIZE for Node's worker pool. Returned as
// KEY=value words to append to a command's environment.
func Env(threads int) []string {
if threads < 1 {
threads = 1
}
t := strconv.Itoa(threads)
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.
func WithEnv(env []string, threads int) []string {
out := make([]string, 0, len(env)+2)
for _, kv := range env {
if strings.HasPrefix(kv, "GOMAXPROCS=") || strings.HasPrefix(kv, "UV_THREADPOOL_SIZE=") {
continue
}
out = append(out, kv)
}
return append(out, Env(threads)...)
}
// InForce is what the kernel holds this process to, read rather than assumed (evidence over assertion): its nice
// level, its cgroup, the cgroup's CPU weight, idle mark and quota. A field the machine does not answer is left empty
// and Unread says which.
type InForce struct {
Nice int `json:"nice"`
Cgroup string `json:"cgroup,omitempty"`
CPUIdle string `json:"cpu-idle,omitempty"`
CPUWeight string `json:"cpu-weight,omitempty"`
// CPUMax is the cgroup's cpu.max: "<quota µs> <period µs>", or "max" with no quota.
CPUMax string `json:"cpu-max,omitempty"`
// Quota is CPUMax as a percent of one processor, 0 with none.
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.
var cgroupRoot = "/sys/fs/cgroup"
// procSelfCgroup is this process's cgroup file. A variable so a test can point it elsewhere.
var procSelfCgroup = "/proc/self/cgroup"
// ReadInForce reads what the kernel holds this process to.
func ReadInForce() InForce {
var f InForce
f.Nice = nice()
raw, err := os.ReadFile(procSelfCgroup)
if err != nil {
f.Unread = append(f.Unread, "cgroup: "+err.Error())
return f
}
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
if strings.HasPrefix(line, "0::") {
f.Cgroup = strings.TrimPrefix(line, "0::")
}
}
if f.Cgroup == "" {
f.Unread = append(f.Unread, "cgroup: not on the unified hierarchy")
return f
}
dir := cgroupRoot + f.Cgroup
read := func(name string) string {
b, err := os.ReadFile(dir + "/" + name)
if err != nil {
f.Unread = append(f.Unread, name+": "+err.Error())
return ""
}
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
}
// quotaOf is a cpu.max value as a percent of one processor: "1200000 100000" is 1200; "max ..." or nothing is 0.
func quotaOf(cpuMax string) int {
fields := strings.Fields(cpuMax)
if len(fields) != 2 || fields[0] == "max" {
return 0
}
q, err1 := strconv.Atoi(fields[0])
p, err2 := strconv.Atoi(fields[1])
if err1 != nil || err2 != nil || p <= 0 {
return 0
}
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
// parenthesis, which may itself hold spaces); 0 when it cannot be read.
func nice() int {
raw, err := os.ReadFile(procSelfStat)
if err != nil {
return 0
}
s := string(raw)
i := strings.LastIndex(s, ")")
if i < 0 {
return 0
}
fields := strings.Fields(s[i+1:])
// After the command: state is field 3, so nice (field 19) is index 16.
if len(fields) < 17 {
return 0
}
n, _ := strconv.Atoi(fields[16])
return n
}
+92
View File
@@ -0,0 +1,92 @@
package buildcpu
import (
"os"
"path/filepath"
"reflect"
"testing"
)
// The cap's words, a node's share, the unit quota and the threads a step is given, in one table (issue 1806).
func TestCapQuotaAndThreads(t *testing.T) {
for _, c := range []struct {
name, setting, node string
cores, slots int
percent, quota int
threads int
refused bool
}{
{name: "default for shanks", setting: Defaults, node: "shanks", cores: 24, slots: 3, percent: 50, quota: 1200, threads: 4},
{name: "default for ace", setting: Defaults, node: "ace", cores: 24, slots: 2, percent: 75, quota: 1800, threads: 9},
{name: "default for g14", setting: Defaults, node: "g14", cores: 16, slots: 1, percent: 50, quota: 800, threads: 8},
{name: "a node not named", setting: Defaults, node: "novox", cores: 8, slots: 1, percent: DefaultPercent, quota: 400, threads: 4},
{name: "percent sign and spaces", setting: " shanks = 25% ", node: "shanks", cores: 24, slots: 3, percent: 25, quota: 600, threads: 2},
{name: "one processor at least", setting: "tiny=10", node: "tiny", cores: 2, slots: 4, percent: 10, quota: 100, threads: 1},
{name: "zero slots counts as one", setting: "x=100", node: "x", cores: 4, slots: 0, percent: 100, quota: 400, threads: 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) {
p, err := Of(c.setting, c.node)
if c.refused {
if err == nil {
t.Fatalf("Of(%q) = %d, want refused", c.setting, p)
}
return
}
if err != nil {
t.Fatal(err)
}
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%")
if got := Format(caps); got != "ace=75,shanks=50" {
t.Errorf("Format = %q", got)
}
if got := WithEnv([]string{"A=1", "GOMAXPROCS=24"}, 3); !reflect.DeepEqual(got, []string{"A=1", "GOMAXPROCS=3", "UV_THREADPOOL_SIZE=3"}) {
t.Errorf("WithEnv = %q", got)
}
}
// What the kernel holds a process to is read from its cgroup and stat files, not assumed.
func TestReadInForce(t *testing.T) {
dir := t.TempDir()
cg := filepath.Join(dir, "cg")
unit := filepath.Join(cg, "system.slice", "mesh-build-go-runtime.service")
if err := os.MkdirAll(unit, 0o755); err != nil {
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"} {
if err := os.WriteFile(filepath.Join(unit, name), []byte(v), 0o644); err != nil {
t.Fatal(err)
}
}
self := filepath.Join(dir, "cgroup")
stat := filepath.Join(dir, "stat")
_ = os.WriteFile(self, []byte("0::/system.slice/mesh-build-go-runtime.service\n"), 0o644)
// 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)
cgroupRoot, procSelfCgroup, procSelfStat = cg, self, stat
t.Cleanup(func() { cgroupRoot, procSelfCgroup, procSelfStat = "/sys/fs/cgroup", "/proc/self/cgroup", "/proc/self/stat" })
got := ReadInForce()
want := InForce{Nice: 19, Cgroup: "/system.slice/mesh-build-go-runtime.service", CPUIdle: "1", CPUWeight: "0",
CPUMax: "1200000 100000", Quota: 1200, IOWeight: "default 1"}
if !reflect.DeepEqual(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)
}
}
+16
View File
@@ -36,3 +36,19 @@ func TestNodeCapacity(t *testing.T) {
t.Errorf("a missing file = %d, want 0", got)
}
}
// Issue 1806: a step's limits come from the cap in the same settings file, read at every call; a cap that cannot be
// read still bounds the step, at the default.
func TestNodeLimits(t *testing.T) {
file := filepath.Join(t.TempDir(), "settings.json")
os.WriteFile(file, []byte(`{"capacity":"shanks=3","cpu-cap":"shanks=25"}`), 0o600)
if l := NodeLimits(file, "shanks", 3, nil); l.Percent != 25 || l.Slots != 3 || l.Threads < 1 {
t.Errorf("from the file: %+v", l)
}
for _, body := range []string{`{"capacity":"shanks=3"}`, `{"cpu-cap":"shanks=0"}`, `not json`} {
os.WriteFile(file, []byte(body), 0o600)
if l := NodeLimits(file, "shanks", 1, nil); l.Percent != 50 {
t.Errorf("%s gives %d%%, want the default 50", body, l.Percent)
}
}
}
+40
View File
@@ -0,0 +1,40 @@
package buildstep
import (
"encoding/json"
"os"
"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
// settings file at path (buildcpu.Setting, its default when the file does not set it), the machine's processors, and
// the node's capacity, so the steps that run at once start no more threads together than the cap gives. Read at every
// call, like the capacity, so a change takes effect at the next step. A file or a setting that cannot be read gives the
// default cap and says why: a step still runs with a bound, never without one.
func NodeLimits(path, node string, slots int, logf func(string, ...any)) buildcpu.Limits {
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...)
}
}
+8
View File
@@ -329,6 +329,14 @@ func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
process[HealthField] = m.RuntimeHealth
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 {
return nil, err
}
+28
View File
@@ -498,6 +498,34 @@ 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"])
}
// 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.
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")
+8 -3
View File
@@ -49,6 +49,11 @@ type Seat struct {
// order Accepts lists them (novox/hq ADR 0525 §2, ADR 0536 §4): the build seats'. Compiled, never stored, like
// Receives.
PulledByKind bool
// Yields says the seat's holder gives way to everything else on its node (novox/hq issue 1806): its runtime runs at
// the lowest CPU and IO priority and under its node's processor cap, the holder's setting `cpu-cap`
// (buildcpu.Setting). The build seats': a build must never slow the operator's desktop. Compiled, never stored, like
// Receives.
Yields bool
// 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).
Serves []Verb
@@ -256,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
// node-build-agent, retired with build-agent (ADR 0524 §5).
{Name: "node-build-go", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Go"),
Decision: "novox/hq ADR 0525, ADR 0536"},
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536"},
{Name: "node-build-node", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Node"),
Decision: "novox/hq ADR 0525, ADR 0536"},
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536"},
{Name: "node-build-image", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("image"),
Decision: "novox/hq ADR 0525, ADR 0536"},
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536"},
// **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
// than one machine, each answering the same names from the same roster, and every machine's
+41
View File
@@ -0,0 +1,41 @@
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
}
+43
View File
@@ -916,6 +916,48 @@ type Process struct {
// Health is how this resource is ready (novox/hq ADR 0240 rule 2, Phase B): one kind and its
// timing, judged by the node-engine beside liveness. Absent: judged alive or not, and nothing more.
Health *Health `json:"health,omitempty"`
// Yields says this process gives way to everything else on its machine (novox/hq issue 1806): the
// runtime of a build seat's holder, whose builds must never slow the operator's desktop. Absent: the
// machine's defaults. For a process that stays up.
Yields *Yields `json:"yields,omitempty"`
}
// YieldIdle is the one priority a process yields at: the lowest CPU and IO priority there is.
const YieldIdle = "idle"
// Yields is how a process gives way, written into its unit by the node-engine (novox/hq issue 1806).
//
// **Priority** "idle": CPUWeight=idle (its cgroup runs only on cycles nothing else wants), IOWeight=1,
// Nice=19 for every process it starts, and the best-effort IO class at its lowest level (never the idle IO
// class, which a busy disk starves for good). **CPUShare** is the most of the machine's processors it may
// take, in percent: the node-engine writes CPUQuota for the processors the machine has, so the bound is
// the kernel's. 0 is no cap.
type Yields struct {
Priority string `json:"priority,omitempty"`
CPUShare int `json:"cpu-share,omitempty"`
}
func (y *Yields) problems(where string, staysUp bool) []string {
if y == nil {
return nil
}
var problems []string
if !staysUp {
problems = append(problems, where+": yields is for a process that stays up")
}
switch y.Priority {
case "", YieldIdle:
default:
problems = append(problems, fmt.Sprintf("%s: yields priority %q is not one this host knows: idle", where, y.Priority))
}
if y.CPUShare < 0 || y.CPUShare > 100 {
problems = append(problems, fmt.Sprintf("%s: yields cpu-share %d is not a percent of the machine, 0 to 100", where, y.CPUShare))
}
if y.Priority == "" && y.CPUShare == 0 {
problems = append(problems, where+": yields says neither a priority nor a cpu-share")
}
return problems
}
func (d *Process) Identity() string { return d.ID }
@@ -948,6 +990,7 @@ func ProcessNameProblem(name string) string {
func (d *Process) validate(where string, _ bool) []string {
problems := d.Health.problems(where, false, !d.RunOnce && d.Schedule == "")
problems = append(problems, d.Yields.problems(where, !d.RunOnce && d.Schedule == "")...)
if problem := ProcessNameProblem(d.Name); problem != "" {
problems = append(problems, where+": "+problem)
}
+19
View File
@@ -92,6 +92,20 @@ func TestTheModesAreExclusive(t *testing.T) {
if problems := watching.validate("a process", false); len(problems) == 0 {
t.Fatal("a scheduled process was given something to restart on, and it is never running")
}
// A process yields only while it stays up, at a priority the host knows, within the machine (issue 1806).
for name, bad := range map[string]func(*Process){
"a step that yields": func(p *Process) { p.RunOnce = true; p.Yields = &Yields{Priority: YieldIdle} },
"an unknown priority": func(p *Process) { p.Yields = &Yields{Priority: "low"} },
"a share over the whole": func(p *Process) { p.Yields = &Yields{CPUShare: 150} },
"a yield saying nothing": func(p *Process) { p.Yields = &Yields{} },
} {
p := aProcess()
bad(p)
if problems := p.validate("a process", false); len(problems) == 0 {
t.Errorf("%s was accepted", name)
}
}
}
// A cadence that is not a cadence is refused near its author, rather than by a machine at the far
@@ -118,6 +132,11 @@ func TestEachModeOnItsOwnIsAccepted(t *testing.T) {
if problems := every.validate("a process", false); len(problems) != 0 {
t.Fatalf("a scheduled process was refused: %v", problems)
}
yielding := aProcess()
yielding.Yields = &Yields{Priority: YieldIdle, CPUShare: 50}
if problems := yielding.validate("a process", false); len(problems) != 0 {
t.Fatalf("a daemon yielding at idle under half the machine was refused: %v", problems)
}
}
// **The one that cannot be escaped, only refused.**