F-tools: the runner's views consumer is proven on the real bus; the heartbeat is asked of the bus only when a bundle wants it, with no race; a seat record says when the bus wrote it; the beat test counts ticks, not sleeps

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 16:26:50 +01:00
parent 76e025dbbc
commit db3b5fe63d
9 changed files with 290 additions and 32 deletions
+39 -18
View File
@@ -1098,6 +1098,17 @@ type Beat struct {
// the bus's own heartbeat to the loop stopped arriving, the bus ended the loop or no longer answers. A Beat that is
// not Missed follows the first answer after one that was. A nil beat says nothing.
func (c *Conn) ConsumeAsBeating(module string, deliver func(Envelope) error, beat func(Beat)) (func(), error) {
return c.ConsumeAsBeatingWhen(module, deliver, beat, nil)
}
// tickHook, when set before a consume starts, is called after each idle heartbeat interval was handled (tests only,
// through SetTickHook), so a test counts beats instead of sleeping. A consume reads it once, when it starts.
var tickHook func()
// ConsumeAsBeatingWhen is ConsumeAsBeating that asks the bus about the consumer, and says anything, only while wanted
// answers true (nil is always): a module whose children asked for no heartbeat costs the bus no consumer-info request.
func (c *Conn) ConsumeAsBeatingWhen(module string, deliver func(Envelope) error, beat func(Beat), wanted func() bool) (func(), error) {
asked := func() bool { return beat != nil && (wanted == nil || wanted()) }
durable := ConsumerOf(c.node, module)
js, err := jetstream.New(c.nc)
if err != nil {
@@ -1106,7 +1117,7 @@ func (c *Conn) ConsumeAsBeating(module string, deliver func(Envelope) error, bea
var lastEvent atomic.Int64
var missed atomic.Bool
say := func(why string) {
if beat != nil && missed.CompareAndSwap(false, true) {
if asked() && missed.CompareAndSwap(false, true) {
beat(Beat{At: time.Now().UTC(), Missed: true, Why: why})
}
}
@@ -1130,6 +1141,30 @@ func (c *Conn) ConsumeAsBeating(module string, deliver func(Envelope) error, bea
h.start()
if beat != nil {
every := IdleHeartbeat
hook := tickHook
tickOnce := func() {
select {
case <-events.Closed():
say("the bus ended reading the consumer " + durable)
return
default:
}
if !asked() {
return
}
ctx, cancel := context.WithTimeout(context.Background(), every)
_, err := js.Consumer(ctx, EventsStream, durable)
cancel()
if err != nil {
say("the bus does not answer about the consumer " + durable + ": " + err.Error())
return
}
if time.Since(time.Unix(0, lastEvent.Load())) < every {
return // an event arrived in this interval: it was the word
}
missed.Store(false)
beat(Beat{At: time.Now().UTC()})
}
go func() {
tick := time.NewTicker(every)
defer tick.Stop()
@@ -1139,24 +1174,10 @@ func (c *Conn) ConsumeAsBeating(module string, deliver func(Envelope) error, bea
return
case <-tick.C:
}
select {
case <-events.Closed():
say("the bus ended reading the consumer " + durable)
continue
default:
tickOnce()
if hook != nil {
hook()
}
ctx, cancel := context.WithTimeout(context.Background(), every)
_, err := js.Consumer(ctx, EventsStream, durable)
cancel()
if err != nil {
say("the bus does not answer about the consumer " + durable + ": " + err.Error())
continue
}
if time.Since(time.Unix(0, lastEvent.Load())) < every {
continue // an event arrived in this interval: it was the word
}
missed.Store(false)
beat(Beat{At: time.Now().UTC()})
}
}()
}
+26 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
@@ -68,6 +69,13 @@ func TestAConsumerSaysItIsBoundWhileIdleAndOnceWhenTheBusEndedIt(t *testing.T) {
restore := bus.IdleHeartbeat
bus.IdleHeartbeat = 200 * time.Millisecond
t.Cleanup(func() { bus.IdleHeartbeat = restore })
ticks := make(chan struct{}, 64)
t.Cleanup(bus.SetTickHook(func() {
select {
case ticks <- struct{}{}:
default:
}
}))
mesh.Consumer(t, "anchor", "quiet", []string{"mesh.mod.alpha.event.>"}, time.Second)
c, err := bus.Connect(bus.Credential{URL: mt.URL(t), Module: "node-tools", Node: "anchor"})
if err != nil {
@@ -87,6 +95,14 @@ func TestAConsumerSaysItIsBoundWhileIdleAndOnceWhenTheBusEndedIt(t *testing.T) {
t.Fatal(err)
}
t.Cleanup(stop)
// A consume nobody asked a heartbeat of says nothing and asks the bus nothing, however many intervals pass.
var unaskedBeats atomic.Int32
unasked, err := c.ConsumeAsBeatingWhen("quiet", func(bus.Envelope) error { return nil },
func(bus.Beat) { unaskedBeats.Add(1) }, func() bool { return false })
if err != nil {
t.Fatal(err)
}
t.Cleanup(unasked)
last := func() (bus.Beat, int) {
mu.Lock()
defer mu.Unlock()
@@ -116,7 +132,13 @@ func TestAConsumerSaysItIsBoundWhileIdleAndOnceWhenTheBusEndedIt(t *testing.T) {
}
}
mu.Unlock()
time.Sleep(600 * time.Millisecond)
for n := 0; n < 3; n++ { // three more intervals pass, counted by the loop's own tick
select {
case <-ticks:
case <-time.After(10 * time.Second):
t.Fatal("the heartbeat loop handled no more intervals")
}
}
mu.Lock()
defer mu.Unlock()
after := 0
@@ -125,6 +147,9 @@ func TestAConsumerSaysItIsBoundWhileIdleAndOnceWhenTheBusEndedIt(t *testing.T) {
after++
}
}
if unaskedBeats.Load() != 0 {
t.Errorf("a consume with no heartbeat asked said %d words", unaskedBeats.Load())
}
if after != missed || missed != 1 {
t.Errorf("Missed is said once, not at every beat: %d then %d", missed, after)
}
+8
View File
@@ -0,0 +1,8 @@
package bus
// SetTickHook makes the next consumes call f after each idle heartbeat interval was handled; the restore puts it back.
func SetTickHook(f func()) (restore func()) {
old := tickHook
tickHook = f
return func() { tickHook = old }
}
+7 -1
View File
@@ -370,7 +370,13 @@ func (c *Conn) SeatRecord(module, bucket, key string) (*StateEntry, error) {
}
var revision uint64
fmt.Sscan(reply.Header.Get("Nats-Sequence"), &revision)
return &StateEntry{Key: module + "." + key, Value: valueOf(reply.Data), Revision: revision}, nil
var at time.Time
if stamp := reply.Header.Get("Nats-Time-Stamp"); stamp != "" {
if t, err := time.Parse(time.RFC3339Nano, stamp); err == nil {
at = t.UTC()
}
}
return &StateEntry{Key: module + "." + key, Value: valueOf(reply.Data), Revision: revision, At: at}, nil
}
// StateCreate writes one key only when it has no value, and answers its revision; a key that has one is
+3
View File
@@ -213,6 +213,9 @@ func TestAnAskerReadsItsOwnRecordAndNoOther(t *testing.T) {
if err != nil || got == nil || string(got.Value) != `{"state":"open"}` {
t.Fatalf("the asker could not read its own ask: %v %v", got, err)
}
if got.At.IsZero() || time.Since(got.At) > time.Minute {
t.Errorf("the record does not say when the bus wrote it: at %v", got.At)
}
if missing, err := conn.SeatRecord("mesh-delivery", "messenger_asks", "a2"); err != nil || missing != nil {
t.Fatalf("a missing ask read as %v, %v", missing, err)
}