From c45bbfc391c8b153ba900fac064be07dd5534cb8 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:18:45 +0100 Subject: [PATCH] =?UTF-8?q?A=20want=20waits=20until=20each=20verb=20it=20i?= =?UTF-8?q?nvokes=20is=20served=20by=20a=20running=20build,=20read=20from?= =?UTF-8?q?=20the=20module=20graph=20(ADR=200614=20=C2=A72,=20=C2=A73;=20i?= =?UTF-8?q?ssue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../cmd/mesh-delivery-assigner/main.go | 11 ++- .../cmd/mesh-delivery-assigner/pass.go | 6 ++ .../cmd/mesh-delivery-assigner/pass_test.go | 52 +++++++++++ .../cmd/mesh-delivery-assigner/verbs.go | 91 +++++++++++++++++++ modules/mesh-delivery-assigner/go.mod | 4 + .../internal/assign/verbs.go | 30 ++++++ .../internal/assign/wants.go | 10 ++ .../internal/assign/wants_test.go | 31 +++++++ .../mesh-delivery-assigner/manifest_test.go | 5 +- modules/mesh-delivery-assigner/module.json | 1 + 10 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/verbs.go create mode 100644 modules/mesh-delivery-assigner/internal/assign/verbs.go diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go index b87acaac..ec894b3b 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go @@ -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 diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go index ed31fc50..6170b6a2 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -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) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go index 498889bb..c3486869 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go @@ -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) + } + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/verbs.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/verbs.go new file mode 100644 index 00000000..049a42e3 --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/verbs.go @@ -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 +} diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index d08194bd..46194575 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -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 diff --git a/modules/mesh-delivery-assigner/internal/assign/verbs.go b/modules/mesh-delivery-assigner/internal/assign/verbs.go new file mode 100644 index 00000000..9edf0d85 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assign/verbs.go @@ -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) +} diff --git a/modules/mesh-delivery-assigner/internal/assign/wants.go b/modules/mesh-delivery-assigner/internal/assign/wants.go index 37d8185b..cf53a27e 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants.go @@ -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, "" } diff --git a/modules/mesh-delivery-assigner/internal/assign/wants_test.go b/modules/mesh-delivery-assigner/internal/assign/wants_test.go index b2e4b4b7..278c9971 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants_test.go @@ -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. diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index c594947e..087b34cd 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -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) { diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index 86ee65ec..7f94deaa 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -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",