From 7c565f2aa5b567a202fe8208f785e6013978d47b Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 18:11:18 +0100 Subject: [PATCH] =?UTF-8?q?Move=20Intended=20and=20JudgeAnswers=20into=20m?= =?UTF-8?q?esh-sdk=20for=20a=20proposal:=20a=20difference=20is=20shown,=20?= =?UTF-8?q?only=20a=20refusal=20or=20silence=20fails=20(ADR=200567=20?= =?UTF-8?q?=C2=A74,=20issue=201126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/compose/frombus/frombus_test.go | 4 +- go/compose/judge/judge.go | 238 +++++++++++++++++++++++++++++ go/compose/judge/judge_test.go | 68 +++++++++ go/delivery/placement.go | 6 +- go/delivery/placement_test.go | 2 +- 5 files changed, 313 insertions(+), 5 deletions(-) create mode 100644 go/compose/judge/judge.go create mode 100644 go/compose/judge/judge_test.go diff --git a/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go index b0f010e..32a9d72 100644 --- a/go/compose/frombus/frombus_test.go +++ b/go/compose/frombus/frombus_test.go @@ -401,7 +401,9 @@ func TestInput(t *testing.T) { check func(delivery.CandidateAnswer) bool }{ "the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, delivery.CandidateSame, - func(a delivery.CandidateAnswer) bool { return len(a.Same) == 3 && a.Proposal == "p-h1" && a.Revision == 7 }}, + func(a delivery.CandidateAnswer) bool { + return len(a.Same) == 3 && a.Proposal == "p-h1" && a.Revision == 7 + }}, "the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), delivery.CandidateSame, func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }}, "one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, delivery.CandidateDiffers, diff --git a/go/compose/judge/judge.go b/go/compose/judge/judge.go new file mode 100644 index 0000000..289f6f4 --- /dev/null +++ b/go/compose/judge/judge.go @@ -0,0 +1,238 @@ +// Package judge is how the seat mesh-placement's holder judges a proposal (novox/hq ADR 0567 §3, §4): which nodes must +// answer it and what it changes on each (Intended), and the verdict of their answers (JudgeAnswers). Moved here from +// the assigner, where it judged a catalogue pull request's placement (ADR 0559 §5), with a proposal in place of a pull +// request's change. It holds no bus client and no timer: the holder gives it what it read. +package judge + +import ( + "fmt" + "reflect" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/compose" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// AnswerBound is how long the holder waits for every answer after the newest candidate was written (ADR 0567 §4): a node +// asleep or away is named past it, never passed. A variable so a test on the bus can shorten it. +var AnswerBound = 10 * time.Minute + +// Intended is, by node, the modules whose composition the proposal changes, and the nodes that must answer it (§3). +// current is the composition input with the current placement and laidInput the same with the proposal's placements +// (laid, by node; nil removes the node's key) laid over it; nodes is every node to look at (every node with a record). +// A node's set holds the modules the proposal adds, removes or whose placed entry differs on that node, and those whose +// composition differs between the two inputs (delivery.CompareComposed), the node's own named "node". Must is every node +// whose key the proposal changes and every other whose set is not empty, sorted. A node that has no key now gets an +// empty set (§7: the seed is answered as the same). A node whose compositions cannot be made on either side keeps the +// modules its entries name, said in notes; such a node is in Must only when its key changes. +func Intended(current, laidInput *compose.Input, currentPlaced map[string]delivery.Placement, laid map[string]*delivery.Placement, + nodes []string, fps map[string]string) (intended map[string]map[string]bool, must []string, notes []string) { + intended = map[string]map[string]bool{} + inMust := map[string]bool{} + all := map[string]bool{} + for _, n := range nodes { + all[n] = true + } + for n := range laid { + all[n] = true + inMust[n] = true + } + names := make([]string, 0, len(all)) + for n := range all { + names = append(names, n) + } + sort.Strings(names) + for _, node := range names { + before, placed := currentPlaced[node] + set := map[string]bool{} + after, changesKey := laid[node] + if changesKey && !placed { + intended[node] = set // a node new to placement: every answer must be the same + continue + } + if changesKey { + var afterModules []delivery.PlacedModule + if after != nil { + afterModules = after.Modules + } + entries := map[string]delivery.PlacedModule{} + for _, m := range before.Modules { + entries[m.Module] = m + } + seen := map[string]bool{} + for _, m := range afterModules { + seen[m.Module] = true + if e, ok := entries[m.Module]; !ok || !reflect.DeepEqual(e, m) { + set[m.Module] = true + } + } + for name := range entries { + if !seen[name] { + set[name] = true + } + } + } + if placed && current != nil && laidInput != nil { + was, _, _, errB := compose.CompositionsShadow(*current, node, fps) + now, _, _, errA := compose.CompositionsShadow(*laidInput, node, fps) + if errA == nil && errB == nil { + running := make([]delivery.Composed, 0, len(was)) + for _, c := range was { + running = append(running, c) + } + cmp := delivery.CompareComposed(now, running) + for _, d := range cmp.Differs { + set[d.Module] = true + } + for _, d := range cmp.Added { + set[d.Module] = true + } + for _, m := range cmp.Removed { + set[m] = true + } + } else if changesKey { + notes = append(notes, fmt.Sprintf("what the proposal changes on %s is read from its placement alone: its "+ + "compositions cannot be made (now: %v; proposed: %v)", node, errB, errA)) + } + } + if len(set) > 0 { + inMust[node] = true + } + if inMust[node] { + intended[node] = set + } + } + for n := range inMust { + must = append(must, n) + } + sort.Strings(must) + return intended, must, notes +} + +// JudgeAnswers is the verdict of a proposal's answers (§4). answers is by node its HaveNode.Candidates entry for the +// proposal (nil when it has none) and revisions by node the revision of its candidate key: an answer counts when it +// names the proposal and that revision. It fails on a refusal by a node that must answer, and, from the proposal's +// Written plus AnswerBound on, on every node of Must with no answer that counts, named; before that it waits for them. +// A difference fails nothing: one Intended computed is listed under Intended, one it did not under Unintended, both +// shown in the ask's brief. The seed alone fails on any difference. +func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]bool, answers map[string]*delivery.CandidateAnswer, + revisions map[string]uint64, online map[string]bool, now time.Time) delivery.PlacementVerdict { + v := delivery.PlacementVerdict{At: now.UTC(), Intended: map[string][]string{}, Unintended: map[string][]string{}, + Answers: map[string]delivery.CandidateAnswer{}} + for node, set := range intended { + for m := range set { + v.Intended[node] = append(v.Intended[node], m) + } + sort.Strings(v.Intended[node]) + } + must := p.Must + if len(must) == 0 { + must = p.Nodes() + } + for _, node := range must { + a := answers[node] + if a == nil || a.Proposal != p.ID || a.Revision == 0 || a.Revision != revisions[node] { + v.Silent = append(v.Silent, node) + continue + } + v.Answers[node] = *a + if a.Refused != "" { + v.Fails = append(v.Fails, fmt.Sprintf("%s cannot compose the proposal: %s", node, a.Refused)) + continue + } + for _, r := range a.Refusals { + v.Fails = append(v.Fails, fmt.Sprintf("%s refuses the proposal for %s: %s", node, r.Module, r.Why)) + } + want := intended[node] + differs := func(module, words string) { + if p.Seed { + v.Fails = append(v.Fails, fmt.Sprintf("%s %s with the seed, which must be the same as what runs", node, words)) + return + } + if !want[module] { + v.Unintended[node] = append(v.Unintended[node], module) + } + } + for _, d := range a.Differs { + differs(d.Module, fmt.Sprintf("composes %s differently (%s)", d.Module, d.Why)) + } + for _, d := range a.Added { + differs(d.Module, "would run "+d.Module) + } + for _, m := range a.Removed { + differs(m, "would stop running "+m) + } + sort.Strings(v.Unintended[node]) + } + if len(v.Intended) == 0 { + v.Intended = nil + } + if len(v.Unintended) == 0 { + v.Unintended = nil + } + if len(v.Answers) == 0 { + v.Answers = nil + } + if len(v.Silent) > 0 { + v.Until = p.Written.Add(AnswerBound).UTC() + named := make([]string, len(v.Silent)) + for i, node := range v.Silent { + named[i] = node + if online != nil && !online[node] { + named[i] = node + " (offline)" + } + } + if now.Before(v.Until) { + v.Notes = append(v.Notes, fmt.Sprintf("waits for the answers of %s until %s", strings.Join(named, ", "), + v.Until.Format(time.RFC3339))) + } else { + v.Fails = append(v.Fails, fmt.Sprintf("%s did not answer within %s of the newest candidate's write at %s; judged "+ + "again when it answers", strings.Join(named, ", "), AnswerBound, p.Written.UTC().Format(time.RFC3339))) + } + } + switch { + case len(v.Fails) > 0: + v.State = delivery.VerdictFail + case len(v.Silent) > 0: + v.State = delivery.VerdictWaits + default: + v.State = delivery.VerdictPass + } + return v +} + +// Brief is the verdict as the ask's brief shows it, by node (§5): what the proposal changes and what each node answered +// beside it, marked, with the proposal's why. +func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string { + var b strings.Builder + fmt.Fprintf(&b, "%s (proposed by %s)", p.Why, p.By) + nodes := map[string]bool{} + for n := range v.Intended { + nodes[n] = true + } + for n := range v.Unintended { + nodes[n] = true + } + for _, n := range p.Nodes() { + nodes[n] = true + } + names := make([]string, 0, len(nodes)) + for n := range nodes { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + fmt.Fprintf(&b, "; %s:", n) + if m := v.Intended[n]; len(m) > 0 { + fmt.Fprintf(&b, " changes %s", strings.Join(m, ", ")) + } else { + b.WriteString(" composes the same") + } + if m := v.Unintended[n]; len(m) > 0 { + fmt.Fprintf(&b, "; also differs, not computed: %s", strings.Join(m, ", ")) + } + } + return b.String() +} diff --git a/go/compose/judge/judge_test.go b/go/compose/judge/judge_test.go new file mode 100644 index 0000000..f9b9bbd --- /dev/null +++ b/go/compose/judge/judge_test.go @@ -0,0 +1,68 @@ +package judge + +import ( + "slices" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// ADR 0567 §3, §4 (rows moved from the assigner's check of a pull request's placement, ADR 0559 §5): what a proposal +// changes by node, and the verdict of the answers: differences shown and failing nothing, a refusal and a silent node +// failing, a later answer judging again, the seed failing on any difference. +func TestJudge(t *testing.T) { + current := map[string]delivery.Placement{"anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: "mail"}}}, "server": {Node: "server"}} + laid := map[string]*delivery.Placement{ + "anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: "mail", Ports: map[string]int{"25": 2525}}, {Module: "web"}}}, + "laptop": {Node: "laptop", Modules: []delivery.PlacedModule{{Module: "web"}}}} + got, must, _ := Intended(nil, nil, current, laid, []string{"anchor", "laptop", "server"}, nil) + if !got["anchor"]["mail"] || !got["anchor"]["web"] || len(got["laptop"]) != 0 || !slices.Equal(must, []string{"anchor", "laptop"}) { + t.Errorf("intended = %v, must %v: want anchor's mail and web, nothing on the laptop that has no key, server untouched", got, must) + } + + at := time.Date(2026, 10, 27, 2, 0, 0, 0, time.UTC) + p := delivery.PlacementProposal{ID: "p-one", By: "op", Why: "move web", Written: at, Base: map[string]uint64{"anchor": 3, "laptop": 0}, + Must: []string{"anchor", "laptop"}} + revisions := map[string]uint64{"anchor": 7, "laptop": 8} + intended := map[string]map[string]bool{"anchor": {"web": true}} + same := func(rev uint64) *delivery.CandidateAnswer { + return &delivery.CandidateAnswer{Proposal: "p-one", Revision: rev, Verdict: delivery.CandidateSame, Same: []string{"mail"}} + } + differs := func(rev uint64, module string) *delivery.CandidateAnswer { + return &delivery.CandidateAnswer{Proposal: "p-one", Revision: rev, Verdict: delivery.CandidateDiffers, + Differs: []delivery.ModuleDiffers{{Module: module, Why: "the port differs"}}} + } + for _, row := range []struct { + name string + seed bool + answers map[string]*delivery.CandidateAnswer + now time.Time + state string + words string + marked string + }{ + {"all the same", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, at, delivery.VerdictPass, "", ""}, + {"a module added where intended", false, map[string]*delivery.CandidateAnswer{"anchor": {Proposal: "p-one", Revision: 7, Verdict: delivery.CandidateDiffers, + Added: []delivery.ModuleAdded{{Module: "web", Unissued: []string{"its own secret"}}}}, "laptop": same(8)}, at, delivery.VerdictPass, "", ""}, + {"a difference not computed passes, marked", false, map[string]*delivery.CandidateAnswer{"anchor": differs(7, "mail"), "laptop": same(8)}, at, delivery.VerdictPass, "", "mail"}, + {"the seed with a difference", true, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": differs(8, "web")}, at, delivery.VerdictFail, "laptop composes web differently", ""}, + {"a refusal", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, Verdict: delivery.CandidateRefused, + Refusals: []delivery.ModuleRefused{{Module: "web", Why: "no own secret"}}}}, at, delivery.VerdictFail, "laptop refuses", ""}, + {"a node not answering, within its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(7)}, at.Add(AnswerBound - time.Second), delivery.VerdictWaits, "laptop (offline)", ""}, + {"an answer of an earlier revision, past its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(6), "laptop": same(8)}, at.Add(AnswerBound), delivery.VerdictFail, "anchor did not answer", ""}, + {"its later answer", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, at.Add(time.Hour), delivery.VerdictPass, "", ""}, + } { + q := p + q.Seed = row.seed + v := JudgeAnswers(q, intended, row.answers, revisions, map[string]bool{"anchor": true}, row.now) + all := strings.Join(append(append([]string{}, v.Fails...), v.Notes...), "; ") + if v.State != row.state || !strings.Contains(all, row.words) { + t.Errorf("%s: %s, %q", row.name, v.State, all) + } + if row.marked != "" && (!slices.Contains(v.Unintended["anchor"], row.marked) || !strings.Contains(Brief(q, v), "not computed: "+row.marked)) { + t.Errorf("%s: the difference is not marked: %+v, %s", row.name, v.Unintended, Brief(q, v)) + } + } +} diff --git a/go/delivery/placement.go b/go/delivery/placement.go index 8fa91ab..e6fce9b 100644 --- a/go/delivery/placement.go +++ b/go/delivery/placement.go @@ -25,10 +25,10 @@ const ( // PlacementHistory is the revisions the placement bucket keeps per key: the bus's most (§6). PlacementHistory = 64 - // PlacementStream is the holder's stream of the seat's events, kept PlacementStreamDays (§6), and declared as the - // holder's data so the mesh's backups keep it. + // PlacementStream is the holder's stream of the seat's events, kept PlacementStreamDays (§6, within the mesh's bound + // on a module's stream, catalogue.StreamMostDays), and declared as the holder's data so the mesh's backups keep it. PlacementStream = "placement" - PlacementStreamDays = 400 + PlacementStreamDays = 30 // The seat's events, said by its holder as its own (§2, §4, §5, §6), each carrying a PlacementEvent. PlacementProposedEvent = "placement-proposed" diff --git a/go/delivery/placement_test.go b/go/delivery/placement_test.go index 37e7598..78cffc3 100644 --- a/go/delivery/placement_test.go +++ b/go/delivery/placement_test.go @@ -42,7 +42,7 @@ func TestPlacement(t *testing.T) { t.Errorf("a bucket is named %q, want %q", got, want) } } - if PlacementHistory != 64 || PlacementStreamDays != 400 { + if PlacementHistory != 64 || PlacementStreamDays != 30 { t.Errorf("history %d, stream %d days", PlacementHistory, PlacementStreamDays) } })