A want waits until each verb it invokes is served by a running build, read from the module graph (ADR 0614 §2, §3; issue 774)

The assigner reads the indexer's module graph (a new reads line): of what a module invokes it waits only on the edges
the graph keeps and another module serves, so a left-out cycle holds nothing; a verb counts as served once a running
healthy build's manifest serves it. The hold is under the provisions' rule, its words naming the verb, since no reader
knows a rule number of its own yet. Without the graph no want waits on a verb.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
This commit is contained in:
2026-11-03 23:26:08 +01:00
parent b4bfc14556
commit c45bbfc391
10 changed files with 238 additions and 3 deletions
@@ -28,6 +28,8 @@ import (
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/provisions"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"mesh-delivery-assigner/internal/assign"
)
@@ -62,6 +64,7 @@ var readBuckets = []string{
provisions.ProvisionsBucket,
delivery.SourceBucket,
delivery.MergesBucket,
graph.Bucket,
}
// readFamilies are the states kept one bucket per node the assigner reads, each named in its manifest's reads: every
@@ -148,6 +151,11 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any
logf("the bucket %s cannot be opened (%v): no release's Approve answers a record ask until it is bound", name, err)
continue
}
if name == graph.Bucket && err != nil {
// The indexer's module graph (ADR 0614 §2, §3): without it no want waits for a verb it invokes until it is bound.
logf("the bucket %s cannot be opened (%v): no want waits for a verb it invokes until it is bound once the bus says it was made", name, err)
continue
}
if name == delivery.PartGrantsBucket && err != nil {
// The grants part's grants (ADR 0571 clause 9): without them no module counts as granted until it is bound.
logf("the bucket %s cannot be opened (%v): no grant is read until it is bound once the bus says it was made", name, err)
@@ -166,7 +174,7 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any
func startsWithout(name string) bool {
switch name {
case delivery.SeatHoldersBucket, provisions.ProvisionsBucket, assign.PoliciesBucket, assign.FindingsBucket,
delivery.AssignerPutsBucket, delivery.SourceBucket, delivery.MergesBucket:
delivery.AssignerPutsBucket, delivery.SourceBucket, delivery.MergesBucket, graph.Bucket:
return true
}
return slices.Contains(seatBuckets, name) || name == delivery.PartGrantsBucket
@@ -251,6 +259,7 @@ var watched = []string{
delivery.SeatHoldersBucket,
delivery.SourceBucket,
delivery.MergesBucket,
graph.Bucket,
}
// follow watches every bucket a pass reads and makes a pass at start, once every watch handed what stands, and after
@@ -155,6 +155,9 @@ func (p *passer) wants(ctx context.Context) {
defer func() { p.sayNeither(neither, stale, left) }()
// Where grants are read from, once for the pass: the grants part's (issue 774, ADR 0571 clause 9).
grants := p.w.grantsBucket()
// The verb edges, once for the pass (ADR 0614 §2, §3).
moduleGraph := p.moduleGraph()
served := p.served(ctx, get, moduleGraph, nodes)
for name := range names {
fp := trunk[name].FP2
name := name
@@ -192,6 +195,9 @@ func (p *passer) wants(ctx context.Context) {
p.clearHeld(name, fp)
}
}
if served != nil {
m.Invokes, m.Served = keptInvokes(moduleGraph, name), served
}
last := map[string]string{}
for _, node := range m.Nodes {
last[node] = p.w.lastHealthy(node, name, fp)
@@ -17,6 +17,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-assigner/internal/assign"
)
@@ -405,6 +407,8 @@ func putOfTheBus(t *testing.T) {
func TestAModuleOnNeitherListIsRaisedOnceAndClearedOnce(t *testing.T) {
// A pair only one source names is a condition of the same kind (issue 1126): folded here to hold the count.
aPlacementAloneNamesWhereModulesRun(t)
// The verb edges a want waits on come from the graph and what runs (ADR 0614 §2, §3): folded here to hold the count.
aWantWaitsOnTheVerbEdgesTheGraphKeeps(t)
t.Run("gathers", testThePassGathersTheModulesOnNeitherList)
p := newPasser(buckets{}, time.Date(2026, 10, 25, 15, 0, 0, 0, time.UTC))
var said []asks.Condition
@@ -1021,3 +1025,51 @@ func TestAnApprovedPlacementCallsNoControllerVerb(t *testing.T) {
t.Fatalf("the old conditions cleared = %+v", conds)
}
}
// ADR 0614 §2, §3: of what a module invokes, the pass keeps as edges the verbs the graph does not leave out and another
// module serves; a verb is served once a running healthy build's manifest (the builder's build, the indexer's manifest)
// serves it, and a build whose manifest cannot be read counts what the graph says its module serves. No graph read: no wait.
func aWantWaitsOnTheVerbEdgesTheGraphKeeps(t *testing.T) {
p := newPasser(buckets{}, time.Now())
fp := func(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 64) }
if p.served(context.Background(), nil, graph.Graph{}, assign.Nodes{}) != nil {
t.Error("with no graph read, a want waits on a verb")
}
for module, n := range map[string]graph.Node{
"caller": {FP: fp("c"), Repository: "novox/caller", Invokes: []string{"seat:x.v", "loop.l", "none.t", "caller.own"}, LeftOut: []string{"loop.l"},
Serves: []string{"caller.own"}},
"server": {FP: fp("e"), Repository: "novox/server", Serves: []string{"seat:x.v", "seat:x.w"}},
"loop": {FP: fp("1"), Repository: "novox/loop", Serves: []string{"loop.l"}},
"late": {FP: fp("2"), Repository: "novox/late", Serves: []string{"late.t"}},
} {
raw, err := graph.Encode(n)
if err != nil {
t.Fatal(err)
}
p.w.put(graph.Bucket, module, raw)
}
g := p.moduleGraph()
if got := keptInvokes(g, "caller"); !slices.Equal(got, []string{"seat:x.v"}) || keptInvokes(g, "unknown") != nil {
t.Errorf("the kept verb edges of caller = %v", got)
}
digest := strings.Repeat("d", 64)
man, _ := delivery.EncodeManifest(delivery.Manifest{Module: "server", JSON: `{"module":"server","claims":[{"name":"x","scope":"mesh","serves":["w"]}]}`})
get := func(_ context.Context, key string) ([]byte, error) {
if key == digest {
return man, nil
}
return nil, errNotThere
}
build, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest})
key, _ := delivery.BuilderBuildKey("server", fp("f"))
p.w.put(delivery.BuilderBuildsBucket, key, build)
running := delivery.Have{Phase: delivery.PhaseRunning, Health: delivery.HealthOK}
at := func(f string) delivery.Have { h := running; h.FP = f; return h }
served := p.served(context.Background(), get, g, assign.Nodes{Haves: map[string]map[string]delivery.Have{
"g14": {"server": at(fp("f")), "late": at(fp("2"))}}})
for v, want := range map[string]bool{"seat:x.w": true, "seat:x.v": false, "late.t": true} {
if served(v) != want {
t.Errorf("served(%s) = %v, want %v: the running server's manifest serves w alone, late's is not read", v, served(v), want)
}
}
}
@@ -0,0 +1,91 @@
package main
import (
"context"
"slices"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"mesh-delivery-assigner/internal/assign"
)
// The verb edges a want waits on (novox/hq ADR 0614 §2, §3): the module graph the indexer writes names what each module
// invokes and serves and which of its verb edges it leaves out on a cycle (one place decides the cycle); a want waits for
// each kept edge until a build serving its verb runs healthy, read from the haves, the builder's builds and the indexer's
// manifests, as a provider is counted (ADR 0603 §5).
// moduleGraph is the module graph as the indexer last wrote it; a key that does not decode is left out, so its module
// waits on no verb.
func (p *passer) moduleGraph() graph.Graph {
g := graph.Graph{}
for key, raw := range p.w.read(graph.Bucket) {
module, err := graph.ParseKey(key)
if err != nil {
continue
}
if n, err := graph.Decode(raw); err == nil {
g[module] = n
}
}
return g
}
// keptInvokes are the verbs of module the graph keeps as edges: each it invokes and does not leave out, that a module
// other than itself serves. A verb no module of the graph serves is no edge, so it holds nothing; nil when the graph holds
// no node of module.
func keptInvokes(g graph.Graph, module string) []string {
n, ok := g[module]
if !ok {
return nil
}
var out []string
for _, v := range n.Invokes {
if slices.Contains(n.LeftOut, v) {
continue
}
if slices.ContainsFunc(g.Servers(v), func(s string) bool { return s != module }) {
out = append(out, v)
}
}
return out
}
// served is, for this pass, whether a build serving a verb runs healthy on some node (graph.Served): every running healthy
// have's build (the builder's builds key of its module and fingerprint) names its manifest, and the verbs that manifest
// serves are served. A healthy build whose manifest cannot be read counts what the graph says its module serves, said in
// the log, so a build read late never holds its callers for good; nil when the graph holds nothing, and no want waits on
// a verb.
func (p *passer) served(ctx context.Context, get getter, g graph.Graph, n assign.Nodes) func(string) bool {
if len(g) == 0 {
return nil
}
served, unread := graph.Served(n.Haves, func(module, fp string) ([]string, bool) { return p.servesOf(ctx, get, module, fp) },
func(module string) []string { return g[module].Serves })
if len(unread) > 0 {
p.logf("the manifest of the running build of %v cannot be read, so what the module graph says they serve is counted as served", unread)
}
return served
}
// servesOf are the verbs the manifest of module's build at fp serves (graph.ServesOf), and whether it could be read.
func (p *passer) servesOf(ctx context.Context, get getter, module, fp string) ([]string, bool) {
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
return nil, false
}
raw := p.w.value(delivery.BuilderBuildsBucket, key)
if raw == nil {
return nil, false
}
b, err := delivery.DecodeBuilderBuild(raw)
if err != nil || b.Manifest == "" {
return nil, false
}
got := p.w.manifest(ctx, get, b.Manifest)
if got.err != "" {
return nil, false
}
return graph.ServesOf(got.manifest), true
}
+4
View File
@@ -20,3 +20,7 @@ require (
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk
@@ -0,0 +1,30 @@
package assign
import (
"fmt"
"slices"
)
// verbWaiting says why the module's want on node waits for a verb it invokes (novox/hq ADR 0614 §2): the first seat verb or
// module tool of Invokes (the module graph's verb edges, those it leaves out on a cycle not among them, §3) that no
// running healthy build serves. Nil Served (nothing read of what runs) waits for none, and a verb a running build serves
// already adds no wait. It holds under the provisions' rule (RuleProvisions): no reader knows a rule number of its own
// yet, so the words name the edge.
func (m Module) verbWaiting(node string) (string, bool) {
if m.Served == nil {
return "", false
}
for _, v := range slices.Sorted(slices.Values(m.Invokes)) {
if !m.Served(v) {
return VerbWaitWords(v, m.Name, node), true
}
}
return "", false
}
// VerbWaitWords are the words of a want held for a verb it invokes (ADR 0614 §2): they open with "the verb", so a reader
// of the want's words (the delivery view) names the edge.
func VerbWaitWords(verb, module, node string) string {
return fmt.Sprintf("the verb %s %s invokes is served by no running build: its want on %s waits until a build serving it runs healthy (ADR 0614 §2)",
verb, module, node)
}
@@ -40,6 +40,13 @@ type Module struct {
// manifest the trunk holds and the modules the mesh provides itself, placed or not. Nil: not known, and no requirement
// is waited on for want of an offer.
Offered func(provision string) bool
// Invokes are the seat verbs and module tools the module graph says the module invokes and keeps as edges to the
// modules serving them, those it leaves out on a cycle not among them (ADR 0614 §2, §3): the want waits until each is
// served (Served). Nil: no graph node is read, and no want waits on a verb.
Invokes []string
// Served says whether a build serving a seat verb or module tool runs healthy, counted from its holder's have (ADR 0614
// §2, as ADR 0603 §5 counts a provider's). Nil: nothing is read of what runs, and no want waits on a verb.
Served func(invoke string) bool
// Chosen is a person's choice of the module's upgrade policy (the assigner's `policies`, ADR 0565 §7); nil: none.
Chosen *catalogue.PersonPolicy
Nodes []string
@@ -153,6 +160,9 @@ func holds(m Module, node string, n Nodes, ordering bool) (bool, int, string) {
if words, waiting := m.provisionWaiting(node); waiting {
return false, delivery.RuleProvisions, words
}
if words, waiting := m.verbWaiting(node); waiting {
return false, delivery.RuleProvisions, words
}
return true, 0, ""
}
@@ -142,6 +142,37 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) {
t.Errorf("%s: g14's want %+v, want it kept at what it runs and naming rule 7", c.why, w)
}
}
// ADR 0614 §2, §3: a want waits, under the provisions' rule, until each verb it invokes is served by a running build,
// and moves once one is; a verb a running build serves already adds no wait, and an edge the graph leaves out on a cycle
// is not among Invokes, so it holds nothing; nothing read of what runs waits for none.
for _, c := range []struct {
name string
invokes []string
served []string
nilRead bool
holds string
}{
{"a verb no running build serves", []string{"store.store_put", "seat:mesh-catalog.modules"}, []string{"store.store_put"}, false, "seat:mesh-catalog.modules"},
{"once a build serving it runs", []string{"seat:mesh-catalog.modules"}, []string{"seat:mesh-catalog.modules"}, false, ""},
{"an edge left out on a cycle", nil, nil, false, ""},
{"nothing read of what runs", []string{"seat:mesh-catalog.modules"}, nil, true, ""},
} {
m, n := ready()
m.Invokes = c.invokes
if !c.nilRead {
m.Served = func(v string) bool { return slices.Contains(c.served, v) }
}
out := Wants(m, n, wantedOld(), nil)
if c.holds == "" {
if out.Wants["g14"].FP != fNew {
t.Errorf("%s: held %+v", c.name, out.Rules["g14"])
}
continue
}
if out.Wants["g14"].FP == fNew || out.Rules["g14"].Rule != delivery.RuleProvisions || out.Rules["g14"].Words != VerbWaitWords(c.holds, "mail", "g14") {
t.Errorf("%s: want %+v, held by %+v", c.name, out.Wants["g14"], out.Rules["g14"])
}
}
// ADR 0603 §9: the newest source revision governs. The module now at fNew no longer requires the database an older revision
// asked for and requires oidc instead; the seat's keys follow the newest manifest, so the older edge is no wait: a provided
// oidc moves the want, an asked one holds it and names oidc and never database.
@@ -34,8 +34,9 @@ func TestManifest(t *testing.T) {
reads := slices.Clone(m.Reads)
slices.Sort(reads)
// ADR 0593 §8: it composes nothing, so it reads no candidate, held or resolved value, compose step or build log; it reads
// every node's published compositions to compare them, and the gate's source and merges, for the release that carries a build (ADR 0601 §1).
want := []string{"mesh-delivery-builder.builds", "mesh-delivery-gate.merges", "mesh-delivery-gate.source", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules",
// every node's published compositions to compare them, and the gate's source and merges, for the release that carries a build (ADR 0601 §1);
// and the indexer's module graph, whose verb edges a want waits on (ADR 0614 §2, §3).
want := []string{"mesh-delivery-builder.builds", "mesh-delivery-gate.merges", "mesh-delivery-gate.source", "mesh-delivery-indexer.graph", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules",
"mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-grants.grants", "mesh-host.composed",
"mesh-provisioner.provisions", "node-engine.have"}
if !reflect.DeepEqual(reads, want) {
@@ -27,6 +27,7 @@
"mesh-delivery-builder.builds",
"mesh-delivery-gate.merges",
"mesh-delivery-gate.source",
"mesh-delivery-indexer.graph",
"mesh-delivery-indexer.manifests",
"mesh-delivery-indexer.modules",
"mesh-delivery-teller.answers",