Node-engine: the finding of a seat list unread for five minutes, written once and cleared at the first full read (H-7, ADR 0636 §1; issue 1966)

This commit is contained in:
2026-11-05 14:16:04 +01:00
parent 7623279efe
commit 66fe7c8a0d
2 changed files with 267 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
// Package seatwait is the node-engine's word that its reader of the seat list has waited (novox/hq ADR 0636 §1): a node
// whose reader holds only the bootstrapped definitions for five minutes writes one finding in the engine's own state
// `findings`, which the condition keeper raises as a condition and clears at the first full read (ADR 0564 §2).
//
// Event-driven: the reader says each change (Changed), and the one bound is a timer started when the wait begins and
// stopped when it ends, never a loop. A timer that fires looks at the reader again before it writes, so a read that
// ended the wait without a change of the held set (the seat answered the list the reader already held) writes nothing.
package seatwait
import (
"fmt"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/seatlist"
"git.novox.be/novox/mesh-sdk/go/views"
)
// Bound is how long the reader may wait before the node says it (ADR 0636 §1).
const Bound = 5 * time.Minute
// Kind is the finding's kind; its id is the node's.
const Kind = "seat-list-unread"
// Reader is what the Waiter asks of the seat list's reader.
type Reader interface {
Status() seatlist.Status
}
// Timer is a started timer that can be stopped.
type Timer interface{ Stop() bool }
// Clock gives the time and starts a timer; the tests give their own, so nothing sleeps.
type Clock interface {
Now() time.Time
AfterFunc(d time.Duration, f func()) Timer
}
// Waiter writes and clears the node's finding.
type Waiter struct {
Node string
Observer string
Reader Reader
Findings views.FindingWriter
Clock Clock
// Say tells the node's journal; nil says nothing.
Say func(string)
mu sync.Mutex
timer Timer
since time.Time
written *views.Finding
}
func waiting(st seatlist.Status) bool { return !st.Heard || len(st.Waiting) > 0 }
// Changed is told when the reader's held set or its status moved: it starts the bound when the reader waits and has no
// bound running, stops it and clears the finding when the reader has read the whole list.
func (w *Waiter) Changed() {
st := w.Reader.Status()
w.mu.Lock()
defer w.mu.Unlock()
if !waiting(st) {
if w.timer != nil {
w.timer.Stop()
w.timer = nil
}
w.since = time.Time{}
if w.written != nil {
if err := w.Findings.Delete(*w.written); err != nil {
w.say(fmt.Sprintf("the node's finding that its seat list is unread cannot be cleared: %v", err))
return
}
w.written = nil
}
return
}
if w.timer != nil || w.written != nil {
return
}
w.since = w.Clock.Now()
w.timer = w.Clock.AfterFunc(Bound, w.expired)
}
// Waiting says whether the bound is running or the finding stands, and since when.
func (w *Waiter) Waiting() (bool, time.Time) {
w.mu.Lock()
defer w.mu.Unlock()
return w.timer != nil || w.written != nil, w.since
}
func (w *Waiter) expired() {
st := w.Reader.Status()
w.mu.Lock()
defer w.mu.Unlock()
w.timer = nil
if !waiting(st) {
w.since = time.Time{}
return
}
f := views.Finding{Observer: w.Observer, Kind: Kind, ID: w.Node,
Summary: fmt.Sprintf("%s has read no complete seat list since %s: %s", w.Node, w.since.UTC().Format(time.RFC3339), st.Health()),
Headline: fmt.Sprintf("%s waits for the seat list", w.Node),
Explanation: "The node holds only the definitions it starts with, so it keeps running what it runs and starts, composes and grants nothing new. It reads the list again at the next change the seat mesh-catalog says, or when asked to.",
At: w.Clock.Now()}
if err := w.Findings.Put(f); err != nil {
w.say(fmt.Sprintf("the node's finding that its seat list is unread cannot be written: %v", err))
return
}
w.written = &f
w.say(f.Summary)
}
func (w *Waiter) say(s string) {
if w.Say != nil {
w.Say(s)
}
}
+149
View File
@@ -0,0 +1,149 @@
package seatwait
import (
"errors"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/seatlist"
"git.novox.be/novox/mesh-sdk/go/views"
)
type fakeReader struct{ st seatlist.Status }
func (f *fakeReader) Status() seatlist.Status { return f.st }
type fakeTimer struct {
c *fakeClock
due time.Time
f func()
stopped bool
}
func (t *fakeTimer) Stop() bool { was := !t.stopped; t.stopped = true; return was }
type fakeClock struct {
now time.Time
timers []*fakeTimer
}
func (c *fakeClock) Now() time.Time { return c.now }
func (c *fakeClock) AfterFunc(d time.Duration, f func()) Timer {
t := &fakeTimer{c: c, due: c.now.Add(d), f: f}
c.timers = append(c.timers, t)
return t
}
// advance moves the clock and fires the timers that fell due and were not stopped.
func (c *fakeClock) advance(d time.Duration) {
c.now = c.now.Add(d)
for _, t := range c.timers {
if !t.stopped && !t.due.After(c.now) {
t.stopped = true
t.f()
}
}
}
type fakeFindings struct {
put, deleted []views.Finding
err error
}
func (f *fakeFindings) Put(x views.Finding) error {
if f.err != nil {
return f.err
}
f.put = append(f.put, x)
return nil
}
func (f *fakeFindings) Delete(x views.Finding) error { f.deleted = append(f.deleted, x); return nil }
// ADR 0636 §1: a reader that waited five minutes makes the node write one finding, which the first full read clears.
func caseTheNodeWritesOneFindingAfterFiveMinutesAndClearsItAtTheFirstFullRead(t *testing.T) {
reader := &fakeReader{st: seatlist.Status{Heard: false, LastError: "no answer"}}
clock := &fakeClock{now: time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)}
findings := &fakeFindings{}
var said []string
w := &Waiter{Node: "ace", Observer: "node-engine", Reader: reader, Findings: findings, Clock: clock, Say: func(s string) { said = append(said, s) }}
w.Changed()
clock.advance(Bound - time.Second)
if len(findings.put) != 0 {
t.Fatalf("a finding was written before the bound: %+v", findings.put)
}
w.Changed() // another change while waiting starts no second bound
clock.advance(time.Second)
if len(findings.put) != 1 || findings.put[0].Kind != Kind || findings.put[0].ID != "ace" {
t.Fatalf("after five minutes the node wrote %+v, want one %s finding for ace", findings.put, Kind)
}
clock.advance(time.Hour)
if len(findings.put) != 1 {
t.Fatalf("the finding was written again: %d", len(findings.put))
}
reader.st = seatlist.Status{Heard: true}
w.Changed()
if len(findings.deleted) != 1 || findings.deleted[0].ID != "ace" {
t.Fatalf("the first full read left the finding: deleted %+v", findings.deleted)
}
if on, _ := w.Waiting(); on {
t.Error("the waiter still waits after the full read")
}
}
// A read that ends the wait stops the bound; a timer that fires after the list was read writes nothing.
func caseAReadWithinTheBoundWritesNothing(t *testing.T) {
reader := &fakeReader{st: seatlist.Status{Heard: false}}
clock := &fakeClock{now: time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)}
findings := &fakeFindings{}
w := &Waiter{Node: "ace", Observer: "node-engine", Reader: reader, Findings: findings, Clock: clock}
w.Changed()
clock.advance(time.Minute)
reader.st = seatlist.Status{Heard: true}
w.Changed()
clock.advance(time.Hour)
if len(findings.put) != 0 || len(findings.deleted) != 0 {
t.Fatalf("a list read in time wrote %+v and cleared %+v", findings.put, findings.deleted)
}
// The seat answered the list already held: no change was said, so the timer is the one to look again.
reader.st = seatlist.Status{Heard: false}
w.Changed()
reader.st = seatlist.Status{Heard: true}
clock.advance(Bound)
if len(findings.put) != 0 {
t.Fatalf("a timer that found the list read wrote %+v", findings.put)
}
}
// A finding that cannot be written is said and tried again at the next change, not lost.
func caseAFindingThatCannotBeWrittenIsSaidAndTriedAgain(t *testing.T) {
reader := &fakeReader{st: seatlist.Status{Heard: false}}
clock := &fakeClock{now: time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)}
findings := &fakeFindings{err: errors.New("bus down")}
var said []string
w := &Waiter{Node: "ace", Observer: "node-engine", Reader: reader, Findings: findings, Clock: clock, Say: func(s string) { said = append(said, s) }}
w.Changed()
clock.advance(Bound)
if len(said) != 1 || len(findings.put) != 0 {
t.Fatalf("said %v, wrote %v", said, findings.put)
}
findings.err = nil
w.Changed()
clock.advance(Bound)
if len(findings.put) != 1 {
t.Fatalf("the finding was not written at the next bound: %v", findings.put)
}
}
// ADR 0636 §1, the node's finding of a seat list unread.
func TestTheSeatListWait(t *testing.T) {
for name, run := range map[string]func(*testing.T){
"five minutes write one finding and the first full read clears it": caseTheNodeWritesOneFindingAfterFiveMinutesAndClearsItAtTheFirstFullRead,
"a read within the bound writes nothing": caseAReadWithinTheBoundWritesNothing,
"a finding that cannot be written is said and tried again": caseAFindingThatCannotBeWrittenIsSaidAndTriedAgain,
} {
t.Run(name, run)
}
}