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, `_`, 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 } } }