feat(bus): 进程内事件总线(通配订阅/非阻塞扇出/Close 幂等)

This commit is contained in:
Aska committed 2026-09-06 04:26:02 +08:00
1 parent c1fb20bd6b
commit dd16176bb7
4 files changed
+833

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
// Package bus provides an in-process publish/subscribe event bus. It is the
// asynchronous communication channel mandated by the Cyrene architecture:
// synchronous calls go through interfaces and dependency injection, while
// asynchronous events and agent-to-agent messages flow through this bus. The
// API is deliberately NATS-like so the bus can later be swapped for an
// out-of-process NATS deployment (or another transport) without changing call
// sites; see DevDocs/00-overview.md and DevDocs/02-development-constraints.md.
//
// # Matching
//
// Both subjects and subscription patterns are dot-separated token strings.
// Two wildcards are supported:
//
// - "*" matches exactly one non-empty token. For example "runtime.*.start"
// matches "runtime.loop.start" but not "runtime.loop".
// - ">" matches one or more trailing tokens and may only appear as the last
// token (NATS semantics). For example "runtime.>" matches
// "runtime.loop.start" but not "runtime".
//
// A pattern with no wildcard matches only an identical subject.
//
// # Delivery semantics
//
// Publish fans out asynchronously and never waits for a handler to finish.
// Each subscription is served by a single worker goroutine reading from a
// bounded per-subscription channel, which serializes handler execution for
// that subscription and bounds its memory. When the buffer is full the event
// is dropped and counted (see busImpl.Dropped) instead of blocking the
// publisher; that trade-off is what keeps Publish non-blocking and is the
// documented cost of an unbounded producer.
//
// Handler errors are ignored by this M0 core; consumers that need them should
// capture and log them at the call site. A panic raised by a handler is
// recovered so a misbehaving subscriber cannot take down the bus or starve
// other subscribers.
package bus
import (
"context"
"errors"
)
// Event is a single published message.
type Event struct {
// Subject is the token string the message was published to.
Subject string
// Payload is the arbitrary value associated with the event.
Payload any
}
// Handler processes a delivered Event. It runs on the subscription's own
// worker goroutine, so invocations for the same subscription are serialized.
// A non-nil error is ignored by the M0 core (see the package comment).
type Handler func(ctx context.Context, e Event) error
// Bus is the in-process publish/subscribe contract. Keep this interface
// narrow so an alternate transport (for example NATS) can satisfy it without
// changing consumers, and so it is trivial to mock in tests.
type Bus interface {
// Publish asynchronously fans an event out to every subscription whose
// pattern matches subject. It never blocks waiting for a handler to run;
// when a subscription's buffer is full the event is dropped for that
// subscription and counted. It returns ErrClosed if the bus is closed and
// a wrapped error if subject is invalid or ctx is canceled.
Publish(ctx context.Context, subject string, payload any) error
// Subscribe registers h for events whose subject matches pattern and
// returns an unsubscribe function that removes the registration. When ctx
// is canceled the registration is removed automatically. It returns
// ErrClosed if the bus is already closed.
Subscribe(ctx context.Context, pattern string, h Handler) (unsubscribe func(), err error)
// Close is idempotent and safe to call concurrently. It prevents further
// Publish/Subscribe calls, unsubscribes every registration, cancels the
// handlers' contexts, and stops every worker. It returns nil.
Close() error
}
// Sentinel errors returned by the bus. Use errors.Is to test for them.
var (
// ErrClosed is returned when publishing to or subscribing on a closed bus.
ErrClosed = errors.New("bus: closed")
// ErrInvalidArgument is returned when a subject is empty, a pattern is
// malformed, or a handler is nil.
ErrInvalidArgument = errors.New("bus: invalid argument")
)
// defaultQueueSize is the per-subscription event buffer used when no option
// overrides it.
const defaultQueueSize = 64
// config carries the resolved construction options.
type config struct {
queueSize int
}
// Option customizes a bus built by New. Options are applied in order before
// the bus starts accepting traffic.
type Option func(*config)
// WithQueueSize sets the per-subscription event buffer size. Buffers bound how
// many undelivered events a subscription may hold before events begin to be
// dropped. Non-positive values are ignored and the default is kept.
func WithQueueSize(n int) Option {
return func(c *config) {
if n > 0 {
c.queueSize = n
}
}
}
// New builds a bus with the supplied options. The returned *busImpl
// implements Bus and also exposes busImpl.Dropped for observability.
func New(opts ...Option) *busImpl {
cfg := config{queueSize: defaultQueueSize}
for _, opt := range opts {
opt(&cfg)
}
ctx, cancel := context.WithCancel(context.Background())
return &busImpl{
queueSize: cfg.queueSize,
subs: make(map[uint64]*subscription),
ctx: ctx,
cancel: cancel,
}
}
+454
View File
@@ -0,0 +1,454 @@
package bus
import (
"context"
"errors"
"sync"
"testing"
"time"
)
// recvWithTimeout blocks until a value is delivered or the deadline passes,
// failing the test on timeout. It makes delivery assertions deterministic
// instead of relying on a bare sleep.
func recvWithTimeout[T any](t *testing.T, ch <-chan T, timeout time.Duration) T {
t.Helper()
select {
case v := <-ch:
return v
case <-time.After(timeout):
t.Fatalf("timed out waiting for a delivery after %v", timeout)
var zero T
return zero
}
}
// assertNoRecv fails if a value arrives on ch within d. It is used for the
// negative cases where the registration is already gone, so nothing is ever
// enqueued and the channel stays empty.
func assertNoRecv[T any](t *testing.T, ch <-chan T, d time.Duration) {
t.Helper()
select {
case v := <-ch:
t.Fatalf("unexpected delivery: %v", v)
case <-time.After(d):
}
}
// waitSubs polls the registry until it holds exactly want subscriptions, with a
// deadline, so context-cancellation behavior can be asserted deterministically.
func waitSubs(t *testing.T, b *busImpl, want int) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
b.mu.RLock()
got := len(b.subs)
b.mu.RUnlock()
if got == want {
return
}
if time.Now().After(deadline) {
t.Fatalf("subscriber count = %d, want %d", got, want)
}
time.Sleep(time.Millisecond)
}
}
func TestMatch_Rules(t *testing.T) {
t.Parallel()
tests := []struct {
name string
pattern string
subject string
want bool
}{
{name: "exact match", pattern: "runtime.loop.start", subject: "runtime.loop.start", want: true},
{name: "exact mismatch", pattern: "runtime.loop.start", subject: "runtime.loop.stop", want: false},
{name: "exact subject has more tokens", pattern: "runtime.loop", subject: "runtime.loop.start", want: false},
{name: "exact subject has fewer tokens", pattern: "runtime.loop.start", subject: "runtime", want: false},
{name: "gt matches nested", pattern: "runtime.>", subject: "runtime.loop.start", want: true},
{name: "gt matches one child", pattern: "runtime.>", subject: "runtime.loop", want: true},
{name: "gt does not match parent", pattern: "runtime.>", subject: "runtime", want: false},
{name: "gt alone matches nested", pattern: ">", subject: "runtime.loop.start", want: true},
{name: "gt alone matches single", pattern: ">", subject: "runtime", want: true},
{name: "star matches one token", pattern: "runtime.*.start", subject: "runtime.loop.start", want: true},
{name: "star rejects extra token", pattern: "runtime.*.start", subject: "runtime.loop.x.start", want: false},
{name: "star rejects value mismatch", pattern: "runtime.*.start", subject: "runtime.loop.stop", want: false},
{name: "star alone matches single", pattern: "*", subject: "runtime", want: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := match(tt.pattern, tt.subject); got != tt.want {
t.Errorf("match(%q, %q) = %v, want %v", tt.pattern, tt.subject, got, tt.want)
}
})
}
}
func TestPublish_ExactMatch(t *testing.T) {
t.Parallel()
b := New()
defer b.Close()
got := make(chan any, 4)
if _, err := b.Subscribe(context.Background(), "runtime.loop.start", func(ctx context.Context, e Event) error {
got <- e.Payload
return nil
}); err != nil {
t.Fatalf("Subscribe: %v", err)
}
if err := b.Publish(context.Background(), "runtime.loop.start", "hit"); err != nil {
t.Fatalf("Publish: %v", err)
}
if p := recvWithTimeout(t, got, 2*time.Second); p != "hit" {
t.Fatalf("payload = %v, want hit", p)
}
if err := b.Publish(context.Background(), "runtime.loop.stop", "miss"); err != nil {
t.Fatalf("Publish: %v", err)
}
assertNoRecv(t, got, 100*time.Millisecond)
}
func TestPublish_GTailWildcard(t *testing.T) {
t.Parallel()
b := New()
defer b.Close()
got := make(chan any, 4)
if _, err := b.Subscribe(context.Background(), "runtime.>", func(ctx context.Context, e Event) error {
got <- e.Payload
return nil
}); err != nil {
t.Fatalf("Subscribe: %v", err)
}
for _, subject := range []string{"runtime.loop.start", "runtime.loop", "runtime.loop.a.b"} {
if err := b.Publish(context.Background(), subject, subject); err != nil {
t.Fatalf("Publish(%q): %v", subject, err)
}
if p := recvWithTimeout(t, got, 2*time.Second); p != subject {
t.Fatalf("payload = %v, want %q", p, subject)
}
}
// "runtime.>" must not match the exact "runtime", and a different root
// must not match at all.
if err := b.Publish(context.Background(), "runtime", "miss-root"); err != nil {
t.Fatalf("Publish: %v", err)
}
if err := b.Publish(context.Background(), "other.x", "miss-other"); err != nil {
t.Fatalf("Publish: %v", err)
}
assertNoRecv(t, got, 100*time.Millisecond)
}
func TestPublish_StarWildcard(t *testing.T) {
t.Parallel()
b := New()
defer b.Close()
got := make(chan any, 4)
if _, err := b.Subscribe(context.Background(), "runtime.*.start", func(ctx context.Context, e Event) error {
got <- e.Payload
return nil
}); err != nil {
t.Fatalf("Subscribe: %v", err)
}
if err := b.Publish(context.Background(), "runtime.loop.start", "hit"); err != nil {
t.Fatalf("Publish: %v", err)
}
if p := recvWithTimeout(t, got, 2*time.Second); p != "hit" {
t.Fatalf("payload = %v, want hit", p)
}
for _, subject := range []string{"runtime.loop.x.start", "runtime.loop.stop", "runtime.loop"} {
if err := b.Publish(context.Background(), subject, "miss"); err != nil {
t.Fatalf("Publish(%q): %v", subject, err)
}
}
assertNoRecv(t, got, 100*time.Millisecond)
}
func TestSubscribe_UnsubscribeStopsDelivery(t *testing.T) {
t.Parallel()
b := New()
defer b.Close()
got := make(chan any, 2)
unsub, err := b.Subscribe(context.Background(), "a.b", func(ctx context.Context, e Event) error {
got <- e.Payload
return nil
})
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
if err := b.Publish(context.Background(), "a.b", "first"); err != nil {
t.Fatalf("Publish: %v", err)
}
if p := recvWithTimeout(t, got, 2*time.Second); p != "first" {
t.Fatalf("payload = %v, want first", p)
}
unsub()
if err := b.Publish(context.Background(), "a.b", "second"); err != nil {
t.Fatalf("Publish: %v", err)
}
assertNoRecv(t, got, 100*time.Millisecond)
}
func TestPublish_NonBlockingSlowHandler(t *testing.T) {
t.Parallel()
b := New()
defer b.Close()
started := make(chan struct{})
release := make(chan struct{})
var once sync.Once
handler := func(ctx context.Context, e Event) error {
once.Do(func() { close(started) })
<-release
return nil
}
if _, err := b.Subscribe(context.Background(), "slow", handler); err != nil {
t.Fatalf("Subscribe: %v", err)
}
if err := b.Publish(context.Background(), "slow", "payload"); err != nil {
t.Fatalf("Publish: %v", err)
}
// The handler started and is still blocked on release. Because Publish has
// already returned, it did not wait for the handler to finish.
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("handler did not start")
}
select {
case <-release:
t.Fatal("handler finished before release was closed")
case <-time.After(50 * time.Millisecond):
}
close(release)
}
func TestBus_CloseStopsDelivery(t *testing.T) {
t.Parallel()
b := New()
defer b.Close()
got := make(chan any, 1)
if _, err := b.Subscribe(context.Background(), "a.b", func(ctx context.Context, e Event) error {
got <- e.Payload
return nil
}); err != nil {
t.Fatalf("Subscribe: %v", err)
}
if err := b.Publish(context.Background(), "a.b", "before-close"); err != nil {
t.Fatalf("Publish: %v", err)
}
if p := recvWithTimeout(t, got, 2*time.Second); p != "before-close" {
t.Fatalf("payload = %v, want before-close", p)
}
if err := b.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if err := b.Publish(context.Background(), "a.b", "after-close"); !errors.Is(err, ErrClosed) {
t.Fatalf("Publish after Close error = %v, want ErrClosed", err)
}
assertNoRecv(t, got, 100*time.Millisecond)
}
func TestPublish_InvalidSubject(t *testing.T) {
t.Parallel()
b := New()
defer b.Close()
for _, subject := range []string{"", ".a", "a.", "a..b"} {
err := b.Publish(context.Background(), subject, "x")
if !errors.Is(err, ErrInvalidArgument) {
t.Errorf("Publish(%q) error = %v, want ErrInvalidArgument", subject, err)
}
}
}
func TestSubscribe_InvalidPattern(t *testing.T) {
t.Parallel()
b := New()
defer b.Close()
for _, pattern := range []string{"", ".a", "a.", "a..b", "a.>.b"} {
_, err := b.Subscribe(context.Background(), pattern, func(ctx context.Context, e Event) error { return nil })
if !errors.Is(err, ErrInvalidArgument) {
t.Errorf("Subscribe(%q) error = %v, want ErrInvalidArgument", pattern, err)
}
}
}
func TestSubscribe_NilHandler(t *testing.T) {
t.Parallel()
b := New()
defer b.Close()
_, err := b.Subscribe(context.Background(), "a.b", nil)
if !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("Subscribe(nil) error = %v, want ErrInvalidArgument", err)
}
}
func TestBus_ClosedError(t *testing.T) {
t.Parallel()
b := New()
if err := b.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if err := b.Publish(context.Background(), "a.b", "x"); !errors.Is(err, ErrClosed) {
t.Fatalf("Publish after Close error = %v, want ErrClosed", err)
}
if _, err := b.Subscribe(context.Background(), "a.b", func(ctx context.Context, e Event) error { return nil }); !errors.Is(err, ErrClosed) {
t.Fatalf("Subscribe after Close error = %v, want ErrClosed", err)
}
if err := b.Close(); err != nil {
t.Fatalf("second Close error = %v, want nil", err)
}
}
func TestBus_DroppedCounter(t *testing.T) {
t.Parallel()
b := New(WithQueueSize(1))
defer b.Close()
started := make(chan struct{})
release := make(chan struct{})
var once sync.Once
if _, err := b.Subscribe(context.Background(), "d", func(ctx context.Context, e Event) error {
once.Do(func() { close(started) })
<-release
return nil
}); err != nil {
t.Fatalf("Subscribe: %v", err)
}
if err := b.Publish(context.Background(), "d", "first"); err != nil {
t.Fatalf("Publish first: %v", err)
}
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("handler did not start; worker failed to drain")
}
// The worker is now blocked in the handler, so it consumed "first" and the
// single-slot buffer is empty. The next enqueue fills it; the two after
// that find it full and are dropped.
for _, payload := range []string{"fill1", "fill2", "fill3"} {
if err := b.Publish(context.Background(), "d", payload); err != nil {
t.Fatalf("Publish %s: %v", payload, err)
}
}
if got := b.Dropped(); got != 2 {
t.Fatalf("Dropped() = %d, want 2", got)
}
close(release)
}
func TestSubscribe_ContextCancelAutoUnsub(t *testing.T) {
t.Parallel()
b := New()
defer b.Close()
got := make(chan any, 1)
ctx, cancel := context.WithCancel(context.Background())
if _, err := b.Subscribe(ctx, "a.b", func(ctx context.Context, e Event) error {
got <- e.Payload
return nil
}); err != nil {
t.Fatalf("Subscribe: %v", err)
}
cancel()
waitSubs(t, b, 0)
if err := b.Publish(context.Background(), "a.b", "after-cancel"); err != nil {
t.Fatalf("Publish: %v", err)
}
assertNoRecv(t, got, 100*time.Millisecond)
}
func TestPublish_ConcurrentSafety(t *testing.T) {
t.Parallel()
b := New(WithQueueSize(8))
defer b.Close()
const workers = 8
const iterations = 200
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
if i%3 == 0 {
unsub, err := b.Subscribe(context.Background(), "c", func(ctx context.Context, e Event) error {
return nil
})
if err == nil {
unsub()
}
continue
}
_ = b.Publish(context.Background(), "c", i)
_ = b.Publish(context.Background(), "c.x", i)
}
}()
}
wg.Wait()
if err := b.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
func TestClose_Concurrent(t *testing.T) {
t.Parallel()
b := New()
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = b.Publish(context.Background(), "a.b", 1)
_, _ = b.Subscribe(context.Background(), "a.b", func(ctx context.Context, e Event) error { return nil })
_ = b.Close()
}()
}
wg.Wait()
if err := b.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
+192
View File
@@ -0,0 +1,192 @@
package bus
import (
"context"
"fmt"
"sync"
"sync/atomic"
)
// Compile-time assertion that *busImpl satisfies Bus.
var _ Bus = (*busImpl)(nil)
// busImpl is the default in-process Bus implementation. It is safe for
// concurrent use.
type busImpl struct {
queueSize int
mu sync.RWMutex
subs map[uint64]*subscription
nextID atomic.Uint64
dropped atomic.Uint64
closed atomic.Bool
ctx context.Context
cancel context.CancelFunc
closeOnce sync.Once
}
// Dropped returns the total number of events dropped because a subscription's
// buffer was full. It is useful for observing backpressure on the bus.
func (b *busImpl) Dropped() uint64 {
return b.dropped.Load()
}
// subscription is one registered handler plus its bounded queue and the single
// worker goroutine that drains it.
type subscription struct {
id uint64
pattern string
handler Handler
events chan Event
done chan struct{}
ctx context.Context
cancel context.CancelFunc
once sync.Once
}
// stop idempotently cancels the handler context and signals the worker to
// exit. It is safe to call more than once and from more than one goroutine.
func (s *subscription) stop() {
s.once.Do(func() {
s.cancel()
close(s.done)
})
}
// Publish fans an event out to every matching subscription without waiting for
// any handler. It holds the read lock across the fan-out so a concurrent
// Close cannot stop a worker mid-broadcast. Events that cannot be queued
// because a subscription buffer is full are dropped and counted.
func (b *busImpl) Publish(ctx context.Context, subject string, payload any) error {
if err := validateSubject(subject); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return fmt.Errorf("bus: publish %q: %w", subject, err)
}
b.mu.RLock()
defer b.mu.RUnlock()
if b.closed.Load() {
return fmt.Errorf("bus is closed: %w", ErrClosed)
}
ev := Event{Subject: subject, Payload: payload}
for _, sub := range b.subs {
if !match(sub.pattern, subject) {
continue
}
if err := ctx.Err(); err != nil {
return fmt.Errorf("bus: publish %q: %w", subject, err)
}
select {
case sub.events <- ev:
default:
b.dropped.Add(1)
}
}
return nil
}
// Subscribe registers a handler and returns an unsubscribe function. The
// registration is removed automatically when the caller's context is canceled.
func (b *busImpl) Subscribe(ctx context.Context, pattern string, h Handler) (func(), error) {
if err := validatePattern(pattern); err != nil {
return nil, err
}
if h == nil {
return nil, fmt.Errorf("bus: handler must not be nil: %w", ErrInvalidArgument)
}
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("bus: subscribe %q: %w", pattern, err)
}
subCtx, subCancel := context.WithCancel(b.ctx)
sub := &subscription{
id: b.nextID.Add(1),
pattern: pattern,
handler: h,
events: make(chan Event, b.queueSize),
done: make(chan struct{}),
ctx: subCtx,
cancel: subCancel,
}
b.mu.Lock()
if b.closed.Load() {
b.mu.Unlock()
subCancel()
return nil, fmt.Errorf("bus is closed: %w", ErrClosed)
}
b.subs[sub.id] = sub
b.mu.Unlock()
b.startWorker(sub)
go func() {
select {
case <-ctx.Done():
b.removeSub(sub.id, sub)
case <-sub.done:
// Already removed; nothing to do.
}
}()
return func() { b.removeSub(sub.id, sub) }, nil
}
// Close is idempotent and safe to call concurrently. It marks the bus closed,
// cancels every handler context, unsubscribes all registrations, and signals
// every worker to stop. It does not wait for in-flight handlers, which instead
// observe their canceled context.
func (b *busImpl) Close() error {
b.closeOnce.Do(func() {
b.mu.Lock()
b.closed.Store(true)
b.cancel()
for _, sub := range b.subs {
sub.stop()
}
b.subs = make(map[uint64]*subscription)
b.mu.Unlock()
})
return nil
}
// startWorker launches the single goroutine that drains the subscription queue
// and runs the handler for each event.
func (b *busImpl) startWorker(sub *subscription) {
go func() {
for {
select {
case <-sub.done:
return
case ev := <-sub.events:
b.deliver(sub, ev)
}
}
}()
}
// deliver runs one handler invocation. It recovers panics so a misbehaving
// subscriber cannot crash the bus, and it ignores the handler's returned error
// (see the package comment).
func (b *busImpl) deliver(sub *subscription, ev Event) {
defer func() {
_ = recover()
}()
_ = sub.handler(sub.ctx, ev)
}
// removeSub unsubscribes a registration exactly once. The events channel is
// never closed by a sender, so a concurrent Publish cannot panic on a send;
// this method only closes the done channel after the registration is gone.
func (b *busImpl) removeSub(id uint64, sub *subscription) {
b.mu.Lock()
if _, ok := b.subs[id]; ok {
delete(b.subs, id)
b.mu.Unlock()
sub.stop()
return
}
b.mu.Unlock()
}
+63
View File
@@ -0,0 +1,63 @@
package bus
import (
"fmt"
"strings"
)
// match reports whether subject matches pattern using the wildcard rules
// described in the package comment: "*" matches exactly one non-empty token,
// ">" matches one or more trailing tokens and must be the last token, and any
// other token matches an identical subject token. Callers must have validated
// both inputs, so an empty token or a misplaced ">" never reaches here.
func match(pattern, subject string) bool {
p := strings.Split(pattern, ".")
s := strings.Split(subject, ".")
i, j := 0, 0
for i < len(p) {
if p[i] == ">" {
// ">" is guaranteed to be the last token; it needs at least one
// remaining subject token.
return j < len(s)
}
if j >= len(s) {
return false
}
if p[i] != "*" && p[i] != s[j] {
return false
}
i++
j++
}
return j == len(s)
}
// validateSubject rejects an empty subject or one containing an empty token
// (leading/trailing/repeated dots).
func validateSubject(subject string) error {
if subject == "" {
return fmt.Errorf("bus: subject must not be empty: %w", ErrInvalidArgument)
}
if strings.HasPrefix(subject, ".") || strings.HasSuffix(subject, ".") || strings.Contains(subject, "..") {
return fmt.Errorf("bus: subject %q contains an empty token: %w", subject, ErrInvalidArgument)
}
return nil
}
// validatePattern rejects an empty pattern, one with an empty token, or one
// where the ">" wildcard is not the final token.
func validatePattern(pattern string) error {
if pattern == "" {
return fmt.Errorf("bus: pattern must not be empty: %w", ErrInvalidArgument)
}
if strings.HasPrefix(pattern, ".") || strings.HasSuffix(pattern, ".") || strings.Contains(pattern, "..") {
return fmt.Errorf("bus: pattern %q contains an empty token: %w", pattern, ErrInvalidArgument)
}
tokens := strings.Split(pattern, ".")
for i, tok := range tokens {
if tok == ">" && i != len(tokens)-1 {
return fmt.Errorf("bus: pattern %q: '>' wildcard must be the last token: %w", pattern, ErrInvalidArgument)
}
}
return nil
}