Files
mesh-delivery/modules/mesh-delivery-view/internal/view/reach_test.go
T
jschoubben 0de534c692 Names N3 (view): the status view names its own buckets from its credential's module
Its states answers, reach and waits (viewstate WaitsBucket, ReachBucket, ViewAnswersBucket), its
first views and its marks were named from the constant ViewModule; they are now
`<cred.Module>_<state>` (viewstate.BucketsOf), and the model is made for its module.

Builds: design 47 G A-296

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 19:14:55 +01:00

257 lines
14 KiB
Go

package view
import (
"errors"
"strings"
"testing"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/trunk"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// 1280 (ADR 0556 clauses 2 and 3): the view derives, per repository, how far each module reached and the reached head, writes
// it when it differs, and says each new reached head once as its event delivered.
func TestTheViewDerivesHowFarEachRepositoryReached(t *testing.T) {
const web, sdk = "novox/web", "novox/sdk"
commitA, commitB, commitC := strings.Repeat("a", 40), strings.Repeat("b", 40), strings.Repeat("c", 40)
commitS, commitX, commitD := strings.Repeat("5", 40), strings.Repeat("e", 40), strings.Repeat("d", 40)
const fpMail = "src2:3333333333333333333333333333333333333333333333333333333333333333"
mod := func(module, fp, commit string, src uint64, history bool) Entry {
v, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: strings.Repeat("a", 64), Repository: web, Commit: commit, Src: int64(src), Path: module})
if err != nil {
t.Fatal(err)
}
return Entry{Bucket: ModulesBucket, Key: module, Value: v, At: t0, History: history, Branch: testBranch}
}
modIn := func(fp, commit string, src uint64, in *meshcatalog.ModulesByModuleValueInputs2, history bool) Entry {
v, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: strings.Repeat("a", 64), Repository: web, Commit: commit, Src: int64(src), Path: "web", Inputs2: in})
if err != nil {
t.Fatal(err)
}
return Entry{Bucket: ModulesBucket, Key: "web", Value: v, At: t0, History: history, Branch: testBranch}
}
in := func(tree string, pkgs ...string) *meshcatalog.ModulesByModuleValueInputs2 {
return &meshcatalog.ModulesByModuleValueInputs2{Tree: tree, Packages: pkgs}
}
// A later build of web (fpLater) that g14, running fpNew, lacks; issue 1966: the hold names both builds, what moved when the
// commit is the same, and a build that failed as failed.
const fpLater = "src2:9999999999999999999999999999999999999999999999999999999999999999"
later := func(commit string, to *meshcatalog.ModulesByModuleValueInputs2, extra ...Entry) []Entry {
return append([]Entry{modIn(fpLater, commit, 12, to, true), modIn(fpLater, commit, 12, to, false)}, extra...)
}
// Each watched branch's source as its verb source answers it, one revision heard after another (design 47 G A-173).
srcOn := func(branch string, rev uint64, webHead string) Entry {
v, err := meshdeliveryclient.EncodeSource(meshdelivery.SourceByBranchValue{Group: "g", Heads: map[string]string{web: webHead, sdk: commitS}})
if err != nil {
t.Fatal(err)
}
return Entry{Bucket: SourceBucket, Key: keys.EscapeName(branch), Value: v, At: t0, Revision: rev}
}
src := func(rev uint64, webHead string) Entry { return srcOn(testBranch, rev, webHead) }
// The reach the view's own state held at its start (A-225).
kept := func(reached string, rev uint64) Entry {
key, _ := trunk.RepositoryKey(web)
v, err := viewstate.EncodeReach(viewstate.Reach{Repository: web, Reached: reached, ReachedSrc: rev})
if err != nil {
t.Fatal(err)
}
return Entry{Bucket: viewstate.BucketsOf(viewstate.ViewModule).Reach, Key: key, Value: v, At: t0}
}
taken := func(repository, merge string) Entry {
key, _ := trunk.RepositoryKey(repository)
v, err := takein.EncodeTaken(takein.Taken{Repository: repository, Merge: merge, At: t0})
if err != nil {
t.Fatal(err)
}
return Entry{Bucket: TakenBucket, Key: key, Value: v, At: t0}
}
haveIn := func(node, module, fp string, phase string, health string) Entry {
e := haveFP(t, node, module, fp, health)
v, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Phase: phase, Health: health})
e.Value = v
return e
}
// web changed at A (fpOld) and at C (fpNew); mail first appeared at A. The source named A, B, then C as web's head.
world := func(t *testing.T, restarted bool) []Entry {
heard := []Entry{src(10, commitA), src(11, commitB), src(12, commitC)}
if restarted { // a start after which only the newest revision is heard, over the reach the state kept (A-225)
heard = []Entry{kept(commitB, 11), src(12, commitC)}
}
return append(heard,
mod("web", fpOld, commitA, 10, true), modIn(fpNew, commitC, 12, in("t1", "mesh-sdk/go"), true), mod("mail", fpMail, commitA, 0, true),
modIn(fpNew, commitC, 12, in("t1", "mesh-sdk/go"), false), mod("mail", fpMail, commitA, 0, false),
taken(web, commitC), taken(sdk, commitS),
wantE(t, "ace", "web", meshwantsclient.Want{Fp: fpNew}), haveFP(t, "ace", "web", fpNew, nodeengineclient.HealthOK),
wantE(t, "g14", "web", meshwantsclient.Want{Fp: fpNew}), haveFP(t, "g14", "web", fpNew, nodeengineclient.HealthOK),
wantE(t, "ace", "mail", meshwantsclient.Want{Fp: fpMail}), haveFP(t, "ace", "mail", fpMail, nodeengineclient.HealthOK))
}
for _, tc := range []struct {
name string
then []Entry
reached string // web's reached head; "" none
holds string // words one of web's holds carries; "" none
waiting string // words web's module web waits with; "" none
notYet []string // the merges the reached head's event says are not yet on the machines
refused bool // the bus refuses every write and event until the watches handed over (a start, issue 1126)
// restarted: the view started again, heard only the newest source revision, and its state reach kept commitB (A-225)
restarted bool
}{
{name: "every node runs the newest build", reached: commitC},
// design 47 G A-173: a repository's reach is from the source of its modules' branch; another branch's source moves it not.
{name: "another branch's source naming the repository moves not its reach", reached: commitC,
then: []Entry{srcOn("beta", 13, commitX)}},
// A-225: after a restart the history is the revisions heard since; the kept reach stands until a newer one is derived.
{name: "after a restart the reach kept stands while the newest head lags", reached: commitB, holds: "web on g14", restarted: true,
then: []Entry{haveFP(t, "g14", "web", fpOld, nodeengineclient.HealthOK)}, waiting: "not yet the"},
{name: "after a restart a newer head reached replaces the kept one", reached: commitC, restarted: true},
{name: "a node lagging stops the head before the change it lacks", reached: commitB, holds: "web on g14",
then: []Entry{haveFP(t, "g14", "web", fpOld, nodeengineclient.HealthOK)}, waiting: "not yet the"},
// ADR 0603 §7: a node away holds no head back, is named, and holds it again once it returns still behind.
{name: "a node away lagging holds no head back and is named", reached: commitC, waiting: "g14 is away",
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 build",
then: []Entry{wantE(t, "g14", "web", meshwantsclient.Want{Fp: fpOld, Newest: fpNew, Rule: 2}), haveFP(t, "g14", "web", fpOld, nodeengineclient.HealthOK)}},
{name: "a later build of the same commit names the mesh package that moved", reached: commitB,
holds: "web on ace runs build 22222222 (cccccccc), not yet 99999999 (cccccccc, a mesh package of mesh-sdk/go)",
then: later(commitC, in("t1", "mesh-sdk/go"))},
{name: "a later build of the same commit names the tree that moved", reached: commitB,
holds: "runs build 22222222 (cccccccc), not yet 99999999 (cccccccc, tree)", then: later(commitC, in("t2", "mesh-sdk/go"))},
{name: "a later build of another commit names no input", reached: commitB,
holds: "runs build 22222222 (cccccccc), not yet 99999999 (eeeeeeee)", then: later(commitX, in("t2"))},
{name: "the newest build, failed, is said failed with its first words", reached: commitB,
holds: "its newest build 99999999 (eeeeeeee) failed: go build ./...: undefined: Foo", then: later(commitX, in("t2"),
builderKey(t, "web", fpLater, builds.BuilderBuild{State: builds.BuildFailed, Error: "go build ./...: undefined: Foo"}))},
{name: "a failed build that is not the newest is the build the node waits for", reached: commitB,
holds: "the build it waits for 99999999 (eeeeeeee) failed: go build", then: append(later(commitX, in("t2"),
builderKey(t, "web", fpLater, builds.BuilderBuild{State: builds.BuildFailed, Error: "go build ./...: undefined: Foo"})),
modIn("src2:"+strings.Repeat("8", 64), commitD, 12, in("t3"), true), modIn("src2:"+strings.Repeat("8", 64), commitD, 12, in("t3"), false))},
{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)}},
{name: "a node with no running have holds no head back and its module's merges are not yet", reached: commitC, waiting: "not running",
notYet: []string{commitA, commitC},
then: []Entry{haveIn("g14", "web", fpNew, nodeengineclient.PhaseApplying, nodeengineclient.HealthUnknown)}},
{name: "an unrelated module not running leaves the head reached (the reviewer's probe)", reached: commitC,
notYet: []string{commitA},
then: []Entry{haveIn("ace", "mail", fpMail, nodeengineclient.PhaseFailed, nodeengineclient.HealthFailed)}},
{name: "a module no node wants holds nothing", reached: commitC,
then: []Entry{wantE(t, "ace", "mail", meshwantsclient.Want{Removed: true})}},
{name: "the indexer not yet having taken the head in", reached: commitB, holds: "has not yet taken in",
then: []Entry{taken(web, commitB)}},
{name: "a change whose source revision is not known yet holds no head the source named", reached: commitC,
then: []Entry{mod("web", "src2:"+strings.Repeat("9", 64), commitX, 0, true), mod("web", "src2:"+strings.Repeat("9", 64), commitX, 0, false)}},
// 1126 note 94: a merge that removes the repository's last module.json moves nothing; once the indexer dropped its
// modules and took the merge in, the repository reaches it, held by nothing, as the library with no module does.
{name: "a merge that removed the last module is reached at the merge the indexer took in", reached: commitD,
then: func() []Entry {
head := src(13, commitD)
gone := func(module string, history bool) Entry {
return Entry{Bucket: ModulesBucket, Key: module, Deleted: true, At: t0, History: history}
}
return []Entry{head, gone("web", true), gone("mail", true), gone("web", false), gone("mail", false), taken(web, commitD)}
}()},
// 1126: the view started while the bus did not answer its writes; its reach and its event are made at the next change it
// takes in, though the derivation is the same, so the state reach never keeps an older have than node_haves says.
{name: "a write and an event refused at the start are made at the next change", reached: commitC, refused: true,
then: []Entry{haveFP(t, "ace", "mail", fpMail, nodeengineclient.HealthOK)}},
} {
t.Run(tc.name, func(t *testing.T) {
m := New(viewstate.ViewModule)
var written []ReachWrite
var said []DeliveredEvent
refuse := tc.refused
errRefused := errors.New("nats: no responders available for request")
m.SetReachWriter(func(w ReachWrite) error {
if refuse {
return errRefused
}
written = append(written, w)
return nil
})
m.SetDeliveredEvent(func(e DeliveredEvent) error {
if refuse {
return errRefused
}
if e.Repository != "" {
said = append(said, e)
}
return nil
})
fill(m, world(t, tc.restarted)...)
if len(written) != 0 {
t.Fatalf("written before every watch handed over: %v", written)
}
for _, b := range m.reachBuckets() {
m.Initial(b)
}
refuse = false
fill(m, tc.then...)
key, _ := trunk.RepositoryKey(web)
r := m.Reach()[key]
if r.Reached != tc.reached {
t.Errorf("reached = %.8s, want %.8s (holds %v)", r.Reached, tc.reached, r.Holds)
}
if got := strings.Join(r.Holds, "; "); (tc.holds == "") != (got == "") || !strings.Contains(got, tc.holds) {
t.Errorf("holds = %q, want words with %q", got, tc.holds)
}
if got := r.Modules["web"].Waiting; (tc.waiting == "") != (got == "") || !strings.Contains(got, tc.waiting) {
t.Errorf("web waits with %q, want words with %q", got, tc.waiting)
}
// A library reaches the head the indexer took in, with no module.
sk, _ := trunk.RepositoryKey(sdk)
if lib := m.Reach()[sk]; lib.Reached != commitS || len(lib.Modules) != 0 {
t.Errorf("the library's reach = %+v", lib)
}
// Each key is written once with its last value; a reached head is said once, for its repository alone.
last := map[string]viewstate.Reach{}
for _, w := range written {
v, err := viewstate.DecodeReach(w.Value)
if err != nil {
t.Fatalf("written %s: %v", w.Key, err)
}
last[w.Key] = v
}
if !viewstate.SameReach(last[key], r) {
t.Errorf("the last value written is %+v, not the reach derived %+v", last[key], r)
}
if tc.reached != "" {
var last DeliveredEvent
for _, e := range said {
if e.Repository == web {
last = e
}
}
if last.Repository == "" {
t.Errorf("the reached head of %s was never said", web)
}
if strings.Join(last.NotYet, ",") != strings.Join(tc.notYet, ",") {
t.Errorf("not yet = %v, want %v", last.NotYet, tc.notYet)
}
}
{
n := len(said)
fill(m, tc.then...)
if len(said) != n && tc.reached == commitC {
t.Errorf("said again: %+v", said[n:])
}
for _, e := range said {
if len(e.Heads) != 1 || e.Group != "" || e.Revision == 0 {
t.Errorf("a reached head said as %+v", e)
}
}
}
})
}
}