The long-form invokes entry is read by mesh-sdk's second reader release (09d6070e): packaging's manifest tests pass with it placed and fail before it is joined. Builds: design 47 G §558, §617, §633, §635
266 lines
11 KiB
Go
266 lines
11 KiB
Go
package seatwait
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
|
"git.novox.be/novox/mesh-sdk/go/keys"
|
|
"git.novox.be/novox/mesh-sdk/go/seatlist"
|
|
)
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
|
|
// fakeFindings is a held writer: what it holds is what stands in the state, seeded by the test for an earlier process's.
|
|
type fakeFindings struct {
|
|
put []condseat.Finding
|
|
cleared []condseat.Finding // as held when cleared
|
|
why []string
|
|
err error
|
|
held []condseat.Finding
|
|
}
|
|
|
|
func (f *fakeFindings) Held() []condseat.Finding { return append([]condseat.Finding(nil), f.held...) }
|
|
|
|
func (f *fakeFindings) Write(_ context.Context, x condseat.Finding) error {
|
|
if f.err != nil {
|
|
return f.err
|
|
}
|
|
f.put = append(f.put, x)
|
|
f.held = append(f.held, x)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeFindings) Clear(_ context.Context, scope, id, kind, why string) error {
|
|
for i, h := range f.held {
|
|
if h.Scope == scope && h.ID == id && h.Kind == kind {
|
|
f.held = append(f.held[:i:i], f.held[i+1:]...)
|
|
f.cleared = append(f.cleared, h)
|
|
f.why = append(f.why, why)
|
|
return nil
|
|
}
|
|
}
|
|
f.cleared = append(f.cleared, condseat.Finding{Scope: scope, ID: id, Kind: kind}) // cleared of nothing, said all the same
|
|
f.why = append(f.why, why)
|
|
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, Writer: 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.cleared) != 1 || findings.cleared[0].ID != "ace" {
|
|
t.Fatalf("the first full read left the finding: deleted %+v", findings.cleared)
|
|
}
|
|
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, Writer: 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.cleared) != 0 {
|
|
t.Fatalf("a list read in time wrote %+v and cleared %+v", findings.put, findings.cleared)
|
|
}
|
|
|
|
// 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, Writer: 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{held: []condseat.Finding{{Scope: "machine", ID: "ace", Kind: Kind}}}
|
|
w := &Waiter{Node: "ace", Observer: "node-engine", Reader: reader, Writer: 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.cleared) != 1 || findings.cleared[0].ID != "ace" || len(findings.put) != 0 {
|
|
t.Fatalf("the first full read left the earlier process's finding: deleted %+v, put %+v", findings.cleared, findings.put)
|
|
}
|
|
}
|
|
|
|
// Design 47, test 55: a module held for its claim is a finding that names the node, the module and why, written once,
|
|
// and cleared when the module is composed again or no longer wanted, also one an earlier run of the engine left.
|
|
func caseAHeldModuleIsAFindingClearedWhenItIsComposedOrNoLongerWanted(t *testing.T) {
|
|
// the writer holds what an earlier run of the engine left (seeded at its Start), and what another node's keeper wrote
|
|
findings := &fakeFindings{held: []condseat.Finding{
|
|
{Scope: "module", ID: HeldID("gone", "ace"), Kind: HeldKind},
|
|
{Scope: "module", ID: HeldID("elsewhere", "g14"), Kind: HeldKind}}}
|
|
k := &HeldKeeper{Node: "ace", Observer: "node-engine", Writer: findings}
|
|
k.Reconcile(map[string]string{"mesh-grants": "its seat is refused", "other": "no claim"})
|
|
if len(findings.put) != 2 || findings.put[0].ID != "mesh-grants.ace" || len(findings.cleared) != 1 || findings.cleared[0].ID != "gone.ace" {
|
|
t.Fatalf("wrote %v, cleared %v", findings.put, findings.cleared)
|
|
}
|
|
// the finding is of the module on its machine, in the contract's layout (design 47, test 55)
|
|
if f := findings.put[0]; f.Scope != "module" || f.Subject.Scope != "module" || f.Subject.Machine != "ace" || f.Subject.ID != "mesh-grants.ace" ||
|
|
f.Severity != condseat.FindingSeverityWarning || f.Source != HeldKind || f.Observer != "node-engine" {
|
|
t.Fatalf("a held module's finding is %+v", f)
|
|
}
|
|
if got := findings.held[0]; got.ID != "elsewhere.g14" {
|
|
t.Fatalf("another node's finding was touched: %+v", findings.held)
|
|
}
|
|
k.Reconcile(map[string]string{"mesh-grants": "its seat is refused", "other": "no claim"})
|
|
if len(findings.put) != 2 {
|
|
t.Fatalf("a module held for the same words was written again: %v", findings.put)
|
|
}
|
|
k.Reconcile(map[string]string{"mesh-grants": "its seat is refused"})
|
|
if len(findings.cleared) != 2 || findings.cleared[1].ID != "other.ace" {
|
|
t.Fatalf("a module composed again kept its finding: %v", findings.cleared)
|
|
}
|
|
k.Reconcile(nil)
|
|
if len(findings.cleared) != 3 {
|
|
t.Fatalf("a module no longer wanted kept its finding: %v", findings.cleared)
|
|
}
|
|
}
|
|
|
|
// Design 47 G §614, A-117: the finding entry-not-found is of scope module with the fingerprint, escaped, as its id, and cleared by it.
|
|
func caseAnEntryNotFoundIsAFindingOfTheFingerprint(t *testing.T) {
|
|
w := &fakeFindings{}
|
|
fp := "src2:" + strings.Repeat("a", 64)
|
|
now := func() time.Time { return time.Unix(1, 0) }
|
|
if err := SayEntryNotFound(context.Background(), w, "n1", "mesh-host", fp, true, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(w.put) != 1 || w.put[0].Kind != EntryNotFoundKind || w.put[0].Scope != "module" || w.put[0].ID != keys.EscapeName(fp) || strings.Contains(w.put[0].ID, ":") {
|
|
t.Fatalf("written %+v", w.put)
|
|
}
|
|
if err := SayEntryNotFound(context.Background(), w, "n1", "mesh-host", fp, false, now); err != nil || len(w.cleared) != 1 || w.cleared[0].ID != w.put[0].ID {
|
|
t.Fatalf("cleared %+v, %v", w.cleared, err)
|
|
}
|
|
}
|
|
|
|
// ADR 0636 §1, the node's finding of a seat list unread.
|
|
// §633: the bound is the manifest's `view-waits-minutes` when one is given: a finding at two minutes, none before.
|
|
func caseAGivenBoundReplacesTheFiveMinutes(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{}
|
|
w := &Waiter{Node: "ace", Observer: "node-engine", Reader: reader, Writer: findings, Clock: clock, Bound: 2 * time.Minute}
|
|
w.Changed()
|
|
clock.advance(2*time.Minute - time.Second)
|
|
if len(findings.put) != 0 {
|
|
t.Fatalf("a finding was written before the given bound: %+v", findings.put)
|
|
}
|
|
clock.advance(time.Second)
|
|
if len(findings.put) != 1 {
|
|
t.Fatalf("no finding at the given bound: %+v", findings.put)
|
|
}
|
|
}
|
|
|
|
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 given bound replaces the five minutes": caseAGivenBoundReplacesTheFiveMinutes,
|
|
"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,
|
|
"an entry not found is a finding of the fingerprint": caseAnEntryNotFoundIsAFindingOfTheFingerprint,
|
|
"a held module is a finding cleared when it is composed or no longer wanted": caseAHeldModuleIsAFindingClearedWhenItIsComposedOrNoLongerWanted,
|
|
} {
|
|
t.Run(name, run)
|
|
}
|
|
}
|