From 4f6e1021c60ff8a8f461322c2c1f323a86e28830 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 16:36:15 +0100 Subject: [PATCH] =?UTF-8?q?view=5Ftiers:=20a=20source=20revision's=20tiers?= =?UTF-8?q?=20worked=20out=20from=20the=20module=20graph=20at=20each=20cal?= =?UTF-8?q?l,=20and=20per=20module=20what=20it=20waits=20on=20now,=20its?= =?UTF-8?q?=20build,=20each=20node's=20want=20and=20have=20and=20each=20pr?= =?UTF-8?q?ovision=20it=20awaits,=20the=20nodes=20offline=20named=20away?= =?UTF-8?q?=20(ADR=200603=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cmd/mesh-delivery-view-tools/main.go | 15 +- .../cmd/mesh-delivery-view-tools/main_test.go | 1 + .../tiers_rows_test.go | 120 ++++++ .../mesh-delivery-view/internal/view/read.go | 3 + .../mesh-delivery-view/internal/view/tiers.go | 383 ++++++++++++++++++ modules/mesh-delivery-view/manifest_test.go | 4 +- modules/mesh-delivery-view/module.json | 5 +- 7 files changed, 527 insertions(+), 4 deletions(-) create mode 100644 modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go create mode 100644 modules/mesh-delivery-view/internal/view/tiers.go diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go index d795395b..69e67c73 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go @@ -13,6 +13,7 @@ import ( "mesh-delivery-view/internal/view" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/provisions" stdio "git.novox.be/novox/mesh-sdk/go" ) @@ -43,6 +44,16 @@ func Tools(r view.Reader, u view.UsageReader) []stdio.Tool { module, _ := args["module"].(string) return r.Reach(repository, module) }}, + {Name: view.TiersTool, + Description: "A source revision's tiers, worked out now and never stored (novox/hq ADR 0603 §6): the modules the revision moves " + + "(each whose fingerprint last changed at it), ordered by the module graph (a module after the bases and the providers " + + "of the provisions it awaits among them), and for each module what it waits on now and why: its build key, on each node " + + "that wants it its want (the assigner's rule and words when held back), its have, and each provision it awaits. Nodes " + + "the watchman says are offline are named away and left out. At most 500 modules, said when cut short; said when the " + + "module graph cannot be read. Give revision for an older one, none for the gate's newest. Read-only.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "revision": map[string]any{"type": "integer", "description": "the source revision; none for the gate's newest"}}}, + Run: func(args map[string]any) (any, error) { return r.TiersOf(args) }}, {Name: view.ModuleHistoryTool, Description: "The history of one module's value in the indexer's modules state (modules.), with times: each value it " + "still keeps (up to 20), newest first, with its revision, when it was written, put or del, the repository and commit " + @@ -307,7 +318,9 @@ func main() { PredefinedState: read(stdio.State(delivery.IndexerModule + "." + delivery.IndexerPredefinedState)), ConfigCandidatesState: read(stdio.State(delivery.IndexerModule + "." + delivery.IndexerConfigCandidatesState)), PackagesState: read(stdio.State(delivery.IndexerModule + "." + delivery.PackagesState)), - BuildLog: stdio.Log(delivery.BuildLogRead).Since} + GraphState: read(stdio.State(delivery.GraphRead)), SourceState: read(stdio.State(delivery.GateModule + ".source")), + ProvisionsState: read(stdio.State(provisions.ProvisionsRead)), + BuildLog: stdio.Log(delivery.BuildLogRead).Since} u := view.UsageReader{Haves: r.HavesState, Resolved: read(stdio.State("mesh-config.resolved")), Tries: read(stdio.State("mesh-delivery-checker.tries")), Steps: map[string]view.StateRead{}, Pauses: map[string]view.StateRead{}, Backlog: backlogOf(socket())} for _, lang := range delivery.AskLanguages { diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go index 4d54278c..144d9d30 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go @@ -55,6 +55,7 @@ const ( // 1562/AC1: indexer_module answers the key's value as stored: FP, FP2, None2, Inputs2, Path and Toolchains are all there. func TestModuleToolAnswersTheStoredValue(t *testing.T) { t.Run("indexer_packages answers the published modules", packagesToolAnswersThePublishedModules) + t.Run("view_tiers names each module's wait", tiersToolSaysEachModulesWait) r := fake{"a": withFP2, "b": withNone}.reader() for key, want := range map[string]string{"a": withFP2, "b": withNone} { out, err := run(t, r, view.ModuleReadTool, map[string]any{"module": key}) diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go new file mode 100644 index 00000000..815e1407 --- /dev/null +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/provisions" + + "mesh-delivery-view/internal/view" +) + +// encoded is v as its contract's encoder writes it, failing the test when it refuses. +func encoded(t *testing.T, v []byte, err error) string { + t.Helper() + if err != nil { + t.Fatal(err) + } + return string(v) +} + +// tiersToolSaysEachModulesWait: view_tiers orders a source revision's modules by the module graph and names each one's +// wait (ADR 0603 §6): a consumer after its provider, held by the assigner's provision rule and its provision not provided; +// a module whose build waits for a package; a provider with no wait; a node offline named away; older modules left out. +func tiersToolSaysEachModulesWait(t *testing.T) { + fp := func(c string) string { return "src2:" + strings.Repeat(c, 64) } + manifest := strings.Repeat("d", 64) + mod := func(f string, src uint64) string { + b, err := delivery.EncodeModule(delivery.Module{FP2: f, Manifest: manifest, Src: src, FPSrc: src, Repository: "novox/mesh-catalog", Commit: "c"}) + return encoded(t, b, err) + } + node := func(g delivery.GraphNode) string { + b, err := delivery.EncodeGraphNode(g) + return encoded(t, b, err) + } + build := func(b delivery.BuilderBuild) string { + v, err := delivery.EncodeBuilderBuild(b) + return encoded(t, v, err) + } + want := func(w delivery.Want) string { + v, err := delivery.EncodeWant(w) + return encoded(t, v, err) + } + have := func(h delivery.Have) string { + v, err := delivery.EncodeHave(h) + return encoded(t, v, err) + } + key := func(k string, err error) string { + if err != nil { + t.Fatal(err) + } + return k + } + since := time.Date(2026, 11, 3, 16, 40, 0, 0, time.UTC) + r := fake{"db": mod(fp("a"), 9), "web": mod(fp("b"), 9), "lib": mod(fp("c"), 9), "old": mod(fp("e"), 5)}.reader() + r.GraphState = fake{ + "db": node(delivery.GraphNode{FP: fp("a"), Repository: "novox/mesh-catalog", Provides: []string{"database"}}), + "web": node(delivery.GraphNode{FP: fp("b"), Repository: "novox/mesh-catalog", Requires: []string{"database"}, Awaits: []string{"database"}}), + "lib": node(delivery.GraphNode{FP: fp("c"), Repository: "novox/mesh-catalog"}), + }.reader().State + r.Builds = fake{ + key(delivery.BuilderBuildKey("db", fp("a"))): build(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: manifest}), + key(delivery.BuilderBuildKey("web", fp("b"))): build(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: manifest}), + key(delivery.BuilderBuildKey("lib", fp("c"))): build(delivery.BuilderBuild{State: delivery.BuildQueued, Waits: "@novox/x@1.2.0"}), + }.reader().State + r.WantsState = fake{ + key(delivery.WantKey("ace", "db")): want(delivery.Want{FP: fp("a"), Self: true}), + key(delivery.WantKey("ace", "web")): want(delivery.Want{Self: true, FP: fp("f"), Newest: fp("b"), Rule: delivery.RuleProvisions, Words: "the provision ace.web.database of web on ace is asked, not provided"}), + key(delivery.WantKey("g14", "web")): want(delivery.Want{Self: true, FP: fp("f"), Newest: fp("b"), Rule: delivery.RuleCanary, Words: "the canary"}), + }.reader().State + r.WantsState.History = func(string) ([]view.Revision, error) { return []view.Revision{{At: since}}, nil } + r.HavesState = fake{key(delivery.HaveKey("ace", "db")): have(delivery.Have{FP: fp("a"), Phase: delivery.PhaseRunning, Health: delivery.HealthOK})}.reader().State + p, err := provisions.Encode(provisions.Provision{Provision: "database", Consumer: provisions.End{Module: "web", Node: "ace"}, + Provider: provisions.End{Module: "db", Node: "ace"}, State: provisions.Asked, Why: "the build db runs does not offer it", At: since}) + r.ProvisionsState = fake{key(provisions.Key("ace", "web", "database", "")): encoded(t, p, err)}.reader().State + off, err := delivery.EncodePresence(delivery.Presence{Online: false}) + r.PresenceState = fake{key(delivery.PresenceNodeKey("g14")): encoded(t, off, err)}.reader().State + + out, err := run(t, r, view.TiersTool, map[string]any{"revision": float64(9)}) + got, _ := out.(view.Tiers) + if err != nil || got.Revision != 9 || got.Modules != 3 || len(got.Tiers) != 2 || !reflect.DeepEqual(got.Away, []string{"g14"}) || got.Graph != "" { + t.Fatalf("view_tiers 9 = %+v, %v", out, err) + } + first := fmt.Sprint(got.Tiers[0]) + if len(got.Tiers[0]) != 2 || got.Tiers[0][0].Module != "db" || len(got.Tiers[0][0].Waits) != 0 || got.Tiers[0][1].Module != "lib" || + len(got.Tiers[0][1].Waits) != 1 || !strings.Contains(got.Tiers[0][1].Waits[0].Words, "waiting for the package @novox/x@1.2.0") { + t.Errorf("the first tier = %s, want db with no wait and lib's build waiting for its package", first) + } + web := got.Tiers[1][0] + if web.Module != "web" || !reflect.DeepEqual(web.After, []string{"db"}) || len(web.Waits) != 2 || + web.Waits[0].Edge != "want" || web.Waits[0].Node != "ace" || web.Waits[0].Rule != delivery.RuleProvisions || !web.Waits[0].Since.Equal(since) || + web.Waits[1].Edge != "provision database" || !strings.Contains(web.Waits[1].Words, "asked: the build db runs does not offer it") { + t.Errorf("web = %+v, want after db, held on ace by the provision rule and its provision asked (g14 is away)", web) + } + // No revision named: the gate's newest, its source key's revision. + r.SourceState = view.StateRead{GetEntry: func(string) (json.RawMessage, uint64, error) { return json.RawMessage(`{}`), 9, nil }} + if out, err := run(t, r, view.TiersTool, map[string]any{}); err != nil || out.(view.Tiers).Revision != 9 || out.(view.Tiers).Modules != 3 { + t.Errorf("view_tiers of the gate's newest = %+v, %v", out, err) + } + // The graph not readable: every module in the first tier, said; past 500 modules the answer says it is cut short. + r.GraphState = view.StateRead{Keys: func() ([]string, error) { return nil, errors.New("no bucket") }, Get: r.GraphState.Get} + if out, err := run(t, r, view.TiersTool, map[string]any{"revision": float64(9)}); err != nil || len(out.(view.Tiers).Tiers) != 1 || + !strings.Contains(out.(view.Tiers).Graph, "cannot be read") { + t.Errorf("view_tiers with no graph = %+v, %v", out, err) + } + many := fake{} + for i := 0; i < view.TiersMost+2; i++ { + many[fmt.Sprintf("m%03d", i)] = mod(fp("a"), 9) + } + r.State = many.reader().State + if out, err := run(t, r, view.TiersTool, map[string]any{"revision": "9"}); err != nil || out.(view.Tiers).Modules != view.TiersMost+2 || + !strings.Contains(out.(view.Tiers).Cut, "the first 500") { + t.Errorf("view_tiers past its bound = %+v, %v", out.(view.Tiers).Cut, err) + } +} diff --git a/modules/mesh-delivery-view/internal/view/read.go b/modules/mesh-delivery-view/internal/view/read.go index d9af163e..89d80210 100644 --- a/modules/mesh-delivery-view/internal/view/read.go +++ b/modules/mesh-delivery-view/internal/view/read.go @@ -55,6 +55,9 @@ type Reader struct { PredefinedState, ConfigCandidatesState StateRead // PackagesState is the indexer's published packages (delivery.PackagesBucket, ADR 0593 §2); unset reads none. PackagesState StateRead + // GraphState is the indexer's module graph (delivery.GraphBucket, ADR 0603 §2), SourceState the gate's source and + // ProvisionsState the provisioner's keys: what view_tiers reads (tiers.go). Unset reads none, said in the answer. + GraphState, SourceState, ProvisionsState StateRead // BuildLog reads the builder's log from a time (stdio.Log(delivery.BuildLogRead).Since); unset reads none. BuildLog LogSince } diff --git a/modules/mesh-delivery-view/internal/view/tiers.go b/modules/mesh-delivery-view/internal/view/tiers.go new file mode 100644 index 00000000..6289d96f --- /dev/null +++ b/modules/mesh-delivery-view/internal/view/tiers.go @@ -0,0 +1,383 @@ +package view + +import ( + "encoding/json" + "fmt" + "maps" + "slices" + "strconv" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/provisions" +) + +// The tiers of a source revision are a view, worked out at each call and never stored (novox/hq ADR 0603 §1, §6): the +// modules the revision moves, ordered by the module graph's ordering edges among them (a base, a provision awaited from +// another of them), and for each module what it waits on now: its build key, each node's want and have, and each +// provision it awaits on each node, with the rule and the words of the part that holds it. Nothing in the mesh waits on +// a tier; the tiers say where the revision stands. A node the watchman says is offline is named away and left out of +// what the revision waits on (ADR 0603 §7). + +// TiersTool is the view's read tool of a source revision's tiers. +const TiersTool = "view_tiers" + +// TiersMost is the most modules view_tiers answers (ADR 0603 §2); past it the answer says how many it left out. +const TiersMost = 500 + +// Tiers is a source revision's tiers: Revision the source revision (the gate's source key's revision), Modules how many +// modules it moves (each whose fingerprint last changed at it, delivery.Module.FPSrc), Tiers those modules in order, the +// first tier waiting on none of the others. Cut says when the answer holds fewer than Modules, Graph why the module graph +// could not be read (then every module is in the first tier), Cycle a cycle among them (its modules form the last +// tier), and Away the nodes left out. +type Tiers struct { + Revision uint64 `json:"revision"` + Modules int `json:"modules"` + Tiers [][]TierModule `json:"tiers"` + Cut string `json:"cut,omitempty"` + Graph string `json:"graph,omitempty"` + Cycle []string `json:"cycle,omitempty"` + Away []string `json:"away,omitempty"` +} + +// TierModule is one module of a tier: its fingerprint, the modules of the same revision it waits for (After), and each +// wait of its own now; no wait is a module that reached every node present. +type TierModule struct { + Module string `json:"module"` + FP string `json:"fp,omitempty"` + None string `json:"none,omitempty"` + After []string `json:"after,omitempty"` + Waits []TierWait `json:"waits,omitempty"` +} + +// TierWait is one thing a module waits on: the edge (build, want, have, or provision ), the node it waits on (none +// for its build), the assigner's rule for a want held back, the part's words and since when, where the part says it. +type TierWait struct { + Edge string `json:"edge"` + Node string `json:"node,omitempty"` + Rule int `json:"rule,omitempty"` + Words string `json:"words"` + Since time.Time `json:"since,omitzero"` +} + +// Tiers works out the tiers of a source revision, the gate's newest when revision is 0. +func (r Reader) Tiers(revision uint64) (Tiers, error) { + if revision == 0 { + if r.SourceState.GetEntry == nil { + return Tiers{}, fmt.Errorf("the gate's source cannot be read here: name the source revision") + } + _, rev, err := r.SourceState.GetEntry(delivery.SourceMainKey) + if err != nil { + return Tiers{}, fmt.Errorf("the gate's source: %w", err) + } + if rev == 0 { + return Tiers{}, fmt.Errorf("the gate holds no source revision yet") + } + revision = rev + } + modules, err := values(r.State) + if err != nil { + return Tiers{}, fmt.Errorf("the indexer's modules: %w", err) + } + moved := map[string]delivery.Module{} + for key, raw := range modules { + if m, err := delivery.DecodeModule(raw); err == nil && m.FPSrc == revision { + moved[key] = m + } + } + out := Tiers{Revision: revision, Modules: len(moved)} + names := slices.Sorted(maps.Keys(moved)) + if len(names) > TiersMost { + out.Cut = fmt.Sprintf("the revision moves %d modules: the first %d by name are shown, %s and the %d after it are not", + len(names), TiersMost, names[TiersMost], len(names)-TiersMost-1) + names = names[:TiersMost] + } + graph := delivery.Graph{} + if raws, err := values(r.GraphState); err != nil { + out.Graph = "the module graph cannot be read, so no module is ordered after another: " + err.Error() + } else { + for key, raw := range raws { + if n, err := delivery.DecodeGraphNode(raw); err == nil { + graph[key] = n + } + } + } + away := r.away() + out.Away = slices.Sorted(maps.Keys(away)) + wants, _ := values(r.WantsState) + provided, perr := values(r.ProvisionsState) + + // The ordering among the revision's modules: each one's tier is one past the highest of the modules it waits for. + in := map[string]bool{} + for _, n := range names { + in[n] = true + } + after := map[string][]string{} + for _, n := range names { + for _, b := range graph.Before(n) { + if in[b] { + after[n] = append(after[n], b) + } + } + } + sub := delivery.Graph{} + for _, n := range names { + if g, ok := graph[n]; ok { + sub[n] = g + } + } + out.Cycle = sub.Cycle() + tier := map[string]int{} + var level func(string, map[string]bool) int + level = func(n string, visiting map[string]bool) int { + if t, ok := tier[n]; ok { + return t + } + if visiting[n] { + return -1 + } + visiting[n] = true + t := 0 + for _, b := range after[n] { + lb := level(b, visiting) + if lb < 0 { + return -1 + } + t = max(t, lb+1) + } + delete(visiting, n) + tier[n] = t + return t + } + top := 0 + for _, n := range names { + if t := level(n, map[string]bool{}); t > top { + top = t + } + } + for _, n := range names { + if _, ok := tier[n]; !ok { + tier[n] = top + 1 // on a cycle: after every tier that is ordered + } + } + for _, n := range names { + t := tier[n] + for len(out.Tiers) <= t { + out.Tiers = append(out.Tiers, nil) + } + m := moved[n] + tm := TierModule{Module: n, FP: m.FP2, None: m.None2, After: after[n]} + tm.Waits = r.waitsOf(n, m, graph[n], wants, provided, perr, away) + out.Tiers[t] = append(out.Tiers[t], tm) + } + compact := out.Tiers[:0] + for _, t := range out.Tiers { + if len(t) > 0 { + compact = append(compact, t) + } + } + out.Tiers = compact + return out, nil +} + +// away are the nodes the watchman's presence says are offline. +func (r Reader) away() map[string]bool { + out := map[string]bool{} + presence, err := values(r.PresenceState) + if err != nil { + return out + } + for key, raw := range presence { + node, part, _, err := delivery.ParsePresenceKey(key) + if err != nil || part != "" { + continue + } + if p, err := delivery.DecodePresence(raw); err == nil && !p.Online { + out[node] = true + } + } + return out +} + +// waitsOf is what module m waits on now: its build at its fingerprint, then on each node present that wants it, its want, +// its have, and each provision its graph node awaits. +func (r Reader) waitsOf(module string, m delivery.Module, g delivery.GraphNode, wants, provided map[string]json.RawMessage, + perr error, away map[string]bool) []TierWait { + var out []TierWait + if m.FP2 == "" { + return []TierWait{{Edge: "build", Words: "it has no fingerprint, so nothing is built: " + m.None2}} + } + if w, ok := r.buildWait(module, m.FP2); ok { + out = append(out, w) + } + var nodes []string + for key := range wants { + node, mod, err := delivery.ParseWantKey(key) + if err == nil && mod == module { + nodes = append(nodes, node) + } + } + slices.Sort(nodes) + for _, node := range nodes { + if away[node] { + continue + } + w, err := delivery.DecodeWant(wants[mustWantKey(node, module)]) + if err != nil || w.Removed { + continue + } + if w.FP != m.FP2 { + words := w.Words + if words == "" { + words = "the want names " + short(w.FP) + ", not " + short(m.FP2) + } + out = append(out, TierWait{Edge: "want", Node: node, Rule: w.Rule, Words: words, Since: r.since(r.WantsState, mustWantKey(node, module))}) + } else if hw, ok := r.haveWait(node, module, m.FP2); ok { + out = append(out, hw) + } + for _, a := range g.Awaits { + out = append(out, provisionWaits(node, module, a, provided, perr)...) + } + } + return out +} + +func mustWantKey(node, module string) string { + k, _ := delivery.WantKey(node, module) + return k +} + +// buildWait is the wait of the build key ., none when it is done. +func (r Reader) buildWait(module, fp string) (TierWait, bool) { + key, err := delivery.BuilderBuildKey(module, fp) + if err != nil || r.Builds.Get == nil { + return TierWait{Edge: "build", Words: "the builder's builds cannot be read here"}, true + } + raw, err := r.Builds.Get(key) + switch { + case err != nil: + return TierWait{Edge: "build", Words: "the build key " + key + " cannot be read: " + err.Error()}, true + case raw == nil: + return TierWait{Edge: "build", Words: "the builder holds no build of " + short(fp) + " yet"}, true + } + b, err := delivery.DecodeBuilderBuild(raw) + if err != nil { + return TierWait{Edge: "build", Words: "the build key " + key + " does not read: " + err.Error()}, true + } + switch b.State { + case delivery.BuildDone: + return TierWait{}, false + case delivery.BuildFailed: + return TierWait{Edge: "build", Words: "the build of " + short(fp) + " failed: " + b.Error}, true + } + words := "the build of " + short(fp) + " is " + b.State + if b.Waits != "" { + words += ", waiting for the package " + b.Waits + } + if b.Step != "" { + words += ": " + b.Step + } + return TierWait{Edge: "build", Words: words, Since: r.since(r.Builds, key)}, true +} + +// haveWait is the wait of node's have of module at fp, none when it runs fp healthy. +func (r Reader) haveWait(node, module, fp string) (TierWait, bool) { + key, err := delivery.HaveKey(node, module) + if err != nil || r.HavesState.Get == nil { + return TierWait{}, false + } + raw, err := r.HavesState.Get(key) + if err != nil || raw == nil { + return TierWait{Edge: "have", Node: node, Words: node + " has not yet said what it runs of " + module}, true + } + h, err := delivery.DecodeHave(raw) + if err != nil { + return TierWait{Edge: "have", Node: node, Words: "its have does not read: " + err.Error()}, true + } + if h.FP == fp && h.Phase == delivery.PhaseRunning && h.Health == delivery.HealthOK { + return TierWait{}, false + } + words := fmt.Sprintf("%s runs %s, %s", node, short(h.FP), h.Phase) + if h.Health != "" { + words += ", health " + string(h.Health) + } + if h.Detail != "" { + words += ": " + h.Detail + } + return TierWait{Edge: "have", Node: node, Words: words, Since: r.since(r.HavesState, key)}, true +} + +// provisionWaits are the waits of the provision a that module awaits on node: none once every key of it is provided. +func provisionWaits(node, module, a string, provided map[string]json.RawMessage, perr error) []TierWait { + edge := "provision " + a + if perr != nil { + return []TierWait{{Edge: edge, Node: node, Words: "the provisioner's keys cannot be read: " + perr.Error()}} + } + prefix, _ := provisions.Key(node, module, a, "") + var out []TierWait + found := false + for _, key := range slices.Sorted(maps.Keys(provided)) { + if key != prefix && !strings.HasPrefix(key, prefix+".") { + continue + } + found = true + p, err := provisions.Decode(provided[key]) + if err != nil { + out = append(out, TierWait{Edge: edge, Node: node, Words: "the provision key " + key + " does not read: " + err.Error()}) + continue + } + if p.State == provisions.Provided { + continue + } + words := "the provision " + key + " is " + p.State + if p.Why != "" { + words += ": " + p.Why + } + out = append(out, TierWait{Edge: edge, Node: node, Words: words, Since: p.At}) + } + if !found { + out = append(out, TierWait{Edge: edge, Node: node, Words: "the provisioner holds no key of " + a + " for " + module + " on " + node + " yet"}) + } + return out +} + +// since is when key's value was written, from the state's history, or zero when it cannot say. +func (r Reader) since(s StateRead, key string) time.Time { + if s.History == nil { + return time.Time{} + } + revs, err := s.History(key) + if err != nil || len(revs) == 0 { + return time.Time{} + } + return revs[len(revs)-1].At +} + +// tiersRevision reads the tool's revision argument: a number, as JSON gives it or as text; 0 for the gate's newest. +func tiersRevision(v any) (uint64, error) { + switch x := v.(type) { + case nil: + return 0, nil + case float64: + if x < 0 || x != float64(uint64(x)) { + return 0, fmt.Errorf("the revision %v is no source revision", x) + } + return uint64(x), nil + case string: + if strings.TrimSpace(x) == "" { + return 0, nil + } + return strconv.ParseUint(strings.TrimSpace(x), 10, 64) + } + return 0, fmt.Errorf("the revision %v is no source revision", v) +} + +// TiersOf is view_tiers' answer for its arguments. +func (r Reader) TiersOf(args map[string]any) (any, error) { + revision, err := tiersRevision(args["revision"]) + if err != nil { + return nil, err + } + return r.Tiers(revision) +} diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index eb5d1e2c..4e65b88f 100644 --- a/modules/mesh-delivery-view/manifest_test.go +++ b/modules/mesh-delivery-view/manifest_test.go @@ -87,7 +87,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 if m.Module != "mesh-delivery-view" { t.Errorf("module = %q", m.Module) } - want := []string{"mesh-build-go.pauses", "mesh-build-go.steps", "mesh-build-image.pauses", "mesh-build-image.steps", "mesh-build-node.pauses", "mesh-build-node.steps", "mesh-conditions.conditions", "mesh-config.resolved", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", "mesh-delivery-assigner.placement", "mesh-delivery-assigner.wants", "mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.config-candidates", "mesh-delivery-indexer.heard", "mesh-delivery-indexer.modules", "mesh-delivery-indexer.packages", "mesh-delivery-indexer.predefined", "mesh-delivery-indexer.taken", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "mesh-healers.attempts", "mesh-vault.signers", "node-engine.have"} // view_status reads the keeper's conditions and the healers' attempts (1952/V1); build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 + want := []string{"mesh-build-go.pauses", "mesh-build-go.steps", "mesh-build-image.pauses", "mesh-build-image.steps", "mesh-build-node.pauses", "mesh-build-node.steps", "mesh-conditions.conditions", "mesh-config.resolved", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", "mesh-delivery-assigner.placement", "mesh-delivery-assigner.wants", "mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.config-candidates", "mesh-delivery-indexer.graph", "mesh-delivery-indexer.heard", "mesh-delivery-indexer.modules", "mesh-delivery-indexer.packages", "mesh-delivery-indexer.predefined", "mesh-delivery-indexer.taken", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "mesh-healers.attempts", "mesh-provisioner.provisions", "mesh-vault.signers", "node-engine.have"} // view_tiers reads the module graph and the provisioner's keys (ADR 0603 §6); view_status reads the keeper's conditions and the healers' attempts (1952/V1); build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 got := slices.Clone(m.Reads) slices.Sort(got) if !reflect.DeepEqual(got, want) { @@ -119,7 +119,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 } // The two read tools of the indexer's modules (issue 1562), carried by a bundle the runtime launches; the indexer // itself serves no tool (ADR 0437 §4). Carrying them gives the tool runner read access to every bucket the view reads. - if want := []string{"view_status", "indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "indexer_predefined", "indexer_config_candidates", "indexer_packages", "builder_build", "builder_builds", "builder_history", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard"}; !reflect.DeepEqual(m.Tools, want) { + if want := []string{"view_status", "indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "indexer_predefined", "indexer_config_candidates", "indexer_packages", "builder_build", "builder_builds", "builder_history", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard", "view_tiers"}; !reflect.DeepEqual(m.Tools, want) { t.Errorf("tools = %v, want %v", m.Tools, want) } for _, a := range m.Build.Artifacts { diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json index 8ba20327..55bbbfb6 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -23,7 +23,8 @@ "build_usage", "view_reach", "indexer_module_history", - "indexer_heard" + "indexer_heard", + "view_tiers" ], "emits": [ "delivered", @@ -54,6 +55,7 @@ "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.config-candidates", + "mesh-delivery-indexer.graph", "mesh-delivery-indexer.heard", "mesh-delivery-indexer.modules", "mesh-delivery-indexer.packages", @@ -62,6 +64,7 @@ "mesh-delivery-teller.answers", "mesh-conditions.conditions", "mesh-healers.attempts", + "mesh-provisioner.provisions", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "mesh-vault.signers",