Merge pull request 'The node-engine runs yielding processes in one capped idle slice (issue 1806, ADR 0553/0554, change A)' (#148) from fix/build-cpu-yields into main
This commit was merged in pull request #148.
This commit is contained in:
@@ -3,7 +3,7 @@ module github.com/novox/mesh-host
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112527-c2a410bb6376
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026134601-17b7cc480d6d
|
||||||
github.com/nats-io/nats-server/v2 v2.11.17
|
github.com/nats-io/nats-server/v2 v2.11.17
|
||||||
github.com/nats-io/nats.go v1.54.0
|
github.com/nats-io/nats.go v1.54.0
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
|
|||||||
@@ -1,17 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024234636-10b6e202320f h1:lUEyNPPF3w4Q0LIgcUUD6ofkKVhCF0n7dVjMoixQmXo=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026134601-17b7cc480d6d h1:7F/dFerm01wvuGLPgJ67/VfdcvDexc+BQEE3qzOrlu4=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024234636-10b6e202320f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026134601-17b7cc480d6d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025204213-f63fa86c7456 h1:He2o9vSKjgKbWOCOgOJY0WlaDh1n8RcGhvGY/dwXsBQ=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025204213-f63fa86c7456/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025204730-0be0ffd6159e h1:k/1NjjW5HuuuHW2ldyWTvjp+1u3fN0RO18Q57Iup6Dk=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025204730-0be0ffd6159e/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026105311-175a5567b8bd h1:ef6y09csev6xY/suFrIFGUXbgvDA29kummFtHcIb3B8=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026105311-175a5567b8bd/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026110029-83ee7769d8ee h1:ZdBMkF4WYJi82jb3xYi9Z2lXN/iMTn9r3SFC122ROY4=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026110029-83ee7769d8ee/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112307-af66adc180cd h1:DV4xfPRqmh6p20D2QNN+9EGFAyvpGOZPTRsSbpT7oww=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112307-af66adc180cd/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112527-c2a410bb6376 h1:by2zBASLb8h5jA1IiYJWykXwLnHYpuZh/zFNa4NsbOU=
|
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112527-c2a410bb6376/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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
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=
|
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -94,6 +95,14 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner,
|
|||||||
if r.Schedule != "" {
|
if r.Schedule != "" {
|
||||||
unit = r.Name + ".timer"
|
unit = r.Name + ".timer"
|
||||||
}
|
}
|
||||||
|
// The yielding slice is checked even then: a slice file removed by hand would leave the build unbound.
|
||||||
|
if wrote, err := placeYieldSlice(r); err != nil {
|
||||||
|
return out, err
|
||||||
|
} else if wrote {
|
||||||
|
if _, err := run(ctx, "systemctl", "daemon-reload"); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
}
|
||||||
if active, err := run(ctx, "systemctl", "is-active", "--quiet", unit); err == nil {
|
if active, err := run(ctx, "systemctl", "is-active", "--quiet", unit); err == nil {
|
||||||
_ = active
|
_ = active
|
||||||
return out, nil
|
return out, nil
|
||||||
@@ -194,6 +203,9 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner,
|
|||||||
if err := os.WriteFile(unit, []byte(unitFor(r)), 0o644); err != nil {
|
if err := os.WriteFile(unit, []byte(unitFor(r)), 0o644); err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
if _, err := placeYieldSlice(r); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
if _, err := run(ctx, "systemctl", "daemon-reload"); err != nil {
|
if _, err := run(ctx, "systemctl", "daemon-reload"); err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -282,6 +294,7 @@ func unitFor(r *declaration.Process) string {
|
|||||||
if r.User != "" {
|
if r.User != "" {
|
||||||
fmt.Fprintf(&b, "User=%s\n", r.User)
|
fmt.Fprintf(&b, "User=%s\n", r.User)
|
||||||
}
|
}
|
||||||
|
writeYields(&b, r.Yields)
|
||||||
fmt.Fprintf(&b, "ExecStart=%s\n", strings.Join(runFrom(r), " "))
|
fmt.Fprintf(&b, "ExecStart=%s\n", strings.Join(runFrom(r), " "))
|
||||||
if r.Schedule != "" || r.RunOnce {
|
if r.Schedule != "" || r.RunOnce {
|
||||||
// Started by its timer, or once by the host, and expected to finish. Restarting it would have it run
|
// Started by its timer, or once by the host, and expected to finish. Restarting it would have it run
|
||||||
@@ -298,6 +311,78 @@ func unitFor(r *declaration.Process) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// machineCPUs is how many processors this machine has, for a share of them in a quota. A variable only so a test can
|
||||||
|
// fix it.
|
||||||
|
var machineCPUs = runtime.NumCPU
|
||||||
|
|
||||||
|
// writeYields writes how a process gives way to everything else on its machine (novox/hq ADR 0553, issue 1806): a
|
||||||
|
// build seat's holder, whose builds must never slow the operator's desktop.
|
||||||
|
//
|
||||||
|
// **Into the node's one yielding slice**, where the CPU weight and the quota are (yieldSlice): every yielding process
|
||||||
|
// of the machine shares them, so three build modules take one share together, never one each. **Per process**, what a
|
||||||
|
// slice cannot say: Nice=19 for every process it starts, and the lowest best-effort IO level. The IO level binds only
|
||||||
|
// where the disk's scheduler honours it (bfq); under `none`, the usual one for NVMe, it does nothing. Not the idle IO
|
||||||
|
// class: a disk that is never idle would starve a build for good.
|
||||||
|
//
|
||||||
|
// The share is written into the unit as a comment, so a changed share changes the unit, the daemon's identity, and the
|
||||||
|
// slice is written again.
|
||||||
|
func writeYields(b *strings.Builder, y *declaration.Yields) {
|
||||||
|
if y == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(b, "# Yields: %s, %d%% of the machine shared in %s\n", y.Priority, y.CPUShare, declaration.YieldSlice)
|
||||||
|
fmt.Fprintf(b, "Slice=%s\n", declaration.YieldSlice)
|
||||||
|
if y.Priority == declaration.YieldIdle {
|
||||||
|
b.WriteString("Nice=19\nIOSchedulingClass=best-effort\nIOSchedulingPriority=7\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// yieldSlice is the node's one yielding slice (declaration.YieldSlice) for a process that yields: idle CPU weight and
|
||||||
|
// IO weight 1 for an idle priority, and CPUQuota for its share of the processors this machine has (systemd counts a
|
||||||
|
// quota in percent of one processor, so half of 24 is 1200%), at least one processor. At the top of the hierarchy, so
|
||||||
|
// its weight and quota bind against the operator's user slice and the system's.
|
||||||
|
//
|
||||||
|
// The IO weight binds only where the disk's scheduler honours it (bfq, or the io.cost controller); under `none` it does
|
||||||
|
// nothing, and the CPU bound is what holds.
|
||||||
|
//
|
||||||
|
// Every yielding process of a machine carries the node's one share (the composer's, ADR 0553 §3), so whichever writes
|
||||||
|
// it writes the same file. Left in place when nothing yields any more: an empty slice binds nothing.
|
||||||
|
func yieldSlice(y *declaration.Yields) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever a yielding\n")
|
||||||
|
b.WriteString("# process is declared again (novox/hq ADR 0553).\n")
|
||||||
|
b.WriteString("[Unit]\nDescription=The mesh's yielding processes: builds, at idle priority under one share\n\n")
|
||||||
|
b.WriteString("[Slice]\n")
|
||||||
|
if y.Priority == declaration.YieldIdle {
|
||||||
|
b.WriteString("CPUWeight=idle\nIOWeight=1\n")
|
||||||
|
}
|
||||||
|
if y.CPUShare > 0 {
|
||||||
|
quota := y.CPUShare * machineCPUs()
|
||||||
|
if quota < 100 {
|
||||||
|
quota = 100
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "CPUQuota=%d%%\n", quota)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeYieldSlice writes the yielding slice for a process that yields, when what is there differs, and says whether it
|
||||||
|
// wrote: the caller reloads the service manager before it starts anything.
|
||||||
|
func placeYieldSlice(r *declaration.Process) (bool, error) {
|
||||||
|
if r.Yields == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
path := filepath.Join(unitDir, declaration.YieldSlice)
|
||||||
|
want := yieldSlice(r.Yields)
|
||||||
|
if have, err := os.ReadFile(path); err == nil && string(have) == want {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(want), 0o644); err != nil {
|
||||||
|
return false, fmt.Errorf("the yielding slice %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// timerFor is the cadence a scheduled process runs on.
|
// timerFor is the cadence a scheduled process runs on.
|
||||||
//
|
//
|
||||||
// **The mesh's cron expression, handed to the machine's own timer.** The host already parses and
|
// **The mesh's cron expression, handed to the machine's own timer.** The host already parses and
|
||||||
|
|||||||
@@ -83,6 +83,60 @@ func TestAProcessRunsAsWhoItSaid(t *testing.T) {
|
|||||||
if strings.Contains(unitFor(aProcess()), "User=") {
|
if strings.Contains(unitFor(aProcess()), "User=") {
|
||||||
t.Fatalf("a process that named no user had one written for it:\n%s", unitFor(aProcess()))
|
t.Fatalf("a process that named no user had one written for it:\n%s", unitFor(aProcess()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A process that yields (ADR 0553) runs in the node's one yielding slice at nice 19; the slice holds the idle weight
|
||||||
|
// and the quota for its share of the machine's processors; one that does not yield has none of it.
|
||||||
|
defer func(n func() int) { machineCPUs = n }(machineCPUs)
|
||||||
|
for _, c := range []struct {
|
||||||
|
cpus int
|
||||||
|
y *declaration.Yields
|
||||||
|
unit, slice, not []string
|
||||||
|
}{
|
||||||
|
{24, &declaration.Yields{Priority: declaration.YieldIdle, CPUShare: 50},
|
||||||
|
[]string{"Slice=meshyield.slice\n", "Nice=19\n", "IOSchedulingPriority=7\n", "50%"},
|
||||||
|
[]string{"CPUWeight=idle\n", "IOWeight=1\n", "CPUQuota=1200%\n"}, []string{"CPUQuota=", "CPUWeight="}},
|
||||||
|
{1, &declaration.Yields{CPUShare: 25}, []string{"Slice=meshyield.slice\n"}, []string{"CPUQuota=100%\n"},
|
||||||
|
[]string{"Nice="}},
|
||||||
|
{24, nil, nil, nil, []string{"Slice=", "Nice=", "CPUQuota="}},
|
||||||
|
} {
|
||||||
|
machineCPUs = func() int { return c.cpus }
|
||||||
|
p := aProcess()
|
||||||
|
p.Yields = c.y
|
||||||
|
unit := unitFor(p)
|
||||||
|
for _, w := range c.unit {
|
||||||
|
if !strings.Contains(unit, w) {
|
||||||
|
t.Errorf("yields %+v: the unit does not say %q:\n%s", c.y, w, unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range c.not {
|
||||||
|
if strings.Contains(unit, n) {
|
||||||
|
t.Errorf("yields %+v: the unit says %q:\n%s", c.y, n, unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.y == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slice := yieldSlice(c.y)
|
||||||
|
for _, w := range c.slice {
|
||||||
|
if !strings.Contains(slice, w) {
|
||||||
|
t.Errorf("yields %+v on %d processors: the slice does not say %q:\n%s", c.y, c.cpus, w, slice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The slice is placed beside the units, once, and again only when it differs.
|
||||||
|
was := unitDir
|
||||||
|
unitDir = t.TempDir()
|
||||||
|
defer func() { unitDir = was }()
|
||||||
|
p := aProcess()
|
||||||
|
p.Yields = &declaration.Yields{Priority: declaration.YieldIdle, CPUShare: 50}
|
||||||
|
for i, want := range []bool{true, false} {
|
||||||
|
if wrote, err := placeYieldSlice(p); err != nil || wrote != want {
|
||||||
|
t.Errorf("placing %d: wrote %v (%v), want %v", i, wrote, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile(filepath.Join(unitDir, "meshyield.slice")); err != nil || !strings.Contains(string(b), "[Slice]") {
|
||||||
|
t.Errorf("the slice file: %q %v", b, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// **Three modes, one kind.** A scheduled process is a timer plus a unit that finishes, not a unit
|
// **Three modes, one kind.** A scheduled process is a timer plus a unit that finishes, not a unit
|
||||||
|
|||||||
@@ -22,6 +22,14 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The operator's public keys, ed25519 in base64, one a line: the node applies a composition made by hand only when one of these signed it (ADR 0539 clause 3). Public keys, no secret. Empty trusts no operator key.",
|
"description": "The operator's public keys, ed25519 in base64, one a line: the node applies a composition made by hand only when one of these signed it (ADR 0539 clause 3). Public keys, no secret. Empty trusts no operator key.",
|
||||||
"default": ""
|
"default": ""
|
||||||
|
},
|
||||||
|
"build-cpu-share": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The most of this node's processors its builds may take together, in percent (1 to 100): the node-engine holds every build module's runtime of the node in one slice at idle priority under this quota, and each build step sizes its threads from it (novox/hq ADR 0553). Set per node through the node's settings layer; the composer reads it for the build seats' runtimes, the engine's own code does not.",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100,
|
||||||
|
"default": 50,
|
||||||
|
"runtime": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
|
|||||||
Reference in New Issue
Block a user