The operator, 2026-10-25: only if a module has a new version in commit x is a new value written naming commit x. A merge wrote every module of the repository at the new commit, fingerprint unchanged (about 186 keys per mesh-catalog merge), and the controller's take-in took each in again: twice tonight it flooded. Now a merge that gives a module the value it holds but for the commit writes nothing, and the value keeps the commit its fingerprint first appeared at. The kind: a write that differs only in where it was computed. The other instance, a source revision re-stamping Src on every module at its repository's head after any merge anywhere, now stamps Src once. The trunk head stays the gate's source.main.
321 lines
14 KiB
Go
321 lines
14 KiB
Go
package index
|
|
|
|
import (
|
|
"context"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// 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 {
|
|
commit := "cada7e3c"
|
|
if id == "build-1792587792086548572" {
|
|
commit = "f10fbda1"
|
|
}
|
|
raw := `{"id":"` + id + `","repository":"` + agentCloneURL + `","ref":"main","module":"build-agent","commit":"` + commit + `",` +
|
|
`"path":"modules/build-agent","at":"` + at + `","source-fingerprint":"` + src + `","source-inputs":` + aInputs + `}`
|
|
return [2]any{delivery.BuildKey("build-agent", id), []byte(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.
|
|
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, delivery.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 := delivery.EncodeToolchain(delivery.Toolchain{Language: language, Version: version, Recipe: recipe, At: time.Now()})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func reportToolchain(t *testing.T, ix *Indexer, module, node, language, version string) {
|
|
t.Helper()
|
|
key, err := delivery.ToolchainKey(node, language)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ix.ApplyToolchain(context.Background(), module, key, toolchainValue(t, language, version, "1"), 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 != delivery.ToolchainNoReport {
|
|
t.Fatalf("before: none2 = %q", m.None2)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
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, _ := delivery.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) delivery.SourceInputs2 {
|
|
t.Helper()
|
|
return delivery.SourceInputs2{Module: module, Tree: "tree-" + module, Builder: builder, Bases: bases,
|
|
Locks: map[string]string{"tools": avahiGoSum},
|
|
Toolchains: map[string]delivery.Toolchain2{"tools": {Language: "go", Version: "go1.27.1", Recipe: "1"}}}
|
|
}
|
|
|
|
func fp2(t *testing.T, in delivery.SourceInputs2) string {
|
|
t.Helper()
|
|
fp, none := delivery.SourceFingerprint2(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)
|
|
}
|
|
a := agentBuild("build-1792587792086548572", agentSrcY, "2026-10-21T15:03:00Z")
|
|
ix.Builds.Put(a[0].(string), 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
ix, mods := newFormIndexer(t, builderModules(t)...)
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1")
|
|
before := mods.writes
|
|
time.Sleep(2 * time.Millisecond)
|
|
reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") // the same version and recipe, a later time
|
|
if mods.writes != before {
|
|
t.Errorf("modules written %d times after the same values, 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]delivery.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)
|
|
}
|
|
// 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)
|
|
}
|
|
}
|