seat list: ReloadIfUnread reads the list again once when a host's events path is live; event hub: StartUntilBound binds a late consumer without a restart (B9e; issue 1966)

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-05 16:19:26 +01:00
parent 9489385165
commit a25c600810
4 changed files with 178 additions and 0 deletions
+39
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go/jetstream"
)
@@ -85,6 +86,44 @@ func (h *EventHub) Start(ctx context.Context, js jetstream.JetStream, cred Accou
return nil
}
// StartUntilBound is Start that does not give up: when the module's consumer is not there yet it returns that error at
// once, and tries again on a goroutine of its own, after each wait, until it binds or ctx ends; then live is called (nil
// calls nothing), so a part that needs the events path live does what it does only then. wait returns false to stop
// trying (nil waits five seconds or for ctx). The bus makes the consumer when the module's account is composed, which
// says nothing a module can hear before the consumer exists, so the retry is a wait on that, bounded by ctx.
func (h *EventHub) StartUntilBound(ctx context.Context, js jetstream.JetStream, cred Account, wait func(context.Context) bool, live func()) error {
err := h.Start(ctx, js, cred)
if err == nil {
if live != nil {
live()
}
return nil
}
if wait == nil {
wait = func(ctx context.Context) bool {
t := time.NewTimer(5 * time.Second)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
}
go func() {
for wait(ctx) && ctx.Err() == nil {
if h.Start(ctx, js, cred) == nil {
if live != nil {
live()
}
return
}
}
}()
return err
}
// Stop ends the pull.
func (h *EventHub) Stop() {
h.mu.Lock()
+67
View File
@@ -0,0 +1,67 @@
//go:build integration
package process_test
import (
"context"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/testbus"
)
// A module whose consumer the bus makes after the module started is not lost for that order: the hub tries again after
// each wait and calls live once it is bound (the checker's "until it starts again" hole).
func TestTheHubBindsALateConsumerAndSaysItIsLive(t *testing.T) {
nc, err := nats.Connect(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
defer nc.Close()
js, err := jetstream.New(nc)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: process.EventsStream, Subjects: []string{"mesh.mod.*.event.>"}}); err != nil {
t.Fatal(err)
}
cred := process.Account{Node: "n1", Module: "late"}
hub := process.NewEventHub(t.Logf)
waits, live := make(chan chan bool), make(chan struct{}, 1)
wait := func(ctx context.Context) bool {
step := make(chan bool)
select {
case waits <- step:
case <-ctx.Done():
return false
}
return <-step
}
if err := hub.StartUntilBound(ctx, js, cred, wait, func() { live <- struct{}{} }); err == nil {
t.Fatal("no consumer was there, and the start said nothing")
}
select {
case <-live:
t.Fatal("live before the consumer was there")
default:
}
step := <-waits
step <- true // tried again: still no consumer
step = <-waits
if _, err := js.CreateOrUpdateConsumer(ctx, process.EventsStream, jetstream.ConsumerConfig{Durable: cred.EventConsumerName(), AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
t.Fatal(err)
}
step <- true
select {
case <-live:
case <-ctx.Done():
t.Fatal("the consumer was made and the hub never said it is live")
}
hub.Stop()
}