Files
mesh-contracts/go/clients/nodeengineclient/enginereads/enginereads_test.go
T
jschoubben c9ffed7042
mesh/delivery merged: source.main moved to novox/mesh-contracts@f5d76e84ca71
mesh/delivery-group group fix/1966-engine-reads-asked-again merged: every member is merged
mesh/check checking c9ffed70421a on f5d76e84ca71 | novox/mesh-contracts#4 in group fix/1966-engine-reads-asked-again
S1-S3: the node-engine seat's client asks a node once more when an ask came while one ran, and shows when the event was heard (issue 1966 note 170)
An event of manifest-reads-changed heard while an ask of the same node ran was dropped
without a line, so a restated engine could stay read at its first statement (ace,
2026-11-10 07:36). Every reader goes through Keeper.AskNode, so the assigner, the
builder and the indexer are fixed at once (ADR 0668 §4: the caller hears it and calls
again; ADR 0634 §1: the seat's client).

- AskNode: an ask while one runs is asked once more after it, one follow-up however
  many came, said in one line (1966/AC170-1, AC170-3). The asks of one node so run one
  after another, and take keeps the last answer, as before (no rule on the engine's
  clock: review round 1).
- Statement.EventAt (event-at): when the node's event was last heard, read by the
  parts' engine-reads tools; forgotten with the node's key.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-10 11:42:05 +01:00

213 lines
10 KiB
Go

package enginereads
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
)
// The answer and the event are judged as the contract lays them out and its reader checks: the layout's version, a node
// that is a name, no empty capability, a time, and an event's why one of the two.
func TestTheAnswerAndTheEventAreJudged(t *testing.T) {
const at = "2026-11-05T12:00:00Z"
for name, c := range map[string]struct{ body, bad string }{
"whole": {`{"v":1,"node":"ace","manifest-reads":["mesh-config.resolved"],"stated-at":"` + at + `"}`, ""},
"old layout": {`{"v":2,"node":"ace","stated-at":"` + at + `"}`, "version"},
"bad node": {`{"v":1,"node":"a.b","stated-at":"` + at + `"}`, "no node"},
"empty read": {`{"v":1,"node":"ace","manifest-reads":[" "],"stated-at":"` + at + `"}`, "empty reader"},
"no time": {`{"v":1,"node":"ace","stated-at":"yesterday"}`, "RFC 3339"},
} {
_, _, err := DecodeAnswer([]byte(c.body))
if (c.bad == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), c.bad)) {
t.Errorf("answer %s: %v, want %q", name, err, c.bad)
}
}
for name, c := range map[string]struct{ body, bad string }{
"whole": {`{"v":1,"node":"ace","why":"started","manifest-reads":["a.b"],"at":"` + at + `"}`, ""},
"returned": {`{"v":1,"node":"ace","why":"returned","at":"` + at + `"}`, ""},
"old layout": {`{"v":2,"node":"ace","why":"started","at":"` + at + `"}`, "version"},
"no node": {`{"v":1,"node":"","why":"started","at":"` + at + `"}`, "no node"},
"another why": {`{"v":1,"node":"ace","why":"bored","at":"` + at + `"}`, "bored"},
"no time": {`{"v":1,"node":"ace","why":"started","at":"yesterday"}`, "RFC 3339"},
} {
_, err := DecodeChanged([]byte(c.body))
if (c.bad == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), c.bad)) {
t.Errorf("event %s: %v, want %q", name, err, c.bad)
}
}
// Issue 1966 note 155, ADR 0668 §4: a part whose first call of a node timed out, and whose consumer was bound after
// the node's event was said, calls every node once at the bind, and the node is then held as it answered.
calls := map[string]int{}
k := &Keeper{Sync: true, Call: func(node, _ string, _ any) (json.RawMessage, error) {
calls[node]++
if node == "ace" && calls[node] == 1 {
return nil, context.DeadlineExceeded
}
return json.RawMessage(`{"v":1,"node":"` + node + `","manifest-reads":["a.b"],"stated-at":"` + at + `"}`), nil
}}
k.AskNode("ace", "the first view")
if k.Statements()["ace"].Answered {
t.Errorf("a call that timed out reads as answered")
}
k.AtBind(func() []string { return []string{"ace", "g14"} })(time.Unix(0, 0))
if p := k.Present([]string{"ace", "g14"}); len(p["ace"]) != 1 || len(p["g14"]) != 1 || calls["ace"] != 2 || calls["g14"] != 1 {
t.Errorf("after the bind: present %v, calls %v", p, calls)
}
// Review round 1 (ADR 0668 §3): a node whose engine answers nothing reads as its record at the first fault of the call
// made once, while the kept call asks on; what the kept call answers then stands.
var during Statement
f := &Keeper{Sync: true, Fallback: func(string) ([]string, bool) { return []string{"r.s"}, true },
Once: func(string, string, any) (json.RawMessage, error) {
return nil, errors.New("nats: no responders available for request")
}}
f.Call = func(node, _ string, _ any) (json.RawMessage, error) {
during = f.Statements()[node]
return json.RawMessage(`{"v":1,"node":"` + node + `","manifest-reads":["a.b"],"stated-at":"` + at + `"}`), nil
}
f.AskNode("g14", "the first view")
if during.From != FromRecord || during.Answered || f.Statements()["g14"].From != FromVerb {
t.Errorf("while the kept call asked g14 read %+v; after it %+v", during, f.Statements()["g14"])
}
// ADR 0668 §3: a node unanswered keeps when it first was not, across asks; past the bound it is unanswered while its
// machine is heard, none while away, and none once it answers.
clock := time.Date(2026, 11, 10, 3, 0, 0, 0, time.UTC)
answers := false
u := &Keeper{Sync: true, Now: func() time.Time { return clock }, Call: func(node, _ string, _ any) (json.RawMessage, error) {
if answers {
return json.RawMessage(`{"v":1,"node":"` + node + `","manifest-reads":["a.b"],"stated-at":"` + at + `"}`), nil
}
return nil, context.DeadlineExceeded
}}
first := clock
u.AskNode("ace", "the first view")
clock = clock.Add(6 * time.Minute)
u.AskNode("ace", "asked on")
heard := func(string) bool { return true }
if got := u.Unanswered([]string{"ace"}, heard, 5*time.Minute, clock); !got["ace"].Equal(first) ||
len(u.Unanswered([]string{"ace"}, func(string) bool { return false }, 5*time.Minute, clock)) != 0 {
t.Errorf("unanswered past the bound: %v", got)
}
answers = true
u.AskNode("ace", "answered")
if got := u.Unanswered([]string{"ace"}, heard, 5*time.Minute, clock); len(got) != 0 || !u.Statements()["ace"].UnansweredSince.IsZero() {
t.Errorf("an answered node is unanswered: %v", got)
}
// Issue 1966 note 170, 1966/AC170-1 and AC170-3: an event of a node heard while an ask of it runs is not dropped: the
// node is asked once more after that ask ends (one follow-up however many came), said in one line, and the newer
// statement is kept; the event's time is shown beside the statement (event-at).
const stated26, restated = "2026-11-10T07:36:26Z", "2026-11-10T07:36:30Z"
var lines []string
eclock := time.Date(2026, 11, 10, 7, 36, 26, 0, time.UTC)
asks := 0
var e *Keeper
e = &Keeper{Sync: true, Now: func() time.Time { return eclock },
Logf: func(f string, a ...any) { lines = append(lines, fmt.Sprintf(f, a...)) },
Put: func(_ string, raw []byte) error { _, err := Decode(raw); return err },
Call: func(node, _ string, _ any) (json.RawMessage, error) {
asks++
stated := restated
if asks == 1 {
eclock = eclock.Add(4 * time.Second)
e.AskNode(node, Event)
e.AskNode(node, Event)
stated = stated26
} else if got := e.Statements()[node].StatedAt.Format(time.RFC3339); got != stated26 {
t.Errorf("the second ask began before the first was taken in: held %s", got)
}
return json.RawMessage(`{"v":1,"node":"` + node + `","manifest-reads":["a.b"],"stated-at":"` + stated + `"}`), nil
}}
e.AskNode("ace", Event)
held := e.Statements()["ace"]
if asks != 2 || held.StatedAt.Format(time.RFC3339) != restated || e.Asking("ace") {
t.Errorf("an event during an ask: asked %d times, held stated at %s, asking %v", asks, held.StatedAt, e.Asking("ace"))
}
if again := strings.Count(strings.Join(lines, "\n"), "asked once more"); again != 1 {
t.Errorf("the ask kept for later is said in %d lines, not one: %q", again, lines)
}
if !held.EventAt.Equal(eclock) || !strings.Contains(string(must(Encode(held))), `"event-at":"2026-11-10T07:36:30Z"`) {
t.Errorf("the event's time is not shown: %+v", held)
}
if Verb != "manifest-reads" || Event != "manifest-reads-changed" || Consumes != "node-engine.manifest-reads-changed" || Invoke != "seat:node-engine.manifest-reads" {
t.Errorf("the names are %s %s %s %s", Verb, Event, Consumes, Invoke)
}
}
// ADR 0668 §3, ADR 0596 §2 (review round 3): the wait of a part's kept manifest-reads calls. With Arm, a node unanswered
// has its bound kept, and only its expiry asks whether its machine is heard, once: heard, the finding is written at the
// part's clock and the node asked again; away, none; answered, it is cleared. With no Arm, heard is weighed at each step.
func TestReadsCallsSayAnUnansweredNodeHeard(t *testing.T) {
clock := time.Date(2026, 11, 10, 3, 0, 0, 0, time.UTC)
answers, calls := false, 0
k := &Keeper{Sync: true, Now: func() time.Time { return clock }, Call: func(node, _ string, _ any) (json.RawMessage, error) {
calls++
if answers {
return json.RawMessage(`{"v":1,"node":"` + node + `","manifest-reads":["a.b"],"stated-at":"2026-11-10T03:00:00Z"}`), nil
}
return nil, context.DeadlineExceeded
}}
for name, armed := range map[string]bool{"armed": true, "by its own knowledge": false} {
answers, calls = false, 0
k.Forget("ace")
first := clock
k.AskNode("ace", "the first view")
heard, asked := true, 0
raised, cleared := map[string]UnansweredCall{}, map[string]int{}
bounds := map[string]time.Time{}
c := &ReadsCalls{Keeper: k, Nodes: func() []string { return []string{"ace", "g14"} }, Bound: 5 * time.Minute,
Now: func() time.Time { return clock.Add(time.Second) },
Heard: func(context.Context, string) (bool, error) { asked++; return heard, nil },
Raise: func(_ context.Context, u UnansweredCall) error { raised[u.Node] = u; return nil },
Clear: func(_ context.Context, node string) error { cleared[node]++; delete(raised, node); return nil }}
if armed {
c.Arm = func(_ context.Context, node string, ends time.Time) error { bounds[node] = ends; return nil }
}
c.Step(context.Background())
if armed && (asked != 0 || !bounds["ace"].Equal(first.Add(5*time.Minute)) || len(raised) != 0) {
t.Errorf("%s: a step before the bound asked heard %d times, bounds %v, raised %v", name, asked, bounds, raised)
}
clock = clock.Add(6 * time.Minute)
heard, asked = false, 0
if armed {
c.Expired(context.Background(), "ace")
} else {
c.Step(context.Background())
}
if len(raised) != 0 || asked != 1 {
t.Errorf("%s: away, raised %v, heard asked %d times", name, raised, asked)
}
heard, asked, calls = true, 0, 0
if armed {
c.Expired(context.Background(), "ace")
} else {
c.Step(context.Background())
}
if u, ok := raised["ace"]; !ok || !u.Since.Equal(first) || !u.At.Equal(clock.Add(time.Second)) || asked != 1 || calls != 1 {
t.Errorf("%s: heard past the bound, raised %+v, heard asked %d, called %d", name, raised, asked, calls)
}
answers = true
k.AskNode("ace", "answered")
c.Step(context.Background())
if _, ok := raised["ace"]; ok || cleared["ace"] == 0 {
t.Errorf("%s: answered, raised %v, cleared %v", name, raised, cleared)
}
clock = clock.Add(time.Hour)
}
if CallID("ace") != "manifest-reads.ace" {
t.Errorf("the call's id is %s", CallID("ace"))
}
if n, ok := BoundOf(BoundPrefix + "ace"); !ok || n != "ace" {
t.Errorf("the bound's node is %q %v", n, ok)
}
}
func must(b []byte, err error) []byte {
if err != nil {
panic(err)
}
return b
}