feat(bus): 进程内事件总线(通配订阅/非阻塞扇出/Close 幂等)
This commit is contained in:
1 parent
c1fb20bd6b
commit
dd16176bb7
4 files changed
+833
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user