Merge pull request 'The node-engine states the seats of the list it holds, and again on every change (issue 1966, recovery)' (#211) from fix/1966-seat-reads-bootstrap into main

This commit was merged in pull request #211.
This commit is contained in:
2026-11-08 03:45:15 +00:00
4 changed files with 149 additions and 11 deletions
+7 -1
View File
@@ -135,7 +135,8 @@ func (w findingsOfTheNode) Delete(f views.Finding) error {
}
// startSeatList starts this run's reader: its bootstrapped seats are given to catalogue at once, and every change of what
// it holds again, saying so to the composer and to the waiter that writes the five-minute finding.
// it holds again, saying so to the composer and to the waiter that writes the five-minute finding, and stating again what
// the engine reads (link.Session.RestateReads, ADR 0618 §4): the seats it states are those of the list it holds.
func startSeatList(ctx context.Context, session *link.Session, node string, say func(string)) (*seatListState, error) {
st := &seatListState{changed: make(chan struct{}, 1)}
// Start returns at once and reads the seat on a goroutine, which may announce a change before the waiter exists.
@@ -143,6 +144,9 @@ func startSeatList(ctx context.Context, session *link.Session, node string, say
reader, err := seatlist.Start(seatlist.Config{Bus: sessionBus{ctx, session}, Bootstrap: seatlist.Bootstrapped(),
OnChange: func(seats []catalogue.Seat) {
catalogue.UseSeats(seats)
// What the engine reads changed with the list: its record and its event say it (ADR 0618 §4), off the
// reader's goroutine, which returns quickly.
go session.RestateReads(ctx)
if f := announce.Load(); f != nil { // the first read may end before the waiter exists
(*f)()
}
@@ -162,6 +166,7 @@ func startSeatList(ctx context.Context, session *link.Session, node string, say
}
announce.Store(&told)
catalogue.UseSeats(reader.Seats())
session.RestateReads(ctx) // its first statement holds the bootstrapped seats at least (issue 1966)
told()
// The first read ends whether the seat answered or not, and a read that held what the reader held already says no
// change: its end is told to the waiter and the composer, so a status that moved from reading to heard starts or ends
@@ -170,6 +175,7 @@ func startSeatList(ctx context.Context, session *link.Session, node string, say
select {
case <-reader.Ready():
catalogue.UseSeats(reader.Seats())
session.RestateReads(ctx)
told()
case <-ctx.Done():
}
+42
View File
@@ -38,6 +38,17 @@ type haveLink struct {
// stated is what the tool runner's PING says of itself (Stater); statedErr is the runner not answering.
stated map[string]string
statedErr error
// readsSaid are the node seat's manifest-reads-changed events said on the link (ADR 0618 §4).
readsSaid []nodeengine.ManifestReadsChangedEvent
}
func (l *haveLink) SayEngine(event string, body any) error {
if e, ok := body.(nodeengine.ManifestReadsChangedEvent); ok && event == nodeengine.EventManifestReadsChanged {
l.hmu.Lock()
l.readsSaid = append(l.readsSaid, e)
l.hmu.Unlock()
}
return nil
}
func (l *haveLink) Stated(context.Context, string, string) (map[string]string, error) {
@@ -330,6 +341,37 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) {
if got, _ = nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value); got.Record.Contract != NodeKeyContract {
t.Errorf("a report without a health statement dropped the contract: %+v", got.Record)
}
// Issue 1966 (ADR 0618 §4): the seats the engine states are those of the seat list it holds now. A change of the list
// writes the record once and says manifest-reads-changed with the new statement; a restatement that changes nothing
// writes and says nothing; the verb answers the statement and when it was made.
all := catalogue.Seats()
defer catalogue.UseSeats(all)
q.RestateReads(context.Background()) // the statement as the list stands, whatever an earlier test left
_, before := q.StatedReads()
at, saidBefore := l.kv["n1.node"].rev, len(l.readsSaid)
catalogue.UseSeats(slices.DeleteFunc(slices.Clone(all), func(s catalogue.Seat) bool { return s.Name == "mesh-vault" }))
q.RestateReads(context.Background())
got, _ = nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value)
if l.kv["n1.node"].rev != at+1 || slices.Contains(got.Record.ManifestReads, "seat:mesh-vault") ||
!slices.Contains(got.Record.ManifestReads, "seat:mesh-catalog") || !slices.Contains(got.Record.ManifestReads, "word:claims") {
t.Errorf("a seat list without mesh-vault: revision %d (was %d), the record states %v", l.kv["n1.node"].rev, at, got.Record.ManifestReads)
}
if said := l.readsSaid[saidBefore:]; len(said) != 1 || said[0].Why != ReadsStarted || !slices.Equal(said[0].ManifestReads, got.Record.ManifestReads) {
t.Errorf("a changed statement said %+v, want one manifest-reads-changed carrying the record's", said)
}
if reads, made := q.StatedReads(); !slices.Equal(reads, got.Record.ManifestReads) || made.Before(before) {
t.Errorf("the verb answers %v made at %v (was %v), want the record's", reads, made, before)
}
q.RestateReads(context.Background())
if l.kv["n1.node"].rev != at+1 || len(l.readsSaid) != saidBefore+1 {
t.Errorf("an unchanged statement wrote the record or said it again")
}
catalogue.UseSeats(all)
q.RestateReads(context.Background())
if got, _ = nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value); !slices.Equal(got.Record.ManifestReads, catalogue.ManifestReads()) {
t.Errorf("the list back whole: the record states %v, want its contract's", got.Record.ManifestReads)
}
}
// 1132/AC7: a write the bucket refuses is said in the log, and the next apply writes again.
+62 -6
View File
@@ -6,6 +6,7 @@ import (
sdkkeys "git.novox.be/novox/mesh-sdk/go/keys"
"slices"
"sort"
"strings"
"sync"
"time"
@@ -34,13 +35,68 @@ type RecordKeys struct {
Stated []string
}
// manifestReads is what this engine reads of a manifest (novox/hq ADR 0614 §4): the reader capabilities of the mesh-sdk it
// is built with, stated in its record so the assigner moves a want only to an engine that reads its manifest. Computed
// once: it is the build's, and never changes while it runs.
var manifestReads = sync.OnceValue(catalogue.ManifestReads)
// buildReads are the words and forms this engine reads of a manifest (novox/hq ADR 0614 §4): the reader capabilities of
// the mesh-sdk it is built with, its seats left out. Computed once: they are the build's, and never change while it runs.
var buildReads = sync.OnceValue(func() []string {
return slices.DeleteFunc(catalogue.ManifestReads(), func(r string) bool { return strings.HasPrefix(r, catalogue.ReadSeat) })
})
// readsStatedAt is when this engine computed what it reads: its start (the verb manifest-reads, ADR 0618 §3).
var readsStatedAt = time.Now().UTC()
// readsNow is what this engine reads of a manifest now: its build's words and forms, and the seats of the seat list it
// holds now (catalogue.Seats, which the engine's reader of the list gives catalogue: bootstrapped or read; ADR 0636).
func readsNow() []string {
set := map[string]bool{}
for _, r := range buildReads() {
set[r] = true
}
for _, seat := range catalogue.Seats() {
set[catalogue.ReadSeat+seat.Name] = true
}
out := make([]string, 0, len(set))
for r := range set {
out = append(out, r)
}
sort.Strings(out)
return out
}
// readsStatement is what this engine states it reads and since when (the verb manifest-reads, ADR 0618 §3): made at its
// first reading, and made again by Restate whenever the seat list it holds changes what it reads (ADR 0618 §4). The seat
// list is the process's, so the statement is too.
type readsStatement struct {
mu sync.Mutex
reads []string
at time.Time
}
var stated readsStatement
// now is the statement, made at the first reading.
func (s *readsStatement) now() ([]string, time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
if s.reads == nil {
s.reads, s.at = readsNow(), time.Now().UTC()
}
return slices.Clone(s.reads), s.at
}
// restate makes the statement again from what the engine reads now, and says whether it changed.
func (s *readsStatement) restate() ([]string, bool) {
cur := readsNow()
s.mu.Lock()
defer s.mu.Unlock()
if s.reads != nil && slices.Equal(cur, s.reads) {
return slices.Clone(cur), false
}
s.reads, s.at = cur, time.Now().UTC()
return slices.Clone(cur), true
}
// manifestReads is what this engine states it reads of a manifest now, which its record carries.
func manifestReads() []string {
reads, _ := stated.now()
return reads
}
// recordOf is the node's record from one facts report and its keys; nil for a report that carries no profile (one that
// says nothing of the machine, such as a rekey), so the record last written stands.
+38 -4
View File
@@ -93,6 +93,8 @@ type Session struct {
sync.Mutex
linked bool
}
// restating takes RestateReads one at a time.
restating sync.Mutex
// haves is what this node-engine writes beside its facts (novox/hq ADR 0429 §4, issue 1132).
haves haveState
@@ -390,10 +392,42 @@ func (q *Session) RouterRecord(key string) (*stdio.StateEntry, error) {
return asking.RouterRecord(key)
}
// StatedReads is what this engine states it reads of a manifest and when it computed them, which is its start (the
// node seat's verb manifest-reads, novox/hq ADR 0618 §3): the record is written only when it differs, so its put says
// nothing of when an unchanged statement was made.
func (q *Session) StatedReads() ([]string, time.Time) { return manifestReads(), readsStatedAt }
// StatedReads is what this engine states it reads of a manifest and when it made that statement, at its start or at the
// last change of its seat list that changed it (the node seat's verb manifest-reads, novox/hq ADR 0618 §3): the record is
// written only when it differs, so its put says nothing of when an unchanged statement was made.
func (q *Session) StatedReads() ([]string, time.Time) { return stated.now() }
// RestateReads states what this engine reads again, after the seat list it holds changed (novox/hq ADR 0618 §4, ADR
// 0636): when the statement changed, its node record carries the new one at once, written where it differs, and the node
// seat's event manifest-reads-changed says it ("started": what it states changed), so the assigner and the builder ask
// the verb again. A statement that did not change writes and says nothing. Calls are taken one at a time, so the last
// write is the newest statement.
func (q *Session) RestateReads(ctx context.Context) {
q.init()
q.restating.Lock()
defer q.restating.Unlock()
reads, changed := stated.restate()
if !changed {
return
}
s := &q.haves
s.mu.Lock()
if s.node != nil && s.node.Record != nil {
node, rec := *s.node, *s.node.Record
rec.ManifestReads = reads
node.Record = &rec
s.node = &node
}
s.mu.Unlock()
q.mu.Lock()
bus := q.bus
q.mu.Unlock()
if bus != nil {
q.writeHaves(ctx, bus)
}
q.SayEngine(nodeengine.EventManifestReadsChanged, nodeengine.ManifestReadsChangedEvent{V: sdkkeys.Version,
Node: q.Membership.Node, ManifestReads: reads, At: time.Now().UTC().Format(time.RFC3339Nano), Why: ReadsStarted})
}
// The reasons the engine says its reads, in the event's why (novox/hq ADR 0618 §4): it started, or its node returned
// after being away (a link after the first).