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 0b279ac..336b33a 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 @@ -602,3 +602,97 @@ func TestProcessWholeWritesTheAnswerToAnAskNeedingNoProof(t *testing.T) { // 129 } wait(func(s view.Snapshot) bool { return len(s.Asks) == 1 && s.Asks[0].Key == mkey }, "the answered ask never left the list") } + +// TestProcessWholeShowsTheBacklogOfBuildAsks starts the program whole against a bus holding the go seat's work-queue stream +// with its three shared workers, three check asks waiting and one held by a node: the socket's backlog row names three +// waiting, one held, and the oldest's age (1887/A8a AC; compiled in the unit run, run by the merge check). +func TestProcessWholeShowsTheBacklogOfBuildAsks(t *testing.T) { // 1887/A8a AC + 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() + stream, _ := delivery.AskStream(delivery.AskGo) + st, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: stream, Subjects: []string{"mesh.seat.node-build-go.accept.>"}, Retention: jetstream.WorkQueuePolicy}) + if err != nil { + t.Fatal(err) + } + var checkWorker jetstream.Consumer + for _, kind := range delivery.AskKinds { + name, _ := delivery.AskWorker(delivery.AskGo, kind) + subject, _ := delivery.AskSubject(delivery.AskGo, kind) + c, err := st.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{Durable: name, FilterSubject: subject, AckPolicy: jetstream.AckExplicitPolicy}) + if err != nil { + t.Fatal(err) + } + if kind == delivery.AskCheck { + checkWorker = c + } + } + subject, _ := delivery.AskSubject(delivery.AskGo, delivery.AskCheck) + oldest := time.Now().UTC().Add(-2 * time.Minute) + for i, id := range []string{"m0000001aaaaaa", "m0000002bbbbbb", "m0000003cccccc", "m0000004dddddd"} { + body, err := delivery.EncodeStepAsk(delivery.StepAsk{ID: id, Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: "mesh-delivery-checker", At: oldest.Add(time.Duration(i) * time.Second), + Check: &delivery.CheckJob{Fetch: map[string]any{"repository": "novox/x", "commit": "0123456789abcdef0123456789abcdef01234567"}, Levels: []string{delivery.LevelUnit}}}) + if err != nil { + t.Fatal(err) + } + if _, err := js.Publish(ctx, subject, body); err != nil { + t.Fatal(err) + } + } + // A node pulls one and holds it unacknowledged. + held, err := checkWorker.Fetch(1) + if err != nil { + t.Fatal(err) + } + for range held.Messages() { + } + + 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 }) + + var last view.Snapshot + for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) { + s, err := view.Ask(socket) + if err != nil { + continue + } + last = s + for _, b := range s.Backlog { + if b.Language == "go" && b.Kind == "check" && b.Waiting == 3 && b.Held == 1 && b.OldestSecs >= 118 { + return + } + } + } + t.Fatalf("the backlog never showed three waiting asks with the oldest's age; last: %+v", last.Backlog) +} 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 5863ce1..8b190eb 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go @@ -138,6 +138,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return err } 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} { go follow(ctx, js, bucket, model, logger) } diff --git a/modules/mesh-delivery-view/internal/view/backlog.go b/modules/mesh-delivery-view/internal/view/backlog.go new file mode 100644 index 0000000..14e5598 --- /dev/null +++ b/modules/mesh-delivery-view/internal/view/backlog.go @@ -0,0 +1,116 @@ +package view + +import ( + "context" + "fmt" + "time" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The backlog of the build seats (novox/hq ADR 0525 ยง4, issue 1887 A8a): for each language and kind of ask, how many asks wait +// on the seat's stream for a node to pull them, how many a node holds without having finished them, and how old the oldest +// waiting ask is. It is read from the stream's shared pull worker (delivery.AskWorker) when the view is asked: NumPending +// waits, NumAckPending is held by a node, and the oldest waiting ask is the stream's message at the worker's next sequence. + +// BacklogRow is one language's kind of ask: Waiting asks not yet pulled, Held asks pulled by a node and not yet ended, and the +// oldest waiting ask's time and age in seconds. Error says why the row could not be read, never dropping it silently. +type BacklogRow struct { + Language string `json:"language"` + Kind string `json:"kind"` + Waiting uint64 `json:"waiting"` + Held uint64 `json:"held"` + Oldest time.Time `json:"oldest,omitzero"` + OldestSecs int64 `json:"oldest-secs,omitempty"` + Error string `json:"error,omitempty"` +} + +// Backlog reads the seats' workers. Info is the worker's counts and the stream sequence it delivers next; Message is the body +// of the first message of the stream at or after seq on the subject. +type Backlog struct { + Info func(stream, worker string) (waiting, held, next uint64, err error) + Message func(stream, subject string, seq uint64) ([]byte, error) + Now func() time.Time +} + +// Rows are the rows of every language and kind that has an ask waiting or held, or that could not be read. +func (b *Backlog) Rows() []BacklogRow { + now := time.Now + if b.Now != nil { + now = b.Now + } + out := []BacklogRow{} + for _, lang := range delivery.AskLanguages { + stream, _ := delivery.AskStream(lang) + for _, kind := range delivery.AskKinds { + worker, _ := delivery.AskWorker(lang, kind) + subject, _ := delivery.AskSubject(lang, kind) + row := BacklogRow{Language: lang, Kind: kind} + waiting, held, next, err := b.Info(stream, worker) + if err != nil { + row.Error = err.Error() + out = append(out, row) + continue + } + row.Waiting, row.Held = waiting, held + if waiting > 0 { + body, err := b.Message(stream, subject, next) + if err == nil { + var a delivery.StepAsk + a, err = delivery.DecodeStepAsk(body) + if err == nil { + row.Oldest, row.OldestSecs = a.At, int64(now().Sub(a.At).Seconds()) + } + } + if err != nil { + row.Error = fmt.Sprintf("the oldest waiting ask cannot be read: %v", err) + } + } + if row.Waiting > 0 || row.Held > 0 || row.Error != "" { + out = append(out, row) + } + } + } + return out +} + +// BacklogOver reads the backlog from the bus: the worker's consumer info, and the stream's message by sequence. +func BacklogOver(js jetstream.JetStream, timeout time.Duration) *Backlog { + ctx := func() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), timeout) + } + return &Backlog{ + Info: func(stream, worker string) (uint64, uint64, uint64, error) { + c, cancel := ctx() + defer cancel() + s, err := js.Stream(c, stream) + if err != nil { + return 0, 0, 0, fmt.Errorf("the stream %s: %w", stream, err) + } + cons, err := s.Consumer(c, worker) + if err != nil { + return 0, 0, 0, fmt.Errorf("the worker %s: %w", worker, err) + } + info := cons.CachedInfo() + if i, err := cons.Info(c); err == nil { + info = i + } + return uint64(info.NumPending), uint64(info.NumAckPending), info.Delivered.Stream + 1, nil + }, + Message: func(stream, subject string, seq uint64) ([]byte, error) { + c, cancel := ctx() + defer cancel() + s, err := js.Stream(c, stream) + if err != nil { + return nil, err + } + m, err := s.GetMsg(c, seq, jetstream.WithGetMsgSubject(subject)) + if err != nil { + return nil, err + } + return m.Data, nil + }, + } +} diff --git a/modules/mesh-delivery-view/internal/view/backlog_test.go b/modules/mesh-delivery-view/internal/view/backlog_test.go new file mode 100644 index 0000000..2ec5bc4 --- /dev/null +++ b/modules/mesh-delivery-view/internal/view/backlog_test.go @@ -0,0 +1,87 @@ +package view + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +func stepAsk(t *testing.T, id, kind string, at time.Time) []byte { + t.Helper() + b, err := delivery.EncodeStepAsk(delivery.StepAsk{ID: id, Kind: kind, Language: delivery.AskGo, Asker: "mesh-delivery-checker", At: at, + Check: &delivery.CheckJob{Fetch: map[string]any{"repository": "novox/x", "commit": "0123456789abcdef0123456789abcdef01234567"}, Levels: []string{delivery.LevelUnit}}}) + if err != nil { + t.Fatal(err) + } + return b +} + +// fakeSeats is the check worker of the go seat with three asks waiting and one held, the oldest at t0. +func fakeSeats(t *testing.T, now time.Time) *Backlog { + worker, _ := delivery.AskWorker(delivery.AskGo, delivery.AskCheck) + subject, _ := delivery.AskSubject(delivery.AskGo, delivery.AskCheck) + oldest := stepAsk(t, "m0000001aaaaaa", delivery.AskCheck, now.Add(-90*time.Second)) + return &Backlog{Now: func() time.Time { return now }, + Info: func(stream, w string) (uint64, uint64, uint64, error) { + switch w { + case worker: + return 3, 1, 42, nil + case "SEAT_NODE_BUILD_IMAGE_DELIVERY_worker": + return 0, 0, 1, fmt.Errorf("the worker is not there") + } + return 0, 0, 1, nil + }, + Message: func(stream, s string, seq uint64) ([]byte, error) { + if s != subject || seq != 42 { + return nil, fmt.Errorf("asked %s at %d", s, seq) + } + return oldest, nil + }} +} + +func TestThreeWaitingAsksAreShownWithTheOldestsAge(t *testing.T) { // 1887/A8a AC + now := time.Date(2026, 10, 23, 12, 0, 0, 0, time.UTC) + m := New() + m.SetBacklog(fakeSeats(t, now)) + rows := m.Snapshot().Backlog + var check, image *BacklogRow + for i := range rows { + switch { + case rows[i].Language == "go" && rows[i].Kind == "check": + check = &rows[i] + case rows[i].Language == "image" && rows[i].Kind == "delivery": + image = &rows[i] + default: + t.Errorf("a quiet seat is listed: %+v", rows[i]) + } + } + if check == nil || check.Waiting != 3 || check.Held != 1 || check.OldestSecs != 90 || !check.Oldest.Equal(now.Add(-90*time.Second)) || check.Error != "" { + t.Fatalf("check row = %+v", check) + } + if image == nil || image.Error == "" { + t.Errorf("a worker that cannot be read is dropped: %+v", rows) + } + var out strings.Builder + Table(&out, m.Snapshot()) + for _, w := range []string{"BACKLOG OF BUILD ASKS", "go", "check", "3", "1m30s", "unreadable"} { + if !strings.Contains(out.String(), w) { + t.Errorf("table lacks %q:\n%s", w, out.String()) + } + } + rec := httptest.NewRecorder() + Web(m).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if !strings.Contains(rec.Body.String(), "BACKLOG OF BUILD ASKS") || !strings.Contains(rec.Body.String(), "1m30s") { + t.Errorf("page lacks the backlog:\n%s", rec.Body.String()) + } +} + +func TestNoBacklogReaderGivesNoRows(t *testing.T) { + if got := New().Snapshot().Backlog; got == nil || len(got) != 0 { + t.Errorf("backlog = %#v", got) + } +} diff --git a/modules/mesh-delivery-view/internal/view/serve.go b/modules/mesh-delivery-view/internal/view/serve.go index 613552d..f8f6685 100644 --- a/modules/mesh-delivery-view/internal/view/serve.go +++ b/modules/mesh-delivery-view/internal/view/serve.go @@ -236,6 +236,24 @@ func differenceText(s Snapshot) [][]string { return out } +// 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 + for _, b := range s.Backlog { + age := "" + if !b.Oldest.IsZero() { + age = (time.Duration(b.OldestSecs) * time.Second).String() + " (since " + since(b.Oldest) + ")" + } + if b.Error != "" { + age = "unreadable: " + b.Error + } + out = append(out, []string{b.Language, b.Kind, fmt.Sprint(b.Waiting), fmt.Sprint(b.Held), age}) + } + return out +} + +var backlogHead = []string{"LANGUAGE", "KIND", "WAITING", "HELD", "OLDEST WAITING"} + var differenceHead = []string{"NODE", "MODULE", "WANT", "SENT", "RULE", "WORDS"} var head = []string{"NODE", "MODULE", "ASSIGNED", "HAVE", "PHASE", "HEALTH", "SINCE", "DETAIL", "WAITS"} @@ -277,6 +295,14 @@ func Table(w io.Writer, s Snapshot) { row(cells) } } + if b := backlogText(s); len(b) > 0 { + fmt.Fprintln(tw) + row([]string{"BACKLOG OF BUILD ASKS"}) + row(backlogHead) + for _, cells := range b { + row(cells) + } + } if d := differenceText(s); len(d) > 0 { fmt.Fprintln(tw) row([]string{"WANTS AGAINST SENDS"}) @@ -329,6 +355,7 @@ var page = template.Must(template.New("p").Parse(`{{range .}}{{.}}{{end}}{{end}} {{if .Groups}}

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 .Fingerprints}}

FINGERPRINTS

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

OFFLINE

{{range .Offline}}{{range .}}{{end}}{{end}}
{{.}}
{{end}} @@ -383,7 +410,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), "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), "Differences": differenceText(snap), "Backlog": backlogText(snap), "BacklogHead": backlogHead, "DifferenceHead": differenceHead, "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 2f82e00..dfc0db2 100644 --- a/modules/mesh-delivery-view/internal/view/view.go +++ b/modules/mesh-delivery-view/internal/view/view.go @@ -69,6 +69,9 @@ type Snapshot struct { // 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"` + // 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, @@ -186,6 +189,7 @@ type Model struct { 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 + backlog *Backlog answers map[string]string // by key: the router id of the asking the teller's answer is for ("" when unreadable) newest map[string]string // module -> id haves map[string]haveEntry @@ -452,6 +456,25 @@ func errText(err error) string { // Snapshot derives one row per module and node with an open assignment, a row marked left behind for each have with // none, and a row per node's own key. Rows are ordered by node and module. func (m *Model) Snapshot() Snapshot { + s := m.snapshot() + m.mu.Lock() + b := m.backlog + m.mu.Unlock() + s.Backlog = []BacklogRow{} + if b != nil { + s.Backlog = b.Rows() // read from the bus outside the model's lock + } + return s +} + +// SetBacklog gives the model the seats' workers to read the backlog of asks from. +func (m *Model) SetBacklog(b *Backlog) { + m.mu.Lock() + defer m.mu.Unlock() + m.backlog = b +} + +func (m *Model) snapshot() Snapshot { m.mu.Lock() defer m.mu.Unlock() rows := []Row{} diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index 2f25bfe..de423f5 100644 --- a/modules/mesh-delivery-view/manifest_test.go +++ b/modules/mesh-delivery-view/manifest_test.go @@ -48,6 +48,7 @@ type manifest struct { Version string `json:"version"` Slug string `json:"slug"` Reads []string `json:"reads"` + Uses []string `json:"uses"` Tools []string `json:"tools"` OwnSecrets map[string]string `json:"own-secrets"` SecretsOwner string `json:"secrets-owner"` @@ -93,6 +94,10 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 t.Errorf("the manifest declares %q: the view writes only its answers", k) } } + // The view reads the build seats' streams for the backlog of asks (issue 1887 A8a): the three seats it uses. + if want := []string{"node-build-go", "node-build-image", "node-build-node"}; !reflect.DeepEqual(m.Uses, want) { + t.Errorf("uses = %v, want %v", m.Uses, want) + } // The one state the view writes: the operator's answers to the asks that need no proof (ADR 0528 clause 6, 1297/B9), which the teller reads. if want := []state{{Name: "answers", History: 5}}; !reflect.DeepEqual(m.State, want) { t.Errorf("state = %+v, want %+v", m.State, want) diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json index 61ed274..0d5ed50 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -28,6 +28,11 @@ "mesh-delivery-watchman.stuck", "node-engine.have" ], + "uses": [ + "node-build-go", + "node-build-image", + "node-build-node" + ], "own-secrets": { "broker": "${dir:state}/broker" }, diff --git a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/integration_test.go b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/integration_test.go index de366a4..e95ff69 100644 --- a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/integration_test.go +++ b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/integration_test.go @@ -171,7 +171,8 @@ func TestWatchmanWhole(t *testing.T) { // 1135/AC8 askSubject, _ := delivery.AskSubject(delivery.AskGo, delivery.AskCheck) putAsk := func(id string) { t.Helper() - body, err := json.Marshal(delivery.StepAsk{V: delivery.StepAskVersion, ID: id, Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: "mesh-delivery-checker", At: time.Now().UTC()}) + body, err := delivery.EncodeStepAsk(delivery.StepAsk{ID: id, Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: "mesh-delivery-checker", At: time.Now().UTC(), + Check: &delivery.CheckJob{Fetch: map[string]any{"repository": "novox/x", "commit": "0123456789abcdef0123456789abcdef01234567"}, Levels: []string{delivery.LevelUnit}}}) if err != nil { t.Fatal(err) }