Add namespace event filtering support
All checks were successful
CI / build (pull_request) Successful in 21s

Add SubscriptionFilter type and SubscribeWithFilter method to enable
filtering events by type and actor pattern within namespace subscriptions.

- SubscriptionFilter supports event type filtering (e.g., only "OrderPlaced")
- SubscriptionFilter supports actor ID prefix patterns (e.g., "order-*")
- Filters are combined with AND logic
- NATSEventBus uses NATS subjects for server-side filtering when possible
- Comprehensive test coverage for filtering functionality

Closes #21

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 19:24:21 +01:00
parent f0f8978079
commit a8787877f0
3 changed files with 1133 additions and 31 deletions

View File

@@ -2,21 +2,119 @@ package aether
import (
"context"
"strings"
"sync"
)
// SubscriptionFilter defines criteria for filtering events in a subscription.
// Multiple filters are combined with AND logic - an event must match all
// non-empty filter criteria to be delivered.
//
// # Event Type Filtering
//
// EventTypes specifies which event types to receive. If empty, all event types
// are delivered. Otherwise, only events with a matching EventType are delivered.
//
// filter := SubscriptionFilter{
// EventTypes: []string{"OrderPlaced", "OrderShipped"},
// }
//
// # Actor Pattern Filtering
//
// ActorPattern specifies a pattern to match against actor IDs. Patterns support
// two matching modes:
//
// Prefix matching with "*" suffix:
//
// "order-*" // matches "order-123", "order-456", etc.
// "user-*" // matches "user-abc", "user-xyz", etc.
//
// Exact matching (no wildcard):
//
// "order-123" // matches only "order-123"
//
// An empty ActorPattern matches all actor IDs.
//
// # Combining Filters
//
// When both EventTypes and ActorPattern are specified, an event must match
// both criteria (AND logic):
//
// filter := SubscriptionFilter{
// EventTypes: []string{"OrderPlaced"},
// ActorPattern: "order-*",
// }
// // Only delivers OrderPlaced events from actors starting with "order-"
type SubscriptionFilter struct {
// EventTypes limits delivery to events with matching EventType.
// Empty slice means all event types are accepted.
EventTypes []string
// ActorPattern matches against event ActorID.
// Supports prefix matching with "*" suffix (e.g., "order-*").
// Empty string matches all actor IDs.
ActorPattern string
}
// Matches returns true if the event passes all filter criteria.
// An empty filter (no event types, no actor pattern) matches all events.
func (f SubscriptionFilter) Matches(event *Event) bool {
if event == nil {
return false
}
// Check event type filter
if len(f.EventTypes) > 0 {
found := false
for _, et := range f.EventTypes {
if event.EventType == et {
found = true
break
}
}
if !found {
return false
}
}
// Check actor pattern filter
if f.ActorPattern != "" {
if strings.HasSuffix(f.ActorPattern, "*") {
// Prefix matching
prefix := strings.TrimSuffix(f.ActorPattern, "*")
if !strings.HasPrefix(event.ActorID, prefix) {
return false
}
} else {
// Exact matching
if event.ActorID != f.ActorPattern {
return false
}
}
}
return true
}
// EventBroadcaster defines the interface for publishing and subscribing to events
type EventBroadcaster interface {
Subscribe(namespaceID string) <-chan *Event
SubscribeWithFilter(namespaceID string, filter SubscriptionFilter) <-chan *Event
Unsubscribe(namespaceID string, ch <-chan *Event)
Publish(namespaceID string, event *Event)
Stop()
SubscriberCount(namespaceID string) int
}
// filteredSubscriber holds a subscriber channel and its filter
type filteredSubscriber struct {
ch chan *Event
filter SubscriptionFilter
}
// EventBus broadcasts events to multiple subscribers within a namespace
type EventBus struct {
subscribers map[string][]chan *Event // namespaceID -> channels
subscribers map[string][]filteredSubscriber // namespaceID -> filtered subscribers
mutex sync.RWMutex
ctx context.Context
cancel context.CancelFunc
@@ -26,20 +124,31 @@ type EventBus struct {
func NewEventBus() *EventBus {
ctx, cancel := context.WithCancel(context.Background())
return &EventBus{
subscribers: make(map[string][]chan *Event),
subscribers: make(map[string][]filteredSubscriber),
ctx: ctx,
cancel: cancel,
}
}
// Subscribe creates a new subscription channel for a namespace
// Subscribe creates a new subscription channel for a namespace.
// All events published to the namespace will be delivered.
func (eb *EventBus) Subscribe(namespaceID string) <-chan *Event {
return eb.SubscribeWithFilter(namespaceID, SubscriptionFilter{})
}
// SubscribeWithFilter creates a new subscription channel for a namespace with filtering.
// Only events matching the filter criteria will be delivered.
func (eb *EventBus) SubscribeWithFilter(namespaceID string, filter SubscriptionFilter) <-chan *Event {
eb.mutex.Lock()
defer eb.mutex.Unlock()
// Create buffered channel to prevent blocking publishers
ch := make(chan *Event, 100)
eb.subscribers[namespaceID] = append(eb.subscribers[namespaceID], ch)
sub := filteredSubscriber{
ch: ch,
filter: filter,
}
eb.subscribers[namespaceID] = append(eb.subscribers[namespaceID], sub)
return ch
}
@@ -51,10 +160,10 @@ func (eb *EventBus) Unsubscribe(namespaceID string, ch <-chan *Event) {
subs := eb.subscribers[namespaceID]
for i, subscriber := range subs {
if subscriber == ch {
// Remove channel from slice
if subscriber.ch == ch {
// Remove subscriber from slice
eb.subscribers[namespaceID] = append(subs[:i], subs[i+1:]...)
close(subscriber)
close(subscriber.ch)
break
}
}
@@ -65,15 +174,20 @@ func (eb *EventBus) Unsubscribe(namespaceID string, ch <-chan *Event) {
}
}
// Publish sends an event to all subscribers of a namespace
// Publish sends an event to all subscribers of a namespace whose filters match
func (eb *EventBus) Publish(namespaceID string, event *Event) {
eb.mutex.RLock()
defer eb.mutex.RUnlock()
subscribers := eb.subscribers[namespaceID]
for _, ch := range subscribers {
for _, sub := range subscribers {
// Apply filter before delivering
if !sub.filter.Matches(event) {
continue
}
select {
case ch <- event:
case sub.ch <- event:
// Event delivered
default:
// Channel full, skip this subscriber (non-blocking)
@@ -90,12 +204,12 @@ func (eb *EventBus) Stop() {
// Close all subscriber channels
for _, subs := range eb.subscribers {
for _, ch := range subs {
close(ch)
for _, sub := range subs {
close(sub.ch)
}
}
eb.subscribers = make(map[string][]chan *Event)
eb.subscribers = make(map[string][]filteredSubscriber)
}
// SubscriberCount returns the number of subscribers for a namespace