diff --git a/modules/mesh-delivery-view/internal/view/reach.go b/modules/mesh-delivery-view/internal/view/reach.go index 88cd8cd7..3a95d392 100644 --- a/modules/mesh-delivery-view/internal/view/reach.go +++ b/modules/mesh-delivery-view/internal/view/reach.go @@ -10,6 +10,7 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate" "git.novox.be/novox/mesh-delivery/sdk/trunk" ) @@ -195,6 +196,7 @@ func (m *Model) moduleReach(name string, mod meshcatalog.ModulesByModuleValue, h if n := len(hist); n == 0 || hist[n-1].Fp2 != mod.Fp2 || hist[n-1].Commit != mod.Commit { hist = append(slices.Clone(hist), mod) // the value watched now is the newest, whatever the history watch has given } + var values []meshcatalog.ModulesByModuleValue // the catalogue's value of each change, in step with mr.Changes for i := len(hist) - 1; i >= 0 && len(mr.Changes) < viewstate.MostChanges; i-- { c := viewstate.Change{Commit: hist[i].Commit, FP: hist[i].Fp2, Src: uint64(hist[i].Src)} if c.Src == 0 { @@ -202,9 +204,11 @@ func (m *Model) moduleReach(name string, mod meshcatalog.ModulesByModuleValue, h } if n := len(mr.Changes); n > 0 && mr.Changes[n-1].FP == c.FP { mr.Changes[n-1] = c // the same fingerprint again, back to back: its first appearance is the older commit + values[n-1] = hist[i] continue } mr.Changes = append(mr.Changes, c) + values = append(values, hist[i]) } var waiting []string away := m.awayNodes() @@ -264,7 +268,7 @@ func (m *Model) moduleReach(name string, mod meshcatalog.ModulesByModuleValue, h return mr, noBound, "" } next := mr.Changes[floor-1] - words := fmt.Sprintf("%s on %s runs the build of %.12s, not yet that of %.12s", name, floorNode, mr.Floor, next.Commit) + words := m.laggingWords(name, floorNode, mr.Changes[floor], next, values[floor], values[floor-1]) if next.Src == 0 { // The next change's order is not known: the head is held at the floor's own revision, or before every head when that is // not known either. @@ -276,6 +280,86 @@ func (m *Model) moduleReach(name string, mod meshcatalog.ModulesByModuleValue, h return mr, next.Src, words } +// laggingWords says what a node's running build lacks of the module's next change: both builds by fingerprint with their +// commits, which input moved when the commit is the same (the catalogue holds the module's inputs, not the version a mesh +// package resolved to), and a next build that failed, with the first words of its error, instead of "not yet". +func (m *Model) laggingWords(name, node string, run, next viewstate.Change, runV, nextV meshcatalog.ModulesByModuleValue) string { + words := fmt.Sprintf("%s on %s runs build %s (%.8s)", name, node, shortFP(run.FP), run.Commit) + nextIs := fmt.Sprintf("%s (%.8s)", shortFP(next.FP), next.Commit) + if run.Commit == next.Commit { + if moved := inputsMoved(runV, nextV); moved != "" { + nextIs = fmt.Sprintf("%s (%.8s, %s)", shortFP(next.FP), next.Commit, moved) + } + } + if key, err := builds.BuilderBuildKey(name, next.FP); err == nil { + if k, ok := m.keys[key]; ok && k.err == "" && k.value.State == builds.BuildFailed { + return fmt.Sprintf("%s, its newest build %s failed: %s", words, nextIs, firstWords(k.value.Error)) + } + } + return fmt.Sprintf("%s, not yet %s", words, nextIs) +} + +// inputsMoved names what of a module's inputs differs between two values with the same commit, or "". +func inputsMoved(a, b meshcatalog.ModulesByModuleValue) string { + if a.Inputs2 == nil || b.Inputs2 == nil { + return "" + } + x, y := a.Inputs2, b.Inputs2 + var moved []string + if x.Tree != y.Tree { + moved = append(moved, "tree") + } + for _, name := range slices.Sorted(maps.Keys(mergedKeys(x.Locks, y.Locks))) { + if x.Locks[name] != y.Locks[name] { + moved = append(moved, "lock "+name) + } + } + if !slices.Equal(x.Bases, y.Bases) { + moved = append(moved, "a base module") + } + if len(moved) == 0 && x.Builder != y.Builder { + moved = append(moved, "builder") + } + if len(moved) == 0 && len(y.Packages) > 0 { + // Nothing of the module's own moved: a mesh package it resolves at its latest published version did. + moved = append(moved, "a mesh package of "+strings.Join(y.Packages, ", ")) + } + return strings.Join(moved, ", ") +} + +func mergedKeys(a, b map[string]string) map[string]bool { + out := map[string]bool{} + for k := range a { + out[k] = true + } + for k := range b { + out[k] = true + } + return out +} + +// firstWords is the start of an error, up to eighty characters cut at a word. +func firstWords(s string) string { + s = strings.Join(strings.Fields(s), " ") + if len(s) <= 80 { + return s + } + s = s[:80] + if i := strings.LastIndexByte(s, ' '); i > 0 { + s = s[:i] + } + return s + "..." +} + +// shortFP is a fingerprint's first eight characters, after its form. +func shortFP(fp string) string { + fp = strings.TrimPrefix(fp, meshcatalogclient.SourcePrefix2) + if len(fp) > 8 { + fp = fp[:8] + } + return fp +} + // runs is whether a have in this phase runs the fingerprint it names: running, being checked after its apply, or held after a // failed backup, which keeps running what it has (ADR 0539 clause 16). A have backing up or applying, failed or removed runs // nothing the derivation can count. diff --git a/modules/mesh-delivery-view/internal/view/reach_test.go b/modules/mesh-delivery-view/internal/view/reach_test.go index 67dc5d06..a4327f12 100644 --- a/modules/mesh-delivery-view/internal/view/reach_test.go +++ b/modules/mesh-delivery-view/internal/view/reach_test.go @@ -82,7 +82,7 @@ func TestTheViewDerivesHowFarEachRepositoryReached(t *testing.T) { then: []Entry{haveFP(t, "g14", "web", fpOld, nodeengineclient.HealthOK), presenceE(t, "g14", false, t0)}}, {name: "a node returned lagging holds the head again", reached: commitB, holds: "web on g14", waiting: "not yet the", then: []Entry{haveFP(t, "g14", "web", fpOld, nodeengineclient.HealthOK), presenceE(t, "g14", false, t0), presenceE(t, "g14", true, t0)}}, - {name: "a want held back at an older build holds the head as a lag does", reached: commitB, holds: "web on g14 runs the build of", + {name: "a want held back at an older build holds the head as a lag does", reached: commitB, holds: "web on g14 runs build", then: []Entry{wantE(t, "g14", "web", meshwantsclient.Want{Fp: fpOld, Newest: fpNew, Rule: 2}), haveFP(t, "g14", "web", fpOld, nodeengineclient.HealthOK)}}, {name: "a failing health check is said and holds nothing", reached: commitC, waiting: "health check failed", then: []Entry{haveFP(t, "g14", "web", fpNew, nodeengineclient.HealthFailed)}}, diff --git a/modules/mesh-delivery-view/internal/view/reach_words_test.go b/modules/mesh-delivery-view/internal/view/reach_words_test.go new file mode 100644 index 00000000..280d80e2 --- /dev/null +++ b/modules/mesh-delivery-view/internal/view/reach_words_test.go @@ -0,0 +1,77 @@ +package view + +import ( + "strings" + "testing" + + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient" + "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-indexer/takein" + "git.novox.be/novox/mesh-delivery/sdk/trunk" +) + +// Issue 1966: a node that runs an older build of a module than its newest names both builds by fingerprint with their commits, +// says which input moved when the commit is the same, and says a newest build that failed as failed, with its first words. +func TestALaggingNodeNamesTheBuildsAndWhatDiffers(t *testing.T) { + const web = "novox/web" + commitA, commitB, commitC := strings.Repeat("a", 40), strings.Repeat("b", 40), strings.Repeat("c", 40) + inputs := func(tree string, pkgs ...string) *meshcatalog.ModulesByModuleValueInputs2 { + return &meshcatalog.ModulesByModuleValueInputs2{Tree: tree, Packages: pkgs} + } + for _, tc := range []struct { + name string + oldIn, newIn *meshcatalog.ModulesByModuleValueInputs2 + newCommit string + failed string + want, not string + }{ + {name: "same commit, a mesh package moved", oldIn: inputs("t", "mesh-sdk/go"), newIn: inputs("t", "mesh-sdk/go"), newCommit: commitA, + want: "web on g14 runs build 11111111 (aaaaaaaa), not yet 22222222 (aaaaaaaa, a mesh package of mesh-sdk/go)", not: "the build of"}, + {name: "same commit, its tree moved", oldIn: inputs("t1"), newIn: inputs("t2"), newCommit: commitA, + want: "runs build 11111111 (aaaaaaaa), not yet 22222222 (aaaaaaaa, tree)"}, + {name: "another commit names no input", oldIn: inputs("t1"), newIn: inputs("t2"), newCommit: commitB, + want: "runs build 11111111 (aaaaaaaa), not yet 22222222 (bbbbbbbb)", not: "tree"}, + {name: "a newest build that failed is said failed", oldIn: inputs("t1"), newIn: inputs("t2"), newCommit: commitB, failed: "go build ./...: undefined: Foo", + want: "its newest build 22222222 (bbbbbbbb) failed: go build ./...: undefined: Foo", not: "not yet"}, + } { + t.Run(tc.name, func(t *testing.T) { + mod := func(fp, commit string, src int64, in *meshcatalog.ModulesByModuleValueInputs2, history bool) Entry { + v, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: strings.Repeat("a", 64), Repository: web, Commit: commit, Src: src, Path: "web", Inputs2: in}) + if err != nil { + t.Fatal(err) + } + return Entry{Bucket: ModulesBucket, Key: "web", Value: v, At: t0, History: history} + } + src := func(rev uint64, head string, history bool) Entry { + v, _ := meshdeliveryclient.EncodeSource(meshdelivery.SourceByMainValue{Group: "g", Heads: map[string]string{web: head}}) + return Entry{Bucket: SourceBucket, Key: meshdeliveryclient.SourceMainKey, Value: v, At: t0, Revision: rev, History: history} + } + key, _ := trunk.RepositoryKey(web) + v, _ := takein.EncodeTaken(takein.Taken{Repository: web, Merge: commitC, At: t0}) + m := New() + m.SetReachWriter(func(ReachWrite) error { return nil }) + m.SetDeliveredEvent(func(DeliveredEvent) error { return nil }) + entries := []Entry{src(10, commitA, true), src(11, tc.newCommit, true), src(12, commitC, true), src(12, commitC, false), + mod(fpOld, commitA, 10, tc.oldIn, true), mod(fpNew, tc.newCommit, 11, tc.newIn, true), mod(fpNew, tc.newCommit, 11, tc.newIn, false), + {Bucket: TakenBucket, Key: key, Value: v, At: t0}, + wantE(t, "g14", "web", meshwantsclient.Want{Fp: fpOld}), haveFP(t, "g14", "web", fpOld, nodeengineclient.HealthOK)} + if tc.failed != "" { + entries = append(entries, builderKey(t, "web", fpNew, builds.BuilderBuild{State: builds.BuildFailed, Error: tc.failed})) + } + fill(m, entries...) + for _, b := range reachBuckets { + m.Initial(b) + } + fill(m, haveFP(t, "g14", "web", fpOld, nodeengineclient.HealthOK)) + got := strings.Join(m.Reach()[key].Holds, "; ") + if !strings.Contains(got, tc.want) || (tc.not != "" && strings.Contains(got, tc.not)) { + t.Errorf("holds = %q, want words with %q and none with %q", got, tc.want, tc.not) + } + }) + } +}