Add namespace event filtering (SubscribeWithFilter)
All checks were successful
CI / build (pull_request) Successful in 19s
CI / build (push) Successful in 39s

Adds support for filtering events by type or actor pattern within namespace
subscriptions. Key changes:

- Add SubscriptionFilter type with EventTypes and ActorPattern fields
- Add SubscribeWithFilter to EventBroadcaster interface
- Implement filtering in EventBus with full wildcard pattern support preserved
- Implement filtering in NATSEventBus (server-side namespace, client-side filters)
- Add MatchActorPattern function for actor ID pattern matching
- Add comprehensive unit tests for all filtering scenarios

Filter Processing:
- EventTypes: Event must match at least one type in the list (OR within types)
- ActorPattern: Event's ActorID must match the pattern (supports * and > wildcards)
- Multiple filters are combined with AND logic

This implementation works alongside the existing wildcard subscription support:
- Namespace wildcards (* and >) work with event filters
- Filters are applied after namespace pattern matching
- Metrics are properly recorded for filtered subscriptions

Closes #21

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit was merged in pull request #54.
This commit is contained in:
2026-01-10 23:45:57 +01:00
parent e3dbe3d52d
commit ef73fb6bfd
5 changed files with 750 additions and 38 deletions

View File

@@ -414,3 +414,409 @@ func TestEventBus_ConcurrentOperations(t *testing.T) {
wg.Wait()
}
// Tests for SubscribeWithFilter functionality
func TestEventBus_SubscribeWithFilter_EventTypes(t *testing.T) {
eb := NewEventBus()
defer eb.Stop()
// Subscribe with filter for specific event types
filter := &SubscriptionFilter{
EventTypes: []string{"OrderPlaced", "OrderShipped"},
}
ch := eb.SubscribeWithFilter("orders", filter)
// Publish events of different types
events := []*Event{
{ID: "evt-1", EventType: "OrderPlaced", ActorID: "order-1"},
{ID: "evt-2", EventType: "OrderCancelled", ActorID: "order-2"}, // Should not be received
{ID: "evt-3", EventType: "OrderShipped", ActorID: "order-3"},
}
for _, e := range events {
eb.Publish("orders", e)
}
// Should receive evt-1 and evt-3, but not evt-2
received := make(map[string]bool)
timeout := time.After(100 * time.Millisecond)
for i := 0; i < 2; i++ {
select {
case evt := <-ch:
received[evt.ID] = true
case <-timeout:
t.Fatalf("timed out after receiving %d events", len(received))
}
}
if !received["evt-1"] || !received["evt-3"] {
t.Errorf("expected to receive evt-1 and evt-3, got %v", received)
}
// Verify evt-2 was not received
select {
case evt := <-ch:
t.Errorf("unexpected event received: %s", evt.ID)
case <-time.After(50 * time.Millisecond):
// Expected
}
}
func TestEventBus_SubscribeWithFilter_ActorPattern(t *testing.T) {
eb := NewEventBus()
defer eb.Stop()
// Subscribe with filter for specific actor pattern
filter := &SubscriptionFilter{
ActorPattern: "order-*",
}
ch := eb.SubscribeWithFilter("events", filter)
// Publish events from different actors
events := []*Event{
{ID: "evt-1", EventType: "Test", ActorID: "order-123"},
{ID: "evt-2", EventType: "Test", ActorID: "user-456"}, // Should not be received
{ID: "evt-3", EventType: "Test", ActorID: "order-789"},
}
for _, e := range events {
eb.Publish("events", e)
}
// Should receive evt-1 and evt-3, but not evt-2
received := make(map[string]bool)
timeout := time.After(100 * time.Millisecond)
for i := 0; i < 2; i++ {
select {
case evt := <-ch:
received[evt.ID] = true
case <-timeout:
t.Fatalf("timed out after receiving %d events", len(received))
}
}
if !received["evt-1"] || !received["evt-3"] {
t.Errorf("expected to receive evt-1 and evt-3, got %v", received)
}
// Verify evt-2 was not received
select {
case evt := <-ch:
t.Errorf("unexpected event received: %s", evt.ID)
case <-time.After(50 * time.Millisecond):
// Expected
}
}
func TestEventBus_SubscribeWithFilter_Combined(t *testing.T) {
eb := NewEventBus()
defer eb.Stop()
// Subscribe with filter for both event type AND actor pattern
filter := &SubscriptionFilter{
EventTypes: []string{"OrderPlaced"},
ActorPattern: "order-*",
}
ch := eb.SubscribeWithFilter("orders", filter)
// Publish events with various combinations
events := []*Event{
{ID: "evt-1", EventType: "OrderPlaced", ActorID: "order-123"}, // Should be received
{ID: "evt-2", EventType: "OrderPlaced", ActorID: "user-456"}, // Wrong actor
{ID: "evt-3", EventType: "OrderCancelled", ActorID: "order-789"}, // Wrong type
{ID: "evt-4", EventType: "OrderCancelled", ActorID: "user-000"}, // Wrong both
}
for _, e := range events {
eb.Publish("orders", e)
}
// Should only receive evt-1
select {
case evt := <-ch:
if evt.ID != "evt-1" {
t.Errorf("expected evt-1, got %s", evt.ID)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("timed out waiting for event")
}
// Verify no more events arrive
select {
case evt := <-ch:
t.Errorf("unexpected event received: %s", evt.ID)
case <-time.After(50 * time.Millisecond):
// Expected
}
}
func TestEventBus_SubscribeWithFilter_NilFilter(t *testing.T) {
eb := NewEventBus()
defer eb.Stop()
// Subscribe with nil filter - should receive all events
ch := eb.SubscribeWithFilter("events", nil)
events := []*Event{
{ID: "evt-1", EventType: "TypeA", ActorID: "actor-1"},
{ID: "evt-2", EventType: "TypeB", ActorID: "actor-2"},
}
for _, e := range events {
eb.Publish("events", e)
}
received := make(map[string]bool)
timeout := time.After(100 * time.Millisecond)
for i := 0; i < 2; i++ {
select {
case evt := <-ch:
received[evt.ID] = true
case <-timeout:
t.Fatalf("timed out after receiving %d events", len(received))
}
}
if !received["evt-1"] || !received["evt-2"] {
t.Errorf("expected all events, got %v", received)
}
}
func TestEventBus_SubscribeWithFilter_EmptyFilter(t *testing.T) {
eb := NewEventBus()
defer eb.Stop()
// Subscribe with empty filter - should receive all events
ch := eb.SubscribeWithFilter("events", &SubscriptionFilter{})
events := []*Event{
{ID: "evt-1", EventType: "TypeA", ActorID: "actor-1"},
{ID: "evt-2", EventType: "TypeB", ActorID: "actor-2"},
}
for _, e := range events {
eb.Publish("events", e)
}
received := make(map[string]bool)
timeout := time.After(100 * time.Millisecond)
for i := 0; i < 2; i++ {
select {
case evt := <-ch:
received[evt.ID] = true
case <-timeout:
t.Fatalf("timed out after receiving %d events", len(received))
}
}
if !received["evt-1"] || !received["evt-2"] {
t.Errorf("expected all events, got %v", received)
}
}
func TestEventBus_SubscribeWithFilter_WildcardNamespaceAndFilter(t *testing.T) {
eb := NewEventBus()
defer eb.Stop()
// Subscribe to wildcard namespace pattern with event type filter
filter := &SubscriptionFilter{
EventTypes: []string{"OrderPlaced"},
}
ch := eb.SubscribeWithFilter("prod.*", filter)
// Publish events to different namespaces
events := []*Event{
{ID: "evt-1", EventType: "OrderPlaced", ActorID: "order-1"}, // prod.orders - should match
{ID: "evt-2", EventType: "OrderShipped", ActorID: "order-2"}, // prod.orders - wrong type
{ID: "evt-3", EventType: "OrderPlaced", ActorID: "order-3"}, // staging.orders - wrong namespace
}
eb.Publish("prod.orders", events[0])
eb.Publish("prod.orders", events[1])
eb.Publish("staging.orders", events[2])
// Should only receive evt-1
select {
case evt := <-ch:
if evt.ID != "evt-1" {
t.Errorf("expected evt-1, got %s", evt.ID)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("timed out waiting for event")
}
// Verify no more events arrive
select {
case evt := <-ch:
t.Errorf("unexpected event received: %s", evt.ID)
case <-time.After(50 * time.Millisecond):
// Expected
}
}
func TestEventBus_SubscribeWithFilter_MultipleSubscribersWithDifferentFilters(t *testing.T) {
eb := NewEventBus()
defer eb.Stop()
// Two subscribers with different filters on same namespace
filter1 := &SubscriptionFilter{EventTypes: []string{"OrderPlaced"}}
filter2 := &SubscriptionFilter{EventTypes: []string{"OrderShipped"}}
ch1 := eb.SubscribeWithFilter("orders", filter1)
ch2 := eb.SubscribeWithFilter("orders", filter2)
events := []*Event{
{ID: "evt-1", EventType: "OrderPlaced", ActorID: "order-1"},
{ID: "evt-2", EventType: "OrderShipped", ActorID: "order-2"},
}
for _, e := range events {
eb.Publish("orders", e)
}
// ch1 should only receive evt-1
select {
case evt := <-ch1:
if evt.ID != "evt-1" {
t.Errorf("ch1: expected evt-1, got %s", evt.ID)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("ch1 timed out")
}
// ch2 should only receive evt-2
select {
case evt := <-ch2:
if evt.ID != "evt-2" {
t.Errorf("ch2: expected evt-2, got %s", evt.ID)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("ch2 timed out")
}
// Verify no extra events
select {
case evt := <-ch1:
t.Errorf("ch1: unexpected event %s", evt.ID)
case evt := <-ch2:
t.Errorf("ch2: unexpected event %s", evt.ID)
case <-time.After(50 * time.Millisecond):
// Expected
}
}
func TestEventBus_SubscribeWithFilter_UnsubscribeFiltered(t *testing.T) {
eb := NewEventBus()
defer eb.Stop()
filter := &SubscriptionFilter{EventTypes: []string{"OrderPlaced"}}
ch := eb.SubscribeWithFilter("orders", filter)
// Verify subscription count
if eb.SubscriberCount("orders") != 1 {
t.Errorf("expected 1 subscriber, got %d", eb.SubscriberCount("orders"))
}
eb.Unsubscribe("orders", ch)
// Verify unsubscribed
if eb.SubscriberCount("orders") != 0 {
t.Errorf("expected 0 subscribers, got %d", eb.SubscriberCount("orders"))
}
}
func TestEventBus_SubscribeWithFilter_FilteredAndUnfilteredCoexist(t *testing.T) {
eb := NewEventBus()
defer eb.Stop()
// One subscriber with filter, one without
filter := &SubscriptionFilter{EventTypes: []string{"OrderPlaced"}}
chFiltered := eb.SubscribeWithFilter("orders", filter)
chUnfiltered := eb.Subscribe("orders")
events := []*Event{
{ID: "evt-1", EventType: "OrderPlaced", ActorID: "order-1"},
{ID: "evt-2", EventType: "OrderShipped", ActorID: "order-2"},
}
for _, e := range events {
eb.Publish("orders", e)
}
// Filtered subscriber should only receive evt-1
select {
case evt := <-chFiltered:
if evt.ID != "evt-1" {
t.Errorf("filtered: expected evt-1, got %s", evt.ID)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("filtered subscriber timed out")
}
// Unfiltered subscriber should receive both
received := make(map[string]bool)
timeout := time.After(100 * time.Millisecond)
for i := 0; i < 2; i++ {
select {
case evt := <-chUnfiltered:
received[evt.ID] = true
case <-timeout:
t.Fatalf("unfiltered timed out after %d events", len(received))
}
}
if !received["evt-1"] || !received["evt-2"] {
t.Errorf("unfiltered expected both events, got %v", received)
}
}
func TestEventBus_SubscribeWithFilter_WildcardGreaterWithFilter(t *testing.T) {
eb := NewEventBus()
defer eb.Stop()
// Use > wildcard (matches one or more tokens) with filter
filter := &SubscriptionFilter{
ActorPattern: "order-*",
}
ch := eb.SubscribeWithFilter(">", filter)
events := []*Event{
{ID: "evt-1", EventType: "Test", ActorID: "order-123"},
{ID: "evt-2", EventType: "Test", ActorID: "user-456"},
{ID: "evt-3", EventType: "Test", ActorID: "order-789"},
}
// Publish to different namespaces
eb.Publish("tenant-a", events[0])
eb.Publish("tenant-b", events[1])
eb.Publish("prod.orders", events[2])
// Should receive evt-1 and evt-3, but not evt-2
received := make(map[string]bool)
timeout := time.After(100 * time.Millisecond)
for i := 0; i < 2; i++ {
select {
case evt := <-ch:
received[evt.ID] = true
case <-timeout:
t.Fatalf("timed out after %d events", len(received))
}
}
if !received["evt-1"] || !received["evt-3"] {
t.Errorf("expected evt-1 and evt-3, got %v", received)
}
// Verify no evt-2
select {
case evt := <-ch:
t.Errorf("unexpected event: %s", evt.ID)
case <-time.After(50 * time.Millisecond):
// Expected
}
}