Assigner: the node-engine's want first; the acting-wait check sees cycles across steps

The assigner and the node-engine on one node waited for each other for ever: the assigner's
want in step compose for the new node-engine, the node-engine's in step place for the new
assigner, and the cycle check counted one step at a time, so nothing said it. The node-engine's
own want (the holder of the seat node-engine on the node, read from the acting edges' seat) is now
held by no acting edge, so it is written first and the node's other wants, the assigner's own
included, wait for it. Cycles finds a cycle across steps as well as within one, and a want held
on a cycle no rule breaks is the finding acting-cycle (scope module, id <node>.<module>), naming
each edge's step, cleared by the next pass that finds it on none.

Builds: design 47 G §49, §268, §272, A-323, A-330

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-13 01:56:48 +01:00
parent ab31847d4e
commit 73582f92e1
7 changed files with 351 additions and 11 deletions
@@ -2,12 +2,16 @@ package main
import (
"context"
"maps"
"slices"
"strings"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"mesh-delivery-assigner/internal/acting"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/assigngraph"
"mesh-delivery-assigner/internal/assignment"
"mesh-delivery-assigner/internal/buckets"
)
@@ -22,6 +26,9 @@ import (
func (p *passer) actingOf(module string, released map[string]bool) func(node string) []assign.ActingEdge {
return func(node string) []assign.ActingEdge {
var out []assign.ActingEdge
if p.engineOn(node) == module {
return nil // the node-engine's own want is held by no acting edge, so it is written first (§268, A-323, A-330)
}
for _, e := range p.entryEdges(node, module) {
if acting.Held(e.Step) && !released[acting.Key(node, module, e.Module)+"@"+e.Fp] {
out = append(out, assign.ActingEdge{Node: e.Node, Part: e.Module, Fp: e.Fp, Step: e.Step})
@@ -31,6 +38,15 @@ func (p *passer) actingOf(module string, released map[string]bool) func(node str
}
}
// engineOn is the module that holds the seat node-engine on node in the revision the pass computed, read from the acting edges'
// seat (A-330); "" when none is read.
func (p *passer) engineOn(node string) string {
if p.graph == nil {
return ""
}
return assigngraph.Engines(slices.Collect(maps.Values(p.graph.computed.Entries)))[node]
}
// entryEdges are the acting edges of the entry of module on node in the revision the pass computed.
func (p *passer) entryEdges(node, module string) []seat.Edge {
g := p.graph
@@ -72,11 +88,16 @@ func (p *passer) actingWaits(ctx context.Context, n assign.Nodes, wanted map[str
now := p.clock()
kept := p.w.read(buckets.Waits())
standing := map[string]bool{}
engines := assigngraph.Engines(slices.Collect(maps.Values(g.computed.Entries)))
held := map[string]bool{} // `<node>/<module>` of each entry held for a part's version
for _, ref := range g.computed.Revision.Entries {
entry := g.computed.Entries[ref.Digest]
if w, ok := wanted[ref.Module][ref.Node]; ok && w.Fp == entry.Fp {
continue // the entry's version is wanted already: nothing moves, so nothing waits
}
if engines[ref.Node] == ref.Module {
continue // the node-engine's own want waits for no acting part (§268, A-323, A-330)
}
for _, e := range entry.Edges {
if e.Kind != "acting" || !acting.Held(e.Step) {
continue
@@ -86,6 +107,7 @@ func (p *passer) actingWaits(ctx context.Context, n assign.Nodes, wanted map[str
continue
}
standing[key] = true
held[ref.Node+"/"+ref.Module] = true
if raw := kept[key]; raw != nil {
if w, err := acting.Decode(raw); err == nil && w.Entry == entry.Fp && w.Fp == e.Fp {
p.actingReleaseSaid(ctx, key, w)
@@ -104,6 +126,7 @@ func (p *passer) actingWaits(ctx context.Context, n assign.Nodes, wanted map[str
p.put(ctx, buckets.Waits(), key, func() ([]byte, error) { return acting.Encode(w) })
}
}
p.actingCycles(g.computed.Entries, engines, held, n)
for key, raw := range kept {
if !acting.Is(key) || standing[key] {
continue
@@ -150,3 +173,53 @@ func (p *passer) actingReleaseSaid(ctx context.Context, key string, w acting.Wai
func (p *passer) edgeOf(w acting.Wait) (assign.ActingEdge, bool) {
return assign.ActingEdge{Node: w.PartNode, Part: w.Part, Fp: w.Fp, Step: w.Step}, true
}
// actingCycles writes the finding acting-cycle of each held want whose holding edges lie on a cycle no rule breaks, and clears it of
// every other want, a finding a pass before or another process wrote included (design 47 G A-323, A-330). An edge holds while its
// step is the assigner's, its part's version does not run and its wait was not released; the node-engine's own want is held by none.
func (p *passer) actingCycles(entries map[string]seat.AssignmentEntry, engines map[string]string, held map[string]bool, n assign.Nodes) {
if p.findings.Put == nil {
return
}
released := p.actingReleased()
cycles := assigngraph.HeldCycles(slices.Collect(maps.Values(entries)), func(e seat.AssignmentEntry, edge seat.Edge) bool {
return held[e.Node+"/"+e.Module] && engines[e.Node] != e.Module && edge.Kind == "acting" && acting.Held(edge.Step) &&
!released[acting.Key(e.Node, e.Module, edge.Module)+"@"+edge.Fp] && !n.Runs(assign.ActingEdge{Node: edge.Node, Part: edge.Module, Fp: edge.Fp, Step: edge.Step})
})
raised := map[string]bool{}
for _, at := range slices.Sorted(maps.Keys(cycles)) {
node, module, _ := strings.Cut(at, "/")
o := assign.ActingCycleFinding(node, module, cycles[at])
key := mcseatKey(o)
raised[key] = true
if p.said[key+"="+o.Summary] {
continue // written already as it stands
}
if err := p.findings.Put(o.Finding(p.clock())); err != nil {
p.logf("the finding %s is not written: %v", key, err)
continue
}
p.said[key+"="+o.Summary] = true
}
if p.findings.Keys == nil || p.findings.Delete == nil {
return
}
keys, err := p.findings.Keys()
if err != nil {
p.logf("the findings %s are not read, so none is cleared this pass: %v", assign.KindActingCycle, err)
return
}
for _, key := range keys {
if strings.HasSuffix(key, "."+assign.KindActingCycle) && !raised[key] {
if err := p.findings.Delete(key); err != nil {
p.logf("the finding %s is not cleared: %v", key, err)
continue
}
for said := range p.said {
if strings.HasPrefix(said, key+"=") {
delete(p.said, said)
}
}
}
}
}
@@ -8,6 +8,7 @@ import (
"time"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
@@ -116,3 +117,68 @@ func TestTheToolsReadAndReleaseAnActingWait(t *testing.T) {
t.Errorf("wait = %+v", r)
}
}
// Design 47 G §268, A-323, A-330: the assigner and the node-engine on a node are one step: the node-engine's own want waits for no
// acting part, so it moves first, and the node's other wants, the assigner's own included, wait for the new node-engine; a want held
// on a cycle no rule breaks is the finding acting-cycle (scope module, id <node>.<module>) naming each edge's step, cleared by the
// next pass that finds it on none.
func TestTheNodeEngineIsWantedFirstAndAWantHeldOnACycleIsAFinding(t *testing.T) {
p := newPasser(kvSet{buckets.Waits(): &memKV{values: map[string][]byte{}}}, time.Date(2026, 11, 13, 2, 0, 0, 0, time.UTC))
p.publishWants = func(string, any, string) error { return nil }
findings := map[string]mcseat.Finding{}
p.findings = assign.Findings{
Put: func(f mcseat.Finding) error { findings[f.Scope+"."+f.ID+"."+f.Kind] = f; return nil },
Delete: func(key string) error { delete(findings, key); return nil },
Keys: func() ([]string, error) {
var keys []string
for k := range findings {
keys = append(keys, k)
}
return keys, nil
}}
compose := meshwants.Edge{Kind: "acting", Node: "ace", Module: "mesh-host", Fp: "src2:engine", Seat: "node-engine", Step: "compose"}
place := meshwants.Edge{Kind: "acting", Node: "ace", Module: "assigner", Fp: "src2:assigner", Seat: "mesh-wants", Step: "place"}
entries := map[string]meshwants.AssignmentEntry{
"d1": {Node: "ace", Module: "mesh-host", Fp: "src2:engine", Edges: []meshwants.Edge{place}},
"d2": {Node: "ace", Module: "assigner", Fp: "src2:assigner", Edges: []meshwants.Edge{compose}},
"d3": {Node: "ace", Module: "app", Fp: "src2:app", Edges: []meshwants.Edge{compose, place}},
}
p.graph = &graphKeeper{}
for d, e := range entries {
p.graph.computed.Revision.Entries = append(p.graph.computed.Revision.Entries, meshwants.NodeEntryRef{Node: e.Node, Module: e.Module, Digest: d})
}
p.graph.computed.Entries = entries
n := assign.Nodes{Online: map[string]bool{"ace": true}, Running: map[string]map[string]nodeengine.Running{"ace": {
"mesh-host": {Fingerprint: "src2:old"}, "assigner": {Fingerprint: "src2:old"}}}}
wanted := map[string]map[string]mwc.Want{}
if got := p.actingOf("mesh-host", nil)("ace"); len(got) != 0 {
t.Errorf("the node-engine's want is held by %+v, want by no acting edge", got)
}
if got := p.actingOf("assigner", nil)("ace"); len(got) != 1 || got[0].Part != "mesh-host" {
t.Errorf("the assigner's own want is held by %+v, want by the new node-engine", got)
}
p.actingWaits(context.Background(), n, wanted)
kept := p.w.read(buckets.Waits())
if kept[acting.Key("ace", "mesh-host", "assigner")] != nil || kept[acting.Key("ace", "assigner", "mesh-host")] == nil || kept[acting.Key("ace", "app", "mesh-host")] == nil {
t.Errorf("the waits kept: %v", kept)
}
if len(findings) != 0 {
t.Errorf("the cycle the node-engine's want breaks is a finding: %v", findings)
}
// Two parts holding each other across compose and place, neither the node-engine: no rule breaks it.
entries["d4"] = meshwants.AssignmentEntry{Node: "ace", Module: "x", Fp: "src2:x", Edges: []meshwants.Edge{{Kind: "acting", Node: "ace", Module: "y", Fp: "src2:y", Seat: "s-y", Step: "place"}}}
entries["d5"] = meshwants.AssignmentEntry{Node: "ace", Module: "y", Fp: "src2:y", Edges: []meshwants.Edge{{Kind: "acting", Node: "ace", Module: "x", Fp: "src2:x", Seat: "s-x", Step: "compose"}}}
p.graph.computed.Revision.Entries = append(p.graph.computed.Revision.Entries, meshwants.NodeEntryRef{Node: "ace", Module: "x", Digest: "d4"},
meshwants.NodeEntryRef{Node: "ace", Module: "y", Digest: "d5"})
p.actingWaits(context.Background(), n, wanted)
f, ok := findings["module.ace.x.acting-cycle"]
if len(findings) != 2 || !ok || !strings.Contains(f.Summary, "ace/x -[place]-> ace/y -[compose]-> ace/x") {
t.Fatalf("the findings of a cycle no rule breaks: %v", findings)
}
// x's new version runs: y no longer waits, so neither is held on a cycle, and both findings clear.
n.Running["ace"]["x"] = nodeengine.Running{Fingerprint: "src2:x"}
p.actingWaits(context.Background(), n, wanted)
if len(findings) != 0 {
t.Errorf("the findings after the cycle broke: %v", findings)
}
}
@@ -61,7 +61,7 @@ func (p *passer) keepGraph(ctx context.Context) {
if cycles := strings.Join(c.Cycles, "; "); cycles != g.cycled {
g.cycled = cycles
if cycles != "" {
p.logf("the assignment graph holds a cycle among the waits within one step, and its entries wait for each other: %s", cycles)
p.logf("the assignment graph holds a cycle among the acting waits, within one step or across steps: %s", cycles)
}
}
if !g.started {
@@ -154,3 +154,18 @@ func (f Findings) Say(say func(oc.Condition) error) func(oc.Condition) error {
return errors.Join(errs...)
}
}
// KindActingCycle is the finding of a want held on a cycle of acting waits no rule breaks (design 47 G A-323, A-330): scope module,
// id `<node>.<module>` of the held want, naming the cycle with each edge's step; cleared by the next pass that finds it held on no
// cycle.
const KindActingCycle = "acting-cycle"
// ActingCycleFinding is the finding acting-cycle of module's want on node, held on cycle.
func ActingCycleFinding(node, module, cycle string) Observed {
return Observed{Scope: mcc.ScopeModule, ID: node + "." + module, Kind: KindActingCycle, Machine: node, Severity: "warning",
Summary: fmt.Sprintf("the want of %s on %s is held on a cycle of acting waits: %s", module, node, cycle),
Headline: fmt.Sprintf("%s on %s waits on a cycle of acting parts", module, node),
Explanation: fmt.Sprintf("Each acting part on the cycle %s waits for another's new version to run, so none moves by itself. "+
"assigner_release lets one move with the version running.", cycle),
Source: "assigner." + KindActingCycle}
}
@@ -53,7 +53,8 @@ type Computed struct {
// Uncomputed is each placed module whose entry could not be computed, as "<node>/<module>: why", sorted: it is said,
// never left out in silence.
Uncomputed []string
// Cycles are the cycles among the waits within one step (Cycles, design 47 G §272): said, never left in silence.
// Cycles are the cycles among the acting waits, within one step or across steps (Cycles, design 47 G §272, A-323): said, never
// left in silence.
Cycles []string
}
@@ -6,14 +6,15 @@ import (
"slices"
"strings"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
)
// Cycles are the cycles among the waits within one step (design 47 G §272, ADR 0656): an entry waits for its acting parts in the step an
// edge names, so two acting parts acting on each other in one step wait for each other for ever. Each is `<step>: a -> b -> a`, by
// node and module, sorted. Edges of two steps never close a cycle together: the indexer taking in the checker and the checker checking
// the indexer are none, and a holder's own inputs are no edge here (the graph gives an acting edge only from a part to the entries it
// acts on, never to what the part is built from).
// Cycles are the cycles among the acting waits (design 47 G §272, ADR 0656, A-323): an entry waits for its acting parts in the step
// an edge names, so acting parts that wait for each other, in one step or across steps, wait for ever unless a rule breaks the
// cycle. A cycle within one step is `<step>: a -> b -> a`; one across steps names each edge's step, `a -[compose]-> b -[place]-> a`;
// each by node and module, said once from its smallest member, sorted. A holder's own inputs are no edge here (the graph gives an
// acting edge only from a part to the entries it acts on, never to what the part is built from).
func Cycles(entries []meshwants.AssignmentEntry) []string {
type key struct{ step, at string }
next := map[key][]string{}
@@ -68,6 +69,166 @@ func Cycles(entries []meshwants.AssignmentEntry) []string {
}
}
}
// Across steps (A-323): the shortest cycle through each entry over every acting edge, said when its edges name two steps or more.
for _, hops := range cyclesThrough(entries, func(_ meshwants.AssignmentEntry, e meshwants.Edge) bool { return e.Kind == EdgeActing }) {
if crossesSteps(hops) {
out = append(out, hopWords(rotated(hops)))
}
}
slices.Sort(out)
return slices.Compact(out)
}
// HeldCycles is, by `<node>/<module>`, the cycle each entry lies on among the edges holds says hold it (A-323, A-330): a want held
// on such a cycle is held for ever, since no rule breaks it. Each cycle starts at the entry and names each edge's step.
func HeldCycles(entries []meshwants.AssignmentEntry, holds func(e meshwants.AssignmentEntry, edge meshwants.Edge) bool) map[string]string {
out := map[string]string{}
for at, hops := range cyclesThrough(entries, holds) {
out[at] = hopWords(hops)
}
return out
}
// Engines is, by node, the module that holds the seat node-engine there, read from the acting edges' seat (A-330).
func Engines(entries []meshwants.AssignmentEntry) map[string]string {
out := map[string]string{}
for _, e := range entries {
for _, edge := range e.Edges {
if edge.Kind == EdgeActing && edge.Seat == nodeengine.Seat {
out[edge.Node] = edge.Module
}
}
}
return out
}
// hop is one edge of a cycle: from the entry at, in step, to the next hop's entry.
type hop struct{ at, step string }
// cyclesThrough is, by `<node>/<module>`, the shortest cycle through each entry over the edges keep takes, found within the
// entry's strongly connected part, each starting at the entry.
func cyclesThrough(entries []meshwants.AssignmentEntry, keep func(meshwants.AssignmentEntry, meshwants.Edge) bool) map[string][]hop {
type out struct{ to, step string }
next := map[string][]out{}
for _, e := range entries {
at := e.Node + "/" + e.Module
for _, edge := range e.Edges {
if keep(e, edge) {
next[at] = append(next[at], out{edge.Node + "/" + edge.Module, edge.Step})
}
}
}
for at := range next {
slices.SortFunc(next[at], func(a, b out) int { return strings.Compare(a.to+"\x00"+a.step, b.to+"\x00"+b.step) })
}
part := strongParts(slices.Sorted(maps.Keys(next)), func(at string) []string {
var to []string
for _, o := range next[at] {
to = append(to, o.to)
}
return to
})
found := map[string][]hop{}
for _, start := range slices.Sorted(maps.Keys(next)) {
// breadth first from start back to it, within its part
prev := map[string]hop{}
queue := []string{start}
seen := map[string]bool{}
for len(queue) > 0 && found[start] == nil {
at := queue[0]
queue = queue[1:]
for _, o := range next[at] {
if part[o.to] != part[start] {
continue
}
if o.to == start {
hops := []hop{{at, o.step}}
for cur := at; cur != start; {
h := prev[cur]
hops = append(hops, h)
cur = h.at
}
slices.Reverse(hops)
found[start] = hops
break
}
if !seen[o.to] {
seen[o.to] = true
prev[o.to] = hop{at, o.step}
queue = append(queue, o.to)
}
}
}
}
return found
}
// strongParts numbers each vertex by its strongly connected part (Tarjan).
func strongParts(vertices []string, succ func(string) []string) map[string]int {
index, low, part := map[string]int{}, map[string]int{}, map[string]int{}
on := map[string]bool{}
var stack []string
n, parts := 0, 0
var visit func(v string)
visit = func(v string) {
index[v], low[v] = n, n
n++
stack = append(stack, v)
on[v] = true
for _, w := range succ(v) {
if _, ok := index[w]; !ok {
visit(w)
low[v] = min(low[v], low[w])
} else if on[w] {
low[v] = min(low[v], index[w])
}
}
if low[v] == index[v] {
parts++
for {
w := stack[len(stack)-1]
stack = stack[:len(stack)-1]
on[w] = false
part[w] = parts
if w == v {
break
}
}
}
}
for _, v := range vertices {
if _, ok := index[v]; !ok {
visit(v)
}
}
return part
}
func crossesSteps(hops []hop) bool {
for _, h := range hops[1:] {
if h.step != hops[0].step {
return true
}
}
return false
}
// rotated is the cycle started at its smallest member, so one cycle is said once.
func rotated(hops []hop) []hop {
lowest := 0
for i, h := range hops {
if h.at < hops[lowest].at {
lowest = i
}
}
return append(slices.Clone(hops[lowest:]), hops[:lowest]...)
}
// hopWords is a cycle with each edge's step: `a -[compose]-> b -[place]-> a`.
func hopWords(hops []hop) string {
var b strings.Builder
for _, h := range hops {
fmt.Fprintf(&b, "%s -[%s]-> ", h.at, h.step)
}
return b.String() + hops[0].at
}
@@ -7,9 +7,9 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
)
// Design 47 G §272 (AS10): a cycle among the waits within one step is found; an acting part acting on itself's other module, two parts
// acting on each other in different steps, and a chain are none.
func TestACycleAmongTheWaitsWithinOneStepIsFound(t *testing.T) {
// Design 47 G §272 (AS10), A-323: a cycle among the waits is found within one step and across steps, each edge's step named across
// steps; a chain and provisioning edges are none.
func TestACycleAmongTheWaitsWithinOrAcrossStepsIsFound(t *testing.T) {
act := func(node, module, step string) meshwants.Edge {
return meshwants.Edge{Kind: EdgeActing, Node: node, Module: module, Step: step}
}
@@ -25,7 +25,11 @@ func TestACycleAmongTheWaitsWithinOneStepIsFound(t *testing.T) {
[]string{"compose: ace/a -> ace/b -> ace/a"}},
{"three in a ring, said once", []meshwants.AssignmentEntry{entry("c", act("ace", "a", "build")), entry("a", act("ace", "b", "build")), entry("b", act("ace", "c", "build"))},
[]string{"build: ace/a -> ace/b -> ace/c -> ace/a"}},
{"the indexer taking in the checker, the checker checking the indexer", []meshwants.AssignmentEntry{entry("checker", act("ace", "indexer", "take-in")), entry("indexer", act("ace", "checker", "check"))}, nil},
{"the indexer taking in the checker, the checker checking the indexer", []meshwants.AssignmentEntry{entry("checker", act("ace", "indexer", "take-in")), entry("indexer", act("ace", "checker", "check"))},
[]string{"ace/checker -[take-in]-> ace/indexer -[check]-> ace/checker"}},
{"the assigner and the node-engine, across compose and place", []meshwants.AssignmentEntry{entry("assigner", act("ace", "engine", "compose")),
entry("engine", act("ace", "assigner", "place")), entry("app", act("ace", "engine", "compose"), act("ace", "assigner", "place"))},
[]string{"ace/assigner -[compose]-> ace/engine -[place]-> ace/assigner"}},
{"a chain", []meshwants.AssignmentEntry{entry("a", act("ace", "b", "compose")), entry("b", act("ace", "c", "compose")), entry("c")}, nil},
{"provisioning edges are no wait of a step", []meshwants.AssignmentEntry{entry("a", meshwants.Edge{Kind: EdgeProvisioning, Node: "ace", Module: "b"}), entry("b", meshwants.Edge{Kind: EdgeProvisioning, Node: "ace", Module: "a"})}, nil},
} {
@@ -34,3 +38,23 @@ func TestACycleAmongTheWaitsWithinOneStepIsFound(t *testing.T) {
}
}
}
// A-323, A-330: the node-engine on a node is read from the acting edges' seat, and the cycles that hold a want are those of the
// edges said to hold it, each from the entry, naming each edge's step.
func TestTheHeldCyclesAreThoseOfTheHoldingEdges(t *testing.T) {
entries := []meshwants.AssignmentEntry{
{Node: "ace", Module: "assigner", Edges: []meshwants.Edge{{Kind: EdgeActing, Node: "ace", Module: "engine", Step: "compose", Seat: "node-engine"}}},
{Node: "ace", Module: "engine", Edges: []meshwants.Edge{{Kind: EdgeActing, Node: "ace", Module: "assigner", Step: "place", Seat: "mesh-wants"}}},
}
if got := Engines(entries); len(got) != 1 || got["ace"] != "engine" {
t.Errorf("the node-engines %v, want engine on ace", got)
}
all := func(meshwants.AssignmentEntry, meshwants.Edge) bool { return true }
if got := HeldCycles(entries, all); len(got) != 2 || got["ace/engine"] != "ace/engine -[place]-> ace/assigner -[compose]-> ace/engine" {
t.Errorf("held cycles %v", got)
}
// the node-engine's want held by no acting edge: no cycle holds anything
if got := HeldCycles(entries, func(e meshwants.AssignmentEntry, _ meshwants.Edge) bool { return e.Module != "engine" }); len(got) != 0 {
t.Errorf("held cycles with the node-engine's edges holding nothing: %v", got)
}
}