process: one EventHub pulls the module's one durable consumer, handlers by subject pattern; the views reader registers on it instead of pulling (class fix of B7c finding 1; ADR 0635 §5; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// EventHub is the one pull of a module's durable consumer on the shared events stream (novox/hq ADR 0635 §5): a module
|
||||
// has one durable, `<node>_<module>`, so two loops pulling it each take messages the other needs. Every part of the
|
||||
// module that hears events registers a handler by subject pattern, and the hub pulls once and hands each message to the
|
||||
// handler whose pattern it matches. A message no handler takes is acknowledged and said, never left to be redelivered.
|
||||
// The hub never makes the stream or the consumer: they are the controller's, and a bus without them is an error of Start.
|
||||
//
|
||||
// Register every handler, then Start once: a message heard before its handler is registered would be taken by none.
|
||||
type EventHub struct {
|
||||
logf func(string, ...any)
|
||||
|
||||
mu sync.RWMutex
|
||||
routes []hubRoute
|
||||
started bool
|
||||
cons jetstream.ConsumeContext
|
||||
startErr error
|
||||
}
|
||||
|
||||
type hubRoute struct {
|
||||
pattern string
|
||||
handle func(jetstream.Msg)
|
||||
}
|
||||
|
||||
// NewEventHub is a hub saying through logf (nil says nothing).
|
||||
func NewEventHub(logf func(string, ...any)) *EventHub {
|
||||
if logf == nil {
|
||||
logf = func(string, ...any) {}
|
||||
}
|
||||
return &EventHub{logf: logf}
|
||||
}
|
||||
|
||||
// Handle registers handle for the subjects matching pattern (`*` one token, `>` the rest). The handler acknowledges
|
||||
// the message itself. A pattern that overlaps one registered (one matches every subject of the other) is refused,
|
||||
// so a message always has one taker.
|
||||
func (h *EventHub) Handle(pattern string, handle func(jetstream.Msg)) error {
|
||||
if pattern == "" || handle == nil {
|
||||
return fmt.Errorf("an event handler needs a subject pattern and a handler")
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, r := range h.routes {
|
||||
if subjectsOverlap(r.pattern, pattern) {
|
||||
return fmt.Errorf("the events %q overlap %q, already handled: a message has one taker", pattern, r.pattern)
|
||||
}
|
||||
}
|
||||
h.routes = append(h.routes, hubRoute{pattern, handle})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Patterns are the registered subject patterns, in order.
|
||||
func (h *EventHub) Patterns() []string {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
out := make([]string, len(h.routes))
|
||||
for i, r := range h.routes {
|
||||
out[i] = r.pattern
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Start binds the module's durable on the shared events stream and pulls it; later calls do nothing and return the
|
||||
// first one's result. It makes no stream and no consumer.
|
||||
func (h *EventHub) Start(ctx context.Context, js jetstream.JetStream, cred Account) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.started {
|
||||
return h.startErr
|
||||
}
|
||||
h.started = true
|
||||
cons, err := cred.EventConsumer(ctx, js, EventsStream)
|
||||
if err == nil {
|
||||
h.cons, err = HearEvents(cons, h.hear, h.logf)
|
||||
}
|
||||
if err != nil {
|
||||
h.startErr = fmt.Errorf("the consumer %s on %s is not heard: %w", cred.EventConsumerName(), EventsStream, err)
|
||||
}
|
||||
return h.startErr
|
||||
}
|
||||
|
||||
// Stop ends the pull.
|
||||
func (h *EventHub) Stop() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.cons != nil {
|
||||
h.cons.Stop()
|
||||
h.cons = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *EventHub) hear(m jetstream.Msg) {
|
||||
h.mu.RLock()
|
||||
var take func(jetstream.Msg)
|
||||
for _, r := range h.routes {
|
||||
if subjectMatches(r.pattern, m.Subject()) {
|
||||
take = r.handle
|
||||
break
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
if take == nil {
|
||||
h.logf("events: %s is taken by no handler of this module, acknowledged", m.Subject())
|
||||
_ = m.Ack()
|
||||
return
|
||||
}
|
||||
take(m)
|
||||
}
|
||||
|
||||
// subjectMatches says whether subject is one the pattern (`*` one token, `>` the rest) names.
|
||||
func subjectMatches(pattern, subject string) bool {
|
||||
pt, st := strings.Split(pattern, "."), strings.Split(subject, ".")
|
||||
for i, t := range pt {
|
||||
if t == ">" {
|
||||
return len(st) > i
|
||||
}
|
||||
if i >= len(st) || (t != "*" && t != st[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(pt) == len(st)
|
||||
}
|
||||
|
||||
// subjectsOverlap says whether some subject matches both patterns.
|
||||
func subjectsOverlap(a, b string) bool {
|
||||
at, bt := strings.Split(a, "."), strings.Split(b, ".")
|
||||
for i := 0; ; i++ {
|
||||
if i >= len(at) || i >= len(bt) {
|
||||
return len(at) == len(bt)
|
||||
}
|
||||
if at[i] == ">" || bt[i] == ">" {
|
||||
return true
|
||||
}
|
||||
if at[i] != "*" && bt[i] != "*" && at[i] != bt[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
func TestTheHubMatchesSubjectsAndRefusesOverlappingPatterns(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
a, b string
|
||||
match, overlap bool
|
||||
}{
|
||||
{"mesh.mod.*.event.views.*", "mesh.mod.o.event.views.x", true, true},
|
||||
{"mesh.mod.p.event.>", "mesh.mod.p.event", false, false},
|
||||
{"mesh.mod.p.event.a", "mesh.mod.q.event.a", false, false},
|
||||
{"mesh.mod.p.event.>", "mesh.mod.*.event.a", false, true},
|
||||
{"mesh.mod.p.event.a", "mesh.mod.p.event.a.b", false, false},
|
||||
} {
|
||||
if got := subjectMatches(c.a, c.b); got != c.match && c.match {
|
||||
t.Errorf("%s should match %s", c.a, c.b)
|
||||
}
|
||||
if got := subjectsOverlap(c.a, c.b); got != c.overlap {
|
||||
t.Errorf("overlap(%s, %s) = %v", c.a, c.b, got)
|
||||
}
|
||||
}
|
||||
h := NewEventHub(nil)
|
||||
nop := func(jetstream.Msg) {}
|
||||
if err := h.Handle("mesh.mod.p.event.>", nop); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.Handle("mesh.mod.*.event.a", nop); err == nil {
|
||||
t.Fatal("an overlapping pattern was accepted")
|
||||
}
|
||||
if err := h.Handle("mesh.mod.q.event.>", nop); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user