From f048e2b443fa7c83326af2e0c8b5069706e0b5cc Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 16:23:51 +0200 Subject: [PATCH] The view shows each want beside the have of its node, and a source revision's derived state, in place of the comparison with sends (issue 1906 D7) --- .../mesh-delivery-view/integration_test.go | 20 +- .../cmd/mesh-delivery-view/main.go | 4 +- .../internal/view/differences_test.go | 103 ---------- .../mesh-delivery-view/internal/view/serve.go | 65 +++--- .../mesh-delivery-view/internal/view/view.go | 192 ++++++++++++++---- .../internal/view/wants_test.go | 133 ++++++++++++ modules/mesh-delivery-view/manifest_test.go | 2 +- modules/mesh-delivery-view/module.json | 1 + 8 files changed, 337 insertions(+), 183 deletions(-) delete mode 100644 modules/mesh-delivery-view/internal/view/differences_test.go create mode 100644 modules/mesh-delivery-view/internal/view/wants_test.go diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go index 336b33a..75d63dc 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go @@ -433,17 +433,17 @@ func TestProcessWholeListsEveryDifference(t *testing.T) { // 1297/B8 AC go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }() t.Cleanup(func() { stop(); <-done }) - kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.AssignerDifferencesBucket, History: delivery.AssignerDifferencesHistory}) + kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.AssignerWantsBucket, History: delivery.AssignerWantsHistory}) if err != nil { t.Fatal(err) } const fpOld = "src2:1111111111111111111111111111111111111111111111111111111111111111" const fpNew = "src2:2222222222222222222222222222222222222222222222222222222222222222" - for key, d := range map[string]delivery.Difference{ - "ace.plex": {Want: fpOld, Sent: fpNew, Rule: delivery.RuleCanary, Words: "ace is not the canary"}, - "g14.plex": {Want: fpOld, Sent: fpNew, Unexplained: true}, + for key, d := range map[string]delivery.Want{ + "want.ace.plex": {FP: fpNew}, + "want.g14.plex": {FP: fpOld, Newest: fpNew, Rule: delivery.RuleCanary, Words: "the canary ace does not run it healthy yet"}, } { - v, err := delivery.EncodeDifference(d) + v, err := delivery.EncodeWant(d) if err != nil { t.Fatal(err) } @@ -465,13 +465,13 @@ func TestProcessWholeListsEveryDifference(t *testing.T) { // 1297/B8 AC t.Fatalf("%s; last: %+v", what, last) } wait(func(s view.Snapshot) bool { - d := s.Differences - return len(d) == 2 && d[0].Node == "ace" && d[0].Rule == delivery.RuleCanary && d[1].Node == "g14" && d[1].Unexplained - }, "the two differences never showed with their rules") - if err := kv.Delete(ctx, "g14.plex"); err != nil { + d := s.Wants + return len(d) == 2 && d[0].Node == "ace" && d[0].State == view.WantPending && d[1].Node == "g14" && d[1].State == view.WantHeldBack && d[1].Rule == delivery.RuleCanary + }, "the two wants never showed with where they stand") + if err := kv.Delete(ctx, "want.g14.plex"); err != nil { t.Fatal(err) } - wait(func(s view.Snapshot) bool { return len(s.Differences) == 1 }, "the difference taken away never left the list") + wait(func(s view.Snapshot) bool { return len(s.Wants) == 1 }, "the want taken away never left the list") } // TestProcessWholeWritesTheAnswerToAnAskNeedingNoProof starts the program whole, with the assigner's asks, the view's diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go index 8b190eb..86a4fc7 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go @@ -139,7 +139,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro } model.SetWriter(kvWriter{ctx: ctx, js: js}) model.SetBacklog(view.BacklogOver(js, 5*time.Second)) - for _, bucket := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket, delivery.PresenceBucket, delivery.StuckBucket, delivery.CandidatesBucket, delivery.ModulesBucket, delivery.AsksBucket, delivery.AnswersBucket, delivery.BuilderBuildsBucket, delivery.AssignerDifferencesBucket, delivery.AssignerAsksBucket, delivery.ViewAnswersBucket} { + for _, bucket := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket, delivery.PresenceBucket, delivery.StuckBucket, delivery.CandidatesBucket, delivery.ModulesBucket, delivery.AsksBucket, delivery.AnswersBucket, delivery.BuilderBuildsBucket, delivery.AssignerWantsBucket, delivery.SourceBucket, delivery.AssignerAsksBucket, delivery.ViewAnswersBucket} { go follow(ctx, js, bucket, model, logger) } select { @@ -207,7 +207,7 @@ func watchOnce(ctx context.Context, js jetstream.JetStream, bucket string, model continue } model.Apply(view.Entry{Bucket: bucket, Key: e.Key(), Value: e.Value(), At: e.Created(), - Deleted: e.Operation() != jetstream.KeyValuePut}) + Deleted: e.Operation() != jetstream.KeyValuePut, Revision: e.Revision()}) } } } diff --git a/modules/mesh-delivery-view/internal/view/differences_test.go b/modules/mesh-delivery-view/internal/view/differences_test.go deleted file mode 100644 index b8fa993..0000000 --- a/modules/mesh-delivery-view/internal/view/differences_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package view - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -const ( - fpOld = "src2:1111111111111111111111111111111111111111111111111111111111111111" - fpNew = "src2:2222222222222222222222222222222222222222222222222222222222222222" -) - -// diffEntry is a key of the assigner's differences as the assigner writes it (mesh-sdk go/delivery EncodeDifference). -func differenceE(t *testing.T, node, module string, d delivery.Difference) Entry { - t.Helper() - key, err := delivery.DifferenceKey(node, module) - if err != nil { - t.Fatal(err) - } - v, err := delivery.EncodeDifference(d) - if err != nil { - t.Fatal(err) - } - return Entry{Bucket: delivery.AssignerDifferencesBucket, Key: key, Value: v, At: t0} -} - -func TestEveryDifferenceIsListedWithItsRule(t *testing.T) { // 1297/B8 AC - m := New() - fill(m, - differenceE(t, "ace", "plex", delivery.Difference{Want: fpOld, Sent: fpNew, Rule: delivery.RuleCanary, Words: "ace is not the canary"}), - differenceE(t, "g14", "plex", delivery.Difference{Want: fpOld, Sent: fpNew, Unexplained: true}), - differenceE(t, "ace", "old", delivery.Difference{WantRemoved: true, Sent: fpOld, Rule: delivery.RuleNotHeld}), - ) - got := m.Snapshot().Differences - if len(got) != 3 { - t.Fatalf("differences = %+v, want all three", got) - } - // ordered by node then module - if got[0].Node != "ace" || got[0].Module != "old" || got[1].Module != "plex" || got[2].Node != "g14" { - t.Errorf("order = %+v", got) - } - if r := got[1]; r.Want != fpOld || r.Sent != fpNew || r.Rule != delivery.RuleCanary || r.Unexplained || r.Words != "ace is not the canary" { - t.Errorf("row = %+v", r) - } - if !got[0].WantRemoved || got[0].Rule != delivery.RuleNotHeld { - t.Errorf("removed row = %+v", got[0]) - } - if !got[2].Unexplained || got[2].Rule != 0 { - t.Errorf("unexplained row = %+v", got[2]) - } - // a difference taken away (the want and the send agree again) leaves the list - fill(m, Entry{Bucket: delivery.AssignerDifferencesBucket, Key: "g14.plex", Deleted: true}) - if got := m.Snapshot().Differences; len(got) != 2 { - t.Errorf("after the delete = %+v", got) - } -} - -func TestAnUnreadableDifferenceIsNamedAndNotDropped(t *testing.T) { // never dropped silently - m := New() - fill(m, Entry{Bucket: delivery.AssignerDifferencesBucket, Key: "ace.plex", Value: []byte(`{"v":99}`), At: t0}) - s := m.Snapshot() - if len(s.Differences) != 0 || len(s.Unreadable) != 1 || s.Unreadable[0].Bucket != delivery.AssignerDifferencesBucket || s.Unreadable[0].Key != "ace.plex" { - t.Errorf("snapshot = %+v", s) - } -} - -func TestTableAndPageShowTheWantsAgainstTheSends(t *testing.T) { // 1297/B8 AC - m := New() - fill(m, - differenceE(t, "ace", "plex", delivery.Difference{Want: fpOld, Sent: fpNew, Rule: delivery.RuleCanary, Words: "ace is not the canary"}), - differenceE(t, "g14", "plex", delivery.Difference{Want: fpOld, Sent: fpNew, Unexplained: true}), - ) - var out strings.Builder - Table(&out, m.Snapshot()) - for _, w := range []string{"WANTS AGAINST SENDS", "ace", "plex", fpOld, fpNew, "rule 3", "ace is not the canary", "unexplained"} { - if !strings.Contains(out.String(), w) { - t.Errorf("table lacks %q:\n%s", w, out.String()) - } - } - for _, path := range []string{"/", "/wants"} { - rec := httptest.NewRecorder() - Web(m).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) - body := rec.Body.String() - if rec.Code != 200 { - t.Fatalf("%s = %d", path, rec.Code) - } - for _, w := range []string{"WANTS AGAINST SENDS", "rule 3", "unexplained", "ace is not the canary"} { - if !strings.Contains(body, w) { - t.Errorf("%s lacks %q:\n%s", path, w, body) - } - } - } - // With none, the page says so and does not hide the heading. - rec := httptest.NewRecorder() - Web(New()).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/wants", nil)) - if !strings.Contains(rec.Body.String(), "no difference") { - t.Errorf("empty page = %q", rec.Body.String()) - } -} diff --git a/modules/mesh-delivery-view/internal/view/serve.go b/modules/mesh-delivery-view/internal/view/serve.go index f8f6685..d5e5143 100644 --- a/modules/mesh-delivery-view/internal/view/serve.go +++ b/modules/mesh-delivery-view/internal/view/serve.go @@ -209,33 +209,36 @@ func askText(s Snapshot) [][]string { return out } -// differenceText is one line per difference: the node, the module, what the assigner wants, what the controller sends, the rule -// that explains it (or unexplained) and the assigner's words. -func differenceText(s Snapshot) [][]string { +// wantText is one line per want: the node, the module, what the assigner wants, what the node runs, where the two stand and the +// assigner's words (the rule that holds a node back, when one does). +func wantText(s Snapshot) [][]string { var out [][]string - for _, d := range s.Differences { - want, sent := d.Want, d.Sent - switch { - case d.WantRemoved: - want = "removed" - case want == "": + for _, w := range s.Wants { + want, have := w.Want, w.Have + if want == "" { want = "none" } - if sent == "" { - sent = "none" - if d.SentBuild != "" { - sent = "build " + d.SentBuild - } + if have == "" { + have = "none" } - why := "unexplained" - if !d.Unexplained { - why = fmt.Sprintf("rule %d", d.Rule) + words := w.Words + if w.Rule != 0 { + words = fmt.Sprintf("rule %d: %s", w.Rule, w.Words) } - out = append(out, []string{d.Node, d.Module, want, sent, why, d.Words}) + out = append(out, []string{w.Node, w.Module, want, have, w.State, words}) } return out } +// sourceText is the source revision's derived state in one line, then one line for each module on a node it waits for. +func sourceText(s Snapshot) []string { + if s.Source == nil { + return nil + } + out := []string{fmt.Sprintf("source revision %d: %s", s.Source.Revision, s.Source.State)} + return append(out, s.Source.Waiting...) +} + // backlogText is one line per language and kind: its waiting asks, the asks held by a node, the oldest waiting ask's age. func backlogText(s Snapshot) [][]string { var out [][]string @@ -254,7 +257,7 @@ func backlogText(s Snapshot) [][]string { var backlogHead = []string{"LANGUAGE", "KIND", "WAITING", "HELD", "OLDEST WAITING"} -var differenceHead = []string{"NODE", "MODULE", "WANT", "SENT", "RULE", "WORDS"} +var wantHead = []string{"NODE", "MODULE", "WANT", "HAVE", "STATE", "WORDS"} var head = []string{"NODE", "MODULE", "ASSIGNED", "HAVE", "PHASE", "HEALTH", "SINCE", "DETAIL", "WAITS"} @@ -303,10 +306,16 @@ func Table(w io.Writer, s Snapshot) { row(cells) } } - if d := differenceText(s); len(d) > 0 { + if src := sourceText(s); len(src) > 0 { fmt.Fprintln(tw) - row([]string{"WANTS AGAINST SENDS"}) - row(differenceHead) + for _, line := range src { + row([]string{line}) + } + } + if d := wantText(s); len(d) > 0 { + fmt.Fprintln(tw) + row([]string{"WANTS AGAINST HAVES"}) + row(wantHead) for _, cells := range d { row(cells) } @@ -356,16 +365,16 @@ var page = template.Must(template.New("p").Parse(`CANDIDATES{{range .Groups}}{{range .}}{{end}}{{end}}
GROUPPULL REQUESTHEADMODULES
{{.}}
{{end}} {{if .Asks}}

ASKS

{{range .Asks}}{{range .Cells}}{{end}}{{end}}
ASKHEADLINEWORDSPULL REQUESTSANSWER
{{.}}{{if .Options}}
{{range .Options}} {{end}}
{{end}}
{{end}} {{if .Backlog}}

BACKLOG OF BUILD ASKS

{{range .BacklogHead}}{{end}}{{range .Backlog}}{{range .}}{{end}}{{end}}
{{.}}
{{.}}
{{end}} -{{if .Differences}}

WANTS AGAINST SENDS

{{range .DifferenceHead}}{{end}}{{range .Differences}}{{range .}}{{end}}{{end}}
{{.}}
{{.}}
{{end}} +{{if .Source}}

SOURCE REVISION

{{range .Source}}{{.}}
{{end}}

{{end}}{{if .Wants}}

WANTS AGAINST HAVES

{{range .WantHead}}{{end}}{{range .Wants}}{{range .}}{{end}}{{end}}
{{.}}
{{.}}
{{end}} {{if .Fingerprints}}

FINGERPRINTS

{{range .Fingerprints}}{{range .}}{{end}}{{end}}
MODULESTATEINDEXER AGAINST BUILD
{{.}}
{{end}} {{if .Offline}}

OFFLINE

{{range .Offline}}{{range .}}{{end}}{{end}}
{{.}}
{{end}} {{if .Stuck}}

STUCK

{{range .Stuck}}{{range .}}{{end}}{{end}}
BUCKETKEYWAITED FORSINCE
{{.}}
{{end}} {{if .Unreadable}}

UNREADABLE

{{range .Unreadable}}{{range .}}{{end}}{{end}}
{{.}}
{{end}}`)) var wantsPage = template.Must(template.New("w").Parse(` -Wants against sends -

What each node runs

WANTS AGAINST SENDS

-{{if .Differences}}{{range .Head}}{{end}}{{range .Differences}}{{range .}}{{end}}{{end}}
{{.}}
{{.}}
+Wants against haves +

What each node runs

WANTS AGAINST HAVES

+{{if .Wants}}{{range .Head}}{{end}}{{range .Wants}}{{range .}}{{end}}{{end}}
{{.}}
{{.}}
{{else}}

no difference: every want is what the controller sends.

{{end}}`)) // Web is the read-only web view of the same rows, refreshed by the browser. @@ -387,7 +396,7 @@ func Web(m *Model) http.Handler { } if r.URL.Path == "/wants" { w.Header().Set("Content-Type", "text/html; charset=utf-8") - wantsPage.Execute(w, map[string]any{"Head": differenceHead, "Differences": differenceText(m.Snapshot())}) + wantsPage.Execute(w, map[string]any{"Head": wantHead, "Wants": wantText(m.Snapshot())}) return } if r.URL.Path != "/" { @@ -410,7 +419,7 @@ func Web(m *Model) http.Handler { for _, u := range snap.Unreadable { bad = append(bad, []string{u.Bucket, u.Key, u.Error}) } - page.Execute(w, map[string]any{"Head": head, "Rows": rows, "Groups": groupText(snap), "Asks": askPage(snap), "Fingerprints": fingerprintText(snap), "Differences": differenceText(snap), "Backlog": backlogText(snap), "BacklogHead": backlogHead, "DifferenceHead": differenceHead, "Offline": off, "Stuck": stuck, "Unreadable": bad}) + page.Execute(w, map[string]any{"Head": head, "Rows": rows, "Groups": groupText(snap), "Asks": askPage(snap), "Fingerprints": fingerprintText(snap), "Wants": wantText(snap), "Source": sourceText(snap), "Backlog": backlogText(snap), "BacklogHead": backlogHead, "WantHead": wantHead, "Offline": off, "Stuck": stuck, "Unreadable": bad}) }) } diff --git a/modules/mesh-delivery-view/internal/view/view.go b/modules/mesh-delivery-view/internal/view/view.go index dfc0db2..0af41f2 100644 --- a/modules/mesh-delivery-view/internal/view/view.go +++ b/modules/mesh-delivery-view/internal/view/view.go @@ -21,6 +21,8 @@ type Entry struct { Value []byte At time.Time Deleted bool + // Revision is the bucket revision of the change, when the watch gives one (the source revision is the gate's source key's). + Revision uint64 } // Row is one module on one node. @@ -66,27 +68,53 @@ type Snapshot struct { Fingerprints []FingerprintRow `json:"fingerprints"` // Asks is every ask the gate holds that has no answer yet: what waits for the operator's Approve. Asks []AskRow `json:"asks"` - // Differences is every difference between what the assigner wants a node to run and what the controller sends, with the rule that - // explains it or unexplained (novox/hq ADR 0528 clause 4, issue 1297 B8). - Differences []DifferenceRow `json:"differences"` + // Wants is every want of the assigner beside the have of its node and where it stands (novox/hq ADR 0539 §8, issue 1906 D7). + Wants []WantRow `json:"wants"` + // Source is the state of the mesh's source revision, derived from the wants and the haves (ADR 0429 §1): recorded by no part. + // Nil when the gate holds no source yet. + Source *SourceRow `json:"source,omitempty"` // Backlog is, for each language and kind of build ask with one waiting or held, the asks waiting for a node, the asks a node // holds and the oldest waiting ask's age (issue 1887 A8a). Backlog []BacklogRow `json:"backlog"` } -// DifferenceRow is a module on a node whose want and send differ: the fingerprint wanted (empty when the node has no want, -// WantRemoved when the want is removed), the fingerprint of the build the controller's assignment names (empty when it sends -// none; SentBuild names that build), and the rule that explains it (Rule 1 to 6) or Unexplained. -type DifferenceRow struct { - Node string `json:"node"` - Module string `json:"module"` - Want string `json:"want,omitempty"` - WantRemoved bool `json:"want-removed,omitempty"` - Sent string `json:"sent,omitempty"` - SentBuild string `json:"sent-build,omitempty"` - Rule int `json:"rule,omitempty"` - Unexplained bool `json:"unexplained,omitempty"` - Words string `json:"words,omitempty"` +// The states of a want against the have of its node. +const ( + WantApplied = "applied" // the node runs the want's fingerprint, running and healthy + WantPending = "pending" // the node has not yet run the want's fingerprint, or not healthy yet + WantFailed = "failed" // the node runs the want's fingerprint and its health check failed + WantHeldBack = "held back" // the assigner holds the node back from the newest fingerprint (Rule), or set it back + WantRemoved = "removed" // the node's want is removed: the module is no longer assigned to it +) + +// WantRow is a want of the assigner beside the have of its node: the fingerprint wanted, the one the node runs with its phase and +// health, the state of the two, and for a want held back the newest fingerprint, the rule and the assigner's words. +type WantRow struct { + Node string `json:"node"` + Module string `json:"module"` + Want string `json:"want,omitempty"` + Have string `json:"have,omitempty"` + Phase string `json:"phase,omitempty"` + Health string `json:"health,omitempty"` + State string `json:"state"` + Newest string `json:"newest,omitempty"` + Rule int `json:"rule,omitempty"` + Words string `json:"words,omitempty"` +} + +// The states of a source revision (ADR 0429 §1). +const ( + SourceDelivered = "delivered" + SourceNotDelivered = "not delivered" +) + +// SourceRow is the mesh's source revision (the revision of the gate's source key) and whether it is delivered: every want equals its +// module's fingerprint at the indexer and every have equals its want, running and healthy, and every node a module is assigned to +// has a want. Waiting names each module on a node that is not there yet, and why. +type SourceRow struct { + Revision uint64 `json:"revision"` + State string `json:"state"` + Waiting []string `json:"waiting,omitempty"` } // AskRow is an open ask (novox/hq ADR 0437 §10): the gate's ask with no answer under its key in the teller's answers. @@ -185,7 +213,8 @@ type Model struct { modules map[string]moduleEntry // the indexer's modules, by module keys map[string]keyEntry // the builder's build keys, by . asks map[string]askRecord // the gate's asks, by key - diffs map[string]diffEntry // the assigner's differences, by . + wants map[string]wantEntry // the assigner's wants, by . + source *sourceEntry // the gate's source key aasks map[string]assignerAskRecord // the assigner's asks (hold, restore), by key chosen map[string]delivery.ViewAnswer // the answers the view wrote, by ask key writer Writer @@ -256,11 +285,16 @@ type keyEntry struct { err string } -type diffEntry struct { - value delivery.Difference +type wantEntry struct { + value delivery.Want err string } +type sourceEntry struct { + revision uint64 + err string +} + type assignerAskRecord struct { ask asks.Ask err string @@ -290,7 +324,7 @@ type nodeEntry struct { // New is an empty model. func New() *Model { - return &Model{assigns: map[string]assignment{}, builds: map[string]buildEntry{}, groups: map[string]groupEntry{}, modules: map[string]moduleEntry{}, keys: map[string]keyEntry{}, asks: map[string]askRecord{}, diffs: map[string]diffEntry{}, aasks: map[string]assignerAskRecord{}, chosen: map[string]delivery.ViewAnswer{}, answers: map[string]string{}, newest: map[string]string{}, + return &Model{assigns: map[string]assignment{}, builds: map[string]buildEntry{}, groups: map[string]groupEntry{}, modules: map[string]moduleEntry{}, keys: map[string]keyEntry{}, asks: map[string]askRecord{}, wants: map[string]wantEntry{}, aasks: map[string]assignerAskRecord{}, chosen: map[string]delivery.ViewAnswer{}, answers: map[string]string{}, newest: map[string]string{}, haves: map[string]haveEntry{}, nodes: map[string]nodeEntry{}, presence: map[string]presenceEntry{}, stuck: map[string]stuckEntry{}} } @@ -385,13 +419,28 @@ func (m *Model) Apply(e Entry) { if a, err := delivery.DecodeViewAnswer(e.Value); err == nil { m.chosen[askKey] = a } - case delivery.AssignerDifferencesBucket: + case delivery.AssignerWantsBucket: + node, module, err := delivery.ParseWantKey(e.Key) + if err != nil { + return // a hold, and the node's own key, are not a module's want + } + key := node + "." + module if e.Deleted { - delete(m.diffs, e.Key) + delete(m.wants, key) return } - d, err := delivery.DecodeDifference(e.Value) - m.diffs[e.Key] = diffEntry{value: d, err: errText(err)} + w, err := delivery.DecodeWant(e.Value) + m.wants[key] = wantEntry{value: w, err: errText(err)} + case delivery.SourceBucket: + if e.Key != delivery.SourceMainKey { + return + } + if e.Deleted { + m.source = nil + return + } + _, err := delivery.DecodeSource(e.Value) + m.source = &sourceEntry{revision: e.Revision, err: errText(err)} case delivery.AnswersBucket: if e.Deleted { delete(m.answers, e.Key) @@ -585,9 +634,9 @@ func (m *Model) snapshot() Snapshot { unreadable = append(unreadable, UnreadableRow{Bucket: delivery.AssignerAsksBucket, Key: key, Error: a.err}) } } - for key, d := range m.diffs { - if d.err != "" { - unreadable = append(unreadable, UnreadableRow{Bucket: delivery.AssignerDifferencesBucket, Key: key, Error: d.err}) + for key, w := range m.wants { + if w.err != "" { + unreadable = append(unreadable, UnreadableRow{Bucket: delivery.AssignerWantsBucket, Key: key, Error: w.err}) } } sort.Slice(offline, func(i, j int) bool { @@ -601,7 +650,7 @@ func (m *Model) snapshot() Snapshot { return unreadable[i].Bucket+"."+unreadable[i].Key < unreadable[j].Bucket+"."+unreadable[j].Key }) return Snapshot{Rows: rows, Nodes: nodes, Offline: offline, Stuck: stuck, Unreadable: unreadable, - Groups: m.groupRows(), Fingerprints: m.fingerprintRows(), Asks: m.askRows(), Differences: m.differenceRows()} + Groups: m.groupRows(), Fingerprints: m.fingerprintRows(), Asks: m.askRows(), Wants: m.wantRows(), Source: m.sourceRow()} } // groupRows are the readable candidate groups, ordered by group, their pull requests by repository and number. @@ -840,20 +889,33 @@ func (m *Model) Answer(askKey, option, words string) error { return w.Put(key, body) } -// differenceRows are the readable differences, ordered by node and module. -func (m *Model) differenceRows() []DifferenceRow { - out := []DifferenceRow{} - for key, d := range m.diffs { - if d.err != "" { +// wantRows are the readable wants beside the haves of their nodes, ordered by node and module. +func (m *Model) wantRows() []WantRow { + out := []WantRow{} + for key, w := range m.wants { + if w.err != "" { continue } - node, module, err := delivery.ParseDifferenceKey(key) - if err != nil { - continue + node, module, _ := strings.Cut(key, ".") + v := w.value + row := WantRow{Node: node, Module: module, Want: v.FP, Newest: v.Newest, Rule: v.Rule, Words: v.Words} + h, has := m.haves[key] + if has && h.err == "" { + row.Have, row.Phase, row.Health = h.have.FP, string(h.have.Phase), string(h.have.Health) } - v := d.value - out = append(out, DifferenceRow{Node: node, Module: module, Want: v.Want, WantRemoved: v.WantRemoved, Sent: v.Sent, SentBuild: v.SentBuild, - Rule: v.Rule, Unexplained: v.Unexplained, Words: v.Words}) + switch { + case v.Removed: + row.State = WantRemoved + case v.Rule != 0 || v.Newest != "": + row.State = WantHeldBack + case row.Have == v.FP && h.have.Health == delivery.HealthFailed: + row.State = WantFailed + case row.Have == v.FP && h.have.Phase == delivery.PhaseRunning && h.have.Health == delivery.HealthOK: + row.State = WantApplied + default: + row.State = WantPending + } + out = append(out, row) } sort.Slice(out, func(i, j int) bool { if out[i].Node != out[j].Node { @@ -863,3 +925,55 @@ func (m *Model) differenceRows() []DifferenceRow { }) return out } + +// sourceRow derives the state of the source revision the gate holds (ADR 0429 §1; recorded by no part): delivered when every want +// equals its module's fingerprint at the indexer and every have equals its want, running and healthy, and every node a module is +// assigned to has a want. It is nil when the gate holds no readable source. +func (m *Model) sourceRow() *SourceRow { + if m.source == nil || m.source.err != "" { + return nil + } + waiting := []string{} + short := func(fp string) string { + fp = strings.TrimPrefix(fp, delivery.SourcePrefix2) + if len(fp) > 12 { + fp = fp[:12] + } + return fp + } + for key, w := range m.wants { + node, module, _ := strings.Cut(key, ".") + mod, ok := m.modules[module] + if w.err != "" || w.value.Removed || !ok || mod.err != "" || mod.value.FP2 == "" { + continue + } + h := m.haves[key] + switch { + case w.value.FP != mod.value.FP2: + waiting = append(waiting, fmt.Sprintf("%s on %s wants %s, not the module's %s", module, node, short(w.value.FP), short(mod.value.FP2))) + case h.err != "" || h.have.FP != w.value.FP: + waiting = append(waiting, fmt.Sprintf("%s on %s does not yet run %s", module, node, short(w.value.FP))) + case h.have.Health == delivery.HealthFailed: + waiting = append(waiting, fmt.Sprintf("%s on %s runs %s and its health check failed", module, node, short(w.value.FP))) + case h.have.Phase != delivery.PhaseRunning || h.have.Health != delivery.HealthOK: + waiting = append(waiting, fmt.Sprintf("%s on %s runs %s, not yet running and healthy", module, node, short(w.value.FP))) + } + } + for key, a := range m.assigns { + if !a.open() { + continue + } + if _, ok := m.wants[key]; ok { + continue + } + if mod, ok := m.modules[a.Module]; ok && mod.err == "" && mod.value.FP2 != "" { + waiting = append(waiting, fmt.Sprintf("%s on %s has no want yet", a.Module, a.Node)) + } + } + sort.Strings(waiting) + row := &SourceRow{Revision: m.source.revision, State: SourceDelivered} + if len(waiting) > 0 { + row.State, row.Waiting = SourceNotDelivered, waiting + } + return row +} diff --git a/modules/mesh-delivery-view/internal/view/wants_test.go b/modules/mesh-delivery-view/internal/view/wants_test.go new file mode 100644 index 0000000..f8cf9ed --- /dev/null +++ b/modules/mesh-delivery-view/internal/view/wants_test.go @@ -0,0 +1,133 @@ +package view + +import ( + "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) + } + 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) + } + return Entry{Bucket: delivery.HaveBucket, Key: node + "." + module, 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 { + t.Errorf("text = %v", text) + } +} diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index 10421c3..0344708 100644 --- a/modules/mesh-delivery-view/manifest_test.go +++ b/modules/mesh-delivery-view/manifest_test.go @@ -83,7 +83,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 if m.Module != "mesh-delivery-view" { t.Errorf("module = %q", m.Module) } - want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", "mesh-delivery-assigner.wants", "mesh-delivery-builder.builds", "mesh-delivery-gate.asks", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 + want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", "mesh-delivery-assigner.wants", "mesh-delivery-builder.builds", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 got := slices.Clone(m.Reads) slices.Sort(got) if !reflect.DeepEqual(got, want) { diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json index f978e28..b0d9e65 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -26,6 +26,7 @@ "mesh-delivery-assigner.wants", "mesh-delivery-builder.builds", "mesh-delivery-gate.asks", + "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers",