// 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 }