Files
mesh-delivery/modules/mesh-delivery-view/internal/view/wants_test.go
T
jschoubben 726bbf1f1f
mesh/delivery merged: source.main moved to novox/mesh-delivery@2ac146078f47
mesh/check being carried to 726bbf1f1ffa on 2ac146078f47 | from e753009fbc71
The view writes a reach or says an event again when the bus refused it, so a refused write never leaves the reach state at an older have (issue 1126)
The view remembered a reach value, a reached head and a motion event as written or said before the bus took them; a write refused at its start (no responders) was never made again while the derivation stayed the same, and mesh-delivery.show said mesh-delivery-view not running on shanks while node_haves said it running.

Quick fix: forget only the refused reach write. Thorough fix, chosen: every write and event the view makes after its lock (reach, reached head, delivered revision, motion) answers its error, and the model forgets what was refused, so the next change it takes in makes it again; one place, the writer of the state both mesh-delivery.show and knowledge-base.progress read.
2026-10-28 16:11:52 +01:00

410 lines
17 KiB
Go

package view
import (
"encoding/json"
"errors"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
const (
fpOld = "src2:1111111111111111111111111111111111111111111111111111111111111111"
fpNew = "src2:2222222222222222222222222222222222222222222222222222222222222222"
)
func wantE(t *testing.T, node, module string, w delivery.Want) Entry {
t.Helper()
key, err := delivery.WantKey(node, module)
if err != nil {
t.Fatal(err)
}
if !w.Removed {
w.Composed = "sha256:" + strings.Repeat("c", 64)
}
v, err := delivery.EncodeWant(w)
if err != nil {
t.Fatal(err)
}
return Entry{Bucket: delivery.AssignerWantsBucket, Key: key, Value: v, At: t0}
}
func haveFP(t *testing.T, node, module, fp string, health delivery.Health) Entry {
t.Helper()
b, err := delivery.EncodeHave(delivery.Have{FP: fp, Phase: delivery.PhaseRunning, Health: health})
if err != nil {
t.Fatal(err)
}
key, err := delivery.HaveKey(node, module)
if err != nil {
t.Fatal(err)
}
return Entry{Bucket: delivery.HaveBucket, Key: key, Value: b, At: t0}
}
func moduleE(t *testing.T, module, fp string) Entry {
t.Helper()
v, err := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: strings.Repeat("a", 64), Repository: "novox/" + module, Commit: strings.Repeat("1", 40), Path: delivery.RootPath})
if err != nil {
t.Fatal(err)
}
return Entry{Bucket: delivery.ModulesBucket, Key: module, Value: v, At: t0}
}
func sourceE(t *testing.T, revision uint64) Entry {
t.Helper()
v, err := delivery.EncodeSource(delivery.Source{Group: "g", Heads: map[string]string{"novox/web": strings.Repeat("1", 40)}})
if err != nil {
t.Fatal(err)
}
return Entry{Bucket: delivery.SourceBucket, Key: delivery.SourceMainKey, Value: v, At: t0, Revision: revision}
}
// 1906/D7 AC7: every want is a row beside the have of its node, with where it stands.
func TestEveryWantIsARowAgainstItsHave(t *testing.T) {
m := New()
fill(m,
wantE(t, "ace", "web", delivery.Want{FP: fpNew}), haveFP(t, "ace", "web", fpNew, delivery.HealthOK),
wantE(t, "ace", "mail", delivery.Want{FP: fpNew}), haveFP(t, "ace", "mail", fpOld, delivery.HealthOK),
wantE(t, "g14", "web", delivery.Want{FP: fpNew}), haveFP(t, "g14", "web", fpNew, delivery.HealthFailed),
wantE(t, "g14", "mail", delivery.Want{FP: fpOld, Newest: fpNew, Rule: delivery.RuleCanary, Words: "the canary ace does not run it healthy yet"}),
wantE(t, "g14", "old", delivery.Want{Removed: true}),
wantE(t, "novox", "web", delivery.Want{FP: fpNew}),
)
got := map[string]WantRow{}
for _, r := range m.Snapshot().Wants {
got[r.Node+"."+r.Module] = r
}
for key, state := range map[string]string{
"ace.web": WantApplied, "ace.mail": WantPending, "g14.web": WantFailed, "g14.mail": WantHeldBack, "g14.old": WantRemoved, "novox.web": WantPending,
} {
if got[key].State != state {
t.Errorf("%s = %+v, want state %s", key, got[key], state)
}
}
if r := got["g14.mail"]; r.Rule != delivery.RuleCanary || r.Newest != fpNew || r.Words == "" {
t.Errorf("the held-back row keeps its rule and words: %+v", r)
}
if r := got["ace.mail"]; r.Want != fpNew || r.Have != fpOld {
t.Errorf("a pending row shows both: %+v", r)
}
// a want taken away leaves the list
fill(m, Entry{Bucket: delivery.AssignerWantsBucket, Key: "want.novox.web", Deleted: true})
if n := len(m.Snapshot().Wants); n != 5 {
t.Errorf("wants = %d after a delete, want 5", n)
}
}
// 1906/D7 AC7: a source revision is delivered when every want equals its module's fingerprint and every have equals its
// want, healthy; one failed have shows it not delivered and names the module.
func TestASourceRevisionIsDerivedFromWantsAndHaves(t *testing.T) {
m := New()
fill(m, sourceE(t, 14355), moduleE(t, "web", fpNew), moduleE(t, "mail", fpNew),
assign("ace", "web", "b-1", ""), assign("g14", "web", "b-1", ""),
wantE(t, "ace", "web", delivery.Want{FP: fpNew}), haveFP(t, "ace", "web", fpNew, delivery.HealthOK),
wantE(t, "g14", "web", delivery.Want{FP: fpNew}), haveFP(t, "g14", "web", fpNew, delivery.HealthOK),
wantE(t, "ace", "mail", delivery.Want{FP: fpNew}), haveFP(t, "ace", "mail", fpNew, delivery.HealthOK))
s := m.Snapshot().Source
if s == nil || s.Revision != 14355 || s.State != SourceDelivered || len(s.Waiting) != 0 {
t.Fatalf("source = %+v, want revision 14355 delivered", s)
}
fill(m, haveFP(t, "g14", "web", fpNew, delivery.HealthFailed))
s = m.Snapshot().Source
if s.State != SourceNotDelivered || len(s.Waiting) != 1 || !strings.Contains(s.Waiting[0], "web") || !strings.Contains(s.Waiting[0], "g14") {
t.Errorf("a failed have: %+v", s)
}
fill(m, haveFP(t, "g14", "web", fpNew, delivery.HealthOK), wantE(t, "ace", "mail", delivery.Want{FP: fpOld}))
s = m.Snapshot().Source
if s.State != SourceNotDelivered || !strings.Contains(strings.Join(s.Waiting, ";"), "mail") {
t.Errorf("a want that is not the module's fingerprint: %+v", s)
}
fill(m, wantE(t, "ace", "mail", delivery.Want{FP: fpNew}), assign("novox", "web", "b-1", ""))
s = m.Snapshot().Source
if s.State != SourceNotDelivered || !strings.Contains(strings.Join(s.Waiting, ";"), "novox") {
t.Errorf("a node assigned the module with no want: %+v", s)
}
}
func TestNoSourceRevisionHeldMeansNoStateIsDerived(t *testing.T) {
if s := New().Snapshot().Source; s != nil {
t.Errorf("source = %+v", s)
}
}
func TestTheWantsPageSaysWantsAgainstHaves(t *testing.T) {
m := New()
fill(m, wantE(t, "ace", "web", delivery.Want{FP: fpNew}), haveFP(t, "ace", "web", fpOld, delivery.HealthOK))
text := wantText(m.Snapshot())
if len(text) != 1 || text[0][0] != "ace" || text[0][1] != "web" || text[0][2] != fpNew || text[0][3] != fpOld || text[0][4] != WantPending || text[0][5] != "none" {
t.Errorf("text = %v", text)
}
}
// 1906/D7 (ADR 0539 §14): the controller's judgement of the move a want names is shown beside it, with its words.
func TestTheJudgementOfTheMoveIsShownBesideTheWant(t *testing.T) {
m := New()
key, err := delivery.ComposedKey("ace", "web", fpNew)
if err != nil {
t.Fatal(err)
}
v, err := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("c", 64), Judgement: delivery.JudgedCannot, Words: "the composition it runs is gone"})
if err != nil {
t.Fatal(err)
}
fill(m, wantE(t, "ace", "web", delivery.Want{FP: fpNew}), Entry{Bucket: delivery.ControllerComposedBucket, Key: key, Value: v, At: t0})
rows := m.Snapshot().Wants
if len(rows) != 1 || rows[0].Judgement != string(delivery.JudgedCannot) || !strings.Contains(rows[0].Judged, "gone") {
t.Fatalf("rows = %+v", rows)
}
text := wantText(m.Snapshot())
if len(text[0]) != 7 || text[0][5] != "cannot-judge" {
t.Errorf("text = %v", text)
}
}
// 1906/D9 (asked by the lead for issue 1946): the view says, once for each source revision, that it is delivered: the event the
// issue tracker derives "fix delivered" from, in place of the gate's states that go in release 6 (ADR 0539 §17).
func TestTheViewSaysOnceThatASourceRevisionIsDelivered(t *testing.T) {
m := New()
var said []DeliveredEvent
refuse := true // the bus refuses the first try to say it (issue 1126): it is said at the next change, not lost
m.SetDeliveredEvent(func(e DeliveredEvent) error {
if refuse && e.Repository == "" {
refuse = false
return errors.New("nats: no responders available for request")
}
said = append(said, e)
return nil
})
fill(m, sourceE(t, 14355), moduleE(t, "web", fpNew), assign("ace", "web", "b-1", ""),
wantE(t, "ace", "web", delivery.Want{FP: fpNew}))
if len(said) != 0 {
t.Fatalf("said %v before any have", said)
}
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthFailed))
if len(said) != 0 {
t.Fatalf("said %v for a failed have", said)
}
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthOK))
if len(said) != 0 || refuse {
t.Fatalf("said = %+v though the bus refused it", said)
}
fill(m, wantE(t, "ace", "web", delivery.Want{FP: fpNew}))
if len(said) != 1 || said[0].Revision != 14355 || said[0].Group != "g" || said[0].Heads["novox/web"] == "" {
t.Fatalf("said = %+v, want revision 14355 once with its group and heads", said)
}
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthOK), wantE(t, "ace", "web", delivery.Want{FP: fpNew}))
if len(said) != 1 {
t.Errorf("said again for the same revision: %v", said)
}
// a newer revision is delivered when its wants and haves are; a revision that goes bad and good again is not said twice
fill(m, sourceE(t, 14356))
if len(said) != 2 || said[1].Revision != 14356 {
t.Errorf("the next revision: %v", said)
}
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthFailed), haveFP(t, "ace", "web", fpNew, delivery.HealthOK))
if len(said) != 2 {
t.Errorf("said again after a have failed and recovered: %v", said)
}
}
// Review round 1 (view notes): the controller no longer writes the build of an assignment (C6), so a row reads the want and the
// have: the row shows the fingerprint wanted and waits while the node runs another.
func TestARowReadsTheWantWhenTheAssignmentNamesNoBuild(t *testing.T) {
m := New()
fill(m, assign("ace", "web", "", ""), wantE(t, "ace", "web", delivery.Want{FP: fpNew}), haveFP(t, "ace", "web", fpOld, delivery.HealthOK))
r := find(t, m.Snapshot(), "ace", "web")
if r.Assigned != fpNew || r.Have != fpOld || !strings.Contains(r.Waits, "has not applied") {
t.Fatalf("row = %+v, want it to wait for the want's fingerprint", r)
}
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthOK))
if r := find(t, m.Snapshot(), "ace", "web"); r.Waits != "" || r.Have != fpNew {
t.Errorf("row = %+v, want no wait once the have names the want's fingerprint", r)
}
}
// Review round 1 (view notes, issue 1150): a module whose name holds a dot has its have under the escaped key; the row finds it,
// and a have nothing is assigned to keeps the module's name.
func TestADottedModuleNameIsFoundAndKept(t *testing.T) {
m := New()
fill(m, assign("ace", "novox.be", "", ""), wantE(t, "ace", "novox.be", delivery.Want{FP: fpNew}), haveFP(t, "ace", "novox.be", fpNew, delivery.HealthOK),
haveFP(t, "ace", "old.name", fpNew, delivery.HealthOK))
s := m.Snapshot()
if r := find(t, s, "ace", "novox.be"); r.Have != fpNew || r.Waits != "" {
t.Errorf("the dotted module's row = %+v", r)
}
if r := find(t, s, "ace", "old.name"); r.Status != StatusLeftBehind {
t.Errorf("the left-behind dotted module = %+v", r)
}
if rows := s.Wants; len(rows) != 1 || rows[0].Module != "novox.be" || rows[0].State != WantApplied {
t.Errorf("wants = %+v", rows)
}
}
// 1906/D10 (asked by the lead for the messenger, ADR 0308): with no plans to say that a module is in motion, the view says it: `moving`
// when a module's want on a node differs from its have, `settled` when the have reaches the want running and healthy, `stuck` when it
// is held or failed or the watchman says it stopped moving; each once for a node, a module and a composition.
func motionLog(m *Model) *[]MotionEvent {
var said []MotionEvent
m.SetMotionEvent(func(e MotionEvent) error { said = append(said, e); return nil })
for _, b := range []string{delivery.AssignerWantsBucket, delivery.HaveBucket, delivery.StuckBucket} {
m.Initial(b)
}
return &said
}
func types(said []MotionEvent) string {
var out []string
for _, e := range said {
out = append(out, e.Type)
}
return strings.Join(out, ",")
}
func TestAModuleInMotionIsSaidMovingThenSettledOnceEach(t *testing.T) {
m := New()
said := motionLog(m)
fill(m, haveFP(t, "ace", "web", fpOld, delivery.HealthOK))
if len(*said) != 0 {
t.Fatalf("said %v for a have with no want", types(*said))
}
fill(m, wantE(t, "ace", "web", delivery.Want{FP: fpNew}))
if types(*said) != "moving" {
t.Fatalf("said %q, want moving", types(*said))
}
mv := (*said)[0]
if mv.Body.Node != "ace" || mv.Body.Module != "web" || mv.Body.FromFP != fpOld || mv.Body.ToFP != fpNew || mv.Body.Composed != "sha256:"+strings.Repeat("c", 64) ||
mv.Body.Since.IsZero() || mv.Body.At.IsZero() || mv.Body.BoundSeconds != 1800 || mv.ID != "moving.ace.web."+strings.Repeat("c", 64) {
t.Errorf("moving = %+v", mv)
}
fill(m, haveFP(t, "ace", "web", fpOld, delivery.HealthOK), wantE(t, "ace", "web", delivery.Want{FP: fpNew}))
if types(*said) != "moving" {
t.Errorf("said %q after the same state again", types(*said))
}
// 1126: the bus refuses the settled once; it is said at the next change of the have, not lost.
refused := false
m.SetMotionEvent(func(e MotionEvent) error {
if !refused {
refused = true
return errors.New("nats: no responders available for request")
}
*said = append(*said, e)
return nil
})
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthOK))
if types(*said) != "moving" || !refused {
t.Errorf("said %q though the bus refused it", types(*said))
}
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthOK))
if types(*said) != "moving,settled" || (*said)[1].Body.Outcome != "running" {
t.Errorf("said %q, want settled with its outcome", types(*said))
}
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthOK))
if types(*said) != "moving,settled" {
t.Errorf("settled said twice: %q", types(*said))
}
}
func TestAModuleHeldOrFailedIsSaidStuckWithItsWords(t *testing.T) {
m := New()
said := motionLog(m)
fill(m, wantE(t, "ace", "web", delivery.Want{FP: fpNew}))
b, _ := delivery.EncodeHave(delivery.Have{FP: fpOld, Phase: delivery.PhaseHeld, Health: delivery.HealthWaits, Detail: "no space left"})
fill(m, Entry{Bucket: delivery.HaveBucket, Key: "ace.web", Value: b, At: t0})
if types(*said) != "moving,stuck" || (*said)[1].Body.Outcome != "stopped" || (*said)[1].Body.Words != "no space left" {
t.Fatalf("said %v: %+v", types(*said), *said)
}
fill(m, Entry{Bucket: delivery.HaveBucket, Key: "ace.web", Value: b, At: t0})
if types(*said) != "moving,stuck" {
t.Errorf("stuck said twice: %q", types(*said))
}
// failed on the wanted fingerprint
m2 := New()
said2 := motionLog(m2)
fill(m2, wantE(t, "g14", "web", delivery.Want{FP: fpNew}), haveFP(t, "g14", "web", fpNew, delivery.HealthFailed))
if types(*said2) != "moving,stuck" || (*said2)[1].Body.Outcome != "failed" {
t.Errorf("failed: said %q", types(*said2))
}
}
func TestAWantTheWatchmanSaysStoppedMovingIsSaidStuckPastItsBound(t *testing.T) {
m := New()
said := motionLog(m)
fill(m, wantE(t, "ace", "web", delivery.Want{FP: fpNew}), haveFP(t, "ace", "web", fpOld, delivery.HealthOK))
sk, _ := delivery.StuckKey(delivery.AssignerWantsBucket, "want.ace.web")
v, err := delivery.EncodeStuck(delivery.Stuck{Bucket: delivery.AssignerWantsBucket, Key: "want.ace.web", Waited: "the node agent of ace to run web", Since: t0})
if err != nil {
t.Fatal(err)
}
fill(m, Entry{Bucket: delivery.StuckBucket, Key: sk, Value: v, At: t0})
if types(*said) != "moving,stuck" || (*said)[1].Body.Outcome != "stopped" {
t.Errorf("said %q: %+v", types(*said), *said)
}
}
func TestANodeThatSettlesWithoutBeingSeenMovingSaysNothing(t *testing.T) {
m := New()
said := motionLog(m)
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthOK), wantE(t, "ace", "web", delivery.Want{FP: fpNew}))
if len(*said) != 0 {
t.Errorf("said %v for a module that never moved", types(*said))
}
}
// Nothing is said of a module in motion before the buckets have been read: a want read ahead of its have is not a node behind. What is
// in motion when they have been read is said then, and a module that settles after is said settled.
func TestMotionIsSaidOnlyOnceTheBucketsHaveBeenRead(t *testing.T) {
m := New()
var said []MotionEvent
m.SetMotionEvent(func(e MotionEvent) error { said = append(said, e); return nil })
fill(m, wantE(t, "ace", "web", delivery.Want{FP: fpNew}), wantE(t, "ace", "mail", delivery.Want{FP: fpNew}))
m.Initial(delivery.AssignerWantsBucket)
fill(m, haveFP(t, "ace", "web", fpNew, delivery.HealthOK), haveFP(t, "ace", "mail", fpOld, delivery.HealthOK))
m.Initial(delivery.HaveBucket)
if len(said) != 0 {
t.Fatalf("said %v before the stuck keys were read", types(said))
}
m.Initial(delivery.StuckBucket)
if len(said) != 1 || said[0].Type != MotionMoving || said[0].Body.Module != "mail" {
t.Fatalf("said %v: %+v, want only mail moving (web is already there)", types(said), said)
}
fill(m, haveFP(t, "ace", "mail", fpNew, delivery.HealthOK))
if types(said) != "moving,settled" {
t.Errorf("said %q", types(said))
}
}
// assigner_wants lists a node's own want (want.<node>.node, ADR 0539 clause 6) among the wants, and never as unreadable:
// on 2026-10-25 every node's own want was listed unreadable, though its value was what the assigner writes (issue 1945).
func TestTheWantsListANodesOwnWant(t *testing.T) {
own, err := delivery.WantNodeKey("ace")
if err != nil {
t.Fatal(err)
}
value, err := delivery.EncodeNodeWant(delivery.NodeWant{Composed: "sha256:" + strings.Repeat("d", 64)})
if err != nil {
t.Fatal(err)
}
moduleKey, _ := delivery.WantKey("ace", "web")
moduleValue, _ := delivery.EncodeWant(delivery.Want{FP: fpNew, Composed: "sha256:" + strings.Repeat("c", 64)})
state := map[string][]byte{own: value, moduleKey: moduleValue}
r := Reader{WantsState: StateRead{
Keys: func() ([]string, error) { return []string{own, moduleKey}, nil },
Get: func(k string) (json.RawMessage, error) { return state[k], nil },
}}
got, err := r.Wants("", "")
if err != nil {
t.Fatal(err)
}
if len(got.Unreadable) != 0 || got.Wants[own] == nil || got.Wants[moduleKey] == nil {
t.Fatalf("the wants: %v, unreadable %v", got.Wants, got.Unreadable)
}
if got, _ := r.Wants("g14", ""); len(got.Wants) != 0 {
t.Errorf("another node's wants: %v", got.Wants)
}
if got, _ := r.Wants("ace", "web"); got.Wants[own] != nil {
t.Errorf("the node's own want was listed under the module web")
}
}