mesh/delivery ready: it is merged on the Approve
mesh/check failed (3): mesh-build-go: the go.mod of novox/mesh-sdk at 396511b7a276, beside the change, cann…; and 2 more | 00232c71977b on 9c6bf236b6b1
Every recompute wrote every key of its modules state again with identical bytes: recomputeLocked compared the package versions with reflect.DeepEqual across two map types (the contract's map[string]string and sdkstep.PackageVersions), which never match. Each write was a views event, and a reader of the views took each as a gap and asked the whole first view again. The comparison is by maps.Equal now, and write itself skips an answer whose bytes are the bytes held, for every caller and every state it writes; the merge's written list counts only real writes. Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
548 lines
26 KiB
Go
548 lines
26 KiB
Go
package index
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildgo"
|
|
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
|
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
|
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
|
sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
|
"git.novox.be/novox/mesh-sdk/go/keys"
|
|
"git.novox.be/novox/mesh-sdk/go/process"
|
|
|
|
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
|
|
|
"mesh-delivery-indexer/internal/fingerprint"
|
|
)
|
|
|
|
// The fixtures here are mesh-catalog a67f0196's manifests (testdata, captured from that commit) with the module renamed (and,
|
|
// where a test needs one, a build.on entry added as the controller's own test has it, standing_on_test.go:22): the manifest
|
|
// parser, not a hand-written manifest, reads them.
|
|
|
|
func renamed(t *testing.T, from, to string) string {
|
|
t.Helper()
|
|
raw := catalogueManifest(t, from)
|
|
out := strings.Replace(raw, `"module": "`+from+`"`, `"module": "`+to+`"`, 1)
|
|
if out == raw {
|
|
t.Fatalf("%s's manifest has no module line", from)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func standingOn(t *testing.T, raw, base string) string {
|
|
t.Helper()
|
|
out := strings.Replace(raw, `"build": {`, `"build": {
|
|
"on": [{"arg": "RUNTIME_BASE", "module": "`+base+`", "artifact": "runtime"}],`, 1)
|
|
if out == raw {
|
|
t.Fatal("the manifest has no build")
|
|
}
|
|
return out
|
|
}
|
|
|
|
const buildAgentRepo = "novox/mesh-controller"
|
|
|
|
// build-agent's trunk builds as `mesh-controller.builds` showed them on 2026-10-21 (ids, times and source fingerprints
|
|
// as listed; the repository is the clone URL the record names, the ref "main"; the inputs are the shape of aInputs, which
|
|
// the indexer does not read for this): the older one at 13:03 and the newer at 15:03 (issue 1727).
|
|
const (
|
|
agentSrcX = "src1:80e5fb129a98754a85fa3a43b1dded12dddedd25823d12dbe8783287faa0c59c"
|
|
agentSrcY = "src1:44ed217d74a7c7e38400bb0bf32358ed96502cb385d24f1435fe971ad44c7b88"
|
|
agentCloneURL = "http://novox.internal:20000/novox/mesh-controller.git"
|
|
)
|
|
|
|
func agentBuild(id, src, at string) [2]any {
|
|
when, _ := time.Parse(time.RFC3339, at)
|
|
raw, _ := builds.EncodeBuildLogEntry(builds.BuildLogEntry{Event: builds.BuildEndedEvent, Module: "build-agent", FP: src, Repository: agentCloneURL,
|
|
Directory: "modules/build-agent", Commit: "cada7e3c", Node: "n1", At: when, Outcome: builds.BuildDone})
|
|
return [2]any{"build-agent", raw}
|
|
}
|
|
|
|
type mod struct {
|
|
name, manifest string
|
|
}
|
|
|
|
// merged applies one merge of the catalogue carrying these modules (every one a Go bundle's go.sum, a TypeScript bundle's
|
|
// package-lock.json) and returns the indexer.
|
|
// graphOf is the graph node the indexer holds of module.
|
|
func graphOf(t *testing.T, ix *Indexer, module string) graph.Node {
|
|
t.Helper()
|
|
v, ok := ix.Graph.(*memStore).vals[module]
|
|
if !ok {
|
|
t.Fatalf("graph.%s is not held", module)
|
|
}
|
|
g, err := graph.Decode(v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return g
|
|
}
|
|
|
|
func newFormIndexer(t *testing.T, mods ...mod) (*Indexer, *memStore) {
|
|
t.Helper()
|
|
ix, store, f := newFormIndexerUnmerged(t, mods...)
|
|
if err := ix.Apply(context.Background(), f); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// build-agent holds no fingerprint of today's form, as live (no merge of the controller is applied; issue 1727).
|
|
return ix, store
|
|
}
|
|
|
|
// newFormIndexerUnmerged is the indexer and the catalogue merge of these modules, not yet applied.
|
|
func newFormIndexerUnmerged(t *testing.T, mods ...mod) (*Indexer, *memStore, forgemsg.Forge) {
|
|
t.Helper()
|
|
var recs [][2]any
|
|
trees, manifests := map[string]string{}, map[string]string{}
|
|
locks := map[string]map[string]string{}
|
|
for i, m := range mods {
|
|
dir := "modules/" + m.name
|
|
recs = append(recs, rec(t, "build-"+m.name, m.name, catalogueRepo, dir, "main", "", "2026-10-19T09:00:00Z", aInputs))
|
|
trees[dir], manifests[dir] = "tree-"+m.name, m.manifest
|
|
locks[dir] = map[string]string{"go.sum": avahiGoSum, "package-lock.json": strings.Repeat("c", 63) + string(rune('a'+i))}
|
|
}
|
|
recs = append(recs, agentBuild("build-1792580585452696147", agentSrcX, "2026-10-21T13:03:00Z"))
|
|
ix, store, _ := manifestsIndexer(t, recs...)
|
|
return ix, store, withManifests(mergedMsg(catalogueRepo, trees), manifests, locks)
|
|
}
|
|
|
|
// toolchainValue is a toolchains value as the build module's writer encodes it (mesh-sdk go/delivery EncodeToolchain).
|
|
func toolchainValue(t *testing.T, language, version, recipe string) []byte {
|
|
t.Helper()
|
|
b, err := EncodeToolchain(nodebuildgo.Toolchain{Language: language, Version: version, Recipe: recipe, At: ptr(time.Now())})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func reportToolchain(t *testing.T, ix *Indexer, module, node, language, version string) {
|
|
t.Helper()
|
|
reportToolchainRecipe(t, ix, module, node, language, version, "1")
|
|
}
|
|
|
|
func reportToolchainRecipe(t *testing.T, ix *Indexer, module, node, language, version, recipe string) {
|
|
t.Helper()
|
|
key, err := ToolchainKey(node, language)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ix.ApplyToolchain(context.Background(), module, key, toolchainValue(t, language, version, recipe), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestAgreedToolchainChangesAGoModulesReasonWithNoMerge(t *testing.T) { // 1444/AC5
|
|
ix, mods := newFormIndexer(t, mod{"avahi", catalogueManifest(t, "avahi")})
|
|
if m, _ := moduleOf(t, mods, "avahi"); m.None2 != sdkstep.ToolchainNoReport {
|
|
t.Run("a recipe change builds a Go module at once and again once the agreement moves", aRecipeChangeBuildsAGoModuleAtOnceAndAgainOnceTheAgreementMoves)
|
|
t.Fatalf("before: none2 = %q", m.None2)
|
|
}
|
|
// The release that last merged avahi: its source revision names avahi's commit, so Src (and FPSrc) are 7.
|
|
merged, _ := moduleOf(t, mods, "avahi")
|
|
src := meshdelivery.SourceByMainValue{Group: "g", Heads: map[string]string{merged.Repository: merged.Commit}}
|
|
if err := ix.ApplySource(context.Background(), 7, src); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if m, _ := moduleOf(t, mods, "avahi"); m.Src != 7 || m.FpSrc != 7 {
|
|
t.Fatalf("before: src %d fp-src %d, want 7 and 7", m.Src, m.FpSrc)
|
|
}
|
|
if g := graphOf(t, ix, "avahi"); g.None != sdkstep.ToolchainNoReport || g.Toolchains != nil {
|
|
t.Errorf("before: graph.avahi = %+v, want no toolchain edge and the reason", g)
|
|
}
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
|
|
reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.27.1")
|
|
m, _ := moduleOf(t, mods, "avahi")
|
|
// L3 (ADR 0603 §2, §3): the agreement moving rewrites the module's toolchain edge, and again when it moves on.
|
|
if g := graphOf(t, ix, "avahi"); g.FP != m.Fp2 || g.Toolchains["go"].Version != "go1.27.1" || g.Toolchains["go"].Module != "mesh-build-go" {
|
|
t.Errorf("after: graph.avahi = %+v, want the toolchain edge of go1.27.1", g)
|
|
}
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.28.0")
|
|
reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.28.0")
|
|
if g := graphOf(t, ix, "avahi"); g.Toolchains["go"].Version != "go1.28.0" {
|
|
t.Errorf("moved on: graph.avahi = %+v, want the toolchain edge of go1.28.0", g)
|
|
}
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
|
|
reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.27.1")
|
|
m, _ = moduleOf(t, mods, "avahi")
|
|
if m.None2 != "" || m.Fp2 == "" {
|
|
t.Errorf("none2 = %q fp2 = %q, want a fingerprint: no builder input is waited for (ADR 0530 §1)", m.None2, m.Fp2)
|
|
}
|
|
// ADR 0601 §1 (review round 2): a fingerprint moved with no merge has no release that carries it: fp-src 0, not known,
|
|
// though the source revision is still the release's at the module's own Src; a source revision heard again stamps nothing.
|
|
if m.FpSrc != 0 {
|
|
t.Errorf("a fingerprint moved by a recompute: fp-src %d, want 0", m.FpSrc)
|
|
}
|
|
if err := ix.ApplySource(context.Background(), 8, src); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if again, _ := moduleOf(t, mods, "avahi"); again.FpSrc != 0 {
|
|
t.Errorf("a source revision stamped the recomputed fingerprint: fp-src %d", again.FpSrc)
|
|
}
|
|
}
|
|
|
|
// ADR 0603 §3 (D9): a source revision that changes the Go build module's recipe and a Go module builds the module at once, with
|
|
// the recipe the nodes run, and again once the agreement moves to the new one: the module never waits and never loses its
|
|
// fingerprint between the two, and its toolchain edge names the recipe each was computed with.
|
|
func aRecipeChangeBuildsAGoModuleAtOnceAndAgainOnceTheAgreementMoves(t *testing.T) {
|
|
ix, mods := newFormIndexer(t, mod{"avahi", catalogueManifest(t, "avahi")})
|
|
for _, node := range []string{"novox", "ace"} {
|
|
reportToolchain(t, ix, "mesh-build-go", node, "go", "go1.27.1")
|
|
}
|
|
first, _ := moduleOf(t, mods, "avahi")
|
|
if first.Fp2 == "" || graphOf(t, ix, "avahi").Toolchains["go"].Recipe != "1" {
|
|
t.Fatalf("with the running recipe: fp2 %q, graph %+v", first.Fp2, graphOf(t, ix, "avahi"))
|
|
}
|
|
// The release carrying the new recipe is merged, but the nodes still run the old one: one node reporting the new recipe is
|
|
// no strict majority, so the fingerprint stays, nothing waits.
|
|
reportToolchainRecipe(t, ix, "mesh-build-go", "novox", "go", "go1.27.1", "2")
|
|
if held, _ := moduleOf(t, mods, "avahi"); held.Fp2 != first.Fp2 || held.None2 != "" {
|
|
t.Errorf("one node on the new recipe: fp2 %q none2 %q, want the fingerprint of the running recipe, %q", held.Fp2, held.None2, first.Fp2)
|
|
}
|
|
reportToolchainRecipe(t, ix, "mesh-build-go", "ace", "go", "go1.27.1", "2")
|
|
again, _ := moduleOf(t, mods, "avahi")
|
|
if again.Fp2 == "" || again.Fp2 == first.Fp2 || again.None2 != "" || graphOf(t, ix, "avahi").Toolchains["go"].Recipe != "2" {
|
|
t.Errorf("the agreement moved: fp2 %q (was %q) none2 %q graph %+v, want a second fingerprint at recipe 2", again.Fp2, first.Fp2, again.None2, graphOf(t, ix, "avahi"))
|
|
}
|
|
}
|
|
|
|
func TestNodesReportingDifferentVersionsLeaveTheReasonNamingBoth(t *testing.T) { // 1444/AC6
|
|
// Both reports are read before the module's first fingerprint is computed (a fingerprint computed before they
|
|
// disagree is kept: TestAModuleKeepsTheToolchainsItsFingerprintWasComputedWith).
|
|
ix, mods := newFormIndexer(t, mod{"avahi", catalogueManifest(t, "avahi")})
|
|
for node, version := range map[string]string{"novox": "go1.27.1", "ace": "go1.26.0"} {
|
|
key, _ := ToolchainKey(node, "go")
|
|
ix.PutToolchain("mesh-build-go", key, toolchainValue(t, "go", version, "1"), false)
|
|
}
|
|
if err := ix.Recompute(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m, _ := moduleOf(t, mods, "avahi")
|
|
if !strings.Contains(m.None2, "builders disagree") || !strings.Contains(m.None2, "go1.27.1") || !strings.Contains(m.None2, "go1.26.0") {
|
|
t.Errorf("none2 = %q, want 'builders disagree' naming both versions", m.None2)
|
|
}
|
|
}
|
|
|
|
// builderModules are the builder, a base, a module on the base (named so that it is stored before its base) and a module
|
|
// whose base is a TypeScript module with no toolchain reported.
|
|
func builderModules(t *testing.T) []mod {
|
|
return []mod{
|
|
{"mesh-delivery-builder", renamed(t, "avahi", "mesh-delivery-builder")},
|
|
{"mesh-tools", renamed(t, "avahi", "mesh-tools")},
|
|
{"avahi", standingOn(t, catalogueManifest(t, "avahi"), "mesh-tools")},
|
|
{"other", standingOn(t, renamed(t, "avahi", "other"), "ts-base")},
|
|
{"ts-base", renamed(t, "audit-logger", "ts-base")},
|
|
}
|
|
}
|
|
|
|
func goInputs(t *testing.T, ix *Indexer, mods *memStore, module, builder string, bases ...string) fingerprint.Inputs {
|
|
t.Helper()
|
|
return fingerprint.Inputs{Module: module, Tree: "tree-" + module, Builder: builder, Bases: bases,
|
|
Locks: map[string]string{"tools": avahiGoSum},
|
|
Toolchains: map[string]meshcatalogclient.Toolchain2{"tools": {Language: "go", Version: "go1.27.1", Recipe: "1"}}}
|
|
}
|
|
|
|
func fp2(t *testing.T, in fingerprint.Inputs) string {
|
|
t.Helper()
|
|
fp, none := fingerprint.Of(in)
|
|
if none != "" {
|
|
t.Fatal(none)
|
|
}
|
|
return fp
|
|
}
|
|
|
|
// 1887/I1, ADR 0530 §1: bases are computed before the modules taking them, and no module's fingerprint takes the builder,
|
|
// the builder's own included: a trunk build of build-agent (or of the builder) moves nothing.
|
|
func TestBasesAreComputedFirstAndNoFingerprintTakesTheBuilder(t *testing.T) { // 1444/AC7, AC8
|
|
ix, mods := newFormIndexer(t, builderModules(t)...)
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
|
|
builderFP := fp2(t, goInputs(t, ix, mods, "mesh-delivery-builder", ""))
|
|
toolsFP := fp2(t, goInputs(t, ix, mods, "mesh-tools", ""))
|
|
want := map[string]string{"mesh-delivery-builder": builderFP, "mesh-tools": toolsFP, "avahi": fp2(t, goInputs(t, ix, mods, "avahi", "", toolsFP))}
|
|
for name, fp := range want {
|
|
if m, _ := moduleOf(t, mods, name); m.Fp2 != fp || m.None2 != "" || m.Inputs2 == nil || m.Inputs2.Builder != "" {
|
|
t.Errorf("%s: fp2 = %q none2 = %q inputs %+v, want %s with no builder input", name, m.Fp2, m.None2, m.Inputs2, fp)
|
|
}
|
|
}
|
|
if m, _ := moduleOf(t, mods, "other"); m.Fp2 != "" || !strings.Contains(m.None2, "ts-base") || !strings.Contains(m.None2, "base") {
|
|
t.Errorf("other: fp2 = %q none2 = %q, want none2 naming the base ts-base", m.Fp2, m.None2)
|
|
}
|
|
// L3 (ADR 0603 §2): the module graph holds each module's edges as this recompute computed them.
|
|
goTool := map[string]graph.Toolchain{"go": {Module: "mesh-build-go", Version: "go1.27.1", Recipe: "1"}}
|
|
if g := graphOf(t, ix, "avahi"); g.FP != want["avahi"] || !reflect.DeepEqual(g.Bases, []string{"mesh-tools"}) ||
|
|
!reflect.DeepEqual(g.Toolchains, goTool) || g.Repository != catalogueRepo || g.Path != "modules/avahi" {
|
|
t.Errorf("graph.avahi = %+v", g)
|
|
}
|
|
if g := graphOf(t, ix, "other"); g.FP != "" || !strings.Contains(g.None, "ts-base") || !reflect.DeepEqual(g.Bases, []string{"ts-base"}) {
|
|
t.Errorf("graph.other = %+v, want its base and its reason", g)
|
|
}
|
|
graph := ix.Graph.(*memStore)
|
|
written := graph.writes
|
|
if err := ix.Recompute(context.Background()); err != nil || graph.writes != written {
|
|
t.Errorf("a recompute that moves nothing wrote the graph %d times (%v)", graph.writes-written, err)
|
|
}
|
|
// A module the modules state no longer holds (a merge dropped it, issue 1900) leaves the graph at the next recompute.
|
|
delete(mods.vals, "other")
|
|
if err := ix.Recompute(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, held := graph.vals["other"]; held {
|
|
t.Error("graph.other is still held after its module went")
|
|
}
|
|
if _, held := graph.vals["avahi"]; !held {
|
|
t.Error("graph.avahi went with another module")
|
|
}
|
|
// Review finding 3 (ADR 0603 §2): a graph state not on the bus yet (process.ErrNotYet), or one whose listing fails, never
|
|
// fails a recompute; the first writes nothing and says so, the second skips the deletion pass and says so.
|
|
graph.vals["ghost"], graph.revs["ghost"] = []byte("x"), 1
|
|
var said []string
|
|
ix.Logf = func(format string, args ...any) { said = append(said, fmt.Sprintf(format, args...)) }
|
|
for _, row := range []struct {
|
|
name string
|
|
err error
|
|
say string
|
|
}{
|
|
{"not on the bus yet", process.ErrNotYet, "not on the bus yet"},
|
|
{"a listing that fails", errors.New("the bus is slow"), "are not deleted"},
|
|
} {
|
|
said, graph.keysErr = nil, row.err
|
|
if err := ix.Recompute(context.Background()); err != nil {
|
|
t.Errorf("%s: the recompute failed: %v", row.name, err)
|
|
}
|
|
if _, held := graph.vals["ghost"]; !held || !strings.Contains(strings.Join(said, "\n"), row.say) {
|
|
t.Errorf("%s: ghost held %v, log %q, want it kept and %q said", row.name, held, said, row.say)
|
|
}
|
|
}
|
|
graph.keysErr = nil
|
|
if err := ix.Recompute(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, held := graph.vals["ghost"]; held {
|
|
t.Error("graph.ghost is still held once the graph can be listed")
|
|
}
|
|
a := agentBuild("build-1792587792086548572", agentSrcY, "2026-10-21T15:03:00Z")
|
|
_ = ix.Builds.Put(a[1].([]byte))
|
|
if err := ix.Recompute(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for name, fp := range want {
|
|
if m, _ := moduleOf(t, mods, name); m.Fp2 != fp {
|
|
t.Errorf("%s moved with a build of build-agent: fp2 = %q, want %s", name, m.Fp2, fp)
|
|
}
|
|
}
|
|
// ADR 0614 §2, §3: the graph names what each module invokes and serves, and leaves out, over the whole graph, the
|
|
// verb edges of two modules calling each other, from either side; a third calling one of them keeps its edge.
|
|
invoking := func(name, invokes string) mod {
|
|
raw := renamed(t, "avahi", name)
|
|
return mod{name, strings.Replace(raw, `"version": "1",`, `"version": "1", "invokes": [`+invokes+`],`, 1)}
|
|
}
|
|
vx, _ := newFormIndexer(t, invoking("caller", `"callee.avahi_status"`), invoking("callee", `"caller.avahi_browse", "*"`),
|
|
invoking("third", `"callee.avahi_status"`))
|
|
for name, want := range map[string][2][]string{
|
|
"caller": {{"callee.avahi_status"}, {"callee.avahi_status"}},
|
|
"callee": {{"caller.avahi_browse"}, {"caller.avahi_browse"}},
|
|
"third": {{"callee.avahi_status"}, nil},
|
|
} {
|
|
if g := graphOf(t, vx, name); !reflect.DeepEqual(g.Invokes, want[0]) || !reflect.DeepEqual(g.LeftOut, want[1]) ||
|
|
!slices.Contains(g.Serves, name+".avahi_status") {
|
|
t.Errorf("graph.%s = %+v, want it to invoke %v and leave out %v", name, g, want[0], want[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestABuildHeardBeforeAnyToolchainIsReadDropsNoFingerprint(t *testing.T) { // 1727 (review: a restart flapped every FP2)
|
|
ix, mods := newFormIndexer(t, builderModules(t)...)
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
|
|
before, _ := moduleOf(t, mods, "mesh-tools")
|
|
if before.Fp2 == "" {
|
|
t.Fatalf("mesh-tools has no fp2 to begin with: %+v", before)
|
|
}
|
|
// a restarted indexer has read no toolchains bucket yet
|
|
ix.mu.Lock()
|
|
ix.toolchains = nil
|
|
ix.mu.Unlock()
|
|
if err := ix.RecomputeAtStart(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if m, _ := moduleOf(t, mods, "mesh-tools"); m.Fp2 != before.Fp2 || m.None2 != "" {
|
|
t.Errorf("mesh-tools: fp2 = %q none2 = %q, want fp2 %q kept", m.Fp2, m.None2, before.Fp2)
|
|
}
|
|
}
|
|
|
|
func TestAToolchainEntryChangedToTheSameValuesWritesNothing(t *testing.T) { // 1444/AC9; 1966: nothing moved writes nothing
|
|
ctx := context.Background()
|
|
for _, c := range []struct {
|
|
name string
|
|
do func(t *testing.T, ix *Indexer, mods *memStore)
|
|
}{
|
|
{"the same version and recipe, a later time", func(t *testing.T, ix *Indexer, _ *memStore) {
|
|
time.Sleep(2 * time.Millisecond)
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
|
|
}},
|
|
// Issue 1966 (2026-11-08): the package versions compared by reflect.DeepEqual across two map types never matched, so
|
|
// every recompute wrote every module again with the bytes it held, each write a views event.
|
|
{"a recompute with nothing moved", func(t *testing.T, ix *Indexer, _ *memStore) {
|
|
if err := ix.Recompute(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}},
|
|
// write itself: an answer whose bytes are the bytes held is no write, whoever computed it.
|
|
{"a compute answering the bytes held", func(t *testing.T, ix *Indexer, mods *memStore) {
|
|
for _, key := range slices.Sorted(maps.Keys(mods.vals)) {
|
|
if err := write(ctx, ix.Modules, key, func(value []byte, _ bool) ([]byte, bool, error) { return slices.Clone(value), false, nil }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}},
|
|
} {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
ix, mods := newFormIndexer(t, builderModules(t)...)
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
|
|
before := mods.writes
|
|
c.do(t, ix, mods)
|
|
if mods.writes != before {
|
|
t.Errorf("modules written %d times, want none", mods.writes-before)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestModulesThatStandOnEachOtherAreNamedAsACycle(t *testing.T) { // 1444 (the indexer has no graph: a cycle is a reason)
|
|
ix, mods := newFormIndexer(t,
|
|
mod{"aa", standingOn(t, renamed(t, "avahi", "aa"), "bb")},
|
|
mod{"bb", standingOn(t, renamed(t, "avahi", "bb"), "aa")})
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
|
|
for _, name := range []string{"aa", "bb"} {
|
|
if m, _ := moduleOf(t, mods, name); m.Fp2 != "" || !strings.Contains(m.None2, "cycle") {
|
|
t.Errorf("%s: fp2 = %q none2 = %q, want a reason naming the cycle", name, m.Fp2, m.None2)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAMergeWritesEachModuleOnceWithItsFinalValueAndAReplayWritesNothing(t *testing.T) { // 1444 (review finding 8)
|
|
ctx := context.Background()
|
|
ix, mods, f := newFormIndexerUnmerged(t, mod{"avahi", catalogueManifest(t, "avahi")})
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") // agreed before the merge is heard
|
|
if err := ix.Apply(ctx, f); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m, _ := moduleOf(t, mods, "avahi")
|
|
if mods.writes != 1 || m.Fp2 == "" {
|
|
t.Errorf("writes = %d fp2 = %q after one merge, want one write with its fingerprint", mods.writes, m.Fp2)
|
|
}
|
|
if err := ix.Apply(ctx, f); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mods.writes != 1 {
|
|
t.Errorf("writes = %d after the same merge again, want still 1", mods.writes)
|
|
}
|
|
}
|
|
|
|
func TestAMergeKeepsAFingerprintTheModuleStillHas(t *testing.T) { // 1444 (review finding 8: a merge never blanks FP2)
|
|
ctx := context.Background()
|
|
ix, mods := newFormIndexer(t, builderModules(t)...)
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
|
|
before, _ := moduleOf(t, mods, "mesh-tools")
|
|
if before.Fp2 == "" {
|
|
t.Fatalf("mesh-tools has no fp2: %+v", before)
|
|
}
|
|
w := mods.writes
|
|
// the same catalogue merged again at a later commit: nothing moved
|
|
_, _, f := newFormIndexerUnmerged(t, builderModules(t)...)
|
|
f.Merge = otherMerge
|
|
if err := ix.Apply(ctx, f); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// and the commit stays the one the fingerprint first appeared at (the operator, 2026-10-25)
|
|
if after, _ := moduleOf(t, mods, "mesh-tools"); after.Fp2 != before.Fp2 || after.Commit != before.Commit || mods.writes != w {
|
|
t.Errorf("fp2 = %q at %.8s (was %q at %.8s), writes %d more", after.Fp2, after.Commit, before.Fp2, before.Commit, mods.writes-w)
|
|
}
|
|
}
|
|
|
|
func TestAModuleKeepsTheToolchainsItsFingerprintWasComputedWith(t *testing.T) { // 1615/AC3
|
|
ix, mods := newFormIndexer(t, builderModules(t)...)
|
|
if m, _ := moduleOf(t, mods, "avahi"); m.Toolchains != nil {
|
|
t.Fatalf("before a toolchain is reported: toolchains = %+v, want none", m.Toolchains)
|
|
}
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
|
|
m, _ := moduleOf(t, mods, "avahi")
|
|
want := map[string]meshcatalog.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "1"}}
|
|
if m.Fp2 == "" || !reflect.DeepEqual(m.Toolchains, want) {
|
|
t.Fatalf("avahi: fp2 %q toolchains %+v, want %+v", m.Fp2, m.Toolchains, want)
|
|
}
|
|
if o, _ := moduleOf(t, mods, "other"); o.Fp2 != "" || o.Toolchains != nil {
|
|
t.Errorf("a module with no fingerprint carries toolchains %+v", o.Toolchains)
|
|
}
|
|
// An upgrade moves the fingerprint and the toolchains together, with no merge.
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.2")
|
|
n, _ := moduleOf(t, mods, "avahi")
|
|
if n.Fp2 == m.Fp2 || n.Toolchains["go"].Version != "go1.27.2" {
|
|
t.Errorf("after the upgrade: fp2 %q (was %q) toolchains %+v, want the new version with the new fingerprint", n.Fp2, m.Fp2, n.Toolchains)
|
|
}
|
|
// Issue 1585 (ADR 0573 §2): a module whose go.sum names mesh-sdk takes mesh-sdk's latest published version into its
|
|
// fingerprint and keeps it beside its toolchains; a mesh-sdk trunk merge moves it with no merge of its own, and a module
|
|
// using no package is not moved. With no published version held, it has none, naming the package.
|
|
const sdk = "git.novox.be/novox/mesh-sdk/go"
|
|
_, _, uses := newFormIndexerUnmerged(t, builderModules(t)...)
|
|
uses.Packages = map[string][]string{"modules/avahi": {sdk}}
|
|
if err := ix.Apply(context.Background(), uses); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if u, _ := moduleOf(t, mods, "avahi"); u.Fp2 != "" || u.None2 != fingerprint.NoPackageVersion+sdk {
|
|
t.Errorf("no published mesh-sdk: fp2 %q none2 %q", u.Fp2, u.None2)
|
|
}
|
|
tools, _ := moduleOf(t, mods, "mesh-tools")
|
|
sdkMerge := func(commit, tree string) meshcatalog.ModulesByModuleValue {
|
|
t.Helper()
|
|
f := forgemsg.Forge{V: keys.Version, Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-sdk", Branch: "feat/x", Number: 3, Head: h1,
|
|
Merge: commit, Root: tree, Trees: map[string]string{"": tree}, Manifests: map[string]string{"": `{"module":"mesh-sdk","version":"1"}`}}
|
|
if err := ix.Apply(context.Background(), f); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a, _ := moduleOf(t, mods, "avahi")
|
|
if a.Fp2 == "" || !reflect.DeepEqual(a.Packages, map[string]string{sdk: commit}) {
|
|
t.Fatalf("after mesh-sdk's merge %.8s: fp2 %q none2 %q packages %v", commit, a.Fp2, a.None2, a.Packages)
|
|
}
|
|
if o, _ := moduleOf(t, mods, "mesh-tools"); o.Fp2 != tools.Fp2 || o.Packages != nil {
|
|
t.Errorf("mesh-tools uses no package and moved: fp2 %q (was %q) packages %v", o.Fp2, tools.Fp2, o.Packages)
|
|
}
|
|
return a
|
|
}
|
|
first := sdkMerge(strings.Repeat("1", 40), "tree-sdk-1")
|
|
second := sdkMerge(strings.Repeat("2", 40), "tree-sdk-2")
|
|
if second.Fp2 == first.Fp2 {
|
|
t.Errorf("a mesh-sdk trunk merge kept avahi's fingerprint %q", first.Fp2)
|
|
}
|
|
n = second
|
|
// Nodes that disagree keep the fingerprint already computed, with its toolchains (issue 1940, 2026-10-25 20:56: a
|
|
// stale report blanked every module and the controller's take-in refused modules already built); only a new
|
|
// fingerprint needs the agreement: a merge that moves the module's tree gives it none, naming the disagreement.
|
|
reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.26.0")
|
|
if d, _ := moduleOf(t, mods, "avahi"); d.Fp2 != n.Fp2 || !reflect.DeepEqual(d.Toolchains, n.Toolchains) || d.None2 != "" {
|
|
t.Errorf("disagreeing nodes: fp2 %q toolchains %+v none2 %q, want the fingerprint kept (%q) with %+v", d.Fp2, d.Toolchains, d.None2, n.Fp2, n.Toolchains)
|
|
}
|
|
_, _, f := newFormIndexerUnmerged(t, builderModules(t)...)
|
|
f.Trees["modules/avahi"] = "tree-avahi-moved"
|
|
if err := ix.Apply(context.Background(), f); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if d, _ := moduleOf(t, mods, "avahi"); d.Fp2 != "" || d.Toolchains != nil || !strings.HasPrefix(d.None2, "builders disagree") {
|
|
t.Errorf("a new tree while nodes disagree: fp2 %q toolchains %+v none2 %q, want no fingerprint and the disagreement", d.Fp2, d.Toolchains, d.None2)
|
|
}
|
|
// They agree again: the new tree gets its fingerprint.
|
|
reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.27.2")
|
|
if a, _ := moduleOf(t, mods, "avahi"); a.Fp2 == "" || a.Fp2 == n.Fp2 {
|
|
t.Errorf("agreed again: fp2 %q (was %q), want a new fingerprint", a.Fp2, n.Fp2)
|
|
}
|
|
}
|