diff --git a/modules/mesh-delivery-builder/buildrun/names.go b/modules/mesh-delivery-builder/buildrun/names.go index f42177cf..0896d2ea 100644 --- a/modules/mesh-delivery-builder/buildrun/names.go +++ b/modules/mesh-delivery-builder/buildrun/names.go @@ -11,3 +11,7 @@ const ( // StepsBucket is the bucket of a build module's state steps. func StepsBucket(module string) string { return module + "_steps" } + +// ToolchainsBucket is the bucket of a build module's state toolchains, which the checker and the indexer read (the builder +// hears the toolchain-reported events and keeps ToolchainsHeardBucket instead; design 47 G §492, §604). +func ToolchainsBucket(module string) string { return module + "_toolchains" } diff --git a/modules/mesh-delivery-builder/buildrun/standing.go b/modules/mesh-delivery-builder/buildrun/standing.go index 52a490b1..6d1b1963 100644 --- a/modules/mesh-delivery-builder/buildrun/standing.go +++ b/modules/mesh-delivery-builder/buildrun/standing.go @@ -103,11 +103,33 @@ func (g *PlacedNodes) Settled() { g.known = true } +// assignmentsFollowed is what FollowAssignments feeds: the builder's PlacedNodes, or the ToolchainGate of the checker and the +// indexer. settle says the history is read and answers whether what the part passes on changed; placedTold tells its OnPlaced. +type assignmentsFollowed interface { + Assignments(key string, value []byte, deleted bool, at time.Time) bool + settle() bool + placedTold() +} + +func (g *PlacedNodes) settle() bool { g.Settled(); return true } +func (g *PlacedNodes) placedTold() { + if g.OnPlaced != nil { + g.OnPlaced() + } +} + +func (g *ToolchainGate) settle() bool { return g.Settled() } +func (g *ToolchainGate) placedTold() { + if g.OnPlaced != nil { + g.OnPlaced() + } +} + // FollowAssignments reads the seat mesh-module-assigner's assignments with its history into gate, says it settled, and follows it; changed // is told whenever what the gate passes on changed. Where the state cannot be read or watched (not there, or this part // may not read it) it says so and the gate passes every report on: not fatal, since an indexer, builder or checker that // stops for it stops every delivery (issue 1945). An error of the watch after that ends on the channel. -func FollowAssignments(ctx context.Context, js process.BucketOpener, gate *PlacedNodes, changed func(context.Context), logf func(string, ...any)) (<-chan error, error) { +func FollowAssignments(ctx context.Context, js process.BucketOpener, gate assignmentsFollowed, changed func(context.Context), logf func(string, ...any)) (<-chan error, error) { errs := make(chan error, 1) kv, err := js.KeyValue(ctx, AssignmentsBucket) if err != nil { @@ -138,11 +160,10 @@ func FollowAssignments(ctx context.Context, js process.BucketOpener, gate *Place put(e) } } - gate.Settled() - changed(ctx) - if gate.OnPlaced != nil { - gate.OnPlaced() + if gate.settle() { + changed(ctx) } + gate.placedTold() go func() { defer func() { _ = w.Stop() }() for { @@ -158,8 +179,8 @@ func FollowAssignments(ctx context.Context, js process.BucketOpener, gate *Place if e != nil && put(e) { changed(ctx) } - if e != nil && gate.OnPlaced != nil { - gate.OnPlaced() + if e != nil { + gate.placedTold() } } } diff --git a/modules/mesh-delivery-builder/buildrun/toolchaingate.go b/modules/mesh-delivery-builder/buildrun/toolchaingate.go new file mode 100644 index 00000000..0f6a8ae6 --- /dev/null +++ b/modules/mesh-delivery-builder/buildrun/toolchaingate.go @@ -0,0 +1,236 @@ +package buildrun + +import ( + "fmt" + "maps" + "slices" + "sort" + "sync" + "time" + + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient" + meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +// A build module writes . of its toolchains state on every node it runs on, and nothing deletes the key +// when the module leaves a node: no part writes another module's state, and the module is gone. A report of a node the +// build module no longer runs on is therefore no builder's (issue 1940: mesh-build-node taken off ace on 2026-10-23 left +// ace.typescript at v26.12.0, and every Node fingerprint said "builders disagree"). +// +// ToolchainGate stands between the toolchains buckets and what takes their entries in (the indexer's PutToolchain, a +// ToolchainSet): an entry is passed on only while the assignments (the seat mesh-module-assigner's state `assignments`, held by +// the assigner, AssignmentsBucket, read by meshmoduleassignerclient.Placed; ADR 0567, ADR 0549 §3) places its build module on +// its node, and only once it was written since that assignments; it is passed on as deleted otherwise, said once, and +// passed on when its node writes it again. A report written before the assignments is one an earlier run of the module +// left (issue 1940, 2026-10-25 20:56: mesh-build-node put on ace again brought ace.typescript v26.12.0 of 2026-10-23 +// back into the agreement before the module ran there). Until the assignments is settled, and while no node's assignments +// is held (before the assigner's adopt writes the first one, or where the state cannot be read), every entry is passed +// on. Builders that run and report different versions still disagree: that refusal is the guard of novox/hq ADR 0465, +// and it stays. +// +// This replaces the copies of the gate the indexer, the builder and the checker each kept over the controller's +// assignments (issue 1940's subtask, issue 774: those go with the controller's lease, ADR 0549 §6). +type ToolchainGate struct { + mu sync.Mutex + modules map[string]bool // the build modules whose toolchains pass the gate + forward func(module, key string, value []byte, deleted bool) bool // answers whether it changed what it holds + logf func(format string, a ...any) + placed placementsSince + known bool // the assignments is settled + raw map[string]map[string][]byte // module -> toolchains key -> value, as the bucket holds it + left map[string]string // / -> why it was said left out, so each reason is said once + // OnPlaced is told, outside the gate's lock, once the assignments's history is read and after each revision of it + // FollowAssignments hears from then on (the counted nodes, Nodes, may have changed). Nil tells nobody. + OnPlaced func() +} + +// placementsSince is where the gate reads which modules stand on which node, and since when: the assigner's state +// (meshmoduleassignerclient.Placed, fed by Assignments) or the assignment half (meshwantsclient.Placed, fed by Revision). +type placementsSince interface { + Seeded() bool + Since(node, module string) (time.Time, bool) + ByNode() map[string][]string +} + +// NewToolchainGate is a gate over the toolchains of modules, passing what stands on to forward and saying with logf what +// it leaves out. +func NewToolchainGate(modules []string, forward func(module, key string, value []byte, deleted bool) bool, logf func(string, ...any)) *ToolchainGate { + g := &ToolchainGate{modules: map[string]bool{}, forward: forward, logf: logf, placed: meshmoduleassignerclient.NewPlaced(), + raw: map[string]map[string][]byte{}, left: map[string]string{}} + for _, m := range modules { + g.modules[m] = true + } + return g +} + +// UseModules replaces the build modules whose reports the gate passes on again when the assignments moves (a part that +// finds them from the seat list and the manifests, as the indexer does, gives them at each change), and passes every +// report it holds on again. It answers whether what it passed on changed. +func (g *ToolchainGate) UseModules(modules []string) bool { + g.mu.Lock() + defer g.mu.Unlock() + g.modules = map[string]bool{} + for _, m := range modules { + g.modules[m] = true + } + return g.all() +} + +// Toolchain takes one change of a build module's toolchains bucket in and answers whether what it passed on changed. +func (g *ToolchainGate) Toolchain(module, key string, value []byte, deleted bool) bool { + g.mu.Lock() + defer g.mu.Unlock() + if g.raw[module] == nil { + g.raw[module] = map[string][]byte{} + } + if deleted { + delete(g.raw[module], key) + } else { + g.raw[module][key] = value + } + return g.pass(module, key) +} + +// Assignments takes one revision of the seat mesh-module-assigner's assignments in (at is when the bus wrote it) and answers whether what it +// passed on changed. A value that does not read is said, and places nothing on its node. +func (g *ToolchainGate) Assignments(key string, value []byte, deleted bool, at time.Time) bool { + g.mu.Lock() + defer g.mu.Unlock() + placed, ok := g.placed.(*meshmoduleassignerclient.Placed) + if !ok { + g.logf("%s: %s is not read, the gate takes the assignment half (design 47)", AssignmentsBucket, key) + return false + } + was := placed.Seeded() + node, changed, err := placed.Put(key, value, deleted, at) + if err != nil { + g.logf("%s: %v (issue 1940)", AssignmentsBucket, err) + } + if !g.known { + return false + } + if was != g.placed.Seeded() { + // The first assignments came, or the last went: every report's standing changed. + return g.all() + } + return g.again(node, changed) +} + +// Revision takes one assignment graph revision the part's view holds in (design 47 *Each part acts on its own entries*: +// the placements come from the seat mesh-wants' assignment half, never the assigner's state; assignmentview's Held hands +// each) and answers whether what it passed on changed. The first revision taken settles the gate, as the assignments' +// history read does; a later one passes on again the reports of each node whose build modules' standing changed. A gate +// fed by Revision reads no Assignments. +func (g *ToolchainGate) Revision(r meshwants.AssignmentRevision) bool { + g.mu.Lock() + defer g.mu.Unlock() + placed, ok := g.placed.(*meshwantsclient.Placed) + if !ok { + placed = meshwantsclient.NewPlaced() + g.placed = placed + } + was := placed.Seeded() + changed := placed.Take(r) + if changed == nil { + return false + } + if !g.known || was != placed.Seeded() { + g.known = true + return g.all() + } + moved := false + for _, node := range slices.Sorted(maps.Keys(changed)) { + moved = g.again(node, changed[node]) || moved + } + return moved +} + +// Nodes are the nodes the assignments places any module on, sorted: the nodes the mesh counts (a node leaves the count +// when the assignments no longer names it; ADR 0624 §1). None until the assignments is read. +func (g *ToolchainGate) Nodes() []string { + var out []string + for node, modules := range g.placed.ByNode() { + if len(modules) > 0 { + out = append(out, node) + } + } + sort.Strings(out) + return out +} + +// Settled says the assignments's history is read: from now on only the reports of placed nodes pass, once a node's +// assignments is held. It answers whether what it passed on changed. +func (g *ToolchainGate) Settled() bool { + g.mu.Lock() + defer g.mu.Unlock() + g.known = true + return g.all() +} + +// all passes on every report again. The caller holds mu. +func (g *ToolchainGate) all() bool { + changed := false + for _, module := range slices.Sorted(maps.Keys(g.raw)) { + for _, k := range slices.Sorted(maps.Keys(g.raw[module])) { + changed = g.pass(module, k) || changed + } + } + return changed +} + +// again passes on every report of node by a build module among modules. The caller holds mu. +func (g *ToolchainGate) again(node string, modules []string) bool { + changed := false + for _, module := range modules { + if !g.modules[module] { + continue + } + for _, k := range slices.Sorted(maps.Keys(g.raw[module])) { + if n, _, err := buildstep.ParseToolchainKey(k); err == nil && n == node { + changed = g.pass(module, k) || changed + } + } + } + return changed +} + +// pass hands one key on as it stands: its value when its node is placed and wrote it since (or the assignments is not +// known), deleted otherwise. The caller holds mu. +func (g *ToolchainGate) pass(module, key string) bool { + value, held := g.raw[module][key] + if !held { + return g.forward(module, key, nil, true) + } + node, _, err := buildstep.ParseToolchainKey(key) + why := "" + if err == nil && g.known && g.placed.Seeded() { + why = g.leftOut(module, node, value) + } + if why == "" { + delete(g.left, module+"/"+key) + return g.forward(module, key, value, false) + } + if g.left[module+"/"+key] != why { + g.left[module+"/"+key] = why + g.logf("%s.%s is left out of the agreed toolchain: %s (issue 1940)", ToolchainsBucket(module), key, why) + } + return g.forward(module, key, nil, true) +} + +// leftOut is why a node's report is no builder's, or empty when it counts: the catalogue does not place the module on its +// node, or the report was written before it did. A value that is no toolchain is passed on for its reader to refuse. +// The caller holds mu. +func (g *ToolchainGate) leftOut(module, node string, value []byte) string { + since, placed := g.placed.Since(node, module) + if !placed { + return fmt.Sprintf("the catalogue does not place %s on %s, so it is no builder", module, node) + } + tc, err := buildstep.DecodeToolchain(value) + if err == nil && tc.At.Before(since) { + return fmt.Sprintf("it was written at %s, before %s was placed on %s at %s: it counts once %s writes it again", + tc.At.UTC().Format(time.RFC3339), module, node, since.UTC().Format(time.RFC3339), node) + } + return "" +} diff --git a/modules/mesh-delivery-builder/buildrun/toolchaingate_test.go b/modules/mesh-delivery-builder/buildrun/toolchaingate_test.go new file mode 100644 index 00000000..9e6521c3 --- /dev/null +++ b/modules/mesh-delivery-builder/buildrun/toolchaingate_test.go @@ -0,0 +1,260 @@ +package buildrun + +import ( + "bytes" + "context" + "errors" + "fmt" + "log" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +func gateReport(t *testing.T, node, version string, at time.Time) (string, []byte) { + t.Helper() + key, _ := buildstep.ToolchainKey(node, "typescript") + value, err := buildstep.EncodeToolchain(buildstep.Toolchain{Language: "typescript", Version: version, Recipe: "f59571cf4be75adf", At: at}) + if err != nil { + t.Fatal(err) + } + return key, value +} + +// placing is a value of the state `assignments` placing modules on node. + +func agreedOf(set *ToolchainSet) (string, string) { + tc, none := buildstep.AgreedToolchain(set.Entries("typescript")) + return tc.Version, none +} + +// Issue 1940, the gate over the catalogue's assignments (issue 774, ADR 0549 §3): a build node's report counts only while +// the assignments places its build module on its node, and only once written since; before the assignments is settled, and +// while no node's assignments is held, every report counts. The live cases: mesh-build-node taken off ace left +// ace.typescript at v26.12.0 against v26.12.1 on the three others; put on ace again, its report of 2026-10-23 06:29 came +// back before the module ran there. +func TestToolchainGate(t *testing.T) { + old := time.Date(2026, 10, 23, 6, 29, 18, 0, time.UTC) // ace's report from its earlier run + placed := time.Date(2026, 10, 25, 19, 56, 45, 0, time.UTC) // mesh-build-node placed on ace again + three := []string{"g14", "novox", "shanks"} + start := func(t *testing.T, seeded bool) (*ToolchainGate, *ToolchainSet, *[]string) { + set := &ToolchainSet{} + said := &[]string{} + gate := NewToolchainGate([]string{GoBuildModule, NodeBuildModule}, set.Put, func(f string, a ...any) { *said = append(*said, fmt.Sprintf(f, a...)) }) + for _, node := range three { + if seeded { + key, value := placing(t, node, NodeBuildModule) + gate.Assignments(key, value, false, old.Add(-time.Hour)) + } + key, value := gateReport(t, node, "v26.12.1", placed.Add(time.Minute)) + gate.Toolchain(NodeBuildModule, key, value, false) + } + return gate, set, said + } + + t.Run("not placed on ace: its report is no builder's", func(t *testing.T) { + gate, set, said := start(t, true) + key, value := gateReport(t, "ace", "v26.12.0", old) + gate.Toolchain(NodeBuildModule, key, value, false) + if a := buildstep.ToolchainAgreement(set.Entries("typescript")); len(a.SittingOut) != 1 || a.SittingOut[0].Node != "ace" { // ADR 0584: the majority is agreed, ace's report counts as the one that sits out + t.Fatalf("before the assignments is settled every report counts; got %+v", a) + } + k, v := placing(t, "ace", GoBuildModule) // another build module on ace: no matter + gate.Assignments(k, v, false, old) + if !gate.Settled() { + t.Fatal("settling left ace's report out, so what was passed on changed") + } + if v, none := agreedOf(set); v != "v26.12.1" || none != "" { + t.Fatalf("the placed nodes agree on v26.12.1; got %q, %q", v, none) + } + if len(*said) != 1 || !strings.Contains((*said)[0], "does not place mesh-build-node on ace") { + t.Fatalf("ace's report is said left out once; said %q", *said) + } + if gate.Toolchain(NodeBuildModule, key, value, false) { + t.Fatal("a report of a node not placed changed what was passed on") + } + // Placed on ace with a report written since: it counts, and the builders really disagree. + k, v = placing(t, "ace", GoBuildModule, NodeBuildModule) + gate.Assignments(k, v, false, old.Add(-time.Minute)) + if a := buildstep.ToolchainAgreement(set.Entries("typescript")); a.Toolchain.Version != "v26.12.1" || len(a.SittingOut) != 1 || a.SittingOut[0].Version != "v26.12.0" { + t.Fatalf("builders that run different versions: the majority is agreed and ace sits out; got %+v", a) + } + // Taken off ace again (its key deleted): its report goes again. + if !gate.Assignments(k, nil, true, placed) { + t.Fatal("taking ace's assignments away passed nothing on") + } + if v, none := agreedOf(set); v != "v26.12.1" || none != "" { + t.Fatalf("got %q, %q", v, none) + } + // A report deleted from the bucket goes whether its node is placed or not. + key, _ = gateReport(t, "g14", "v26.12.1", placed) + if !gate.Toolchain(NodeBuildModule, key, nil, true) || len(set.Entries("typescript")) != 2 { + t.Fatalf("deleting g14's report: %d entries left, want novox and shanks", len(set.Entries("typescript"))) + } + }) + + t.Run("build modules given later pass their reports on when the assignments moves", func(t *testing.T) { + set := &ToolchainSet{} + gate := NewToolchainGate(nil, set.Put, func(string, ...any) {}) + gate.Settled() + k, v := placing(t, "novox", GoBuildModule) + gate.Assignments(k, v, false, old) + key, value := gateReport(t, "ace", "v26.12.1", placed.Add(time.Minute)) + gate.Toolchain(NodeBuildModule, key, value, false) // ace is not placed: left out + k, v = placing(t, "ace", NodeBuildModule) + if gate.Assignments(k, v, false, placed) || len(set.Entries("typescript")) != 0 { + t.Fatalf("a module the gate was not given passed on at the assignments: %v", set.Entries("typescript")) + } + if !gate.UseModules([]string{NodeBuildModule}) || len(set.Entries("typescript")) != 1 { + t.Fatalf("given mesh-build-node, ace's report placed since is passed on; entries %v", set.Entries("typescript")) + } + }) + + for _, c := range []struct { + name string + at time.Time // when ace wrote its report + counts bool + }{ + {"a report left before the assignments", old, false}, + {"a report written since the assignments", placed.Add(30 * time.Second), true}, + } { + t.Run(c.name, func(t *testing.T) { + gate, set, said := start(t, true) + k, v := placing(t, "ace") // ace's key, first heard placing nothing + gate.Assignments(k, v, false, old.Add(-time.Hour)) + gate.Settled() + k, v = placing(t, "ace", NodeBuildModule) + if gate.Assignments(k, v, false, placed) { + t.Fatal("assignments with no report passed something on") + } + key, value := gateReport(t, "ace", "v26.12.0", c.at) + if got := gate.Toolchain(NodeBuildModule, key, value, false); got != c.counts { + t.Fatalf("ace's report passed on: %v, want %v", got, c.counts) + } + // ace's assignments written again with another module beside: its standing does not move, nothing is said again. + k, v = placing(t, "ace", GoBuildModule, NodeBuildModule) + if gate.Assignments(k, v, false, placed.Add(time.Minute)) { + t.Fatal("the same assignments of mesh-build-node changed what was passed on") + } + if c.counts { + return + } + if v, none := agreedOf(set); v != "v26.12.1" || none != "" { + t.Fatalf("ace's left report is no builder's; got %q, %q", v, none) + } + if len(*said) != 1 || !strings.Contains((*said)[0], "before mesh-build-node was placed on ace at 2026-10-25T19:56:45Z") { + t.Fatalf("said %q, want once why ace's report is left out", *said) + } + key, value = gateReport(t, "ace", "v26.12.1", placed.Add(time.Minute)) + if !gate.Toolchain(NodeBuildModule, key, value, false) || len(set.Entries("typescript")) != 4 { + t.Fatal("ace's new report was not passed on") + } + }) + } + + t.Run("no assignments held: every report counts", func(t *testing.T) { + gate, set, _ := start(t, false) + key, value := gateReport(t, "ace", "v26.12.0", old) + gate.Toolchain(NodeBuildModule, key, value, false) + gate.Settled() + if n := len(set.Entries("typescript")); n != 4 { + t.Fatalf("before the first assignments file every report counts; got %d", n) + } + k, v := placing(t, "g14", NodeBuildModule) + if !gate.Assignments(k, v, false, old.Add(-time.Hour)) || len(set.Entries("typescript")) != 1 { + t.Fatalf("the first assignments leaves every node it does not place out; got %d", len(set.Entries("typescript"))) + } + if !gate.Assignments(k, nil, true, placed) || len(set.Entries("typescript")) != 4 { + t.Fatal("the last assignments gone: every report counts again") + } + }) + + // Design 47 *Each part acts on its own entries*: fed by the assignment half's revisions, the gate settles at the first, + // leaves out a node no revision places the build module on, and counts a node placed in a later revision only by a + // report written since that revision's time. + t.Run("fed by the assignment half", func(t *testing.T) { + set := &ToolchainSet{} + said := &[]string{} + gate := NewToolchainGate([]string{GoBuildModule, NodeBuildModule}, set.Put, func(f string, a ...any) { *said = append(*said, fmt.Sprintf(f, a...)) }) + for _, node := range append([]string{"ace"}, three...) { + key, value := gateReport(t, node, "v26.12.1", placed.Add(time.Minute)) + if node == "ace" { + key, value = gateReport(t, node, "v26.12.0", old) + } + gate.Toolchain(NodeBuildModule, key, value, false) + } + revision := func(n int64, at time.Time, nodes ...string) meshwants.AssignmentRevision { + r := meshwants.AssignmentRevision{Revision: n, Kind: "trunk", At: &at} + for _, node := range nodes { + r.Entries = append(r.Entries, meshwants.NodeEntryRef{Node: node, Module: NodeBuildModule, Digest: "d-" + node}) + } + return r + } + if !gate.Revision(revision(7, old.Add(-time.Hour), three...)) || len(set.Entries("typescript")) != 3 { + t.Fatalf("the first revision settles the gate and leaves ace out; %d entries", len(set.Entries("typescript"))) + } + if gate.Revision(revision(7, old.Add(-time.Hour), three...)) { + t.Fatal("the same revision again changed what was passed on") + } + if gate.Revision(revision(8, placed, "ace", "g14", "novox", "shanks")); len(set.Entries("typescript")) != 3 { + t.Fatal("ace's report from before revision 8 placed it counts") + } + if len(*said) < 2 || !strings.Contains((*said)[len(*said)-1], "before mesh-build-node was placed on ace at 2026-10-25T19:56:45Z") { + t.Fatalf("said %q, want why ace's old report is left out", *said) + } + key, value := gateReport(t, "ace", "v26.12.1", placed.Add(time.Minute)) + if !gate.Toolchain(NodeBuildModule, key, value, false) || len(set.Entries("typescript")) != 4 { + t.Fatal("ace's report written since revision 8 is not passed on") + } + if !gate.Revision(revision(9, placed.Add(time.Hour), "ace", "novox", "shanks")) || len(set.Entries("typescript")) != 3 || len(gate.Nodes()) != 3 { + t.Fatalf("g14 taken off in revision 9: %d entries, nodes %v", len(set.Entries("typescript")), gate.Nodes()) + } + }) + + t.Run("the history: a module in the first revision heard of a key stands since before it", func(t *testing.T) { + p := meshmoduleassignerclient.NewPlaced() + // ace's node key is the first revision heard of it: its module stands since before. + k, v := placing(t, "ace", NodeBuildModule) + p.Put(k, v, false, old) + if _, changed, _ := p.Put("ace."+NodeBuildModule, []byte("{}"), false, placed); len(changed) != 0 { + t.Errorf("a module key says which modules stand: %v", changed) + } + k2, v2 := placing(t, "g14") + p.Put(k2, v2, false, old) + k2, v2 = placing(t, "g14", NodeBuildModule) + if _, changed, _ := p.Put(k2, v2, false, placed); strings.Join(changed, ",") != NodeBuildModule { + t.Errorf("a module placed says changed %v", changed) + } + if _, _, err := p.Put("novox.node", []byte("not json"), false, old); err == nil { + t.Error("a value that does not read was not said") + } + if s, ok := p.Since("ace", NodeBuildModule); !ok || !s.IsZero() { + t.Errorf("ace's module stands since %v, %v; want the zero time", s, ok) + } + if s, ok := p.Since("g14", NodeBuildModule); !ok || !s.Equal(placed) { + t.Errorf("g14's module stands since %v, %v; want the revision that placed it", s, ok) + } + if got := p.PlacedNodes(NodeBuildModule); strings.Join(got, ",") != "ace,g14" || len(p.ByNode()) != 2 || !p.Seeded() { + t.Errorf("PlacedNodes = %v, ByNode = %v", got, p.ByNode()) + } + }) + + t.Run("assignments the bus refuses is said, and the part goes on", func(t *testing.T) { + var logs bytes.Buffer + logger := log.New(&logs, "", 0) + refused := errors.New("nats: permissions violation for publish to $JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_assignments") + gate, _, _ := start(t, false) + for _, bus := range []refusingBus{{err: refused}, {refuseOpen: true, err: refused}} { + if _, err := FollowAssignments(context.Background(), bus, gate, func(context.Context) {}, logger.Printf); err != nil { + t.Errorf("a refused assignments stopped the start: %v", err) + } + } + if !strings.Contains(logs.String(), "cannot be watched") || !strings.Contains(logs.String(), "cannot be read") { + t.Errorf("the refusals were not said: %q", logs.String()) + } + }) +} diff --git a/modules/mesh-delivery-builder/buildrun/toolchains.go b/modules/mesh-delivery-builder/buildrun/toolchains.go index ba2368c6..af1f53e6 100644 --- a/modules/mesh-delivery-builder/buildrun/toolchains.go +++ b/modules/mesh-delivery-builder/buildrun/toolchains.go @@ -91,3 +91,39 @@ const ( ToolchainsTool = "builder_toolchains" ToolchainsDropTool = "builder_toolchains_drop" ) + +// 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 := buildstep.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 := buildstep.DecodeToolchain(value) + if err != nil || tc.Language != language { + return false + } + if s.by == nil { + s.by = map[string]map[string]buildstep.Toolchain{} + } + if s.by[language] == nil { + s.by[language] = map[string]buildstep.Toolchain{} + } + cur, had := s.by[language][node] + s.by[language][node] = tc + return !had || cur.Version != tc.Version || cur.Recipe != tc.Recipe || cur.Digest != tc.Digest +}