Show what the watchman says: offline presence, stuck keys and unreadable values, in the view and its command (issue 1168)
mesh/repo-check head 954728b39042 merged into trunk 8ae804fe6450 beside mesh-controller#388@ac79e293 — pass: its merge-check.sh passed
mesh/merge-gate head 954728b39042 merged into trunk 8ae804fe6450 — pass: group feat/delivery-watch composed: every machine composes with the change as it d…
mesh/delivery delivered
mesh/delivery-group group feat/delivery-watch delivered: every member is delivered
mesh/repo-check head 954728b39042 merged into trunk 8ae804fe6450 beside mesh-controller#388@ac79e293 — pass: its merge-check.sh passed
mesh/merge-gate head 954728b39042 merged into trunk 8ae804fe6450 — pass: group feat/delivery-watch composed: every machine composes with the change as it d…
mesh/delivery delivered
mesh/delivery-group group feat/delivery-watch delivered: every member is delivered
This commit is contained in:
@@ -45,7 +45,7 @@ func TestProcessWholeSeesTheBuckets(t *testing.T) { // 1134/AC8
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
kvs := map[string]jetstream.KeyValue{}
|
kvs := map[string]jetstream.KeyValue{}
|
||||||
for _, b := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket} {
|
for _, b := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket, delivery.PresenceBucket, delivery.StuckBucket} {
|
||||||
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b, History: 5})
|
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b, History: 5})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -122,3 +122,89 @@ func TestProcessWholeSeesTheBuckets(t *testing.T) { // 1134/AC8
|
|||||||
put(delivery.HaveBucket, "a.x", haveValue("b1", delivery.PhaseRunning, delivery.HealthOK))
|
put(delivery.HaveBucket, "a.x", haveValue("b1", delivery.PhaseRunning, delivery.HealthOK))
|
||||||
rowsMatch(func(s view.Snapshot) bool { r := byKey(s, "a", "x"); return r.Phase == "running" && r.Health == "ok" })
|
rowsMatch(func(s view.Snapshot) bool { r := byKey(s, "a", "x"); return r.Phase == "running" && r.Health == "ok" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProcessWholeShowsWhatTheWatchmanSays starts the program whole and writes an offline presence and a stuck key to the
|
||||||
|
// watchman's buckets, sees both shown, then writes them online and moved and sees both gone.
|
||||||
|
func TestProcessWholeShowsWhatTheWatchmanSays(t *testing.T) { // 1168/AC5
|
||||||
|
srv, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: -1, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
go srv.Start()
|
||||||
|
if !srv.ReadyForConnections(10 * time.Second) {
|
||||||
|
t.Fatal("the bus did not come up")
|
||||||
|
}
|
||||||
|
t.Cleanup(srv.Shutdown)
|
||||||
|
nc, err := nats.Connect(srv.ClientURL())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(nc.Close)
|
||||||
|
js, err := jetstream.New(nc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
kvs := map[string]jetstream.KeyValue{}
|
||||||
|
for _, b := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket, delivery.PresenceBucket, delivery.StuckBucket} {
|
||||||
|
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b, History: 5})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
kvs[b] = kv
|
||||||
|
}
|
||||||
|
presence := func(key string, online bool) {
|
||||||
|
t.Helper()
|
||||||
|
b, _ := delivery.EncodePresence(delivery.Presence{Online: online})
|
||||||
|
if _, err := kvs[delivery.PresenceBucket].Put(ctx, key, b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stuck := func(moved bool) {
|
||||||
|
t.Helper()
|
||||||
|
b, _ := delivery.EncodeStuck(delivery.Stuck{Bucket: delivery.HaveBucket, Key: "a.x", Waited: "a report from a", Since: time.Now(), Moved: moved})
|
||||||
|
if _, err := kvs[delivery.StuckBucket].Put(ctx, delivery.HaveBucket+".a.x", b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
presence("a", false)
|
||||||
|
presence("part.mesh-delivery-view.i1", false)
|
||||||
|
stuck(false)
|
||||||
|
|
||||||
|
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "i")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||||
|
broker := filepath.Join(dir, "broker")
|
||||||
|
cred, _ := json.Marshal(credential{URL: srv.ClientURL(), Node: "n", Module: "mesh-delivery-view", User: "mesh-delivery-view", Password: "x"})
|
||||||
|
if err := os.WriteFile(broker, cred, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
socket := filepath.Join(dir, "run", "cli.sock")
|
||||||
|
env := map[string]string{"MESH_BROKER_FILE": broker, "MESH_DELIVERY_SOCKET": socket, "MESH_DELIVERY_WEB": "127.0.0.1:0"}
|
||||||
|
runCtx, stop := context.WithCancel(ctx)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
|
||||||
|
t.Cleanup(func() { stop(); <-done })
|
||||||
|
|
||||||
|
wait := func(check func(view.Snapshot) bool) {
|
||||||
|
t.Helper()
|
||||||
|
var last view.Snapshot
|
||||||
|
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) {
|
||||||
|
if s, err := view.Ask(socket); err == nil {
|
||||||
|
last = s
|
||||||
|
if check(s) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("the socket never showed the expected lists; last: %+v", last)
|
||||||
|
}
|
||||||
|
wait(func(s view.Snapshot) bool { return len(s.Offline) == 2 && len(s.Stuck) == 1 && s.Stuck[0].Key == "a.x" })
|
||||||
|
presence("a", true)
|
||||||
|
presence("part.mesh-delivery-view.i1", true)
|
||||||
|
stuck(true)
|
||||||
|
wait(func(s view.Snapshot) bool { return len(s.Offline) == 0 && len(s.Stuck) == 0 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, bucket := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket} {
|
for _, bucket := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket, delivery.PresenceBucket, delivery.StuckBucket} {
|
||||||
go follow(ctx, js, bucket, model, logger)
|
go follow(ctx, js, bucket, model, logger)
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -143,13 +143,44 @@ func Table(w io.Writer, s Snapshot) {
|
|||||||
row([]string{n.Node, fmt.Sprint(n.Windows), fmt.Sprint(n.Busy), fmt.Sprint(n.Strays), since(n.Since)})
|
row([]string{n.Node, fmt.Sprint(n.Windows), fmt.Sprint(n.Busy), fmt.Sprint(n.Strays), since(n.Since)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(s.Offline) > 0 {
|
||||||
|
fmt.Fprintln(tw)
|
||||||
|
row([]string{"OFFLINE", "NAME", "STATE", "SINCE"})
|
||||||
|
for _, o := range s.Offline {
|
||||||
|
row([]string{o.Kind, o.name(), "offline", since(o.Since)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(s.Stuck) > 0 {
|
||||||
|
fmt.Fprintln(tw)
|
||||||
|
row([]string{"STUCK", "KEY", "WAITED FOR", "SINCE"})
|
||||||
|
for _, k := range s.Stuck {
|
||||||
|
row([]string{k.Bucket, k.Key, k.Waited, since(k.Since)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(s.Unreadable) > 0 {
|
||||||
|
fmt.Fprintln(tw)
|
||||||
|
row([]string{"UNREADABLE", "KEY", "WHY"})
|
||||||
|
for _, u := range s.Unreadable {
|
||||||
|
row([]string{u.Bucket, u.Key, u.Error})
|
||||||
|
}
|
||||||
|
}
|
||||||
tw.Flush()
|
tw.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o OfflineRow) name() string {
|
||||||
|
if o.Kind == "part" {
|
||||||
|
return o.Part + "." + o.Instance
|
||||||
|
}
|
||||||
|
return o.Node
|
||||||
|
}
|
||||||
|
|
||||||
var page = template.Must(template.New("p").Parse(`<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="5">
|
var page = template.Must(template.New("p").Parse(`<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="5">
|
||||||
<title>Delivery</title><style>body{font:14px sans-serif;margin:1em}table{border-collapse:collapse}td,th{padding:2px 10px;text-align:left}</style></head>
|
<title>Delivery</title><style>body{font:14px sans-serif;margin:1em}table{border-collapse:collapse}td,th{padding:2px 10px;text-align:left}</style></head>
|
||||||
<body><h1>What each node runs</h1><table><tr>{{range .Head}}<th>{{.}}</th>{{end}}</tr>
|
<body><h1>What each node runs</h1><table><tr>{{range .Head}}<th>{{.}}</th>{{end}}</tr>
|
||||||
{{range .Rows}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table></body></html>`))
|
{{range .Rows}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>
|
||||||
|
{{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>`))
|
||||||
|
|
||||||
// Web is the read-only web view of the same rows, refreshed by the browser.
|
// Web is the read-only web view of the same rows, refreshed by the browser.
|
||||||
func Web(m *Model) http.Handler {
|
func Web(m *Model) http.Handler {
|
||||||
@@ -168,6 +199,17 @@ func Web(m *Model) http.Handler {
|
|||||||
rows = append(rows, rowText(row))
|
rows = append(rows, rowText(row))
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
page.Execute(w, map[string]any{"Head": head, "Rows": rows})
|
snap := m.Snapshot()
|
||||||
|
var off, stuck, bad [][]string
|
||||||
|
for _, o := range snap.Offline {
|
||||||
|
off = append(off, []string{o.Kind, o.name(), "offline", since(o.Since)})
|
||||||
|
}
|
||||||
|
for _, k := range snap.Stuck {
|
||||||
|
stuck = append(stuck, []string{k.Bucket, k.Key, k.Waited, since(k.Since)})
|
||||||
|
}
|
||||||
|
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, "Offline": off, "Stuck": stuck, "Unreadable": bad})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,3 +110,44 @@ func TestWebIsTheSameRowsAndReadOnly(t *testing.T) { // 1134/AC6
|
|||||||
t.Fatalf("POST = %d, want 405", rec.Code)
|
t.Fatalf("POST = %d, want 405", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func watchmanSnapshot(t *testing.T) *Model {
|
||||||
|
t.Helper()
|
||||||
|
m := New()
|
||||||
|
fill(m, presenceE(t, "a", false, t0), stuckE(t, "node-engine_have", "a.x", "a report from a", t0, false),
|
||||||
|
Entry{Bucket: delivery.PresenceBucket, Key: "c", Value: []byte(`{"v":9}`), At: t0})
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableAndWebShowTheWatchmansLists(t *testing.T) { // 1168/AC4
|
||||||
|
m := watchmanSnapshot(t)
|
||||||
|
var out strings.Builder
|
||||||
|
Table(&out, m.Snapshot())
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
Web(m).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
for name, text := range map[string]string{"table": out.String(), "web": rec.Body.String()} {
|
||||||
|
for _, w := range []string{"OFFLINE", "STUCK", "UNREADABLE", "node-engine_have", "a.x", "a report from a", "offline", "c"} {
|
||||||
|
if !strings.Contains(text, w) {
|
||||||
|
t.Errorf("%s lacks %q:\n%s", name, w, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketCarriesTheWatchmansLists(t *testing.T) { // 1168/AC4
|
||||||
|
m := watchmanSnapshot(t)
|
||||||
|
dir, _ := os.MkdirTemp(os.Getenv("TMPDIR"), "w")
|
||||||
|
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||||
|
path := filepath.Join(dir, "cli.sock")
|
||||||
|
l, err := ListenSocket(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
go ServeSocket(ctx, l, m)
|
||||||
|
got, err := Ask(path)
|
||||||
|
if err != nil || len(got.Offline) != 1 || len(got.Stuck) != 1 || len(got.Unreadable) != 1 {
|
||||||
|
t.Fatalf("socket = %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,36 @@ type NodeRow struct {
|
|||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
Rows []Row `json:"rows"`
|
Rows []Row `json:"rows"`
|
||||||
Nodes []NodeRow `json:"nodes"`
|
Nodes []NodeRow `json:"nodes"`
|
||||||
|
// Offline is every node and part instance whose presence is offline (the watchman's word).
|
||||||
|
Offline []OfflineRow `json:"offline"`
|
||||||
|
// Stuck is every key that stopped moving and has not moved again.
|
||||||
|
Stuck []StuckRow `json:"stuck"`
|
||||||
|
// Unreadable is every watchman value the view could not read, named by its key: never dropped silently.
|
||||||
|
Unreadable []UnreadableRow `json:"unreadable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfflineRow is a node ("node") or a part's instance ("part") that is offline, since the presence entry's time.
|
||||||
|
type OfflineRow struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Node string `json:"node,omitempty"`
|
||||||
|
Part string `json:"part,omitempty"`
|
||||||
|
Instance string `json:"instance,omitempty"`
|
||||||
|
Since time.Time `json:"since,omitzero"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StuckRow is a key that stopped moving: its bucket and key, what it waited for and since when.
|
||||||
|
type StuckRow struct {
|
||||||
|
Bucket string `json:"bucket"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Waited string `json:"waited,omitempty"`
|
||||||
|
Since time.Time `json:"since,omitzero"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnreadableRow is a value of the watchman's buckets that did not decode, with the reader's error.
|
||||||
|
type UnreadableRow struct {
|
||||||
|
Bucket string `json:"bucket"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Error string `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Model holds the rows' inputs in memory and nothing else: a restart fills it again from the buckets' last values.
|
// Model holds the rows' inputs in memory and nothing else: a restart fills it again from the buckets' last values.
|
||||||
@@ -61,6 +91,20 @@ type Model struct {
|
|||||||
newest map[string]string // module -> id
|
newest map[string]string // module -> id
|
||||||
haves map[string]haveEntry
|
haves map[string]haveEntry
|
||||||
nodes map[string]nodeEntry
|
nodes map[string]nodeEntry
|
||||||
|
// the watchman's buckets, by key
|
||||||
|
presence map[string]presenceEntry
|
||||||
|
stuck map[string]stuckEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type presenceEntry struct {
|
||||||
|
value delivery.Presence
|
||||||
|
err string
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type stuckEntry struct {
|
||||||
|
value delivery.Stuck
|
||||||
|
err string
|
||||||
}
|
}
|
||||||
|
|
||||||
// assignment holds only the fields of the controller's Assignment the view reads.
|
// assignment holds only the fields of the controller's Assignment the view reads.
|
||||||
@@ -107,7 +151,8 @@ type nodeEntry struct {
|
|||||||
// New is an empty model.
|
// New is an empty model.
|
||||||
func New() *Model {
|
func New() *Model {
|
||||||
return &Model{assigns: map[string]assignment{}, builds: map[string]build{}, newest: map[string]string{},
|
return &Model{assigns: map[string]assignment{}, builds: map[string]build{}, newest: map[string]string{},
|
||||||
haves: map[string]haveEntry{}, nodes: map[string]nodeEntry{}}
|
haves: map[string]haveEntry{}, nodes: map[string]nodeEntry{},
|
||||||
|
presence: map[string]presenceEntry{}, stuck: map[string]stuckEntry{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply takes one change of a key. A value it cannot read is kept as an error on its row, never guessed.
|
// Apply takes one change of a key. A value it cannot read is kept as an error on its row, never guessed.
|
||||||
@@ -136,6 +181,20 @@ func (m *Model) Apply(e Entry) {
|
|||||||
}
|
}
|
||||||
h, err := delivery.DecodeHave(e.Value)
|
h, err := delivery.DecodeHave(e.Value)
|
||||||
m.haves[key] = haveEntry{have: h, err: errText(err), at: e.At}
|
m.haves[key] = haveEntry{have: h, err: errText(err), at: e.At}
|
||||||
|
case delivery.PresenceBucket:
|
||||||
|
if e.Deleted {
|
||||||
|
delete(m.presence, e.Key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := delivery.DecodePresence(e.Value)
|
||||||
|
m.presence[e.Key] = presenceEntry{value: p, err: errText(err), at: e.At}
|
||||||
|
case delivery.StuckBucket:
|
||||||
|
if e.Deleted {
|
||||||
|
delete(m.stuck, e.Key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := delivery.DecodeStuck(e.Value)
|
||||||
|
m.stuck[e.Key] = stuckEntry{value: st, err: errText(err)}
|
||||||
case AssignmentsBucket:
|
case AssignmentsBucket:
|
||||||
if e.Deleted {
|
if e.Deleted {
|
||||||
delete(m.assigns, e.Key)
|
delete(m.assigns, e.Key)
|
||||||
@@ -238,7 +297,47 @@ func (m *Model) Snapshot() Snapshot {
|
|||||||
Strays: count(n.node.Strays), Since: n.at, Error: n.err})
|
Strays: count(n.node.Strays), Since: n.at, Error: n.err})
|
||||||
}
|
}
|
||||||
sort.Slice(nodes, func(i, j int) bool { return nodes[i].Node < nodes[j].Node })
|
sort.Slice(nodes, func(i, j int) bool { return nodes[i].Node < nodes[j].Node })
|
||||||
return Snapshot{Rows: rows, Nodes: nodes}
|
offline, stuck, unreadable := []OfflineRow{}, []StuckRow{}, []UnreadableRow{}
|
||||||
|
for key, p := range m.presence {
|
||||||
|
if p.err != "" {
|
||||||
|
unreadable = append(unreadable, UnreadableRow{Bucket: delivery.PresenceBucket, Key: key, Error: p.err})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.value.Online {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
node, part, instance, err := delivery.ParsePresenceKey(key)
|
||||||
|
if err != nil {
|
||||||
|
unreadable = append(unreadable, UnreadableRow{Bucket: delivery.PresenceBucket, Key: key, Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if part != "" {
|
||||||
|
offline = append(offline, OfflineRow{Kind: "part", Part: part, Instance: instance, Since: p.at})
|
||||||
|
} else {
|
||||||
|
offline = append(offline, OfflineRow{Kind: "node", Node: node, Since: p.at})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, st := range m.stuck {
|
||||||
|
if st.err != "" {
|
||||||
|
unreadable = append(unreadable, UnreadableRow{Bucket: delivery.StuckBucket, Key: key, Error: st.err})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if st.value.Moved {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stuck = append(stuck, StuckRow{Bucket: st.value.Bucket, Key: st.value.Key, Waited: st.value.Waited, Since: st.value.Since})
|
||||||
|
}
|
||||||
|
sort.Slice(offline, func(i, j int) bool {
|
||||||
|
a, b := offline[i], offline[j]
|
||||||
|
return a.Kind+a.Node+a.Part+a.Instance < b.Kind+b.Node+b.Part+b.Instance
|
||||||
|
})
|
||||||
|
sort.Slice(stuck, func(i, j int) bool {
|
||||||
|
return stuck[i].Bucket+"."+stuck[i].Key < stuck[j].Bucket+"."+stuck[j].Key
|
||||||
|
})
|
||||||
|
sort.Slice(unreadable, func(i, j int) bool {
|
||||||
|
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}
|
||||||
}
|
}
|
||||||
|
|
||||||
// count is the number of items of a carried JSON array or object, and 0 for anything else.
|
// count is the number of items of a carried JSON array or object, and 0 for anything else.
|
||||||
|
|||||||
@@ -220,3 +220,77 @@ func TestNodeRow(t *testing.T) { // 1134/AC1 (the node's own key gives a node ro
|
|||||||
t.Fatalf("nodes = %+v", n)
|
t.Fatalf("nodes = %+v", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func presenceE(t *testing.T, key string, online bool, at time.Time) Entry {
|
||||||
|
t.Helper()
|
||||||
|
b, err := delivery.EncodePresence(delivery.Presence{Online: online})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return Entry{Bucket: delivery.PresenceBucket, Key: key, Value: b, At: at}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stuckE(t *testing.T, bucket, key, waited string, since time.Time, moved bool) Entry {
|
||||||
|
t.Helper()
|
||||||
|
b, err := delivery.EncodeStuck(delivery.Stuck{Bucket: bucket, Key: key, Waited: waited, Since: since, Moved: moved})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return Entry{Bucket: delivery.StuckBucket, Key: bucket + "." + key, Value: b, At: since}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOfflineAndStuckAreListed(t *testing.T) { // 1168/AC2
|
||||||
|
m := New()
|
||||||
|
fill(m, base(t)...)
|
||||||
|
fill(m,
|
||||||
|
presenceE(t, "a", false, t0),
|
||||||
|
presenceE(t, "b", true, t0),
|
||||||
|
presenceE(t, "part.mesh-delivery-view.i1", false, t0.Add(time.Minute)),
|
||||||
|
stuckE(t, "node-engine_have", "a.x", "a report from a", t0.Add(2*time.Minute), false),
|
||||||
|
stuckE(t, "node-engine_have", "b.x", "a report from b", t0, true),
|
||||||
|
)
|
||||||
|
s := m.Snapshot()
|
||||||
|
want := []OfflineRow{{Kind: "node", Node: "a", Since: t0}, {Kind: "part", Part: "mesh-delivery-view", Instance: "i1", Since: t0.Add(time.Minute)}}
|
||||||
|
if !reflect.DeepEqual(s.Offline, want) {
|
||||||
|
t.Fatalf("offline = %+v, want %+v", s.Offline, want)
|
||||||
|
}
|
||||||
|
wantStuck := []StuckRow{{Bucket: "node-engine_have", Key: "a.x", Waited: "a report from a", Since: t0.Add(2 * time.Minute)}}
|
||||||
|
if !reflect.DeepEqual(s.Stuck, wantStuck) {
|
||||||
|
t.Fatalf("stuck = %+v, want %+v", s.Stuck, wantStuck)
|
||||||
|
}
|
||||||
|
// back online and moved leave the lists
|
||||||
|
fill(m, presenceE(t, "a", true, t0.Add(5*time.Minute)), presenceE(t, "part.mesh-delivery-view.i1", true, t0.Add(5*time.Minute)),
|
||||||
|
stuckE(t, "node-engine_have", "a.x", "a report from a", t0.Add(2*time.Minute), true))
|
||||||
|
s = m.Snapshot()
|
||||||
|
if len(s.Offline) != 0 || len(s.Stuck) != 0 {
|
||||||
|
t.Fatalf("offline %+v stuck %+v, want both empty", s.Offline, s.Stuck)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownWatchmanVersionIsUnreadableNotDropped(t *testing.T) { // 1168/AC3
|
||||||
|
m := New()
|
||||||
|
fill(m,
|
||||||
|
Entry{Bucket: delivery.PresenceBucket, Key: "c", Value: []byte(`{"v":9,"online":false}`), At: t0},
|
||||||
|
Entry{Bucket: delivery.StuckBucket, Key: "node-engine_have.c.x", Value: []byte(`{"v":9}`), At: t0},
|
||||||
|
presenceE(t, "a", false, t0),
|
||||||
|
)
|
||||||
|
s := m.Snapshot()
|
||||||
|
if len(s.Unreadable) != 2 || len(s.Offline) != 1 {
|
||||||
|
t.Fatalf("unreadable %+v offline %+v", s.Unreadable, s.Offline)
|
||||||
|
}
|
||||||
|
_, sdkErr := delivery.DecodePresence([]byte(`{"v":9,"online":false}`))
|
||||||
|
var got *UnreadableRow
|
||||||
|
for i := range s.Unreadable {
|
||||||
|
if s.Unreadable[i].Key == "c" {
|
||||||
|
got = &s.Unreadable[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got == nil || got.Bucket != delivery.PresenceBucket || got.Error != sdkErr.Error() {
|
||||||
|
t.Fatalf("unreadable = %+v, want key c with the SDK's error %q", s.Unreadable, sdkErr)
|
||||||
|
}
|
||||||
|
// a good value later replaces the unreadable one
|
||||||
|
fill(m, presenceE(t, "c", true, t0))
|
||||||
|
if len(m.Snapshot().Unreadable) != 1 {
|
||||||
|
t.Fatal("a readable value must clear the unreadable mark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
|
|||||||
if m.Module != "mesh-delivery-view" {
|
if m.Module != "mesh-delivery-view" {
|
||||||
t.Errorf("module = %q", m.Module)
|
t.Errorf("module = %q", m.Module)
|
||||||
}
|
}
|
||||||
want := []string{"mesh-controller.assignments", "mesh-controller.builds", "node-engine.have"}
|
want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // 1168/AC1
|
||||||
got := slices.Clone(m.Reads)
|
got := slices.Clone(m.Reads)
|
||||||
slices.Sort(got)
|
slices.Sort(got)
|
||||||
if !reflect.DeepEqual(got, want) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
"reads": [
|
"reads": [
|
||||||
"mesh-controller.assignments",
|
"mesh-controller.assignments",
|
||||||
"mesh-controller.builds",
|
"mesh-controller.builds",
|
||||||
|
"mesh-delivery-watchman.presence",
|
||||||
|
"mesh-delivery-watchman.stuck",
|
||||||
"node-engine.have"
|
"node-engine.have"
|
||||||
],
|
],
|
||||||
"own-secrets": {
|
"own-secrets": {
|
||||||
|
|||||||
Reference in New Issue
Block a user