Files
jschoubben 6304332ad5 The seat-list finding is read from the node's own state, not kept in memory (issue 1966)
A new node-engine process cleared nothing at its first full read of the seat list, because the Waiter remembered in memory whether
it had written the finding; ace and g14 kept the condition for ever. The Waiter now asks the node's state findings whether the
finding stands, clears it at the first full read and writes it at the bound only when it does not stand.

Builds: design 47 H-7, ADR 0636 §1 (the finding is cleared at the first full read), issue 1966

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

181 lines
6.5 KiB
Go

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
standing bool // the bus state holds the finding, written by this process or an earlier one
}
func (f *fakeFindings) Has(views.Finding) (bool, error) { return f.standing, nil }
func (f *fakeFindings) Put(x views.Finding) error {
if f.err != nil {
return f.err
}
f.put = append(f.put, x)
f.standing = true
return nil
}
func (f *fakeFindings) Delete(x views.Finding) error {
f.deleted = append(f.deleted, x)
f.standing = false
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)
}
}
// A finding an earlier process wrote stands in the bus state: the new process clears it at its first full read with no
// write first, and while the list stays unread it neither starts a bound nor writes it again (ADR 0636 §1, issue 1966).
func caseAFindingOfAnEarlierProcessIsClearedAtTheFirstFullRead(t *testing.T) {
reader := &fakeReader{st: seatlist.Status{Heard: false}}
clock := &fakeClock{now: time.Date(2026, 11, 11, 12, 0, 0, 0, time.UTC)}
findings := &fakeFindings{standing: true}
w := &Waiter{Node: "ace", Observer: "node-engine", Reader: reader, Findings: findings, Clock: clock}
w.Changed()
clock.advance(Bound)
if len(findings.put) != 0 || len(clock.timers) != 0 {
t.Fatalf("a standing finding was written again (%+v) or a bound started (%d)", findings.put, len(clock.timers))
}
if on, _ := w.Waiting(); !on {
t.Error("the waiter does not say the finding stands")
}
reader.st = seatlist.Status{Heard: true}
w.Changed()
if len(findings.deleted) != 1 || findings.deleted[0].ID != "ace" || len(findings.put) != 0 {
t.Fatalf("the first full read left the earlier process's finding: deleted %+v, put %+v", findings.deleted, 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,
"a finding of an earlier process is cleared at the first full read": caseAFindingOfAnEarlierProcessIsClearedAtTheFirstFullRead,
} {
t.Run(name, run)
}
}