Files
mesh-delivery/sdk/graph/graph.go
T
jschoubben df05b1a5f7 The module graph carries verb and bus-state edges, and leaves out every verb edge on a cycle (ADR 0614 §2, §3, §5, §6; issue 774)
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
2026-11-03 23:25:35 +01:00

621 lines
22 KiB
Go

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.
// A bus-state entry of its manifest is a requirement of the provision BusState (ADR 0614 §5). Invokes are the seat verbs
// (`seat:<seat>.<verb>`) and module tools (`<module>.<tool>`) 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"`
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"`
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 {
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)
}
}
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:<seat>.<verb> nor <module>.<tool>", 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:<seat>.<verb>` or a module tool `<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
}
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)
g.Invokes, g.Serves, g.LeftOut = sortedOnce(g.Invokes), sortedOnce(g.Serves), sortedOnce(g.LeftOut)
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):
// the module whose directory is the package's directory or the nearest one above it (the longest match, a package
// published from inside a module's directory belonging to that module), 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
}
best, bestLen := "", -1
for _, name := range slices.Sorted(maps.Keys(g)) {
n := g[name]
if n.Repository != repository {
continue
}
switch {
case n.Path == dir:
return name
case n.Path == delivery.RootPath:
if bestLen < 0 {
best, bestLen = name, 0
}
case strings.HasPrefix(dir, n.Path+"/") && len(n.Path) > bestLen:
best, bestLen = name, len(n.Path)
}
}
return best
}
// 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
}
// 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 {
for _, p := range g.Providers(a) {
if p != name {
out = append(out, p)
}
}
}
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)
}
// 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 (
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, 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 {
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)
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:<seat>.<verb>`, catalogue.Claim.ServesFor) and its own
// tools (`<module>.<tool>`), 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, 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
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)
}
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)))
}