The module graph is mesh-delivery's own: a Go module sdk/ with package graph, used by path by the indexer, the view and the checker, and the seat mesh-catalog's answers built from mesh-sdk's contract types (the operator, 2026-11-03: no graph-related code in mesh-sdk, only contracts; research 081 D11, D13)
This commit is contained in:
@@ -10,6 +10,8 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
)
|
||||
|
||||
// The check refuses a change whose module graph has a cycle and names it (novox/hq ADR 0603 §2): modules that would each
|
||||
@@ -49,15 +51,15 @@ func (st *state) refuseCycle(ctx context.Context) *delivery.Verdict {
|
||||
if err != nil {
|
||||
return fail(goModule, "the change's module graph cannot be worked out, so no cycle can be ruled out: "+err.Error())
|
||||
}
|
||||
cycle := delivery.ManifestGraph(change).Cycle()
|
||||
cycle := graph.OfManifests(change).Cycle()
|
||||
if cycle == nil {
|
||||
st.c.note("the change's module graph has no cycle")
|
||||
return nil
|
||||
}
|
||||
words := strings.Join(cycle, " -> ")
|
||||
if before, err := read(req.Cases[1].Manifests); err == nil {
|
||||
among := delivery.Graph{}
|
||||
for name, n := range delivery.ManifestGraph(before) {
|
||||
among := graph.Graph{}
|
||||
for name, n := range graph.OfManifests(before) {
|
||||
if slices.Contains(cycle, name) {
|
||||
among[name] = n
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
|
||||
"mesh-delivery-indexer/internal/index"
|
||||
)
|
||||
|
||||
@@ -35,7 +37,7 @@ func CatalogTools(c index.CatalogRead) []stdio.Tool {
|
||||
}
|
||||
|
||||
// catalogRead reads the indexer's modules, manifests and graph states for the seat's verbs.
|
||||
func catalogRead(modules, manifests, graph stdio.KeptState) index.CatalogRead {
|
||||
func catalogRead(modules, manifests, nodes stdio.KeptState) index.CatalogRead {
|
||||
return index.CatalogRead{
|
||||
Modules: func() (map[string]delivery.Module, error) {
|
||||
keys, err := modules.Keys()
|
||||
@@ -72,21 +74,21 @@ func catalogRead(modules, manifests, graph stdio.KeptState) index.CatalogRead {
|
||||
kept, err := delivery.DecodeManifest(e.Value)
|
||||
return kept.JSON, err
|
||||
},
|
||||
Graph: func() (delivery.Graph, error) {
|
||||
keys, err := graph.Keys()
|
||||
Graph: func() (graph.Graph, error) {
|
||||
keys, err := nodes.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := delivery.Graph{}
|
||||
g := graph.Graph{}
|
||||
for _, k := range keys {
|
||||
e, err := graph.Get(k)
|
||||
e, err := nodes.Get(k)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("graph.%s: %w", k, err)
|
||||
}
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
if n, err := delivery.DecodeGraphNode(e.Value); err == nil {
|
||||
if n, err := graph.Decode(e.Value); err == nil {
|
||||
g[k] = n
|
||||
}
|
||||
}
|
||||
@@ -97,5 +99,5 @@ func catalogRead(modules, manifests, graph stdio.KeptState) index.CatalogRead {
|
||||
|
||||
// catalogTools are the seat's verbs over the indexer's own states, as main serves them once the indexer claims the seat.
|
||||
func catalogTools() []stdio.Tool {
|
||||
return CatalogTools(catalogRead(stdio.State("modules"), stdio.State("manifests"), stdio.State(delivery.GraphState)))
|
||||
return CatalogTools(catalogRead(stdio.State("modules"), stdio.State("manifests"), stdio.State(graph.State)))
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import (
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"mesh-delivery-indexer/internal/index"
|
||||
@@ -300,7 +302,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
// the judge refuses no use of an old section.
|
||||
oldSections := process.AwaitBucket(ctx, js, conn, index.OldSectionsBucket, "the count of the old sections is not kept, so the judge refuses no use of one", logger.Printf)
|
||||
// The module graph (ADR 0603 §2): a state this delivery brings, bound when it appears; until then no graph is written.
|
||||
graph := process.AwaitBucket(ctx, js, conn, delivery.GraphBucket, "no module graph is written", logger.Printf)
|
||||
graph := process.AwaitBucket(ctx, js, conn, graph.Bucket, "no module graph is written", logger.Printf)
|
||||
ix := &index.Indexer{OldSections: awaitedStore{oldSections}, Store: kvStore{candidates}, Modules: kvStore{modules}, Manifests: kvStore{manifests}, Taken: awaitedStore{taken}, Builds: known,
|
||||
Verdicts: awaitedStore{verdicts}, SayRefused: sayRefused(js, cred, logger.Printf), SayAccepted: sayAccepted(js, cred, logger.Printf),
|
||||
Predefined: awaitedStore{predefined}, ConfigCandidates: awaitedStore{configCandidates}, Logf: logger.Printf,
|
||||
|
||||
@@ -67,7 +67,7 @@ type Indexer struct {
|
||||
// Packages is the packages state (delivery.PackagesBucket, packages.go; novox/hq ADR 0593 §2); nil publishes none,
|
||||
// and every fingerprint takes the version line.
|
||||
Packages Store
|
||||
// Graph is the module graph's state (delivery.GraphBucket, graph.go; novox/hq ADR 0603 §2); nil writes no graph.
|
||||
// Graph is the module graph's state (graph.Bucket, graph.go; novox/hq ADR 0603 §2); nil writes no graph.
|
||||
Graph Store
|
||||
// Files asks the forge for every blob under each directory at a commit, by directory then path relative to it, each
|
||||
// with its git object id (gitea.gitea_commit_trees with files): the listing a package's digest is computed from.
|
||||
|
||||
@@ -9,13 +9,15 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
)
|
||||
|
||||
// The seat mesh-catalog's verbs, answered from the indexer's own states (novox/hq ADR 0604 §1, §2): modules, module,
|
||||
// provides and dependents read the modules state, the manifests state and the module graph, and write nothing. The
|
||||
// indexer serves no request subject (ADR 0437 §4): its tools bundle serves them as <seat>.<verb>. Each answer keeps the
|
||||
// field names of the old catalog module's tool it replaces (catalog_modules, catalog_module, catalog_provides,
|
||||
// catalog_dependents), so a caller that moves to the seat reads the same fields.
|
||||
// catalog_dependents): the answers are the seat's contract (catalogue.CatalogModules and the rest), built here.
|
||||
|
||||
// CatalogRead reads the states the seat's verbs answer from.
|
||||
type CatalogRead struct {
|
||||
@@ -24,99 +26,75 @@ type CatalogRead struct {
|
||||
// Manifest is the manifest kept under a digest in the manifests state, as its JSON.
|
||||
Manifest func(digest string) (string, error)
|
||||
// Graph is the module graph, by module.
|
||||
Graph func() (delivery.Graph, error)
|
||||
Graph func() (graph.Graph, error)
|
||||
}
|
||||
|
||||
// CatalogEntry is one module of the catalog: its repository, its directory there (delivery.RootPath for the root), the
|
||||
// merge commit it was last changed at, and its fingerprint or why it has none.
|
||||
type CatalogEntry struct {
|
||||
Module string `json:"module"`
|
||||
Repository string `json:"repository"`
|
||||
Path string `json:"path"`
|
||||
Commit string `json:"commit"`
|
||||
FP string `json:"fp,omitempty"`
|
||||
None string `json:"none,omitempty"`
|
||||
}
|
||||
|
||||
// CatalogModule is one module whole: its entry and its manifest as the trunk holds it.
|
||||
type CatalogModule struct {
|
||||
CatalogEntry
|
||||
Manifest json.RawMessage `json:"manifest"`
|
||||
}
|
||||
|
||||
// CatalogEnd is a module that provides, requires or depends on something, with its commit; Scope is a provider's scope.
|
||||
type CatalogEnd struct {
|
||||
Module string `json:"module"`
|
||||
Commit string `json:"commit"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
func entryOf(name string, m delivery.Module) CatalogEntry {
|
||||
return CatalogEntry{Module: name, Repository: m.Repository, Path: m.Path, Commit: m.Commit, FP: m.FP2, None: m.None2}
|
||||
func entryOf(name string, m delivery.Module) catalogue.CatalogEntry {
|
||||
return catalogue.CatalogEntry{Module: name, Repository: m.Repository, Path: m.Path, Commit: m.Commit, FP: m.FP2, None: m.None2}
|
||||
}
|
||||
|
||||
// CatalogModulesAnswer is the verb modules: every module the modules state holds, by name.
|
||||
func (c CatalogRead) CatalogModulesAnswer() (map[string]any, error) {
|
||||
func (c CatalogRead) CatalogModulesAnswer() (catalogue.CatalogModules, error) {
|
||||
mods, err := c.Modules()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the indexer's modules cannot be read: %w", err)
|
||||
return catalogue.CatalogModules{}, fmt.Errorf("the indexer's modules cannot be read: %w", err)
|
||||
}
|
||||
out := []CatalogEntry{}
|
||||
out := []catalogue.CatalogEntry{}
|
||||
for _, name := range slices.Sorted(maps.Keys(mods)) {
|
||||
out = append(out, entryOf(name, mods[name]))
|
||||
}
|
||||
return map[string]any{"modules": out}, nil
|
||||
return catalogue.CatalogModules{Modules: out}, nil
|
||||
}
|
||||
|
||||
// CatalogModuleAnswer is the verb module: one module whole, its manifest included.
|
||||
func (c CatalogRead) CatalogModuleAnswer(name string) (map[string]any, error) {
|
||||
func (c CatalogRead) CatalogModuleAnswer(name string) (catalogue.CatalogOne, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("name the module")
|
||||
return catalogue.CatalogOne{}, fmt.Errorf("name the module")
|
||||
}
|
||||
mods, err := c.Modules()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the indexer's modules cannot be read: %w", err)
|
||||
return catalogue.CatalogOne{}, fmt.Errorf("the indexer's modules cannot be read: %w", err)
|
||||
}
|
||||
m, ok := mods[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the trunk holds no module %s", name)
|
||||
return catalogue.CatalogOne{}, fmt.Errorf("the trunk holds no module %s", name)
|
||||
}
|
||||
one := CatalogModule{CatalogEntry: entryOf(name, m)}
|
||||
one := catalogue.CatalogModule{CatalogEntry: entryOf(name, m)}
|
||||
if m.Manifest == "" {
|
||||
return nil, fmt.Errorf("the indexer keeps no manifest of %s", name)
|
||||
return catalogue.CatalogOne{}, fmt.Errorf("the indexer keeps no manifest of %s", name)
|
||||
}
|
||||
raw, err := c.Manifest(m.Manifest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the manifest %s of %s cannot be read: %w", m.Manifest, name, err)
|
||||
return catalogue.CatalogOne{}, fmt.Errorf("the manifest %s of %s cannot be read: %w", m.Manifest, name, err)
|
||||
}
|
||||
one.Manifest = json.RawMessage(raw)
|
||||
return map[string]any{"module": one}, nil
|
||||
return catalogue.CatalogOne{Module: one}, nil
|
||||
}
|
||||
|
||||
// CatalogProvidesAnswer is the verb provides: the modules that provide a provision, each with its scope (a module provides
|
||||
// its own name, at node scope), and the modules that require it, from the module graph's provision edges.
|
||||
func (c CatalogRead) CatalogProvidesAnswer(provision string) (map[string]any, error) {
|
||||
func (c CatalogRead) CatalogProvidesAnswer(provision string) (catalogue.CatalogProvides, error) {
|
||||
provision = strings.TrimSpace(provision)
|
||||
if provision == "" {
|
||||
return nil, fmt.Errorf("name the provision")
|
||||
return catalogue.CatalogProvides{}, fmt.Errorf("name the provision")
|
||||
}
|
||||
g, mods, err := c.graphAndModules()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return catalogue.CatalogProvides{}, err
|
||||
}
|
||||
provides, requires := []CatalogEnd{}, []CatalogEnd{}
|
||||
provides, requires := []catalogue.CatalogEnd{}, []catalogue.CatalogEnd{}
|
||||
for _, name := range g.Providers(provision) {
|
||||
end := CatalogEnd{Module: name, Commit: mods[name].Commit, Scope: catalogue.ScopeNode}
|
||||
end := catalogue.CatalogEnd{Module: name, Commit: mods[name].Commit, Scope: catalogue.ScopeNode}
|
||||
if name != provision {
|
||||
end.Scope = c.scopeOf(mods[name], provision)
|
||||
}
|
||||
provides = append(provides, end)
|
||||
}
|
||||
for _, name := range g.Requirers(provision) {
|
||||
requires = append(requires, CatalogEnd{Module: name, Commit: mods[name].Commit})
|
||||
requires = append(requires, catalogue.CatalogEnd{Module: name, Commit: mods[name].Commit})
|
||||
}
|
||||
return map[string]any{"provision": provision, "provides": provides, "requires": requires}, nil
|
||||
return catalogue.CatalogProvides{Provision: provision, Provides: provides, Requires: requires}, nil
|
||||
}
|
||||
|
||||
// scopeOf is the scope the module's manifest offers provision at, "" when its manifest cannot be read.
|
||||
@@ -142,23 +120,23 @@ func (c CatalogRead) scopeOf(m delivery.Module, provision string) string {
|
||||
|
||||
// CatalogDependentsAnswer is the verb dependents: the modules whose build edges reach the module, directly or through
|
||||
// others, from the module graph.
|
||||
func (c CatalogRead) CatalogDependentsAnswer(module string) (map[string]any, error) {
|
||||
func (c CatalogRead) CatalogDependentsAnswer(module string) (catalogue.CatalogDependents, error) {
|
||||
module = strings.TrimSpace(module)
|
||||
if module == "" {
|
||||
return nil, fmt.Errorf("name the module")
|
||||
return catalogue.CatalogDependents{}, fmt.Errorf("name the module")
|
||||
}
|
||||
g, mods, err := c.graphAndModules()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return catalogue.CatalogDependents{}, err
|
||||
}
|
||||
out := []CatalogEnd{}
|
||||
out := []catalogue.CatalogEnd{}
|
||||
for _, name := range g.Dependents(module) {
|
||||
out = append(out, CatalogEnd{Module: name, Commit: mods[name].Commit})
|
||||
out = append(out, catalogue.CatalogEnd{Module: name, Commit: mods[name].Commit})
|
||||
}
|
||||
return map[string]any{"module": module, "dependents": out}, nil
|
||||
return catalogue.CatalogDependents{Module: module, Dependents: out}, nil
|
||||
}
|
||||
|
||||
func (c CatalogRead) graphAndModules() (delivery.Graph, map[string]delivery.Module, error) {
|
||||
func (c CatalogRead) graphAndModules() (graph.Graph, map[string]delivery.Module, error) {
|
||||
g, err := c.Graph()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("the module graph cannot be read: %w", err)
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
)
|
||||
|
||||
// capturedKeys are the field names of the first object at path (a list's first element when the value is a list) in the
|
||||
@@ -67,15 +69,15 @@ func catalogVerbsAnswerFromTheStates(t *testing.T) {
|
||||
raw, _, err := ix.manifestRaw(context.Background(), digest)
|
||||
return raw, err
|
||||
},
|
||||
Graph: func() (delivery.Graph, error) {
|
||||
g := delivery.Graph{}
|
||||
Graph: func() (graph.Graph, error) {
|
||||
g := graph.Graph{}
|
||||
for k := range ix.Graph.(*memStore).vals {
|
||||
g[k] = graphOf(t, ix, k)
|
||||
}
|
||||
return g, nil
|
||||
},
|
||||
}
|
||||
answer := func(v map[string]any, err error) []byte {
|
||||
answer := func(v any, err error) []byte {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -8,10 +8,12 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
)
|
||||
|
||||
// The module graph (novox/hq ADR 0603 §2): at every recompute the indexer writes, in its state graph
|
||||
// (delivery.GraphBucket), one key per module it holds the inputs of, its build and provision edges as that recompute
|
||||
// (graph.Bucket), one key per module it holds the inputs of, its build and provision edges as that recompute
|
||||
// computed them, and deletes the key of a module the modules state no longer holds. A key is written only when its value
|
||||
// changes, so its history of 5 is five changes of the module's edges. The delivery view, the checker and the seat
|
||||
// mesh-catalog's verbs read it; nothing stores an order (ADR 0603 §1).
|
||||
@@ -33,11 +35,11 @@ type graphEdges struct {
|
||||
|
||||
// graphNode is the graph node of e: its edges, the awaited provisions judged against providers (catalogue.Awaited, as
|
||||
// the assigner judges them).
|
||||
func graphNode(e graphEdges, providers func(string) []catalogue.Manifest) delivery.GraphNode {
|
||||
func graphNode(e graphEdges, providers func(string) []catalogue.Manifest) graph.Node {
|
||||
if e.fp == "" && e.none == "" {
|
||||
e.none = "its fingerprint was not computed"
|
||||
}
|
||||
n := delivery.GraphEdgesOf(e.manifest, providers)
|
||||
n := graph.EdgesOf(e.manifest, providers)
|
||||
n.FP, n.None, n.Repository, n.Path = e.fp, e.none, e.cur.Repository, e.cur.Path
|
||||
n.Packages = slices.Sorted(maps.Keys(e.pkgs))
|
||||
tcs := e.tcs
|
||||
@@ -50,9 +52,9 @@ func graphNode(e graphEdges, providers func(string) []catalogue.Manifest) delive
|
||||
continue
|
||||
}
|
||||
if n.Toolchains == nil {
|
||||
n.Toolchains = map[string]delivery.GraphToolchain{}
|
||||
n.Toolchains = map[string]graph.Toolchain{}
|
||||
}
|
||||
n.Toolchains[language] = delivery.GraphToolchain{Module: builder, Version: t.Version, Recipe: t.Recipe}
|
||||
n.Toolchains[language] = graph.Toolchain{Module: builder, Version: t.Version, Recipe: t.Recipe}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -79,7 +81,7 @@ func (ix *Indexer) writeGraph(ctx context.Context, edges map[string]graphEdges)
|
||||
return out
|
||||
}
|
||||
for _, name := range names {
|
||||
key, err := delivery.GraphKey(name)
|
||||
key, err := graph.Key(name)
|
||||
if err != nil {
|
||||
ix.logf("graph.%s is not written: %v", name, err)
|
||||
continue
|
||||
@@ -87,11 +89,11 @@ func (ix *Indexer) writeGraph(ctx context.Context, edges map[string]graphEdges)
|
||||
next := graphNode(edges[name], providers)
|
||||
err = write(ctx, ix.Graph, key, func(value []byte, found bool) ([]byte, bool, error) {
|
||||
if found {
|
||||
if cur, err := delivery.DecodeGraphNode(value); err == nil && delivery.SameGraphNode(cur, next) {
|
||||
if cur, err := graph.Decode(value); err == nil && graph.Same(cur, next) {
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
data, err := delivery.EncodeGraphNode(next)
|
||||
data, err := graph.Encode(next)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("graph.%s: %w", key, err)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
|
||||
"mesh-delivery-indexer/internal/fingerprint"
|
||||
)
|
||||
|
||||
@@ -61,13 +63,13 @@ type mod struct {
|
||||
// merged applies one merge of the catalogue carrying these modules (every one a Go bundle's go.sum, a TypeScript bundle's
|
||||
// package-lock.json) and returns the indexer.
|
||||
// graphOf is the graph node the indexer holds of module.
|
||||
func graphOf(t *testing.T, ix *Indexer, module string) delivery.GraphNode {
|
||||
func graphOf(t *testing.T, ix *Indexer, module string) graph.Node {
|
||||
t.Helper()
|
||||
v, ok := ix.Graph.(*memStore).vals[module]
|
||||
if !ok {
|
||||
t.Fatalf("graph.%s is not held", module)
|
||||
}
|
||||
g, err := delivery.DecodeGraphNode(v)
|
||||
g, err := graph.Decode(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -232,7 +234,7 @@ func TestBasesAreComputedFirstAndNoFingerprintTakesTheBuilder(t *testing.T) { //
|
||||
t.Errorf("other: fp2 = %q none2 = %q, want none2 naming the base ts-base", m.FP2, m.None2)
|
||||
}
|
||||
// L3 (ADR 0603 §2): the module graph holds each module's edges as this recompute computed them.
|
||||
goTool := map[string]delivery.GraphToolchain{"go": {Module: "mesh-build-go", Version: "go1.27.1", Recipe: "1"}}
|
||||
goTool := map[string]graph.Toolchain{"go": {Module: "mesh-build-go", Version: "go1.27.1", Recipe: "1"}}
|
||||
if g := graphOf(t, ix, "avahi"); g.FP != want["avahi"] || !reflect.DeepEqual(g.Bases, []string{"mesh-tools"}) ||
|
||||
!reflect.DeepEqual(g.Toolchains, goTool) || g.Repository != catalogueRepo || g.Path != "modules/avahi" {
|
||||
t.Errorf("graph.avahi = %+v", g)
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
)
|
||||
|
||||
// The controller's manifest reader is mesh-controller internal/catalogue (Manifest, manifest.go: Consumes :521,
|
||||
@@ -91,7 +93,7 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build
|
||||
}
|
||||
// 1126 note 76: heard keeps the last 64 FORGE messages the indexer handled, for indexer_heard.
|
||||
if want := []state{{Name: "candidates", History: 10}, {Name: "heard", History: 64}, {Name: "manifests", History: 1}, {Name: "modules", History: 20}, {Name: "predefined", History: 5}, {Name: "config-candidates", History: 5}, {Name: "taken", History: 1}, {Name: "takeins", History: 1}, {Name: "verdicts", History: 5}, {Name: "oldsections", History: 1}, {Name: delivery.PackagesState, History: delivery.PackagesHistory},
|
||||
{Name: delivery.GraphState, History: delivery.GraphHistory}}; !reflect.DeepEqual(m.State, want) {
|
||||
{Name: graph.State, History: graph.History}}; !reflect.DeepEqual(m.State, want) {
|
||||
t.Errorf("state = %+v, want %+v", m.State, want)
|
||||
}
|
||||
if want := []string{"mesh-build-go.toolchains", "mesh-build-node.toolchains", "mesh-delivery-assigner.placement", "mesh-delivery-builder.build-log", "mesh-delivery-checker.previews", "mesh-delivery-gate.source"}; !reflect.DeepEqual(sorted(m.Reads), want) {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"mesh-delivery-view/internal/view"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
@@ -318,7 +320,7 @@ 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)),
|
||||
GraphState: read(stdio.State(delivery.GraphRead)), SourceState: read(stdio.State(delivery.GateModule + ".source")),
|
||||
GraphState: read(stdio.State(graph.Read)), 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")),
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
|
||||
"mesh-delivery-view/internal/view"
|
||||
@@ -34,8 +36,8 @@ func tiersToolSaysEachModulesWait(t *testing.T) {
|
||||
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)
|
||||
node := func(g graph.Node) string {
|
||||
b, err := graph.Encode(g)
|
||||
return encoded(t, b, err)
|
||||
}
|
||||
build := func(b delivery.BuilderBuild) string {
|
||||
@@ -59,9 +61,9 @@ func tiersToolSaysEachModulesWait(t *testing.T) {
|
||||
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"}),
|
||||
"db": node(graph.Node{FP: fp("a"), Repository: "novox/mesh-catalog", Provides: []string{"database"}}),
|
||||
"web": node(graph.Node{FP: fp("b"), Repository: "novox/mesh-catalog", Requires: []string{"database"}, Awaits: []string{"database"}}),
|
||||
"lib": node(graph.Node{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}),
|
||||
|
||||
@@ -55,7 +55,7 @@ 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
|
||||
// GraphState is the indexer's module graph (graph.Bucket, 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.
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
)
|
||||
|
||||
@@ -93,13 +95,13 @@ func (r Reader) Tiers(revision uint64) (Tiers, error) {
|
||||
len(names), TiersMost, names[TiersMost], len(names)-TiersMost-1)
|
||||
names = names[:TiersMost]
|
||||
}
|
||||
graph := delivery.Graph{}
|
||||
gr := graph.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
|
||||
if n, err := graph.Decode(raw); err == nil {
|
||||
gr[key] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,15 +117,15 @@ func (r Reader) Tiers(revision uint64) (Tiers, error) {
|
||||
}
|
||||
after := map[string][]string{}
|
||||
for _, n := range names {
|
||||
for _, b := range graph.Before(n) {
|
||||
for _, b := range gr.Before(n) {
|
||||
if in[b] {
|
||||
after[n] = append(after[n], b)
|
||||
}
|
||||
}
|
||||
}
|
||||
sub := delivery.Graph{}
|
||||
sub := graph.Graph{}
|
||||
for _, n := range names {
|
||||
if g, ok := graph[n]; ok {
|
||||
if g, ok := gr[n]; ok {
|
||||
sub[n] = g
|
||||
}
|
||||
}
|
||||
@@ -168,7 +170,7 @@ func (r Reader) Tiers(revision uint64) (Tiers, error) {
|
||||
}
|
||||
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)
|
||||
tm.Waits = r.waitsOf(n, m, gr[n], wants, provided, perr, away)
|
||||
out.Tiers[t] = append(out.Tiers[t], tm)
|
||||
}
|
||||
compact := out.Tiers[:0]
|
||||
@@ -202,7 +204,7 @@ func (r Reader) away() map[string]bool {
|
||||
|
||||
// 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,
|
||||
func (r Reader) waitsOf(module string, m delivery.Module, g graph.Node, wants, provided map[string]json.RawMessage,
|
||||
perr error, away map[string]bool) []TierWait {
|
||||
var out []TierWait
|
||||
if m.FP2 == "" {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module git.novox.be/novox/mesh-delivery/sdk
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103155154-cf5812101b0b
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103155154-cf5812101b0b h1:UPkJqQj+1y6WraOxDidkrokNjA25jxmCk+zIRkujjMc=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103155154-cf5812101b0b/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
@@ -0,0 +1,398 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Version is the version of a node's value this package writes and the only one it reads.
|
||||
const Version = 1
|
||||
|
||||
// ErrUnknownVersion is what Decode returns for a value whose version is missing or not Version.
|
||||
var ErrUnknownVersion = errors.New("graph: unknown value version")
|
||||
|
||||
func decode(data []byte, into *Node) error {
|
||||
var probe struct {
|
||||
V *int `json:"v"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &probe); err != nil {
|
||||
return fmt.Errorf("graph: value does not fit its shape: %w", err)
|
||||
}
|
||||
if probe.V == nil || *probe.V != Version {
|
||||
return fmt.Errorf("%w: this reader reads %d", ErrUnknownVersion, Version)
|
||||
}
|
||||
if err := json.Unmarshal(data, into); err != nil {
|
||||
return fmt.Errorf("graph: value does not fit its shape: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkName refuses a module name a key cannot hold (delivery.ModulesKey's rule).
|
||||
func checkName(kind, name string) error {
|
||||
if _, err := delivery.ModulesKey(name); err != nil {
|
||||
return fmt.Errorf("graph: the %s name %q: %w", kind, name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkRepository refuses a repository that is not <owner>/<name>.
|
||||
func checkRepository(repository string) error {
|
||||
owner, name, ok := strings.Cut(repository, "/")
|
||||
if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
|
||||
return fmt.Errorf("graph: the repository %q is not <owner>/<name>", repository)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The module graph (novox/hq ADR 0603 §2, ADR 0604 §1): the indexer keeps, as its state `graph`, one key per module it
|
||||
// holds the inputs of, written at every recompute and deleted with the module. A key holds the module's build edges
|
||||
// (its bases, the mesh packages its fingerprint takes, and per language the build module, toolchain version and recipe
|
||||
// version its fingerprint names, ADR 0530 §1) and its provision edges (what its manifest provides, requires, and of what
|
||||
// it requires what a want of it waits for). Nothing stores an order: a reader works it out from the edges (ADR 0603
|
||||
// §1, §6). The indexer writes it and works the edges out (EdgesOf), the delivery view, the checker and the indexer's
|
||||
// answers of the seat mesh-catalog read it; all three are in this repository, which uses this package by path (the
|
||||
// operator, 2026-11-03: no graph-related code in mesh-sdk, only contracts). It names no module.
|
||||
|
||||
const (
|
||||
// State is the name of the indexer's state of the module graph, as its manifest declares it, and Bucket its
|
||||
// bucket: one key per module (Key).
|
||||
State = "graph"
|
||||
Bucket = delivery.IndexerModule + "_" + State
|
||||
// Read is the line a reader's manifest names in `reads` to read the graph.
|
||||
Read = delivery.IndexerModule + "." + State
|
||||
// History is the revisions the graph state keeps per key (ADR 0603 §2).
|
||||
History = 5
|
||||
)
|
||||
|
||||
// Key is the key, in Bucket, of a module: `<module>`, as ModulesKey.
|
||||
func Key(module string) (string, error) { return delivery.ModulesKey(module) }
|
||||
|
||||
// ParseKey is the inverse of Key.
|
||||
func ParseKey(key string) (string, error) {
|
||||
module, err := Key(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("graph: %q is not a graph key: %w", key, err)
|
||||
}
|
||||
return module, nil
|
||||
}
|
||||
|
||||
// Node is the value of a graph key: one module's edges as its last recompute computed them. FP is its new-form
|
||||
// fingerprint then, or None why it has none (exactly one is set); Repository and Path say where it is (as Module), so a
|
||||
// package edge is resolved to the module that publishes it (GraphPublisher). Bases are the modules it is built on;
|
||||
// Packages the mesh packages (Go module paths) its fingerprint took (Module.Packages); Toolchains, by language, the
|
||||
// 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.
|
||||
// Every list is sorted and each entry once; an encode sorts them.
|
||||
type Node struct {
|
||||
V int `json:"v"`
|
||||
FP string `json:"fp,omitempty"`
|
||||
None string `json:"none,omitempty"`
|
||||
Repository string `json:"repository"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Bases []string `json:"bases,omitempty"`
|
||||
Packages []string `json:"packages,omitempty"`
|
||||
Toolchains map[string]Toolchain `json:"toolchains,omitempty"`
|
||||
Provides []string `json:"provides,omitempty"`
|
||||
Requires []string `json:"requires,omitempty"`
|
||||
Awaits []string `json:"awaits,omitempty"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Recipe string `json:"recipe"`
|
||||
}
|
||||
|
||||
func (g Node) check() error {
|
||||
switch {
|
||||
case (g.FP == "") == (g.None == ""):
|
||||
return fmt.Errorf("graph: a graph node has either a fingerprint or a reason for none, not both or neither")
|
||||
case g.FP != "" && !strings.HasPrefix(g.FP, delivery.SourcePrefix2):
|
||||
return fmt.Errorf("graph: the graph node's fingerprint %q does not start with %s", g.FP, delivery.SourcePrefix2)
|
||||
}
|
||||
if err := checkRepository(g.Repository); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, b := range g.Bases {
|
||||
if err := checkName("base", b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, p := range g.Packages {
|
||||
if _, _, ok := delivery.PackageRepository(p); !ok {
|
||||
return fmt.Errorf("graph: the graph node's package %q is no mesh package's path", p)
|
||||
}
|
||||
}
|
||||
for language, t := range g.Toolchains {
|
||||
if err := checkName("build module", t.Module); err != nil {
|
||||
return fmt.Errorf("graph: the graph node's toolchain of %q: %w", language, err)
|
||||
}
|
||||
if language == "" || t.Version == "" || t.Recipe == "" {
|
||||
return fmt.Errorf("graph: the graph node's toolchain of %q needs its language, version and recipe version", language)
|
||||
}
|
||||
}
|
||||
for _, list := range [][]string{g.Provides, g.Requires, g.Awaits} {
|
||||
if slices.Contains(list, "") {
|
||||
return fmt.Errorf("graph: a graph node names an empty provision")
|
||||
}
|
||||
}
|
||||
for _, a := range g.Awaits {
|
||||
if !slices.Contains(g.Requires, a) {
|
||||
return fmt.Errorf("graph: the graph node awaits %q, which it does not require", a)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortedOnce(in []string) []string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := slices.Clone(in)
|
||||
slices.Sort(out)
|
||||
return slices.Compact(out)
|
||||
}
|
||||
|
||||
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)
|
||||
if len(g.Toolchains) == 0 {
|
||||
g.Toolchains = nil
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// Encode is g as JSON at Version, its lists sorted; it refuses what Node's contract does not hold.
|
||||
func Encode(g Node) ([]byte, error) {
|
||||
g = g.normal()
|
||||
if err := g.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.V = Version
|
||||
return json.Marshal(g)
|
||||
}
|
||||
|
||||
// Decode reads a graph node; it refuses an unknown version (ErrUnknownVersion) and what Encode refuses.
|
||||
func Decode(data []byte) (Node, error) {
|
||||
var g Node
|
||||
if err := decode(data, &g); err != nil {
|
||||
return Node{}, err
|
||||
}
|
||||
if err := g.check(); err != nil {
|
||||
return Node{}, err
|
||||
}
|
||||
return g.normal(), nil
|
||||
}
|
||||
|
||||
// Same says whether two graph nodes are the same value once sorted, so a writer writes only when they differ.
|
||||
func Same(a, b Node) bool {
|
||||
a, b = a.normal(), b.normal()
|
||||
a.V, b.V = 0, 0
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
|
||||
// Graph is the module graph as a reader holds it: every graph node, by module.
|
||||
type Graph map[string]Node
|
||||
|
||||
// Providers are the modules that provide provision: each whose Provides names it, and the module of that name (a module
|
||||
// provides its own name), sorted.
|
||||
func (g Graph) Providers(provision string) []string {
|
||||
var out []string
|
||||
for name, n := range g {
|
||||
if name == provision || slices.Contains(n.Provides, provision) {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Requirers are the modules that require provision, not optionally, sorted.
|
||||
func (g Graph) Requirers(provision string) []string {
|
||||
var out []string
|
||||
for name, n := range g {
|
||||
if slices.Contains(n.Requires, provision) {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Publisher is the module of the graph whose repository and directory publish the mesh package pkg (PackageRepository),
|
||||
// or "" when the graph holds none.
|
||||
func (g Graph) Publisher(pkg string) string {
|
||||
repository, dir, ok := delivery.PackageRepository(pkg)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if dir == "" {
|
||||
dir = delivery.RootPath
|
||||
}
|
||||
for _, name := range slices.Sorted(maps.Keys(g)) {
|
||||
if n := g[name]; n.Repository == repository && n.Path == dir {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildEdges are the modules n's build edges reach: its bases, the publisher of each of its packages, and the build
|
||||
// module of each language.
|
||||
func (g Graph) buildEdges(n Node) []string {
|
||||
out := slices.Clone(n.Bases)
|
||||
for _, p := range n.Packages {
|
||||
if m := g.Publisher(p); m != "" {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
for _, t := range n.Toolchains {
|
||||
out = append(out, t.Module)
|
||||
}
|
||||
return sortedOnce(out)
|
||||
}
|
||||
|
||||
// Dependents are the modules whose build edges reach module, directly or through other modules' (a base of a base, a
|
||||
// module built by a build module that stands on module), sorted and without module itself (ADR 0604 §1): what a
|
||||
// change of module builds again.
|
||||
func (g Graph) Dependents(module string) []string {
|
||||
reverse := map[string][]string{}
|
||||
for _, name := range slices.Sorted(maps.Keys(g)) {
|
||||
for _, to := range g.buildEdges(g[name]) {
|
||||
reverse[to] = append(reverse[to], name)
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{module: true}
|
||||
queue := []string{module}
|
||||
var out []string
|
||||
for len(queue) > 0 {
|
||||
at := queue[0]
|
||||
queue = queue[1:]
|
||||
for _, from := range reverse[at] {
|
||||
if !seen[from] {
|
||||
seen[from] = true
|
||||
out = append(out, from)
|
||||
queue = append(queue, from)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
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 {
|
||||
n := g[name]
|
||||
out := slices.Clone(n.Bases)
|
||||
for _, a := range n.Awaits {
|
||||
for _, p := range g.Providers(a) {
|
||||
if p != name {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
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.
|
||||
func (g Graph) Cycle() []string {
|
||||
edges := g.Before
|
||||
const (
|
||||
unseen = iota
|
||||
onPath
|
||||
done
|
||||
)
|
||||
state := map[string]int{}
|
||||
var path []string
|
||||
var visit func(string) []string
|
||||
visit = func(name string) []string {
|
||||
state[name] = onPath
|
||||
path = append(path, name)
|
||||
for _, to := range edges(name) {
|
||||
if _, ours := g[to]; !ours {
|
||||
continue
|
||||
}
|
||||
switch state[to] {
|
||||
case onPath:
|
||||
i := slices.Index(path, to)
|
||||
return append(slices.Clone(path[i:]), to)
|
||||
case unseen:
|
||||
if c := visit(to); c != nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
path = path[:len(path)-1]
|
||||
state[name] = done
|
||||
return nil
|
||||
}
|
||||
for _, name := range slices.Sorted(maps.Keys(g)) {
|
||||
if state[name] == unseen {
|
||||
if c := visit(name); c != nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
func EdgesOf(manifest catalogue.Manifest, providers func(provision string) []catalogue.Manifest) Node {
|
||||
var n Node
|
||||
if manifest.Build != nil {
|
||||
for _, on := range manifest.Build.On {
|
||||
if on.Module != "" {
|
||||
n.Bases = append(n.Bases, on.Module)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, o := range manifest.Provides {
|
||||
n.Provides = append(n.Provides, o.Name)
|
||||
}
|
||||
for _, r := range manifest.Requires {
|
||||
if !manifest.OptionalRequires[r] {
|
||||
n.Requires = append(n.Requires, r)
|
||||
}
|
||||
}
|
||||
n.Awaits = catalogue.Awaited(manifest, providers)
|
||||
return n.normal()
|
||||
}
|
||||
|
||||
// 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).
|
||||
func OfManifests(manifests map[string]catalogue.Manifest) Graph {
|
||||
providers := func(provision string) []catalogue.Manifest {
|
||||
var out []catalogue.Manifest
|
||||
for _, name := range slices.Sorted(maps.Keys(manifests)) {
|
||||
if slices.Contains(manifests[name].Offers(), provision) {
|
||||
out = append(out, manifests[name])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
g := Graph{}
|
||||
for name, m := range manifests {
|
||||
g[name] = EdgesOf(m, providers)
|
||||
}
|
||||
return g
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// TestGraphEdges: the module graph's cycle, dependents and providers, each from the edges alone (ADR 0603 §2, ADR 0604 §1).
|
||||
func TestGraphEdges(t *testing.T) {
|
||||
t.Run("a node's value", nodeValues)
|
||||
fp := "src2:" + strings.Repeat("b", 64)
|
||||
node := func(repository, path string, edit func(*Node)) Node {
|
||||
n := Node{FP: fp, Repository: repository, Path: path}
|
||||
if edit != nil {
|
||||
edit(&n)
|
||||
}
|
||||
return n
|
||||
}
|
||||
gotool := map[string]Toolchain{"go": {Module: "build-go", Version: "go1", Recipe: "1"}}
|
||||
// A base chain, a package and a build module: app stands on base, imports lib's package, is built by build-go.
|
||||
g := Graph{
|
||||
"build-go": node("novox/mesh-catalog", "modules/build-go", func(n *Node) { n.Toolchains = gotool }),
|
||||
"lib": node("novox/lib", delivery.RootPath, nil),
|
||||
"base": node("novox/mesh-catalog", "modules/base", nil),
|
||||
"app": node("novox/mesh-catalog", "modules/app", func(n *Node) {
|
||||
n.Bases, n.Packages, n.Toolchains = []string{"base"}, []string{"git.novox.be/novox/lib"}, gotool
|
||||
}),
|
||||
"top": node("novox/mesh-catalog", "modules/top", func(n *Node) { n.Bases = []string{"app"} }),
|
||||
"db": node("novox/mesh-catalog", "modules/db", func(n *Node) { n.Provides = []string{"database"} }),
|
||||
"web": node("novox/mesh-catalog", "modules/web", func(n *Node) {
|
||||
n.Requires, n.Awaits = []string{"database", "db"}, []string{"database"}
|
||||
}),
|
||||
}
|
||||
for module, want := range map[string][]string{
|
||||
"base": {"app", "top"},
|
||||
"lib": {"app", "top"},
|
||||
"build-go": {"app", "top"}, // build-go's own toolchain edge reaches itself, which is left out
|
||||
"top": nil,
|
||||
"db": nil, // a provision edge is no build edge
|
||||
} {
|
||||
if got := g.Dependents(module); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("dependents of %s = %v, want %v", module, got, want)
|
||||
}
|
||||
}
|
||||
if got := g.Providers("database"); !reflect.DeepEqual(got, []string{"db"}) {
|
||||
t.Errorf("providers of database = %v", got)
|
||||
}
|
||||
if got := g.Providers("db"); !reflect.DeepEqual(got, []string{"db"}) { // a module provides its own name
|
||||
t.Errorf("providers of db = %v", got)
|
||||
}
|
||||
if got := g.Before("web"); !reflect.DeepEqual(got, []string{"db"}) { // web awaits database, which db provides
|
||||
t.Errorf("web waits for %v", got)
|
||||
}
|
||||
if got := g.Before("top"); !reflect.DeepEqual(got, []string{"app"}) {
|
||||
t.Errorf("top waits for %v", got)
|
||||
}
|
||||
if got := g.Requirers("db"); !reflect.DeepEqual(got, []string{"web"}) {
|
||||
t.Errorf("requirers of db = %v", got)
|
||||
}
|
||||
if g.Publisher("git.novox.be/novox/lib") != "lib" || g.Publisher("git.novox.be/novox/mesh-sdk/go") != "" {
|
||||
t.Error("a package's publisher is not the module at its repository and directory")
|
||||
}
|
||||
// From manifests alone (the check's graph): a requirement answered by a helper at mesh scope is awaited, an optional
|
||||
// one is not, and two modules awaiting each other's provision are a cycle.
|
||||
offer := func(name string) []catalogue.Offer {
|
||||
return []catalogue.Offer{{Name: name, Scope: catalogue.ScopeMesh, AnsweredBy: catalogue.AnsweredByHelper}}
|
||||
}
|
||||
ms := map[string]catalogue.Manifest{
|
||||
"db": {Module: "db", Provides: offer("database"), Requires: []string{"site", "logs"}, OptionalRequires: map[string]bool{"logs": true}},
|
||||
"web": {Module: "web", Provides: offer("site"), Requires: []string{"database"}, Build: &catalogue.Build{On: []catalogue.BuildsOn{{Module: "base"}}}},
|
||||
}
|
||||
mg := OfManifests(ms)
|
||||
if db := mg["db"]; !reflect.DeepEqual(db.Requires, []string{"site"}) || !reflect.DeepEqual(db.Awaits, []string{"site"}) ||
|
||||
!reflect.DeepEqual(mg["web"].Bases, []string{"base"}) || !reflect.DeepEqual(db.Provides, []string{"database"}) {
|
||||
t.Errorf("the graph of manifests = %+v", mg)
|
||||
}
|
||||
if got := mg.Cycle(); !reflect.DeepEqual(got, []string{"db", "web", "db"}) {
|
||||
t.Errorf("the cycle of manifests = %v", got)
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
edit func(Graph)
|
||||
want []string
|
||||
}{
|
||||
"none: a toolchain on itself and a requirement not awaited order nothing": {func(Graph) {}, nil},
|
||||
"bases": {func(g Graph) {
|
||||
b := g["base"]
|
||||
b.Bases = []string{"top"}
|
||||
g["base"] = b
|
||||
}, []string{"app", "base", "top", "app"}},
|
||||
"provisions a -> b -> a": {func(g Graph) {
|
||||
d := g["db"]
|
||||
d.Requires, d.Awaits = []string{"site"}, []string{"site"}
|
||||
g["db"] = d
|
||||
w := g["web"]
|
||||
w.Provides = []string{"site"}
|
||||
g["web"] = w
|
||||
}, []string{"db", "web", "db"}},
|
||||
"a requirement not awaited is no edge": {func(g Graph) {
|
||||
d := g["db"]
|
||||
d.Requires = []string{"site"}
|
||||
g["db"] = d
|
||||
w := g["web"]
|
||||
w.Provides = []string{"site"}
|
||||
g["web"] = w
|
||||
}, nil},
|
||||
} {
|
||||
h := Graph{}
|
||||
for k, v := range g {
|
||||
h[k] = v
|
||||
}
|
||||
c.edit(h)
|
||||
if got := h.Cycle(); !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("%s: cycle = %v, want %v", name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sampleNode is a graph node with every kind of edge, its lists unsorted.
|
||||
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"}}
|
||||
}
|
||||
|
||||
// nodeValues: a node round-trips with its lists sorted, an unknown version is refused, a node that does not hold is
|
||||
// refused naming why, and the state is the indexer's graph (ADR 0603 §2).
|
||||
func nodeValues(t *testing.T) {
|
||||
if Bucket != "mesh-delivery-indexer_graph" || Read != "mesh-delivery-indexer.graph" || History != 5 || State != "graph" {
|
||||
t.Error("the graph's state differs from the decided layout")
|
||||
}
|
||||
g := sampleNode()
|
||||
data, err := Encode(g)
|
||||
if err != nil || !strings.Contains(string(data), `"v":1`) {
|
||||
t.Fatalf("encode: %s %v", data, err)
|
||||
}
|
||||
if got, err := Decode(data); err != nil || !Same(got, g) || !slices.IsSorted(got.Bases) {
|
||||
t.Errorf("round trip: %+v %v", got, err)
|
||||
}
|
||||
if Same(g, Node{None: "x", Repository: "a/b"}) {
|
||||
t.Error("different graph nodes are the same")
|
||||
}
|
||||
for _, in := range []string{`{"none":"x","repository":"a/b"}`, `{"v":2,"none":"x","repository":"a/b"}`} {
|
||||
if _, err := Decode([]byte(in)); !errors.Is(err, ErrUnknownVersion) {
|
||||
t.Errorf("decode of %s = %v, want ErrUnknownVersion", in, err)
|
||||
}
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
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"},
|
||||
} {
|
||||
g := sampleNode()
|
||||
c.change(&g)
|
||||
if _, err := Encode(g); err == nil || !strings.Contains(err.Error(), c.word) {
|
||||
t.Errorf("a node with %s: %v, want it refused naming %q", name, err, c.word)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user