From df05b1a5f7964451f4c059c89670069494a4f1b2 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:14:22 +0100 Subject: [PATCH] =?UTF-8?q?The=20module=20graph=20carries=20verb=20and=20b?= =?UTF-8?q?us-state=20edges,=20and=20leaves=20out=20every=20verb=20edge=20?= =?UTF-8?q?on=20a=20cycle=20(ADR=200614=20=C2=A72,=20=C2=A73,=20=C2=A75,?= =?UTF-8?q?=20=C2=A76;=20issue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A graph node now names what its manifest invokes and serves, and the verb edges the graph leaves out; a bus-state entry is a requirement of bus-state. Before and Cycle count the kept verb edges beside bases and awaited provisions, never a package or toolchain edge. LeaveOut leaves out every verb edge whose ends reach each other, so the builder's remove and a holder's builder_versions hold nothing and the check never refuses them. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- sdk/graph/graph.go | 235 +++++++++++++++++++++++++++++++++++++--- sdk/graph/graph_test.go | 74 +++++++++++-- 2 files changed, 286 insertions(+), 23 deletions(-) diff --git a/sdk/graph/graph.go b/sdk/graph/graph.go index b0c7951c..4af2b677 100644 --- a/sdk/graph/graph.go +++ b/sdk/graph/graph.go @@ -91,6 +91,10 @@ func ParseKey(key string) (string, error) { // build module and the toolchain it was computed with. Provides are the provisions its manifest offers (its own name is // provided too and is not listed), Requires what it requires and does not require optionally, and Awaits those of // Requires a want of it waits for (catalogue.Awaited, ADR 0570 §4): the provision edges that order a source revision. +// A bus-state entry of its manifest is a requirement of the provision BusState (ADR 0614 §5). Invokes are the seat verbs +// (`seat:.`) and module tools (`.`) its manifest and its own bus accounts invoke, Serves those +// its claims serve and its own tools answer, in the same two forms (ADR 0614 §2), and LeftOut those of Invokes whose +// verb edge lies on a cycle and is left out of the graph, so no want waits on it (§3; LeaveOut works them out). // Every list is sorted and each entry once; an encode sorts them. type Node struct { V int `json:"v"` @@ -104,8 +108,15 @@ type Node struct { Provides []string `json:"provides,omitempty"` Requires []string `json:"requires,omitempty"` Awaits []string `json:"awaits,omitempty"` + Invokes []string `json:"invokes,omitempty"` + Serves []string `json:"serves,omitempty"` + LeftOut []string `json:"left-out,omitempty"` } +// BusState is the provision a bus-state entry of a manifest requires (ADR 0614 §5): the bus module's build that offers it +// goes before the module that asks bus state. +const BusState = "bus-state" + // Toolchain is a build edge of one language: the build module that builds it (Module) and the toolchain version // and recipe version its fingerprint names. type Toolchain struct { @@ -152,9 +163,30 @@ func (g Node) check() error { return fmt.Errorf("graph: the graph node awaits %q, which it does not require", a) } } + for _, list := range [][]string{g.Invokes, g.Serves} { + for _, v := range list { + if !isVerb(v) { + return fmt.Errorf("graph: the graph node names %q, which is neither seat:. nor .", v) + } + } + } + for _, l := range g.LeftOut { + if !slices.Contains(g.Invokes, l) { + return fmt.Errorf("graph: the graph node leaves out %q, which it does not invoke", l) + } + } return nil } +// isVerb says whether v is a seat verb `seat:.` or a module tool `.`. +func isVerb(v string) bool { + if _, _, ok := catalogue.SeatInvoke(v); ok { + return true + } + module, tool, ok := strings.Cut(v, ".") + return ok && module != "" && tool != "" && !strings.HasPrefix(v, "seat:") +} + func sortedOnce(in []string) []string { if len(in) == 0 { return nil @@ -167,6 +199,7 @@ func sortedOnce(in []string) []string { func (g Node) normal() Node { g.Bases, g.Packages = sortedOnce(g.Bases), sortedOnce(g.Packages) g.Provides, g.Requires, g.Awaits = sortedOnce(g.Provides), sortedOnce(g.Requires), sortedOnce(g.Awaits) + g.Invokes, g.Serves, g.LeftOut = sortedOnce(g.Invokes), sortedOnce(g.Serves), sortedOnce(g.LeftOut) if len(g.Toolchains) == 0 { g.Toolchains = nil } @@ -304,9 +337,22 @@ func (g Graph) Dependents(module string) []string { return out } -// Before are the modules name waits for, by the graph's ordering edges, sorted: its bases and, for each provision it -// awaits, every other module that provides it (ADR 0603 §1). A toolchain or package edge orders nothing (§3). -func (g Graph) Before(name string) []string { +// Servers are the modules whose Serves names invoke (a seat verb or a module tool), sorted. +func (g Graph) Servers(invoke string) []string { + var out []string + for name, n := range g { + if slices.Contains(n.Serves, invoke) { + out = append(out, name) + } + } + slices.Sort(out) + return out +} + +// waits are the modules name waits for, sorted: its bases, for each provision it awaits every other module that provides +// it (ADR 0603 §1), and with verbs for each invoke the module serving it (ADR 0614 §2), those it leaves out (§3) left +// out unless all is set. +func (g Graph) waits(name string, verbs, all bool) []string { n := g[name] out := slices.Clone(n.Bases) for _, a := range n.Awaits { @@ -316,15 +362,101 @@ func (g Graph) Before(name string) []string { } } } + if verbs { + for _, v := range n.Invokes { + if !all && slices.Contains(n.LeftOut, v) { + continue + } + for _, s := range g.Servers(v) { + if s != name || all { + out = append(out, s) + } + } + } + } return sortedOnce(out) } -// Cycle is the first cycle of the graph's ordering edges, as the modules it passes through with the first named again -// at its end (a -> b -> a), or nil for none (ADR 0603 §2: the check refuses it and names it). The ordering edges are a -// module's bases and, for each provision it awaits, every other module that provides it: modules ordered on each other -// would each wait for the other for good. A toolchain edge is no ordering edge (ADR 0603 §3: nothing waits on a build -// module), and neither is a package edge. The modules are visited in sorted order, so the same graph names the same -// cycle. +// Before are the modules name waits for, by the graph's ordering edges, sorted: its bases, for each provision it awaits +// (bus state among them, ADR 0614 §5) every other module that provides it (ADR 0603 §1), and for each verb it invokes +// and the graph keeps, every other module that serves it (ADR 0614 §2, §3). A toolchain or package edge orders nothing: +// it is a trigger, never a wait (ADR 0614 §6). +func (g Graph) Before(name string) []string { return g.waits(name, true, false) } + +// LeaveOut sets each node's LeftOut (ADR 0614 §3): every invoke whose verb edge lies on a cycle of the edges a part waits +// on (bases, awaited provisions, every verb edge), whichever module the edge starts from, a module invoking what it +// serves itself included. An edge lies on a cycle when the module it reaches reaches the module it starts from: both are +// in one strongly connected part of the graph. What is left of the graph then has no cycle through a verb edge, so the +// check's refusal (Cycle) never meets one. +func (g Graph) LeaveOut() { + part := g.parts(func(name string) []string { return g.waits(name, true, true) }) + for _, name := range slices.Sorted(maps.Keys(g)) { + n := g[name] + n.LeftOut = nil + for _, v := range n.Invokes { + for _, s := range g.Servers(v) { + if part[s] == part[name] { + n.LeftOut = append(n.LeftOut, v) + break + } + } + } + g[name] = n.normal() + } +} + +// parts numbers each module by the strongly connected part of edges it is in (Tarjan's), so two modules have one number +// when each reaches the other. +func (g Graph) parts(edges func(string) []string) map[string]int { + index, low, part := map[string]int{}, map[string]int{}, map[string]int{} + onStack := map[string]bool{} + var stack []string + next, parts := 0, 0 + var visit func(string) + visit = func(name string) { + index[name], low[name] = next, next + next++ + stack = append(stack, name) + onStack[name] = true + for _, to := range edges(name) { + if _, ours := g[to]; !ours { + continue + } + if _, seen := index[to]; !seen { + visit(to) + low[name] = min(low[name], low[to]) + } else if onStack[to] { + low[name] = min(low[name], index[to]) + } + } + if low[name] == index[name] { + for { + top := stack[len(stack)-1] + stack = stack[:len(stack)-1] + onStack[top] = false + part[top] = parts + if top == name { + break + } + } + parts++ + } + } + for _, name := range slices.Sorted(maps.Keys(g)) { + if _, seen := index[name]; !seen { + visit(name) + } + } + return part +} + +// Cycle is the first cycle of the graph's ordering edges (Before), as the modules it passes through with the first named +// again at its end (a -> b -> a), or nil for none (ADR 0603 §2: the check refuses it and names it). The ordering edges +// are what a part waits on, provisions, bus state and the verb edges the graph keeps (ADR 0614 §6), and bases, since a +// module built on another's artifact can never build in a cycle of bases: modules ordered on each other would each wait +// for the other for good. A verb edge the graph leaves out (LeaveOut) is none, and neither is a toolchain edge (ADR 0603 +// §3: nothing waits on a build module) or a package edge (ADR 0614 §6: a trigger). The modules are visited in sorted +// order, so the same graph names the same cycle. func (g Graph) Cycle() []string { edges := g.Before const ( @@ -367,9 +499,11 @@ func (g Graph) Cycle() []string { } // EdgesOf is the graph node of manifest's own edges: its bases (each module its build stands on), what it provides, -// what it requires and does not require optionally, and of those what a want of it waits for (catalogue.Awaited, as the -// assigner judges it), providers answering the manifests that provide a provision. The fingerprint, place, packages and -// toolchains are the indexer's to add; this is the part a reader of manifests alone (the checker) works out the same way. +// what it requires and does not require optionally, a bus-state entry as a requirement of BusState (ADR 0614 §5), and of +// those what a want of it waits for (catalogue.Awaited, as the assigner judges it; BusState whenever a provider offers +// it), providers answering the manifests that provide a provision; and the verbs and tools it invokes and serves +// (InvokesOf, ServesOf). The fingerprint, place, packages, toolchains and the verb edges left out (LeaveOut, over the +// whole graph) are the indexer's to add; this is the part a reader of manifests alone (the checker) works out the same way. func EdgesOf(manifest catalogue.Manifest, providers func(provision string) []catalogue.Manifest) Node { var n Node if manifest.Build != nil { @@ -388,11 +522,56 @@ func EdgesOf(manifest catalogue.Manifest, providers func(provision string) []cat } } n.Awaits = catalogue.Awaited(manifest, providers) + if len(manifest.BusState) > 0 { + n.Requires = append(n.Requires, BusState) + if len(providers(BusState)) > 0 { + n.Awaits = append(n.Awaits, BusState) + } + } + n.Invokes, n.Serves = InvokesOf(manifest), ServesOf(manifest) return n.normal() } +// InvokesOf are the seat verbs and module tools manifest invokes, its own and its processes' and tools bundles' bus +// accounts' (catalogue.Manifest.BusAccounts), sorted, each once; `*`, every tool, names no edge and is left out. +func InvokesOf(manifest catalogue.Manifest) []string { + all := slices.Clone(manifest.Invokes) + for _, a := range manifest.BusAccounts() { + all = append(all, a.Bus.Invokes...) + } + var out []string + for _, v := range all { + if isVerb(v) { + out = append(out, v) + } + } + return sortedOnce(out) +} + +// ServesOf are the seat verbs manifest's claims serve (`seat:.`, catalogue.Claim.ServesFor) and its own +// tools (`.`), sorted, each once. +func ServesOf(manifest catalogue.Manifest) []string { + var out []string + for _, c := range manifest.Claims { + for _, v := range c.ServesFor(manifest) { + out = append(out, "seat:"+c.Name+"."+v) + } + } + for _, t := range manifest.Tools { + out = append(out, manifest.Module+"."+t) + } + var verbs []string + for _, v := range out { + if isVerb(v) { + verbs = append(verbs, v) + } + } + return sortedOnce(verbs) +} + // OfManifests is the graph of these manifests' own edges (EdgesOf), by module, each provision's providers being -// the manifests among them that offer it: what the check works out for a change to find a cycle (ADR 0603 §2). +// the manifests among them that offer it, with the verb edges on a cycle left out (LeaveOut): what the check works out +// for a change to find a cycle (ADR 0603 §2, ADR 0614 §3). func OfManifests(manifests map[string]catalogue.Manifest) Graph { providers := func(provision string) []catalogue.Manifest { var out []catalogue.Manifest @@ -407,5 +586,35 @@ func OfManifests(manifests map[string]catalogue.Manifest) Graph { for name, m := range manifests { g[name] = EdgesOf(m, providers) } + g.LeaveOut() return g } + +// Served is whether a running build serves a seat verb or module tool (ADR 0614 §2, §7), counted from the holders' own +// haves as a provider is (ADR 0603 §5): every have running healthy, by node and module, whose build's manifest serves it +// (servesOf answers the verbs of a module's build at a fingerprint, ServesOf of its manifest, and whether it was read). +// A build whose manifest cannot be read counts what fallback answers of its module (the graph's Serves, so a build read +// late never holds its callers for good, or nothing), and is named in unread, sorted, so its reader says so. +func Served(haves map[string]map[string]delivery.Have, servesOf func(module, fp string) ([]string, bool), + fallback func(module string) []string) (served func(invoke string) bool, unread []string) { + set := map[string]bool{} + for _, node := range slices.Sorted(maps.Keys(haves)) { + for _, module := range slices.Sorted(maps.Keys(haves[node])) { + h := haves[node][module] + if h.Phase != delivery.PhaseRunning || h.Health != delivery.HealthOK { + continue + } + serves, ok := servesOf(module, h.FP) + if !ok { + unread = append(unread, module) + if fallback != nil { + serves = fallback(module) + } + } + for _, v := range serves { + set[v] = true + } + } + } + return func(v string) bool { return set[v] }, slices.Compact(slices.Sorted(slices.Values(unread))) +} diff --git a/sdk/graph/graph_test.go b/sdk/graph/graph_test.go index 2848f19c..e87a15c5 100644 --- a/sdk/graph/graph_test.go +++ b/sdk/graph/graph_test.go @@ -2,6 +2,7 @@ package graph import ( "errors" + "maps" "reflect" "slices" "strings" @@ -97,6 +98,39 @@ func TestGraphEdges(t *testing.T) { if got := mg.Cycle(); !reflect.DeepEqual(got, []string{"db", "web", "db"}) { t.Errorf("the cycle of manifests = %v", got) } + // The verb and bus-state edges (ADR 0614 §2, §3, §5, §6), from manifests alone: a caller waits for the module serving + // what it invokes, a bus-state entry requires bus-state of the bus module offering it, the builder's remove and the + // holder's builder_versions close a cycle and both are left out, read from either side, a package edge gives no order, + // and a module invoking its own tool is no wait. + builder := catalogue.Manifest{Module: "builder", Tools: []string{"builder_versions"}, Invokes: []string{"seat:artifact-store.remove"}} + store := catalogue.Manifest{Module: "store", Claims: []catalogue.Claim{{Name: "artifact-store", Scope: catalogue.ScopeMesh, + Serves: []string{"remove"}}}, Invokes: []string{"builder.builder_versions", "*"}} + verbs := map[string]catalogue.Manifest{ + "builder": builder, "store": store, + "bus": {Module: "bus", Provides: []catalogue.Offer{{Name: BusState, Scope: catalogue.ScopeMesh}}}, + "keeper": {Module: "keeper", BusState: []catalogue.BusStateEntry{{Name: "cache", Storage: "key-value"}}}, + "caller": {Module: "caller", Invokes: []string{"seat:artifact-store.remove", "caller.own", "nobody.serves"}, Tools: []string{"own"}}, + } + vg := OfManifests(verbs) + for name, want := range map[string][]string{"caller": {"store"}, "keeper": {"bus"}, "builder": nil, "store": nil} { + if got := vg.Before(name); !reflect.DeepEqual(got, want) { + t.Errorf("%s waits for %v, want %v", name, got, want) + } + } + if b, s := vg["builder"], vg["store"]; !reflect.DeepEqual(b.LeftOut, []string{"seat:artifact-store.remove"}) || + !reflect.DeepEqual(s.LeftOut, []string{"builder.builder_versions"}) || + !reflect.DeepEqual(s.Serves, []string{"seat:artifact-store.remove"}) || !reflect.DeepEqual(s.Invokes, []string{"builder.builder_versions"}) { + t.Errorf("the builder's cycle is not left out from either side: %+v %+v", b, s) + } + if k := vg["keeper"]; !reflect.DeepEqual(k.Requires, []string{BusState}) || !reflect.DeepEqual(k.Awaits, []string{BusState}) { + t.Errorf("a bus-state entry is no requirement of bus-state: %+v", k) + } + if c := vg["caller"]; !reflect.DeepEqual(c.LeftOut, []string{"caller.own"}) || vg.Cycle() != nil { + t.Errorf("a module's own tool is a wait, or a left-out verb edge a cycle: %+v %v", c, vg.Cycle()) + } + if got := OfManifests(map[string]catalogue.Manifest{"keeper": verbs["keeper"]})["keeper"]; got.Awaits != nil { + t.Errorf("bus-state with no module offering it is awaited: %+v", got) + } for name, c := range map[string]struct { edit func(Graph) want []string @@ -115,6 +149,19 @@ func TestGraphEdges(t *testing.T) { w.Provides = []string{"site"} g["web"] = w }, []string{"db", "web", "db"}}, + "a package cycle orders nothing": {func(g Graph) { + l := g["lib"] + l.Packages = []string{"git.novox.be/novox/mesh-catalog/modules/app"} + g["lib"] = l + }, nil}, + "a verb edge with a provision, before LeaveOut leaves it out": {func(g Graph) { + d := g["db"] + d.Invokes = []string{"web.page"} + g["db"] = d + w := g["web"] + w.Serves = []string{"web.page"} + g["web"] = w + }, []string{"db", "web", "db"}}, "a requirement not awaited is no edge": {func(g Graph) { d := g["db"] d.Requires = []string{"site"} @@ -132,6 +179,9 @@ func TestGraphEdges(t *testing.T) { if got := h.Cycle(); !reflect.DeepEqual(got, c.want) { t.Errorf("%s: cycle = %v, want %v", name, got, c.want) } + if h.LeaveOut(); h.Cycle() != nil && slices.ContainsFunc(slices.Collect(maps.Values(h)), func(n Node) bool { return len(n.Invokes) > 0 }) { + t.Errorf("%s: a cycle through a verb edge is left once its verb edges are left out: %v", name, h.Cycle()) + } } } @@ -140,7 +190,9 @@ func sampleNode() Node { return Node{FP: "src2:" + strings.Repeat("a", 64), Repository: "novox/mesh-catalog", Path: "modules/gitea", Bases: []string{"z-base", "a-base"}, Packages: []string{"git.novox.be/novox/mesh-sdk/go"}, Toolchains: map[string]Toolchain{"go": {Module: "build-go", Version: "go1.27.1", Recipe: "3"}}, - Provides: []string{"forge"}, Requires: []string{"postgres-database", "dns"}, Awaits: []string{"postgres-database"}} + Provides: []string{"forge"}, Requires: []string{"postgres-database", "dns"}, Awaits: []string{"postgres-database"}, + Invokes: []string{"seat:mesh-catalog.modules", "builder.builder_versions"}, Serves: []string{"seat:forge.pulls"}, + LeftOut: []string{"builder.builder_versions"}} } // nodeValues: a node round-trips with its lists sorted, an unknown version is refused, a node that does not hold is @@ -169,15 +221,17 @@ func nodeValues(t *testing.T) { change func(*Node) word string }{ - "both": {func(g *Node) { g.None = "why" }, "both"}, - "neither": {func(g *Node) { g.FP = "" }, "neither"}, - "an old form": {func(g *Node) { g.FP = "src1:x" }, "src2:"}, - "a dotted base": {func(g *Node) { g.Bases = []string{"a.b"} }, "a.b"}, - "a foreign package": {func(g *Node) { g.Packages = []string{"example.com/x"} }, "example.com/x"}, - "a toolchain of no module": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Version: "go1", Recipe: "1"}} }, "build module"}, - "a toolchain of no recipe": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Module: "b", Version: "go1"}} }, "recipe"}, - "awaiting what it does not require": {func(g *Node) { g.Awaits = []string{"other"} }, "other"}, - "no repository": {func(g *Node) { g.Repository = "" }, "repositor"}, + "both": {func(g *Node) { g.None = "why" }, "both"}, + "neither": {func(g *Node) { g.FP = "" }, "neither"}, + "an old form": {func(g *Node) { g.FP = "src1:x" }, "src2:"}, + "a dotted base": {func(g *Node) { g.Bases = []string{"a.b"} }, "a.b"}, + "a foreign package": {func(g *Node) { g.Packages = []string{"example.com/x"} }, "example.com/x"}, + "a toolchain of no module": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Version: "go1", Recipe: "1"}} }, "build module"}, + "a toolchain of no recipe": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Module: "b", Version: "go1"}} }, "recipe"}, + "awaiting what it does not require": {func(g *Node) { g.Awaits = []string{"other"} }, "other"}, + "no repository": {func(g *Node) { g.Repository = "" }, "repositor"}, + "leaving out what it does not invoke": {func(g *Node) { g.LeftOut = []string{"x.y"} }, "x.y"}, + "an invoke of no verb": {func(g *Node) { g.Invokes = []string{"seat:nothing"} }, "seat:nothing"}, } { g := sampleNode() c.change(&g)