chore: import upstream snapshot with attribution
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
// Package events is for event streaming and storage
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// DefaultStream is the default events stream implementation
|
||||
DefaultStream Stream
|
||||
// DefaultStore is the default events store implementation
|
||||
DefaultStore Store
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrMissingTopic is returned if a blank topic was provided to publish
|
||||
ErrMissingTopic = errors.New("missing topic")
|
||||
// ErrEncodingMessage is returned from publish if there was an error encoding the message option
|
||||
ErrEncodingMessage = errors.New("error encoding message")
|
||||
)
|
||||
|
||||
// Stream is an event streaming interface
|
||||
type Stream interface {
|
||||
Publish(topic string, msg interface{}, opts ...PublishOption) error
|
||||
Consume(topic string, opts ...ConsumeOption) (<-chan Event, error)
|
||||
}
|
||||
|
||||
// Store is an event store interface
|
||||
type Store interface {
|
||||
Read(topic string, opts ...ReadOption) ([]*Event, error)
|
||||
Write(event *Event, opts ...WriteOption) error
|
||||
}
|
||||
|
||||
type AckFunc func() error
|
||||
type NackFunc func() error
|
||||
|
||||
// Event is the object returned by the broker when you subscribe to a topic
|
||||
type Event struct {
|
||||
// ID to uniquely identify the event
|
||||
ID string
|
||||
// Topic of event, e.g. "registry.service.created"
|
||||
Topic string
|
||||
// Timestamp of the event
|
||||
Timestamp time.Time
|
||||
// Metadata contains the values the event was indexed by
|
||||
Metadata map[string]string
|
||||
// Payload contains the encoded message
|
||||
Payload []byte
|
||||
|
||||
ackFunc AckFunc
|
||||
nackFunc NackFunc
|
||||
}
|
||||
|
||||
// Unmarshal the events message into an object
|
||||
func (e *Event) Unmarshal(v interface{}) error {
|
||||
return json.Unmarshal(e.Payload, v)
|
||||
}
|
||||
|
||||
// Ack acknowledges successful processing of the event in ManualAck mode
|
||||
func (e *Event) Ack() error {
|
||||
return e.ackFunc()
|
||||
}
|
||||
|
||||
func (e *Event) SetAckFunc(f AckFunc) {
|
||||
e.ackFunc = f
|
||||
}
|
||||
|
||||
// Nack negatively acknowledges processing of the event (i.e. failure) in ManualAck mode
|
||||
func (e *Event) Nack() error {
|
||||
return e.nackFunc()
|
||||
}
|
||||
|
||||
func (e *Event) SetNackFunc(f NackFunc) {
|
||||
e.nackFunc = f
|
||||
}
|
||||
|
||||
// Publish an event to a topic
|
||||
func Publish(topic string, msg interface{}, opts ...PublishOption) error {
|
||||
return DefaultStream.Publish(topic, msg, opts...)
|
||||
}
|
||||
|
||||
// Consume to events
|
||||
func Consume(topic string, opts ...ConsumeOption) (<-chan Event, error) {
|
||||
return DefaultStream.Consume(topic, opts...)
|
||||
}
|
||||
|
||||
// Read events for a topic
|
||||
func Read(topic string, opts ...ReadOption) ([]*Event, error) {
|
||||
return DefaultStore.Read(topic, opts...)
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"go-micro.dev/v6/logger"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
// NewStream returns an initialized memory stream
|
||||
func NewStream(opts ...Option) (Stream, error) {
|
||||
// parse the options
|
||||
var options Options
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
st := options.Store
|
||||
if st == nil {
|
||||
st = store.NewMemoryStore()
|
||||
}
|
||||
return &mem{store: st}, nil
|
||||
}
|
||||
|
||||
type subscriber struct {
|
||||
Group string
|
||||
Topic string
|
||||
Channel chan Event
|
||||
|
||||
sync.RWMutex
|
||||
retryMap map[string]int
|
||||
retryLimit int
|
||||
autoAck bool
|
||||
ackWait time.Duration
|
||||
|
||||
pending []Event
|
||||
notify chan struct{}
|
||||
}
|
||||
|
||||
type mem struct {
|
||||
store store.Store
|
||||
|
||||
subs []*subscriber
|
||||
sync.RWMutex
|
||||
}
|
||||
|
||||
func (m *mem) Publish(topic string, msg interface{}, opts ...PublishOption) error {
|
||||
// validate the topic
|
||||
if len(topic) == 0 {
|
||||
return ErrMissingTopic
|
||||
}
|
||||
|
||||
// parse the options
|
||||
options := PublishOptions{
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
|
||||
// encode the message if it's not already encoded
|
||||
var payload []byte
|
||||
if p, ok := msg.([]byte); ok {
|
||||
payload = p
|
||||
} else {
|
||||
p, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return ErrEncodingMessage
|
||||
}
|
||||
payload = p
|
||||
}
|
||||
|
||||
// construct the event
|
||||
event := &Event{
|
||||
ID: uuid.New().String(),
|
||||
Topic: topic,
|
||||
Timestamp: options.Timestamp,
|
||||
Metadata: options.Metadata,
|
||||
Payload: payload,
|
||||
}
|
||||
|
||||
// serialize the event to bytes
|
||||
bytes, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Error encoding event")
|
||||
}
|
||||
|
||||
// write to the store
|
||||
key := fmt.Sprintf("%v/%v", event.Topic, event.ID)
|
||||
if err := m.store.Write(&store.Record{Key: key, Value: bytes}); err != nil {
|
||||
return errors.Wrap(err, "Error writing event to store")
|
||||
}
|
||||
|
||||
// send to the subscribers async
|
||||
go m.handleEvent(event)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mem) Consume(topic string, opts ...ConsumeOption) (<-chan Event, error) {
|
||||
// validate the topic
|
||||
if len(topic) == 0 {
|
||||
return nil, ErrMissingTopic
|
||||
}
|
||||
|
||||
// parse the options
|
||||
options := ConsumeOptions{
|
||||
Group: uuid.New().String(),
|
||||
AutoAck: true,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
|
||||
// Note: RetryLimit is configured but retry logic is basic for the in-memory implementation.
|
||||
// For production use with advanced retry capabilities, use NATS JetStream.
|
||||
|
||||
// setup the subscriber
|
||||
sub := &subscriber{
|
||||
Channel: make(chan Event),
|
||||
Topic: topic,
|
||||
Group: options.Group,
|
||||
retryMap: map[string]int{},
|
||||
autoAck: true,
|
||||
retryLimit: options.GetRetryLimit(),
|
||||
notify: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
if !options.AutoAck {
|
||||
if options.AckWait == 0 {
|
||||
return nil, fmt.Errorf("invalid AckWait passed, should be positive integer")
|
||||
}
|
||||
sub.autoAck = options.AutoAck
|
||||
sub.ackWait = options.AckWait
|
||||
go sub.dispatchManualAck()
|
||||
}
|
||||
|
||||
// register the subscriber
|
||||
m.Lock()
|
||||
m.subs = append(m.subs, sub)
|
||||
m.Unlock()
|
||||
|
||||
// lookup previous events if the start time option was passed
|
||||
if options.Offset.Unix() > 0 {
|
||||
go m.lookupPreviousEvents(sub, options.Offset)
|
||||
}
|
||||
|
||||
// return the channel
|
||||
return sub.Channel, nil
|
||||
}
|
||||
|
||||
// lookupPreviousEvents finds events for a subscriber which occurred before a given time and sends
|
||||
// them into the subscribers channel
|
||||
func (m *mem) lookupPreviousEvents(sub *subscriber, startTime time.Time) {
|
||||
// lookup all events which match the topic (a blank topic will return all results)
|
||||
recs, err := m.store.Read(sub.Topic+"/", store.ReadPrefix())
|
||||
if err != nil && logger.V(logger.ErrorLevel, logger.DefaultLogger) {
|
||||
logger.Errorf("Error looking up previous events: %v", err)
|
||||
return
|
||||
} else if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// loop through the records and send it to the channel if it matches
|
||||
for _, r := range recs {
|
||||
var ev Event
|
||||
if err := json.Unmarshal(r.Value, &ev); err != nil {
|
||||
continue
|
||||
}
|
||||
if ev.Timestamp.Unix() < startTime.Unix() {
|
||||
continue
|
||||
}
|
||||
sendEvent(&ev, sub)
|
||||
}
|
||||
}
|
||||
|
||||
// handleEvents sends the event to any registered subscribers.
|
||||
func (m *mem) handleEvent(ev *Event) {
|
||||
m.RLock()
|
||||
subs := m.subs
|
||||
m.RUnlock()
|
||||
|
||||
// filteredSubs is a KV map of the queue name and subscribers. This is used to prevent a message
|
||||
// being sent to two subscribers with the same queue.
|
||||
filteredSubs := map[string]*subscriber{}
|
||||
|
||||
// filter down to subscribers who are interested in this topic
|
||||
for _, sub := range subs {
|
||||
if len(sub.Topic) == 0 || sub.Topic == ev.Topic {
|
||||
filteredSubs[sub.Group] = sub
|
||||
}
|
||||
}
|
||||
|
||||
// send the message to each channel async (since one channel might be blocked)
|
||||
for _, sub := range filteredSubs {
|
||||
sendEvent(ev, sub)
|
||||
}
|
||||
}
|
||||
|
||||
func sendEvent(ev *Event, sub *subscriber) {
|
||||
evCopy := *ev
|
||||
if !sub.autoAck {
|
||||
sub.Lock()
|
||||
sub.pending = append(sub.pending, evCopy)
|
||||
sub.Unlock()
|
||||
sub.wake()
|
||||
return
|
||||
}
|
||||
|
||||
go func(s *subscriber) {
|
||||
s.Channel <- evCopy
|
||||
}(sub)
|
||||
}
|
||||
|
||||
func (s *subscriber) wake() {
|
||||
select {
|
||||
case s.notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *subscriber) dispatchManualAck() {
|
||||
for {
|
||||
s.Lock()
|
||||
for len(s.pending) == 0 {
|
||||
s.Unlock()
|
||||
<-s.notify
|
||||
s.Lock()
|
||||
}
|
||||
ev := s.pending[0]
|
||||
s.pending = s.pending[1:]
|
||||
s.retryMap[ev.ID] = 0
|
||||
s.Unlock()
|
||||
|
||||
s.deliverManualAck(ev)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *subscriber) deliverManualAck(ev Event) {
|
||||
retries := 0
|
||||
for {
|
||||
if s.retryLimit > -1 && retries > s.retryLimit {
|
||||
if logger.V(logger.ErrorLevel, logger.DefaultLogger) {
|
||||
logger.Errorf("Message retry limit reached, discarding: %v %d %d", ev.ID, retries, s.retryLimit)
|
||||
}
|
||||
s.Lock()
|
||||
delete(s.retryMap, ev.ID)
|
||||
s.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
result := make(chan bool, 1)
|
||||
evCopy := ev
|
||||
evCopy.SetAckFunc(func() error {
|
||||
select {
|
||||
case result <- true:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
})
|
||||
evCopy.SetNackFunc(func() error {
|
||||
select {
|
||||
case result <- false:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
s.Channel <- evCopy
|
||||
|
||||
timer := time.NewTimer(s.ackWait)
|
||||
select {
|
||||
case acked := <-result:
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
if acked {
|
||||
s.Lock()
|
||||
delete(s.retryMap, ev.ID)
|
||||
s.Unlock()
|
||||
return
|
||||
}
|
||||
retries++
|
||||
case <-timer.C:
|
||||
retries++
|
||||
}
|
||||
|
||||
s.Lock()
|
||||
s.retryMap[ev.ID] = retries
|
||||
s.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# NATS JetStream
|
||||
|
||||
This plugin uses NATS with JetStream to send and receive events.
|
||||
|
||||
## Create a stream
|
||||
|
||||
```go
|
||||
ev, err := natsjs.NewStream(
|
||||
natsjs.Address("nats://10.0.1.46:4222"),
|
||||
natsjs.MaxAge(24*160*time.Minute),
|
||||
)
|
||||
```
|
||||
|
||||
## Consume a stream
|
||||
|
||||
```go
|
||||
ee, err := events.Consume("test",
|
||||
events.WithAutoAck(false, time.Second*30),
|
||||
events.WithGroup("testgroup"),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
msg := <-ee
|
||||
// Process the message
|
||||
logger.Info("Received message:", string(msg.Payload))
|
||||
err := msg.Ack()
|
||||
if err != nil {
|
||||
logger.Error("Error acknowledging message:", err)
|
||||
} else {
|
||||
logger.Info("Message acknowledged")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
```
|
||||
|
||||
## Publish an Event to the stream
|
||||
|
||||
```go
|
||||
err = ev.Publish("test", []byte("hello world"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package natsjs_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
nserver "github.com/nats-io/nats-server/v2/server"
|
||||
)
|
||||
|
||||
func getFreeLocalhostAddress() string {
|
||||
l, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||
defer l.Close()
|
||||
return l.Addr().String()
|
||||
}
|
||||
|
||||
func natsServer(ctx context.Context, t *testing.T, opts *nserver.Options) {
|
||||
t.Helper()
|
||||
|
||||
// Report errors with Errorf (not Fatalf/require), which are safe to
|
||||
// call from this non-test goroutine; Fatalf/FailNow are not.
|
||||
server, err := nserver.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Errorf("nats: new server: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
server.SetLoggerV2(
|
||||
NewLogWrapper(),
|
||||
true, true, false,
|
||||
)
|
||||
|
||||
// first start NATS
|
||||
go server.Start()
|
||||
if !server.ReadyForConnections(time.Second * 10) {
|
||||
t.Errorf("NATS server not ready")
|
||||
return
|
||||
}
|
||||
|
||||
// Manage the JetStream store dir ourselves rather than via t.TempDir.
|
||||
// t.TempDir registers a RemoveAll that runs when the test ends, which
|
||||
// races this goroutine's shutdown — the server can still be releasing
|
||||
// JetStream files, leaving the dir non-empty ("directory not empty").
|
||||
// Remove it here instead, only after the server has fully stopped.
|
||||
storeDir, err := os.MkdirTemp("", "nats-js")
|
||||
if err != nil {
|
||||
t.Errorf("nats: temp dir: %v", err)
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(storeDir)
|
||||
|
||||
// second start JetStream
|
||||
if err := server.EnableJetStream(&nserver.JetStreamConfig{StoreDir: filepath.Join(storeDir, "nats-js")}); err != nil {
|
||||
t.Errorf("nats: enable jetstream: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
server.Shutdown()
|
||||
server.WaitForShutdown()
|
||||
}
|
||||
|
||||
func NewLogWrapper() *LogWrapper {
|
||||
return &LogWrapper{}
|
||||
}
|
||||
|
||||
type LogWrapper struct {
|
||||
}
|
||||
|
||||
// Noticef logs a notice statement.
|
||||
func (l *LogWrapper) Noticef(format string, v ...interface{}) {
|
||||
fmt.Printf(format+"\n", v...)
|
||||
}
|
||||
|
||||
// Warnf logs a warning statement.
|
||||
func (l *LogWrapper) Warnf(format string, v ...interface{}) {
|
||||
fmt.Printf(format+"\n", v...)
|
||||
}
|
||||
|
||||
// Fatalf logs a fatal statement.
|
||||
func (l *LogWrapper) Fatalf(format string, v ...interface{}) {
|
||||
fmt.Printf(format+"\n", v...)
|
||||
}
|
||||
|
||||
// Errorf logs an error statement.
|
||||
func (l *LogWrapper) Errorf(format string, v ...interface{}) {
|
||||
fmt.Printf(format+"\n", v...)
|
||||
}
|
||||
|
||||
// Debugf logs a debug statement.
|
||||
func (l *LogWrapper) Debugf(format string, v ...interface{}) {
|
||||
fmt.Printf(format+"\n", v...)
|
||||
}
|
||||
|
||||
// Tracef logs a trace statement.
|
||||
func (l *LogWrapper) Tracef(format string, v ...interface{}) {
|
||||
fmt.Printf(format+"\n", v...)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// Package natsjs provides a NATS Jetstream implementation of the events.Stream interface.
|
||||
package natsjs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
nats "github.com/nats-io/nats.go"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"go-micro.dev/v6/events"
|
||||
"go-micro.dev/v6/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultClusterID = "micro"
|
||||
)
|
||||
|
||||
// NewStream returns an initialized nats stream or an error if the connection to the nats
|
||||
// server could not be established.
|
||||
func NewStream(opts ...Option) (events.Stream, error) {
|
||||
// parse the options
|
||||
options := Options{
|
||||
ClientID: uuid.New().String(),
|
||||
ClusterID: defaultClusterID,
|
||||
Logger: logger.DefaultLogger,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
|
||||
s := &stream{opts: options}
|
||||
|
||||
conn, natsJetStreamCtx, err := connectToNatsJetStream(options)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error connecting to nats cluster %v: %w", options.ClusterID, err)
|
||||
}
|
||||
|
||||
s.conn = conn
|
||||
s.natsJetStreamCtx = natsJetStreamCtx
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
type stream struct {
|
||||
opts Options
|
||||
conn *nats.Conn // store connection for lifecycle management
|
||||
natsJetStreamCtx nats.JetStreamContext
|
||||
}
|
||||
|
||||
func connectToNatsJetStream(options Options) (*nats.Conn, nats.JetStreamContext, error) {
|
||||
nopts := nats.GetDefaultOptions()
|
||||
if options.TLSConfig != nil {
|
||||
nopts.Secure = true
|
||||
nopts.TLSConfig = options.TLSConfig
|
||||
}
|
||||
|
||||
if options.NkeyConfig != "" {
|
||||
nopts.Nkey = options.NkeyConfig
|
||||
}
|
||||
|
||||
if len(options.Address) > 0 {
|
||||
nopts.Servers = strings.Split(options.Address, ",")
|
||||
}
|
||||
|
||||
if options.Name != "" {
|
||||
nopts.Name = options.Name
|
||||
}
|
||||
|
||||
if options.Username != "" && options.Password != "" {
|
||||
nopts.User = options.Username
|
||||
nopts.Password = options.Password
|
||||
}
|
||||
|
||||
conn, err := nopts.Connect()
|
||||
if err != nil {
|
||||
tls := nopts.TLSConfig != nil
|
||||
return nil, nil, fmt.Errorf("error connecting to nats at %v with tls enabled (%v): %w", options.Address, tls, err)
|
||||
}
|
||||
|
||||
js, err := conn.JetStream()
|
||||
if err != nil {
|
||||
conn.Close() // Close connection if JetStream context fails
|
||||
return nil, nil, fmt.Errorf("error while obtaining JetStream context: %w", err)
|
||||
}
|
||||
|
||||
return conn, js, nil
|
||||
}
|
||||
|
||||
// Publish a message to a topic.
|
||||
func (s *stream) Publish(topic string, msg interface{}, opts ...events.PublishOption) error {
|
||||
// validate the topic
|
||||
if len(topic) == 0 {
|
||||
return events.ErrMissingTopic
|
||||
}
|
||||
|
||||
// parse the options
|
||||
options := events.PublishOptions{
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
|
||||
// encode the message if it's not already encoded
|
||||
var payload []byte
|
||||
if p, ok := msg.([]byte); ok {
|
||||
payload = p
|
||||
} else {
|
||||
p, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return events.ErrEncodingMessage
|
||||
}
|
||||
payload = p
|
||||
}
|
||||
|
||||
// construct the event
|
||||
event := &events.Event{
|
||||
ID: uuid.New().String(),
|
||||
Topic: topic,
|
||||
Timestamp: options.Timestamp,
|
||||
Metadata: options.Metadata,
|
||||
Payload: payload,
|
||||
}
|
||||
|
||||
// serialize the event to bytes
|
||||
bytes, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Error encoding event")
|
||||
}
|
||||
|
||||
// publish the event to the topic's channel
|
||||
// publish synchronously if configured
|
||||
if s.opts.SyncPublish {
|
||||
_, err := s.natsJetStreamCtx.Publish(event.Topic, bytes)
|
||||
if err != nil {
|
||||
err = errors.Wrap(err, "Error publishing message to topic")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// publish asynchronously by default
|
||||
if _, err := s.natsJetStreamCtx.PublishAsync(event.Topic, bytes); err != nil {
|
||||
return errors.Wrap(err, "Error publishing message to topic")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Consume from a topic.
|
||||
func (s *stream) Consume(topic string, opts ...events.ConsumeOption) (<-chan events.Event, error) {
|
||||
// validate the topic
|
||||
if len(topic) == 0 {
|
||||
return nil, events.ErrMissingTopic
|
||||
}
|
||||
|
||||
log := s.opts.Logger
|
||||
|
||||
// parse the options
|
||||
options := events.ConsumeOptions{
|
||||
Group: uuid.New().String(),
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
|
||||
// setup the subscriber
|
||||
channel := make(chan events.Event)
|
||||
handleMsg := func(msg *nats.Msg) {
|
||||
ctx, cancel := context.WithCancel(context.TODO())
|
||||
defer cancel()
|
||||
|
||||
// decode the message
|
||||
var evt events.Event
|
||||
if err := json.Unmarshal(msg.Data, &evt); err != nil {
|
||||
log.Logf(logger.ErrorLevel, "Error decoding message: %v", err)
|
||||
// not acknowledging the message is the way to indicate an error occurred
|
||||
return
|
||||
}
|
||||
if options.AutoAck {
|
||||
// set up the ack funcs
|
||||
evt.SetAckFunc(func() error {
|
||||
return msg.Ack()
|
||||
})
|
||||
|
||||
evt.SetNackFunc(func() error {
|
||||
return msg.Nak()
|
||||
})
|
||||
} else {
|
||||
// set up the ack funcs
|
||||
evt.SetAckFunc(func() error {
|
||||
return nil
|
||||
})
|
||||
evt.SetNackFunc(func() error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// push onto the channel and wait for the consumer to take the event off before we acknowledge it.
|
||||
channel <- evt
|
||||
|
||||
if !options.AutoAck {
|
||||
return
|
||||
}
|
||||
|
||||
if err := msg.Ack(nats.Context(ctx)); err != nil {
|
||||
|
||||
log.Logf(logger.ErrorLevel, "Error acknowledging message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ensure that a stream exists for that topic
|
||||
_, err := s.natsJetStreamCtx.StreamInfo(topic)
|
||||
if err != nil {
|
||||
cfg := &nats.StreamConfig{
|
||||
Name: topic,
|
||||
}
|
||||
if s.opts.RetentionPolicy != 0 {
|
||||
cfg.Retention = nats.RetentionPolicy(s.opts.RetentionPolicy)
|
||||
}
|
||||
if s.opts.MaxAge > 0 {
|
||||
cfg.MaxAge = s.opts.MaxAge
|
||||
}
|
||||
|
||||
_, err = s.natsJetStreamCtx.AddStream(cfg)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Stream did not exist and adding a stream failed")
|
||||
}
|
||||
}
|
||||
|
||||
// setup the options
|
||||
subOpts := []nats.SubOpt{}
|
||||
|
||||
if options.CustomRetries {
|
||||
subOpts = append(subOpts, nats.MaxDeliver(options.GetRetryLimit()))
|
||||
}
|
||||
|
||||
if options.AutoAck {
|
||||
subOpts = append(subOpts, nats.AckAll())
|
||||
} else {
|
||||
subOpts = append(subOpts, nats.AckExplicit())
|
||||
}
|
||||
|
||||
if !options.Offset.IsZero() {
|
||||
subOpts = append(subOpts, nats.StartTime(options.Offset))
|
||||
} else {
|
||||
subOpts = append(subOpts, nats.DeliverNew())
|
||||
}
|
||||
|
||||
if options.AckWait > 0 {
|
||||
subOpts = append(subOpts, nats.AckWait(options.AckWait))
|
||||
}
|
||||
|
||||
// connect the subscriber via a queue group only if durable streams are enabled
|
||||
if !s.opts.DisableDurableStreams {
|
||||
subOpts = append(subOpts, nats.Durable(options.Group))
|
||||
_, err = s.natsJetStreamCtx.QueueSubscribe(topic, options.Group, handleMsg, subOpts...)
|
||||
} else {
|
||||
subOpts = append(subOpts, nats.ConsumerName(options.Group))
|
||||
_, err = s.natsJetStreamCtx.Subscribe(topic, handleMsg, subOpts...)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Error subscribing to topic")
|
||||
}
|
||||
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
// Close implements io.Closer and closes the underlying NATS connection.
|
||||
// This method is optional but recommended to prevent connection leaks.
|
||||
func (s *stream) Close() error {
|
||||
if s.conn != nil {
|
||||
s.conn.Close()
|
||||
s.conn = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure stream implements io.Closer
|
||||
var _ io.Closer = (*stream)(nil)
|
||||
@@ -0,0 +1,112 @@
|
||||
package natsjs_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
nserver "github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/test-go/testify/require"
|
||||
"go-micro.dev/v6/events"
|
||||
"go-micro.dev/v6/events/natsjs"
|
||||
)
|
||||
|
||||
type Payload struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func TestSingleEvent(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.TODO())
|
||||
defer cancel()
|
||||
|
||||
// variables
|
||||
demoPayload := Payload{
|
||||
ID: "123",
|
||||
Name: "Hello World",
|
||||
}
|
||||
topic := "foobar"
|
||||
|
||||
clusterName := "test-cluster"
|
||||
|
||||
natsAddr := getFreeLocalhostAddress()
|
||||
natsPort, _ := strconv.Atoi(strings.Split(natsAddr, ":")[1])
|
||||
|
||||
// start the NATS with JetStream server
|
||||
go natsServer(ctx,
|
||||
t,
|
||||
&nserver.Options{
|
||||
Host: strings.Split(natsAddr, ":")[0],
|
||||
Port: natsPort,
|
||||
Cluster: nserver.ClusterOpts{
|
||||
Name: clusterName,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// consumer
|
||||
consumerClient, err := natsjs.NewStream(
|
||||
natsjs.Address(natsAddr),
|
||||
natsjs.ClusterID(clusterName),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
consumer := func(_ context.Context, t *testing.T, client events.Stream, cancel context.CancelFunc) {
|
||||
t.Helper()
|
||||
defer cancel()
|
||||
|
||||
foobarEvents, err := client.Consume(topic)
|
||||
require.Nil(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// wait for the event
|
||||
event := <-foobarEvents
|
||||
|
||||
p := Payload{}
|
||||
err = json.Unmarshal(event.Payload, &p)
|
||||
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, demoPayload.ID, p.ID)
|
||||
assert.Equal(t, demoPayload.Name, p.Name)
|
||||
}
|
||||
|
||||
go consumer(ctx, t, consumerClient, cancel)
|
||||
|
||||
// publisher
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
publisherClient, err := natsjs.NewStream(
|
||||
natsjs.Address(natsAddr),
|
||||
natsjs.ClusterID(clusterName),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
publisher := func(_ context.Context, t *testing.T, client events.Stream) {
|
||||
t.Helper()
|
||||
err := client.Publish(topic, demoPayload)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
go publisher(ctx, t, publisherClient)
|
||||
|
||||
// wait until consumer received the event
|
||||
<-ctx.Done()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package natsjs
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/logger"
|
||||
)
|
||||
|
||||
// Options which are used to configure the nats stream.
|
||||
type Options struct {
|
||||
ClusterID string
|
||||
ClientID string
|
||||
Address string
|
||||
NkeyConfig string
|
||||
TLSConfig *tls.Config
|
||||
Logger logger.Logger
|
||||
SyncPublish bool
|
||||
Name string
|
||||
DisableDurableStreams bool
|
||||
Username string
|
||||
Password string
|
||||
RetentionPolicy int
|
||||
MaxAge time.Duration
|
||||
MaxMsgSize int
|
||||
}
|
||||
|
||||
// Option is a function which configures options.
|
||||
type Option func(o *Options)
|
||||
|
||||
// ClusterID sets the cluster id for the nats connection.
|
||||
func ClusterID(id string) Option {
|
||||
return func(o *Options) {
|
||||
o.ClusterID = id
|
||||
}
|
||||
}
|
||||
|
||||
// ClientID sets the client id for the nats connection.
|
||||
func ClientID(id string) Option {
|
||||
return func(o *Options) {
|
||||
o.ClientID = id
|
||||
}
|
||||
}
|
||||
|
||||
// Address of the nats cluster.
|
||||
func Address(addr string) Option {
|
||||
return func(o *Options) {
|
||||
o.Address = addr
|
||||
}
|
||||
}
|
||||
|
||||
// TLSConfig to use when connecting to the cluster.
|
||||
func TLSConfig(t *tls.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.TLSConfig = t
|
||||
}
|
||||
}
|
||||
|
||||
// NkeyConfig string to use when connecting to the cluster.
|
||||
func NkeyConfig(nkey string) Option {
|
||||
return func(o *Options) {
|
||||
o.NkeyConfig = nkey
|
||||
}
|
||||
}
|
||||
|
||||
// Logger sets the underlying logger.
|
||||
func Logger(log logger.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = log
|
||||
}
|
||||
}
|
||||
|
||||
// SynchronousPublish allows using a synchronous publishing instead of the default asynchronous.
|
||||
func SynchronousPublish(sync bool) Option {
|
||||
return func(o *Options) {
|
||||
o.SyncPublish = sync
|
||||
}
|
||||
}
|
||||
|
||||
// Name allows to add a name to the natsjs connection.
|
||||
func Name(name string) Option {
|
||||
return func(o *Options) {
|
||||
o.Name = name
|
||||
}
|
||||
}
|
||||
|
||||
// DisableDurableStreams will disable durable streams.
|
||||
func DisableDurableStreams() Option {
|
||||
return func(o *Options) {
|
||||
o.DisableDurableStreams = true
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate authenticates the connection with the given username and password.
|
||||
func Authenticate(username, password string) Option {
|
||||
return func(o *Options) {
|
||||
o.Username = username
|
||||
o.Password = password
|
||||
}
|
||||
}
|
||||
func RetentionPolicy(rp int) Option {
|
||||
return func(o *Options) {
|
||||
o.RetentionPolicy = rp
|
||||
}
|
||||
}
|
||||
|
||||
func MaxMsgSize(size int) Option {
|
||||
return func(o *Options) {
|
||||
o.MaxMsgSize = size
|
||||
}
|
||||
}
|
||||
func MaxAge(age time.Duration) Option {
|
||||
return func(o *Options) {
|
||||
o.MaxAge = age
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
// Store persists published events for durability and replay. If nil, an
|
||||
// in-memory store is used and events do not survive a restart.
|
||||
Store store.Store
|
||||
}
|
||||
|
||||
type Option func(o *Options)
|
||||
|
||||
// WithStore backs the stream with a durable store (e.g. the file store), so
|
||||
// published events persist and can be replayed across restarts.
|
||||
func WithStore(s store.Store) Option {
|
||||
return func(o *Options) { o.Store = s }
|
||||
}
|
||||
|
||||
type StoreOptions struct {
|
||||
TTL time.Duration
|
||||
Backup Backup
|
||||
}
|
||||
|
||||
type StoreOption func(o *StoreOptions)
|
||||
|
||||
// PublishOptions contains all the options which can be provided when publishing an event
|
||||
type PublishOptions struct {
|
||||
// Metadata contains any keys which can be used to query the data, for example a customer id
|
||||
Metadata map[string]string
|
||||
// Timestamp to set for the event, if the timestamp is a zero value, the current time will be used
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// PublishOption sets attributes on PublishOptions
|
||||
type PublishOption func(o *PublishOptions)
|
||||
|
||||
// WithMetadata sets the Metadata field on PublishOptions
|
||||
func WithMetadata(md map[string]string) PublishOption {
|
||||
return func(o *PublishOptions) {
|
||||
o.Metadata = md
|
||||
}
|
||||
}
|
||||
|
||||
// WithTimestamp sets the timestamp field on PublishOptions
|
||||
func WithTimestamp(t time.Time) PublishOption {
|
||||
return func(o *PublishOptions) {
|
||||
o.Timestamp = t
|
||||
}
|
||||
}
|
||||
|
||||
// ConsumeOptions contains all the options which can be provided when subscribing to a topic
|
||||
type ConsumeOptions struct {
|
||||
// Group is the name of the consumer group, if two consumers have the same group the events
|
||||
// are distributed between them
|
||||
Group string
|
||||
// Offset is the time from which the messages should be consumed from. If not provided then
|
||||
// the messages will be consumed starting from the moment the Subscription starts.
|
||||
Offset time.Time
|
||||
// AutoAck if true (default true), automatically acknowledges every message so it will not be redelivered.
|
||||
// If false specifies that each message need ts to be manually acknowledged by the subscriber.
|
||||
// If processing is successful the message should be ack'ed to remove the message from the stream.
|
||||
// If processing is unsuccessful the message should be nack'ed (negative acknowledgement) which will mean it will
|
||||
// remain on the stream to be processed again.
|
||||
AutoAck bool
|
||||
AckWait time.Duration
|
||||
// RetryLimit indicates number of times a message is retried
|
||||
RetryLimit int
|
||||
// CustomRetries indicates whether to use RetryLimit
|
||||
CustomRetries bool
|
||||
}
|
||||
|
||||
// ConsumeOption sets attributes on ConsumeOptions
|
||||
type ConsumeOption func(o *ConsumeOptions)
|
||||
|
||||
// WithGroup sets the consumer group to be part of when consuming events
|
||||
func WithGroup(q string) ConsumeOption {
|
||||
return func(o *ConsumeOptions) {
|
||||
o.Group = q
|
||||
}
|
||||
}
|
||||
|
||||
// WithOffset sets the offset time at which to start consuming events
|
||||
func WithOffset(t time.Time) ConsumeOption {
|
||||
return func(o *ConsumeOptions) {
|
||||
o.Offset = t
|
||||
}
|
||||
}
|
||||
|
||||
// WithAutoAck sets the AutoAck field on ConsumeOptions and an ackWait duration after which if no ack is received
|
||||
// the message is requeued in case auto ack is turned off
|
||||
func WithAutoAck(ack bool, ackWait time.Duration) ConsumeOption {
|
||||
return func(o *ConsumeOptions) {
|
||||
o.AutoAck = ack
|
||||
o.AckWait = ackWait
|
||||
}
|
||||
}
|
||||
|
||||
// WithRetryLimit sets the RetryLimit field on ConsumeOptions.
|
||||
// Set to -1 for infinite retries (default)
|
||||
func WithRetryLimit(retries int) ConsumeOption {
|
||||
return func(o *ConsumeOptions) {
|
||||
o.RetryLimit = retries
|
||||
o.CustomRetries = true
|
||||
}
|
||||
}
|
||||
|
||||
func (s ConsumeOptions) GetRetryLimit() int {
|
||||
if !s.CustomRetries {
|
||||
return -1
|
||||
}
|
||||
return s.RetryLimit
|
||||
}
|
||||
|
||||
// WriteOptions contains all the options which can be provided when writing an event to a store
|
||||
type WriteOptions struct {
|
||||
// TTL is the duration the event should be recorded for, a zero value TTL indicates the event should
|
||||
// be stored indefinitely
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
// WriteOption sets attributes on WriteOptions
|
||||
type WriteOption func(o *WriteOptions)
|
||||
|
||||
// WithTTL sets the TTL attribute on WriteOptions
|
||||
func WithTTL(d time.Duration) WriteOption {
|
||||
return func(o *WriteOptions) {
|
||||
o.TTL = d
|
||||
}
|
||||
}
|
||||
|
||||
// ReadOptions contains all the options which can be provided when reading events from a store
|
||||
type ReadOptions struct {
|
||||
// Limit the number of results to return
|
||||
Limit uint
|
||||
// Offset the results by this number, useful for paginated queries
|
||||
Offset uint
|
||||
}
|
||||
|
||||
// ReadOption sets attributes on ReadOptions
|
||||
type ReadOption func(o *ReadOptions)
|
||||
|
||||
// ReadLimit sets the limit attribute on ReadOptions
|
||||
func ReadLimit(l uint) ReadOption {
|
||||
return func(o *ReadOptions) {
|
||||
o.Limit = 1
|
||||
}
|
||||
}
|
||||
|
||||
// ReadOffset sets the offset attribute on ReadOptions
|
||||
func ReadOffset(l uint) ReadOption {
|
||||
return func(o *ReadOptions) {
|
||||
o.Offset = 1
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"go-micro.dev/v6/logger"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
const joinKey = "/"
|
||||
|
||||
// NewStore returns an initialized events store
|
||||
func NewStore(opts ...StoreOption) Store {
|
||||
// parse the options
|
||||
var options StoreOptions
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
if options.TTL.Seconds() == 0 {
|
||||
options.TTL = time.Hour * 24
|
||||
}
|
||||
|
||||
// return the store
|
||||
evs := &evStore{
|
||||
opts: options,
|
||||
store: store.NewMemoryStore(),
|
||||
}
|
||||
if options.Backup != nil {
|
||||
go evs.backupLoop()
|
||||
}
|
||||
return evs
|
||||
}
|
||||
|
||||
type evStore struct {
|
||||
opts StoreOptions
|
||||
store store.Store
|
||||
}
|
||||
|
||||
// Read events for a topic
|
||||
func (s *evStore) Read(topic string, opts ...ReadOption) ([]*Event, error) {
|
||||
// validate the topic
|
||||
if len(topic) == 0 {
|
||||
return nil, ErrMissingTopic
|
||||
}
|
||||
|
||||
// parse the options
|
||||
options := ReadOptions{
|
||||
Offset: 0,
|
||||
Limit: 250,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
|
||||
// execute the request
|
||||
recs, err := s.store.Read(topic+joinKey,
|
||||
store.ReadPrefix(),
|
||||
store.ReadLimit(options.Limit),
|
||||
store.ReadOffset(options.Offset),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Error reading from store")
|
||||
}
|
||||
|
||||
// unmarshal the result
|
||||
result := make([]*Event, len(recs))
|
||||
for i, r := range recs {
|
||||
var e Event
|
||||
if err := json.Unmarshal(r.Value, &e); err != nil {
|
||||
return nil, errors.Wrap(err, "Invalid event returned from stroe")
|
||||
}
|
||||
result[i] = &e
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Write an event to the store
|
||||
func (s *evStore) Write(event *Event, opts ...WriteOption) error {
|
||||
// parse the options
|
||||
options := WriteOptions{
|
||||
TTL: s.opts.TTL,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
|
||||
// construct the store record
|
||||
bytes, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Error mashaling event to JSON")
|
||||
}
|
||||
// suffix event ID with hour resolution for easy retrieval in batches
|
||||
timeSuffix := time.Now().Format("2006010215")
|
||||
|
||||
record := &store.Record{
|
||||
// key is such that reading by prefix indexes by topic and reading by suffix indexes by time
|
||||
Key: event.Topic + joinKey + event.ID + joinKey + timeSuffix,
|
||||
Value: bytes,
|
||||
Expiry: options.TTL,
|
||||
}
|
||||
|
||||
// write the record to the store
|
||||
if err := s.store.Write(record); err != nil {
|
||||
return errors.Wrap(err, "Error writing to the store")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *evStore) backupLoop() {
|
||||
for {
|
||||
err := s.opts.Backup.Snapshot(s.store)
|
||||
if err != nil {
|
||||
logger.Errorf("Error running backup %s", err)
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
// Backup is an interface for snapshotting the events store to long term storage
|
||||
type Backup interface {
|
||||
Snapshot(st store.Store) error
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestStore(t *testing.T) {
|
||||
store := NewStore()
|
||||
|
||||
testData := []Event{
|
||||
{ID: uuid.New().String(), Topic: "foo"},
|
||||
{ID: uuid.New().String(), Topic: "foo"},
|
||||
{ID: uuid.New().String(), Topic: "bar"},
|
||||
}
|
||||
|
||||
// write the records to the store
|
||||
t.Run("Write", func(t *testing.T) {
|
||||
for _, event := range testData {
|
||||
err := store.Write(&event)
|
||||
assert.Nilf(t, err, "Writing an event should not return an error")
|
||||
}
|
||||
})
|
||||
|
||||
// should not be able to read events from a blank topic
|
||||
t.Run("ReadMissingTopic", func(t *testing.T) {
|
||||
evs, err := store.Read("")
|
||||
assert.Equal(t, err, ErrMissingTopic, "Reading a blank topic should return an error")
|
||||
assert.Nil(t, evs, "No events should be returned")
|
||||
})
|
||||
|
||||
// should only get the events from the topic requested
|
||||
t.Run("ReadTopic", func(t *testing.T) {
|
||||
evs, err := store.Read("foo")
|
||||
assert.Nilf(t, err, "No error should be returned")
|
||||
assert.Len(t, evs, 2, "Only the events for this topic should be returned")
|
||||
})
|
||||
|
||||
// limits should be honored
|
||||
t.Run("ReadTopicLimit", func(t *testing.T) {
|
||||
evs, err := store.Read("foo", ReadLimit(1))
|
||||
assert.Nilf(t, err, "No error should be returned")
|
||||
assert.Len(t, evs, 1, "The result should include no more than the read limit")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type testPayload struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
type testCase struct {
|
||||
str Stream
|
||||
name string
|
||||
}
|
||||
|
||||
func TestStream(t *testing.T) {
|
||||
tcs := []testCase{}
|
||||
|
||||
stream, err := NewStream()
|
||||
assert.Nilf(t, err, "NewStream should not return an error")
|
||||
assert.NotNilf(t, stream, "NewStream should return a stream object")
|
||||
tcs = append(tcs, testCase{str: stream, name: "memory"})
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runTestStream(t, tc.str)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func runTestStream(t *testing.T, stream Stream) {
|
||||
// TestMissingTopic will test the topic validation on publish
|
||||
t.Run("TestMissingTopic", func(t *testing.T) {
|
||||
err := stream.Publish("", nil)
|
||||
assert.Equalf(t, err, ErrMissingTopic, "Publishing to a blank topic should return an error")
|
||||
})
|
||||
|
||||
// TestConsumeTopic will publish a message to the test topic. The subscriber will subscribe to the
|
||||
// same test topic.
|
||||
t.Run("TestConsumeTopic", func(t *testing.T) {
|
||||
payload := &testPayload{Message: "HelloWorld"}
|
||||
metadata := map[string]string{"foo": "bar"}
|
||||
|
||||
// create the subscriber
|
||||
evChan, err := stream.Consume("test")
|
||||
assert.Nilf(t, err, "Consume should not return an error")
|
||||
|
||||
// setup the subscriber async
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
go func() {
|
||||
timeout := time.NewTimer(time.Millisecond * 250)
|
||||
|
||||
select {
|
||||
case event := <-evChan:
|
||||
assert.NotNilf(t, event, "The message was nil")
|
||||
assert.Equal(t, event.Metadata, metadata, "Metadata didn't match")
|
||||
|
||||
var result testPayload
|
||||
err := event.Unmarshal(&result)
|
||||
assert.Nil(t, err, "Error decoding result")
|
||||
assert.Equal(t, result, *payload, "Payload didn't match")
|
||||
|
||||
wg.Done()
|
||||
case <-timeout.C:
|
||||
t.Errorf("Event was not received")
|
||||
wg.Done()
|
||||
}
|
||||
}()
|
||||
|
||||
err = stream.Publish("test", payload, WithMetadata(metadata))
|
||||
assert.Nil(t, err, "Publishing a valid message should not return an error")
|
||||
|
||||
// wait for the subscriber to receive the message or timeout
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
// TestConsumeGroup will publish a message to a random topic. Two subscribers will then consume
|
||||
// the message from the firehose topic with different queues. The second subscriber will be registered
|
||||
// after the message is published to test durability.
|
||||
t.Run("TestConsumeGroup", func(t *testing.T) {
|
||||
topic := uuid.New().String()
|
||||
payload := &testPayload{Message: "HelloWorld"}
|
||||
metadata := map[string]string{"foo": "bar"}
|
||||
|
||||
// create the first subscriber
|
||||
evChan1, err := stream.Consume(topic)
|
||||
assert.Nilf(t, err, "Consume should not return an error")
|
||||
|
||||
// setup the subscriber async
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
timeout := time.NewTimer(time.Millisecond * 250)
|
||||
|
||||
select {
|
||||
case event := <-evChan1:
|
||||
assert.NotNilf(t, event, "The message was nil")
|
||||
assert.Equal(t, event.Metadata, metadata, "Metadata didn't match")
|
||||
|
||||
var result testPayload
|
||||
err := event.Unmarshal(&result)
|
||||
assert.Nil(t, err, "Error decoding result")
|
||||
assert.Equal(t, result, *payload, "Payload didn't match")
|
||||
|
||||
wg.Done()
|
||||
case <-timeout.C:
|
||||
t.Errorf("Event was not received")
|
||||
wg.Done()
|
||||
}
|
||||
}()
|
||||
|
||||
err = stream.Publish(topic, payload, WithMetadata(metadata))
|
||||
assert.Nil(t, err, "Publishing a valid message should not return an error")
|
||||
|
||||
// create the second subscriber
|
||||
evChan2, err := stream.Consume(topic,
|
||||
WithGroup("second_queue"),
|
||||
WithOffset(time.Now().Add(time.Minute*-1)),
|
||||
)
|
||||
assert.Nilf(t, err, "Consume should not return an error")
|
||||
|
||||
go func() {
|
||||
timeout := time.NewTimer(time.Second * 1)
|
||||
|
||||
select {
|
||||
case event := <-evChan2:
|
||||
assert.NotNilf(t, event, "The message was nil")
|
||||
assert.Equal(t, event.Metadata, metadata, "Metadata didn't match")
|
||||
|
||||
var result testPayload
|
||||
err := event.Unmarshal(&result)
|
||||
assert.Nil(t, err, "Error decoding result")
|
||||
assert.Equal(t, result, *payload, "Payload didn't match")
|
||||
|
||||
wg.Done()
|
||||
case <-timeout.C:
|
||||
t.Errorf("Event was not received")
|
||||
wg.Done()
|
||||
}
|
||||
}()
|
||||
|
||||
// wait for the subscriber to receive the message or timeout
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
t.Run("AckingNacking", func(t *testing.T) {
|
||||
ch, err := stream.Consume("foobarAck", WithAutoAck(false, 5*time.Second))
|
||||
assert.NoError(t, err, "Unexpected error subscribing")
|
||||
assert.NoError(t, stream.Publish("foobarAck", map[string]string{"foo": "message 1"}))
|
||||
assert.NoError(t, stream.Publish("foobarAck", map[string]string{"foo": "message 2"}))
|
||||
assert.NoError(t, stream.Publish("foobarAck", map[string]string{"foo": "message 3"}))
|
||||
|
||||
ev := <-ch
|
||||
ev.Ack()
|
||||
ev = <-ch
|
||||
nacked := ev.ID
|
||||
ev.Nack()
|
||||
select {
|
||||
case ev = <-ch:
|
||||
assert.Equal(t, ev.ID, nacked, "Nacked message should have been received again")
|
||||
assert.NoError(t, ev.Ack())
|
||||
case <-time.After(7 * time.Second):
|
||||
t.Fatalf("Timed out waiting for message to be put back on queue")
|
||||
}
|
||||
select {
|
||||
case ev = <-ch:
|
||||
assert.NotEqual(t, ev.ID, nacked, "Queued message should only be received after the nacked message is redelivered")
|
||||
assert.NoError(t, ev.Ack())
|
||||
case <-time.After(7 * time.Second):
|
||||
t.Fatalf("Timed out waiting for queued message")
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
t.Run("Retries", func(t *testing.T) {
|
||||
ch, err := stream.Consume("foobarRetries", WithAutoAck(false, 5*time.Second), WithRetryLimit(1))
|
||||
assert.NoError(t, err, "Unexpected error subscribing")
|
||||
assert.NoError(t, stream.Publish("foobarRetries", map[string]string{"foo": "message 1"}))
|
||||
|
||||
ev := <-ch
|
||||
id := ev.ID
|
||||
ev.Nack()
|
||||
ev = <-ch
|
||||
assert.Equal(t, id, ev.ID, "Nacked message should have been received again")
|
||||
ev.Nack()
|
||||
select {
|
||||
case ev = <-ch:
|
||||
t.Fatalf("Unexpected event received")
|
||||
case <-time.After(7 * time.Second):
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
t.Run("InfiniteRetries", func(t *testing.T) {
|
||||
ch, err := stream.Consume("foobarRetriesInf", WithAutoAck(false, 2*time.Second))
|
||||
assert.NoError(t, err, "Unexpected error subscribing")
|
||||
assert.NoError(t, stream.Publish("foobarRetriesInf", map[string]string{"foo": "message 1"}))
|
||||
|
||||
count := 0
|
||||
id := ""
|
||||
for {
|
||||
select {
|
||||
case ev := <-ch:
|
||||
if id != "" {
|
||||
assert.Equal(t, id, ev.ID, "Nacked message should have been received again")
|
||||
}
|
||||
id = ev.ID
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("Unexpected event received")
|
||||
}
|
||||
|
||||
count++
|
||||
if count == 11 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
t.Run("twoSubs", func(t *testing.T) {
|
||||
ch1, err := stream.Consume("foobarTwoSubs1", WithAutoAck(false, 5*time.Second))
|
||||
assert.NoError(t, err, "Unexpected error subscribing to topic 1")
|
||||
ch2, err := stream.Consume("foobarTwoSubs2", WithAutoAck(false, 5*time.Second))
|
||||
assert.NoError(t, err, "Unexpected error subscribing to topic 2")
|
||||
|
||||
assert.NoError(t, stream.Publish("foobarTwoSubs2", map[string]string{"foo": "message 1"}))
|
||||
assert.NoError(t, stream.Publish("foobarTwoSubs1", map[string]string{"foo": "message 1"}))
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
ev := <-ch1
|
||||
assert.Equal(t, "foobarTwoSubs1", ev.Topic, "Received message from unexpected topic")
|
||||
wg.Done()
|
||||
}()
|
||||
go func() {
|
||||
ev := <-ch2
|
||||
assert.Equal(t, "foobarTwoSubs2", ev.Topic, "Received message from unexpected topic")
|
||||
wg.Done()
|
||||
}()
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user