373 lines
12 KiB
Go
373 lines
12 KiB
Go
// 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 (
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"strconv"
|
|
"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 a node's answer after its candidate was written (ADR 0567 §4): the time to
|
|
// live of the node's pulse key (delivery.PulseAnswerKey), whose expiry names it silent, 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 on every node of Must with
|
|
// no answer that counts once its pulse key has ended (pulsing false: the bound's expiry, heard on the bus); while it
|
|
// stands 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, pulsing map[string]bool, now time.Time) delivery.PlacementVerdict {
|
|
v := delivery.PlacementVerdict{At: now.UTC(), Intended: map[string][]string{}, Unintended: map[string][]string{}, Carried: 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 {
|
|
// The seed carries what its nodes found, each named in the brief for the operator who approves it.
|
|
v.Carried[node] = append(v.Carried[node], words)
|
|
return
|
|
}
|
|
if !want[module] {
|
|
v.Unintended[node] = append(v.Unintended[node], module)
|
|
}
|
|
}
|
|
for _, d := range a.Differs {
|
|
differs(d.Module, differenceWords(d))
|
|
}
|
|
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.Carried) == 0 {
|
|
v.Carried = nil
|
|
}
|
|
if len(v.Answers) == 0 {
|
|
v.Answers = nil
|
|
}
|
|
var waiting, gone []string
|
|
for _, node := range v.Silent {
|
|
named := node
|
|
if online != nil && !online[node] {
|
|
named += " (offline)"
|
|
}
|
|
if pulsing[node] {
|
|
waiting = append(waiting, named)
|
|
} else {
|
|
gone = append(gone, named)
|
|
}
|
|
}
|
|
if len(waiting) > 0 {
|
|
v.Notes = append(v.Notes, fmt.Sprintf("waits for the answers of %s, each for %s from its candidate's write",
|
|
strings.Join(waiting, ", "), AnswerBound))
|
|
}
|
|
if len(gone) > 0 {
|
|
v.Fails = append(v.Fails, fmt.Sprintf("%s did not answer within %s of its candidate's write; judged again when it "+
|
|
"answers", strings.Join(gone, ", "), AnswerBound))
|
|
}
|
|
switch {
|
|
case len(v.Fails) > 0:
|
|
v.State = delivery.VerdictFail
|
|
case len(waiting) > 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, and what stays held as the node holds it today, 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
|
|
}
|
|
for n, a := range v.Answers {
|
|
if len(a.Held) > 0 {
|
|
nodes[n] = true
|
|
}
|
|
}
|
|
for n := range v.Carried {
|
|
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, ", "))
|
|
}
|
|
if m := v.Carried[n]; len(m) > 0 {
|
|
fmt.Fprintf(&b, "; the seed carries: %s", strings.Join(m, "; "))
|
|
}
|
|
if held := v.Answers[n].Held; len(held) > 0 {
|
|
var names []string
|
|
for _, h := range held {
|
|
today := h.Today
|
|
if today == "" {
|
|
today = "held"
|
|
}
|
|
names = append(names, h.Module+" ("+today+")")
|
|
}
|
|
fmt.Fprintf(&b, "; stays as today: %s", strings.Join(names, ", "))
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// differenceWords names one module's difference: the module, the first field that differs in it with its value as it
|
|
// runs and as the seed composes it, each cut to about 60 characters ("(value cut)" where a body shown is cut before the
|
|
// field), and how many fields more; or the resource and why when no body is shown.
|
|
func differenceWords(d delivery.ModuleDiffers) string {
|
|
for _, b := range d.Bodies {
|
|
if len(b.Paths) == 0 {
|
|
continue
|
|
}
|
|
path := b.Paths[0]
|
|
words := fmt.Sprintf("%s: %s %s → %s", d.Module, fieldName(b.Running, b.Composed, path),
|
|
valueAt(b.Running, path), valueAt(b.Composed, path))
|
|
if n := len(b.Paths) - 1; n > 0 {
|
|
words += fmt.Sprintf(" (and %d more fields)", n)
|
|
}
|
|
return words
|
|
}
|
|
if d.Resource != "" {
|
|
return fmt.Sprintf("%s: %s (%s)", d.Module, d.Resource, d.Why)
|
|
}
|
|
return fmt.Sprintf("%s: %s", d.Module, d.Why)
|
|
}
|
|
|
|
// shownValue bounds a value named in a brief.
|
|
const shownValue = 60
|
|
|
|
// walk follows a differing path (delivery.DifferingPaths: keys joined by dots, a key may hold dots, and a JSON value
|
|
// written in a string is read as JSON) through a body, answering the keys it passed and the value at its end.
|
|
func walk(body, path string) (keys []string, value any, ok bool) {
|
|
var v any
|
|
if json.Unmarshal([]byte(body), &v) != nil {
|
|
return nil, nil, false
|
|
}
|
|
rest := path
|
|
for rest != "" {
|
|
if s, isString := v.(string); isString {
|
|
var inner any
|
|
if json.Unmarshal([]byte(s), &inner) != nil {
|
|
return nil, nil, false
|
|
}
|
|
v = inner
|
|
}
|
|
switch t := v.(type) {
|
|
case map[string]any:
|
|
best := ""
|
|
for k := range t {
|
|
if (rest == k || strings.HasPrefix(rest, k+".")) && len(k) > len(best) {
|
|
best = k
|
|
}
|
|
}
|
|
if best == "" {
|
|
return nil, nil, false
|
|
}
|
|
keys, v, rest = append(keys, best), t[best], strings.TrimPrefix(strings.TrimPrefix(rest, best), ".")
|
|
case []any:
|
|
seg, after, _ := strings.Cut(rest, ".")
|
|
n, err := strconv.Atoi(seg)
|
|
if err != nil || n < 0 || n >= len(t) {
|
|
return nil, nil, false
|
|
}
|
|
keys, v, rest = append(keys, seg), t[n], after
|
|
default:
|
|
return nil, nil, false
|
|
}
|
|
}
|
|
return keys, v, true
|
|
}
|
|
|
|
// valueAt is the value at path in a shown body, cut to shownValue, or why it is not shown.
|
|
func valueAt(body, path string) string {
|
|
if body == "" {
|
|
return "(none)"
|
|
}
|
|
_, v, ok := walk(body, path)
|
|
if !ok {
|
|
return "(value cut)"
|
|
}
|
|
s, isString := v.(string)
|
|
if !isString {
|
|
raw, _ := json.Marshal(v)
|
|
s = string(raw)
|
|
}
|
|
if r := []rune(s); len(r) > shownValue {
|
|
s = string(r[:shownValue-1]) + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// fieldName is the differing field as a person reads it: its parent's name and its own, `PARENT[key]`, from whichever
|
|
// body reaches it; the path's end when neither does.
|
|
func fieldName(running, composed, path string) string {
|
|
for _, body := range []string{running, composed} {
|
|
if keys, _, ok := walk(body, path); ok && len(keys) > 0 {
|
|
if len(keys) == 1 {
|
|
return keys[0]
|
|
}
|
|
return keys[len(keys)-2] + "[" + keys[len(keys)-1] + "]"
|
|
}
|
|
}
|
|
return path
|
|
}
|