Merge pull request 'Page an answer larger than one message of the bus, and keep overviews brief (hq issue 314)' (#138) from fix/314-a-large-answer-is-paged-not-lost into main
This commit was merged in pull request #138.
This commit is contained in:
@@ -144,11 +144,9 @@ func listConditions(ctx context.Context, args []string) error {
|
||||
}
|
||||
}
|
||||
if *asJSON {
|
||||
if out == nil {
|
||||
out = []conditions.Condition{}
|
||||
}
|
||||
return printJSON(map[string]any{"conditions": out, "open": len(open),
|
||||
"note": "urgent first, then oldest first; a condition clears when observation says so, never by hand"})
|
||||
return printJSON(map[string]any{"conditions": inBrief(out), "open": len(open), "counted": counted(out),
|
||||
"note": "urgent first, then oldest first; a condition clears when observation says so, never by hand; " +
|
||||
"each with its newest evidence — `conditions key=<key>` gives one whole"})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
if len(open) == 0 {
|
||||
@@ -164,6 +162,46 @@ func listConditions(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// briefEvidence is how many observations a condition carries in a list of them: its newest. The store
|
||||
// keeps ten, and a list of hundreds of conditions with ten each was the larger half of an answer more
|
||||
// than the bus carries in one message (novox/hq issue 314). One condition whole is `conditions key=`.
|
||||
const briefEvidence = 1
|
||||
|
||||
// conditionInBrief is a condition as a list carries it: its newest evidence, with how much more there is
|
||||
// and where to read it. Everything else of it, so a reader of the list — the operator's channel among
|
||||
// them — reads the same condition it always did.
|
||||
type conditionInBrief struct {
|
||||
conditions.Condition
|
||||
// EvidenceKept is how many observations the condition keeps, when the list shows fewer.
|
||||
EvidenceKept int `json:"evidence-kept,omitempty"`
|
||||
More string `json:"more,omitempty"`
|
||||
}
|
||||
|
||||
// inBrief is a list of conditions in brief; never null.
|
||||
func inBrief(list []conditions.Condition) []conditionInBrief {
|
||||
out := make([]conditionInBrief, 0, len(list))
|
||||
for _, c := range list {
|
||||
b := conditionInBrief{Condition: c}
|
||||
if len(c.Evidence) > briefEvidence {
|
||||
b.EvidenceKept = len(c.Evidence)
|
||||
b.Evidence = c.Evidence[:briefEvidence:briefEvidence]
|
||||
b.More = c.Show()
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// counted is how many of a list each source raised of each kind — `D14 stalled: 689` — so a list of
|
||||
// hundreds says its shape before its lines.
|
||||
func counted(list []conditions.Condition) map[string]int {
|
||||
out := map[string]int{}
|
||||
for _, c := range list {
|
||||
out[c.Source+" "+c.Kind]++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// concerns says whether a condition is about a machine: it names it, or its key does.
|
||||
func concerns(c conditions.Condition, machine string) bool {
|
||||
if c.Subject.Machine == machine || slices.Contains(c.Subject.Also, machine) {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
)
|
||||
|
||||
// stalledConditions are n open conditions like those of 2026-10-08: one per delivery held past its bound,
|
||||
// each keeping all its evidence.
|
||||
func stalledConditions(n int) []conditions.Condition {
|
||||
at := time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)
|
||||
var out []conditions.Condition
|
||||
for i := 0; i < n; i++ {
|
||||
id := fmt.Sprintf("d-%012x", 0x51a11ed+i)
|
||||
lines := stalledObservations([]stalledLine{{ID: id, State: "checking", For: "13h2m", Bound: "45m",
|
||||
H2: "close: recheck", Says: "the check was asked and nothing has answered"}})
|
||||
o := lines[0]
|
||||
c := conditions.Condition{Key: o.Key(), Kind: o.Kind, Subject: conditions.Subject{Scope: o.Scope, ID: o.ID},
|
||||
Severity: o.Severity, Summary: o.Summary, Source: probeDeliveriesID, Raised: at, LastObserved: at,
|
||||
Observations: 160, Count: 1, Resolver: conditions.ResolverSelf}
|
||||
for e := 0; e < conditions.KeptEvidence; e++ {
|
||||
c.Evidence = append(c.Evidence, conditions.Evidence{At: at.Add(time.Duration(e) * 5 * time.Minute),
|
||||
Said: "checking for " + (13*time.Hour + time.Duration(e)*time.Minute).String()})
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// novox/hq issue 314: a list of hundreds of open conditions carries each one's newest evidence, with
|
||||
// how much more it keeps and where to read it, and the verb's answer says it once — as data, not again
|
||||
// as the text the command printed. On 2026-10-08, 689 stalled deliveries made that answer more than the
|
||||
// bus carries in one message; now it is less than half of one.
|
||||
func TestAListOfConditionsCarriesEachInBrief(t *testing.T) {
|
||||
list := stalledConditions(689)
|
||||
printed, _ := json.MarshalIndent(map[string]any{"conditions": inBrief(list), "open": len(list),
|
||||
"counted": counted(list)}, "", " ")
|
||||
var parsed any
|
||||
if err := json.Unmarshal(printed, &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wire, _ := json.Marshal(map[string]any{"result": verbAnswer{OK: true, Answer: parsed,
|
||||
Output: "its answer, as data, is `answer`\n"}})
|
||||
before, _ := json.MarshalIndent(map[string]any{"conditions": list}, "", " ")
|
||||
t.Logf("689 stalled deliveries: %d bytes on the wire, where the whole list printed was %d and was sent twice",
|
||||
len(wire), len(before))
|
||||
if len(wire) >= 512<<10 {
|
||||
t.Fatalf("the answer of 689 conditions is %d bytes, not under half of one message of the bus", len(wire))
|
||||
}
|
||||
var doc struct {
|
||||
Conditions []conditionInBrief `json:"conditions"`
|
||||
Counted map[string]int `json:"counted"`
|
||||
}
|
||||
if err := json.Unmarshal(printed, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := doc.Conditions[0]
|
||||
if len(doc.Conditions) != 689 || len(c.Evidence) != 1 || c.EvidenceKept != conditions.KeptEvidence ||
|
||||
c.Key != list[0].Key || c.Summary != list[0].Summary || !strings.Contains(c.More, "key="+c.Key) {
|
||||
t.Fatalf("a condition in brief reads %+v", c)
|
||||
}
|
||||
if doc.Counted["D14 stalled"] != 689 {
|
||||
t.Fatalf("counted %v", doc.Counted)
|
||||
}
|
||||
if one := inBrief(list[:1])[0]; len(list[0].Evidence) != conditions.KeptEvidence || one.Evidence[0] != list[0].Evidence[0] {
|
||||
t.Fatal("the brief changed the condition it was made from, or kept other than the newest")
|
||||
}
|
||||
}
|
||||
|
||||
// The self-check's verdict carries at most overviewFindings of one probe's findings, with their count and
|
||||
// how to read them all; `doctor probe=` gives that probe whole (novox/hq issue 314).
|
||||
func TestAVerdictCarriesAProbesFindingsInBrief(t *testing.T) {
|
||||
var found []string
|
||||
for i := 0; i < 689; i++ {
|
||||
found = append(found, fmt.Sprintf("delivery d-%012x has been checking for 13h, past its bound of 45m", i))
|
||||
}
|
||||
run := doctorRun{Run: "run-1", Probes: []probeVerdict{{ID: "D14", Verdict: "failed", Found: found},
|
||||
{ID: "D1", Verdict: "passed"}}}
|
||||
answer, err := verdictAnswer(run, time.Now(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
brief := answer["run"].(doctorRun).Probes[0]
|
||||
if len(brief.Found) != overviewFindings || brief.FoundCount != 689 || !strings.Contains(brief.More, "probe=D14") {
|
||||
t.Fatalf("the verdict carries %d findings of D14, count %d, more %q", len(brief.Found), brief.FoundCount, brief.More)
|
||||
}
|
||||
if len(run.Probes[0].Found) != 689 {
|
||||
t.Fatal("the overview cut the run it was made from")
|
||||
}
|
||||
whole, err := verdictAnswer(run, time.Now(), "d14")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p := whole["run"].(doctorRun).Probes; len(p) != 1 || len(p[0].Found) != 689 || p[0].More != "" {
|
||||
t.Fatalf("doctor probe=D14 answered %+v", p)
|
||||
}
|
||||
if _, err := verdictAnswer(run, time.Now(), "D99"); err == nil {
|
||||
t.Fatal("a probe the run does not have was answered")
|
||||
}
|
||||
if !strings.Contains(doctorText(answer), "probe=D14` gives them all") {
|
||||
t.Fatalf("the text does not say how to read them all:\n%s", doctorText(answer))
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,50 @@ type probeVerdict struct {
|
||||
Unconfirmed []string `json:"unconfirmed,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Took string `json:"took,omitempty"`
|
||||
// FoundCount and More are an overview's: how many findings the probe had when the overview shows
|
||||
// fewer, and how to read them all (novox/hq issue 314). Never in a heartbeat.
|
||||
FoundCount int `json:"found-count,omitempty"`
|
||||
More string `json:"more,omitempty"`
|
||||
}
|
||||
|
||||
// overviewFindings is how many findings of one probe an overview carries. On 2026-10-08 D14 found 689
|
||||
// stalled deliveries, and an overview carrying every one of them, beside the conditions they raised,
|
||||
// was more than the bus carries in one message (novox/hq issue 314); one probe's verdict whole is
|
||||
// `doctor probe=<id>`.
|
||||
const overviewFindings = 20
|
||||
|
||||
// inOverview is a run as an overview carries it: each probe's findings at most overviewFindings, with
|
||||
// their count and how to read them all. The run itself is not changed.
|
||||
func inOverview(run doctorRun) doctorRun {
|
||||
probes := make([]probeVerdict, len(run.Probes))
|
||||
for i, p := range run.Probes {
|
||||
total := len(p.Found) + len(p.Unconfirmed)
|
||||
if total > overviewFindings {
|
||||
p.FoundCount = len(p.Found)
|
||||
if len(p.Found) > overviewFindings {
|
||||
p.Found = p.Found[:overviewFindings:overviewFindings]
|
||||
}
|
||||
if room := overviewFindings - len(p.Found); len(p.Unconfirmed) > room {
|
||||
p.Unconfirmed = p.Unconfirmed[:room:room]
|
||||
}
|
||||
p.More = fmt.Sprintf("%d found and %d unconfirmed, %d of them shown — `doctor probe=%s` gives them all",
|
||||
p.FoundCount, total-p.FoundCount, len(p.Found)+len(p.Unconfirmed), p.ID)
|
||||
}
|
||||
probes[i] = p
|
||||
}
|
||||
run.Probes = probes
|
||||
return run
|
||||
}
|
||||
|
||||
// oneProbe is a run with only one probe's verdict, whole.
|
||||
func oneProbe(run doctorRun, id string) (doctorRun, error) {
|
||||
for _, p := range run.Probes {
|
||||
if strings.EqualFold(p.ID, id) {
|
||||
run.Probes = []probeVerdict{p}
|
||||
return run, nil
|
||||
}
|
||||
}
|
||||
return doctorRun{}, fmt.Errorf("the run %s has no probe %q — `doctor probes=true` lists them", run.Run, id)
|
||||
}
|
||||
|
||||
// doctorCounts are a run's verdicts, counted.
|
||||
@@ -386,12 +430,13 @@ func doctorCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
set := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
||||
asJSON := set.Bool("json", false, "as data")
|
||||
probe := set.String("probe", "", "one probe's verdict, with every finding")
|
||||
if rest, err := parseAround(set, args); err != nil {
|
||||
return err
|
||||
} else if len(rest) > 0 {
|
||||
return errors.New("doctor [run|probes|signals] [--json]")
|
||||
return errors.New("doctor [run|probes|signals] [--probe <id>] [--json]")
|
||||
}
|
||||
answer, err := doctorAnswer(ctx, sub)
|
||||
answer, err := doctorAnswer(ctx, sub, *probe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -403,12 +448,18 @@ func doctorCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
|
||||
// doctorAnswer is what the verb answers, as data.
|
||||
func doctorAnswer(ctx context.Context, sub string) (any, error) {
|
||||
//
|
||||
// A verdict is an overview — each probe's findings at most overviewFindings — unless probe names one,
|
||||
// which is then answered alone and whole (novox/hq issue 314).
|
||||
func doctorAnswer(ctx context.Context, sub, probe string) (any, error) {
|
||||
if probe != "" && sub != "" && sub != "run" {
|
||||
return nil, fmt.Errorf("probe names one probe of a verdict; %s has none", sub)
|
||||
}
|
||||
switch sub {
|
||||
case "":
|
||||
if doctorFrom != nil {
|
||||
if run := doctorFrom.lastRun(); run != nil {
|
||||
return verdictAnswer(*run, time.Now()), nil
|
||||
return verdictAnswer(*run, time.Now(), probe)
|
||||
}
|
||||
return nil, fmt.Errorf("the self-check has not finished its first run yet: it runs %s after the "+
|
||||
"controller starts, then every %s — `doctor run` runs it now", doctorFirstAfter, doctorEvery)
|
||||
@@ -417,7 +468,7 @@ func doctorAnswer(ctx context.Context, sub string) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return verdictAnswer(run, time.Now()), nil
|
||||
return verdictAnswer(run, time.Now(), probe)
|
||||
case "run":
|
||||
d := doctorFrom
|
||||
if d == nil {
|
||||
@@ -428,7 +479,7 @@ func doctorAnswer(ctx context.Context, sub string) (any, error) {
|
||||
defer closeIt()
|
||||
d = local
|
||||
}
|
||||
return verdictAnswer(d.runOnce(ctx, "asked by "+link.Caller()), time.Now()), nil
|
||||
return verdictAnswer(d.runOnce(ctx, "asked by "+link.Caller()), time.Now(), probe)
|
||||
case "probes":
|
||||
return probesAnswer(), nil
|
||||
case "signals":
|
||||
@@ -442,11 +493,20 @@ func doctorAnswer(ctx context.Context, sub string) (any, error) {
|
||||
}
|
||||
|
||||
// verdictAnswer is a run as the verb answers it, with its age.
|
||||
func verdictAnswer(run doctorRun, now time.Time) map[string]any {
|
||||
func verdictAnswer(run doctorRun, now time.Time, probe string) (map[string]any, error) {
|
||||
if probe != "" {
|
||||
one, err := oneProbe(run, probe)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
run = one
|
||||
} else {
|
||||
run = inOverview(run)
|
||||
}
|
||||
return map[string]any{"run": run, "age": now.Sub(run.At).Round(time.Second).String(),
|
||||
"note": "a probe that could not run is never a pass; each failure is an open condition until a run passes it, " +
|
||||
"and one a single look can be wrong about — an unanswered question, a slow answer — is raised when two " +
|
||||
"runs in a row see it"}
|
||||
"runs in a row see it"}, nil
|
||||
}
|
||||
|
||||
// probesAnswer is the registry.
|
||||
@@ -515,6 +575,9 @@ func doctorText(answer any) string {
|
||||
for _, f := range p.Unconfirmed {
|
||||
fmt.Fprintf(&b, " unconfirmed, raised if the next run sees it too: %s\n", f)
|
||||
}
|
||||
if p.More != "" {
|
||||
fmt.Fprintf(&b, " … %s\n", p.More)
|
||||
}
|
||||
if p.Error != "" {
|
||||
fmt.Fprintf(&b, " %s\n", p.Error)
|
||||
}
|
||||
|
||||
@@ -91,8 +91,8 @@ type meshStatus struct {
|
||||
// Conditions is every open condition, urgent first and then oldest first (novox/hq to-be 45 §2):
|
||||
// what is wrong, as the watchdogs, the self-check and the providers say it. Always present — an
|
||||
// empty list is "none open" — unless they could not be read, which ConditionsUnread says.
|
||||
Conditions []conditions.Condition `json:"conditions"`
|
||||
ConditionsUnread string `json:"conditionsUnread,omitempty"`
|
||||
Conditions []conditionInBrief `json:"conditions"`
|
||||
ConditionsUnread string `json:"conditionsUnread,omitempty"`
|
||||
// Failing is every consumer a provider says it keeps failing (novox/hq ADR 0224): the open
|
||||
// conditions of that kind, carried here as well because ADR 0224 names this field. Absent when no
|
||||
// provider says so. A document without it called the mesh well while the identity provider
|
||||
@@ -235,10 +235,9 @@ func statusAsJSON(asked answers) ([]byte, error) {
|
||||
out.Unheld = asked.unheld
|
||||
out.HandActsThisWeek, out.HandActsUnread = asked.handActs, asked.handActsUnread
|
||||
out.HealsThisWeek, out.HealsUnread = asked.heals, asked.healsUnread
|
||||
out.Conditions, out.ConditionsUnread = asked.conditions, asked.conditionsUnread
|
||||
if out.Conditions == nil {
|
||||
out.Conditions = []conditions.Condition{}
|
||||
}
|
||||
// In brief, as `conditions` lists them: status leads with every open condition, and their whole
|
||||
// evidence is each one's own (novox/hq issue 314).
|
||||
out.Conditions, out.ConditionsUnread = inBrief(asked.conditions), asked.conditionsUnread
|
||||
out.Failing = providerStandings(asked.conditions)
|
||||
out.Overflowing = asked.overflowing
|
||||
for name := range asked.refused {
|
||||
|
||||
@@ -673,6 +673,9 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
if which > 1 {
|
||||
return nil, errors.New("doctor answers one of run, probes or signals at a time")
|
||||
}
|
||||
if p := str("probe"); p != "" {
|
||||
argv = append(argv, "--probe", p)
|
||||
}
|
||||
return append(argv, "--json"), nil
|
||||
case "rotate":
|
||||
if p := str("provision"); p != "" {
|
||||
@@ -805,6 +808,11 @@ var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true, "co
|
||||
// The delivery's owner's verbs answer JSON where they read (plan, order, check, walks) — novox/hq ADR 0239.
|
||||
"delivery": true}
|
||||
|
||||
// overviewVerbs are the JSON verbs whose answer is said once, as data, and not again as the text the
|
||||
// command printed: the overviews, which grow with the mesh (novox/hq issue 314). Every reader of theirs
|
||||
// reads `answer` first.
|
||||
var overviewVerbs = map[string]bool{"status": true, "conditions": true, "doctor": true}
|
||||
|
||||
// repairingCommand names a command line that repairs by hand, and so says why: a push, a plan stopped
|
||||
// or closed, a consumer re-made (novox/hq to-be 45 §7). Empty for any other.
|
||||
func repairingCommand(argv []string) string {
|
||||
@@ -870,6 +878,11 @@ func runVerb(ctx context.Context, argv []string) (verbAnswer, error) {
|
||||
var parsed any
|
||||
if json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &parsed) == nil {
|
||||
answer.Answer = parsed
|
||||
if overviewVerbs[argv[0]] {
|
||||
// Once, as data: the same document again as text doubled an answer that already
|
||||
// outgrew one message of the bus (novox/hq issue 314).
|
||||
answer.Output = stderr.String() + "its answer, as data, is `answer`\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
var exit *exec.ExitError
|
||||
@@ -923,10 +936,10 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
||||
return nil, err
|
||||
}
|
||||
sub := ""
|
||||
if len(argv) > 2 {
|
||||
if len(argv) > 2 && !strings.HasPrefix(argv[1], "-") {
|
||||
sub = argv[1]
|
||||
}
|
||||
return doctorAnswer(ctx, sub)
|
||||
return doctorAnswer(ctx, sub, a.given["probe"])
|
||||
}
|
||||
return seatTools(), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user