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)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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(`<!doctype html><html><head><me
|
||||
{{if .Groups}}<h2>CANDIDATES</h2><table><tr><th>GROUP</th><th>PULL REQUEST</th><th>HEAD</th><th>MODULES</th></tr>{{range .Groups}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
||||
{{if .Asks}}<h2>ASKS</h2><table><tr><th>ASK</th><th>HEADLINE</th><th>WORDS</th><th>PULL REQUESTS</th><th>ANSWER</th></tr>{{range .Asks}}<tr>{{range .Cells}}<td>{{.}}</td>{{end}}<td>{{if .Options}}<form method="post" action="/answer"><input type="hidden" name="key" value="{{.Key}}"><input name="words" placeholder="words (optional)">{{range .Options}} <button name="option" value="{{.}}">{{.}}</button>{{end}}</form>{{end}}</td></tr>{{end}}</table>{{end}}
|
||||
{{if .Backlog}}<h2>BACKLOG OF BUILD ASKS</h2><table><tr>{{range .BacklogHead}}<th>{{.}}</th>{{end}}</tr>{{range .Backlog}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
||||
{{if .Differences}}<h2>WANTS AGAINST SENDS</h2><table><tr>{{range .DifferenceHead}}<th>{{.}}</th>{{end}}</tr>{{range .Differences}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
||||
{{if .Source}}<h2>SOURCE REVISION</h2><p>{{range .Source}}{{.}}<br>{{end}}</p>{{end}}{{if .Wants}}<h2>WANTS AGAINST HAVES</h2><table><tr>{{range .WantHead}}<th>{{.}}</th>{{end}}</tr>{{range .Wants}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
||||
{{if .Fingerprints}}<h2>FINGERPRINTS</h2><table><tr><th>MODULE</th><th>STATE</th><th>INDEXER AGAINST BUILD</th></tr>{{range .Fingerprints}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
||||
{{if .Offline}}<h2>OFFLINE</h2><table>{{range .Offline}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
||||
{{if .Stuck}}<h2>STUCK</h2><table><tr><th>BUCKET</th><th>KEY</th><th>WAITED FOR</th><th>SINCE</th></tr>{{range .Stuck}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
||||
{{if .Unreadable}}<h2>UNREADABLE</h2><table>{{range .Unreadable}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}</body></html>`))
|
||||
|
||||
var wantsPage = template.Must(template.New("w").Parse(`<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="5">
|
||||
<title>Wants against sends</title><style>body{font:14px sans-serif;margin:1em}table{border-collapse:collapse}td,th{padding:2px 10px;text-align:left}</style></head>
|
||||
<body><p><a href="/">What each node runs</a></p><h1>WANTS AGAINST SENDS</h1>
|
||||
{{if .Differences}}<table><tr>{{range .Head}}<th>{{.}}</th>{{end}}</tr>{{range .Differences}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>
|
||||
<title>Wants against haves</title><style>body{font:14px sans-serif;margin:1em}table{border-collapse:collapse}td,th{padding:2px 10px;text-align:left}</style></head>
|
||||
<body><p><a href="/">What each node runs</a></p><h1>WANTS AGAINST HAVES</h1>
|
||||
{{if .Wants}}<table><tr>{{range .Head}}<th>{{.}}</th>{{end}}</tr>{{range .Wants}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>
|
||||
{{else}}<p>no difference: every want is what the controller sends.</p>{{end}}</body></html>`))
|
||||
|
||||
// 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})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <module>.<fingerprint>
|
||||
asks map[string]askRecord // the gate's asks, by key
|
||||
diffs map[string]diffEntry // the assigner's differences, by <node>.<module>
|
||||
wants map[string]wantEntry // the assigner's wants, by <node>.<module>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user