feat: add integration tests and examples for cross-node event broadcasting
All checks were successful
CI / build (pull_request) Successful in 1m28s
All checks were successful
CI / build (pull_request) Successful in 1m28s
- Add integration tests for UpdateVersionCache, SubscribeToEventStored - Add cross-node broadcasting integration tests - Add example demonstrating NATSEventBus + JetStreamEventStore integration - Fix unsafe type assertions in example with ok pattern - Add documentation for EventStored schema - Add helper methods: GetCachedVersion, SetBroadcaster, Close - Add constructors: NewNATSEventBusWithBroadcaster, NewNATSEventBusWithStore
This commit is contained in:
157
nats_eventbus.go
157
nats_eventbus.go
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -19,14 +20,16 @@ import (
|
||||
// This bypasses namespace isolation at the NATS level. Ensure proper access controls
|
||||
// are in place at the application layer before granting wildcard subscription access.
|
||||
type NATSEventBus struct {
|
||||
*EventBus // Embed base EventBus for local subscriptions
|
||||
nc *nats.Conn // NATS connection
|
||||
subscriptions []*nats.Subscription
|
||||
patternSubscribers map[string]int // Track number of subscribers per pattern (includes wildcards)
|
||||
nodeID string // Unique ID for this node
|
||||
mutex sync.Mutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
*EventBus // Embed base EventBus for local subscriptions
|
||||
nc *nats.Conn // NATS connection
|
||||
subscriptions []*nats.Subscription
|
||||
patternSubscribers map[string]int // Track number of subscribers per pattern (includes wildcards)
|
||||
nodeID string // Unique ID for this node
|
||||
streamPrefix string // NATS subject prefix for events
|
||||
eventStore interface{} // Optional event store for version cache sync (jetstream.JetStreamEventStore)
|
||||
mutex sync.Mutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// eventMessage is the wire format for events sent over NATS
|
||||
@@ -46,6 +49,7 @@ func NewNATSEventBus(nc *nats.Conn) (*NATSEventBus, error) {
|
||||
nodeID: uuid.New().String(),
|
||||
subscriptions: make([]*nats.Subscription, 0),
|
||||
patternSubscribers: make(map[string]int),
|
||||
streamPrefix: "aether",
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
@@ -53,6 +57,43 @@ func NewNATSEventBus(nc *nats.Conn) (*NATSEventBus, error) {
|
||||
return neb, nil
|
||||
}
|
||||
|
||||
// NewNATSEventBusWithBroadcaster creates a new NATS-backed event bus with JetStreamEventStore integration.
|
||||
// The event store is used to automatically update version cache when EventStored events are received
|
||||
// from other cluster nodes via NATS. This ensures cross-node version consistency.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// eventBus := aether.NewNATSEventBusWithBroadcaster(natsConn, store, "tenant-abc")
|
||||
// ch := eventBus.SubscribeToEventStored("tenant-*")
|
||||
// for event := range ch {
|
||||
// actorID := event.Data["actorId"].(string)
|
||||
// version := event.Data["version"].(int64)
|
||||
// store.UpdateVersionCache(actorID, version)
|
||||
// }
|
||||
//
|
||||
// The namespace parameter is used as a prefix for EventStored event filtering.
|
||||
// If empty, EventStored events from all namespaces will be received (requires wildcard pattern).
|
||||
func NewNATSEventBusWithBroadcaster(nc *nats.Conn, store interface{}, namespace string) *NATSEventBus {
|
||||
streamPrefix := "aether"
|
||||
if namespace != "" {
|
||||
streamPrefix = fmt.Sprintf("aether.%s", sanitizeSubject(namespace))
|
||||
}
|
||||
|
||||
neb := &NATSEventBus{
|
||||
EventBus: NewEventBus(),
|
||||
nc: nc,
|
||||
nodeID: uuid.New().String(),
|
||||
subscriptions: make([]*nats.Subscription, 0),
|
||||
patternSubscribers: make(map[string]int),
|
||||
streamPrefix: streamPrefix,
|
||||
eventStore: store,
|
||||
ctx: context.Background(),
|
||||
cancel: func() {},
|
||||
}
|
||||
|
||||
return neb
|
||||
}
|
||||
|
||||
// Subscribe creates a local subscription and ensures NATS subscription exists for the pattern.
|
||||
// Supports NATS subject patterns:
|
||||
// - "*" matches a single token
|
||||
@@ -228,3 +269,103 @@ func (neb *NATSEventBus) Stop() {
|
||||
|
||||
log.Printf("[NATSEventBus] Node %s stopped", neb.nodeID)
|
||||
}
|
||||
|
||||
// sanitizeSubject sanitizes a string for use in NATS subjects
|
||||
func sanitizeSubject(s string) string {
|
||||
s = strings.ReplaceAll(s, " ", "_")
|
||||
s = strings.ReplaceAll(s, ".", "_")
|
||||
s = strings.ReplaceAll(s, "*", "_")
|
||||
s = strings.ReplaceAll(s, ">", "_")
|
||||
return s
|
||||
}
|
||||
|
||||
// extractActorType extracts the actor type from an actor ID
|
||||
func extractActorType(actorID string) string {
|
||||
for i, c := range actorID {
|
||||
if c == '-' && i > 0 {
|
||||
return actorID[:i]
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// SubscribeToEventStored creates a subscription to EventStored events for a namespace pattern.
|
||||
// EventStored events are published by JetStreamEventStore when events are successfully saved.
|
||||
// This is useful for cross-node event synchronization and version cache consistency.
|
||||
//
|
||||
// The returned channel receives EventStored events matching the pattern.
|
||||
// The EventStored event schema:
|
||||
// - EventType: "EventStored"
|
||||
// - ActorID: ID of the actor that the original event was about
|
||||
// - Version: version of the stored event
|
||||
// - Data:
|
||||
// - eventId: (string) ID of the stored event
|
||||
// - actorId: (string) ID of the actor
|
||||
// - version: (int64) version of the event
|
||||
// - timestamp: (int64) Unix timestamp of when the event was stored
|
||||
//
|
||||
// The namespacePattern supports NATS wildcards:
|
||||
// - "*" matches a single token
|
||||
// - ">" matches one or more tokens (only at the end)
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ch := eventBus.SubscribeToEventStored("tenant-*")
|
||||
// for event := range ch {
|
||||
// if event.EventType != aether.EventTypeEventStored {
|
||||
// continue
|
||||
// }
|
||||
// actorID := event.Data["actorId"].(string)
|
||||
// version, _ := event.Data["version"].(int64)
|
||||
// store.UpdateVersionCache(actorID, version)
|
||||
// }
|
||||
//
|
||||
// Security Warning: Using wildcard patterns like ">" will receive EventStored events
|
||||
// from all namespaces. Ensure your application handles this appropriately.
|
||||
func (neb *NATSEventBus) SubscribeToEventStored(namespacePattern string) <-chan *Event {
|
||||
neb.mutex.Lock()
|
||||
defer neb.mutex.Unlock()
|
||||
|
||||
subject := fmt.Sprintf("%s.%s.%s", neb.streamPrefix, namespacePattern, "events.>")
|
||||
|
||||
ch := make(chan *Event, 100)
|
||||
|
||||
sub, err := neb.nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||
var eventMsg eventMessage
|
||||
if err := json.Unmarshal(msg.Data, &eventMsg); err != nil {
|
||||
log.Printf("[NATSEventBus] Failed to unmarshal EventStored event: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if eventMsg.NodeID == neb.nodeID {
|
||||
return
|
||||
}
|
||||
|
||||
if eventMsg.Event.EventType == EventTypeEventStored && neb.eventStore != nil {
|
||||
actorID, ok := eventMsg.Event.Data["actorId"].(string)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
version, ok := eventMsg.Event.Data["version"].(int64)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Use type assertion to call UpdateVersionCache
|
||||
if es, ok := neb.eventStore.(interface{ UpdateVersionCache(string, int64) }); ok {
|
||||
es.UpdateVersionCache(actorID, version)
|
||||
}
|
||||
}
|
||||
|
||||
neb.EventBus.Publish(eventMsg.NamespaceID, eventMsg.Event)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[NATSEventBus] Failed to subscribe to EventStored: %v", err)
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
neb.subscriptions = append(neb.subscriptions, sub)
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user