From bb63d83f849e250275714210cfbe2705c03a5067 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 27 Oct 2026 16:19:02 +0100 Subject: [PATCH] Derive a want's Self from the placement now, in every rule, so a want written while a node was placed does not outlive it (issue 1126) After the placement seed's revert the rules that hold a want (a hold, rules 3 and 5) kept g14's and shanks's Self wants: the held branch copied them whole, and the rule branch wrote a want naming no composition, which EncodeWant refuses. Module.Self, set once from the placement, is now the one source, and every want the rules write or keep is derived from it: an unplaced node's kept want names the controller's composition of the same fingerprint, asked as any record move; with none, it stays and rule 7 says why. --- .../cmd/mesh-delivery-assigner/pass.go | 7 +- .../internal/assign/wants.go | 47 ++++++++++ .../internal/assign/wants_test.go | 90 +++++++++++++++++++ 3 files changed, 141 insertions(+), 3 deletions(-) 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 0413710..e87746a 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -144,6 +144,7 @@ func (p *passer) wants(ctx context.Context) { fp := trunk[name].FP2 name := name m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name], + Self: func(node string) bool { return placed[node] }, Composed: func(node, fp string) (delivery.ComposedDigest, bool) { if placed[node] { // The node composes its own (ADR 0547 §2, §4): no composition of the controller's is waited for, the want @@ -185,7 +186,7 @@ func (p *passer) wants(ctx context.Context) { last[node] = p.w.lastHealthy(node, name, fp) } out := assign.Wants(m, nodes, wanted[name], last) - p.yeses(ctx, name, m, placed, wanted[name], &out) + p.yeses(ctx, name, m, wanted[name], &out) gatherNeither(neither, name, out) leftNeither(left, m, wanted[name], out) p.sayNoWant(name, m.Nodes, wanted[name], out) @@ -897,9 +898,9 @@ func (p *passer) sayPlacement(differ []assign.Pair) map[string]bool { // person must allow (ADR 0547 §3): for a have of phase // waiting-yes it raises the record ask once, naming the composition waiting and the node's judgement, and on a yes writes // the want again naming it; a yes once written is kept while the want stays at its build, so the node is not asked again. -func (p *passer) yeses(ctx context.Context, module string, m assign.Module, placed map[string]bool, before map[string]delivery.Want, out *assign.Outcome) { +func (p *passer) yeses(ctx context.Context, module string, m assign.Module, before map[string]delivery.Want, out *assign.Outcome) { for _, node := range m.Nodes { - if !placed[node] { + if m.Self == nil || !m.Self(node) { continue } was, had := before[node] diff --git a/modules/mesh-delivery-assigner/internal/assign/wants.go b/modules/mesh-delivery-assigner/internal/assign/wants.go index bd736c6..3923a4f 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants.go @@ -47,8 +47,14 @@ type Module struct { Now time.Time // Puts are the puts of the module, by node (ADR 0546): while one owns a node's want (PutOwns), the rules pass over it. Puts map[string]delivery.Put + // Self says whether node composes the module itself: the catalogue's placement names the node (ADR 0547 §2). It is the one + // source of a want's Self: every want the rules write or keep is derived from it (selfDerived), so a want written Self while + // the node was placed does not outlive the placement (issue 1126). Nil: no node does. + Self func(node string) bool } +func (m Module) selfOn(node string) bool { return m.Self != nil && m.Self(node) } + func (m Module) composition(node, fp string) (delivery.ComposedDigest, bool) { if m.Composed == nil { return delivery.ComposedDigest{}, false @@ -418,6 +424,10 @@ func Wants(m Module, n Nodes, wants map[string]delivery.Want, lastHealthy map[st } } } + next, write := m.selfDerived(node, before, had, next, n, &out) + if !write { + continue + } if !had || !delivery.SameWant(normal(before), normal(next)) { out.Wants[node] = next } @@ -430,6 +440,43 @@ func Wants(m Module, n Nodes, wants map[string]delivery.Want, lastHealthy map[st return out } +// selfDerived is next with its Self derived from the placement now (Module.Self), whichever rule made it, and whether it is +// written. A placed node's want names the build alone (ADR 0547 §2). An unplaced node's want that a rule kept from the want +// written while the node was placed (held at F, held back by rule 3 or 5, a data module asked about) names the controller's +// composition of the same fingerprint, asked as any move of a record module is (ADR 0539 §14): on 2026-10-27, after the +// placement seed's revert, g14's systemd-resolved (held) and forticlient (rule 5), and shanks's forticlient (rule 3), kept +// their Self wants, which their node agents, no longer placed, could not compose (issue 1126). With no such composition, or +// while its ask waits, the want written stays and the node's holding says why. +func (m Module) selfDerived(node string, before delivery.Want, had bool, next delivery.Want, n Nodes, out *Outcome) (delivery.Want, bool) { + switch { + case next.Removed: + return next, true + case m.selfOn(node): + next.Self, next.Composed = true, "" + return next, true + case !next.Self && next.Composed != "": + return next, true + } + next.Self, next.Yes = false, "" + d, there := m.composition(node, next.FP) + if !there || d.Digest == "" { + out.Rules[node] = Holding{Rule: delivery.RuleComposed, Words: fmt.Sprintf("%s on %s was composed by the node while it was placed, "+ + "and the controller has not composed it at %s, so its want stays", m.Name, node, short(next.FP))} + return next, false + } + at := m + at.FP = next.FP + running, ran := n.Haves[node][m.Name] + if need, wait := at.mustAsk(node, before, had, ran, running.FP, running.Composed == "", d); wait { + if need != nil && !slices.Contains(out.Record, *need) { // the branch held back at an older fingerprint may have asked it + out.Record = append(out.Record, *need) + } + return next, false + } + next.Composed = d.Digest + return next, true +} + // NoWantWords say why a node gets no want for a module: the rule that holds its first want back and why (issue 1945). func NoWantWords(rule int, words string) string { return fmt.Sprintf("no want: rule %d: %s", rule, words) diff --git a/modules/mesh-delivery-assigner/internal/assign/wants_test.go b/modules/mesh-delivery-assigner/internal/assign/wants_test.go index ff629e2..56fdc87 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants_test.go @@ -87,6 +87,96 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { } } + // 1126/AC-SW1, AC-SW2, AC-SW3: a want written Self while g14 was placed, kept by a rule after the placement is gone, names + // the controller's composition of the fingerprint the rule keeps (on 2026-10-27 g14's systemd-resolved, held, and forticlient, + // rule 5, kept their Self wants, which g14 could no longer compose); with no such composition, or while a record ask waits, it + // stays and is said; a placed node's want is Self with no composition. + selfAt := func(fp string) map[string]delivery.Want { + return map[string]delivery.Want{"ace": {FP: fNew, Composed: dig(fNew)}, "g14": {FP: fp, Self: true, Yes: dig(fp)}} + } + atF := func(_ *Module, n *Nodes) { + n.Haves["g14"]["mail"] = delivery.Have{FP: fNew, Composed: dig(fNew), Phase: delivery.PhaseFailed, Health: delivery.HealthOK} + } + for _, c := range []struct { + name string + set func(*Module, *Nodes) + before string + want delivery.Want // the want of g14 written; zero: none written + rule int // the rule g14's holding names + asked bool + }{ + {name: "rule 5 at F", before: fNew, set: func(m *Module, n *Nodes) { + atF(m, n) + n.Haves["ace"]["store"] = delivery.Have{FP: fpOf('s'), Phase: delivery.PhaseRunning, Health: delivery.HealthWaits} + }, want: delivery.Want{FP: fNew, Composed: dig(fNew)}, rule: delivery.RuleProviders}, + {name: "rule 3 at F", before: fNew, set: func(m *Module, n *Nodes) { + atF(m, n) + n.Haves["ace"]["mail"] = delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + }, want: delivery.Want{FP: fNew, Composed: dig(fNew)}, rule: delivery.RuleCanary}, + {name: "rule 3 held back at an older fingerprint", before: fOld, set: func(_ *Module, n *Nodes) { + n.Haves["ace"]["mail"] = delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + }, want: delivery.Want{FP: fOld, Composed: dig(fOld), Newest: fNew, Rule: delivery.RuleCanary, + Words: "the canary ace does not run bbbbbbbbbbbb healthy yet"}, rule: delivery.RuleCanary}, + {name: "held at F with nothing to set back to", before: fNew, set: func(m *Module, n *Nodes) { atF(m, n); m.Held = true }, + want: delivery.Want{FP: fNew, Composed: dig(fNew), Words: "mail is held at bbbbbbbbbbbb and g14 ran no other fingerprint healthy to set it back to"}, + rule: delivery.RuleNotHeld}, + {name: "held data module", before: fNew, set: func(m *Module, n *Nodes) { + atF(m, n) + m.Held = true + m.Manifest.Data = &catalogue.Data{Own: []catalogue.DataItem{{ID: "mail", Class: catalogue.ClassIrreplaceable}}} + }, want: delivery.Want{FP: fNew, Composed: dig(fNew), + Words: "mail holds data, so it is not set back from bbbbbbbbbbbb: the operator is asked whether to restore it"}, rule: delivery.RuleNotHeld}, + {name: "a record ask waits", before: fNew, set: func(m *Module, n *Nodes) { + atF(m, n) + recording(m) + m.Held = true + m.Composed = func(_, fp string) (delivery.ComposedDigest, bool) { + return delivery.ComposedDigest{Digest: dig(fp), Judgement: delivery.JudgedRecreates, Against: dig(fOld)}, true + } + }, rule: delivery.RuleNotHeld, asked: true}, + {name: "no composition of F", before: fNew, set: func(m *Module, n *Nodes) { + atF(m, n) + m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) { + if node == "g14" { + return delivery.ComposedDigest{}, false + } + return composedEverywhere(node, fp) + } + }, rule: delivery.RuleComposed}, + {name: "placed", before: fNew, set: func(m *Module, n *Nodes) { + atF(m, n) + m.Self = func(node string) bool { return node == "g14" } + m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) { + if node == "g14" { + return delivery.ComposedDigest{Judgement: delivery.JudgedNeither}, true + } + return composedEverywhere(node, fp) + } + }, want: delivery.Want{FP: fNew, Self: true}}, + } { + m, n := ready() + c.set(&m, &n) + before := selfAt(c.before) + if c.name == "placed" { + before["g14"] = delivery.Want{FP: fNew, Composed: dig(fNew)} + } + out := Wants(m, n, before, nil) + if got := out.Wants["g14"]; got != c.want { + t.Errorf("%s: g14's want = %+v, want %+v", c.name, got, c.want) + } + if got := out.Rules["g14"].Rule; got != c.rule { + t.Errorf("%s: g14 is held by rule %d (%s), want %d", c.name, got, out.Rules["g14"].Words, c.rule) + } + if asked := len(out.Record) == 1 && out.Record[0].Node == "g14"; asked != c.asked { + t.Errorf("%s: record asks %+v", c.name, out.Record) + } + if w, ok := out.Wants["g14"]; ok { + if _, err := delivery.EncodeWant(w); err != nil { + t.Errorf("%s: g14's want does not encode: %v", c.name, err) + } + } + } + // Rule 6 per node: a grant written for the module's other nodes does not grant a node whose account it does not name // (issue 1945, live on 2026-10-25 at 20:04: ace's mesh-build-go was wanted with no account on the bus). m, n := ready()