From e554094b74992cb7f1d76cea8ec1dcada10b5b67 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 21:25:00 +0200 Subject: [PATCH] The builder uses mesh-sdk's buildrun package for the caller, the steps, the places, the presence, toolchain and have sets and the choice of nodes, so the checker shares them (issue 1671) --- .../cmd/mesh-delivery-builder/main.go | 13 +- .../cmd/mesh-delivery-builder/settings.go | 7 +- .../mesh-delivery-builder/settings_test.go | 5 +- .../cmd/mesh-delivery-builder/toolchains.go | 17 +- .../internal/build/choose.go | 88 --------- .../internal/build/choose_test.go | 95 --------- .../internal/build/claim.go | 5 +- .../internal/build/fakes_test.go | 94 +++++++++ .../internal/build/health.go | 56 ------ .../internal/build/places.go | 171 ---------------- .../internal/build/places_test.go | 186 ------------------ .../internal/build/plan.go | 13 +- .../internal/build/plan_test.go | 9 +- .../internal/build/presence.go | 53 ----- .../internal/build/presence_test.go | 43 ---- .../internal/build/round4_test.go | 45 ++--- .../internal/build/run.go | 27 +-- .../internal/build/run_test.go | 23 +-- .../internal/build/steps.go | 67 ------- .../internal/build/steps_test.go | 116 ----------- .../internal/build/toolchains.go | 64 ------ .../internal/build/toolchains_test.go | 51 ----- 22 files changed, 175 insertions(+), 1073 deletions(-) delete mode 100644 modules/mesh-delivery-builder/internal/build/choose.go delete mode 100644 modules/mesh-delivery-builder/internal/build/choose_test.go create mode 100644 modules/mesh-delivery-builder/internal/build/fakes_test.go delete mode 100644 modules/mesh-delivery-builder/internal/build/health.go delete mode 100644 modules/mesh-delivery-builder/internal/build/places.go delete mode 100644 modules/mesh-delivery-builder/internal/build/places_test.go delete mode 100644 modules/mesh-delivery-builder/internal/build/presence.go delete mode 100644 modules/mesh-delivery-builder/internal/build/presence_test.go delete mode 100644 modules/mesh-delivery-builder/internal/build/steps.go delete mode 100644 modules/mesh-delivery-builder/internal/build/steps_test.go delete mode 100644 modules/mesh-delivery-builder/internal/build/toolchains.go delete mode 100644 modules/mesh-delivery-builder/internal/build/toolchains_test.go diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go index 9db5436..b5d2d34 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go @@ -13,6 +13,7 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-sdk/go/delivery/buildrun" "io" "log" "os" @@ -162,16 +163,16 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro if err != nil { return fmt.Errorf("the build capacity setting: %w", err) } - tools := &build.ToolchainSet{} - presence := &build.PresenceSet{} - have := &build.HaveSet{} + tools := &buildrun.ToolchainSet{} + presence := &buildrun.PresenceSet{} + have := &buildrun.HaveSet{} b := &build.Builder{ Claimer: build.Claimer{Store: kvStore{builds}, By: cred.Node + "." + cred.Module}, - Runner: build.Runner{Caller: caller, Steps: stepsWaiter{js}, Bound: bound}, + Runner: buildrun.Runner{Caller: caller, Steps: stepsWaiter{js}, Bound: bound}, Toolchains: tools, Presence: presence, Health: have, - Places: &build.Places{Capacity: capacity}, + Places: &buildrun.Places{Capacity: capacity}, Manifests: manifestReader{manifests}, Nodes: nodesPerFingerprint, Logf: logger.Printf, @@ -184,7 +185,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // A build module says each time it wrote its toolchains key: its stream, and the builder's durable consumer on it, are // made by the controller. Where the stream is not there yet the builder says so and is started again with the build // module. - for _, module := range []string{build.GoBuildModule, build.NodeBuildModule} { + for _, module := range []string{buildrun.GoBuildModule, buildrun.NodeBuildModule} { stream := toolchainStreams[module] cons, err := js.Consumer(ctx, stream, cred.Node+"_"+cred.Module) if err != nil { diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/settings.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/settings.go index 36ead03..e0255f1 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/settings.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/settings.go @@ -3,9 +3,8 @@ package main import ( "encoding/json" "fmt" + "git.novox.be/novox/mesh-sdk/go/delivery/buildrun" "os" - - "mesh-delivery-builder/internal/build" ) // capacityFrom is the build capacity of each node (ADR 0478 clause 3): the module's setting `capacity`, `= 0 && (qualifies == nil || qualifies(e.Node)) { // a capacity of 0 keeps the builder off a node - nodes = append(nodes, e.Node) - } - } - if len(nodes) == 0 { - sort.Strings(reporting) - return nil, fmt.Errorf("no %s build node qualifies: %s report the toolchain, and none is online with a healthy build module and a build capacity", language, strings.Join(reporting, ", ")) - } - return places.choose(nodes, want), nil -} - -// DigestsDifferPrefix opens the error of a build whose nodes built an artifact to different digests (ADR 0478 clause 4): -// such a key is not built again as a new revision, since the same source on the same toolchains gave two answers and -// building again settles nothing; it waits for another fingerprint. -const DigestsDifferPrefix = "digests differ:" - -// Agree is the one digest every node built an artifact to (ADR 0478 clause 4), or an error naming each node and its -// digest when they differ. One node's digest agrees with itself. -func Agree(artifact string, byNode map[string]string) (string, error) { - nodes := make([]string, 0, len(byNode)) - for n := range byNode { - nodes = append(nodes, n) - } - sort.Strings(nodes) - var first string - for i, n := range nodes { - if i == 0 { - first = byNode[n] - continue - } - if byNode[n] != first { - msg := fmt.Sprintf("%s the nodes built %s to different digests:", DigestsDifferPrefix, artifact) - for _, n := range nodes { - msg += fmt.Sprintf(" %s %s;", n, byNode[n]) - } - return "", fmt.Errorf("%s", msg[:len(msg)-1]) - } - } - return first, nil -} diff --git a/modules/mesh-delivery-builder/internal/build/choose_test.go b/modules/mesh-delivery-builder/internal/build/choose_test.go deleted file mode 100644 index 88a6b53..0000000 --- a/modules/mesh-delivery-builder/internal/build/choose_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package build - -import ( - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -func tcs(language, version string, nodes ...string) []delivery.NodeToolchain { - var out []delivery.NodeToolchain - for _, n := range nodes { - out = append(out, delivery.NodeToolchain{Node: n, Toolchain: delivery.Toolchain{Language: language, Version: version, Recipe: "1"}}) - } - return out -} - -func TestChooseTakesTheAgreedNodesWithFewestRunningStepsTiesByName(t *testing.T) { // 1607/AC4, ADR 0478 clause 3 - entries := tcs("go", "go1.27.1", "novox", "ace", "g14", "shanks") - nodes, tc, err := ChooseNodes("go", entries, placesRunning(map[string]int{"novox": 3, "ace": 1, "g14": 1, "shanks": 0}), nil, 2) - if err != nil { - t.Fatal(err) - } - if len(nodes) != 2 || nodes[0] != "shanks" || nodes[1] != "ace" { - t.Errorf("nodes = %v, want [shanks ace]: fewest steps first, a tie by name", nodes) - } - if tc.Version != "go1.27.1" || tc.Recipe != "1" || tc.Language != "go" { - t.Errorf("toolchain = %+v", tc) - } -} - -func TestChooseWithOneNodeAnswersOneNode(t *testing.T) { // 1607/AC4 - nodes, _, err := ChooseNodes("go", tcs("go", "go1.27.1", "novox"), nil, nil, 2) - if err != nil || len(nodes) != 1 || nodes[0] != "novox" { - t.Errorf("nodes = %v, err %v, want [novox]", nodes, err) - } -} - -func TestChooseFailsNamingTheLanguageAndTheVersionsWhenNoNodeQualifies(t *testing.T) { // 1607/AC4 - _, _, err := ChooseNodes("typescript", nil, nil, nil, 2) - if err == nil || !strings.Contains(err.Error(), "typescript") || !strings.Contains(err.Error(), delivery.ToolchainNoReport) { - t.Errorf("no report: error = %v, want it to name typescript and %q", err, delivery.ToolchainNoReport) - } - entries := append(tcs("go", "go1.27.1", "novox"), tcs("go", "go1.26.0", "ace")...) - _, _, err = ChooseNodes("go", entries, nil, nil, 2) - if err == nil || !strings.Contains(err.Error(), "go1.27.1") || !strings.Contains(err.Error(), "go1.26.0") || !strings.Contains(err.Error(), "go") { - t.Errorf("disagreeing nodes: error = %v, want both versions named", err) - } -} - -func TestAgreeNeedsEqualDigestsAndNamesBothNodesWhenTheyDiffer(t *testing.T) { // 1607/AC4, ADR 0478 clause 4 - d, err := Agree("tool", map[string]string{"ace": digest, "novox": digest}) - if err != nil || d != digest { - t.Errorf("Agree = %q, %v, want %q", d, err, digest) - } - _, err = Agree("tool", map[string]string{"ace": digest, "novox": "sha256:" + hex64('9')}) - if err == nil || !strings.Contains(err.Error(), "ace") || !strings.Contains(err.Error(), "novox") || !strings.Contains(err.Error(), digest) || !strings.Contains(err.Error(), hex64('9')) || !strings.Contains(err.Error(), "tool") { - t.Errorf("differing digests: error = %v, want the artifact, both nodes and both digests", err) - } - if d, err := Agree("tool", map[string]string{"ace": digest}); err != nil || d != digest { - t.Errorf("one node: %q, %v", d, err) - } -} - -func TestChooseSkipsNodesThatAreNotOnlineAndFailsNamingThemWhenNoneIs(t *testing.T) { // 1607 review 3, ADR 0478 clause 3 - entries := tcs("go", "go1.27.1", "ace", "g14", "novox") - online := func(n string) bool { return n != "g14" } // a sleeping laptop - nodes, tc, err := ChooseNodes("go", entries, placesRunning(map[string]int{"novox": 5}), online, 2) - if err != nil || len(nodes) != 2 || nodes[0] != "ace" || nodes[1] != "novox" || tc.Version != "go1.27.1" { - t.Errorf("nodes = %v, toolchain %+v, err %v, want [ace novox] with g14 left out although it runs nothing", nodes, tc, err) - } - _, tc, err = ChooseNodes("go", entries, nil, func(string) bool { return false }, 2) - if err == nil || !strings.Contains(err.Error(), "online") || !strings.Contains(err.Error(), "g14") || !strings.Contains(err.Error(), "go") { - t.Errorf("none online: error = %v, want it to name the nodes that report and that none is online", err) - } - if tc.Version != "go1.27.1" { - t.Errorf("none online: the agreed toolchain %+v is still answered", tc) - } -} - -// placesRunning is Places with these steps counted as running, for a test of the choice. -func placesRunning(running map[string]int) *Places { - return &Places{running: running, changed: make(chan struct{})} -} - -func TestChoosePrefersNodesBelowTheirCapacity(t *testing.T) { // round 3 - entries := tcs("go", "go1.27.1", "ace", "novox", "shanks") - // shanks has capacity 3 with 2 running: room; ace has the default 1 with 1 running: none; novox has none running. - p := placesRunning(map[string]int{"shanks": 2, "ace": 1}) - p.Capacity = map[string]int{"shanks": 3} - nodes, _, err := ChooseNodes("go", entries, p, nil, 2) - if err != nil || len(nodes) != 2 || nodes[0] != "novox" || nodes[1] != "shanks" { - t.Errorf("nodes = %v, err %v, want [novox shanks]: the nodes with room, ace at capacity left out", nodes, err) - } -} diff --git a/modules/mesh-delivery-builder/internal/build/claim.go b/modules/mesh-delivery-builder/internal/build/claim.go index 6d16939..9ab0285 100644 --- a/modules/mesh-delivery-builder/internal/build/claim.go +++ b/modules/mesh-delivery-builder/internal/build/claim.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "git.novox.be/novox/mesh-sdk/go/delivery/buildrun" "strings" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -70,7 +71,7 @@ type ClaimOptions struct { // when a second node qualifies now (ClaimOptions.MoreNodes): that is taken as it stands, with the done value, and its // builder writes the new revision when the second build ends (ADR 0478 clause 4); // - a failed key is taken again as a new revision of the same key (ADR 0429 clause 18), except one failed for a kind no -// builder builds yet (RefusedPrefix, ADR 0478 clause 2) or for digests that differ (DigestsDifferPrefix, clause 4), +// builder builds yet (RefusedPrefix, ADR 0478 clause 2) or for digests that differ (buildrun.DigestsDifferPrefix, clause 4), // which wait for another fingerprint; // - a key left queued or building by this builder is taken as it stands (ADR 0478 clause 5: a stopped builder takes its // own again when it starts), and so is one left by another builder when ClaimOptions.TakeOver (clause 6). @@ -97,7 +98,7 @@ func (c Claimer) Claim(ctx context.Context, module, fingerprint, manifest string return &Claim{Key: key, Rev: rev, Build: cur}, nil } return nil, nil - case cur.State == delivery.BuildFailed && (strings.HasPrefix(cur.Error, RefusedPrefix) || strings.HasPrefix(cur.Error, DigestsDifferPrefix)): + case cur.State == delivery.BuildFailed && (strings.HasPrefix(cur.Error, RefusedPrefix) || strings.HasPrefix(cur.Error, buildrun.DigestsDifferPrefix)): return nil, nil case cur.State == delivery.BuildFailed: return c.write(ctx, key, building, func(data []byte) (uint64, error) { return c.Store.Update(ctx, key, data, rev) }) diff --git a/modules/mesh-delivery-builder/internal/build/fakes_test.go b/modules/mesh-delivery-builder/internal/build/fakes_test.go new file mode 100644 index 0000000..2b993db --- /dev/null +++ b/modules/mesh-delivery-builder/internal/build/fakes_test.go @@ -0,0 +1,94 @@ +package build + +// The fakes of the build modules the builder tests run against: a caller answering a long tool at once with the step and its key, +// and the steps watch. The caller, the steps watch and the runner themselves are the SDK's (mesh-sdk go/delivery/buildrun, issue 1671). + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +type call struct { + node, key string + body any +} + +// fakeCaller answers a long tool as the build modules do: at once, with the step and its key. +type fakeCaller struct { + calls []call + answer string + err error +} + +func (f *fakeCaller) CallOn(node, key string, body any) (json.RawMessage, error) { + f.calls = append(f.calls, call{node, key, body}) + if f.err != nil { + return nil, f.err + } + return json.RawMessage(f.answer), nil +} + +// fakeSteps holds the steps values by `/`; Wait on a key it does not hold blocks until the context ends. +type fakeSteps struct { + mu sync.Mutex + steps map[string]delivery.Step + // during, when set, is called as a wait starts and the function it answers as the wait ends. + during func(key string) func() +} + +func (f *fakeSteps) Wait(ctx context.Context, module, key string) (delivery.Step, error) { + f.mu.Lock() + s, ok := f.steps[module+"/"+key] + during := f.during + f.mu.Unlock() + if ok { + if during != nil { + defer during(key)() + } + return s, nil + } + <-ctx.Done() + return delivery.Step{}, context.Cause(ctx) +} + +func stepAt(node, id, state, reason string, result string) delivery.Step { + s := delivery.Step{ID: id, Tool: "build_go_fetch", Node: node, State: state, Reason: reason} + if result != "" { + s.Result = json.RawMessage(result) + } + return s +} + +// presenceValue is a watchman presence value, as its writer encodes it (mesh-sdk go/delivery EncodePresence). +func presenceValue(t *testing.T, online bool) []byte { + t.Helper() + b, err := delivery.EncodePresence(delivery.Presence{Online: online}) + if err != nil { + t.Fatal(err) + } + return b +} + +// tcValue is a toolchains value, as the build module's writer encodes it (mesh-sdk go/delivery EncodeToolchain). +func tcValue(t *testing.T, language, version string) []byte { + t.Helper() + b, err := delivery.EncodeToolchain(delivery.Toolchain{Language: language, Version: version, Recipe: "1", At: time.Now()}) + if err != nil { + t.Fatal(err) + } + return b +} + +// tcs are the toolchain entries of a language on nodes, as the toolchains set gives them. +func tcs(language, version string, nodes ...string) []delivery.NodeToolchain { + var out []delivery.NodeToolchain + for _, n := range nodes { + out = append(out, delivery.NodeToolchain{Node: n, Toolchain: delivery.Toolchain{Language: language, Version: version, Recipe: "1"}}) + } + return out +} diff --git a/modules/mesh-delivery-builder/internal/build/health.go b/modules/mesh-delivery-builder/internal/build/health.go deleted file mode 100644 index d75e40b..0000000 --- a/modules/mesh-delivery-builder/internal/build/health.go +++ /dev/null @@ -1,56 +0,0 @@ -package build - -import ( - "sync" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// HaveSet holds what each node's node-engine says of each module on it (node-engine_have, key `.`): the -// builder reads it to know whether a node's build module is running and healthy, since a build module whose installed -// toolchain is not the one its recipe builds with is unhealthy (mesh-build-go build_go_health) and its steps would -// refuse (ADR 0478 clause 3). A module the node-engine has written no key of is not healthy. Safe for use from the watch -// and from a build at once. -type HaveSet struct { - mu sync.Mutex - healthy map[string]bool // by `.` -} - -// Put takes a change of the have bucket in and answers whether a module's healthy state changed. A node's own key -// (`.node`) and a value that is not a have are left out. -func (s *HaveSet) Put(key string, value []byte, deleted bool) bool { - node, module, isNode, err := delivery.ParseHaveKey(key) - if err != nil || isNode { - return false - } - ok := false - if !deleted { - h, err := delivery.DecodeHave(value) - if err != nil { - return false - } - ok = h.Phase == delivery.PhaseRunning && h.Health == delivery.HealthOK - } - k := node + "." + module - s.mu.Lock() - defer s.mu.Unlock() - if s.healthy[k] == ok { - return false - } - if s.healthy == nil { - s.healthy = map[string]bool{} - } - if ok { - s.healthy[k] = true - } else { - delete(s.healthy, k) - } - return true -} - -// Healthy is whether the module on the node was last seen running and healthy. -func (s *HaveSet) Healthy(node, module string) bool { - s.mu.Lock() - defer s.mu.Unlock() - return s.healthy[node+"."+module] -} diff --git a/modules/mesh-delivery-builder/internal/build/places.go b/modules/mesh-delivery-builder/internal/build/places.go deleted file mode 100644 index 65d41fb..0000000 --- a/modules/mesh-delivery-builder/internal/build/places.go +++ /dev/null @@ -1,171 +0,0 @@ -package build - -import ( - "context" - "fmt" - "sort" - "strconv" - "strings" - "sync" -) - -// DefaultCapacity is the build capacity of a node that has no setting: one build step at a time. -const DefaultCapacity = 1 - -// ParseCapacity reads the builder's capacity setting: `node=steps` pairs separated by commas, such as -// `shanks=3,ace=2`. A node without a pair has DefaultCapacity. A pair with no node, a count that is not a whole number of -// at least 0, and a node named twice are refused. A count of 0 keeps the builder off the node: it is never chosen. -func ParseCapacity(s string) (map[string]int, error) { - out := map[string]int{} - for _, pair := range strings.Split(s, ",") { - pair = strings.TrimSpace(pair) - if pair == "" { - continue - } - node, count, ok := strings.Cut(pair, "=") - node, count = strings.TrimSpace(node), strings.TrimSpace(count) - n, err := strconv.Atoi(count) - if !ok || node == "" || err != nil || n < 0 { - return nil, fmt.Errorf("the capacity %q is not =", pair) - } - if _, dup := out[node]; dup { - return nil, fmt.Errorf("the capacity names %s twice", node) - } - out[node] = n - } - return out, nil -} - -// Places counts the build steps the builder runs on each node and holds a lane back while its node is at its -// capacity (the operator's decision of 2026-10-21; ADR 0478 clause 3). A lane is the fetch and the builds of one build -// module on one node, run one step at a time, so a lane holds one place while it runs. A node is chosen for a lane by -// reserving a place on it at the moment of the choice (choose), so that builds choosing at once see each other's -// choices and none waits on a full node while another has room. A lane waits for its place by watching the ends of the -// other lanes, not by a timer: ending a lane wakes the waiting ones. -type Places struct { - // Capacity is the number of steps at once by node; a node not in it has DefaultCapacity. - Capacity map[string]int - - mu sync.Mutex - running map[string]int // lanes holding a place - reserved map[string]int // lanes chosen for the node that do not hold a place yet - changed chan struct{} // closed, and replaced, each time a lane ends -} - -// CapacityOf is how many steps the builder runs on the node at once: DefaultCapacity for a node the setting does not -// name, and 0 for one it names so, where the builder never builds. -func (p *Places) CapacityOf(node string) int { - if n, ok := p.Capacity[node]; ok && n >= 0 { - return n - } - return DefaultCapacity -} - -// Running is the steps in flight now, by node. -func (p *Places) Running() map[string]int { - p.mu.Lock() - defer p.mu.Unlock() - out := make(map[string]int, len(p.running)) - for n, c := range p.running { - if c > 0 { - out[n] = c - } - } - return out -} - -// Reserved is the lanes chosen for a node that hold no place yet, by node. -func (p *Places) Reserved() map[string]int { - p.mu.Lock() - defer p.mu.Unlock() - out := make(map[string]int, len(p.reserved)) - for n, c := range p.reserved { - if c > 0 { - out[n] = c - } - } - return out -} - -func (p *Places) init() { - if p.running == nil { - p.running = map[string]int{} - } - if p.reserved == nil { - p.reserved = map[string]int{} - } - if p.changed == nil { - p.changed = make(chan struct{}) - } -} - -// Acquire takes a place on the node, waiting while the node is at its capacity, and answers the function that gives it -// back. A place reserved for the node by a choice is the one taken. It returns an error when ctx ends first, and then -// gives up the reservation too. -func (p *Places) Acquire(ctx context.Context, node string) (release func(), err error) { - for { - p.mu.Lock() - p.init() - if p.running[node] < p.CapacityOf(node) { - p.running[node]++ - if p.reserved[node] > 0 { - p.reserved[node]-- - } - p.mu.Unlock() - var once sync.Once - return func() { - once.Do(func() { - p.mu.Lock() - p.running[node]-- - close(p.changed) - p.changed = make(chan struct{}) - p.mu.Unlock() - }) - }, nil - } - wait := p.changed - p.mu.Unlock() - select { - case <-wait: - case <-ctx.Done(): - p.Cancel(node) - return nil, fmt.Errorf("waiting for a place on %s: %w", node, ctx.Err()) - } - } -} - -// Cancel gives up a place reserved for the node that will not be taken. -func (p *Places) Cancel(node string) { - p.mu.Lock() - defer p.mu.Unlock() - p.init() - if p.reserved[node] > 0 { - p.reserved[node]-- - } -} - -// choose orders the nodes (those with room first, counting the lanes running and the lanes reserved, then the fewest of -// them, then by name), keeps the first want and reserves a place on each, all at once. -func (p *Places) choose(nodes []string, want int) []string { - p.mu.Lock() - defer p.mu.Unlock() - p.init() - load := func(n string) int { return p.running[n] + p.reserved[n] } - sort.SliceStable(nodes, func(i, j int) bool { - ri, rj := load(nodes[i]) < p.CapacityOf(nodes[i]), load(nodes[j]) < p.CapacityOf(nodes[j]) - if ri != rj { - return ri - } - if load(nodes[i]) != load(nodes[j]) { - return load(nodes[i]) < load(nodes[j]) - } - return nodes[i] < nodes[j] - }) - if len(nodes) > want { - nodes = nodes[:want] - } - for _, n := range nodes { - p.reserved[n]++ - } - return nodes -} diff --git a/modules/mesh-delivery-builder/internal/build/places_test.go b/modules/mesh-delivery-builder/internal/build/places_test.go deleted file mode 100644 index 27c7d8d..0000000 --- a/modules/mesh-delivery-builder/internal/build/places_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package build - -import ( - "context" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -func TestParseCapacityReadsNodeEqualsCountPairs(t *testing.T) { - c, err := ParseCapacity(" shanks=3, ace=2 ") - if err != nil || c["shanks"] != 3 || c["ace"] != 2 || len(c) != 2 { - t.Errorf("capacity = %v, %v", c, err) - } - if c, err := ParseCapacity(""); err != nil || len(c) != 0 { - t.Errorf("empty: %v, %v", c, err) - } - for _, bad := range []string{"shanks", "shanks=x", "=2", "a=1,a=2", "shanks=-1"} { - if _, err := ParseCapacity(bad); err == nil { - t.Errorf("%q accepted", bad) - } - } -} - -func acquireAsync(p *Places, node string) chan func() { - got := make(chan func(), 1) - go func() { - release, err := p.Acquire(context.Background(), node) - if err != nil { - close(got) - return - } - got <- release - }() - return got -} - -func TestThreeStepsOnANodeOfCapacityTwoRunTwoAtOnceAndTheThirdStartsWhenOneEnds(t *testing.T) { // round 3 - p := &Places{Capacity: map[string]int{"ace": 2}} - a, b := acquireAsync(p, "ace"), acquireAsync(p, "ace") - ra, rb := <-a, <-b - third := acquireAsync(p, "ace") - select { - case <-third: - t.Fatal("a third step started on a node of capacity 2 with two running") - case <-time.After(50 * time.Millisecond): - } - if p.Running()["ace"] != 2 { - t.Errorf("running = %v, want 2 on ace", p.Running()) - } - ra() - select { - case r := <-third: - r() - case <-time.After(2 * time.Second): - t.Fatal("the third step did not start when one ended") - } - rb() -} - -func TestANodeWithNoSettingRunsOneStepAtATime(t *testing.T) { // round 3 - p := &Places{Capacity: map[string]int{"shanks": 3}} - first := <-acquireAsync(p, "g14") - second := acquireAsync(p, "g14") - select { - case <-second: - t.Fatal("two steps on a node without a setting") - case <-time.After(50 * time.Millisecond): - } - first() - r := <-second - r() - if p.CapacityOf("g14") != 1 || p.CapacityOf("shanks") != 3 { - t.Errorf("capacities = %d, %d", p.CapacityOf("g14"), p.CapacityOf("shanks")) - } -} - -func TestAWaitingStepStopsWhenItsContextEnds(t *testing.T) { // round 3 - p := &Places{} - r := <-acquireAsync(p, "ace") - defer r() - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - go func() { _, err := p.Acquire(ctx, "ace"); done <- err }() - cancel() - select { - case err := <-done: - if err == nil { - t.Error("Acquire returned no error for a cancelled wait") - } - case <-time.After(2 * time.Second): - t.Fatal("a cancelled wait did not return") - } -} - -func TestStepsOfSeveralBuildsNeverExceedANodesCapacity(t *testing.T) { // round 3, through the builder - r := newRig(t, catalogue.Artifact{Name: "tool", Kind: "bundle", Language: "go"}) - r.b.Places = &Places{Capacity: map[string]int{"ace": 2}} // novox has none: one at a time - var now, most atomic.Int32 - var mu sync.Mutex - perNode := map[string]int{} - r.bm.steps.during = func(key string) func() { - node, _, _ := strings.Cut(key, ".") - mu.Lock() - perNode[node]++ - if int32(perNode[node]) > most.Load() && node == "ace" { - most.Store(int32(perNode[node])) - } - if node == "novox" && perNode[node] > 1 { - t.Errorf("%d steps at once on novox, which has capacity 1", perNode[node]) - } - mu.Unlock() - now.Add(1) - time.Sleep(15 * time.Millisecond) - return func() { mu.Lock(); perNode[node]--; mu.Unlock(); now.Add(-1) } - } - for _, m := range []string{"m1", "m2", "m3", "m4"} { - r.b.Module(context.Background(), m, moduleValue(t, "modules/"+m)) - } - r.b.Wait() - if most.Load() > 2 { - t.Errorf("%d steps at once on ace, which has capacity 2", most.Load()) - } - if most.Load() < 2 { - t.Errorf("at most %d steps at once on ace: the capacity was not used", most.Load()) - } -} - -func TestTwoNodeAgreementStillTakesTwoDifferentNodesWhenCapacitiesAreSet(t *testing.T) { // round 3, ADR 0478 clause 4 - r := newRig(t, catalogue.Artifact{Name: "tool", Kind: "bundle", Language: "go"}) - r.b.Places = &Places{Capacity: map[string]int{"ace": 2, "novox": 3}} - v := r.consider(t, "modules/m") - if v.State != "done" || !strings.Contains(v.Step, "ace and novox") { - t.Errorf("build = %+v, want done on ace and novox", v) - } -} - -// The operator's decision of 2026-10-21 13:40: the builder builds on shanks and ace only; a capacity of 0 keeps it off a node. -func TestACapacityOfZeroKeepsTheBuilderOffTheNodeAndANodeNotNamedKeepsOne(t *testing.T) { // issue 1661 - c, err := ParseCapacity("shanks=3,ace=2,g14=0,novox=0") - if err != nil || c["g14"] != 0 || c["novox"] != 0 || c["shanks"] != 3 || len(c) != 4 { - t.Fatalf("capacity = %v, %v", c, err) - } - p := &Places{Capacity: c} - if p.CapacityOf("g14") != 0 || p.CapacityOf("novox") != 0 || p.CapacityOf("ace") != 2 || p.CapacityOf("other") != DefaultCapacity { - t.Errorf("capacities = %d %d %d %d, want 0 0 2 and the default for a node not named", p.CapacityOf("g14"), p.CapacityOf("novox"), p.CapacityOf("ace"), p.CapacityOf("other")) - } - if _, err := ParseCapacity("shanks=-1"); err == nil { - t.Error("a negative capacity accepted") - } - entries := tcs("go", "go1.27.1", "ace", "g14", "novox", "shanks", "other") - for i := 0; i < 6; i++ { - nodes, _, err := ChooseNodes("go", entries, p, nil, 2) - if err != nil { - t.Fatal(err) - } - for _, n := range nodes { - if n == "g14" || n == "novox" { - t.Fatalf("choice %d: %v includes a node of capacity 0", i, nodes) - } - } - } - // Only nodes of capacity 0 report: nothing qualifies, and the build says why. - _, _, err = ChooseNodes("go", tcs("go", "go1.27.1", "g14", "novox"), p, nil, 2) - if err == nil || !strings.Contains(err.Error(), "capacity") { - t.Errorf("only capacity-0 nodes: error = %v, want it to name the capacity", err) - } -} - -func TestABuildNeverRunsOnANodeOfCapacityZero(t *testing.T) { // issue 1661 - r := newRig(t, catalogue.Artifact{Name: "tool", Kind: "bundle", Language: "go"}) // ace and novox report - r.b.Places = &Places{Capacity: map[string]int{"novox": 0}} - v := r.consider(t, "modules/m") - if v.State != "done" || !strings.Contains(v.Step, "on one node, ace") { - t.Fatalf("build = %+v, want done on ace alone", v) - } - for _, c := range r.bm.calls { - if c.node == "novox" { - t.Errorf("a step was called on novox, whose capacity is 0: %+v", c) - } - } -} diff --git a/modules/mesh-delivery-builder/internal/build/plan.go b/modules/mesh-delivery-builder/internal/build/plan.go index d765825..edebb4e 100644 --- a/modules/mesh-delivery-builder/internal/build/plan.go +++ b/modules/mesh-delivery-builder/internal/build/plan.go @@ -5,17 +5,12 @@ package build import ( "fmt" + "git.novox.be/novox/mesh-sdk/go/delivery/buildrun" "sort" "git.novox.be/novox/mesh-sdk/go/catalogue" ) -// The build modules a task is given to, and the language whose toolchain each reports (the indexer's ToolchainModules). -const ( - GoBuildModule = "mesh-build-go" - NodeBuildModule = "mesh-build-node" -) - // RefusedPrefix opens the error of a build key that is failed for a kind no builder builds yet (ADR 0478 clause 2): such // a key is not built again as a new revision. const RefusedPrefix = "refused kind:" @@ -79,11 +74,11 @@ func PlanOf(m catalogue.Manifest) (Plan, error) { for _, a := range artifacts { switch { case a.Kind == catalogue.ArtifactArchive: - p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: GoBuildModule, Language: "go"}) + p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: buildrun.GoBuildModule, Language: "go"}) case a.Kind == catalogue.ArtifactBundle && a.Language == "go": - p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: GoBuildModule, Language: "go"}) + p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: buildrun.GoBuildModule, Language: "go"}) case a.Kind == catalogue.ArtifactBundle && a.Language == "typescript": - p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: NodeBuildModule, Language: "typescript"}) + p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: buildrun.NodeBuildModule, Language: "typescript"}) case a.Kind == catalogue.ArtifactBundle: return Plan{}, &Refused{Artifact: a.Name, What: fmt.Sprintf("bundle of language %q", a.Language)} default: diff --git a/modules/mesh-delivery-builder/internal/build/plan_test.go b/modules/mesh-delivery-builder/internal/build/plan_test.go index bbeb0c3..3760917 100644 --- a/modules/mesh-delivery-builder/internal/build/plan_test.go +++ b/modules/mesh-delivery-builder/internal/build/plan_test.go @@ -2,6 +2,7 @@ package build import ( "errors" + "git.novox.be/novox/mesh-sdk/go/delivery/buildrun" "strings" "testing" @@ -23,9 +24,9 @@ func TestPlanRoutesEachKindToItsBuildModule(t *testing.T) { // 1607/AC2 t.Fatal(err) } want := []Task{ - {Artifact: "files", Kind: "archive", Module: GoBuildModule, Language: "go"}, - {Artifact: "tool", Kind: "bundle", Module: GoBuildModule, Language: "go"}, - {Artifact: "web", Kind: "bundle", Module: NodeBuildModule, Language: "typescript"}, + {Artifact: "files", Kind: "archive", Module: buildrun.GoBuildModule, Language: "go"}, + {Artifact: "tool", Kind: "bundle", Module: buildrun.GoBuildModule, Language: "go"}, + {Artifact: "web", Kind: "bundle", Module: buildrun.NodeBuildModule, Language: "typescript"}, } if len(p.Tasks) != len(want) { t.Fatalf("tasks = %+v, want %+v", p.Tasks, want) @@ -35,7 +36,7 @@ func TestPlanRoutesEachKindToItsBuildModule(t *testing.T) { // 1607/AC2 t.Errorf("task %d = %+v, want %+v", i, p.Tasks[i], want[i]) } } - if modules := p.Modules(); len(modules) != 2 || modules[0] != GoBuildModule || modules[1] != NodeBuildModule { + if modules := p.Modules(); len(modules) != 2 || modules[0] != buildrun.GoBuildModule || modules[1] != buildrun.NodeBuildModule { t.Errorf("modules = %v", modules) } } diff --git a/modules/mesh-delivery-builder/internal/build/presence.go b/modules/mesh-delivery-builder/internal/build/presence.go deleted file mode 100644 index b1131aa..0000000 --- a/modules/mesh-delivery-builder/internal/build/presence.go +++ /dev/null @@ -1,53 +0,0 @@ -package build - -import ( - "sync" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// PresenceSet holds which nodes are online, as the watchman writes it (mesh-delivery-watchman_presence, key ``, the -// node-engine's connection): a node asleep or off the bus is offline and its build module cannot answer. A node the -// watchman has no key of is not online. Safe for use from the watch and from a build at once. -type PresenceSet struct { - mu sync.Mutex - online map[string]bool -} - -// Put takes a change of the presence bucket in and answers whether a node's online state changed. A part's key and a -// value that is not a presence are left out. -func (s *PresenceSet) Put(key string, value []byte, deleted bool) bool { - node, part, _, err := delivery.ParsePresenceKey(key) - if err != nil || part != "" { - return false - } - on := false - if !deleted { - p, err := delivery.DecodePresence(value) - if err != nil { - return false - } - on = p.Online - } - s.mu.Lock() - defer s.mu.Unlock() - if s.online[node] == on { - return false - } - if s.online == nil { - s.online = map[string]bool{} - } - if on { - s.online[node] = true - } else { - delete(s.online, node) - } - return true -} - -// Online is whether the watchman last saw the node online. -func (s *PresenceSet) Online(node string) bool { - s.mu.Lock() - defer s.mu.Unlock() - return s.online[node] -} diff --git a/modules/mesh-delivery-builder/internal/build/presence_test.go b/modules/mesh-delivery-builder/internal/build/presence_test.go deleted file mode 100644 index c1f3de7..0000000 --- a/modules/mesh-delivery-builder/internal/build/presence_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package build - -import ( - "testing" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -func presenceValue(t *testing.T, online bool) []byte { - t.Helper() - b, err := delivery.EncodePresence(delivery.Presence{Online: online}) - if err != nil { - t.Fatal(err) - } - return b -} - -func TestPresenceSetKnowsOnlyNodesTheWatchmanSawOnline(t *testing.T) { // 1607 review 3 - var s PresenceSet - if s.Online("ace") { - t.Error("a node the watchman never wrote is online") - } - if !s.Put("ace", presenceValue(t, true), false) || !s.Online("ace") { - t.Error("ace online was not taken in") - } - if s.Put("ace", presenceValue(t, true), false) { - t.Error("the same presence again counted as a change") - } - if !s.Put("ace", presenceValue(t, false), false) || s.Online("ace") { - t.Error("ace going offline was not taken in") - } - s.Put("g14", presenceValue(t, true), false) - if !s.Put("g14", nil, true) || s.Online("g14") { - t.Error("a deleted key is still online") - } - // A part's instance (part..) is not a node. - if s.Put("part.mesh-delivery-builder.novox", presenceValue(t, true), false) || s.Online("part") { - t.Error("a part's key was taken for a node") - } - if s.Put("novox", []byte("junk"), false) { - t.Error("junk counted") - } -} diff --git a/modules/mesh-delivery-builder/internal/build/round4_test.go b/modules/mesh-delivery-builder/internal/build/round4_test.go index fc7f638..24f962f 100644 --- a/modules/mesh-delivery-builder/internal/build/round4_test.go +++ b/modules/mesh-delivery-builder/internal/build/round4_test.go @@ -2,6 +2,7 @@ package build import ( "context" + "git.novox.be/novox/mesh-sdk/go/delivery/buildrun" "strings" "testing" @@ -40,14 +41,14 @@ func TestAnArchiveOnlyModuleIsBuiltAlthoughItsFingerprintNamesNoToolchain(t *tes func TestAnArchiveOnlyModuleNeedsNoAgreedToolchainButNeedsNodesThatQualify(t *testing.T) { // review round 4, 1 r := newRig(t, archiveA) - r.tools.Put(GoBuildModule, "ace.go", tcValue(t, "go", "go1.26.0"), false) // the nodes disagree: a bundle would wait - r.b.Health = &HaveSet{} // no build module is healthy anywhere + r.tools.Put(buildrun.GoBuildModule, "ace.go", tcValue(t, "go", "go1.26.0"), false) // the nodes disagree: a bundle would wait + r.b.Health = &buildrun.HaveSet{} // no build module is healthy anywhere r.b.Module(context.Background(), "m", moduleValueWith(t, "modules/m", nil)) r.b.Wait() if v := valueOf(t, r.st, r.key); v.State != delivery.BuildFailed || !strings.Contains(v.Error, "healthy") { t.Errorf("build = %+v, want failed: no node's build module is healthy", v) } - have := &HaveSet{} + have := &buildrun.HaveSet{} have.Put("novox.mesh-build-go", haveValue(t, delivery.PhaseRunning, delivery.HealthOK), false) r.b.Health = have r.b.Reconsider(context.Background()) @@ -60,11 +61,11 @@ func TestAnArchiveOnlyModuleNeedsNoAgreedToolchainButNeedsNodesThatQualify(t *te func TestABundleAndAnArchiveOfOneBuildModuleStillNeedTheAgreedToolchain(t *testing.T) { // review round 4, 1 p, err := PlanOf(catalogue.Manifest{Module: "m", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{goBundle, archiveA}}}) - if err != nil || !p.NeedsToolchain(GoBuildModule) { - t.Errorf("a plan with a Go bundle: %v, %v", p.NeedsToolchain(GoBuildModule), err) + if err != nil || !p.NeedsToolchain(buildrun.GoBuildModule) { + t.Errorf("a plan with a Go bundle: %v, %v", p.NeedsToolchain(buildrun.GoBuildModule), err) } p, _ = PlanOf(catalogue.Manifest{Module: "m", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{archiveA}}}) - if p.NeedsToolchain(GoBuildModule) { + if p.NeedsToolchain(buildrun.GoBuildModule) { t.Error("a plan of an archive needs a toolchain") } } @@ -90,7 +91,7 @@ func TestDifferingDigestsFailTheKeyWithThePrefixAndNothingBuildsItAgain(t *testi func TestClaimLeavesAKeyFailedForDifferingDigestsAlone(t *testing.T) { // review round 4, 2 st := newMem() - seed(t, st, delivery.BuilderBuild{State: delivery.BuildFailed, Error: DigestsDifferPrefix + " the nodes built tool to different digests: ace x; novox y"}) + seed(t, st, delivery.BuilderBuild{State: delivery.BuildFailed, Error: buildrun.DigestsDifferPrefix + " the nodes built tool to different digests: ace x; novox y"}) writes := st.writes got, err := (Claimer{Store: st, By: "novox.a"}).Claim(context.Background(), "m", fp1, "", ClaimOptions{TakeOver: true}) if err != nil || got != nil || st.writes != writes { @@ -103,10 +104,10 @@ func TestADoneKeyOfOneNodeIsBuiltAgainOnASecondNodeAndTheDigestsCompared(t *test r := newRig(t, goBundle) r.pres.Put("ace", presenceValue(t, false), false) // only novox qualifies v := r.consider(t, "modules/m") - if v.State != delivery.BuildDone || !strings.HasPrefix(v.Step, OneNodePrefix+GoBuildModule+"): ") || !strings.Contains(v.Step, "on one node, novox") { + if v.State != delivery.BuildDone || !strings.HasPrefix(v.Step, OneNodePrefix+buildrun.GoBuildModule+"): ") || !strings.Contains(v.Step, "on one node, novox") { t.Fatalf("build = %+v, want done on one node, marked", v) } - if got := OneNodeModules(v); len(got) != 1 || got[0] != GoBuildModule { + if got := OneNodeModules(v); len(got) != 1 || got[0] != buildrun.GoBuildModule { t.Errorf("OneNodeModules = %v", got) } rev, calls := r.st.revs[r.key], len(r.bm.callsOf("build_go_build")) @@ -141,7 +142,7 @@ func TestASecondNodeThatDisagreesFailsTheDoneKeyWithTheDigestsPrefix(t *testing. r.pres.Put("ace", presenceValue(t, true), false) r.b.Reconsider(context.Background()) r.b.Wait() - if v := valueOf(t, r.st, r.key); v.State != delivery.BuildFailed || !strings.HasPrefix(v.Error, DigestsDifferPrefix) { + if v := valueOf(t, r.st, r.key); v.State != delivery.BuildFailed || !strings.HasPrefix(v.Error, buildrun.DigestsDifferPrefix) { t.Errorf("build = %+v, want failed with digests differ", v) } } @@ -161,7 +162,7 @@ func TestASecondBuildThatFailsForAnotherReasonLeavesTheDoneKeyStanding(t *testin // 4. A step never waits on a full node while another qualifying node has room. func TestChoosesMadeAtOnceSeeEachOthersReservationsSoNoLaneWaitsWhileANodeHasRoom(t *testing.T) { // review round 4, 4 - p := &Places{Capacity: map[string]int{"ace": 2, "g14": 1, "novox": 1, "shanks": 3}} + p := &buildrun.Places{Capacity: map[string]int{"ace": 2, "g14": 1, "novox": 1, "shanks": 3}} entries := tcs("go", "go1.27.1", "ace", "g14", "novox", "shanks") total := 2 + 1 + 1 + 3 var waiting int @@ -170,7 +171,7 @@ func TestChoosesMadeAtOnceSeeEachOthersReservationsSoNoLaneWaitsWhileANodeHasRoo for _, n := range []string{"ace", "g14", "novox", "shanks"} { room += p.CapacityOf(n) - p.Running()[n] - p.Reserved()[n] } - nodes, _, err := ChooseNodes("go", entries, p, nil, 2) + nodes, _, err := buildrun.ChooseNodes("go", entries, p, nil, 2) if err != nil || len(nodes) != 2 { t.Fatalf("choice %d: %v, %v", i, nodes, err) } @@ -199,8 +200,8 @@ func TestChoosesMadeAtOnceSeeEachOthersReservationsSoNoLaneWaitsWhileANodeHasRoo } func TestACancelledReservationFreesTheChoiceAndAnAcquireTakesItsReservation(t *testing.T) { // review round 4, 4 - p := &Places{} - nodes, _, _ := ChooseNodes("go", tcs("go", "go1.27.1", "ace"), p, nil, 1) + p := &buildrun.Places{} + nodes, _, _ := buildrun.ChooseNodes("go", tcs("go", "go1.27.1", "ace"), p, nil, 1) if len(nodes) != 1 || p.Reserved()["ace"] != 1 { t.Fatalf("nodes %v, reserved %v", nodes, p.Reserved()) } @@ -209,7 +210,7 @@ func TestACancelledReservationFreesTheChoiceAndAnAcquireTakesItsReservation(t *t t.Fatalf("acquire: %v, reserved %v, running %v", err, p.Reserved(), p.Running()) } release() - ChooseNodes("go", tcs("go", "go1.27.1", "ace"), p, nil, 1) + buildrun.ChooseNodes("go", tcs("go", "go1.27.1", "ace"), p, nil, 1) p.Cancel("ace") if len(p.Reserved()) != 0 { t.Errorf("reserved after Cancel = %v", p.Reserved()) @@ -218,25 +219,25 @@ func TestACancelledReservationFreesTheChoiceAndAnAcquireTakesItsReservation(t *t // 5. A node whose build module is not healthy is not chosen. func TestHaveSetKnowsOnlyBuildModulesRunningAndHealthy(t *testing.T) { // review round 4, 5 - var s HaveSet - if s.Healthy("ace", GoBuildModule) { + var s buildrun.HaveSet + if s.Healthy("ace", buildrun.GoBuildModule) { t.Error("a module with no have is healthy") } - if !s.Put("ace.mesh-build-go", haveValue(t, delivery.PhaseRunning, delivery.HealthOK), false) || !s.Healthy("ace", GoBuildModule) { + if !s.Put("ace.mesh-build-go", haveValue(t, delivery.PhaseRunning, delivery.HealthOK), false) || !s.Healthy("ace", buildrun.GoBuildModule) { t.Error("a running, healthy module is not") } if s.Put("ace.mesh-build-go", haveValue(t, delivery.PhaseRunning, delivery.HealthOK), false) { t.Error("the same state counted as a change") } - if !s.Put("ace.mesh-build-go", haveValue(t, delivery.PhaseRunning, delivery.HealthFailed), false) || s.Healthy("ace", GoBuildModule) { + if !s.Put("ace.mesh-build-go", haveValue(t, delivery.PhaseRunning, delivery.HealthFailed), false) || s.Healthy("ace", buildrun.GoBuildModule) { t.Error("a module whose health failed is healthy") } s.Put("ace.mesh-build-go", haveValue(t, delivery.PhaseRunning, delivery.HealthOK), false) - if !s.Put("ace.mesh-build-go", haveValue(t, delivery.PhaseFailed, delivery.HealthOK), false) || s.Healthy("ace", GoBuildModule) { + if !s.Put("ace.mesh-build-go", haveValue(t, delivery.PhaseFailed, delivery.HealthOK), false) || s.Healthy("ace", buildrun.GoBuildModule) { t.Error("a module whose phase failed is healthy") } s.Put("ace.mesh-build-go", haveValue(t, delivery.PhaseRunning, delivery.HealthOK), false) - if !s.Put("ace.mesh-build-go", nil, true) || s.Healthy("ace", GoBuildModule) { + if !s.Put("ace.mesh-build-go", nil, true) || s.Healthy("ace", buildrun.GoBuildModule) { t.Error("a deleted have is healthy") } if s.Put("ace.node", []byte(`{}`), false) || s.Put("ace.mesh-build-go", []byte(`not json`), false) { @@ -246,7 +247,7 @@ func TestHaveSetKnowsOnlyBuildModulesRunningAndHealthy(t *testing.T) { // review func TestANodeWhoseBuildModuleIsNotHealthyIsNotChosen(t *testing.T) { // review round 4, 5 r := newRig(t, goBundle) - have := &HaveSet{} + have := &buildrun.HaveSet{} have.Put("ace.mesh-build-go", haveValue(t, delivery.PhaseRunning, delivery.HealthFailed), false) // go is not the recipe's have.Put("novox.mesh-build-go", haveValue(t, delivery.PhaseRunning, delivery.HealthOK), false) r.b.Health = have diff --git a/modules/mesh-delivery-builder/internal/build/run.go b/modules/mesh-delivery-builder/internal/build/run.go index 53de028..66877e3 100644 --- a/modules/mesh-delivery-builder/internal/build/run.go +++ b/modules/mesh-delivery-builder/internal/build/run.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-sdk/go/delivery/buildrun" "sort" "strings" "sync" @@ -12,13 +13,13 @@ import ( "git.novox.be/novox/mesh-sdk/go/delivery" ) -// ToolchainEntries are the nodes' toolchain entries of a language (ToolchainSet). +// ToolchainEntries are the nodes' toolchain entries of a language (buildrun.ToolchainSet). type ToolchainEntries interface { Entries(language string) []delivery.NodeToolchain } // toolPrefix is the start of each build module's tool names: fetch and build. -var toolPrefix = map[string]string{GoBuildModule: "build_go_", NodeBuildModule: "build_node_"} +var toolPrefix = map[string]string{buildrun.GoBuildModule: "build_go_", buildrun.NodeBuildModule: "build_node_"} // Builder builds, dark, every new-form fingerprint the indexer writes (novox/hq ADR 0453 clause 4, ADR 0478): it is // handed each modules value as the watch of the indexer's bucket sees it, and for each module with a fingerprint and an @@ -27,7 +28,7 @@ var toolPrefix = map[string]string{GoBuildModule: "build_go_", NodeBuildModule: // change of the toolchains. type Builder struct { Claimer Claimer - Runner Runner + Runner buildrun.Runner Toolchains ToolchainEntries // Presence says which nodes are online (the watchman's); a node that is not is not chosen. Presence interface{ Online(node string) bool } @@ -37,8 +38,8 @@ type Builder struct { Health interface { Healthy(node, module string) bool } - // Places holds a step back while its node is at its build capacity; a Builder with none runs one step at a time on each node. - Places *Places + // buildrun.Places holds a step back while its node is at its build capacity; a Builder with none runs one step at a time on each node. + Places *buildrun.Places // Nodes is how many build nodes build each fingerprint: two until release R4e (ADR 0478 clause 4). Nodes int // Limit is how many builds run at once; four when 0. @@ -75,7 +76,7 @@ func (b *Builder) init() { b.known, b.inflight, b.again, b.plans, b.tried = map[string]delivery.Module{}, map[string]bool{}, map[string]delivery.Module{}, map[string]planned{}, map[string]bool{} b.secondAt = map[string]uint64{} if b.Places == nil { - b.Places = &Places{} + b.Places = &buildrun.Places{} } }) } @@ -293,7 +294,7 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) { return } result.By, result.Manifest = b.Claimer.By, m.Manifest - if cl.Build.State == delivery.BuildDone && result.State == delivery.BuildFailed && !strings.HasPrefix(result.Error, DigestsDifferPrefix) { + if cl.Build.State == delivery.BuildDone && result.State == delivery.BuildFailed && !strings.HasPrefix(result.Error, buildrun.DigestsDifferPrefix) { // A done key built again on a second node keeps its done value when the new build fails for another reason than // the digests: the first build stands, and a later change of the nodes looks again. b.logf("%s: built again on a second node and failed, the done build stands: %s", cl.Key, result.Error) @@ -318,7 +319,7 @@ func (b *Builder) stale(m delivery.Module, plan Plan) string { if !plan.NeedsToolchain(bm) { continue // an archive is packed with no toolchain } - language := LanguageOf[bm] + language := buildrun.LanguageOf[bm] tc, none := delivery.AgreedToolchain(b.Toolchains.Entries(language)) if none != "" { return fmt.Sprintf("the %s toolchain is not agreed: %s", language, none) @@ -367,12 +368,12 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p } } for _, bm := range plan.Modules() { - language := LanguageOf[bm] + language := buildrun.LanguageOf[bm] entries := b.Toolchains.Entries(language) qualifies := func(node string) bool { return b.qualifies(node, bm) } var nodes []string if plan.NeedsToolchain(bm) { - ns, tc, err := ChooseNodes(language, entries, b.Places, qualifies, b.Nodes) + ns, tc, err := buildrun.ChooseNodes(language, entries, b.Places, qualifies, b.Nodes) if err != nil { giveUpLanes() return failed("%s", err) @@ -386,7 +387,7 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p nodes = ns } else { // An archive is packed with no toolchain (the fingerprint names none): any node of the build module that qualifies. - ns, err := ChooseAnyNodes(language, entries, b.Places, qualifies, b.Nodes) + ns, err := buildrun.ChooseAnyNodes(language, entries, b.Places, qualifies, b.Nodes) if err != nil { giveUpLanes() return failed("%s", err) @@ -434,7 +435,7 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p byNode[l.node] = d } } - d, err := Agree(t.Artifact, byNode) + d, err := buildrun.Agree(t.Artifact, byNode) if err != nil { return failed("%s", err) } @@ -463,7 +464,7 @@ func (b *Builder) moreNodes(modules []string) bool { for _, bm := range modules { seen := map[string]bool{} n := 0 - for _, e := range b.Toolchains.Entries(LanguageOf[bm]) { + for _, e := range b.Toolchains.Entries(buildrun.LanguageOf[bm]) { if !seen[e.Node] && b.qualifies(e.Node, bm) { seen[e.Node] = true n++ diff --git a/modules/mesh-delivery-builder/internal/build/run_test.go b/modules/mesh-delivery-builder/internal/build/run_test.go index 34c89a6..9764790 100644 --- a/modules/mesh-delivery-builder/internal/build/run_test.go +++ b/modules/mesh-delivery-builder/internal/build/run_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "git.novox.be/novox/mesh-sdk/go/delivery/buildrun" "strings" "sync" "testing" @@ -94,22 +95,22 @@ type rig struct { b *Builder st *memStore bm *buildModules - tools *ToolchainSet - pres *PresenceSet + tools *buildrun.ToolchainSet + pres *buildrun.PresenceSet mf *fakeManifests key string } func newRig(t *testing.T, artifacts ...catalogue.Artifact) *rig { t.Helper() - r := &rig{st: newMem(), bm: newBuildModules(), tools: &ToolchainSet{}, pres: &PresenceSet{}, mf: &fakeManifests{byDigest: map[string]catalogue.Manifest{mdigest('a'): {Module: "m", Build: &catalogue.Build{Artifacts: artifacts}}}}} + r := &rig{st: newMem(), bm: newBuildModules(), tools: &buildrun.ToolchainSet{}, pres: &buildrun.PresenceSet{}, mf: &fakeManifests{byDigest: map[string]catalogue.Manifest{mdigest('a'): {Module: "m", Build: &catalogue.Build{Artifacts: artifacts}}}}} for _, n := range []string{"ace", "novox"} { - r.tools.Put(GoBuildModule, n+".go", tcValue(t, "go", "go1.27.1"), false) - r.tools.Put(NodeBuildModule, n+".typescript", tcValue(t, "typescript", "tsc5"), false) + r.tools.Put(buildrun.GoBuildModule, n+".go", tcValue(t, "go", "go1.27.1"), false) + r.tools.Put(buildrun.NodeBuildModule, n+".typescript", tcValue(t, "typescript", "tsc5"), false) r.pres.Put(n, presenceValue(t, true), false) } r.b = &Builder{ - Claimer: Claimer{Store: r.st, By: "novox.builder"}, Runner: Runner{Caller: r.bm, Steps: r.bm.steps, Bound: time.Second}, + Claimer: Claimer{Store: r.st, By: "novox.builder"}, Runner: buildrun.Runner{Caller: r.bm, Steps: r.bm.steps, Bound: time.Second}, Toolchains: r.tools, Presence: r.pres, Manifests: r.mf, Nodes: 2, Logf: t.Logf, } var err error @@ -202,7 +203,7 @@ func TestDifferingDigestsFailTheBuildNamingBothNodes(t *testing.T) { // 1607/AC4 func TestOneQualifyingNodeBuildsOnceAndTheKeySaysSo(t *testing.T) { // 1607/AC4, ADR 0478 clause 4 r := newRig(t, goBundle) - r.tools.Put(GoBuildModule, "ace.go", nil, true) + r.tools.Put(buildrun.GoBuildModule, "ace.go", nil, true) v := r.consider(t, "modules/m") if v.State != delivery.BuildDone || !strings.Contains(v.Step, "one node") || !strings.Contains(v.Step, "novox") { t.Errorf("build = %+v, want done with its step saying one node, novox", v) @@ -340,8 +341,8 @@ func TestAKeyIsBuiltOnlyWithTheToolchainItsFingerprintNamesAndNothingIsWrittenOt t.Errorf("keys %v and %d calls for a recipe that differs or a value that names no toolchain", r.st.vals, len(r.bm.calls)) } // The toolchain moves under a key that is not built yet: the old fingerprint stays unbuilt, the new one builds. - r.tools.Put(GoBuildModule, "ace.go", tcValue(t, "go", "go1.27.2"), false) - r.tools.Put(GoBuildModule, "novox.go", tcValue(t, "go", "go1.27.2"), false) + r.tools.Put(buildrun.GoBuildModule, "ace.go", tcValue(t, "go", "go1.27.2"), false) + r.tools.Put(buildrun.GoBuildModule, "novox.go", tcValue(t, "go", "go1.27.2"), false) r.b.Reconsider(context.Background()) r.b.Wait() if len(r.st.vals) != 0 { @@ -356,7 +357,7 @@ func TestAKeyIsBuiltOnlyWithTheToolchainItsFingerprintNamesAndNothingIsWrittenOt func TestNodesTheyDisagreeOnLeaveTheKeyUnwritten(t *testing.T) { // 1615/AC4 r := newRig(t, goBundle) - r.tools.Put(GoBuildModule, "ace.go", tcValue(t, "go", "go1.26.0"), false) + r.tools.Put(buildrun.GoBuildModule, "ace.go", tcValue(t, "go", "go1.26.0"), false) r.b.Module(context.Background(), "m", moduleValue(t, "modules/m")) r.b.Wait() if len(r.st.vals) != 0 || len(r.bm.calls) != 0 { @@ -376,7 +377,7 @@ func TestARootModulesPathIsDotAndIsGivenToTheBuildTools(t *testing.T) { // 1615/ func TestANodeThatIsNotOnlineIsNotChosenAndComingBackWakesTheBuild(t *testing.T) { // 1607 review 3 r := newRig(t, goBundle) - r.tools.Put(GoBuildModule, "g14.go", tcValue(t, "go", "go1.27.1"), false) // a laptop that reports its toolchain and sleeps + r.tools.Put(buildrun.GoBuildModule, "g14.go", tcValue(t, "go", "go1.27.1"), false) // a laptop that reports its toolchain and sleeps v := r.consider(t, "modules/m") if v.State != delivery.BuildDone { t.Fatalf("build = %+v", v) diff --git a/modules/mesh-delivery-builder/internal/build/steps.go b/modules/mesh-delivery-builder/internal/build/steps.go deleted file mode 100644 index 3a84936..0000000 --- a/modules/mesh-delivery-builder/internal/build/steps.go +++ /dev/null @@ -1,67 +0,0 @@ -package build - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// Caller calls a tool of a module on one named machine (mesh-sdk process.Conn.CallOn; ADR 0469 clause 3). -type Caller interface { - CallOn(node, key string, body any) (json.RawMessage, error) -} - -// Steps waits for the end of a build module's long step: the value of its key `.` in the module's steps state -// (ADR 0469 clause 2) once its state is not running. It is a watch of that key, not a poll; it returns when the step -// ends or ctx does. -type Steps interface { - Wait(ctx context.Context, module, key string) (delivery.Step, error) -} - -// Runner runs a build module's long steps: the call answers at once with the step's key, and the result is read from that -// key when the step ends (ADR 0469 clause 2). -type Runner struct { - Caller Caller - Steps Steps - // Bound is how long a step may run before the builder stops waiting for it. - Bound time.Duration -} - -// Run calls tool of the build module on node with args, waits for the step's end and answers its result. A step that -// failed or was refused is an error naming the node, the state and the reason. -func (r Runner) Run(ctx context.Context, module, node, tool string, args map[string]any) (json.RawMessage, error) { - raw, err := r.Caller.CallOn(node, module+"."+tool, args) - if err != nil { - return nil, fmt.Errorf("%s on %s: %w", tool, node, err) - } - var answer struct { - Step string `json:"step"` - Key string `json:"key"` - State string `json:"state"` - } - if err := json.Unmarshal(raw, &answer); err != nil { - return nil, fmt.Errorf("%s on %s answered what is not a step: %w", tool, node, err) - } - if answer.Key == "" { - return nil, fmt.Errorf("%s on %s answered no step key: %s", tool, node, raw) - } - if n, _, err := delivery.ParseStepKey(answer.Key); err != nil || n != node { - return nil, fmt.Errorf("%s on %s answered the step key %q, which is not a key of that node", tool, node, answer.Key) - } - wait, cancel := context.WithTimeoutCause(ctx, r.Bound, fmt.Errorf("the step %s did not end within %s", answer.Key, r.Bound)) - defer cancel() - step, err := r.Steps.Wait(wait, module, answer.Key) - if err != nil { - if cause := context.Cause(wait); cause != nil && ctx.Err() == nil { - err = cause - } - return nil, fmt.Errorf("%s on %s: %w", tool, node, err) - } - if step.State != delivery.StepPassed { - return nil, fmt.Errorf("%s on %s: the step %s %s: %s", tool, node, answer.Key, step.State, step.Reason) - } - return step.Result, nil -} diff --git a/modules/mesh-delivery-builder/internal/build/steps_test.go b/modules/mesh-delivery-builder/internal/build/steps_test.go deleted file mode 100644 index 4a0503b..0000000 --- a/modules/mesh-delivery-builder/internal/build/steps_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package build - -import ( - "context" - "encoding/json" - "errors" - "strings" - "sync" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -type call struct { - node, key string - body any -} - -// fakeCaller answers a long tool as the build modules do: at once, with the step and its key. -type fakeCaller struct { - calls []call - answer string - err error -} - -func (f *fakeCaller) CallOn(node, key string, body any) (json.RawMessage, error) { - f.calls = append(f.calls, call{node, key, body}) - if f.err != nil { - return nil, f.err - } - return json.RawMessage(f.answer), nil -} - -// fakeSteps holds the steps values by `/`; Wait on a key it does not hold blocks until the context ends. -type fakeSteps struct { - mu sync.Mutex - steps map[string]delivery.Step - // during, when set, is called as a wait starts and the function it answers as the wait ends. - during func(key string) func() -} - -func (f *fakeSteps) Wait(ctx context.Context, module, key string) (delivery.Step, error) { - f.mu.Lock() - s, ok := f.steps[module+"/"+key] - during := f.during - f.mu.Unlock() - if ok { - if during != nil { - defer during(key)() - } - return s, nil - } - <-ctx.Done() - return delivery.Step{}, context.Cause(ctx) -} - -func stepAt(node, id, state, reason string, result string) delivery.Step { - s := delivery.Step{ID: id, Tool: "build_go_fetch", Node: node, State: state, Reason: reason} - if result != "" { - s.Result = json.RawMessage(result) - } - return s -} - -func TestRunCallsTheToolOnTheNodeAndReadsTheStepsKey(t *testing.T) { // 1607/AC5 - caller := &fakeCaller{answer: `{"step":"abc1","key":"ace.abc1","state":"running"}`} - steps := &fakeSteps{steps: map[string]delivery.Step{"mesh-build-go/ace.abc1": stepAt("ace", "abc1", delivery.StepPassed, "", `{"workspace":"w1","commit":"c"}`)}} - res, err := Runner{Caller: caller, Steps: steps, Bound: time.Second}.Run(context.Background(), "mesh-build-go", "ace", "build_go_fetch", map[string]any{"repository": "novox/r", "commit": "c"}) - if err != nil { - t.Fatal(err) - } - if len(caller.calls) != 1 || caller.calls[0].node != "ace" || caller.calls[0].key != "mesh-build-go.build_go_fetch" { - t.Errorf("calls = %+v, want one on ace for mesh-build-go.build_go_fetch", caller.calls) - } - var fr struct{ Workspace string } - if err := json.Unmarshal(res, &fr); err != nil || fr.Workspace != "w1" { - t.Errorf("result = %s (%v), want the step's result", res, err) - } -} - -func TestRunFailsOnAFailedOrRefusedStepNamingItsReason(t *testing.T) { // 1607/AC5 - for _, state := range []string{delivery.StepFailed, delivery.StepRefused} { - caller := &fakeCaller{answer: `{"step":"abc1","key":"ace.abc1","state":"running"}`} - steps := &fakeSteps{steps: map[string]delivery.Step{"mesh-build-go/ace.abc1": stepAt("ace", "abc1", state, "go is go1.26.0, the recipe builds with go1.27.1", "")}} - _, err := Runner{Caller: caller, Steps: steps, Bound: time.Second}.Run(context.Background(), "mesh-build-go", "ace", "build_go_build", nil) - if err == nil || !strings.Contains(err.Error(), "go1.27.1") || !strings.Contains(err.Error(), state) || !strings.Contains(err.Error(), "ace") { - t.Errorf("%s: error = %v, want the node, the state and the reason", state, err) - } - } -} - -func TestRunStopsWaitingAtTheBound(t *testing.T) { // 1607/AC5 - caller := &fakeCaller{answer: `{"step":"abc1","key":"ace.abc1","state":"running"}`} - _, err := Runner{Caller: caller, Steps: &fakeSteps{}, Bound: 20 * time.Millisecond}.Run(context.Background(), "mesh-build-go", "ace", "build_go_build", nil) - if err == nil || !strings.Contains(err.Error(), "ace.abc1") || !strings.Contains(err.Error(), "did not end") { - t.Errorf("error = %v, want the step key and that it did not end within its bound", err) - } -} - -func TestRunRefusesAnAnswerWithNoKeyAndAKeyOfAnotherNode(t *testing.T) { // 1607/AC5 - for _, answer := range []string{`{"state":"running"}`, `{"step":"abc1","key":"novox.abc1","state":"running"}`, `not json`} { - _, err := Runner{Caller: &fakeCaller{answer: answer}, Steps: &fakeSteps{}, Bound: time.Second}.Run(context.Background(), "mesh-build-go", "ace", "build_go_fetch", nil) - if err == nil { - t.Errorf("answer %s: no error", answer) - } - } -} - -func TestRunReturnsTheCallsOwnError(t *testing.T) { // 1607/AC5 - boom := errors.New("no responders") - _, err := Runner{Caller: &fakeCaller{err: boom}, Steps: &fakeSteps{}, Bound: time.Second}.Run(context.Background(), "mesh-build-go", "ace", "build_go_fetch", nil) - if !errors.Is(err, boom) || !strings.Contains(err.Error(), "ace") { - t.Errorf("error = %v, want the call's error naming the node", err) - } -} diff --git a/modules/mesh-delivery-builder/internal/build/toolchains.go b/modules/mesh-delivery-builder/internal/build/toolchains.go deleted file mode 100644 index 44af6e6..0000000 --- a/modules/mesh-delivery-builder/internal/build/toolchains.go +++ /dev/null @@ -1,64 +0,0 @@ -package build - -import ( - "sync" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// LanguageOf is the language each build module's toolchains state holds (the indexer's ToolchainModules, reversed). -var LanguageOf = map[string]string{GoBuildModule: "go", NodeBuildModule: "typescript"} - -// ToolchainSet holds what the build modules' toolchains buckets hold: each node's entry of a language, as the watches -// put them in. Safe for use from the watches and from a build at once. -type ToolchainSet struct { - mu sync.Mutex - by map[string]map[string]delivery.Toolchain // language -> node -> entry -} - -// Put takes a change of a build module's toolchains bucket in: a put of a node's entry (key .) or its -// delete. It answers whether the language's entries changed (a node's version or recipe, or a node appearing or going). -// A key or a value that is not an entry of the module's language is left out. -func (s *ToolchainSet) Put(module, key string, value []byte, deleted bool) bool { - language, ok := LanguageOf[module] - if !ok { - return false - } - node, keyLanguage, err := delivery.ParseToolchainKey(key) - if err != nil || keyLanguage != language { - return false - } - s.mu.Lock() - defer s.mu.Unlock() - if deleted { - if _, had := s.by[language][node]; !had { - return false - } - delete(s.by[language], node) - return true - } - tc, err := delivery.DecodeToolchain(value) - if err != nil || tc.Language != language { - return false - } - if s.by == nil { - s.by = map[string]map[string]delivery.Toolchain{} - } - if s.by[language] == nil { - s.by[language] = map[string]delivery.Toolchain{} - } - cur, had := s.by[language][node] - s.by[language][node] = tc - return !had || cur.Version != tc.Version || cur.Recipe != tc.Recipe -} - -// Entries are the nodes' entries of a language, in no particular order. -func (s *ToolchainSet) Entries(language string) []delivery.NodeToolchain { - s.mu.Lock() - defer s.mu.Unlock() - var out []delivery.NodeToolchain - for node, tc := range s.by[language] { - out = append(out, delivery.NodeToolchain{Node: node, Toolchain: tc}) - } - return out -} diff --git a/modules/mesh-delivery-builder/internal/build/toolchains_test.go b/modules/mesh-delivery-builder/internal/build/toolchains_test.go deleted file mode 100644 index 83b95ca..0000000 --- a/modules/mesh-delivery-builder/internal/build/toolchains_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package build - -import ( - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -func tcValue(t *testing.T, language, version string) []byte { - t.Helper() - b, err := delivery.EncodeToolchain(delivery.Toolchain{Language: language, Version: version, Recipe: "1", At: time.Now()}) - if err != nil { - t.Fatal(err) - } - return b -} - -func TestToolchainSetKeepsEachNodesEntryByLanguageAndSaysWhenOneChanged(t *testing.T) { // 1607/AC6 - var s ToolchainSet - if !s.Put(GoBuildModule, "novox.go", tcValue(t, "go", "go1.27.1"), false) { - t.Error("a first entry did not count as a change") - } - if s.Put(GoBuildModule, "novox.go", tcValue(t, "go", "go1.27.1"), false) { - t.Error("the same entry again counted as a change") - } - s.Put(GoBuildModule, "ace.go", tcValue(t, "go", "go1.27.1"), false) - s.Put(NodeBuildModule, "ace.typescript", tcValue(t, "typescript", "tsc5"), false) - if got := s.Entries("go"); len(got) != 2 { - t.Errorf("go entries = %+v, want two", got) - } - if got := s.Entries("typescript"); len(got) != 1 || got[0].Node != "ace" || got[0].Version != "tsc5" { - t.Errorf("typescript entries = %+v", got) - } - if !s.Put(GoBuildModule, "ace.go", nil, true) || len(s.Entries("go")) != 1 { - t.Error("a delete did not remove the node's entry") - } -} - -func TestToolchainSetLeavesOutWhatIsNotAToolchainOfTheModulesLanguage(t *testing.T) { // 1607/AC6 - var s ToolchainSet - if s.Put(GoBuildModule, "novox.typescript", tcValue(t, "typescript", "x"), false) { - t.Error("a typescript toolchain in the go build module's bucket counted") - } - if s.Put(GoBuildModule, "novox.go", []byte("not json"), false) || s.Put("other-module", "novox.go", tcValue(t, "go", "x"), false) { - t.Error("garbage or an unknown module counted") - } - if len(s.Entries("go")) != 0 { - t.Error("an entry was kept") - } -}