From 8448315b7263d2e0ef4996d9c4bb5ca52083bc02 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 16:11:41 +0100 Subject: [PATCH] =?UTF-8?q?Delivery=20SDK:=20the=20toolchain=20a=20languag?= =?UTF-8?q?e's=20builders=20agree=20on=20(entries,=20majority=20rule)=20ov?= =?UTF-8?q?er=20a=20seat-neutral=20form,=20so=20the=20builder,=20the=20che?= =?UTF-8?q?cker=20and=20the=20indexer=20read=20a=20build=20seat's=20toolch?= =?UTF-8?q?ains=20state=20with=20one=20rule=20(ADR=200634=20=C2=A71,=20?= =?UTF-8?q?=C2=A72;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- sdk/buildstep/toolchains.go | 85 ++++++++++++++++++++++++++++++++ sdk/buildstep/toolchains_test.go | 58 ++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 sdk/buildstep/toolchains.go create mode 100644 sdk/buildstep/toolchains_test.go diff --git a/sdk/buildstep/toolchains.go b/sdk/buildstep/toolchains.go new file mode 100644 index 00000000..6dc878f7 --- /dev/null +++ b/sdk/buildstep/toolchains.go @@ -0,0 +1,85 @@ +package buildstep + +import ( + "fmt" + "sort" + "strings" +) + +// **The toolchain a language's builders agree on** (novox/hq ADR 0584): the nodes report their toolchain in the state +// `toolchains` of a node-build seat (the seat's generated type holds the entry); this file holds the one rule over the +// entries every reader of that state shares, over a seat-neutral form, so no reader copies it. + +// ToolchainNoReport is the reason AgreedToolchain gives when no node has reported a language's toolchain. +const ToolchainNoReport = "toolchain not yet reported" + +// Toolchain is what a toolchain is identified by: the language, the installed version and the build module's recipe +// version. It has the fields of the mesh-catalog client's Toolchain2, which a caller converts it to. +type Toolchain struct { + Language string + Version string + Recipe string +} + +// NodeToolchain is one node's entry of a language, as read from a build seat's state toolchains. +type NodeToolchain struct { + Node string + Toolchain +} + +// SittingOut is a node whose toolchain of a language is not the agreed one: it builds nothing of that language until +// it reports the agreed version (novox/hq ADR 0584 clause 2). +type SittingOut struct { + Node string + Version string + Recipe string +} + +// Agreement is what the nodes' entries of one language come to. Toolchain is the agreed one, set when Wait is empty. +// Wait is why nothing is agreed: "toolchain not yet reported" with no entries, or "builders disagree" naming each version +// and recipe with the nodes reporting it, when no version has a strict majority. SittingOut lists, sorted by node, the +// nodes that report another version than the agreed one. +type Agreement struct { + Toolchain Toolchain + SittingOut []SittingOut + Wait string +} + +// ToolchainAgreement is the agreed toolchain of a language: the version and recipe a strict majority of the reporting +// nodes report (novox/hq ADR 0584 clause 1). With no strict majority (two against two) or no entry it gives no toolchain +// and says why in Wait. The answer does not depend on the entries' order. The entries must all be of one language. +func ToolchainAgreement(entries []NodeToolchain) Agreement { + if len(entries) == 0 { + return Agreement{Wait: ToolchainNoReport} + } + groups := map[Toolchain][]string{} + for _, e := range entries { + k := Toolchain{Language: e.Language, Version: e.Version, Recipe: e.Recipe} + groups[k] = append(groups[k], e.Node) + } + for k, nodes := range groups { + if 2*len(nodes) > len(entries) { + a := Agreement{Toolchain: k} + for _, e := range entries { + if e.Version != k.Version || e.Recipe != k.Recipe { + a.SittingOut = append(a.SittingOut, SittingOut{Node: e.Node, Version: e.Version, Recipe: e.Recipe}) + } + } + sort.Slice(a.SittingOut, func(i, j int) bool { return a.SittingOut[i].Node < a.SittingOut[j].Node }) + return a + } + } + parts := make([]string, 0, len(groups)) + for k, nodes := range groups { + sort.Strings(nodes) + parts = append(parts, fmt.Sprintf("%s recipe %s on %s", k.Version, k.Recipe, strings.Join(nodes, ", "))) + } + sort.Strings(parts) + return Agreement{Wait: "builders disagree: " + strings.Join(parts, "; ")} +} + +// AgreedToolchain is ToolchainAgreement's toolchain, or none and why (its Wait). +func AgreedToolchain(entries []NodeToolchain) (tc Toolchain, none string) { + a := ToolchainAgreement(entries) + return a.Toolchain, a.Wait +} diff --git a/sdk/buildstep/toolchains_test.go b/sdk/buildstep/toolchains_test.go new file mode 100644 index 00000000..a1f376ef --- /dev/null +++ b/sdk/buildstep/toolchains_test.go @@ -0,0 +1,58 @@ +package buildstep + +import ( + "strings" + "testing" +) + +func tcEntry(node, lang, version, recipe string) NodeToolchain { + return NodeToolchain{Node: node, Toolchain: Toolchain{Language: lang, Version: version, Recipe: recipe}} +} + +// 1443/AC2-4, ADR 0584: the agreed toolchain is the strict majority's; a minority node sits out; a tie waits. +func TestToolchainAgreement(t *testing.T) { + e := tcEntry + for _, c := range []struct { + name string + in []NodeToolchain + version string + out []SittingOut + wait []string // "" wait means agreed + }{ + {"all agree", []NodeToolchain{e("ace", "go", "1.26.1", "3"), e("novox", "go", "1.26.1", "3")}, "1.26.1", nil, nil}, + {"three against one", []NodeToolchain{e("ace", "typescript", "26.12.1", "3"), e("g14", "typescript", "26.13.0", "3"), e("novox", "typescript", "26.13.0", "3"), e("shanks", "typescript", "26.13.0", "3")}, + "26.13.0", []SittingOut{{"ace", "26.12.1", "3"}}, nil}, + {"two against two", []NodeToolchain{e("ace", "go", "1.26.1", "3"), e("novox", "go", "1.26.2", "3"), e("g14", "go", "1.26.1", "3"), e("shanks", "go", "1.26.2", "3")}, + "", nil, []string{"builders disagree", "1.26.1 recipe 3 on ace, g14", "1.26.2 recipe 3 on novox, shanks"}}, + {"one node alone", []NodeToolchain{e("ace", "go", "1.26.1", "3")}, "1.26.1", nil, nil}, + {"no report", nil, "", nil, []string{ToolchainNoReport}}, + } { + a := ToolchainAgreement(c.in) + rev := make([]NodeToolchain, len(c.in)) + for i, x := range c.in { + rev[len(c.in)-1-i] = x + } + if b := ToolchainAgreement(rev); b.Wait != a.Wait || b.Toolchain != a.Toolchain { + t.Errorf("%s: order changed the answer: %+v vs %+v", c.name, a, b) + } + if a.Toolchain.Version != c.version || len(a.SittingOut) != len(c.out) { + t.Errorf("%s: %+v", c.name, a) + } + for i := range c.out { + if a.SittingOut[i] != c.out[i] { + t.Errorf("%s: sitting out %+v, want %+v", c.name, a.SittingOut, c.out) + } + } + if (len(c.wait) == 0) != (a.Wait == "") { + t.Errorf("%s: wait %q", c.name, a.Wait) + } + for _, w := range c.wait { + if !strings.Contains(a.Wait, w) { + t.Errorf("%s: %q does not name %q", c.name, a.Wait, w) + } + } + if tc, none := AgreedToolchain(c.in); tc != a.Toolchain || none != a.Wait { + t.Errorf("%s: AgreedToolchain %+v %q", c.name, tc, none) + } + } +}