Move Intended and JudgeAnswers into mesh-sdk for a proposal: a difference is shown, only a refusal or silence fails (ADR 0567 §4, issue 1126)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user