diff --git a/go.mod b/go.mod index a5334da..ac493ab 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 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.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/go.sum b/go.sum index 976c4b4..0728d2e 100644 --- a/go.sum +++ b/go.sum @@ -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.20261024234636-10b6e202320f/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= +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.20261026134601-17b7cc480d6d/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/internal/apply/process.go b/internal/apply/process.go index cbf4e06..ef07f4c 100644 --- a/internal/apply/process.go +++ b/internal/apply/process.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "time" @@ -94,6 +95,14 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner, if r.Schedule != "" { 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 { _ = active return out, nil @@ -189,6 +198,9 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner, if err := os.WriteFile(unit, []byte(unitFor(r)), 0o644); err != nil { return out, err } + if _, err := placeYieldSlice(r); err != nil { + return out, err + } if _, err := run(ctx, "systemctl", "daemon-reload"); err != nil { return out, err } @@ -277,6 +289,7 @@ func unitFor(r *declaration.Process) string { if r.User != "" { fmt.Fprintf(&b, "User=%s\n", r.User) } + writeYields(&b, r.Yields) fmt.Fprintf(&b, "ExecStart=%s\n", strings.Join(runFrom(r), " ")) if r.Schedule != "" || r.RunOnce { // Started by its timer, or once by the host, and expected to finish. Restarting it would have it run @@ -293,6 +306,78 @@ func unitFor(r *declaration.Process) 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. // // **The mesh's cron expression, handed to the machine's own timer.** The host already parses and diff --git a/internal/apply/process_test.go b/internal/apply/process_test.go index 20b1cec..c99eef7 100644 --- a/internal/apply/process_test.go +++ b/internal/apply/process_test.go @@ -83,6 +83,60 @@ func TestAProcessRunsAsWhoItSaid(t *testing.T) { if strings.Contains(unitFor(aProcess()), "User=") { 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 diff --git a/module.json b/module.json index fcb6b77..8fd4fa6 100644 --- a/module.json +++ b/module.json @@ -22,6 +22,14 @@ "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.", "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": [