TLS-5, TLS-6: the tool runner holds the indexer's manifests and modules and the assigner's placement and seat-holders from their first views and views events, never their state, and the console gains mesh_first_views and mesh_first_view_again (ADR 0615, ADR 0621, issue 1966)

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 12:06:20 +01:00
parent 8b1ab467a9
commit 31247dff6b
17 changed files with 577 additions and 64 deletions
+195
View File
@@ -0,0 +1,195 @@
// Package firstview is the tool runner's picture of the four states it reads of the indexer and the assigner: the assigner's
// placement and seat-holders, the indexer's manifests and modules (novox/hq ADR 0615 §1, §3; ADR 0621 §2; issue 1966 TLS-5).
//
// **It never reads the owners' state.** At start it asks each owner's first-view tool until answered (the owner answers in a
// runtime of its own, never through this one: ADR 0621 §1), then follows the owner's views family, one event per changed key;
// a gap in the revisions, or an owner whose state was made again, asks the first view once more, said in the log. Past two
// minutes without an answer it writes a finding in its own state `findings`, which the condition keeper raises and clears.
package firstview
import (
"context"
"encoding/json"
"fmt"
"sort"
"sync"
"time"
"git.novox.be/novox/mesh-delivery/sdk/views"
"github.com/novox/mesh-tools/node-tools/internal/bus"
)
// The owners and their first-view tools (the tools' addresses are `<owner>.<tool>`).
const (
Indexer = "mesh-delivery-indexer"
Assigner = "mesh-delivery-assigner"
)
// Spec is one first view the runner holds.
type Spec struct{ Owner, Tool, State string }
// Specs are the four.
var Specs = []Spec{
{Assigner, "placement", "placement"},
{Assigner, "seat-holders", "seat-holders"},
{Indexer, "manifests", "manifests"},
{Indexer, "modules", "modules"},
}
// Name is the name a state is read by, `<owner>.<state>`: delivery.PlacementRead, delivery.SeatHoldersRead and the indexer's.
func (s Spec) Name() string { return s.Owner + "." + s.State }
// Views is the four followers.
type Views struct {
logf func(string, ...any)
followers map[string]*views.Follower // by Spec.Name()
byState map[string]*views.Follower // by the state an event names
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
deps Deps
}
// FindingsState is the runner's own state the findings are written in.
const FindingsState = "findings"
// Deps is what the views reach the bus through: Start makes them from the runner's connection, a test from fakes.
type Deps struct {
// Module is the runner's own name, the observer of its findings.
Module string
// Ask calls an owner's tool, `<owner>.<tool>`.
Ask func(address string, body any) (json.RawMessage, error)
// Consume hands each event of the owners' views families to deliver, until the stop is called.
Consume func(deliver func(bus.Envelope) error) (stop func(), err error)
// PutFinding and DeleteFinding keep and remove a finding in the runner's own state.
PutFinding func(key string, value json.RawMessage) error
DeleteFinding func(key string) error
// Bound and Wait are the follower's (zero: two minutes, five seconds).
Bound, Wait time.Duration
}
// Start asks the four first views, each until answered, and follows the owners' views families on the runner's own
// consumers. It returns at once: the picture fills as the owners answer.
func Start(parent context.Context, conn *bus.Conn, logf func(string, ...any)) (*Views, func()) {
me := conn.Module()
return StartWith(parent, Deps{Module: me,
Ask: func(address string, body any) (json.RawMessage, error) {
got, err := conn.Ask(address, body, "")
return got.Result, err
},
Consume: func(deliver func(bus.Envelope) error) (func(), error) { return conn.ConsumeAs(me, deliver) },
PutFinding: func(key string, value json.RawMessage) error {
_, err := conn.StatePut(me, FindingsState, key, value)
return err
},
DeleteFinding: func(key string) error { return conn.StateDelete(me, FindingsState, key) },
}, logf)
}
// StartWith is Start over deps.
func StartWith(parent context.Context, d Deps, logf func(string, ...any)) (*Views, func()) {
ctx, cancel := context.WithCancel(parent)
v := &Views{logf: logf, ctx: ctx, cancel: cancel, followers: map[string]*views.Follower{}, byState: map[string]*views.Follower{}, deps: d}
for _, s := range Specs {
s := s
call := func(tool string, body any) (json.RawMessage, error) { return d.Ask(s.Owner+"."+tool, body) }
f := &views.Follower{Owner: s.Owner, Tool: s.Tool, State: s.State, Ask: views.AskVia(call, s.Tool), Logf: logf,
Bound: d.Bound, Wait: d.Wait,
Raise: func(st views.Status) { v.finding(st, true) },
Clear: func(st views.Status) { v.finding(st, false) }}
v.followers[s.Name()] = f
v.byState[s.State] = f
v.wg.Add(1)
go func() { defer v.wg.Done(); _ = f.Start(ctx) }()
}
stop, err := d.Consume(v.hear)
if err != nil {
logf("[mesh-tools] the owners' views are not followed (%v): the pictures are the first views alone until the runner starts again", err)
stop = func() {}
}
return v, func() { stop(); cancel(); v.wg.Wait() }
}
// hear applies one views event; a gap asks the first view once more.
func (v *Views) hear(env bus.Envelope) error {
ev, err := views.DecodeEvent(env.Body)
if err != nil {
return nil // not a views event: nothing to hold, nothing to retry
}
f := v.byState[ev.State]
if f == nil || f.Owner != ev.Owner {
return nil
}
if gap, why := f.Apply(ev); gap {
v.logf("[mesh-tools] a gap in %s's %s (%s): its first view %s is asked once more", f.Owner, f.State, why, f.Tool)
go func() { _ = f.Again(v.ctx, "a gap: "+why) }()
}
return nil
}
// finding writes or removes the runner's finding of a first view unanswered past its bound, in its own state.
func (v *Views) finding(st views.Status, raised bool) {
key, value, err := views.UnansweredFinding(v.deps.Module, st, time.Now())
if err != nil {
v.logf("[mesh-tools] the finding of the first view %s is not made: %v", st.Tool, err)
return
}
if raised {
err = v.deps.PutFinding(key, value)
} else {
err = v.deps.DeleteFinding(key)
}
if err != nil {
v.logf("[mesh-tools] the finding of the first view %s is not kept: %v", st.Tool, err)
}
}
// State is the picture of the state spec names (`<owner>.<state>`), every current value by key; an error says the owner has
// not answered yet, so nothing is claimed of it.
func (v *Views) State(name string) (map[string][]byte, error) {
f := v.followers[name]
if f == nil {
return nil, fmt.Errorf("the tool runner holds no first view of %s", name)
}
select {
case <-f.Ready():
default:
return nil, fmt.Errorf("%s's first view %s has not been answered yet", f.Owner, f.Tool)
}
out := map[string][]byte{}
for k, raw := range f.Values() {
out[k] = raw
}
return out, nil
}
// Entry is one value of the picture, nil when the owner holds none.
func (v *Views) Entry(name, key string) ([]byte, error) {
all, err := v.State(name)
if err != nil {
return nil, err
}
return all[key], nil
}
// Status is each first view as it stands, by `<owner>.<state>`, sorted.
func (v *Views) Status() []views.Status {
var out []views.Status
for _, f := range v.followers {
out = append(out, f.Status())
}
sort.Slice(out, func(i, j int) bool { return out[i].Owner+out[i].State < out[j].Owner+out[j].State })
return out
}
// Again asks every first view once more, by hand, for why.
func (v *Views) Again(ctx context.Context, why string) []error {
var errs []error
for _, f := range v.followers {
if err := f.Again(ctx, why); err != nil {
errs = append(errs, err)
}
}
return errs
}
@@ -0,0 +1,168 @@
package firstview
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-delivery/sdk/views"
"github.com/novox/mesh-tools/node-tools/internal/bus"
)
// owners is the four owners' first-view tools as a test answers them: a state's values at a revision, or nothing while down.
type owners struct {
mu sync.Mutex
down bool
state map[string]map[string]string // tool → key → value
rev map[string]uint64
asked map[string]int
deliver func(bus.Envelope) error
finds map[string]bool
}
func newOwners() *owners {
return &owners{state: map[string]map[string]string{"placement": {"anchor": `{"p":1}`}, "seat-holders": {"seat": `{"h":1}`},
"manifests": {"d1": `{"m":1}`}, "modules": {"web": `{"w":1}`}},
rev: map[string]uint64{"placement": 4, "seat-holders": 2, "manifests": 7, "modules": 3}, asked: map[string]int{}, finds: map[string]bool{}}
}
func (o *owners) ask(address string, _ any) (json.RawMessage, error) {
o.mu.Lock()
defer o.mu.Unlock()
tool := address[strings.LastIndex(address, ".")+1:]
o.asked[tool]++
if o.down {
return nil, errors.New("503 no responders")
}
vals := map[string]json.RawMessage{}
for k, v := range o.state[tool] {
vals[k] = json.RawMessage(v)
}
return json.Marshal(views.Answer{V: 1, Owner: "o", State: tool, Revision: o.rev[tool], Created: time.Unix(1000, 0).UTC(), Values: vals})
}
func (o *owners) say(t *testing.T, state, key, value string, revision uint64, gap bool) {
t.Helper()
ev := views.Event{V: 1, Owner: owner(state), State: state, Key: key, Revision: revision, Created: time.Unix(1000, 0).UTC(), Gap: gap}
if value != "" {
ev.Value = json.RawMessage(value)
}
raw, _ := json.Marshal(ev)
if err := o.deliver(bus.Envelope{Key: ev.Owner + ".views." + state, Body: raw}); err != nil {
t.Fatal(err)
}
}
func owner(state string) string {
for _, s := range Specs {
if s.State == state {
return s.Owner
}
}
return ""
}
func eventually(t *testing.T, what string, ok func() bool) {
t.Helper()
for end := time.Now().Add(3 * time.Second); !ok(); time.Sleep(5 * time.Millisecond) {
if time.Now().After(end) {
t.Fatal(what)
}
}
}
// issue 1966 TLS-5 (ADR 0615 §2, §4, §5, §6): the runner holds the four states from the owners' first views and their
// events alone; nothing is claimed of a state not yet answered; a gap asks the first view once more; an owner that stays down
// past the bound is a finding, cleared by the answer; the status and the hand ask show them.
func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *testing.T) {
o := newOwners()
o.down = true
var logs []string
var lmu sync.Mutex
deps := Deps{Module: "node-tools", Ask: o.ask, Bound: 30 * time.Millisecond, Wait: 10 * time.Millisecond,
Consume: func(deliver func(bus.Envelope) error) (func(), error) { o.deliver = deliver; return func() {}, nil },
PutFinding: func(key string, _ json.RawMessage) error {
o.mu.Lock()
o.finds[key] = true
o.mu.Unlock()
return nil
},
DeleteFinding: func(key string) error {
o.mu.Lock()
delete(o.finds, key)
o.mu.Unlock()
return nil
}}
v, stop := StartWith(context.Background(), deps, func(f string, a ...any) { lmu.Lock(); logs = append(logs, f); lmu.Unlock() })
defer stop()
// Down: nothing is claimed, and past the bound each unanswered view is a finding.
if _, err := v.State("mesh-delivery-assigner.placement"); err == nil || !strings.Contains(err.Error(), "not been answered") {
t.Fatalf("a state its owner has not answered is claimed: %v", err)
}
eventually(t, "no finding past the bound", func() bool { o.mu.Lock(); defer o.mu.Unlock(); return len(o.finds) == 4 })
// Up: answered, the findings are cleared, and the pictures hold the owners' values.
o.mu.Lock()
o.down = false
o.mu.Unlock()
eventually(t, "not answered", func() bool { _, err := v.State("mesh-delivery-indexer.manifests"); return err == nil })
eventually(t, "findings not cleared", func() bool { o.mu.Lock(); defer o.mu.Unlock(); return len(o.finds) == 0 })
if got, _ := v.Entry("mesh-delivery-indexer.modules", "web"); string(got) != `{"w":1}` {
t.Errorf("the modules picture holds %q", got)
}
if got, _ := v.Entry("mesh-delivery-indexer.modules", "none"); got != nil {
t.Errorf("an absent key is %q", got)
}
// Events: the next revision is applied; an old one passed over; a deletion removes; none of it asks again.
asked := func(tool string) int { o.mu.Lock(); defer o.mu.Unlock(); return o.asked[tool] }
before := asked("placement")
o.say(t, "placement", "laptop", `{"p":2}`, 5, false)
o.say(t, "placement", "laptop", `{"p":0}`, 5, false)
o.say(t, "placement", "anchor", "", 6, false)
// (a deleted mark carries no value: Deleted is the encoding's, set below)
if got, _ := v.Entry("mesh-delivery-assigner.placement", "laptop"); string(got) != `{"p":2}` {
t.Errorf("the next revision is not applied: %q", got)
}
if n := asked("placement"); n != before {
t.Errorf("an event in order asked the first view again (%d asks, was %d)", n, before)
}
// A gap (revision 9 where 7 was next) asks the first view once more, said in the log.
o.say(t, "placement", "x", `{"p":9}`, 9, false)
eventually(t, "a gap did not ask the first view again", func() bool { return asked("placement") > before })
// The status says owner, tool, revision and the asks; the hand ask asks every view again.
var placement views.Status
for _, s := range v.Status() {
if s.State == "placement" {
placement = s
}
}
if placement.Owner != "mesh-delivery-assigner" || placement.Tool != "placement" || placement.Asks < 2 || placement.Answered.IsZero() {
t.Errorf("the status of placement is %+v", placement)
}
manifests := asked("manifests")
if errs := v.Again(context.Background(), "doubted"); len(errs) != 0 {
t.Fatalf("the hand ask failed: %v", errs)
}
if asked("manifests") != manifests+1 {
t.Errorf("the hand ask did not ask the manifests again")
}
for _, st := range v.Status() {
if st.State == "manifests" && st.ByHand != 1 {
t.Errorf("the hand ask is not counted: %+v", st)
}
}
lmu.Lock()
defer lmu.Unlock()
if !strings.Contains(strings.Join(logs, "\n"), "a gap in") {
t.Errorf("the gap was not said in the log: %v", logs)
}
}