diff --git a/internal/seatwait/seatwait.go b/internal/seatwait/seatwait.go new file mode 100644 index 0000000..9c74565 --- /dev/null +++ b/internal/seatwait/seatwait.go @@ -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) + } +} diff --git a/internal/seatwait/seatwait_test.go b/internal/seatwait/seatwait_test.go new file mode 100644 index 0000000..c04db1a --- /dev/null +++ b/internal/seatwait/seatwait_test.go @@ -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) + } +}