snapshot of https://github.com/ThreeDotsLabs/watermill.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
master / ci (push) Failing after 0s

This commit is contained in:
upstream-mirror
2026-09-15 21:27:46 +05:00
commit ce933d1277
538 changed files with 46504 additions and 0 deletions

View File

@@ -0,0 +1,168 @@
package cqrs
import (
"context"
stdErrors "errors"
"github.com/ThreeDotsLabs/watermill"
"github.com/pkg/errors"
"github.com/ThreeDotsLabs/watermill/message"
)
type CommandBusConfig struct {
// GeneratePublishTopic is used to generate topic for publishing command.
GeneratePublishTopic CommandBusGeneratePublishTopicFn
// OnSend is called before publishing the command.
// The *message.Message can be modified.
//
// This option is not required.
OnSend CommandBusOnSendFn
// Marshaler is used to marshal and unmarshal commands.
// It is required.
Marshaler CommandEventMarshaler
// Logger instance used to log.
// If not provided, watermill.NopLogger is used.
Logger watermill.LoggerAdapter
}
func (c *CommandBusConfig) setDefaults() {
if c.Logger == nil {
c.Logger = watermill.NopLogger{}
}
}
func (c CommandBusConfig) Validate() error {
var err error
if c.Marshaler == nil {
err = stdErrors.Join(err, errors.New("missing Marshaler"))
}
if c.GeneratePublishTopic == nil {
err = stdErrors.Join(err, errors.New("missing GeneratePublishTopic"))
}
return err
}
type CommandBusGeneratePublishTopicFn func(CommandBusGeneratePublishTopicParams) (string, error)
type CommandBusGeneratePublishTopicParams struct {
CommandName string
Command any
}
type CommandBusOnSendFn func(params CommandBusOnSendParams) error
type CommandBusOnSendParams struct {
CommandName string
Command any
// Message is never nil and can be modified.
Message *message.Message
}
// CommandBus transports commands to command handlers.
type CommandBus struct {
publisher message.Publisher
config CommandBusConfig
}
// NewCommandBusWithConfig creates a new CommandBus.
func NewCommandBusWithConfig(publisher message.Publisher, config CommandBusConfig) (*CommandBus, error) {
if publisher == nil {
return nil, errors.New("missing publisher")
}
config.setDefaults()
if err := config.Validate(); err != nil {
return nil, errors.Wrap(err, "invalid config")
}
return &CommandBus{publisher, config}, nil
}
// NewCommandBus creates a new CommandBus.
// Deprecated: use NewCommandBusWithConfig instead.
func NewCommandBus(
publisher message.Publisher,
generateTopic func(commandName string) string,
marshaler CommandEventMarshaler,
) (*CommandBus, error) {
if publisher == nil {
return nil, errors.New("missing publisher")
}
if generateTopic == nil {
return nil, errors.New("missing generateTopic")
}
if marshaler == nil {
return nil, errors.New("missing marshaler")
}
return &CommandBus{publisher, CommandBusConfig{
GeneratePublishTopic: func(params CommandBusGeneratePublishTopicParams) (string, error) {
return generateTopic(params.CommandName), nil
},
Marshaler: marshaler,
}}, nil
}
// Send sends command to the command bus.
func (c CommandBus) Send(ctx context.Context, cmd any) error {
return c.SendWithModifiedMessage(ctx, cmd, nil)
}
func (c CommandBus) SendWithModifiedMessage(ctx context.Context, cmd any, modify func(*message.Message) error) error {
msg, topicName, err := c.newMessage(ctx, cmd)
if err != nil {
return err
}
if modify != nil {
if err := modify(msg); err != nil {
return errors.Wrap(err, "cannot modify message")
}
}
if err := c.publisher.Publish(topicName, msg); err != nil {
return err
}
return nil
}
func (c CommandBus) newMessage(ctx context.Context, command any) (*message.Message, string, error) {
msg, err := c.config.Marshaler.Marshal(command)
if err != nil {
return nil, "", err
}
commandName := c.config.Marshaler.Name(command)
topicName, err := c.config.GeneratePublishTopic(CommandBusGeneratePublishTopicParams{
CommandName: commandName,
Command: command,
})
if err != nil {
return nil, "", errors.Wrap(err, "cannot generate topic name")
}
msg.SetContext(ctx)
if c.config.OnSend != nil {
err := c.config.OnSend(CommandBusOnSendParams{
CommandName: commandName,
Command: command,
Message: msg,
})
if err != nil {
return nil, "", errors.Wrap(err, "cannot execute OnSend")
}
}
return msg, topicName, nil
}

View File

@@ -0,0 +1,224 @@
package cqrs_test
import (
"context"
"testing"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ThreeDotsLabs/watermill/components/cqrs"
)
func TestCommandBusConfig_Validate(t *testing.T) {
testCases := []struct {
Name string
ModifyValidConfig func(*cqrs.CommandBusConfig)
ExpectedErr error
}{
{
Name: "valid_config",
ModifyValidConfig: nil,
ExpectedErr: nil,
},
{
Name: "missing_Marshaler",
ModifyValidConfig: func(c *cqrs.CommandBusConfig) {
c.Marshaler = nil
},
ExpectedErr: errors.Errorf("missing Marshaler"),
},
{
Name: "missing_GeneratePublishTopic",
ModifyValidConfig: func(c *cqrs.CommandBusConfig) {
c.GeneratePublishTopic = nil
},
ExpectedErr: errors.Errorf("missing GeneratePublishTopic"),
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.Name, func(t *testing.T) {
validConfig := cqrs.CommandBusConfig{
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
return "", nil
},
Marshaler: cqrs.JSONMarshaler{},
}
if tc.ModifyValidConfig != nil {
tc.ModifyValidConfig(&validConfig)
}
err := validConfig.Validate()
if tc.ExpectedErr == nil {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tc.ExpectedErr.Error())
}
})
}
}
func TestNewCommandBus(t *testing.T) {
pub := newPublisherStub()
config := cqrs.CommandBusConfig{
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
return "", nil
},
Marshaler: cqrs.JSONMarshaler{},
}
require.NoError(t, config.Validate())
cb, err := cqrs.NewCommandBusWithConfig(pub, config)
assert.NotNil(t, cb)
assert.NoError(t, err)
config.GeneratePublishTopic = nil
require.Error(t, config.Validate())
cb, err = cqrs.NewCommandBusWithConfig(pub, config)
assert.Nil(t, cb)
assert.Error(t, err)
}
type contextKey string
func TestCommandBus_Send_ContextPropagation(t *testing.T) {
publisher := newPublisherStub()
commandBus, err := cqrs.NewCommandBusWithConfig(
publisher,
cqrs.CommandBusConfig{
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
return "whatever", nil
},
Marshaler: cqrs.JSONMarshaler{},
},
)
require.NoError(t, err)
ctx := context.WithValue(context.Background(), contextKey("key"), "value")
err = commandBus.Send(ctx, "message")
require.NoError(t, err)
assert.Equal(t, ctx, publisher.messages["whatever"][0].Context())
}
func TestCommandBus_Send_topic_name(t *testing.T) {
cb, err := cqrs.NewCommandBusWithConfig(
assertPublishTopicPublisher{ExpectedTopic: "cqrs_test.TestCommand", T: t},
cqrs.CommandBusConfig{
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
return params.CommandName, nil
},
Marshaler: cqrs.JSONMarshaler{},
},
)
require.NoError(t, err)
err = cb.Send(context.Background(), TestCommand{})
require.NoError(t, err)
}
func TestCommandBus_Send_OnSend(t *testing.T) {
publisher := newPublisherStub()
cb, err := cqrs.NewCommandBusWithConfig(
publisher,
cqrs.CommandBusConfig{
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
return "whatever", nil
},
Marshaler: cqrs.JSONMarshaler{},
OnSend: func(params cqrs.CommandBusOnSendParams) error {
params.Message.Metadata.Set("key", "value")
return nil
},
},
)
require.NoError(t, err)
err = cb.Send(context.Background(), TestCommand{})
require.NoError(t, err)
assert.Equal(t, "value", publisher.messages["whatever"][0].Metadata.Get("key"))
}
func TestCommandBus_SendWithModifiedMessage(t *testing.T) {
publisher := newPublisherStub()
cb, err := cqrs.NewCommandBusWithConfig(
publisher,
cqrs.CommandBusConfig{
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
return "whatever", nil
},
Marshaler: cqrs.JSONMarshaler{},
},
)
require.NoError(t, err)
err = cb.SendWithModifiedMessage(context.Background(), TestCommand{}, func(message *message.Message) error {
message.Metadata.Set("key", "value")
return nil
})
require.NoError(t, err)
assert.Equal(t, "value", publisher.messages["whatever"][0].Metadata.Get("key"))
}
func TestCommandBus_SendWithModifiedMessage_modify_error(t *testing.T) {
publisher := newPublisherStub()
cb, err := cqrs.NewCommandBusWithConfig(
publisher,
cqrs.CommandBusConfig{
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
return "whatever", nil
},
Marshaler: cqrs.JSONMarshaler{},
},
)
require.NoError(t, err)
expectedErr := errors.New("some error")
err = cb.SendWithModifiedMessage(
context.Background(),
TestCommand{},
func(message *message.Message) error {
return expectedErr
},
)
assert.ErrorContains(t, err, expectedErr.Error())
}
func TestCommandBus_Send_OnSend_error(t *testing.T) {
publisher := newPublisherStub()
expectedErr := errors.New("some error")
cb, err := cqrs.NewCommandBusWithConfig(
publisher,
cqrs.CommandBusConfig{
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
return "whatever", nil
},
Marshaler: cqrs.JSONMarshaler{},
OnSend: func(params cqrs.CommandBusOnSendParams) error {
return expectedErr
},
},
)
require.NoError(t, err)
err = cb.Send(context.Background(), TestCommand{})
require.EqualError(t, err, "cannot execute OnSend: some error")
}

View File

@@ -0,0 +1,59 @@
package cqrs
import (
"context"
)
// CommandHandler receives a command defined by NewCommand and handles it with the Handle method.
// If using DDD, CommandHandler may modify and persist the aggregate.
//
// In contrast to EventHandler, every Command must have only one CommandHandler.
//
// One instance of CommandHandler is used during handling messages.
// When multiple commands are delivered at the same time, Handle method can be executed multiple times at the same time.
// Because of that, Handle method needs to be thread safe!
type CommandHandler interface {
// HandlerName is the name used in message.Router while creating handler.
//
// It will be also passed to CommandsSubscriberConstructor.
// May be useful, for example, to create a consumer group per each handler.
//
// WARNING: If HandlerName was changed and is used for generating consumer groups,
// it may result with **reconsuming all messages**!
HandlerName() string
NewCommand() any
Handle(ctx context.Context, cmd any) error
}
type genericCommandHandler[Command any] struct {
handleFunc func(ctx context.Context, cmd *Command) error
handlerName string
}
// NewCommandHandler creates a new CommandHandler implementation based on provided function
// and command type inferred from function argument.
func NewCommandHandler[Command any](
handlerName string,
handleFunc func(ctx context.Context, cmd *Command) error,
) CommandHandler {
return &genericCommandHandler[Command]{
handleFunc: handleFunc,
handlerName: handlerName,
}
}
func (c genericCommandHandler[Command]) HandlerName() string {
return c.handlerName
}
func (c genericCommandHandler[Command]) NewCommand() any {
tVar := new(Command)
return tVar
}
func (c genericCommandHandler[Command]) Handle(ctx context.Context, cmd any) error {
command := cmd.(*Command)
return c.handleFunc(ctx, command)
}

View File

@@ -0,0 +1,32 @@
package cqrs_test
import (
"context"
"fmt"
"testing"
"github.com/ThreeDotsLabs/watermill/components/cqrs"
"github.com/stretchr/testify/assert"
)
type SomeCommand struct {
Foo string
}
func TestNewCommandHandler(t *testing.T) {
cmdToSend := &SomeCommand{"bar"}
ch := cqrs.NewCommandHandler(
"some_handler",
func(ctx context.Context, cmd *SomeCommand) error {
assert.Equal(t, cmdToSend, cmd)
return fmt.Errorf("some error")
},
)
assert.Equal(t, "some_handler", ch.HandlerName())
assert.Equal(t, &SomeCommand{}, ch.NewCommand())
err := ch.Handle(context.Background(), cmdToSend)
assert.EqualError(t, err, "some error")
}

View File

@@ -0,0 +1,382 @@
package cqrs
import (
stdErrors "errors"
"fmt"
"github.com/pkg/errors"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
)
type CommandProcessorConfig struct {
// GenerateSubscribeTopic is used to generate topic for subscribing command.
GenerateSubscribeTopic CommandProcessorGenerateSubscribeTopicFn
// SubscriberConstructor is used to create subscriber for CommandHandler.
SubscriberConstructor CommandProcessorSubscriberConstructorFn
// OnHandle is called before handling command.
// OnHandle works in a similar way to middlewares: you can inject additional logic before and after handling a command.
//
// Because of that, you need to explicitly call params.Handler.Handle() to handle the command.
// func(params CommandProcessorOnHandleParams) (err error) {
// // logic before handle
// // (...)
//
// err := params.Handler.Handle(params.Message.Context(), params.Command)
//
// // logic after handle
// // (...)
//
// return err
// }
//
// This option is not required.
OnHandle CommandProcessorOnHandleFn
// Marshaler is used to marshal and unmarshal commands.
// It is required.
Marshaler CommandEventMarshaler
// Logger instance used to log.
// If not provided, watermill.NopLogger is used.
Logger watermill.LoggerAdapter
// If true, CommandProcessor will ack messages even if CommandHandler returns an error.
// If RequestReplyBackend is not null and sending reply fails, the message will be nack-ed anyway.
//
// Warning: It's not recommended to use this option when you are using requestreply component
// (requestreply.NewCommandHandler or requestreply.NewCommandHandlerWithResult), as it may ack the
// command when sending reply failed.
//
// When you are using requestreply, you should use requestreply.PubSubBackendConfig.AckCommandErrors.
AckCommandHandlingErrors bool
// disableRouterAutoAddHandlers is used to keep backwards compatibility.
// it is set when CommandProcessor is created by NewCommandProcessor.
// Deprecated: please migrate to NewCommandProcessorWithConfig.
disableRouterAutoAddHandlers bool
}
func (c *CommandProcessorConfig) setDefaults() {
if c.Logger == nil {
c.Logger = watermill.NopLogger{}
}
}
func (c CommandProcessorConfig) Validate() error {
var err error
if c.Marshaler == nil {
err = stdErrors.Join(err, errors.New("missing Marshaler"))
}
if c.GenerateSubscribeTopic == nil {
err = stdErrors.Join(err, errors.New("missing GenerateSubscribeTopic"))
}
if c.SubscriberConstructor == nil {
err = stdErrors.Join(err, errors.New("missing SubscriberConstructor"))
}
return err
}
type CommandProcessorGenerateSubscribeTopicFn func(CommandProcessorGenerateSubscribeTopicParams) (string, error)
type CommandProcessorGenerateSubscribeTopicParams struct {
CommandName string
CommandHandler CommandHandler
}
// CommandProcessorSubscriberConstructorFn creates subscriber for CommandHandler.
// It allows you to create a separate customized Subscriber for every command handler.
type CommandProcessorSubscriberConstructorFn func(CommandProcessorSubscriberConstructorParams) (message.Subscriber, error)
type CommandProcessorSubscriberConstructorParams struct {
CommandName string
HandlerName string
Handler CommandHandler
}
type CommandProcessorOnHandleFn func(params CommandProcessorOnHandleParams) error
type CommandProcessorOnHandleParams struct {
Handler CommandHandler
CommandName string
Command any
// Message is never nil and can be modified.
Message *message.Message
}
// CommandProcessor determines which CommandHandler should handle the command received from the command bus.
type CommandProcessor struct {
router *message.Router
handlers []CommandHandler
config CommandProcessorConfig
}
func NewCommandProcessorWithConfig(router *message.Router, config CommandProcessorConfig) (*CommandProcessor, error) {
config.setDefaults()
if err := config.Validate(); err != nil {
return nil, err
}
if router == nil && !config.disableRouterAutoAddHandlers {
return nil, errors.New("missing router")
}
return &CommandProcessor{
router: router,
config: config,
}, nil
}
// NewCommandProcessor creates a new CommandProcessor.
// Deprecated. Use NewCommandProcessorWithConfig instead.
func NewCommandProcessor(
handlers []CommandHandler,
generateTopic func(commandName string) string,
subscriberConstructor CommandsSubscriberConstructor,
marshaler CommandEventMarshaler,
logger watermill.LoggerAdapter,
) (*CommandProcessor, error) {
if len(handlers) == 0 {
return nil, errors.New("missing handlers")
}
if generateTopic == nil {
return nil, errors.New("missing generateTopic")
}
if subscriberConstructor == nil {
return nil, errors.New("missing subscriberConstructor")
}
cp, err := NewCommandProcessorWithConfig(
nil,
CommandProcessorConfig{
GenerateSubscribeTopic: func(params CommandProcessorGenerateSubscribeTopicParams) (string, error) {
return generateTopic(params.CommandName), nil
},
SubscriberConstructor: func(params CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return subscriberConstructor(params.HandlerName)
},
Marshaler: marshaler,
Logger: logger,
disableRouterAutoAddHandlers: true,
},
)
if err != nil {
return nil, err
}
for _, handler := range handlers {
if err := cp.AddHandlers(handler); err != nil {
return nil, err
}
}
return cp, nil
}
// CommandsSubscriberConstructor creates subscriber for CommandHandler.
// It allows you to create a separate customized Subscriber for every command handler.
//
// Deprecated: please use CommandProcessorSubscriberConstructorFn instead.
type CommandsSubscriberConstructor func(handlerName string) (message.Subscriber, error)
// AddHandlers adds a new CommandHandler to the CommandProcessor and adds it to the router.
func (p *CommandProcessor) AddHandlers(handlers ...CommandHandler) error {
handledCommands := map[string]struct{}{}
for _, handler := range handlers {
commandName := p.config.Marshaler.Name(handler.NewCommand())
if _, ok := handledCommands[commandName]; ok {
return DuplicateCommandHandlerError{commandName}
}
handledCommands[commandName] = struct{}{}
}
if p.config.disableRouterAutoAddHandlers {
p.handlers = append(p.handlers, handlers...)
return nil
}
for _, handler := range handlers {
if _, err := p.addHandlerToRouter(p.router, handler); err != nil {
return err
}
p.handlers = append(p.handlers, handler)
}
return nil
}
// AddHandler adds a new CommandHandler to the CommandProcessor and adds it to the router.
func (p *CommandProcessor) AddHandler(handler CommandHandler) (*message.Handler, error) {
if p.config.disableRouterAutoAddHandlers {
p.handlers = append(p.handlers, handler)
return nil, nil
}
h, err := p.addHandlerToRouter(p.router, handler)
if err != nil {
return nil, err
}
p.handlers = append(p.handlers, handler)
return h, nil
}
// DuplicateCommandHandlerError occurs when a handler with the same name already exists.
type DuplicateCommandHandlerError struct {
CommandName string
}
func (d DuplicateCommandHandlerError) Error() string {
return fmt.Sprintf("command handler for command %s already exists", d.CommandName)
}
// AddHandlersToRouter adds the CommandProcessor's handlers to the given router.
// It should be called only once per CommandProcessor instance.
//
// It is required to call AddHandlersToRouter only if command processor is created with NewCommandProcessor (disableRouterAutoAddHandlers is set to true).
// Deprecated: please migrate to command processor created by NewCommandProcessorWithConfig.
func (p CommandProcessor) AddHandlersToRouter(r *message.Router) error {
if !p.config.disableRouterAutoAddHandlers {
return errors.New("AddHandlersToRouter should be called only when using deprecated NewCommandProcessor")
}
for i := range p.Handlers() {
handler := p.handlers[i]
if _, err := p.addHandlerToRouter(r, handler); err != nil {
return err
}
}
return nil
}
func (p CommandProcessor) addHandlerToRouter(r *message.Router, handler CommandHandler) (*message.Handler, error) {
handlerName := handler.HandlerName()
commandName := p.config.Marshaler.Name(handler.NewCommand())
topicName, err := p.config.GenerateSubscribeTopic(CommandProcessorGenerateSubscribeTopicParams{
CommandName: commandName,
CommandHandler: handler,
})
if err != nil {
return nil, errors.Wrapf(err, "cannot generate topic for command handler %s", handlerName)
}
logger := p.config.Logger.With(watermill.LogFields{
"command_handler_name": handlerName,
"topic": topicName,
})
handlerFunc, err := p.routerHandlerFunc(handler, logger)
if err != nil {
return nil, err
}
logger.Debug("Adding CQRS command handler to router", nil)
subscriber, err := p.config.SubscriberConstructor(CommandProcessorSubscriberConstructorParams{
CommandName: commandName,
HandlerName: handlerName,
Handler: handler,
})
if err != nil {
return nil, errors.Wrap(err, "cannot create subscriber for command processor")
}
return r.AddConsumerHandler(
handlerName,
topicName,
subscriber,
handlerFunc,
), nil
}
// Handlers returns the CommandProcessor's handlers.
func (p CommandProcessor) Handlers() []CommandHandler {
return p.handlers
}
func (p CommandProcessor) routerHandlerFunc(handler CommandHandler, logger watermill.LoggerAdapter) (message.NoPublishHandlerFunc, error) {
cmd := handler.NewCommand()
cmdName := p.config.Marshaler.Name(cmd)
if err := p.validateCommand(cmd); err != nil {
return nil, err
}
return func(msg *message.Message) error {
cmd := handler.NewCommand()
messageCmdName := p.config.Marshaler.NameFromMessage(msg)
if messageCmdName != cmdName {
logger.Trace("Received different command type than expected, ignoring", watermill.LogFields{
"message_uuid": msg.UUID,
"expected_command_type": cmdName,
"received_command_type": messageCmdName,
})
return nil
}
logger.Debug("Handling command", watermill.LogFields{
"message_uuid": msg.UUID,
"received_command_type": messageCmdName,
})
ctx := CtxWithOriginalMessage(msg.Context(), msg)
msg.SetContext(ctx)
if err := p.config.Marshaler.Unmarshal(msg, cmd); err != nil {
return err
}
handle := func(params CommandProcessorOnHandleParams) (err error) {
return params.Handler.Handle(ctx, params.Command)
}
if p.config.OnHandle != nil {
handle = p.config.OnHandle
}
err := handle(CommandProcessorOnHandleParams{
Handler: handler,
CommandName: messageCmdName,
Command: cmd,
Message: msg,
})
if p.config.AckCommandHandlingErrors && err != nil {
logger.Error("Error when handling command, acking (AckCommandHandlingErrors is enabled)", err, nil)
return nil
}
if err != nil {
logger.Debug("Error when handling command, nacking", watermill.LogFields{"err": err})
return err
}
return nil
}, nil
}
func (p CommandProcessor) validateCommand(cmd interface{}) error {
// CommandHandler's NewCommand must return a pointer, because it is used to unmarshal
if err := isPointer(cmd); err != nil {
return errors.Wrap(err, "command must be a non-nil pointer")
}
return nil
}

View File

@@ -0,0 +1,522 @@
package cqrs_test
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/ThreeDotsLabs/watermill"
"github.com/pkg/errors"
"github.com/ThreeDotsLabs/watermill/components/cqrs"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCommandProcessorConfig_Validate(t *testing.T) {
testCases := []struct {
Name string
ModifyValidConfig func(*cqrs.CommandProcessorConfig)
ExpectedErr error
}{
{
Name: "valid_config",
ModifyValidConfig: nil,
ExpectedErr: nil,
},
{
Name: "missing_Marshaler",
ModifyValidConfig: func(c *cqrs.CommandProcessorConfig) {
c.Marshaler = nil
},
ExpectedErr: errors.Errorf("missing Marshaler"),
},
{
Name: "missing_SubscriberConstructor",
ModifyValidConfig: func(c *cqrs.CommandProcessorConfig) {
c.SubscriberConstructor = nil
},
ExpectedErr: errors.Errorf("missing SubscriberConstructor"),
},
{
Name: "missing_GenerateHandlerSubscribeTopic",
ModifyValidConfig: func(c *cqrs.CommandProcessorConfig) {
c.GenerateSubscribeTopic = nil
},
ExpectedErr: errors.Errorf("missing GenerateSubscribeTopic"),
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.Name, func(t *testing.T) {
validConfig := cqrs.CommandProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
return "", nil
},
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return nil, nil
},
Marshaler: cqrs.JSONMarshaler{},
}
if tc.ModifyValidConfig != nil {
tc.ModifyValidConfig(&validConfig)
}
err := validConfig.Validate()
if tc.ExpectedErr == nil {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tc.ExpectedErr.Error())
}
})
}
}
func TestNewCommandProcessor(t *testing.T) {
config := cqrs.CommandProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
return "", nil
},
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return nil, nil
},
Marshaler: cqrs.JSONMarshaler{},
}
require.NoError(t, config.Validate())
router, err := message.NewRouter(message.RouterConfig{}, watermill.NewStdLogger(false, false))
require.NoError(t, err)
cp, err := cqrs.NewCommandProcessorWithConfig(router, config)
assert.NotNil(t, cp)
assert.NoError(t, err)
config.SubscriberConstructor = nil
require.Error(t, config.Validate())
cp, err = cqrs.NewCommandProcessorWithConfig(router, config)
assert.Nil(t, cp)
assert.Error(t, err)
}
type nonPointerCommandHandler struct {
}
func (nonPointerCommandHandler) HandlerName() string {
return "nonPointerCommandHandler"
}
func (nonPointerCommandHandler) NewCommand() interface{} {
return TestCommand{}
}
func (nonPointerCommandHandler) Handle(ctx context.Context, cmd interface{}) error {
panic("not implemented")
}
func TestCommandProcessor_non_pointer_command(t *testing.T) {
ts := NewTestServices()
handler := nonPointerCommandHandler{}
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
commandProcessor, err := cqrs.NewCommandProcessorWithConfig(
router,
cqrs.CommandProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
return "", nil
},
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return nil, nil
},
Marshaler: ts.Marshaler,
Logger: ts.Logger,
},
)
require.NoError(t, err)
err = commandProcessor.AddHandlers(handler)
assert.IsType(t, cqrs.NonPointerError{}, errors.Cause(err))
}
// TestCommandProcessor_multiple_same_command_handlers checks, that we don't register multiple handlers for the same command.
func TestCommandProcessor_multiple_same_command_handlers(t *testing.T) {
ts := NewTestServices()
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
commandProcessor, err := cqrs.NewCommandProcessorWithConfig(
router,
cqrs.CommandProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
return "", nil
},
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return nil, nil
},
Marshaler: ts.Marshaler,
Logger: ts.Logger,
},
)
require.NoError(t, err)
err = commandProcessor.AddHandlers(
&CaptureCommandHandler{},
&CaptureCommandHandler{},
)
require.Error(t, err)
assert.EqualValues(t, cqrs.DuplicateCommandHandlerError{CommandName: "cqrs_test.TestCommand"}, err)
assert.Equal(t, "command handler for command cqrs_test.TestCommand already exists", err.Error())
}
type mockSubscriber struct {
MessagesToSend []*message.Message
WaitForAckBeforeSendingNext bool
out chan *message.Message
}
func (m *mockSubscriber) Subscribe(ctx context.Context, topic string) (<-chan *message.Message, error) {
m.out = make(chan *message.Message)
go func() {
for _, msg := range m.MessagesToSend {
m.out <- msg
if m.WaitForAckBeforeSendingNext {
<-msg.Acked()
}
}
}()
return m.out, nil
}
func (m mockSubscriber) Close() error {
close(m.out)
return nil
}
func TestCommandProcessor_AckCommandHandlingErrors_option_true(t *testing.T) {
logger := watermill.NewCaptureLogger()
marshaler := cqrs.JSONMarshaler{}
msgToSend, err := marshaler.Marshal(&TestCommand{ID: "1"})
require.NoError(t, err)
mockSub := &mockSubscriber{
MessagesToSend: []*message.Message{
msgToSend,
},
}
router, err := message.NewRouter(message.RouterConfig{}, logger)
require.NoError(t, err)
commandProcessor, err := cqrs.NewCommandProcessorWithConfig(
router,
cqrs.CommandProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
return "commands", nil
},
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return mockSub, nil
},
Marshaler: marshaler,
Logger: logger,
AckCommandHandlingErrors: true,
},
)
require.NoError(t, err)
expectedErr := errors.New("test error")
err = commandProcessor.AddHandlers(cqrs.NewCommandHandler(
"handler", func(ctx context.Context, cmd *TestCommand) error {
return expectedErr
}),
)
require.NoError(t, err)
go func() {
err := router.Run(context.Background())
assert.NoError(t, err)
}()
<-router.Running()
select {
case <-msgToSend.Acked():
// ok
case <-msgToSend.Nacked():
// nack received
t.Fatal("nack received, message should be acked")
case <-time.After(1 * time.Second):
t.Fatal("timeout waiting for ack")
}
// it's pretty important to not ack message silently, so let's assert if it's logged properly
expectedLogMessage := watermill.CapturedMessage{
Level: watermill.ErrorLogLevel,
Fields: map[string]any{
"command_handler_name": "handler",
"topic": "commands",
},
Msg: "Error when handling command, acking (AckCommandHandlingErrors is enabled)",
Err: expectedErr,
}
assert.True(
t,
logger.Has(expectedLogMessage),
"expected log message not found, logs: %#v",
logger.Captured(),
)
}
func TestCommandProcessor_AckCommandHandlingErrors_option_false(t *testing.T) {
logger := watermill.NewCaptureLogger()
marshaler := cqrs.JSONMarshaler{}
msgToSend, err := marshaler.Marshal(&TestCommand{ID: "1"})
require.NoError(t, err)
mockSub := &mockSubscriber{
MessagesToSend: []*message.Message{
msgToSend,
},
}
router, err := message.NewRouter(message.RouterConfig{}, logger)
require.NoError(t, err)
commandProcessor, err := cqrs.NewCommandProcessorWithConfig(
router,
cqrs.CommandProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
return "commands", nil
},
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return mockSub, nil
},
Marshaler: marshaler,
Logger: logger,
AckCommandHandlingErrors: false,
},
)
require.NoError(t, err)
expectedErr := errors.New("test error")
err = commandProcessor.AddHandlers(cqrs.NewCommandHandler(
"handler", func(ctx context.Context, cmd *TestCommand) error {
return expectedErr
}),
)
require.NoError(t, err)
go func() {
err := router.Run(context.Background())
assert.NoError(t, err)
}()
<-router.Running()
select {
case <-msgToSend.Acked():
// nack received
t.Fatal("ack received, message should be nacked")
case <-msgToSend.Nacked():
// ok
case <-time.After(1 * time.Second):
t.Fatal("timeout waiting for ack")
}
}
func TestNewCommandProcessor_OnHandle(t *testing.T) {
ts := NewTestServices()
msg1, err := ts.Marshaler.Marshal(&TestCommand{ID: "1"})
require.NoError(t, err)
msg2, err := ts.Marshaler.Marshal(&TestCommand{ID: "2"})
require.NoError(t, err)
mockSub := &mockSubscriber{
MessagesToSend: []*message.Message{
msg1,
msg2,
},
}
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
handlerCalled := 0
defer func() {
// for msg 1 we are not calling handler - but returning before
assert.Equal(t, 1, handlerCalled)
}()
handler := cqrs.NewCommandHandler("test", func(ctx context.Context, cmd *TestCommand) error {
handlerCalled++
return nil
})
onHandleCalled := int64(0)
config := cqrs.CommandProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
return "commands", nil
},
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return mockSub, nil
},
OnHandle: func(params cqrs.CommandProcessorOnHandleParams) error {
atomic.AddInt64(&onHandleCalled, 1)
assert.IsType(t, &TestCommand{}, params.Command)
assert.Equal(t, "cqrs_test.TestCommand", params.CommandName)
assert.Equal(t, handler, params.Handler)
if params.Command.(*TestCommand).ID == "1" {
assert.Equal(t, msg1, params.Message)
return errors.New("test error")
} else {
assert.Equal(t, msg2, params.Message)
}
return params.Handler.Handle(params.Message.Context(), params.Command)
},
Marshaler: ts.Marshaler,
Logger: ts.Logger,
}
cp, err := cqrs.NewCommandProcessorWithConfig(router, config)
require.NoError(t, err)
err = cp.AddHandlers(handler)
require.NoError(t, err)
go func() {
err := router.Run(context.Background())
assert.NoError(t, err)
}()
<-router.Running()
select {
case <-msg1.Nacked():
// ok
case <-msg1.Acked():
// ack received
t.Fatal("ack received, message should be nacked")
}
select {
case <-msg2.Acked():
// ok
case <-msg2.Nacked():
// nack received
}
assert.EqualValues(t, 2, onHandleCalled)
}
func TestCommandProcessor_AddHandlersToRouter_without_disableRouterAutoAddHandlers(t *testing.T) {
ts := NewTestServices()
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
cp, err := cqrs.NewCommandProcessorWithConfig(
router,
cqrs.CommandProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
return "commands", nil
},
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return ts.CommandsPubSub, nil
},
Marshaler: ts.Marshaler,
Logger: ts.Logger,
},
)
require.NoError(t, err)
err = cp.AddHandlersToRouter(router)
assert.ErrorContains(t, err, "AddHandlersToRouter should be called only when using deprecated NewCommandProcessor")
}
func TestCommandProcessor_original_msg_set_to_ctx(t *testing.T) {
logger := watermill.NewCaptureLogger()
marshaler := cqrs.JSONMarshaler{}
msgToSend, err := marshaler.Marshal(&TestCommand{ID: "1"})
require.NoError(t, err)
mockSub := &mockSubscriber{
MessagesToSend: []*message.Message{
msgToSend,
},
}
router, err := message.NewRouter(message.RouterConfig{}, logger)
require.NoError(t, err)
commandProcessor, err := cqrs.NewCommandProcessorWithConfig(
router,
cqrs.CommandProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
return "commands", nil
},
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return mockSub, nil
},
Marshaler: marshaler,
Logger: logger,
AckCommandHandlingErrors: true,
},
)
require.NoError(t, err)
var msgFromCtx *message.Message
err = commandProcessor.AddHandlers(cqrs.NewCommandHandler(
"handler", func(ctx context.Context, cmd *TestCommand) error {
msgFromCtx = cqrs.OriginalMessageFromCtx(ctx)
return nil
}),
)
require.NoError(t, err)
go func() {
err := router.Run(context.Background())
assert.NoError(t, err)
}()
<-router.Running()
select {
case <-msgToSend.Acked():
// ok
case <-msgToSend.Nacked():
// nack received
t.Fatal("nack received, message should be acked")
case <-time.After(1 * time.Second):
t.Fatal("timeout waiting for ack")
}
require.NotNil(t, msgFromCtx)
assert.Equal(t, msgToSend, msgFromCtx)
}

199
components/cqrs/cqrs.go Normal file
View File

@@ -0,0 +1,199 @@
package cqrs
import (
stdErrors "errors"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/pkg/errors"
)
// Deprecated: use CommandProcessor and EventProcessor instead.
type FacadeConfig struct {
// GenerateCommandsTopic generates topic name based on the command name.
// Command name is generated by CommandEventMarshaler's Name method.
//
// It allows you to use topic per command or one topic for every command.
GenerateCommandsTopic func(commandName string) string
// CommandHandlers return command handlers which should be executed.
CommandHandlers func(commandBus *CommandBus, eventBus *EventBus) []CommandHandler
// CommandsPublisher is Publisher used to publish commands.
CommandsPublisher message.Publisher
// CommandsSubscriberConstructor is constructor for subscribers which will subscribe for messages.
// It will be called for every command handler.
// It allows you to create separated customized Subscriber for every command handler.
CommandsSubscriberConstructor CommandsSubscriberConstructor
// GenerateEventsTopic generates topic name based on the event name.
// Event name is generated by CommandEventMarshaler's Name method.
//
// It allows you to use topic per command or one topic for every command.
GenerateEventsTopic func(eventName string) string
// EventHandlers return event handlers which should be executed.
EventHandlers func(commandBus *CommandBus, eventBus *EventBus) []EventHandler
// EventsPublisher is Publisher used to publish commands.
EventsPublisher message.Publisher
// EventsSubscriberConstructor is constructor for subscribers which will subscribe for messages.
// It will be called for every event handler.
// It allows you to create separated customized Subscriber for every event handler.
EventsSubscriberConstructor EventsSubscriberConstructor
// Router is a Watermill router, which will be used to handle events and commands.
// Router handlers will be automatically generated by AddHandlersToRouter of Command and Event handlers.
Router *message.Router
CommandEventMarshaler CommandEventMarshaler
Logger watermill.LoggerAdapter
}
func (c FacadeConfig) Validate() error {
var err error
if c.CommandsEnabled() {
if c.GenerateCommandsTopic == nil {
err = stdErrors.Join(err, errors.New("GenerateCommandsTopic is nil"))
}
if c.CommandsSubscriberConstructor == nil {
err = stdErrors.Join(err, errors.New("CommandsSubscriberConstructor is nil"))
}
if c.CommandsPublisher == nil {
err = stdErrors.Join(err, errors.New("CommandsPublisher is nil"))
}
}
if c.EventsEnabled() {
if c.GenerateEventsTopic == nil {
err = stdErrors.Join(err, errors.New("GenerateEventsTopic is nil"))
}
if c.EventsSubscriberConstructor == nil {
err = stdErrors.Join(err, errors.New("EventsSubscriberConstructor is nil"))
}
if c.EventsPublisher == nil {
err = stdErrors.Join(err, errors.New("EventsPublisher is nil"))
}
}
if c.Router == nil {
err = stdErrors.Join(err, errors.New("Router is nil"))
}
if c.Logger == nil {
err = stdErrors.Join(err, errors.New("Logger is nil"))
}
if c.CommandEventMarshaler == nil {
err = stdErrors.Join(err, errors.New("CommandEventMarshaler is nil"))
}
return err
}
func (c FacadeConfig) EventsEnabled() bool {
return c.GenerateEventsTopic != nil || c.EventsPublisher != nil || c.EventsSubscriberConstructor != nil
}
func (c FacadeConfig) CommandsEnabled() bool {
return c.GenerateCommandsTopic != nil || c.CommandsPublisher != nil || c.CommandsSubscriberConstructor != nil
}
// Deprecated: use CommandHandler and EventHandler instead.
//
// Facade is a facade for creating the Command and Event buses and processors.
// It was created to avoid boilerplate, when using CQRS in the standard way.
// You can also create buses and processors manually, drawing inspiration from how it's done in NewFacade.
type Facade struct {
commandsTopic func(commandName string) string
commandBus *CommandBus
eventsTopic func(eventName string) string
eventBus *EventBus
commandEventMarshaler CommandEventMarshaler
}
func (f Facade) CommandBus() *CommandBus {
return f.commandBus
}
func (f Facade) EventBus() *EventBus {
return f.eventBus
}
func (f Facade) CommandEventMarshaler() CommandEventMarshaler {
return f.commandEventMarshaler
}
// Deprecated: use CommandHandler and EventHandler instead.
func NewFacade(config FacadeConfig) (*Facade, error) {
if err := config.Validate(); err != nil {
return nil, errors.Wrap(err, "invalid config")
}
c := &Facade{
commandsTopic: config.GenerateCommandsTopic,
eventsTopic: config.GenerateEventsTopic,
commandEventMarshaler: config.CommandEventMarshaler,
}
if config.CommandsEnabled() {
var err error
c.commandBus, err = NewCommandBus(
config.CommandsPublisher,
config.GenerateCommandsTopic,
config.CommandEventMarshaler,
)
if err != nil {
return nil, errors.Wrap(err, "cannot create command bus")
}
} else {
config.Logger.Info("Empty GenerateCommandsTopic, command bus will be not created", nil)
}
if config.EventsEnabled() {
var err error
c.eventBus, err = NewEventBus(config.EventsPublisher, config.GenerateEventsTopic, config.CommandEventMarshaler)
if err != nil {
return nil, errors.Wrap(err, "cannot create event bus")
}
} else {
config.Logger.Info("Empty GenerateEventsTopic, event bus will be not created", nil)
}
if config.CommandHandlers != nil {
commandProcessor, err := NewCommandProcessor(
config.CommandHandlers(c.commandBus, c.eventBus),
config.GenerateCommandsTopic,
config.CommandsSubscriberConstructor,
config.CommandEventMarshaler,
config.Logger,
)
if err != nil {
return nil, errors.Wrap(err, "cannot create command processor")
}
if err := commandProcessor.AddHandlersToRouter(config.Router); err != nil {
return nil, err
}
}
if config.EventHandlers != nil {
eventProcessor, err := NewEventProcessor(
config.EventHandlers(c.commandBus, c.eventBus),
config.GenerateEventsTopic,
config.EventsSubscriberConstructor,
config.CommandEventMarshaler,
config.Logger,
)
if err != nil {
return nil, errors.Wrap(err, "cannot create event processor")
}
if err := eventProcessor.AddHandlersToRouter(config.Router); err != nil {
return nil, err
}
}
return c, nil
}

View File

@@ -0,0 +1,490 @@
package cqrs_test
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/components/cqrs"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
)
// TestCQRS is functional test of CQRS command handler and event handler.
func TestCQRS(t *testing.T) {
testCases := []struct {
Name string
CreateCqrs func(t *testing.T, cc *CaptureCommandHandler, ce *CaptureEventHandler) (*message.Router, *cqrs.CommandBus, *cqrs.EventBus)
}{
{
// facade is deprecated, testing backwards compatibility
Name: "facade",
CreateCqrs: func(t *testing.T, cc *CaptureCommandHandler, ce *CaptureEventHandler) (*message.Router, *cqrs.CommandBus, *cqrs.EventBus) {
router, cqrsFacade := createRouterAndFacade(t, cc, ce)
return router, cqrsFacade.CommandBus(), cqrsFacade.EventBus()
},
},
{
Name: "constructors",
CreateCqrs: createCqrsComponents,
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.Name, func(t *testing.T) {
captureCommandHandler := &CaptureCommandHandler{}
captureEventHandler := &CaptureEventHandler{}
router, commandBus, eventBus := tc.CreateCqrs(t, captureCommandHandler, captureEventHandler)
pointerCmd := &TestCommand{ID: watermill.NewULID()}
require.NoError(t, commandBus.Send(context.Background(), pointerCmd))
assert.EqualValues(t, []interface{}{pointerCmd}, captureCommandHandler.HandledCommands())
captureCommandHandler.Reset()
nonPointerCmd := TestCommand{ID: watermill.NewULID()}
require.NoError(t, commandBus.Send(context.Background(), nonPointerCmd))
// command is always unmarshaled to pointer value
assert.EqualValues(t, []interface{}{&nonPointerCmd}, captureCommandHandler.HandledCommands())
captureCommandHandler.Reset()
pointerEvent := &TestEvent{ID: watermill.NewULID()}
require.NoError(t, eventBus.Publish(context.Background(), pointerEvent))
assert.EqualValues(t, []interface{}{pointerEvent}, captureEventHandler.HandledEvents())
captureEventHandler.Reset()
nonPointerEvent := TestEvent{ID: watermill.NewULID()}
require.NoError(t, eventBus.Publish(context.Background(), nonPointerEvent))
// event is always unmarshaled to pointer value
assert.EqualValues(t, []interface{}{&nonPointerEvent}, captureEventHandler.HandledEvents())
captureEventHandler.Reset()
assert.NoError(t, router.Close())
})
}
}
func createCqrsComponents(t *testing.T, commandHandler *CaptureCommandHandler, eventHandler *CaptureEventHandler) (*message.Router, *cqrs.CommandBus, *cqrs.EventBus) {
ts := NewTestServices()
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
eventProcessor, err := cqrs.NewEventProcessorWithConfig(
router,
cqrs.EventProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
return params.EventName, nil
},
AckOnUnknownEvent: true,
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
assert.Equal(t, "CaptureEventHandler", params.HandlerName)
assert.Implements(t, new(cqrs.EventHandler), params.EventHandler)
assert.NotNil(t, params.EventHandler)
return ts.EventsPubSub, nil
},
Marshaler: ts.Marshaler,
Logger: ts.Logger,
},
)
require.NoError(t, err)
err = eventProcessor.AddHandlers(eventHandler)
require.NoError(t, err)
eventBus, err := cqrs.NewEventBusWithConfig(
ts.EventsPubSub,
cqrs.EventBusConfig{
GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) {
assert.Equal(t, "cqrs_test.TestEvent", params.EventName)
switch cmd := params.Event.(type) {
case *TestEvent:
assert.NotEmpty(t, cmd.ID)
case TestEvent:
assert.NotEmpty(t, cmd.ID)
default:
assert.Fail(t, "unexpected command type: %T", cmd)
}
assert.NotEmpty(t, params.Event)
return params.EventName, nil
},
Marshaler: ts.Marshaler,
Logger: ts.Logger,
},
)
require.NoError(t, err)
commandProcessor, err := cqrs.NewCommandProcessorWithConfig(
router,
cqrs.CommandProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
assert.Equal(t, "cqrs_test.TestCommand", params.CommandName)
assert.Implements(t, new(cqrs.CommandHandler), params.CommandHandler)
assert.NotNil(t, params.CommandHandler)
return params.CommandName, nil
},
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
assert.Equal(t, "CaptureCommandHandler", params.HandlerName)
return ts.CommandsPubSub, nil
},
Marshaler: ts.Marshaler,
Logger: ts.Logger,
AckCommandHandlingErrors: false,
},
)
require.NoError(t, err)
err = commandProcessor.AddHandlers(commandHandler)
require.NoError(t, err)
commandBus, err := cqrs.NewCommandBusWithConfig(ts.CommandsPubSub, cqrs.CommandBusConfig{
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
assert.Equal(t, "cqrs_test.TestCommand", params.CommandName)
switch cmd := params.Command.(type) {
case *TestCommand:
assert.NotEmpty(t, cmd.ID)
case TestCommand:
assert.NotEmpty(t, cmd.ID)
default:
assert.Fail(t, "unexpected command type: %T", cmd)
}
assert.NotNil(t, params.Command)
return params.CommandName, nil
},
Marshaler: ts.Marshaler,
Logger: ts.Logger,
})
require.NoError(t, err)
go func() {
require.NoError(t, router.Run(context.Background()))
}()
<-router.Running()
return router, commandBus, eventBus
}
func createRouterAndFacade(t *testing.T, commandHandler *CaptureCommandHandler, eventHandler *CaptureEventHandler) (*message.Router, *cqrs.Facade) {
ts := NewTestServices()
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
c, err := cqrs.NewFacade(cqrs.FacadeConfig{
GenerateCommandsTopic: func(commandName string) string {
assert.Equal(t, "cqrs_test.TestCommand", commandName)
return commandName
},
GenerateEventsTopic: func(eventName string) string {
assert.Equal(t, "cqrs_test.TestEvent", eventName)
return eventName
},
CommandHandlers: func(cb *cqrs.CommandBus, eb *cqrs.EventBus) []cqrs.CommandHandler {
require.NotNil(t, cb)
require.NotNil(t, eb)
return []cqrs.CommandHandler{commandHandler}
},
EventHandlers: func(cb *cqrs.CommandBus, eb *cqrs.EventBus) []cqrs.EventHandler {
require.NotNil(t, cb)
require.NotNil(t, eb)
return []cqrs.EventHandler{eventHandler}
},
Router: router,
CommandsPublisher: ts.CommandsPubSub,
CommandsSubscriberConstructor: func(handlerName string) (message.Subscriber, error) {
assert.Equal(t, "CaptureCommandHandler", handlerName)
return ts.CommandsPubSub, nil
},
EventsPublisher: ts.EventsPubSub,
EventsSubscriberConstructor: func(handlerName string) (message.Subscriber, error) {
assert.Equal(t, "CaptureEventHandler", handlerName)
return ts.EventsPubSub, nil
},
Logger: ts.Logger,
CommandEventMarshaler: ts.Marshaler,
})
require.NoError(t, err)
go func() {
require.NoError(t, router.Run(context.Background()))
}()
<-router.Running()
assert.Equal(t, c.CommandEventMarshaler(), ts.Marshaler)
return router, c
}
type TestServices struct {
Logger watermill.LoggerAdapter
CommandsPubSub *gochannel.GoChannel
EventsPubSub *gochannel.GoChannel
Marshaler cqrs.CommandEventMarshaler
}
func NewTestServices() TestServices {
logger := watermill.NewStdLogger(true, true)
return TestServices{
Logger: logger,
CommandsPubSub: gochannel.NewGoChannel(
gochannel.Config{BlockPublishUntilSubscriberAck: true},
logger,
),
EventsPubSub: gochannel.NewGoChannel(
gochannel.Config{BlockPublishUntilSubscriberAck: true},
logger,
),
Marshaler: cqrs.JSONMarshaler{},
}
}
type TestCommand struct {
ID string
}
type CaptureCommandHandler struct {
handledCommands []interface{}
}
func (h CaptureCommandHandler) HandlerName() string {
return "CaptureCommandHandler"
}
func (h CaptureCommandHandler) HandledCommands() []interface{} {
return h.handledCommands
}
func (h *CaptureCommandHandler) Reset() {
h.handledCommands = nil
}
func (CaptureCommandHandler) NewCommand() interface{} {
return &TestCommand{}
}
func (h *CaptureCommandHandler) Handle(ctx context.Context, cmd interface{}) error {
h.handledCommands = append(h.handledCommands, cmd.(*TestCommand))
return nil
}
type TestEvent struct {
ID string
When time.Time
}
type AnotherTestEvent struct {
ID string
}
type CaptureEventHandler struct {
handledEvents []interface{}
}
func (h CaptureEventHandler) HandlerName() string {
return "CaptureEventHandler"
}
func (h CaptureEventHandler) HandledEvents() []interface{} {
return h.handledEvents
}
func (h *CaptureEventHandler) Reset() {
h.handledEvents = nil
}
func (CaptureEventHandler) NewEvent() interface{} {
return &TestEvent{}
}
func (h *CaptureEventHandler) Handle(ctx context.Context, event interface{}) error {
h.handledEvents = append(h.handledEvents, event.(*TestEvent))
return nil
}
type assertPublishTopicPublisher struct {
ExpectedTopic string
T *testing.T
}
func (a assertPublishTopicPublisher) Publish(topic string, messages ...*message.Message) error {
assert.Equal(a.T, a.ExpectedTopic, topic)
return nil
}
func (assertPublishTopicPublisher) Close() error {
return nil
}
type publisherStub struct {
messages map[string]message.Messages
mu sync.Mutex
}
func newPublisherStub() *publisherStub {
return &publisherStub{
messages: make(map[string]message.Messages),
}
}
func (*publisherStub) Close() error {
return nil
}
func (p *publisherStub) Publish(topic string, messages ...*message.Message) error {
p.mu.Lock()
defer p.mu.Unlock()
p.messages[topic] = append(p.messages[topic], messages...)
return nil
}
func TestFacadeConfig_Validate(t *testing.T) {
ts := NewTestServices()
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
validConfig := cqrs.FacadeConfig{
GenerateCommandsTopic: func(commandName string) string {
return commandName
},
GenerateEventsTopic: func(eventName string) string {
return eventName
},
CommandHandlers: func(cb *cqrs.CommandBus, eb *cqrs.EventBus) []cqrs.CommandHandler {
return []cqrs.CommandHandler{}
},
EventHandlers: func(cb *cqrs.CommandBus, eb *cqrs.EventBus) []cqrs.EventHandler {
return []cqrs.EventHandler{}
},
Router: router,
CommandsPublisher: ts.CommandsPubSub,
CommandsSubscriberConstructor: func(handlerName string) (message.Subscriber, error) {
return ts.CommandsPubSub, nil
},
EventsPublisher: ts.EventsPubSub,
EventsSubscriberConstructor: func(handlerName string) (message.Subscriber, error) {
return ts.EventsPubSub, nil
},
Logger: ts.Logger,
CommandEventMarshaler: ts.Marshaler,
}
testCases := []struct {
Name string
Config cqrs.FacadeConfig
Valid bool
}{
{
Name: "valid",
Config: validConfig,
Valid: true,
},
{
Name: "missing_GenerateCommandsTopic",
Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) {
config.GenerateCommandsTopic = nil
}),
Valid: false,
},
{
Name: "missing_CommandsSubscriberConstructor",
Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) {
config.CommandsSubscriberConstructor = nil
}),
Valid: false,
},
{
Name: "missing_CommandsPublisher",
Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) {
config.CommandsPublisher = nil
}),
Valid: false,
},
{
Name: "missing_GenerateEventsTopic",
Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) {
config.GenerateEventsTopic = nil
}),
Valid: false,
},
{
Name: "missing_GenerateEventsTopic",
Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) {
config.EventsSubscriberConstructor = nil
}),
Valid: false,
},
{
Name: "missing_EventsPublisher",
Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) {
config.EventsPublisher = nil
}),
Valid: false,
},
{
Name: "missing_Router",
Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) {
config.Router = nil
}),
Valid: false,
},
{
Name: "missing_Logger",
Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) {
config.Logger = nil
}),
Valid: false,
},
{
Name: "missing_CommandEventMarshaler",
Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) {
config.CommandEventMarshaler = nil
}),
Valid: false,
},
}
for _, c := range testCases {
t.Run(c.Name, func(t *testing.T) {
if c.Valid {
assert.NoError(t, c.Config.Validate())
} else {
assert.Error(t, c.Config.Validate())
}
})
}
}
func transformConfig(config cqrs.FacadeConfig, transformFn func(config *cqrs.FacadeConfig)) cqrs.FacadeConfig {
transformFn(&config)
return config
}

27
components/cqrs/ctx.go Normal file
View File

@@ -0,0 +1,27 @@
package cqrs
import (
"context"
"github.com/ThreeDotsLabs/watermill/message"
)
type ctxKey string
const (
originalMessage ctxKey = "original_message"
)
// OriginalMessageFromCtx returns the original message that was received by the event/command handler.
func OriginalMessageFromCtx(ctx context.Context) *message.Message {
val, ok := ctx.Value(originalMessage).(*message.Message)
if !ok {
return nil
}
return val
}
// CtxWithOriginalMessage returns a new context with the original message attached.
func CtxWithOriginalMessage(ctx context.Context, msg *message.Message) context.Context {
return context.WithValue(ctx, originalMessage, msg)
}

3
components/cqrs/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Detailed CQRS documentation can be found in https://watermill.io/docs/cqrs/
package cqrs

View File

@@ -0,0 +1,146 @@
package cqrs
import (
"context"
stdErrors "errors"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/pkg/errors"
)
type EventBusConfig struct {
// GeneratePublishTopic is used to generate topic name for publishing event.
GeneratePublishTopic GenerateEventPublishTopicFn
// OnPublish is called before sending the event.
// The *message.Message can be modified.
//
// This option is not required.
OnPublish OnEventSendFn
// Marshaler is used to marshal and unmarshal events.
// It is required.
Marshaler CommandEventMarshaler
// Logger instance used to log.
// If not provided, watermill.NopLogger is used.
Logger watermill.LoggerAdapter
}
func (c *EventBusConfig) setDefaults() {
if c.Logger == nil {
c.Logger = watermill.NopLogger{}
}
}
func (c EventBusConfig) Validate() error {
var err error
if c.Marshaler == nil {
err = stdErrors.Join(err, errors.New("missing Marshaler"))
}
if c.GeneratePublishTopic == nil {
err = stdErrors.Join(err, errors.New("missing GenerateHandlerTopic"))
}
return err
}
type GenerateEventPublishTopicFn func(GenerateEventPublishTopicParams) (string, error)
type GenerateEventPublishTopicParams struct {
EventName string
Event any
}
type OnEventSendFn func(params OnEventSendParams) error
type OnEventSendParams struct {
EventName string
Event any
// Message is never nil and can be modified.
Message *message.Message
}
// EventBus transports events to event handlers.
type EventBus struct {
publisher message.Publisher
config EventBusConfig
}
// NewEventBus creates a new CommandBus.
// Deprecated: use NewEventBusWithConfig instead.
func NewEventBus(
publisher message.Publisher,
generateTopic func(eventName string) string,
marshaler CommandEventMarshaler,
) (*EventBus, error) {
if publisher == nil {
return nil, errors.New("missing publisher")
}
if generateTopic == nil {
return nil, errors.New("missing generateTopic")
}
if marshaler == nil {
return nil, errors.New("missing marshaler")
}
return &EventBus{
publisher: publisher,
config: EventBusConfig{
GeneratePublishTopic: func(params GenerateEventPublishTopicParams) (string, error) {
return generateTopic(params.EventName), nil
},
Marshaler: marshaler,
},
}, nil
}
// NewEventBusWithConfig creates a new EventBus.
func NewEventBusWithConfig(publisher message.Publisher, config EventBusConfig) (*EventBus, error) {
if publisher == nil {
return nil, errors.New("missing publisher")
}
config.setDefaults()
if err := config.Validate(); err != nil {
return nil, errors.Wrap(err, "invalid config")
}
return &EventBus{publisher, config}, nil
}
// Publish sends event to the event bus.
func (c EventBus) Publish(ctx context.Context, event any) error {
msg, err := c.config.Marshaler.Marshal(event)
if err != nil {
return err
}
eventName := c.config.Marshaler.Name(event)
topicName, err := c.config.GeneratePublishTopic(GenerateEventPublishTopicParams{
EventName: eventName,
Event: event,
})
if err != nil {
return errors.Wrap(err, "cannot generate topic")
}
msg.SetContext(ctx)
if c.config.OnPublish != nil {
err := c.config.OnPublish(OnEventSendParams{
EventName: eventName,
Event: event,
Message: msg,
})
if err != nil {
return errors.Wrap(err, "cannot execute OnPublish")
}
}
return c.publisher.Publish(topicName, msg)
}

View File

@@ -0,0 +1,168 @@
package cqrs_test
import (
"context"
"fmt"
"testing"
"github.com/ThreeDotsLabs/watermill/components/cqrs"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEventBusConfig_Validate(t *testing.T) {
testCases := []struct {
Name string
ModifyValidConfig func(*cqrs.EventBusConfig)
ExpectedErr error
}{
{
Name: "valid_config",
ModifyValidConfig: nil,
ExpectedErr: nil,
},
{
Name: "missing_GenerateEventPublishTopic",
ModifyValidConfig: func(config *cqrs.EventBusConfig) {
config.GeneratePublishTopic = nil
},
ExpectedErr: fmt.Errorf("missing GenerateHandlerTopic"),
},
{
Name: "missing_marshaler",
ModifyValidConfig: func(config *cqrs.EventBusConfig) {
config.Marshaler = nil
},
ExpectedErr: fmt.Errorf("missing Marshaler"),
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.Name, func(t *testing.T) {
validConfig := cqrs.EventBusConfig{
GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) {
return "", nil
},
Marshaler: cqrs.JSONMarshaler{},
}
if tc.ModifyValidConfig != nil {
tc.ModifyValidConfig(&validConfig)
}
err := validConfig.Validate()
if tc.ExpectedErr == nil {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tc.ExpectedErr.Error())
}
})
}
}
func TestNewEventBus(t *testing.T) {
pub := newPublisherStub()
generateTopic := func(commandName string) string {
return ""
}
marshaler := cqrs.JSONMarshaler{}
cb, err := cqrs.NewEventBus(pub, generateTopic, marshaler)
assert.NotNil(t, cb)
assert.NoError(t, err)
cb, err = cqrs.NewEventBus(nil, generateTopic, marshaler)
assert.Nil(t, cb)
assert.Error(t, err)
cb, err = cqrs.NewEventBus(pub, nil, marshaler)
assert.Nil(t, cb)
assert.Error(t, err)
cb, err = cqrs.NewEventBus(pub, generateTopic, nil)
assert.Nil(t, cb)
assert.Error(t, err)
}
func TestEventBus_Send_ContextPropagation(t *testing.T) {
publisher := newPublisherStub()
eventBus, err := cqrs.NewEventBus(
publisher,
func(eventName string) string {
return "whatever"
},
cqrs.JSONMarshaler{},
)
require.NoError(t, err)
ctx := context.WithValue(context.Background(), contextKey("key"), "value")
err = eventBus.Publish(ctx, "message")
require.NoError(t, err)
assert.Equal(t, ctx, publisher.messages["whatever"][0].Context())
}
func TestEventBus_Send_topic_name(t *testing.T) {
cb, err := cqrs.NewEventBus(
assertPublishTopicPublisher{ExpectedTopic: "cqrs_test.TestEvent", T: t},
func(commandName string) string {
return commandName
},
cqrs.JSONMarshaler{},
)
require.NoError(t, err)
err = cb.Publish(context.Background(), TestEvent{})
require.NoError(t, err)
}
func TestEventBus_Send_OnPublish(t *testing.T) {
publisher := newPublisherStub()
eb, err := cqrs.NewEventBusWithConfig(
publisher,
cqrs.EventBusConfig{
GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) {
return "whatever", nil
},
Marshaler: cqrs.JSONMarshaler{},
OnPublish: func(params cqrs.OnEventSendParams) error {
params.Message.Metadata.Set("key", "value")
return nil
},
},
)
require.NoError(t, err)
err = eb.Publish(context.Background(), TestEvent{})
require.NoError(t, err)
assert.Equal(t, "value", publisher.messages["whatever"][0].Metadata.Get("key"))
}
func TestEventBus_Send_OnPublish_error(t *testing.T) {
publisher := newPublisherStub()
expectedErr := errors.New("some error")
eb, err := cqrs.NewEventBusWithConfig(
publisher,
cqrs.EventBusConfig{
GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) {
return "whatever", nil
},
Marshaler: cqrs.JSONMarshaler{},
OnPublish: func(params cqrs.OnEventSendParams) error {
return expectedErr
},
},
)
require.NoError(t, err)
err = eb.Publish(context.Background(), TestEvent{})
require.EqualError(t, err, "cannot execute OnPublish: some error")
}

View File

@@ -0,0 +1,73 @@
package cqrs
import (
"context"
)
// EventHandler receives events defined by NewEvent and handles them with its Handle method.
// If using DDD, CommandHandler may modify and persist the aggregate.
// It can also invoke a process manager, a saga or just build a read model.
//
// In contrast to CommandHandler, every Event can have multiple EventHandlers.
//
// One instance of EventHandler is used during handling messages.
// When multiple events are delivered at the same time, Handle method can be executed multiple times at the same time.
// Because of that, Handle method needs to be thread safe!
type EventHandler interface {
// HandlerName is the name used in message.Router while creating handler.
//
// It will be also passed to EventsSubscriberConstructor.
// May be useful, for example, to create a consumer group per each handler.
//
// WARNING: If HandlerName was changed and is used for generating consumer groups,
// it may result with **reconsuming all messages** !!!
HandlerName() string
NewEvent() any
Handle(ctx context.Context, event any) error
}
type genericEventHandler[T any] struct {
handleFunc func(ctx context.Context, event *T) error
handlerName string
}
// NewEventHandler creates a new EventHandler implementation based on provided function
// and event type inferred from function argument.
func NewEventHandler[T any](
handlerName string,
handleFunc func(ctx context.Context, event *T) error,
) EventHandler {
return &genericEventHandler[T]{
handleFunc: handleFunc,
handlerName: handlerName,
}
}
func (c genericEventHandler[T]) HandlerName() string {
return c.handlerName
}
func (c genericEventHandler[T]) NewEvent() any {
tVar := new(T)
return tVar
}
func (c genericEventHandler[T]) Handle(ctx context.Context, e any) error {
event := e.(*T)
return c.handleFunc(ctx, event)
}
type GroupEventHandler interface {
NewEvent() interface{}
Handle(ctx context.Context, event interface{}) error
}
// NewGroupEventHandler creates a new GroupEventHandler implementation based on provided function
// and event type inferred from function argument.
func NewGroupEventHandler[T any](handleFunc func(ctx context.Context, event *T) error) GroupEventHandler {
return &genericEventHandler[T]{
handleFunc: handleFunc,
}
}

View File

@@ -0,0 +1,48 @@
package cqrs_test
import (
"context"
"fmt"
"testing"
"github.com/ThreeDotsLabs/watermill/components/cqrs"
"github.com/stretchr/testify/assert"
)
type SomeEvent struct {
Foo string
}
func TestNewEventHandler(t *testing.T) {
cmdToSend := &SomeEvent{"bar"}
ch := cqrs.NewEventHandler(
"some_handler",
func(ctx context.Context, cmd *SomeEvent) error {
assert.Equal(t, cmdToSend, cmd)
return fmt.Errorf("some error")
},
)
assert.Equal(t, "some_handler", ch.HandlerName())
assert.Equal(t, &SomeEvent{}, ch.NewEvent())
err := ch.Handle(context.Background(), cmdToSend)
assert.EqualError(t, err, "some error")
}
func TestNewGroupEventHandler(t *testing.T) {
cmdToSend := &SomeEvent{"bar"}
ch := cqrs.NewGroupEventHandler(
func(ctx context.Context, cmd *SomeEvent) error {
assert.Equal(t, cmdToSend, cmd)
return fmt.Errorf("some error")
},
)
assert.Equal(t, &SomeEvent{}, ch.NewEvent())
err := ch.Handle(context.Background(), cmdToSend)
assert.EqualError(t, err, "some error")
}

View File

@@ -0,0 +1,380 @@
package cqrs
import (
stdErrors "errors"
"fmt"
"github.com/pkg/errors"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
)
type EventProcessorConfig struct {
// GenerateSubscribeTopic is used to generate topic for subscribing to events.
// If event processor is using handler groups, GenerateSubscribeTopic is used instead.
GenerateSubscribeTopic EventProcessorGenerateSubscribeTopicFn
// SubscriberConstructor is used to create subscriber for EventHandler.
//
// This function is called for every EventHandler instance.
// If you want to re-use one subscriber for multiple handlers, use GroupEventProcessor instead.
SubscriberConstructor EventProcessorSubscriberConstructorFn
// OnHandle is called before handling event.
// OnHandle works in a similar way to middlewares: you can inject additional logic before and after handling an event.
//
// Because of that, you need to explicitly call params.Handler.Handle() to handle the event.
//
// func(params EventProcessorOnHandleParams) (err error) {
// // logic before handle
// // (...)
//
// err := params.Handler.Handle(params.Message.Context(), params.Event)
//
// // logic after handle
// // (...)
//
// return err
// }
//
// This option is not required.
OnHandle EventProcessorOnHandleFn
// AckOnUnknownEvent is used to decide if message should be acked if event has no handler defined.
AckOnUnknownEvent bool
// Marshaler is used to marshal and unmarshal events.
// It is required.
Marshaler CommandEventMarshaler
// Logger instance used to log.
// If not provided, watermill.NopLogger is used.
Logger watermill.LoggerAdapter
// disableRouterAutoAddHandlers is used to keep backwards compatibility.
// it is set when EventProcessor is created by NewEventProcessor.
// Deprecated: please migrate to NewEventProcessorWithConfig.
disableRouterAutoAddHandlers bool
}
func (c *EventProcessorConfig) setDefaults() {
if c.Logger == nil {
c.Logger = watermill.NopLogger{}
}
}
func (c EventProcessorConfig) Validate() error {
var err error
if c.Marshaler == nil {
err = stdErrors.Join(err, errors.New("missing Marshaler"))
}
if c.GenerateSubscribeTopic == nil {
err = stdErrors.Join(err, errors.New("missing GenerateHandlerTopic"))
}
if c.SubscriberConstructor == nil {
err = stdErrors.Join(err, errors.New("missing SubscriberConstructor"))
}
return err
}
type EventProcessorGenerateSubscribeTopicFn func(EventProcessorGenerateSubscribeTopicParams) (string, error)
type EventProcessorGenerateSubscribeTopicParams struct {
EventName string
EventHandler EventHandler
}
type EventProcessorSubscriberConstructorFn func(EventProcessorSubscriberConstructorParams) (message.Subscriber, error)
type EventProcessorSubscriberConstructorParams struct {
EventName string
HandlerName string
EventHandler EventHandler
}
type EventProcessorOnHandleFn func(params EventProcessorOnHandleParams) error
type EventProcessorOnHandleParams struct {
Handler EventHandler
Event any
EventName string
// Message is never nil and can be modified.
Message *message.Message
}
// EventProcessor determines which EventHandler should handle event received from event bus.
type EventProcessor struct {
router *message.Router
handlers []EventHandler
config EventProcessorConfig
}
// NewEventProcessorWithConfig creates a new EventProcessor.
func NewEventProcessorWithConfig(router *message.Router, config EventProcessorConfig) (*EventProcessor, error) {
config.setDefaults()
if err := config.Validate(); err != nil {
return nil, errors.Wrap(err, "invalid config EventProcessor")
}
if router == nil && !config.disableRouterAutoAddHandlers {
return nil, errors.New("missing router")
}
return &EventProcessor{
router: router,
config: config,
}, nil
}
// NewEventProcessor creates a new EventProcessor.
// Deprecated. Use NewEventProcessorWithConfig instead.
func NewEventProcessor(
individualHandlers []EventHandler,
generateTopic func(eventName string) string,
subscriberConstructor EventsSubscriberConstructor,
marshaler CommandEventMarshaler,
logger watermill.LoggerAdapter,
) (*EventProcessor, error) {
if len(individualHandlers) == 0 {
return nil, errors.New("missing handlers")
}
if generateTopic == nil {
return nil, errors.New("nil generateTopic")
}
if subscriberConstructor == nil {
return nil, errors.New("missing subscriberConstructor")
}
if marshaler == nil {
return nil, errors.New("missing marshaler")
}
if logger == nil {
logger = watermill.NopLogger{}
}
eventProcessorConfig := EventProcessorConfig{
AckOnUnknownEvent: true, // this is the previous default behaviour - keeping backwards compatibility
GenerateSubscribeTopic: func(params EventProcessorGenerateSubscribeTopicParams) (string, error) {
return generateTopic(params.EventName), nil
},
SubscriberConstructor: func(params EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return subscriberConstructor(params.HandlerName)
},
Marshaler: marshaler,
Logger: logger,
disableRouterAutoAddHandlers: true,
}
eventProcessorConfig.setDefaults()
ep, err := NewEventProcessorWithConfig(nil, eventProcessorConfig)
if err != nil {
return nil, err
}
for _, handler := range individualHandlers {
if err := ep.AddHandlers(handler); err != nil {
return nil, err
}
}
return ep, nil
}
// EventsSubscriberConstructor creates a subscriber for EventHandler.
// It allows you to create separated customized Subscriber for every command handler.
//
// When handler groups are used, handler group is passed as handlerName.
// Deprecated: please use EventProcessorSubscriberConstructorFn instead.
type EventsSubscriberConstructor func(handlerName string) (message.Subscriber, error)
// AddHandlers adds a new EventHandler to the EventProcessor and adds it to the router.
func (p *EventProcessor) AddHandlers(handlers ...EventHandler) error {
if p.config.disableRouterAutoAddHandlers {
p.handlers = append(p.handlers, handlers...)
return nil
}
for _, handler := range handlers {
if _, err := p.addHandlerToRouter(p.router, handler); err != nil {
return err
}
p.handlers = append(p.handlers, handler)
}
return nil
}
// AddHandler adds a new EventHandler to the EventProcessor and adds it to the router.
func (p *EventProcessor) AddHandler(handler EventHandler) (*message.Handler, error) {
if p.config.disableRouterAutoAddHandlers {
p.handlers = append(p.handlers, handler)
return nil, nil
}
h, err := p.addHandlerToRouter(p.router, handler)
if err != nil {
return nil, err
}
p.handlers = append(p.handlers, handler)
return h, nil
}
// AddHandlersToRouter adds the EventProcessor's handlers to the given router.
// It should be called only once per EventProcessor instance.
//
// It is required to call AddHandlersToRouter only if command processor is created with NewEventProcessor (disableRouterAutoAddHandlers is set to true).
// Deprecated: please migrate to event processor created by NewEventProcessorWithConfig.
func (p EventProcessor) AddHandlersToRouter(r *message.Router) error {
if !p.config.disableRouterAutoAddHandlers {
return errors.New("AddHandlersToRouter should be called only when using deprecated NewEventProcessor")
}
for i := range p.handlers {
handler := p.handlers[i]
if _, err := p.addHandlerToRouter(r, handler); err != nil {
return err
}
}
return nil
}
func (p EventProcessor) addHandlerToRouter(r *message.Router, handler EventHandler) (*message.Handler, error) {
if err := validateEvent(handler.NewEvent()); err != nil {
return nil, errors.Wrapf(err, "invalid event for handler %s", handler.HandlerName())
}
handlerName := handler.HandlerName()
eventName := p.config.Marshaler.Name(handler.NewEvent())
topicName, err := p.config.GenerateSubscribeTopic(EventProcessorGenerateSubscribeTopicParams{
EventName: eventName,
EventHandler: handler,
})
if err != nil {
return nil, errors.Wrapf(err, "cannot generate topic name for handler %s", handlerName)
}
logger := p.config.Logger.With(watermill.LogFields{
"event_handler_name": handlerName,
"topic": topicName,
})
handlerFunc, err := p.routerHandlerFunc(handler, logger)
if err != nil {
return nil, err
}
if p.config.SubscriberConstructor == nil {
return nil, errors.New("missing SubscriberConstructor config option")
}
subscriber, err := p.config.SubscriberConstructor(EventProcessorSubscriberConstructorParams{
EventName: eventName,
HandlerName: handlerName,
EventHandler: handler,
})
if err != nil {
return nil, errors.Wrap(err, "cannot create subscriber for event processor")
}
return addHandlerToRouter(p.config.Logger, r, handlerName, topicName, handlerFunc, subscriber), nil
}
func (p EventProcessor) Handlers() []EventHandler {
return p.handlers
}
func addHandlerToRouter(logger watermill.LoggerAdapter, r *message.Router, handlerName string, topicName string, handlerFunc message.NoPublishHandlerFunc, subscriber message.Subscriber) *message.Handler {
logger = logger.With(watermill.LogFields{
"event_handler_name": handlerName,
"topic": topicName,
})
logger.Debug("Adding CQRS event handler to router", nil)
return r.AddConsumerHandler(
handlerName,
topicName,
subscriber,
handlerFunc,
)
}
func (p EventProcessor) routerHandlerFunc(handler EventHandler, logger watermill.LoggerAdapter) (message.NoPublishHandlerFunc, error) {
initEvent := handler.NewEvent()
expectedEventName := p.config.Marshaler.Name(initEvent)
if err := validateEvent(initEvent); err != nil {
return nil, err
}
return func(msg *message.Message) error {
event := handler.NewEvent()
messageEventName := p.config.Marshaler.NameFromMessage(msg)
if messageEventName != expectedEventName {
if !p.config.AckOnUnknownEvent {
return fmt.Errorf("received unexpected event type %s, expected %s", messageEventName, expectedEventName)
} else {
logger.Trace("Received different event type than expected, ignoring", watermill.LogFields{
"message_uuid": msg.UUID,
"expected_event_type": expectedEventName,
"received_event_type": messageEventName,
})
return nil
}
}
logger.Debug("Handling event", watermill.LogFields{
"message_uuid": msg.UUID,
"received_event_type": messageEventName,
})
ctx := CtxWithOriginalMessage(msg.Context(), msg)
msg.SetContext(ctx)
if err := p.config.Marshaler.Unmarshal(msg, event); err != nil {
return err
}
handle := func(params EventProcessorOnHandleParams) error {
return params.Handler.Handle(ctx, params.Event)
}
if p.config.OnHandle != nil {
handle = p.config.OnHandle
}
err := handle(EventProcessorOnHandleParams{
Handler: handler,
Event: event,
EventName: messageEventName,
Message: msg,
})
if err != nil {
logger.Debug("Error when handling event", watermill.LogFields{"err": err})
return err
}
return nil
}, nil
}
func validateEvent(event interface{}) error {
// EventHandler's NewEvent must return a pointer, because it is used to unmarshal
if err := isPointer(event); err != nil {
return errors.Wrap(err, "command must be a non-nil pointer")
}
return nil
}

View File

@@ -0,0 +1,272 @@
package cqrs
import (
stdErrors "errors"
"fmt"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/pkg/errors"
)
type EventGroupProcessorConfig struct {
// GenerateSubscribeTopic is used to generate topic for subscribing to events for handler groups.
// This option is required for EventProcessor if handler groups are used.
GenerateSubscribeTopic EventGroupProcessorGenerateSubscribeTopicFn
// SubscriberConstructor is used to create subscriber for GroupEventHandler.
// This function is called for every events group once - thanks to that it's possible to have one subscription per group.
// It's useful, when we are processing events from one stream and we want to do it in order.
SubscriberConstructor EventGroupProcessorSubscriberConstructorFn
// OnHandle is called before handling event.
// OnHandle works in a similar way to middlewares: you can inject additional logic before and after handling an event.
//
// Because of that, you need to explicitly call params.Handler.Handle() to handle the event.
//
// func(params EventGroupProcessorOnHandleParams) (err error) {
// // logic before handle
// // (...)
//
// err := params.Handler.Handle(params.Message.Context(), params.Event)
//
// // logic after handle
// // (...)
//
// return err
// }
//
// This option is not required.
OnHandle EventGroupProcessorOnHandleFn
// AckOnUnknownEvent is used to decide if message should be acked if event has no handler defined.
AckOnUnknownEvent bool
// Marshaler is used to marshal and unmarshal events.
// It is required.
Marshaler CommandEventMarshaler
// Logger instance used to log.
// If not provided, watermill.NopLogger is used.
Logger watermill.LoggerAdapter
}
func (c *EventGroupProcessorConfig) setDefaults() {
if c.Logger == nil {
c.Logger = watermill.NopLogger{}
}
}
func (c EventGroupProcessorConfig) Validate() error {
var err error
if c.Marshaler == nil {
err = stdErrors.Join(err, errors.New("missing Marshaler"))
}
if c.GenerateSubscribeTopic == nil {
err = stdErrors.Join(err, errors.New("missing GenerateHandlerGroupTopic"))
}
if c.SubscriberConstructor == nil {
err = stdErrors.Join(err, errors.New("missing SubscriberConstructor"))
}
return err
}
type EventGroupProcessorGenerateSubscribeTopicFn func(EventGroupProcessorGenerateSubscribeTopicParams) (string, error)
type EventGroupProcessorGenerateSubscribeTopicParams struct {
EventGroupName string
EventGroupHandlers []GroupEventHandler
}
type EventGroupProcessorSubscriberConstructorFn func(EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error)
type EventGroupProcessorSubscriberConstructorParams struct {
EventGroupName string
EventGroupHandlers []GroupEventHandler
}
type EventGroupProcessorOnHandleFn func(params EventGroupProcessorOnHandleParams) error
type EventGroupProcessorOnHandleParams struct {
GroupName string
Handler GroupEventHandler
Event any
EventName string
// Message is never nil and can be modified.
Message *message.Message
}
// EventGroupProcessor determines which EventHandler should handle event received from event bus.
// Compared to EventProcessor, EventGroupProcessor allows to have multiple handlers that share the same subscriber instance.
type EventGroupProcessor struct {
router *message.Router
groupEventHandlers map[string][]GroupEventHandler
config EventGroupProcessorConfig
}
// NewEventGroupProcessorWithConfig creates a new EventGroupProcessor.
func NewEventGroupProcessorWithConfig(router *message.Router, config EventGroupProcessorConfig) (*EventGroupProcessor, error) {
config.setDefaults()
if err := config.Validate(); err != nil {
return nil, errors.Wrap(err, "invalid config EventProcessor")
}
if router == nil {
return nil, errors.New("missing router")
}
return &EventGroupProcessor{
router: router,
groupEventHandlers: map[string][]GroupEventHandler{},
config: config,
}, nil
}
// AddHandlersGroup adds a new list of GroupEventHandler to the EventGroupProcessor and adds it to the router.
//
// Compared to AddHandlers, AddHandlersGroup allows to have multiple handlers that share the same subscriber instance.
//
// It's allowed to have multiple handlers for the same event type in one group, but we recommend to not do that.
// Please keep in mind that those handlers will be processed within the same message.
// If first handler succeeds and the second fails, the message will be re-delivered and the first will be re-executed.
//
// Handlers group needs to be unique within the EventProcessor instance.
//
// Handler group name is used as handler's name in router.
func (p *EventGroupProcessor) AddHandlersGroup(groupName string, handlers ...GroupEventHandler) error {
if len(handlers) == 0 {
return errors.New("no handlers provided")
}
if _, ok := p.groupEventHandlers[groupName]; ok {
return fmt.Errorf("event handler group '%s' already exists", groupName)
}
if err := p.addHandlerToRouter(p.router, groupName, handlers); err != nil {
return err
}
p.groupEventHandlers[groupName] = handlers
return nil
}
func (p EventGroupProcessor) addHandlerToRouter(r *message.Router, groupName string, handlersGroup []GroupEventHandler) error {
for i, handler := range handlersGroup {
if err := validateEvent(handler.NewEvent()); err != nil {
return errors.Wrapf(
err,
"invalid event for handler %T (num %d) in group %s",
handler,
i,
groupName,
)
}
}
topicName, err := p.config.GenerateSubscribeTopic(EventGroupProcessorGenerateSubscribeTopicParams{
EventGroupName: groupName,
EventGroupHandlers: handlersGroup,
})
if err != nil {
return errors.Wrapf(err, "cannot generate topic name for handler group %s", groupName)
}
logger := p.config.Logger.With(watermill.LogFields{
"event_handler_group_name": groupName,
"topic": topicName,
})
handlerFunc, err := p.routerHandlerGroupFunc(handlersGroup, groupName, logger)
if err != nil {
return err
}
subscriber, err := p.config.SubscriberConstructor(EventGroupProcessorSubscriberConstructorParams{
EventGroupName: groupName,
EventGroupHandlers: handlersGroup,
})
if err != nil {
return errors.Wrap(err, "cannot create subscriber for event processor")
}
_ = addHandlerToRouter(p.config.Logger, r, groupName, topicName, handlerFunc, subscriber)
return nil
}
func (p EventGroupProcessor) routerHandlerGroupFunc(handlers []GroupEventHandler, groupName string, logger watermill.LoggerAdapter) (message.NoPublishHandlerFunc, error) {
return func(msg *message.Message) error {
messageEventName := p.config.Marshaler.NameFromMessage(msg)
handledAnyEvent := false
for _, handler := range handlers {
initEvent := handler.NewEvent()
expectedEventName := p.config.Marshaler.Name(initEvent)
event := handler.NewEvent()
if messageEventName != expectedEventName {
logger.Trace("Received different event type than expected, ignoring", watermill.LogFields{
"message_uuid": msg.UUID,
"expected_event_type": expectedEventName,
"received_event_type": messageEventName,
})
continue
}
logger.Debug("Handling event", watermill.LogFields{
"message_uuid": msg.UUID,
"received_event_type": messageEventName,
})
ctx := CtxWithOriginalMessage(msg.Context(), msg)
msg.SetContext(ctx)
if err := p.config.Marshaler.Unmarshal(msg, event); err != nil {
return err
}
handle := func(params EventGroupProcessorOnHandleParams) error {
return params.Handler.Handle(ctx, params.Event)
}
if p.config.OnHandle != nil {
handle = p.config.OnHandle
}
err := handle(EventGroupProcessorOnHandleParams{
GroupName: groupName,
Handler: handler,
EventName: messageEventName,
Event: event,
Message: msg,
})
if err != nil {
logger.Debug("Error when handling event", watermill.LogFields{"err": err})
return err
}
handledAnyEvent = true
}
if handledAnyEvent {
return nil
}
if !p.config.AckOnUnknownEvent {
return fmt.Errorf("no handler found for event %s", p.config.Marshaler.NameFromMessage(msg))
} else {
logger.Trace("Received event can't be handled by any handler in handler group", watermill.LogFields{
"message_uuid": msg.UUID,
"received_event_type": messageEventName,
})
return nil
}
}, nil
}

View File

@@ -0,0 +1,457 @@
package cqrs_test
import (
"context"
"fmt"
"sync/atomic"
"testing"
"time"
"github.com/ThreeDotsLabs/watermill/components/cqrs"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEventGroupProcessorConfig_Validate(t *testing.T) {
testCases := []struct {
Name string
ModifyValidConfig func(*cqrs.EventGroupProcessorConfig)
ExpectedErr error
}{
{
Name: "valid_config",
ModifyValidConfig: nil,
ExpectedErr: nil,
},
{
Name: "valid_with_group_handlers",
ExpectedErr: nil,
},
{
Name: "missing_GroupSubscriberConstructor",
ModifyValidConfig: func(config *cqrs.EventGroupProcessorConfig) {
config.SubscriberConstructor = nil
},
ExpectedErr: fmt.Errorf("missing SubscriberConstructor"),
},
{
Name: "missing_GenerateHandlerGroupSubscribeTopic",
ModifyValidConfig: func(config *cqrs.EventGroupProcessorConfig) {
config.GenerateSubscribeTopic = nil
},
ExpectedErr: fmt.Errorf("missing GenerateHandlerGroupTopic"),
},
{
Name: "missing_marshaler",
ModifyValidConfig: func(config *cqrs.EventGroupProcessorConfig) {
config.Marshaler = nil
},
ExpectedErr: fmt.Errorf("missing Marshaler"),
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.Name, func(t *testing.T) {
validConfig := cqrs.EventGroupProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) {
return "", nil
},
SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return nil, nil
},
Marshaler: cqrs.JSONMarshaler{},
}
if tc.ModifyValidConfig != nil {
tc.ModifyValidConfig(&validConfig)
}
err := validConfig.Validate()
if tc.ExpectedErr == nil {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tc.ExpectedErr.Error())
}
})
}
}
func TestNewEventProcessor_OnGroupHandle(t *testing.T) {
ts := NewTestServices()
msg1, err := ts.Marshaler.Marshal(&TestEvent{ID: "1"})
require.NoError(t, err)
msg2, err := ts.Marshaler.Marshal(&TestEvent{ID: "2"})
require.NoError(t, err)
mockSub := &mockSubscriber{
MessagesToSend: []*message.Message{
msg1,
msg2,
},
}
handlerCalled := 0
defer func() {
// for msg 1 we are not calling handler - but returning before
assert.Equal(t, 1, handlerCalled)
}()
handler := cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error {
handlerCalled++
return nil
})
onHandleCalled := int64(0)
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
config := cqrs.EventGroupProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) {
return "events", nil
},
SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return mockSub, nil
},
OnHandle: func(params cqrs.EventGroupProcessorOnHandleParams) error {
atomic.AddInt64(&onHandleCalled, 1)
assert.Equal(t, "some_group", params.GroupName)
assert.IsType(t, &TestEvent{}, params.Event)
assert.Equal(t, "cqrs_test.TestEvent", params.EventName)
assert.Equal(t, handler, params.Handler)
if params.Event.(*TestEvent).ID == "1" {
assert.Equal(t, msg1, params.Message)
return errors.New("test error")
} else {
assert.Equal(t, msg2, params.Message)
}
return params.Handler.Handle(params.Message.Context(), params.Event)
},
Marshaler: ts.Marshaler,
Logger: ts.Logger,
}
cp, err := cqrs.NewEventGroupProcessorWithConfig(router, config)
require.NoError(t, err)
err = cp.AddHandlersGroup("some_group", handler)
require.NoError(t, err)
go func() {
err := router.Run(context.Background())
assert.NoError(t, err)
}()
<-router.Running()
select {
case <-msg1.Nacked():
// ok
case <-msg1.Acked():
// ack received
t.Fatal("ack received, message should be nacked")
}
select {
case <-msg2.Acked():
// ok
case <-msg2.Nacked():
// nack received
}
assert.EqualValues(t, 2, onHandleCalled)
}
func TestNewEventProcessor_AckOnUnknownEvent_handler_group(t *testing.T) {
ts := NewTestServices()
msg, err := ts.Marshaler.Marshal(&UnknownEvent{})
require.NoError(t, err)
mockSub := &mockSubscriber{
MessagesToSend: []*message.Message{
msg,
},
}
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
cp, err := cqrs.NewEventGroupProcessorWithConfig(
router,
cqrs.EventGroupProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) {
return "events", nil
},
SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return mockSub, nil
},
AckOnUnknownEvent: true,
Marshaler: ts.Marshaler,
Logger: ts.Logger,
},
)
require.NoError(t, err)
err = cp.AddHandlersGroup(
"foo",
cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error {
return nil
}),
)
require.NoError(t, err)
go func() {
err := router.Run(context.Background())
assert.NoError(t, err)
}()
<-router.Running()
select {
case <-msg.Acked():
// ok
case <-msg.Nacked():
// ack received
t.Fatal("ack received, message should be nacked")
}
}
func TestNewEventProcessor_AckOnUnknownEvent_disabled_handler_group(t *testing.T) {
ts := NewTestServices()
msg, err := ts.Marshaler.Marshal(&UnknownEvent{})
require.NoError(t, err)
mockSub := &mockSubscriber{
MessagesToSend: []*message.Message{
msg,
},
}
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
cp, err := cqrs.NewEventGroupProcessorWithConfig(
router,
cqrs.EventGroupProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) {
return "events", nil
},
SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return mockSub, nil
},
AckOnUnknownEvent: false,
Marshaler: ts.Marshaler,
Logger: ts.Logger,
},
)
require.NoError(t, err)
err = cp.AddHandlersGroup(
"foo",
cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error {
return nil
}),
)
require.NoError(t, err)
go func() {
err := router.Run(context.Background())
assert.NoError(t, err)
}()
<-router.Running()
select {
case <-msg.Nacked():
// ok
case <-msg.Acked():
t.Fatal("ack received, message should be nacked")
}
}
func TestEventProcessor_handler_group(t *testing.T) {
ts := NewTestServices()
event1 := &TestEvent{ID: "1"}
msg1, err := ts.Marshaler.Marshal(event1)
require.NoError(t, err)
event2 := &AnotherTestEvent{ID: "2"}
msg2, err := ts.Marshaler.Marshal(event2)
require.NoError(t, err)
mockSub := &mockSubscriber{
MessagesToSend: []*message.Message{
msg1,
msg2,
},
WaitForAckBeforeSendingNext: true,
}
var handlersCalls []int
handlers := []cqrs.GroupEventHandler{
cqrs.NewGroupEventHandler(func(ctx context.Context, event *TestEvent) error {
assert.EqualValues(t, event1, event)
handlersCalls = append(handlersCalls, 1)
return nil
}),
cqrs.NewGroupEventHandler(func(ctx context.Context, event *AnotherTestEvent) error {
assert.EqualValues(t, event2, event)
handlersCalls = append(handlersCalls, 2)
return nil
}),
cqrs.NewGroupEventHandler(func(ctx context.Context, event *AnotherTestEvent) error {
assert.EqualValues(t, event2, event)
handlersCalls = append(handlersCalls, 3)
return nil
}),
}
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
eventProcessor, err := cqrs.NewEventGroupProcessorWithConfig(
router,
cqrs.EventGroupProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) {
assert.Equal(t, "some_group", params.EventGroupName)
assert.Equal(t, handlers, params.EventGroupHandlers)
return "events", nil
},
SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) {
assert.Equal(t, "some_group", params.EventGroupName)
assert.Equal(t, handlers, params.EventGroupHandlers)
return mockSub, nil
},
Marshaler: ts.Marshaler,
Logger: ts.Logger,
},
)
require.NoError(t, err)
err = eventProcessor.AddHandlersGroup(
"some_group",
handlers...,
)
require.NoError(t, err)
err = eventProcessor.AddHandlersGroup(
"some_group",
handlers...,
)
require.ErrorContains(t, err, "event handler group 'some_group' already exists")
err = eventProcessor.AddHandlersGroup(
"some_group_2",
)
require.ErrorContains(t, err, "no handlers provided")
go func() {
err := router.Run(context.Background())
assert.NoError(t, err)
}()
<-router.Running()
select {
case <-msg1.Acked():
// ok
case <-time.After(time.Second):
t.Fatal("message 1 not acked")
}
select {
case <-msg2.Acked():
// ok
case <-time.After(time.Second):
t.Fatal("message 2 not acked")
}
assert.Equal(t, []int{1, 2, 3}, handlersCalls)
}
func TestEventGroupProcessor_original_msg_set_to_ctx(t *testing.T) {
ts := NewTestServices()
msg, err := ts.Marshaler.Marshal(&TestEvent{})
require.NoError(t, err)
mockSub := &mockSubscriber{
MessagesToSend: []*message.Message{
msg,
},
}
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
cp, err := cqrs.NewEventGroupProcessorWithConfig(
router,
cqrs.EventGroupProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) {
return "events", nil
},
SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return mockSub, nil
},
AckOnUnknownEvent: true,
Marshaler: ts.Marshaler,
Logger: ts.Logger,
},
)
require.NoError(t, err)
var msgFromCtx *message.Message
err = cp.AddHandlersGroup(
"some_group",
cqrs.NewGroupEventHandler(
func(ctx context.Context, cmd *TestEvent) error {
msgFromCtx = cqrs.OriginalMessageFromCtx(ctx)
return nil
}),
)
require.NoError(t, err)
go func() {
err := router.Run(context.Background())
assert.NoError(t, err)
}()
<-router.Running()
select {
case <-msg.Acked():
// ok
case <-msg.Nacked():
// nack received
t.Fatal("nack received, message should be acked")
case <-time.After(1 * time.Second):
t.Fatal("timeout waiting for ack")
}
require.NotNil(t, msgFromCtx)
assert.Equal(t, msg, msgFromCtx)
}

View File

@@ -0,0 +1,539 @@
package cqrs_test
import (
"context"
"fmt"
"sync/atomic"
"testing"
"time"
"github.com/pkg/errors"
"github.com/ThreeDotsLabs/watermill/components/cqrs"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEventProcessorConfig_Validate(t *testing.T) {
testCases := []struct {
Name string
ModifyValidConfig func(*cqrs.EventProcessorConfig)
ExpectedErr error
}{
{
Name: "valid_config",
ModifyValidConfig: nil,
ExpectedErr: nil,
},
{
Name: "missing_GenerateHandlerSubscribeTopic",
ModifyValidConfig: func(config *cqrs.EventProcessorConfig) {
config.GenerateSubscribeTopic = nil
},
ExpectedErr: fmt.Errorf("missing GenerateHandlerTopic"),
},
{
Name: "missing_marshaler",
ModifyValidConfig: func(config *cqrs.EventProcessorConfig) {
config.Marshaler = nil
},
ExpectedErr: fmt.Errorf("missing Marshaler"),
},
{
Name: "missing_subscriber_constructor",
ModifyValidConfig: func(config *cqrs.EventProcessorConfig) {
config.SubscriberConstructor = nil
},
ExpectedErr: fmt.Errorf("missing SubscriberConstructor"),
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.Name, func(t *testing.T) {
validConfig := cqrs.EventProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
return "", nil
},
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return nil, nil
},
Marshaler: cqrs.JSONMarshaler{},
}
if tc.ModifyValidConfig != nil {
tc.ModifyValidConfig(&validConfig)
}
err := validConfig.Validate()
if tc.ExpectedErr == nil {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tc.ExpectedErr.Error())
}
})
}
}
func TestNewEventProcessor(t *testing.T) {
eventConfig := cqrs.EventProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
return "", nil
},
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return nil, nil
},
Marshaler: cqrs.JSONMarshaler{},
}
require.NoError(t, eventConfig.Validate())
router, err := message.NewRouter(message.RouterConfig{}, nil)
require.NoError(t, err)
cp, err := cqrs.NewEventProcessorWithConfig(router, eventConfig)
assert.NotNil(t, cp)
assert.NoError(t, err)
eventConfig.SubscriberConstructor = nil
require.Error(t, eventConfig.Validate())
cp, err = cqrs.NewEventProcessorWithConfig(router, eventConfig)
assert.Nil(t, cp)
assert.Error(t, err)
}
type nonPointerEventProcessor struct {
}
func (nonPointerEventProcessor) HandlerName() string {
return "nonPointerEventProcessor"
}
func (nonPointerEventProcessor) NewEvent() interface{} {
return TestEvent{}
}
func (nonPointerEventProcessor) Handle(ctx context.Context, cmd interface{}) error {
panic("not implemented")
}
func TestEventProcessor_non_pointer_event(t *testing.T) {
ts := NewTestServices()
handler := nonPointerEventProcessor{}
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
eventProcessor, err := cqrs.NewEventProcessorWithConfig(
router,
cqrs.EventProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
return "", nil
},
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return nil, nil
},
Marshaler: ts.Marshaler,
Logger: ts.Logger,
},
)
require.NoError(t, err)
err = eventProcessor.AddHandlers(handler)
assert.IsType(t, cqrs.NonPointerError{}, errors.Cause(err))
}
type duplicateTestEventHandler1 struct{}
func (h duplicateTestEventHandler1) HandlerName() string {
return "duplicateTestEventHandler1"
}
func (duplicateTestEventHandler1) NewEvent() interface{} {
return &TestEvent{}
}
func (h *duplicateTestEventHandler1) Handle(ctx context.Context, event interface{}) error { return nil }
type duplicateTestEventHandler2 struct{}
func (h duplicateTestEventHandler2) HandlerName() string {
return "duplicateTestEventHandler2"
}
func (duplicateTestEventHandler2) NewEvent() interface{} {
return &TestEvent{}
}
func (h *duplicateTestEventHandler2) Handle(ctx context.Context, event interface{}) error { return nil }
func TestEventProcessor_multiple_same_event_handlers(t *testing.T) {
ts := NewTestServices()
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
eventProcessor, err := cqrs.NewEventProcessorWithConfig(
router,
cqrs.EventProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
return "", nil
},
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return nil, nil
},
Marshaler: ts.Marshaler,
Logger: ts.Logger,
},
)
require.NoError(t, err)
err = eventProcessor.AddHandlers(
&duplicateTestEventHandler1{},
&duplicateTestEventHandler2{},
)
require.NoError(t, err)
}
func TestNewEventProcessor_OnHandle(t *testing.T) {
ts := NewTestServices()
msg1, err := ts.Marshaler.Marshal(&TestEvent{ID: "1"})
require.NoError(t, err)
msg2, err := ts.Marshaler.Marshal(&TestEvent{ID: "2"})
require.NoError(t, err)
mockSub := &mockSubscriber{
MessagesToSend: []*message.Message{
msg1,
msg2,
},
}
handlerCalled := 0
defer func() {
// for msg 1 we are not calling handler - but returning before
assert.Equal(t, 1, handlerCalled)
}()
handler := cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error {
handlerCalled++
return nil
})
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
onHandleCalled := int64(0)
config := cqrs.EventProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
return "events", nil
},
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return mockSub, nil
},
OnHandle: func(params cqrs.EventProcessorOnHandleParams) error {
atomic.AddInt64(&onHandleCalled, 1)
assert.IsType(t, &TestEvent{}, params.Event)
assert.Equal(t, "cqrs_test.TestEvent", params.EventName)
assert.Equal(t, handler, params.Handler)
if params.Event.(*TestEvent).ID == "1" {
assert.Equal(t, msg1, params.Message)
return errors.New("test error")
} else {
assert.Equal(t, msg2, params.Message)
}
return params.Handler.Handle(params.Message.Context(), params.Event)
},
Marshaler: ts.Marshaler,
Logger: ts.Logger,
}
cp, err := cqrs.NewEventProcessorWithConfig(router, config)
require.NoError(t, err)
err = cp.AddHandlers(handler)
require.NoError(t, err)
go func() {
err := router.Run(context.Background())
assert.NoError(t, err)
}()
<-router.Running()
select {
case <-msg1.Nacked():
// ok
case <-msg1.Acked():
// ack received
t.Fatal("ack received, message should be nacked")
}
select {
case <-msg2.Acked():
// ok
case <-msg2.Nacked():
// nack received
}
assert.EqualValues(t, 2, onHandleCalled)
}
type UnknownEvent struct {
}
func TestNewEventProcessor_AckOnUnknownEvent(t *testing.T) {
ts := NewTestServices()
msg, err := ts.Marshaler.Marshal(&UnknownEvent{})
require.NoError(t, err)
mockSub := &mockSubscriber{
MessagesToSend: []*message.Message{
msg,
},
}
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
cp, err := cqrs.NewEventProcessorWithConfig(
router,
cqrs.EventProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
return "events", nil
},
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return mockSub, nil
},
AckOnUnknownEvent: true,
Marshaler: ts.Marshaler,
Logger: ts.Logger,
},
)
require.NoError(t, err)
err = cp.AddHandlers(
cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error {
return nil
}),
)
require.NoError(t, err)
go func() {
err := router.Run(context.Background())
assert.NoError(t, err)
}()
<-router.Running()
select {
case <-msg.Acked():
// ok
case <-msg.Nacked():
// ack received
t.Fatal("ack received, message should be nacked")
}
}
func TestNewEventProcessor_AckOnUnknownEvent_disabled(t *testing.T) {
ts := NewTestServices()
msg, err := ts.Marshaler.Marshal(&UnknownEvent{})
require.NoError(t, err)
mockSub := &mockSubscriber{
MessagesToSend: []*message.Message{
msg,
},
}
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
cp, err := cqrs.NewEventProcessorWithConfig(
router,
cqrs.EventProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
return "events", nil
},
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return mockSub, nil
},
AckOnUnknownEvent: false,
Marshaler: ts.Marshaler,
Logger: ts.Logger,
},
)
require.NoError(t, err)
err = cp.AddHandlers(
cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error {
return nil
}),
)
require.NoError(t, err)
go func() {
err := router.Run(context.Background())
assert.NoError(t, err)
}()
<-router.Running()
select {
case <-msg.Nacked():
// ok
case <-msg.Acked():
// ack received
t.Fatal("ack received, message should be nacked")
}
}
func TestNewEventProcessor_backward_compatibility_of_AckOnUnknownEvent(t *testing.T) {
ts := NewTestServices()
msg, err := ts.Marshaler.Marshal(&UnknownEvent{})
require.NoError(t, err)
mockSub := &mockSubscriber{
MessagesToSend: []*message.Message{
msg,
},
}
cp, err := cqrs.NewEventProcessor(
[]cqrs.EventHandler{
cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error {
return nil
}),
},
func(eventName string) string {
return "events"
},
func(handlerName string) (message.Subscriber, error) {
return mockSub, nil
},
ts.Marshaler,
ts.Logger,
)
require.NoError(t, err)
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
err = cp.AddHandlersToRouter(router)
require.NoError(t, err)
go func() {
err := router.Run(context.Background())
assert.NoError(t, err)
}()
<-router.Running()
select {
case <-msg.Acked():
// ok
case <-msg.Nacked():
// ack received
t.Fatal("ack received, message should be nacked")
}
}
func TestEventProcessor_AddHandlersToRouter_without_disableRouterAutoAddHandlers(t *testing.T) {
ts := NewTestServices()
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
cp, err := cqrs.NewEventProcessorWithConfig(
router,
cqrs.EventProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
return "events", nil
},
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return ts.EventsPubSub, nil
},
AckOnUnknownEvent: false,
Marshaler: ts.Marshaler,
Logger: ts.Logger,
},
)
require.NoError(t, err)
err = cp.AddHandlersToRouter(router)
assert.ErrorContains(t, err, "AddHandlersToRouter should be called only when using deprecated NewEventProcessor")
}
func TestEventProcessor_original_msg_set_to_ctx(t *testing.T) {
ts := NewTestServices()
msg, err := ts.Marshaler.Marshal(&TestEvent{})
require.NoError(t, err)
mockSub := &mockSubscriber{
MessagesToSend: []*message.Message{
msg,
},
}
router, err := message.NewRouter(message.RouterConfig{}, ts.Logger)
require.NoError(t, err)
cp, err := cqrs.NewEventProcessorWithConfig(
router,
cqrs.EventProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
return "events", nil
},
SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return mockSub, nil
},
AckOnUnknownEvent: true,
Marshaler: ts.Marshaler,
Logger: ts.Logger,
},
)
require.NoError(t, err)
var msgFromCtx *message.Message
err = cp.AddHandlers(cqrs.NewEventHandler(
"handler", func(ctx context.Context, cmd *TestEvent) error {
msgFromCtx = cqrs.OriginalMessageFromCtx(ctx)
return nil
}),
)
require.NoError(t, err)
go func() {
err := router.Run(context.Background())
assert.NoError(t, err)
}()
<-router.Running()
select {
case <-msg.Acked():
// ok
case <-msg.Nacked():
// nack received
t.Fatal("nack received, message should be acked")
case <-time.After(1 * time.Second):
t.Fatal("timeout waiting for ack")
}
require.NotNil(t, msgFromCtx)
assert.Equal(t, msg, msgFromCtx)
}

View File

@@ -0,0 +1,55 @@
package cqrs
import (
"errors"
"fmt"
"github.com/ThreeDotsLabs/watermill/message"
)
// CommandEventMarshaler marshals Commands and Events to Watermill's messages and vice versa.
// Payload of the command needs to be marshaled to []bytes.
type CommandEventMarshaler interface {
// Marshal marshals Command or Event to Watermill's message.
Marshal(v interface{}) (*message.Message, error)
// Unmarshal unmarshals watermill's message to v Command or Event.
Unmarshal(msg *message.Message, v interface{}) (err error)
// Name returns the name of Command or Event.
// Name is used to determine, that received command or event is event which we want to handle.
Name(v interface{}) string
// NameFromMessage returns the name of Command or Event from Watermill's message (generated by Marshal).
//
// When we have Command or Event marshaled to Watermill's message,
// we should use NameFromMessage instead of Name to avoid unnecessary unmarshaling.
NameFromMessage(msg *message.Message) string
}
// CommandEventMarshalerDecorator decorates CommandEventMarshaler with additional functionality.
// It can be used to add additional metadata to the message.
type CommandEventMarshalerDecorator struct {
CommandEventMarshaler
// DecorateFunc is called after marshaling the message.
DecorateFunc func(v any, msg *message.Message) error
}
// Marshal marshals Command or Event to Watermill's message and decorates it.
func (c CommandEventMarshalerDecorator) Marshal(v any) (*message.Message, error) {
msg, err := c.CommandEventMarshaler.Marshal(v)
if err != nil {
return nil, err
}
if c.DecorateFunc == nil {
return nil, errors.New("DecorateFunc is nil")
}
if err := c.DecorateFunc(v, msg); err != nil {
return nil, fmt.Errorf("cannot decorate message: %w", err)
}
return msg, nil
}

View File

@@ -0,0 +1,53 @@
package cqrs
import (
"encoding/json"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
)
type JSONMarshaler struct {
NewUUID func() string
GenerateName func(v interface{}) string
}
func (m JSONMarshaler) Marshal(v interface{}) (*message.Message, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
msg := message.NewMessage(
m.newUUID(),
b,
)
msg.Metadata.Set("name", m.Name(v))
return msg, nil
}
func (m JSONMarshaler) newUUID() string {
if m.NewUUID != nil {
return m.NewUUID()
}
// default
return watermill.NewUUID()
}
func (JSONMarshaler) Unmarshal(msg *message.Message, v interface{}) (err error) {
return json.Unmarshal(msg.Payload, v)
}
func (m JSONMarshaler) Name(cmdOrEvent interface{}) string {
if m.GenerateName != nil {
return m.GenerateName(cmdOrEvent)
}
return FullyQualifiedStructName(cmdOrEvent)
}
func (m JSONMarshaler) NameFromMessage(msg *message.Message) string {
return msg.Metadata.Get("name")
}

View File

@@ -0,0 +1,56 @@
package cqrs_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/components/cqrs"
)
var jsonEventToMarshal = TestEvent{
ID: watermill.NewULID(),
When: time.Date(2016, time.August, 15, 14, 13, 12, 0, time.UTC),
}
func TestJsonMarshaler(t *testing.T) {
marshaler := cqrs.JSONMarshaler{}
msg, err := marshaler.Marshal(jsonEventToMarshal)
require.NoError(t, err)
eventToUnmarshal := TestEvent{}
err = marshaler.Unmarshal(msg, &eventToUnmarshal)
require.NoError(t, err)
assert.EqualValues(t, jsonEventToMarshal, eventToUnmarshal)
}
func TestJSONMarshaler_Marshal_new_uuid_set(t *testing.T) {
marshaler := cqrs.JSONMarshaler{
NewUUID: func() string {
return "foo"
},
}
msg, err := marshaler.Marshal(jsonEventToMarshal)
require.NoError(t, err)
assert.Equal(t, msg.UUID, "foo")
}
func TestJSONMarshaler_Marshal_generate_name(t *testing.T) {
marshaler := cqrs.JSONMarshaler{
GenerateName: func(v interface{}) string {
return "foo"
},
}
msg, err := marshaler.Marshal(jsonEventToMarshal)
require.NoError(t, err)
assert.Equal(t, msg.Metadata.Get("name"), "foo")
}

View File

@@ -0,0 +1,85 @@
package cqrs
import (
"reflect"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
"google.golang.org/protobuf/proto"
"github.com/pkg/errors"
)
// ProtoMarshaler is the default Protocol Buffers marshaler.
type ProtoMarshaler struct {
NewUUID func() string
GenerateName func(v interface{}) string
}
// NoProtoMessageError is returned when the given value does not implement proto.Message.
type NoProtoMessageError struct {
v interface{}
}
func (e NoProtoMessageError) Error() string {
rv := reflect.ValueOf(e.v)
if rv.Kind() != reflect.Pointer {
return "v is not proto.Message, you must pass pointer value to implement proto.Message"
}
return "v is not proto.Message"
}
// Marshal marshals the given protobuf's message into watermill's Message.
func (m ProtoMarshaler) Marshal(v interface{}) (*message.Message, error) {
protoMsg, ok := v.(proto.Message)
if !ok {
return nil, errors.WithStack(NoProtoMessageError{v})
}
b, err := proto.Marshal(protoMsg)
if err != nil {
return nil, err
}
msg := message.NewMessage(
m.newUUID(),
b,
)
msg.Metadata.Set("name", m.Name(v))
return msg, nil
}
func (m ProtoMarshaler) newUUID() string {
if m.NewUUID != nil {
return m.NewUUID()
}
// default
return watermill.NewUUID()
}
// Unmarshal unmarshals given watermill's Message into protobuf's message.
func (ProtoMarshaler) Unmarshal(msg *message.Message, v interface{}) (err error) {
protoV, ok := v.(proto.Message)
if !ok {
return errors.WithStack(NoProtoMessageError{v})
}
return proto.Unmarshal(msg.Payload, protoV)
}
// Name returns the command or event's name.
func (m ProtoMarshaler) Name(cmdOrEvent interface{}) string {
if m.GenerateName != nil {
return m.GenerateName(cmdOrEvent)
}
return FullyQualifiedStructName(cmdOrEvent)
}
// NameFromMessage returns the metadata name value for a given Message.
func (m ProtoMarshaler) NameFromMessage(msg *message.Message) string {
return msg.Metadata.Get("name")
}

View File

@@ -0,0 +1,483 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.31.0
// protoc v4.24.4
// source: events.proto
package cqrs_test
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type Status int32
const (
Status_STATUS_UNSPECIFIED Status = 0
Status_ACTIVE Status = 1
Status_DELETED Status = 2
)
// Enum value maps for Status.
var (
Status_name = map[int32]string{
0: "STATUS_UNSPECIFIED",
1: "ACTIVE",
2: "DELETED",
}
Status_value = map[string]int32{
"STATUS_UNSPECIFIED": 0,
"ACTIVE": 1,
"DELETED": 2,
}
)
func (x Status) Enum() *Status {
p := new(Status)
*p = x
return p
}
func (x Status) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (Status) Descriptor() protoreflect.EnumDescriptor {
return file_events_proto_enumTypes[0].Descriptor()
}
func (Status) Type() protoreflect.EnumType {
return &file_events_proto_enumTypes[0]
}
func (x Status) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use Status.Descriptor instead.
func (Status) EnumDescriptor() ([]byte, []int) {
return file_events_proto_rawDescGZIP(), []int{0}
}
type TestProtobufLegacyEvent struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
When *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=when,proto3" json:"when,omitempty"`
}
func (x *TestProtobufLegacyEvent) Reset() {
*x = TestProtobufLegacyEvent{}
if protoimpl.UnsafeEnabled {
mi := &file_events_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *TestProtobufLegacyEvent) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TestProtobufLegacyEvent) ProtoMessage() {}
func (x *TestProtobufLegacyEvent) ProtoReflect() protoreflect.Message {
mi := &file_events_proto_msgTypes[0]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TestProtobufLegacyEvent.ProtoReflect.Descriptor instead.
func (*TestProtobufLegacyEvent) Descriptor() ([]byte, []int) {
return file_events_proto_rawDescGZIP(), []int{0}
}
func (x *TestProtobufLegacyEvent) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *TestProtobufLegacyEvent) GetWhen() *timestamppb.Timestamp {
if x != nil {
return x.When
}
return nil
}
type SubEvent struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Tags []string `protobuf:"bytes,1,rep,name=tags,proto3" json:"tags,omitempty"`
Flags map[string]bool `protobuf:"bytes,2,rep,name=flags,proto3" json:"flags,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"`
}
func (x *SubEvent) Reset() {
*x = SubEvent{}
if protoimpl.UnsafeEnabled {
mi := &file_events_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *SubEvent) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SubEvent) ProtoMessage() {}
func (x *SubEvent) ProtoReflect() protoreflect.Message {
mi := &file_events_proto_msgTypes[1]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SubEvent.ProtoReflect.Descriptor instead.
func (*SubEvent) Descriptor() ([]byte, []int) {
return file_events_proto_rawDescGZIP(), []int{1}
}
func (x *SubEvent) GetTags() []string {
if x != nil {
return x.Tags
}
return nil
}
func (x *SubEvent) GetFlags() map[string]bool {
if x != nil {
return x.Flags
}
return nil
}
type TestComplexProtobufEvent struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"`
When *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=when,proto3" json:"when,omitempty"`
// Complex fields to test edge cases
NestedMap map[string]*SubEvent `protobuf:"bytes,4,rep,name=nested_map,json=nestedMap,proto3" json:"nested_map,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
Events []*SubEvent `protobuf:"bytes,5,rep,name=events,proto3" json:"events,omitempty"`
// Types that are assignable to Result:
//
// *TestComplexProtobufEvent_Success
// *TestComplexProtobufEvent_Error
// *TestComplexProtobufEvent_Fallback
Result isTestComplexProtobufEvent_Result `protobuf_oneof:"result"`
}
func (x *TestComplexProtobufEvent) Reset() {
*x = TestComplexProtobufEvent{}
if protoimpl.UnsafeEnabled {
mi := &file_events_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *TestComplexProtobufEvent) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TestComplexProtobufEvent) ProtoMessage() {}
func (x *TestComplexProtobufEvent) ProtoReflect() protoreflect.Message {
mi := &file_events_proto_msgTypes[2]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TestComplexProtobufEvent.ProtoReflect.Descriptor instead.
func (*TestComplexProtobufEvent) Descriptor() ([]byte, []int) {
return file_events_proto_rawDescGZIP(), []int{2}
}
func (x *TestComplexProtobufEvent) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *TestComplexProtobufEvent) GetData() []byte {
if x != nil {
return x.Data
}
return nil
}
func (x *TestComplexProtobufEvent) GetWhen() *timestamppb.Timestamp {
if x != nil {
return x.When
}
return nil
}
func (x *TestComplexProtobufEvent) GetNestedMap() map[string]*SubEvent {
if x != nil {
return x.NestedMap
}
return nil
}
func (x *TestComplexProtobufEvent) GetEvents() []*SubEvent {
if x != nil {
return x.Events
}
return nil
}
func (m *TestComplexProtobufEvent) GetResult() isTestComplexProtobufEvent_Result {
if m != nil {
return m.Result
}
return nil
}
func (x *TestComplexProtobufEvent) GetSuccess() *SubEvent {
if x, ok := x.GetResult().(*TestComplexProtobufEvent_Success); ok {
return x.Success
}
return nil
}
func (x *TestComplexProtobufEvent) GetError() string {
if x, ok := x.GetResult().(*TestComplexProtobufEvent_Error); ok {
return x.Error
}
return ""
}
func (x *TestComplexProtobufEvent) GetFallback() Status {
if x, ok := x.GetResult().(*TestComplexProtobufEvent_Fallback); ok {
return x.Fallback
}
return Status_STATUS_UNSPECIFIED
}
type isTestComplexProtobufEvent_Result interface {
isTestComplexProtobufEvent_Result()
}
type TestComplexProtobufEvent_Success struct {
Success *SubEvent `protobuf:"bytes,6,opt,name=success,proto3,oneof"`
}
type TestComplexProtobufEvent_Error struct {
Error string `protobuf:"bytes,7,opt,name=error,proto3,oneof"`
}
type TestComplexProtobufEvent_Fallback struct {
Fallback Status `protobuf:"varint,8,opt,name=fallback,proto3,enum=cqrs_test.Status,oneof"`
}
func (*TestComplexProtobufEvent_Success) isTestComplexProtobufEvent_Result() {}
func (*TestComplexProtobufEvent_Error) isTestComplexProtobufEvent_Result() {}
func (*TestComplexProtobufEvent_Fallback) isTestComplexProtobufEvent_Result() {}
var File_events_proto protoreflect.FileDescriptor
var file_events_proto_rawDesc = []byte{
0x0a, 0x0c, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09,
0x63, 0x71, 0x72, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73,
0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x59, 0x0a, 0x17, 0x54, 0x65,
0x73, 0x74, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x4c, 0x65, 0x67, 0x61, 0x63, 0x79,
0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x2e, 0x0a, 0x04, 0x77, 0x68, 0x65, 0x6e, 0x18, 0x03, 0x20,
0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52,
0x04, 0x77, 0x68, 0x65, 0x6e, 0x22, 0x8e, 0x01, 0x0a, 0x08, 0x53, 0x75, 0x62, 0x45, 0x76, 0x65,
0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09,
0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x34, 0x0a, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18,
0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x63, 0x71, 0x72, 0x73, 0x5f, 0x74, 0x65, 0x73,
0x74, 0x2e, 0x53, 0x75, 0x62, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x61, 0x67, 0x73,
0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x1a, 0x38, 0x0a, 0x0a,
0x46, 0x6c, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65,
0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05,
0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c,
0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xcb, 0x03, 0x0a, 0x18, 0x54, 0x65, 0x73, 0x74, 0x43,
0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x78, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x45, 0x76,
0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28,
0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2e, 0x0a, 0x04, 0x77, 0x68, 0x65, 0x6e, 0x18,
0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
0x70, 0x52, 0x04, 0x77, 0x68, 0x65, 0x6e, 0x12, 0x51, 0x0a, 0x0a, 0x6e, 0x65, 0x73, 0x74, 0x65,
0x64, 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x63, 0x71,
0x72, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x70,
0x6c, 0x65, 0x78, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x45, 0x76, 0x65, 0x6e, 0x74,
0x2e, 0x4e, 0x65, 0x73, 0x74, 0x65, 0x64, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52,
0x09, 0x6e, 0x65, 0x73, 0x74, 0x65, 0x64, 0x4d, 0x61, 0x70, 0x12, 0x2b, 0x0a, 0x06, 0x65, 0x76,
0x65, 0x6e, 0x74, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x63, 0x71, 0x72,
0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x75, 0x62, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52,
0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x2f, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65,
0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x63, 0x71, 0x72, 0x73, 0x5f,
0x74, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x75, 0x62, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52,
0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x16, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f,
0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72,
0x12, 0x2f, 0x0a, 0x08, 0x66, 0x61, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x18, 0x08, 0x20, 0x01,
0x28, 0x0e, 0x32, 0x11, 0x2e, 0x63, 0x71, 0x72, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x53,
0x74, 0x61, 0x74, 0x75, 0x73, 0x48, 0x00, 0x52, 0x08, 0x66, 0x61, 0x6c, 0x6c, 0x62, 0x61, 0x63,
0x6b, 0x1a, 0x51, 0x0a, 0x0e, 0x4e, 0x65, 0x73, 0x74, 0x65, 0x64, 0x4d, 0x61, 0x70, 0x45, 0x6e,
0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x63, 0x71, 0x72, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74,
0x2e, 0x53, 0x75, 0x62, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
0x3a, 0x02, 0x38, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x4a, 0x04,
0x08, 0x17, 0x10, 0x1f, 0x2a, 0x39, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x16,
0x0a, 0x12, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49,
0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45,
0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x02, 0x42,
0x0d, 0x5a, 0x0b, 0x2e, 0x2f, 0x63, 0x71, 0x72, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x62, 0x06,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
file_events_proto_rawDescOnce sync.Once
file_events_proto_rawDescData = file_events_proto_rawDesc
)
func file_events_proto_rawDescGZIP() []byte {
file_events_proto_rawDescOnce.Do(func() {
file_events_proto_rawDescData = protoimpl.X.CompressGZIP(file_events_proto_rawDescData)
})
return file_events_proto_rawDescData
}
var file_events_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_events_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
var file_events_proto_goTypes = []interface{}{
(Status)(0), // 0: cqrs_test.Status
(*TestProtobufLegacyEvent)(nil), // 1: cqrs_test.TestProtobufLegacyEvent
(*SubEvent)(nil), // 2: cqrs_test.SubEvent
(*TestComplexProtobufEvent)(nil), // 3: cqrs_test.TestComplexProtobufEvent
nil, // 4: cqrs_test.SubEvent.FlagsEntry
nil, // 5: cqrs_test.TestComplexProtobufEvent.NestedMapEntry
(*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp
}
var file_events_proto_depIdxs = []int32{
6, // 0: cqrs_test.TestProtobufLegacyEvent.when:type_name -> google.protobuf.Timestamp
4, // 1: cqrs_test.SubEvent.flags:type_name -> cqrs_test.SubEvent.FlagsEntry
6, // 2: cqrs_test.TestComplexProtobufEvent.when:type_name -> google.protobuf.Timestamp
5, // 3: cqrs_test.TestComplexProtobufEvent.nested_map:type_name -> cqrs_test.TestComplexProtobufEvent.NestedMapEntry
2, // 4: cqrs_test.TestComplexProtobufEvent.events:type_name -> cqrs_test.SubEvent
2, // 5: cqrs_test.TestComplexProtobufEvent.success:type_name -> cqrs_test.SubEvent
0, // 6: cqrs_test.TestComplexProtobufEvent.fallback:type_name -> cqrs_test.Status
2, // 7: cqrs_test.TestComplexProtobufEvent.NestedMapEntry.value:type_name -> cqrs_test.SubEvent
8, // [8:8] is the sub-list for method output_type
8, // [8:8] is the sub-list for method input_type
8, // [8:8] is the sub-list for extension type_name
8, // [8:8] is the sub-list for extension extendee
0, // [0:8] is the sub-list for field type_name
}
func init() { file_events_proto_init() }
func file_events_proto_init() {
if File_events_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_events_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*TestProtobufLegacyEvent); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_events_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*SubEvent); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_events_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*TestComplexProtobufEvent); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
file_events_proto_msgTypes[2].OneofWrappers = []interface{}{
(*TestComplexProtobufEvent_Success)(nil),
(*TestComplexProtobufEvent_Error)(nil),
(*TestComplexProtobufEvent_Fallback)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_events_proto_rawDesc,
NumEnums: 1,
NumMessages: 5,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_events_proto_goTypes,
DependencyIndexes: file_events_proto_depIdxs,
EnumInfos: file_events_proto_enumTypes,
MessageInfos: file_events_proto_msgTypes,
}.Build()
File_events_proto = out.File
file_events_proto_rawDesc = nil
file_events_proto_goTypes = nil
file_events_proto_depIdxs = nil
}

View File

@@ -0,0 +1,90 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// source: testdata/events.proto
package cqrs_test
import (
fmt "fmt"
math "math"
proto "github.com/golang/protobuf/proto"
timestamp "github.com/golang/protobuf/ptypes/timestamp"
)
// Reference imports to suppress errors if they are not otherwise used.
var _ = proto.Marshal
var _ = fmt.Errorf
var _ = math.Inf
// This is a compile-time assertion to ensure that this generated file
// is compatible with the proto package it is being compiled against.
// A compilation error at this line likely means your copy of the
// proto package needs to be updated.
const _ = proto.ProtoPackageIsVersion3 // please upgrade the proto package
type TestProtobufEvent struct {
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
When *timestamp.Timestamp `protobuf:"bytes,3,opt,name=when,proto3" json:"when,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *TestProtobufEvent) Reset() { *m = TestProtobufEvent{} }
func (m *TestProtobufEvent) String() string { return proto.CompactTextString(m) }
func (*TestProtobufEvent) ProtoMessage() {}
func (*TestProtobufEvent) Descriptor() ([]byte, []int) {
return fileDescriptor_37faf0ac8d97ee4c, []int{0}
}
func (m *TestProtobufEvent) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_TestProtobufEvent.Unmarshal(m, b)
}
func (m *TestProtobufEvent) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
return xxx_messageInfo_TestProtobufEvent.Marshal(b, m, deterministic)
}
func (m *TestProtobufEvent) XXX_Merge(src proto.Message) {
xxx_messageInfo_TestProtobufEvent.Merge(m, src)
}
func (m *TestProtobufEvent) XXX_Size() int {
return xxx_messageInfo_TestProtobufEvent.Size(m)
}
func (m *TestProtobufEvent) XXX_DiscardUnknown() {
xxx_messageInfo_TestProtobufEvent.DiscardUnknown(m)
}
var xxx_messageInfo_TestProtobufEvent proto.InternalMessageInfo
func (m *TestProtobufEvent) GetId() string {
if m != nil {
return m.Id
}
return ""
}
func (m *TestProtobufEvent) GetWhen() *timestamp.Timestamp {
if m != nil {
return m.When
}
return nil
}
func init() {
proto.RegisterType((*TestProtobufEvent)(nil), "cqrs_test.TestProtobufEvent")
}
func init() { proto.RegisterFile("testdata/events.proto", fileDescriptor_37faf0ac8d97ee4c) }
var fileDescriptor_37faf0ac8d97ee4c = []byte{
// 146 bytes of a gzipped FileDescriptorProto
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0x12, 0x2d, 0x49, 0x2d, 0x2e,
0x49, 0x49, 0x2c, 0x49, 0xd4, 0x4f, 0x2d, 0x4b, 0xcd, 0x2b, 0x29, 0xd6, 0x2b, 0x28, 0xca, 0x2f,
0xc9, 0x17, 0xe2, 0x4c, 0x2e, 0x2c, 0x2a, 0x8e, 0x07, 0xc9, 0x49, 0xc9, 0xa7, 0xe7, 0xe7, 0xa7,
0xe7, 0xa4, 0xea, 0x83, 0x25, 0x92, 0x4a, 0xd3, 0xf4, 0x4b, 0x32, 0x73, 0x53, 0x8b, 0x4b, 0x12,
0x73, 0x0b, 0x20, 0x6a, 0x95, 0x82, 0xb9, 0x04, 0x43, 0x52, 0x8b, 0x4b, 0x02, 0xa0, 0xf2, 0xae,
0x20, 0x73, 0x84, 0xf8, 0xb8, 0x98, 0x32, 0x53, 0x24, 0x18, 0x15, 0x18, 0x35, 0x38, 0x83, 0x98,
0x32, 0x53, 0x84, 0xf4, 0xb8, 0x58, 0xca, 0x33, 0x52, 0xf3, 0x24, 0x98, 0x15, 0x18, 0x35, 0xb8,
0x8d, 0xa4, 0xf4, 0x20, 0x86, 0xea, 0xc1, 0x0c, 0xd5, 0x0b, 0x81, 0x19, 0x1a, 0x04, 0x56, 0x97,
0xc4, 0x06, 0x96, 0x31, 0x06, 0x04, 0x00, 0x00, 0xff, 0xff, 0xff, 0x4e, 0x39, 0x0e, 0xa0, 0x00,
0x00, 0x00,
}

View File

@@ -0,0 +1,127 @@
package cqrs
import (
"fmt"
"runtime/debug"
stderrors "errors"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/gogo/protobuf/proto"
"github.com/pkg/errors"
stdproto "google.golang.org/protobuf/proto"
)
// ProtobufMarshaler a protobuf marshaler using github.com/gogo/protobuf/proto (deprecated).
//
// DEPRECATED: Use ProtoMarshaler instead. This marshaler will not work with newer protobuf files.
// IMPORTANT: This marshaler is backward and forward compatible with ProtoMarshaler.
// ProtobufMarshaler from Watermill versions until v1.4.3 are not forward compatible with ProtoMarshaler.
// Suggested migration steps:
// 1. Update Watermill to v1.4.4 or newer, so all publishers and subscribers will be forward and backward compatible.
// 2. Change all usages of ProtobufMarshaler to ProtoMarshaler.
type ProtobufMarshaler struct {
NewUUID func() string
GenerateName func(v interface{}) string
// DisableStdProtoFallback disables fallback to github.com/golang/protobuf/proto when github.com/gogo/protobuf/proto
// because receiving a message that was marshaled with github.com/golang/protobuf/proto.
// Fallback is enabled by default to enable migration to ProtoMarshaler.
DisableStdProtoFallback bool
}
// Marshal marshals the given protobuf's message into watermill's Message.
func (m ProtobufMarshaler) Marshal(v interface{}) (msg *message.Message, err error) {
defer func() {
// gogo proto can panic on unmarshal (for example, because it received a message from ProtoMarshaler with oneof)
if r := recover(); r != nil {
err = stderrors.Join(err, fmt.Errorf(
"github.com/gogo/protobuf/proto panic (we recommend migrating marshaler to cqrs.ProtoMarshaler to avoid that): %v\n%s",
r,
string(debug.Stack()),
))
}
if err != nil && !m.DisableStdProtoFallback {
_, isStdProtoMsg := v.(stdproto.Message)
if isStdProtoMsg {
msg, err = m.ToProtoMarshaler().Marshal(v)
}
}
}()
protoMsg, ok := v.(proto.Message)
if !ok {
return nil, errors.WithStack(NoProtoMessageError{v})
}
b, err := proto.Marshal(protoMsg)
if err != nil {
return nil, err
}
msg = message.NewMessage(
m.newUUID(),
b,
)
msg.Metadata.Set("name", m.Name(v))
return msg, nil
}
func (m ProtobufMarshaler) newUUID() string {
if m.NewUUID != nil {
return m.NewUUID()
}
// default
return watermill.NewUUID()
}
// Unmarshal unmarshals given watermill's Message into protobuf's message.
func (m ProtobufMarshaler) Unmarshal(msg *message.Message, v interface{}) (err error) {
protoV, ok := v.(proto.Message)
if !ok {
return errors.WithStack(NoProtoMessageError{v})
}
defer func() {
// gogo proto can panic on unmarshal (for example, because it received a message from ProtoMarshaler with oneof)
if r := recover(); r != nil {
err = stderrors.Join(err, fmt.Errorf(
"github.com/gogo/protobuf/proto panic (we recommend migrating marshaler to cqrs.ProtoMarshaler to avoid that): %v\n%s",
r,
string(debug.Stack()),
))
}
if err != nil && !m.DisableStdProtoFallback {
err = m.ToProtoMarshaler().Unmarshal(msg, v)
}
}()
return proto.Unmarshal(msg.Payload, protoV)
}
func (m ProtobufMarshaler) ToProtoMarshaler() ProtoMarshaler {
return ProtoMarshaler{
NewUUID: m.NewUUID,
GenerateName: m.GenerateName,
}
}
// Name returns the command or event's name.
func (m ProtobufMarshaler) Name(cmdOrEvent interface{}) string {
if m.GenerateName != nil {
return m.GenerateName(cmdOrEvent)
}
return FullyQualifiedStructName(cmdOrEvent)
}
// NameFromMessage returns the metadata name value for a given Message.
func (m ProtobufMarshaler) NameFromMessage(msg *message.Message) string {
return msg.Metadata.Get("name")
}

View File

@@ -0,0 +1,142 @@
package cqrs_test
import (
"testing"
"github.com/ThreeDotsLabs/watermill/components/cqrs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProtobufMarshaler_with_fallback(t *testing.T) {
marshaler := cqrs.ProtobufMarshaler{}
assertProtoMarshalUnmarshal(
t,
marshaler,
marshaler,
newProtoTestComplexEvent(),
&TestComplexProtobufEvent{},
"cqrs_test.TestComplexProtobufEvent",
)
legacyEvent, _ := newProtoLegacyTestEvent()
assertProtoMarshalUnmarshal(
t,
marshaler,
marshaler,
legacyEvent,
&TestProtobufEvent{},
"cqrs_test.TestProtobufEvent",
)
}
func TestProtobufMarshaler_without_fallback_legacy_event(t *testing.T) {
legacyEvent, _ := newProtoLegacyTestEvent()
marshaler := cqrs.ProtobufMarshaler{
DisableStdProtoFallback: true,
}
assertProtoMarshalUnmarshal(
t,
marshaler,
marshaler,
legacyEvent,
&TestProtobufEvent{},
"cqrs_test.TestProtobufEvent",
)
}
func TestProtobufMarshaler_Marshal_generated_name(t *testing.T) {
marshaler := cqrs.ProtobufMarshaler{
NewUUID: func() string {
return "foo"
},
}
msg, err := marshaler.Marshal(newProtoTestComplexEvent())
require.NoError(t, err)
assert.Equal(t, msg.UUID, "foo")
}
func TestProtobufMarshaler_catch_panic(t *testing.T) {
marshalerNoFallback := cqrs.ProtobufMarshaler{
DisableStdProtoFallback: true,
}
marshalerWithFallback := cqrs.ProtobufMarshaler{
DisableStdProtoFallback: false,
}
complexEvent := newProtoTestComplexEvent()
msg, err := marshalerNoFallback.Marshal(complexEvent)
assert.Nil(t, msg)
assert.ErrorContains(t, err, "(we recommend migrating marshaler to cqrs.ProtoMarshaler to avoid that)")
assert.ErrorContains(t, err, "invalid memory address or nil pointer dereference")
assert.ErrorContains(t, err, "github.com/gogo/protobuf/proto panic")
assert.ErrorContains(t, err, "runtime/debug.Stack()", "error should contain stack trace")
// let's simulate situation when publishing service uses fallback and consuming service does not
msg, err = marshalerWithFallback.Marshal(complexEvent)
require.NoError(t, err)
err = marshalerNoFallback.Unmarshal(msg, &TestComplexProtobufEvent{})
assert.ErrorContains(t, err, "(we recommend migrating marshaler to cqrs.ProtoMarshaler to avoid that)")
assert.ErrorContains(t, err, "protobuf tag not enough fields in TestComplexProtobufEvent.state")
assert.ErrorContains(t, err, "github.com/gogo/protobuf/proto panic")
assert.ErrorContains(t, err, "runtime/debug.Stack()", "error should contain stack trace")
// marshaler with fallback should handle this message
err = marshalerWithFallback.Unmarshal(msg, &TestComplexProtobufEvent{})
require.NoError(t, err)
}
func TestProtobufMarshaler_compatible_with_ProtoMarshaler(t *testing.T) {
legacyEvent, legacyEventRegenerated := newProtoLegacyTestEvent()
complexEvent := newProtoTestComplexEvent()
deprecatedMarshaler := cqrs.ProtobufMarshaler{}
newMarshaler := cqrs.ProtoMarshaler{}
t.Run("from_deprecated_to_new", func(t *testing.T) {
assertProtoMarshalUnmarshal(
t,
deprecatedMarshaler,
newMarshaler,
complexEvent,
&TestComplexProtobufEvent{},
"cqrs_test.TestComplexProtobufEvent",
)
assertProtoMarshalUnmarshal(
t,
deprecatedMarshaler,
newMarshaler,
legacyEvent,
&TestProtobufLegacyEvent{},
"cqrs_test.TestProtobufEvent",
)
})
t.Run("from_new_to_deprecated", func(t *testing.T) {
assertProtoMarshalUnmarshal(
t,
newMarshaler,
deprecatedMarshaler,
complexEvent,
&TestComplexProtobufEvent{},
"cqrs_test.TestComplexProtobufEvent",
)
assertProtoMarshalUnmarshal(
t,
newMarshaler,
deprecatedMarshaler,
legacyEventRegenerated,
&TestProtobufEvent{},
"cqrs_test.TestProtobufLegacyEvent",
)
})
}

View File

@@ -0,0 +1,114 @@
package cqrs_test
import (
"encoding/json"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/components/cqrs"
)
func TestProtoMarshaler(t *testing.T) {
assertProtoMarshalUnmarshal(
t,
cqrs.ProtoMarshaler{},
cqrs.ProtoMarshaler{},
newProtoTestComplexEvent(),
&TestComplexProtobufEvent{},
"cqrs_test.TestComplexProtobufEvent",
)
}
func TestProtoMarshaler_Marshal_generated_name(t *testing.T) {
marshaler := cqrs.ProtoMarshaler{
NewUUID: func() string {
return "foo"
},
}
msg, err := marshaler.Marshal(newProtoTestComplexEvent())
require.NoError(t, err)
assert.Equal(t, msg.UUID, "foo")
}
// newProtoLegacyTestEvent returns the same event in two different protobuf versions
func newProtoLegacyTestEvent() (*TestProtobufEvent, *TestProtobufLegacyEvent) {
when := timestamppb.New(time.Now())
id := watermill.NewULID()
legacy := &TestProtobufEvent{
Id: id,
When: when,
}
regenerated := &TestProtobufLegacyEvent{
Id: id,
When: when,
}
return legacy, regenerated
}
func newProtoTestComplexEvent() *TestComplexProtobufEvent {
when := timestamppb.New(time.Now())
eventToMarshal := &TestComplexProtobufEvent{
Id: watermill.NewULID(),
Data: []byte("data"),
When: when,
NestedMap: map[string]*SubEvent{
"foo": {
Tags: []string{"tag1", "tag2"},
Flags: map[string]bool{"flag1": true, "flag2": false},
},
},
Events: []*SubEvent{
{
Tags: []string{"tag1", "tag2"},
},
{
Tags: []string{"tag3", "tag4"},
},
},
Result: &TestComplexProtobufEvent_Success{
Success: &SubEvent{
Tags: []string{"tag10"},
Flags: map[string]bool{"flag10": true},
},
},
}
return eventToMarshal
}
func assertProtoMarshalUnmarshal[T1, T2 fmt.Stringer](
t *testing.T,
marshaler cqrs.CommandEventMarshaler,
unmarshaler cqrs.CommandEventMarshaler,
eventToMarshal T1,
eventToUnmarshal T2,
expectedEventName string,
) {
t.Helper()
msg, err := marshaler.Marshal(eventToMarshal)
require.NoError(t, err)
err = unmarshaler.Unmarshal(msg, eventToUnmarshal)
require.NoError(t, err)
eventToMarshalJson, err := json.Marshal(eventToMarshal)
require.NoError(t, err)
eventToUnmarshalJson, err := json.Marshal(eventToUnmarshal)
require.NoError(t, err)
assert.JSONEq(t, string(eventToMarshalJson), string(eventToUnmarshalJson))
assert.Equal(t, expectedEventName, msg.Metadata.Get("name"))
}

58
components/cqrs/name.go Normal file
View File

@@ -0,0 +1,58 @@
package cqrs
import (
"fmt"
"strings"
)
// FullyQualifiedStructName returns object name in format [package].[type name].
// For example, for the struct:
//
// package events
// type UserCreated struct {}
//
// it will return "events.UserCreated".
//
// It ignores if the value is a pointer or not.
func FullyQualifiedStructName(v interface{}) string {
s := fmt.Sprintf("%T", v)
s = strings.TrimLeft(s, "*")
return s
}
// StructName returns struct name in format [type name].
// For example, for the struct:
//
// package events
// type UserCreated struct {}
//
// it will return "UserCreated".
//
// It ignores if the value is a pointer or not.
func StructName(v interface{}) string {
segments := strings.Split(fmt.Sprintf("%T", v), ".")
return segments[len(segments)-1]
}
type namedStruct interface {
Name() string
}
// NamedStruct returns the name from a message implementing the following interface:
//
// type namedStruct interface {
// Name() string
// }
//
// It ignores if the value is a pointer or not.
func NamedStruct(fallback func(v interface{}) string) func(v interface{}) string {
return func(v interface{}) string {
if v, ok := v.(namedStruct); ok {
return v.Name()
}
return fallback(v)
}
}

View File

@@ -0,0 +1,49 @@
package cqrs_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/ThreeDotsLabs/watermill/components/cqrs"
)
func TestFullyQualifiedStructName(t *testing.T) {
type Object struct{}
assert.Equal(t, "cqrs_test.Object", cqrs.FullyQualifiedStructName(Object{}))
assert.Equal(t, "cqrs_test.Object", cqrs.FullyQualifiedStructName(&Object{}))
}
func BenchmarkFullyQualifiedStructName(b *testing.B) {
type Object struct{}
o := Object{}
for i := 0; i < b.N; i++ {
cqrs.FullyQualifiedStructName(o)
}
}
func TestStructName(t *testing.T) {
type Object struct{}
assert.Equal(t, "Object", cqrs.StructName(Object{}))
assert.Equal(t, "Object", cqrs.StructName(&Object{}))
}
func TestNamedStruct(t *testing.T) {
assert.Equal(t, "named object", cqrs.NamedStruct(cqrs.StructName)(namedObject{}))
assert.Equal(t, "named object", cqrs.NamedStruct(cqrs.StructName)(&namedObject{}))
// Test fallback
type Object struct{}
assert.Equal(t, "Object", cqrs.NamedStruct(cqrs.StructName)(Object{}))
assert.Equal(t, "Object", cqrs.NamedStruct(cqrs.StructName)(&Object{}))
}
type namedObject struct{}
func (namedObject) Name() string {
return "named object"
}

23
components/cqrs/object.go Normal file
View File

@@ -0,0 +1,23 @@
package cqrs
import (
"reflect"
)
func isPointer(v interface{}) error {
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Pointer || rv.IsNil() {
return NonPointerError{rv.Type()}
}
return nil
}
type NonPointerError struct {
Type reflect.Type
}
func (e NonPointerError) Error() string {
return "non-pointer command: " + e.Type.String() + ", handler.NewCommand() should return pointer to the command"
}

38
components/cqrs/testdata/events.proto vendored Normal file
View File

@@ -0,0 +1,38 @@
syntax = "proto3";
package cqrs_test;
option go_package = "./cqrs_test";
import "google/protobuf/timestamp.proto";
message TestProtobufLegacyEvent {
string id = 1;
google.protobuf.Timestamp when = 3;
}
enum Status {
STATUS_UNSPECIFIED = 0;
ACTIVE = 1;
DELETED = 2;
}
message SubEvent {
repeated string tags = 1;
map<string, bool> flags = 2;
}
message TestComplexProtobufEvent {
string id = 1;
bytes data = 2;
google.protobuf.Timestamp when = 3;
map<string, SubEvent> nested_map = 4;
repeated SubEvent events = 5;
oneof result {
SubEvent success = 6;
string error = 7;
Status fallback = 8;
}
reserved 23 to 30;
}

68
components/delay/delay.go Normal file
View File

@@ -0,0 +1,68 @@
package delay
import (
"context"
"time"
"github.com/ThreeDotsLabs/watermill/message"
)
// Delay represents a message's delay.
// It can be either a delay until a specific time or a delay for a specific duration.
// The zero value of Delay is a zero delay.
//
// IMPORTANT: Delay doesn't work with all Pub/Subs! Using it won't have any effect on Pub/Subs that don't support it.
// See the list of supported Pub/Subs in the documentation: https://watermill.io/advanced/delayed-messages/
type Delay struct {
time time.Time
duration time.Duration
}
func (d Delay) IsZero() bool {
return d.time.IsZero()
}
// Until returns a delay of the given time.
func Until(delayedUntil time.Time) Delay {
return Delay{
time: delayedUntil,
duration: delayedUntil.Sub(time.Now().UTC()),
}
}
// For returns a delay of now plus the given duration.
func For(delayedFor time.Duration) Delay {
return Delay{
time: time.Now().UTC().Add(delayedFor),
duration: delayedFor,
}
}
type contextKey string
var (
delayContextKey = contextKey("delay")
)
// WithContext returns a new context with the given delay.
// If used together with a publisher wrapped with NewPublisher, the delay will be applied to the message.
//
// IMPORTANT: Delay doesn't work with all Pub/Subs! Using it won't have any effect on Pub/Subs that don't support it.
// See the list of supported Pub/Subs in the documentation: https://watermill.io/advanced/delayed-messages/
func WithContext(ctx context.Context, delay Delay) context.Context {
return context.WithValue(ctx, delayContextKey, delay)
}
const (
DelayedUntilKey = "_watermill_delayed_until"
DelayedForKey = "_watermill_delayed_for"
)
// Message sets the delay metadata on the message.
//
// IMPORTANT: Delay doesn't work with all Pub/Subs! Using it won't have any effect on Pub/Subs that don't support it.
// See the list of supported Pub/Subs in the documentation: https://watermill.io/advanced/delayed-messages/
func Message(msg *message.Message, delay Delay) {
msg.Metadata.Set(DelayedUntilKey, delay.time.Format(time.RFC3339))
msg.Metadata.Set(DelayedForKey, delay.duration.String())
}

View File

@@ -0,0 +1,83 @@
package delay
import (
"errors"
"github.com/ThreeDotsLabs/watermill/message"
)
type DefaultDelayGeneratorParams struct {
Topic string
Message *message.Message
}
// PublisherConfig is a configuration for the delay publisher.
type PublisherConfig struct {
// DefaultDelayGenerator is a function that generates the default delay for a message.
// If the message doesn't have the delay metadata set, the default delay will be applied.
DefaultDelayGenerator func(params DefaultDelayGeneratorParams) (Delay, error)
// AllowNoDelay allows publishing messages without a delay set.
// By default, the publisher returns an error when a message is published without a delay and no default delay generator is provided.
AllowNoDelay bool
}
// NewPublisher wraps a publisher with a delay mechanism.
// A message can be published with delay metadata set in the context by using the WithContext function.
// If the message doesn't have the delay metadata set, the default delay will be applied, if provided.
func NewPublisher(pub message.Publisher, config PublisherConfig) (message.Publisher, error) {
return &publisher{
pub: pub,
config: config,
}, nil
}
type publisher struct {
pub message.Publisher
config PublisherConfig
}
func (p *publisher) Publish(topic string, messages ...*message.Message) error {
for i := range messages {
err := p.applyDelay(topic, messages[i])
if err != nil {
return err
}
}
return p.pub.Publish(topic, messages...)
}
func (p *publisher) Close() error {
return p.pub.Close()
}
func (p *publisher) applyDelay(topic string, msg *message.Message) error {
if msg.Metadata.Get(DelayedForKey) != "" {
return nil
}
if msg.Context().Value(delayContextKey) != nil {
delay := msg.Context().Value(delayContextKey).(Delay)
Message(msg, delay)
return nil
}
if p.config.DefaultDelayGenerator != nil {
delay, err := p.config.DefaultDelayGenerator(DefaultDelayGeneratorParams{
Topic: topic,
Message: msg,
})
if err != nil {
return err
}
Message(msg, delay)
return nil
}
if !p.config.AllowNoDelay {
return errors.New("message doesn't have a delay set")
}
return nil
}

View File

@@ -0,0 +1,178 @@
package delay_test
import (
"context"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ThreeDotsLabs/watermill/components/delay"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
)
func TestPublisher(t *testing.T) {
pubSub := gochannel.NewGoChannel(gochannel.Config{}, nil)
messages, err := pubSub.Subscribe(context.Background(), "test")
require.NoError(t, err)
pub, err := delay.NewPublisher(pubSub, delay.PublisherConfig{})
require.NoError(t, err)
pubAllowNoDelay, err := delay.NewPublisher(pubSub, delay.PublisherConfig{
AllowNoDelay: true,
})
require.NoError(t, err)
defaultDelayPub, err := delay.NewPublisher(pubSub, delay.PublisherConfig{
DefaultDelayGenerator: func(params delay.DefaultDelayGeneratorParams) (delay.Delay, error) {
return delay.For(1 * time.Second), nil
},
})
require.NoError(t, err)
testCases := []struct {
name string
publisher message.Publisher
messageConstructor func(id string) *message.Message
expectedError bool
expectedDelay time.Duration
}{
{
name: "no delay",
publisher: pub,
messageConstructor: func(id string) *message.Message {
return message.NewMessage(id, nil)
},
expectedError: true,
expectedDelay: 0,
},
{
name: "no delay but allowed",
publisher: pubAllowNoDelay,
messageConstructor: func(id string) *message.Message {
return message.NewMessage(id, nil)
},
expectedDelay: 0,
},
{
name: "default delay",
publisher: defaultDelayPub,
messageConstructor: func(id string) *message.Message {
return message.NewMessage(id, nil)
},
expectedDelay: 1 * time.Second,
},
{
name: "delay from metadata",
publisher: pub,
messageConstructor: func(id string) *message.Message {
msg := message.NewMessage(id, nil)
delay.Message(msg, delay.For(2*time.Second))
return msg
},
expectedDelay: 2 * time.Second,
},
{
name: "default delay override with metadata",
publisher: defaultDelayPub,
messageConstructor: func(id string) *message.Message {
msg := message.NewMessage(id, nil)
delay.Message(msg, delay.For(2*time.Second))
return msg
},
expectedDelay: 2 * time.Second,
},
{
name: "delay from context",
publisher: pub,
messageConstructor: func(id string) *message.Message {
msg := message.NewMessage(id, nil)
ctx := delay.WithContext(context.Background(), delay.For(3*time.Second))
msg.SetContext(ctx)
return msg
},
expectedDelay: 3 * time.Second,
},
{
name: "default delay override with context",
publisher: defaultDelayPub,
messageConstructor: func(id string) *message.Message {
msg := message.NewMessage(id, nil)
ctx := delay.WithContext(context.Background(), delay.For(3*time.Second))
msg.SetContext(ctx)
return msg
},
expectedDelay: 3 * time.Second,
},
{
name: "delay with until",
publisher: pub,
messageConstructor: func(id string) *message.Message {
msg := message.NewMessage(id, nil)
delay.Message(msg, delay.Until(time.Now().UTC().Add(4*time.Second)))
return msg
},
expectedDelay: 4 * time.Second,
},
{
name: "both metadata and context set",
publisher: defaultDelayPub,
messageConstructor: func(id string) *message.Message {
msg := message.NewMessage(id, nil)
delay.Message(msg, delay.For(5*time.Second))
ctx := delay.WithContext(context.Background(), delay.For(6*time.Second))
msg.SetContext(ctx)
return msg
},
expectedDelay: 5 * time.Second,
},
}
for i, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
id := fmt.Sprint(i)
msg := testCase.messageConstructor(id)
err = testCase.publisher.Publish("test", msg)
if testCase.expectedError {
require.Error(t, err)
return
}
require.NoError(t, err)
assertMessage(t, messages, id, testCase.expectedDelay)
})
}
}
func assertMessage(t *testing.T, messages <-chan *message.Message, expectedID string, expectedDelay time.Duration) {
t.Helper()
select {
case msg := <-messages:
assert.Equal(t, expectedID, msg.UUID)
if expectedDelay == 0 {
assert.Empty(t, msg.Metadata.Get(delay.DelayedUntilKey))
assert.Empty(t, msg.Metadata.Get(delay.DelayedForKey))
} else {
delayedFor, err := time.ParseDuration(msg.Metadata.Get(delay.DelayedForKey))
require.NoError(t, err)
assert.Equal(t, expectedDelay, delayedFor.Round(time.Second))
delayedUntil, err := time.Parse(time.RFC3339, msg.Metadata.Get(delay.DelayedUntilKey))
require.NoError(t, err)
assert.WithinDuration(t, time.Now().UTC().Add(expectedDelay), delayedUntil, 1*time.Second)
}
msg.Ack()
case <-time.After(100 * time.Millisecond):
require.Fail(t, "timeout")
}
}

125
components/fanin/fanin.go Normal file
View File

@@ -0,0 +1,125 @@
package fanin
import (
"context"
"fmt"
"slices"
"time"
"github.com/pkg/errors"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
)
type Config struct {
// SourceTopics contains topics on which FanIn subscribes.
SourceTopics []string
// TargetTopic determines the topic on which messages from SourceTopics are published.
TargetTopic string
// CloseTimeout determines how long router should work for handlers when closing.
CloseTimeout time.Duration
}
// FanIn is a component that receives messages from 1..N topics from a subscriber and publishes them
// on a specified topic in the publisher. In effect, messages are "multiplexed".
type FanIn struct {
router *message.Router
config Config
logger watermill.LoggerAdapter
}
func (c *Config) setDefaults() {
if c.CloseTimeout == 0 {
c.CloseTimeout = time.Second * 30
}
}
func (c *Config) Validate() error {
if len(c.SourceTopics) == 0 {
return errors.New("sourceTopics must not be empty")
}
if slices.Contains(c.SourceTopics, "") {
return errors.New("sourceTopics must not be empty")
}
if c.TargetTopic == "" {
return errors.New("targetTopic must not be empty")
}
if slices.Contains(c.SourceTopics, c.TargetTopic) {
return errors.New("sourceTopics must not contain targetTopic")
}
return nil
}
// NewFanIn creates a new FanIn.
func NewFanIn(
subscriber message.Subscriber,
publisher message.Publisher,
config Config,
logger watermill.LoggerAdapter,
) (*FanIn, error) {
if subscriber == nil {
return nil, errors.New("missing subscriber")
}
if publisher == nil {
return nil, errors.New("missing publisher")
}
config.setDefaults()
if err := config.Validate(); err != nil {
return nil, err
}
if logger == nil {
logger = watermill.NopLogger{}
}
routerConfig := message.RouterConfig{CloseTimeout: config.CloseTimeout}
if err := routerConfig.Validate(); err != nil {
return nil, errors.Wrap(err, "invalid router config")
}
router, err := message.NewRouter(routerConfig, logger)
if err != nil {
return nil, errors.Wrap(err, "cannot create a router")
}
for _, topic := range config.SourceTopics {
router.AddHandler(
fmt.Sprintf("fan_in_%s", topic),
topic,
subscriber,
config.TargetTopic,
publisher,
func(msg *message.Message) ([]*message.Message, error) {
return []*message.Message{msg}, nil
},
)
}
return &FanIn{
router: router,
config: config,
logger: logger,
}, nil
}
// Run runs the FanIn.
func (f *FanIn) Run(ctx context.Context) error {
return f.router.Run(ctx)
}
// Running is closed when FanIn is running.
func (f *FanIn) Running() chan struct{} {
return f.router.Running()
}
// Close gracefully closes the FanIn
func (f *FanIn) Close() error {
return f.router.Close()
}

View File

@@ -0,0 +1,206 @@
package fanin_test
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/components/fanin"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
)
func TestFanIn(t *testing.T) {
const (
upstreamTopicPattern = "upstream-topic-%d"
downstreamTopic = "downstream-topic"
cancelAfter = time.Millisecond * 100
workersCount = 3
messagesCount = 10
upstreamTopicsCount = 5
)
var upstreamTopics []string
for i := 1; i <= upstreamTopicsCount; i++ {
topic := fmt.Sprintf(upstreamTopicPattern, i)
upstreamTopics = append(upstreamTopics, topic)
}
logger := watermill.NopLogger{}
pubsub := gochannel.NewGoChannel(gochannel.Config{}, watermill.NopLogger{})
fi, err := fanin.NewFanIn(
pubsub,
pubsub,
fanin.Config{
SourceTopics: upstreamTopics,
TargetTopic: downstreamTopic,
},
logger,
)
require.NoError(t, err)
router, err := message.NewRouter(message.RouterConfig{}, logger)
require.NoError(t, err)
expectedNumberOfMessages := workersCount * messagesCount * upstreamTopicsCount
receivedMessages := make(chan string, expectedNumberOfMessages)
for i := 0; i < workersCount; i++ {
router.AddConsumerHandler(
fmt.Sprintf("worker-%v", i),
downstreamTopic,
pubsub,
func(msg *message.Message) error {
payload := string(msg.Payload)
receivedMessages <- payload
return nil
},
)
}
ctx, cancel := context.WithTimeout(context.Background(), cancelAfter)
defer cancel()
go func() {
err := router.Run(ctx)
require.NoError(t, err)
}()
go func() {
err := fi.Run(ctx)
require.NoError(t, err)
}()
<-router.Running()
<-fi.Running()
var wg sync.WaitGroup
wg.Add(len(upstreamTopics) * messagesCount)
for _, topic := range upstreamTopics {
go func(topic string) {
for i := 0; i < messagesCount; i++ {
msg := message.NewMessage(watermill.NewUUID(), []byte(topic))
err := pubsub.Publish(topic, msg)
require.NoError(t, err)
wg.Done()
}
}(topic)
}
wg.Wait()
counts := map[string]int{}
loop:
for {
select {
case msg := <-receivedMessages:
counts[msg]++
case <-time.After(cancelAfter):
close(receivedMessages)
break loop
}
}
sum := 0
require.Len(t, counts, upstreamTopicsCount)
for _, count := range counts {
require.Equal(t, workersCount*messagesCount, count)
sum += count
}
require.Equal(t, expectedNumberOfMessages, sum)
}
func TestNewFanIn(t *testing.T) {
pubsub := gochannel.NewGoChannel(gochannel.Config{}, nil)
t.Run("error when subscriber nil", func(t *testing.T) {
_, err := fanin.NewFanIn(
nil,
nil,
fanin.Config{},
nil,
)
require.EqualError(t, err, "missing subscriber")
})
t.Run("error when publisher nil", func(t *testing.T) {
_, err := fanin.NewFanIn(
pubsub,
nil,
fanin.Config{},
nil,
)
require.EqualError(t, err, "missing publisher")
})
t.Run("error when sourceTopics empty", func(t *testing.T) {
_, err := fanin.NewFanIn(
pubsub,
pubsub,
fanin.Config{},
nil,
)
require.EqualError(t, err, "sourceTopics must not be empty")
})
t.Run("error when sourceTopics empty", func(t *testing.T) {
_, err := fanin.NewFanIn(
pubsub,
pubsub,
fanin.Config{
SourceTopics: []string{""},
},
nil,
)
require.EqualError(t, err, "sourceTopics must not be empty")
})
t.Run("error when targetTopic empty", func(t *testing.T) {
_, err := fanin.NewFanIn(
pubsub,
pubsub,
fanin.Config{
SourceTopics: []string{"topic"},
TargetTopic: "",
},
nil,
)
require.EqualError(t, err, "targetTopic must not be empty")
})
t.Run("error when sourceTopics contains targetTopic", func(t *testing.T) {
_, err := fanin.NewFanIn(
pubsub,
pubsub,
fanin.Config{
SourceTopics: []string{"topic"},
TargetTopic: "topic",
},
nil,
)
require.EqualError(t, err, "sourceTopics must not contain targetTopic")
})
t.Run("correct", func(t *testing.T) {
_, err := fanin.NewFanIn(
pubsub,
pubsub,
fanin.Config{
SourceTopics: []string{"topic"},
TargetTopic: "targetTopic",
},
nil,
)
require.NoError(t, err)
})
}

View File

@@ -0,0 +1,80 @@
package forwarder
import (
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/pkg/errors"
)
// messageEnvelope wraps Watermill message and contains destination topic.
type messageEnvelope struct {
DestinationTopic string `json:"destination_topic"`
UUID string `json:"uuid"`
Payload []byte `json:"payload"`
Metadata map[string]string `json:"metadata"`
}
func newMessageEnvelope(destTopic string, msg *message.Message) (*messageEnvelope, error) {
e := &messageEnvelope{
DestinationTopic: destTopic,
UUID: msg.UUID,
Payload: msg.Payload,
Metadata: msg.Metadata,
}
if err := e.validate(); err != nil {
return nil, errors.Wrap(err, "cannot create a message envelope")
}
return e, nil
}
func (e *messageEnvelope) validate() error {
if e.DestinationTopic == "" {
return errors.New("unknown destination topic")
}
return nil
}
func wrapMessageInEnvelope(
destinationTopic string,
msg *message.Message,
marshaler Marshaler,
) (*message.Message, error) {
envelope, err := newMessageEnvelope(destinationTopic, msg)
if err != nil {
return nil, errors.Wrap(err, "cannot envelope a message")
}
envelopedMessage, err := marshaler.Marshal(envelope)
if err != nil {
return nil, errors.Wrap(err, "cannot marshal a message")
}
wrappedMsg := message.NewMessage(watermill.NewUUID(), envelopedMessage)
wrappedMsg.SetContext(msg.Context())
return wrappedMsg, nil
}
func unwrapMessageFromEnvelope(
msg *message.Message,
marshaler Marshaler,
) (destinationTopic string, unwrappedMsg *message.Message, err error) {
envelopedMsg := messageEnvelope{}
if err := marshaler.Unmarshal(msg.Payload, &envelopedMsg); err != nil {
return "", nil, errors.Wrap(err, "cannot unmarshal message wrapped in an envelope")
}
if err := envelopedMsg.validate(); err != nil {
return "", nil, errors.Wrap(err, "an unmarshalled message envelope is invalid")
}
watermillMessage := message.NewMessage(envelopedMsg.UUID, envelopedMsg.Payload)
watermillMessage.Metadata = envelopedMsg.Metadata
watermillMessage.SetContext(msg.Context())
return envelopedMsg.DestinationTopic, watermillMessage, nil
}

View File

@@ -0,0 +1,46 @@
package forwarder
import (
"context"
"testing"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type contextKey string
func TestEnvelope(t *testing.T) {
expectedUUID := watermill.NewUUID()
marshaler := DefaultMarshaler{}
expectedPayload := message.Payload("msg content")
expectedMetadata := message.Metadata{"key": "value"}
expectedDestinationTopic := "dest_topic"
ctx := context.WithValue(context.Background(), contextKey("key"), "value")
msg := message.NewMessage(expectedUUID, expectedPayload)
msg.Metadata = expectedMetadata
msg.SetContext(ctx)
wrappedMsg, err := wrapMessageInEnvelope(expectedDestinationTopic, msg, marshaler)
require.NoError(t, err)
require.NotNil(t, wrappedMsg)
v, ok := wrappedMsg.Context().Value(contextKey("key")).(string)
require.True(t, ok)
require.Equal(t, "value", v)
destinationTopic, unwrappedMsg, err := unwrapMessageFromEnvelope(wrappedMsg, marshaler)
require.NoError(t, err)
require.NotNil(t, unwrappedMsg)
assert.Equal(t, expectedUUID, unwrappedMsg.UUID)
assert.Equal(t, expectedPayload, unwrappedMsg.Payload)
assert.Equal(t, expectedMetadata, unwrappedMsg.Metadata)
assert.Equal(t, expectedDestinationTopic, destinationTopic)
v, ok = unwrappedMsg.Context().Value(contextKey("key")).(string)
require.True(t, ok)
require.Equal(t, "value", v)
}

View File

@@ -0,0 +1,169 @@
package forwarder
import (
"context"
"time"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/pkg/errors"
)
const (
defaultForwarderTopic = "forwarder_topic"
defaultHandlerName = "events_forwarder"
)
type Config struct {
// ForwarderTopic is a topic on which the forwarder will be listening to enveloped messages to forward.
// Defaults to `forwarder_topic`.
ForwarderTopic string
// HandlerName is the name of the forwarder's handler registered in the router.
// Defaults to `events_forwarder`.
//
// Handler names must be unique within a router, so it needs to be set explicitly
// if you want to run multiple forwarders on the same router.
HandlerName string
// Middlewares are used to decorate forwarder's handler function.
Middlewares []message.HandlerMiddleware
// CloseTimeout determines how long router should work for handlers when closing.
CloseTimeout time.Duration
// AckWhenCannotUnwrap enables acking of messages which cannot be unwrapped from an envelope.
AckWhenCannotUnwrap bool
// Router is a router used by the forwarder.
// If not provided, a new router will be created.
//
// If router is provided, it's not necessary to call `Forwarder.Run()` if the router is started with `router.Run()`.
Router *message.Router
// Marshaler is used to deserialize envelopes received on ForwarderTopic.
// It must match the Marshaler used by the decorated Publisher.
// Defaults to DefaultMarshaler, which uses encoding/json.
Marshaler Marshaler
}
func (c *Config) setDefaults() {
if c.CloseTimeout == 0 {
c.CloseTimeout = time.Second * 30
}
if c.ForwarderTopic == "" {
c.ForwarderTopic = defaultForwarderTopic
}
if c.HandlerName == "" {
c.HandlerName = defaultHandlerName
}
if c.Marshaler == nil {
c.Marshaler = DefaultMarshaler{}
}
}
func (c *Config) Validate() error {
if c.ForwarderTopic == "" {
return errors.New("empty forwarder topic")
}
if c.HandlerName == "" {
return errors.New("empty handler name")
}
return nil
}
// Forwarder subscribes to the topic provided in the config and publishes them to the destination topic embedded in the enveloped message.
type Forwarder struct {
router *message.Router
publisher message.Publisher
logger watermill.LoggerAdapter
config Config
}
// NewForwarder creates a forwarder which will subscribe to the topic provided in the config using the provided subscriber.
// It will publish messages received on this subscription to the destination topic embedded in the enveloped message using the provided publisher.
//
// Provided subscriber and publisher can be from different Watermill Pub/Sub implementations, i.e. MySQL subscriber and Google Pub/Sub publisher.
//
// Note: Keep in mind that by default the forwarder will nack all messages which weren't sent using a decorated publisher.
// You can change this behavior by passing a middleware which will ack them instead.
func NewForwarder(
subscriberIn message.Subscriber,
publisherOut message.Publisher,
logger watermill.LoggerAdapter,
config Config,
) (*Forwarder, error) {
config.setDefaults()
routerConfig := message.RouterConfig{CloseTimeout: config.CloseTimeout}
if err := routerConfig.Validate(); err != nil {
return nil, errors.Wrap(err, "invalid router config")
}
var router *message.Router
if config.Router != nil {
router = config.Router
} else {
var err error
router, err = message.NewRouter(routerConfig, logger)
if err != nil {
return nil, errors.Wrap(err, "cannot create a router")
}
}
f := &Forwarder{router, publisherOut, logger, config}
handler := router.AddConsumerHandler(
config.HandlerName,
config.ForwarderTopic,
subscriberIn,
f.forwardMessage,
)
handler.AddMiddleware(config.Middlewares...)
return f, nil
}
// Run runs forwarder's handler responsible for forwarding messages.
// This call is blocking while the forwarder is running.
// ctx will be propagated to the forwarder's subscription.
//
// To stop Run() you should call Close() on the forwarder.
func (f *Forwarder) Run(ctx context.Context) error {
return f.router.Run(ctx)
}
// Close stops forwarder's handler.
func (f *Forwarder) Close() error {
return f.router.Close()
}
// Running returns channel which is closed when the forwarder is running.
func (f *Forwarder) Running() chan struct{} {
return f.router.Running()
}
func (f *Forwarder) forwardMessage(msg *message.Message) error {
destTopic, unwrappedMsg, err := unwrapMessageFromEnvelope(msg, f.config.Marshaler)
if err != nil {
f.logger.Error("Could not unwrap a message from an envelope", err, watermill.LogFields{
"uuid": msg.UUID,
"payload": msg.Payload,
"metadata": msg.Metadata,
"acked": f.config.AckWhenCannotUnwrap,
})
if f.config.AckWhenCannotUnwrap {
return nil
}
return errors.Wrap(err, "cannot unwrap message from an envelope")
}
if err := f.publisher.Publish(destTopic, unwrappedMsg); err != nil {
return errors.Wrap(err, "cannot publish a message")
}
return nil
}

View File

@@ -0,0 +1,263 @@
package forwarder_test
import (
"context"
"testing"
"time"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/components/forwarder"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
var (
logger = watermill.NewStdLogger(true, true)
forwarderTopic = "forwarder_topic"
outTopic = "out_topic"
)
func TestForwarder(t *testing.T) {
suite.Run(t, new(ForwarderSuite))
}
// ForwarderSuite tests forwarding messages from PubSubIn to PubSubOut (which are GoChannel implementation underneath).
type ForwarderSuite struct {
suite.Suite
ctx context.Context
cancelCtx func()
publisherIn PubSubInPublisher
subscriberIn PubSubInSubscriber
publisherOut PubSubOutPublisher
subscriberOut PubSubOutSubscriber
decoratedPublisherIn *forwarder.Publisher
outMessagesCh <-chan *message.Message
}
func (s *ForwarderSuite) SetupTest() {
// Create test context with a 5 seconds timeout so it will close any subscriptions/handlers running in the background
// in case of too long test execution.
s.ctx, s.cancelCtx = context.WithTimeout(context.Background(), time.Second*5)
// Create a set of publisher and subscribers for both In and Out Pub/Subs.
s.publisherIn, s.subscriberIn = newPubSubIn()
s.publisherOut, s.subscriberOut = newPubSubOut()
s.decoratedPublisherIn = forwarder.NewPublisher(s.publisherIn, forwarder.PublisherConfig{ForwarderTopic: forwarderTopic})
s.listenOnOutTopic()
}
func (s *ForwarderSuite) TearDownTest() {
s.NoError(s.publisherIn.Close())
s.NoError(s.subscriberIn.Close())
s.NoError(s.publisherOut.Close())
s.NoError(s.subscriberOut.Close())
s.cancelCtx()
}
func (s *ForwarderSuite) TestForwarder_publish_using_decorated_publisher() {
fwd := s.setupForwarder(forwarder.Config{ForwarderTopic: forwarderTopic})
defer func() {
s.NoError(fwd.Close())
}()
sentMsg := s.sampleMessage()
err := s.decoratedPublisherIn.Publish(outTopic, sentMsg)
s.Require().NoError(err)
s.requireFirstMessage(sentMsg)
}
func (s *ForwarderSuite) TestForwarder_publish_using_non_decorated_publisher() {
msgAckedDetectorMiddleware, msgAckedCh := s.setupMessageAckedDetectorMiddleware()
fwd := s.setupForwarder(forwarder.Config{
ForwarderTopic: forwarderTopic,
Middlewares: []message.HandlerMiddleware{msgAckedDetectorMiddleware},
})
defer func() {
s.NoError(fwd.Close())
}()
sentMsg := s.sampleMessage()
err := s.publisherIn.Publish(forwarderTopic, sentMsg)
s.Require().NoError(err)
s.requireFirstAckingResult(msgAckedCh, false)
}
func (s *ForwarderSuite) TestForwarder_publish_using_non_decorated_publisher_acking_enabled() {
msgAckedDetectorMiddleware, msgAckedCh := s.setupMessageAckedDetectorMiddleware()
fwd := s.setupForwarder(forwarder.Config{
ForwarderTopic: forwarderTopic,
Middlewares: []message.HandlerMiddleware{msgAckedDetectorMiddleware},
AckWhenCannotUnwrap: true,
})
defer func() {
s.NoError(fwd.Close())
}()
sentMsg := s.sampleMessage()
err := s.publisherIn.Publish(forwarderTopic, sentMsg)
s.Require().NoError(err)
s.requireFirstAckingResult(msgAckedCh, true)
}
// TestForwarder_multiple_forwarders_on_one_router ensures that forwarders sharing a router
// can coexist as long as they have unique handler names.
func TestForwarder_multiple_forwarders_on_one_router(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
pubSubIn := gochannel.NewGoChannel(gochannel.Config{}, logger)
pubSubOut := gochannel.NewGoChannel(gochannel.Config{}, logger)
router, err := message.NewRouter(message.RouterConfig{}, logger)
require.NoError(t, err)
handlerNames := []string{"first_forwarder", "second_forwarder"}
for _, handlerName := range handlerNames {
_, err := forwarder.NewForwarder(pubSubIn, pubSubOut, logger, forwarder.Config{
ForwarderTopic: handlerName + "_topic",
HandlerName: handlerName,
Router: router,
})
require.NoError(t, err)
}
go func() {
assert.NoError(t, router.Run(ctx))
}()
defer func() {
assert.NoError(t, router.Close())
}()
select {
case <-router.Running():
case <-ctx.Done():
t.Fatal("router not running")
}
outMessagesCh, err := pubSubOut.Subscribe(ctx, outTopic)
require.NoError(t, err)
for _, handlerName := range handlerNames {
publisher := forwarder.NewPublisher(pubSubIn, forwarder.PublisherConfig{
ForwarderTopic: handlerName + "_topic",
})
msg := message.NewMessage(watermill.NewUUID(), message.Payload(handlerName))
require.NoError(t, publisher.Publish(outTopic, msg))
}
// Both forwarders publish to the same out topic, so messages can arrive in any order.
receivedPayloads := map[string]bool{}
for range handlerNames {
select {
case receivedMsg := <-outMessagesCh:
receivedPayloads[string(receivedMsg.Payload)] = true
receivedMsg.Ack()
case <-time.After(time.Second * 3):
t.Fatal("didn't receive all forwarded messages")
}
}
assert.Equal(t, map[string]bool{"first_forwarder": true, "second_forwarder": true}, receivedPayloads)
}
type PubSubInPublisher struct {
message.Publisher
}
type PubSubInSubscriber struct {
message.Subscriber
}
type PubSubOutPublisher struct {
message.Publisher
}
type PubSubOutSubscriber struct {
message.Subscriber
}
func newPubSubIn() (PubSubInPublisher, PubSubInSubscriber) {
channelPubSub := gochannel.NewGoChannel(gochannel.Config{}, logger)
return PubSubInPublisher{channelPubSub}, PubSubInSubscriber{channelPubSub}
}
func newPubSubOut() (PubSubOutPublisher, PubSubOutSubscriber) {
channelPubSub := gochannel.NewGoChannel(gochannel.Config{}, logger)
return PubSubOutPublisher{channelPubSub}, PubSubOutSubscriber{channelPubSub}
}
func (s *ForwarderSuite) setupForwarder(config forwarder.Config) *forwarder.Forwarder {
f, err := forwarder.NewForwarder(s.subscriberIn, s.publisherOut, logger, config)
s.Require().NoError(err)
go func() {
s.Require().NoError(f.Run(s.ctx))
}()
select {
case <-f.Running():
case <-s.ctx.Done():
s.T().Fatal("forwarder not running")
}
return f
}
func (s *ForwarderSuite) listenOnOutTopic() {
var err error
s.outMessagesCh, err = s.subscriberOut.Subscribe(s.ctx, outTopic)
s.Require().NoError(err)
}
func (s *ForwarderSuite) requireFirstMessage(expectedMessage *message.Message) {
select {
case receivedMessage := <-s.outMessagesCh:
s.Require().NotNil(receivedMessage)
s.Require().Truef(receivedMessage.Equals(expectedMessage), "received message: '%s', expected: '%s'", receivedMessage, expectedMessage)
receivedMessage.Ack()
case <-time.After(time.Second):
s.T().Fatal("didn't receive any message after 1 sec")
}
}
func (s *ForwarderSuite) setupMessageAckedDetectorMiddleware() (message.HandlerMiddleware, <-chan bool) {
messageAckedCh := make(chan bool, 1)
messageAckedDetector := func(handlerFunc message.HandlerFunc) message.HandlerFunc {
return func(msg *message.Message) ([]*message.Message, error) {
msgs, err := handlerFunc(msg)
messageAckedCh <- err == nil
// Always return nil as we don't want to nack the message in tests.
return msgs, nil
}
}
return messageAckedDetector, messageAckedCh
}
func (s *ForwarderSuite) requireFirstAckingResult(msgAckedCh <-chan bool, expected bool) {
select {
case msgAcked := <-msgAckedCh:
s.Require().Equal(expected, msgAcked)
case <-time.After(time.Second):
s.T().Fatal("acking result not received after 1 sec")
}
}
func (s *ForwarderSuite) sampleMessage() *message.Message {
msg := message.NewMessage(watermill.NewUUID(), message.Payload("message payload"))
msg.Metadata = message.Metadata{"key": "value"}
return msg
}

View File

@@ -0,0 +1,27 @@
package forwarder
import "encoding/json"
// Marshaler is used by the Forwarder Publisher to serialize the envelope sent
// to the forwarder topic, and by the Forwarder consumer to deserialize it.
//
// The interface matches the signatures of encoding/json's Marshal and Unmarshal,
// so any drop-in JSON library (e.g. github.com/bytedance/sonic, github.com/goccy/go-json)
// can be adapted with a small wrapper.
//
// The default is DefaultMarshaler, which uses encoding/json.
type Marshaler interface {
Marshal(v any) ([]byte, error)
Unmarshal(data []byte, v any) error
}
// DefaultMarshaler uses the standard library's encoding/json package.
type DefaultMarshaler struct{}
func (DefaultMarshaler) Marshal(v any) ([]byte, error) {
return json.Marshal(v)
}
func (DefaultMarshaler) Unmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}

View File

@@ -0,0 +1,73 @@
package forwarder
import (
"github.com/ThreeDotsLabs/watermill/message"
"github.com/pkg/errors"
)
// PublisherConfig configures the decorating Publisher returned by NewPublisher.
type PublisherConfig struct {
// ForwarderTopic is a topic which the forwarder is listening to. Publisher will send enveloped messages to this topic.
// Defaults to `forwarder_topic`.
ForwarderTopic string
// Marshaler is used to serialize envelopes sent to ForwarderTopic.
// It must match the Marshaler used by the Forwarder consuming the topic.
// Defaults to DefaultMarshaler, which uses encoding/json.
Marshaler Marshaler
}
func (c *PublisherConfig) setDefaults() {
if c.ForwarderTopic == "" {
c.ForwarderTopic = defaultForwarderTopic
}
if c.Marshaler == nil {
c.Marshaler = DefaultMarshaler{}
}
}
func (c *PublisherConfig) Validate() error {
if c.ForwarderTopic == "" {
return errors.New("empty forwarder topic")
}
return nil
}
// Publisher changes `Publish` method behavior so it wraps a sent message in an envelope
// and sends it to the forwarder topic provided in the config.
type Publisher struct {
wrappedPublisher message.Publisher
config PublisherConfig
}
func NewPublisher(publisher message.Publisher, config PublisherConfig) *Publisher {
config.setDefaults()
return &Publisher{
wrappedPublisher: publisher,
config: config,
}
}
func (p *Publisher) Publish(topic string, messages ...*message.Message) error {
envelopedMessages := make([]*message.Message, 0, len(messages))
for _, msg := range messages {
envelopedMsg, err := wrapMessageInEnvelope(topic, msg, p.config.Marshaler)
if err != nil {
return errors.Wrapf(err, "cannot wrap message, target topic: '%s', uuid: '%s'", topic, msg.UUID)
}
envelopedMessages = append(envelopedMessages, envelopedMsg)
}
if err := p.wrappedPublisher.Publish(p.config.ForwarderTopic, envelopedMessages...); err != nil {
return errors.Wrapf(err, "cannot publish messages to forwarder topic: '%s'", p.config.ForwarderTopic)
}
return nil
}
func (p *Publisher) Close() error {
return p.wrappedPublisher.Close()
}

View File

@@ -0,0 +1,140 @@
package metrics
import (
"github.com/ThreeDotsLabs/watermill/internal"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
)
type PrometheusMetricsBuilderConfig struct {
Namespace string
Subsystem string
AdditionalLabels []MetricLabel
}
func NewPrometheusMetricsBuilderWithConfig(prometheusRegistry prometheus.Registerer, config PrometheusMetricsBuilderConfig) PrometheusMetricsBuilder {
builder := PrometheusMetricsBuilder{
Namespace: config.Namespace,
Subsystem: config.Subsystem,
PrometheusRegistry: prometheusRegistry,
additionalLabels: config.AdditionalLabels,
}
return builder
}
func NewPrometheusMetricsBuilder(prometheusRegistry prometheus.Registerer, namespace string, subsystem string) PrometheusMetricsBuilder {
return NewPrometheusMetricsBuilderWithConfig(prometheusRegistry, PrometheusMetricsBuilderConfig{
Namespace: namespace,
Subsystem: subsystem,
})
}
// PrometheusMetricsBuilder provides methods to decorate publishers, subscribers and handlers.
type PrometheusMetricsBuilder struct {
// PrometheusRegistry may be filled with a pre-existing Prometheus registry, or left empty for the default registry.
PrometheusRegistry prometheus.Registerer
Namespace string
Subsystem string
// PublishBuckets defines the histogram buckets for publish time histogram, defaulted if nil.
PublishBuckets []float64
// HandlerBuckets defines the histogram buckets for handle execution time histogram, defaulted to watermill's default.
HandlerBuckets []float64
additionalLabels []MetricLabel
}
// AddPrometheusRouterMetrics is a convenience function that acts on the message router to add the metrics middleware
// to all its handlers. The handlers' publishers and subscribers are also decorated.
// The default buckets are used for the handler execution time histogram (use your own provisioning
// with NewRouterMiddlewareWithConfig if needed).
func (b PrometheusMetricsBuilder) AddPrometheusRouterMetrics(r *message.Router) {
r.AddPublisherDecorators(b.DecoratePublisher)
r.AddSubscriberDecorators(b.DecorateSubscriber)
r.AddMiddleware(b.NewRouterMiddleware().Middleware)
}
// DecoratePublisher wraps the underlying publisher with Prometheus metrics.
func (b PrometheusMetricsBuilder) DecoratePublisher(pub message.Publisher) (message.Publisher, error) {
var err error
d := PublisherPrometheusMetricsDecorator{
pub: pub,
publisherName: internal.StructName(pub),
additionalLabels: b.additionalLabels,
}
d.publishTimeSeconds, err = b.registerHistogramVec(prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: b.Namespace,
Subsystem: b.Subsystem,
Name: "publish_time_seconds",
Help: "The time that a publishing attempt (success or not) took in seconds",
Buckets: b.PublishBuckets,
},
toLabelsSlice(publisherLabelKeys, b.additionalLabels),
))
if err != nil {
return nil, errors.Wrap(err, "could not register publish time metric")
}
return d, nil
}
// DecorateSubscriber wraps the underlying subscriber with Prometheus metrics.
func (b PrometheusMetricsBuilder) DecorateSubscriber(sub message.Subscriber) (message.Subscriber, error) {
var err error
d := &SubscriberPrometheusMetricsDecorator{
closing: make(chan struct{}),
subscriberName: internal.StructName(sub),
additionalLabels: b.additionalLabels,
}
d.subscriberMessagesReceivedTotal, err = b.registerCounterVec(prometheus.NewCounterVec(
prometheus.CounterOpts{
Namespace: b.Namespace,
Subsystem: b.Subsystem,
Name: "subscriber_messages_received_total",
Help: "The total number of messages received by the subscriber",
},
toLabelsSlice(append(subscriberLabelKeys, labelAcked), b.additionalLabels),
))
if err != nil {
return nil, errors.Wrap(err, "could not register time to ack metric")
}
d.Subscriber, err = message.MessageTransformSubscriberDecorator(d.recordMetrics)(sub)
if err != nil {
return nil, errors.Wrap(err, "could not decorate subscriber with metrics decorator")
}
return d, nil
}
func (b PrometheusMetricsBuilder) register(c prometheus.Collector) (prometheus.Collector, error) {
err := b.PrometheusRegistry.Register(c)
if err == nil {
return c, nil
}
if are, ok := err.(prometheus.AlreadyRegisteredError); ok {
return are.ExistingCollector, nil
}
return nil, err
}
func (b PrometheusMetricsBuilder) registerCounterVec(c *prometheus.CounterVec) (*prometheus.CounterVec, error) {
col, err := b.register(c)
if err != nil {
return nil, err
}
return col.(*prometheus.CounterVec), nil
}
func (b PrometheusMetricsBuilder) registerHistogramVec(h *prometheus.HistogramVec) (*prometheus.HistogramVec, error) {
col, err := b.register(h)
if err != nil {
return nil, err
}
return col.(*prometheus.HistogramVec), nil
}

28
components/metrics/ctx.go Normal file
View File

@@ -0,0 +1,28 @@
package metrics
import "context"
type contextValue int
const (
publishObserved contextValue = iota
subscribeObserved
)
// setPublishObservedToCtx is used to achieve metrics idempotency in case of double applied middleware
func setPublishObservedToCtx(ctx context.Context) context.Context {
return context.WithValue(ctx, publishObserved, true)
}
func publishAlreadyObserved(ctx context.Context) bool {
return ctx.Value(publishObserved) != nil
}
// setSubscribeObservedToCtx is used to achieve metrics idempotency in case of double applied middleware
func setSubscribeObservedToCtx(ctx context.Context) context.Context {
return context.WithValue(ctx, subscribeObserved, true)
}
func subscribeAlreadyObserved(ctx context.Context) bool {
return ctx.Value(subscribeObserved) != nil
}

View File

@@ -0,0 +1,92 @@
package metrics
import (
"time"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
"github.com/ThreeDotsLabs/watermill/message"
)
var (
handlerLabelKeys = []string{
labelKeyHandlerName,
labelSuccess,
}
// defaultHandlerExecutionTimeBuckets are one order of magnitude smaller than default buckets (5ms~10s),
// because the handler execution times are typically shorter (µs~ms range).
defaultHandlerExecutionTimeBuckets = []float64{
0.0005,
0.001,
0.0025,
0.005,
0.01,
0.025,
0.05,
0.1,
0.25,
0.5,
1,
}
)
// HandlerPrometheusMetricsMiddleware is a middleware that captures Prometheus metrics.
type HandlerPrometheusMetricsMiddleware struct {
handlerExecutionTimeSeconds *prometheus.HistogramVec
additionalLabels []MetricLabel
}
// Middleware returns the middleware ready to be used with watermill's Router.
func (m HandlerPrometheusMetricsMiddleware) Middleware(h message.HandlerFunc) message.HandlerFunc {
return func(msg *message.Message) (msgs []*message.Message, err error) {
now := time.Now()
ctx := msg.Context()
labels := prometheus.Labels{
labelKeyHandlerName: message.HandlerNameFromCtx(ctx),
}
for _, lb := range m.additionalLabels {
labels[lb.Label] = lb.ComputeValueFn(ctx)
}
defer func() {
if err != nil {
labels[labelSuccess] = "false"
} else {
labels[labelSuccess] = "true"
}
m.handlerExecutionTimeSeconds.With(labels).Observe(time.Since(now).Seconds())
}()
return h(msg)
}
}
// NewRouterMiddleware returns new middleware.
func (b PrometheusMetricsBuilder) NewRouterMiddleware() HandlerPrometheusMetricsMiddleware {
var err error
m := HandlerPrometheusMetricsMiddleware{
additionalLabels: b.additionalLabels,
}
if b.HandlerBuckets == nil {
b.HandlerBuckets = defaultHandlerExecutionTimeBuckets
}
m.handlerExecutionTimeSeconds, err = b.registerHistogramVec(prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: b.Namespace,
Subsystem: b.Subsystem,
Name: "handler_execution_time_seconds",
Help: "The total time elapsed while executing the handler function in seconds",
Buckets: b.HandlerBuckets,
},
toLabelsSlice(handlerLabelKeys, b.additionalLabels),
))
if err != nil {
panic(errors.Wrap(err, "could not register handler execution time metric"))
}
return m
}

View File

@@ -0,0 +1,41 @@
package metrics
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// CreateRegistryAndServeHTTP establishes an HTTP server that exposes the /metrics endpoint for Prometheus at the given address.
// It returns a new prometheus registry (to register the metrics on) and a canceling function that ends the server.
func CreateRegistryAndServeHTTP(addr string) (registry *prometheus.Registry, cancel func()) {
registry = prometheus.NewRegistry()
return registry, ServeHTTP(addr, registry)
}
// ServeHTTP establishes an HTTP server that exposes the /metrics endpoint for Prometheus at the given address.
// It takes an existing Prometheus registry and returns a canceling function that ends the server.
func ServeHTTP(addr string, registry *prometheus.Registry) (cancel func()) {
router := chi.NewRouter()
handler := promhttp.HandlerFor(registry, promhttp.HandlerOpts{})
router.Get("/metrics", func(w http.ResponseWriter, r *http.Request) {
handler.ServeHTTP(w, r)
})
server := http.Server{
Addr: addr,
Handler: router,
}
go func() {
err := server.ListenAndServe()
if !errors.Is(err, http.ErrServerClosed) {
panic(err)
}
}()
return func() { _ = server.Close() }
}

View File

@@ -0,0 +1,66 @@
package metrics_test
import (
"net/http"
"testing"
"time"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/stretchr/testify/assert"
"github.com/ThreeDotsLabs/watermill/components/metrics"
)
func TestCreateRegistryAndServeHTTP_metrics_endpoint(t *testing.T) {
reg, cancel := metrics.CreateRegistryAndServeHTTP(":8090")
defer cancel()
err := reg.Register(collectors.NewBuildInfoCollector())
if err != nil {
t.Fatal(errors.Wrap(err, "registration of prometheus build info collector failed"))
}
waitServerReady(t, "http://localhost:8090")
resp, err := http.DefaultClient.Get("http://localhost:8090/metrics")
if resp != nil {
defer func() { _ = resp.Body.Close() }()
}
if err != nil {
t.Fatal(errors.Wrap(err, "call to metrics endpoint failed"))
}
assert.NotNil(t, resp)
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestCreateRegistryAndServeHTTP_unknown_endpoint(t *testing.T) {
reg, cancel := metrics.CreateRegistryAndServeHTTP(":8091")
defer cancel()
err := reg.Register(collectors.NewBuildInfoCollector())
if err != nil {
t.Error(errors.Wrap(err, "registration of prometheus build info collector failed"))
}
waitServerReady(t, "http://localhost:8091")
resp, err := http.DefaultClient.Get("http://localhost:8091/unknown")
if resp != nil {
defer func() { _ = resp.Body.Close() }()
}
if err != nil {
t.Fatal(errors.Wrap(err, "call to unknown endpoint failed"))
}
assert.NotNil(t, resp)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
// server might have small delay before being able to server traffic
func waitServerReady(t *testing.T, addr string) {
for i := 0; i < 50; i++ {
_, err := http.DefaultClient.Get(addr)
// assume server ready when no err anymore
if err == nil {
return
}
time.Sleep(100 * time.Millisecond)
continue
}
}

View File

@@ -0,0 +1,75 @@
package metrics
import (
"context"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/prometheus/client_golang/prometheus"
)
const (
labelKeyHandlerName = "handler_name"
labelKeyPublisherName = "publisher_name"
labelKeySubscriberName = "subscriber_name"
labelSuccess = "success"
labelAcked = "acked"
labelValueNoHandler = "<no handler>"
)
var (
labelGetters = map[string]func(context.Context) string{
labelKeyHandlerName: message.HandlerNameFromCtx,
labelKeyPublisherName: message.PublisherNameFromCtx,
labelKeySubscriberName: message.SubscriberNameFromCtx,
}
)
func labelsFromCtx(ctx context.Context, labels ...string) prometheus.Labels {
ctxLabels := map[string]string{}
for _, l := range labels {
k := l
ctxLabels[l] = ""
getter, ok := labelGetters[k]
if !ok {
continue
}
v := getter(ctx)
if v != "" {
ctxLabels[l] = v
}
}
return ctxLabels
}
type LabelComputeValueFn func(msgCtx context.Context) string
type MetricLabel struct {
Label string
ComputeValueFn LabelComputeValueFn
}
func toLabelsSlice(baseLabels []string, customs []MetricLabel) []string {
labels := make([]string, len(baseLabels), len(baseLabels)+len(customs))
copy(labels, baseLabels)
for _, label := range customs {
//Check if the additional label is already in the base labels. We cannot have duplicate labels
//If it's in the base, just skip it as the compute function is going to overwrite the default value
contains := false
for _, baseLabel := range baseLabels {
if baseLabel == label.Label {
contains = true
break
}
}
if !contains {
labels = append(labels, label.Label)
}
}
return labels
}

View File

@@ -0,0 +1,70 @@
package metrics
import (
"time"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/prometheus/client_golang/prometheus"
)
var (
publisherLabelKeys = []string{
labelKeyHandlerName,
labelKeyPublisherName,
labelSuccess,
}
)
// PublisherPrometheusMetricsDecorator decorates a publisher to capture Prometheus metrics.
type PublisherPrometheusMetricsDecorator struct {
pub message.Publisher
publisherName string
publishTimeSeconds *prometheus.HistogramVec
additionalLabels []MetricLabel
}
// Publish updates the relevant publisher metrics and calls the wrapped publisher's Publish.
func (m PublisherPrometheusMetricsDecorator) Publish(topic string, messages ...*message.Message) (err error) {
if len(messages) == 0 {
return m.pub.Publish(topic)
}
// TODO: take ctx not only from first msg. Might require changing the signature of Publish, which is planned anyway.
ctx := messages[0].Context()
labels := labelsFromCtx(ctx, publisherLabelKeys...)
if labels[labelKeyPublisherName] == "" {
labels[labelKeyPublisherName] = m.publisherName
}
if labels[labelKeyHandlerName] == "" {
labels[labelKeyHandlerName] = labelValueNoHandler
}
for _, lb := range m.additionalLabels {
labels[lb.Label] = lb.ComputeValueFn(ctx)
}
start := time.Now()
defer func() {
if publishAlreadyObserved(ctx) {
// decorator idempotency when applied decorator multiple times
return
}
if err != nil {
labels[labelSuccess] = "false"
} else {
labels[labelSuccess] = "true"
}
m.publishTimeSeconds.With(labels).Observe(time.Since(start).Seconds())
}()
for _, msg := range messages {
msg.SetContext(setPublishObservedToCtx(msg.Context()))
}
return m.pub.Publish(topic, messages...)
}
// Close decreases the total publisher count, closes the Prometheus HTTP server and calls wrapped Close.
func (m PublisherPrometheusMetricsDecorator) Close() error {
return m.pub.Close()
}

View File

@@ -0,0 +1,57 @@
package metrics
import (
"github.com/ThreeDotsLabs/watermill/message"
"github.com/prometheus/client_golang/prometheus"
)
var (
subscriberLabelKeys = []string{
labelKeyHandlerName,
labelKeySubscriberName,
}
)
// SubscriberPrometheusMetricsDecorator decorates a subscriber to capture Prometheus metrics.
type SubscriberPrometheusMetricsDecorator struct {
message.Subscriber
subscriberName string
subscriberMessagesReceivedTotal *prometheus.CounterVec
closing chan struct{}
additionalLabels []MetricLabel
}
func (s SubscriberPrometheusMetricsDecorator) recordMetrics(msg *message.Message) {
if msg == nil {
return
}
ctx := msg.Context()
labels := labelsFromCtx(ctx, subscriberLabelKeys...)
if labels[labelKeySubscriberName] == "" {
labels[labelKeySubscriberName] = s.subscriberName
}
if labels[labelKeyHandlerName] == "" {
labels[labelKeyHandlerName] = labelValueNoHandler
}
for _, lb := range s.additionalLabels {
labels[lb.Label] = lb.ComputeValueFn(ctx)
}
go func() {
if subscribeAlreadyObserved(ctx) {
// decorator idempotency when applied decorator multiple times
return
}
select {
case <-msg.Acked():
labels[labelAcked] = "acked"
case <-msg.Nacked():
labels[labelAcked] = "nacked"
}
s.subscriberMessagesReceivedTotal.With(labels).Inc()
}()
msg.SetContext(setSubscribeObservedToCtx(msg.Context()))
}

View File

@@ -0,0 +1,298 @@
package requestreply
import (
"context"
stdErrors "errors"
"fmt"
"time"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/pkg/errors"
)
// PubSubBackend is a Backend that uses Pub/Sub to transport commands and replies.
type PubSubBackend[Result any] struct {
config PubSubBackendConfig
marshaler BackendPubsubMarshaler[Result]
}
// NewPubSubBackend creates a new PubSubBackend.
//
// If you want to use backend together with `NewCommandHandler` (without result), you should pass `NoResult` or `struct{}` as Result type.
func NewPubSubBackend[Result any](
config PubSubBackendConfig,
marshaler BackendPubsubMarshaler[Result],
) (*PubSubBackend[Result], error) {
config.setDefaults()
if err := config.Validate(); err != nil {
return nil, errors.Wrap(err, "invalid config")
}
if marshaler == nil {
return nil, errors.New("marshaler cannot be nil")
}
return &PubSubBackend[Result]{
config: config,
marshaler: marshaler,
}, nil
}
type PubSubBackendSubscribeParams struct {
Command any
OperationID OperationID
}
type PubSubBackendSubscriberConstructorFn func(PubSubBackendSubscribeParams) (message.Subscriber, error)
type PubSubBackendGenerateSubscribeTopicFn func(PubSubBackendSubscribeParams) (string, error)
type PubSubBackendPublishParams struct {
Command any
CommandMessage *message.Message
OperationID OperationID
}
type PubSubBackendGeneratePublishTopicFn func(PubSubBackendPublishParams) (string, error)
type PubSubBackendOnCommandProcessedParams struct {
HandleErr error
PubSubBackendPublishParams
}
type PubSubBackendModifyNotificationMessageFn func(msg *message.Message, params PubSubBackendOnCommandProcessedParams) error
type PubSubBackendOnListenForReplyFinishedFn func(ctx context.Context, params PubSubBackendSubscribeParams)
type ReplyPublishErrorHandler func(replyTopic string, notificationMsg *message.Message, err error) error
type PubSubBackendConfig struct {
Publisher message.Publisher
SubscriberConstructor PubSubBackendSubscriberConstructorFn
GeneratePublishTopic PubSubBackendGeneratePublishTopicFn
GenerateSubscribeTopic PubSubBackendGenerateSubscribeTopicFn
Logger watermill.LoggerAdapter
ListenForReplyTimeout *time.Duration
ModifyNotificationMessage PubSubBackendModifyNotificationMessageFn
OnListenForReplyFinished PubSubBackendOnListenForReplyFinishedFn
// AckCommandErrors determines if the command should be acked or nacked when handler returns an error.
// Command will be nacked by default when sending reply fails, you can control this behaviour with the
// ReplyPublishErrorHandler config option.
// You should use this option instead of cqrs.CommandProcessorConfig.AckCommandHandlingErrors, as it's aware
// if error was returned by handler or sending reply failed.
AckCommandErrors bool
// ReplyPublishErrorHandler if not nil will be invoked when sending the reply fails. If it returns an error
// the command will be nacked.
ReplyPublishErrorHandler ReplyPublishErrorHandler
}
func (p *PubSubBackendConfig) setDefaults() {
if p.Logger == nil {
p.Logger = watermill.NopLogger{}
}
}
func (p *PubSubBackendConfig) Validate() error {
var err error
if p.Publisher == nil {
err = stdErrors.Join(err, errors.New("publisher cannot be nil"))
}
if p.SubscriberConstructor == nil {
err = stdErrors.Join(err, errors.New("subscriber constructor cannot be nil"))
}
if p.GeneratePublishTopic == nil {
err = stdErrors.Join(err, errors.New("GeneratePublishTopic cannot be nil"))
}
if p.GenerateSubscribeTopic == nil {
err = stdErrors.Join(err, errors.New("GenerateSubscribeTopic cannot be nil"))
}
return err
}
func (p PubSubBackend[Result]) ListenForNotifications(
ctx context.Context,
params BackendListenForNotificationsParams,
) (<-chan Reply[Result], error) {
start := time.Now()
replyContext := PubSubBackendSubscribeParams(params)
// this needs to be done before publishing the message to avoid race condition
notificationsSubscriber, err := p.config.SubscriberConstructor(replyContext)
if err != nil {
return nil, errors.Wrap(err, "cannot create request/reply notifications subscriber")
}
replyNotificationTopic, err := p.config.GenerateSubscribeTopic(replyContext)
if err != nil {
return nil, errors.Wrap(err, "cannot generate request/reply notifications topic")
}
var cancel context.CancelFunc
if p.config.ListenForReplyTimeout != nil {
ctx, cancel = context.WithTimeout(ctx, *p.config.ListenForReplyTimeout)
} else {
ctx, cancel = context.WithCancel(ctx)
}
notifyMsgs, err := notificationsSubscriber.Subscribe(ctx, replyNotificationTopic)
if err != nil {
cancel()
return nil, errors.Wrap(err, "cannot subscribe to request/reply notifications topic")
}
p.config.Logger.Debug(
"Subscribed to request/reply notifications topic",
watermill.LogFields{
"request_reply_topic": replyNotificationTopic,
},
)
replyChan := make(chan Reply[Result], 1)
go func() {
defer func() {
if p.config.OnListenForReplyFinished == nil {
return
}
p.config.OnListenForReplyFinished(ctx, replyContext)
}()
defer close(replyChan)
defer cancel()
for {
select {
case <-ctx.Done():
replyChan <- Reply[Result]{
Error: ReplyTimeoutError{time.Since(start), ctx.Err()},
}
return
case notifyMsg, ok := <-notifyMsgs:
if !ok {
// subscriber is closed
replyChan <- Reply[Result]{
Error: ReplyTimeoutError{time.Since(start), fmt.Errorf("subscriber closed")},
}
return
}
resp, ok, unmarshalErr := p.handleNotifyMsg(notifyMsg, string(params.OperationID), p.marshaler)
if unmarshalErr != nil {
replyChan <- Reply[Result]{
Error: ReplyUnmarshalError{unmarshalErr},
}
} else if ok {
replyChan <- Reply[Result]{
HandlerResult: resp.HandlerResult,
Error: resp.Error,
NotificationMessage: notifyMsg,
}
}
// we assume that more messages may arrive (in case of fan-out commands handling) - we don't exit yet
}
}
}()
return replyChan, nil
}
const OperationIDMetadataKey = "_watermill_requestreply_op_id"
func (p PubSubBackend[Result]) OnCommandProcessed(ctx context.Context, params BackendOnCommandProcessedParams[Result]) error {
p.config.Logger.Debug("Sending request reply", nil)
notificationMsg, err := p.marshaler.MarshalReply(params)
if err != nil {
return errors.Wrap(err, "cannot marshal request reply notification")
}
notificationMsg.SetContext(ctx)
operationID, err := operationIDFromMetadata(params.CommandMessage)
if err != nil {
return err
}
notificationMsg.Metadata.Set(OperationIDMetadataKey, string(operationID))
if p.config.ModifyNotificationMessage != nil {
processedContext := PubSubBackendOnCommandProcessedParams{
HandleErr: params.HandleErr,
PubSubBackendPublishParams: PubSubBackendPublishParams{
Command: params.Command,
CommandMessage: params.CommandMessage,
OperationID: operationID,
},
}
if err := p.config.ModifyNotificationMessage(notificationMsg, processedContext); err != nil {
return errors.Wrap(err, "cannot modify notification message")
}
}
replyTopic, err := p.config.GeneratePublishTopic(PubSubBackendPublishParams{
Command: params.Command,
CommandMessage: params.CommandMessage,
OperationID: operationID,
})
if err != nil {
return errors.Wrap(err, "cannot generate request/reply notify topic")
}
err = p.config.Publisher.Publish(replyTopic, notificationMsg)
if err != nil {
if p.config.ReplyPublishErrorHandler != nil {
err = p.config.ReplyPublishErrorHandler(replyTopic, notificationMsg, err)
}
}
if err != nil {
return errors.Wrap(err, "cannot publish command executed message")
}
if p.config.AckCommandErrors {
// we are ignoring handler error - message will be acked
return nil
} else {
// if handler returned error, it will nack the message
// if params.HandleErr is nil, message will be acked
return params.HandleErr
}
}
func operationIDFromMetadata(msg *message.Message) (OperationID, error) {
operationID := msg.Metadata.Get(OperationIDMetadataKey)
if operationID == "" {
return "", errors.Errorf("cannot get notification ID from command message metadata, key: %s", OperationIDMetadataKey)
}
return OperationID(operationID), nil
}
func (p PubSubBackend[Result]) handleNotifyMsg(
msg *message.Message,
expectedCommandUuid string,
marshaler BackendPubsubMarshaler[Result],
) (Reply[Result], bool, error) {
defer msg.Ack()
if msg.Metadata.Get(OperationIDMetadataKey) != expectedCommandUuid {
p.config.Logger.Debug("Received notify message with different command UUID", nil)
return Reply[Result]{}, false, nil
}
res, unmarshalErr := marshaler.UnmarshalReply(msg)
return res, true, unmarshalErr
}

View File

@@ -0,0 +1,58 @@
package requestreply
import (
"encoding/json"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/pkg/errors"
)
type BackendPubsubMarshaler[Result any] interface {
MarshalReply(params BackendOnCommandProcessedParams[Result]) (*message.Message, error)
UnmarshalReply(msg *message.Message) (reply Reply[Result], err error)
}
const (
ErrorMetadataKey = "_watermill_requestreply_error"
HasErrorMetadataKey = "_watermill_requestreply_has_error"
)
type BackendPubsubJSONMarshaler[Result any] struct{}
func (m BackendPubsubJSONMarshaler[Result]) MarshalReply(
params BackendOnCommandProcessedParams[Result],
) (*message.Message, error) {
msg := message.NewMessage(watermill.NewUUID(), nil)
if params.HandleErr != nil {
msg.Metadata.Set(ErrorMetadataKey, params.HandleErr.Error())
msg.Metadata.Set(HasErrorMetadataKey, "1")
} else {
msg.Metadata.Set(HasErrorMetadataKey, "0")
}
b, err := json.Marshal(params.HandlerResult)
if err != nil {
return nil, errors.Wrap(err, "cannot marshal reply")
}
msg.Payload = b
return msg, nil
}
func (m BackendPubsubJSONMarshaler[Result]) UnmarshalReply(msg *message.Message) (Reply[Result], error) {
reply := Reply[Result]{}
if msg.Metadata.Get(HasErrorMetadataKey) == "1" {
reply.Error = errors.New(msg.Metadata.Get(ErrorMetadataKey))
}
var result Result
if err := json.Unmarshal(msg.Payload, &result); err != nil {
return Reply[Result]{}, errors.Wrap(err, "cannot unmarshal result")
}
reply.HandlerResult = result
return reply, nil
}

View File

@@ -0,0 +1,128 @@
package requestreply
import (
"context"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/pkg/errors"
)
type CommandBus interface {
SendWithModifiedMessage(ctx context.Context, cmd any, modify func(*message.Message) error) error
}
// SendWithReply sends command to the command bus and receives a replies of the command handler.
// It returns a channel with replies, cancel function and error.
// If more than one replies are sent, only the first which is received is returned.
//
// If you expect multiple replies, please use SendWithReplies instead.
//
// SendWithReply is blocking until the first reply is received or the context is canceled.
// SendWithReply can be cancelled by cancelling context or
// by exceeding the timeout set in the backend (if set).
//
// SendWithReply can listen for handlers with results (NewCommandHandlerWithResult) and without results (NewCommandHandler).
// If you are listening for handlers without results, you should pass `NoResult` or `struct{}` as `Result` generic type:
//
// reply, err := requestreply.SendWithReply[requestreply.NoResult](
// context.Background(),
// ts.CommandBus,
// ts.RequestReplyBackend,
// &TestCommand{ID: "1"},
// )
//
// If `NewCommandHandlerWithResult` handler returns a specific type, you should pass it as `Result` generic type:
//
// reply, err := requestreply.SendWithReply[SomeTypeReturnedByHandler](
// context.Background(),
// ts.CommandBus,
// ts.RequestReplyBackend,
// &TestCommand{ID: "1"},
// )
func SendWithReply[Result any](
ctx context.Context,
c CommandBus,
backend Backend[Result],
cmd any,
) (Reply[Result], error) {
replyCh, cancel, err := SendWithReplies[Result](ctx, c, backend, cmd)
if err != nil {
return Reply[Result]{}, errors.Wrap(err, "SendWithReplies failed")
}
defer cancel()
select {
case <-ctx.Done():
return Reply[Result]{}, errors.Wrap(ctx.Err(), "context closed")
case reply := <-replyCh:
return reply, nil
}
}
// SendWithReplies sends command to the command bus and receives a replies of the command handler.
// It returns a channel with replies, cancel function and error.
//
// SendWithReplies can be cancelled by calling cancel function or by cancelling context or
// When SendWithReplies is canceled, the returned channel is closed as well.
// by exceeding the timeout set in the backend (if set).
// Warning: It's important to cancel the function, because it's listening for the replies in the background.
// Lack of cancelling the function can lead to subscriber leak.
//
// SendWithReplies can listen for handlers with results (NewCommandHandlerWithResult) and without results (NewCommandHandler).
// If you are listening for handlers without results, you should pass `NoResult` or `struct{}` as `Result` generic type:
//
// replyCh, cancel, err := requestreply.SendWithReplies[requestreply.NoResult](
// context.Background(),
// ts.CommandBus,
// ts.RequestReplyBackend,
// &TestCommand{ID: "1"},
// )
//
// If `NewCommandHandlerWithResult` handler returns a specific type, you should pass it as `Result` generic type:
//
// replyCh, cancel, err := requestreply.SendWithReplies[SomeTypeReturnedByHandler](
// context.Background(),
// ts.CommandBus,
// ts.RequestReplyBackend,
// &TestCommand{ID: "1"},
// )
//
// SendWithReplies will send the replies to the channel until the context is cancelled or the timeout is exceeded.
// They are multiple cases when more than one reply can be sent:
// - when the handler returns an error, and backend is configured to nack the message on error
// (for the PubSubBackend, it depends on `PubSubBackendConfig.AckCommandErrors` option.),
// - when you are using fan-out mechanism and commands are handled multiple times,
func SendWithReplies[Result any](
ctx context.Context,
c CommandBus,
backend Backend[Result],
cmd any,
) (replCh <-chan Reply[Result], cancel func(), err error) {
ctx, cancel = context.WithCancel(ctx)
defer func() {
if err != nil {
cancel()
}
}()
operationID := watermill.NewUUID()
replyChan, err := backend.ListenForNotifications(ctx, BackendListenForNotificationsParams{
Command: cmd,
OperationID: OperationID(operationID),
})
if err != nil {
return nil, cancel, errors.Wrap(err, "cannot listen for reply")
}
if err := c.SendWithModifiedMessage(ctx, cmd, func(m *message.Message) error {
m.Metadata.Set(OperationIDMetadataKey, operationID)
return nil
}); err != nil {
return nil, cancel, errors.Wrap(err, "cannot send command")
}
return replyChan, cancel, nil
}

View File

@@ -0,0 +1,81 @@
package requestreply
import (
"context"
"github.com/ThreeDotsLabs/watermill/components/cqrs"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/pkg/errors"
)
// NewCommandHandler creates a new CommandHandler which supports the request-reply pattern.
// The result handler is handler compatible with cqrs.CommandHandler.
//
// The logic if a command should be acked or not is based on the logic of the Backend.
// For example, for the PubSubBackend, it depends on the `PubSubBackendConfig.AckCommandErrors` option.
func NewCommandHandler[Command any](
handlerName string,
backend Backend[struct{}],
handleFunc func(ctx context.Context, cmd *Command) error,
) cqrs.CommandHandler {
return cqrs.NewCommandHandler(handlerName, func(ctx context.Context, cmd *Command) error {
handlerErr := handleFunc(ctx, cmd)
originalMessage, err := originalCommandMsgFromCtx(ctx)
if err != nil {
return err
}
return backend.OnCommandProcessed(ctx, BackendOnCommandProcessedParams[struct{}]{
Command: cmd,
CommandMessage: originalMessage,
HandleErr: handlerErr,
})
})
}
// NewCommandHandlerWithResult creates a new CommandHandler which supports the request-reply pattern with a result.
// The result handler is handler compatible with cqrs.CommandHandler.
//
// In addition to cqrs.CommandHandler, it also allows returning a result from the handler.
// The result is passed to the Backend implementation and sent to the caller.
//
// The logic if a command should be acked or not is based on the logic of the Backend.
// For example, for the PubSubBackend, it depends on the `PubSubBackendConfig.AckCommandErrors` option.
//
// The reply is sent to the caller, even if the handler returns an error.
func NewCommandHandlerWithResult[Command any, Result any](
handlerName string,
backend Backend[Result],
handleFunc func(ctx context.Context, cmd *Command) (Result, error),
) cqrs.CommandHandler {
return cqrs.NewCommandHandler(handlerName, func(ctx context.Context, cmd *Command) error {
resp, handlerErr := handleFunc(ctx, cmd)
originalMessage, err := originalCommandMsgFromCtx(ctx)
if err != nil {
return err
}
return backend.OnCommandProcessed(ctx, BackendOnCommandProcessedParams[Result]{
Command: cmd,
CommandMessage: originalMessage,
HandlerResult: resp,
HandleErr: handlerErr,
})
})
}
func originalCommandMsgFromCtx(ctx context.Context) (*message.Message, error) {
originalMessage := cqrs.OriginalMessageFromCtx(ctx)
if originalMessage == nil {
// This should not happen, as long as cqrs.CommandProcessor is used - but it's not mandatory.
// In this case, it's enough to use cqrs.CtxWithOriginalMessage
return nil, errors.New(
"original message not found in context, did you pass context correctly everywhere? " +
"did you use cqrs.CommandProcessor? " +
"if you are using custom implementation, please call cqrs.CtxWithOriginalMessage on the context passed to the handler",
)
}
return originalMessage, nil
}

View File

@@ -0,0 +1,91 @@
package requestreply
import (
"context"
"fmt"
"time"
"github.com/ThreeDotsLabs/watermill/message"
)
// NoResult is a result type for commands that don't have result.
type NoResult = struct{}
type Reply[Result any] struct {
// HandlerResult contains the handler result.
// It's preset only when NewCommandHandlerWithResult is used. If NewCommandHandler is used, HandlerResult is empty.
//
// Result is sent even if the handler returns an error.
HandlerResult Result
// Error contains the error returned by the command handler or the Backend when handling notification fails.
// Handling the notification can fail, for example, when unmarshaling the message or if there's a timeout.
// If listening for a reply times out or the context is canceled, the Error is ReplyTimeoutError.
//
// If an error from the handler is returned, CommandHandlerError is returned.
// If processing was successful, Error is nil.
Error error
// NotificationMessage contains the notification message sent after the command is handled.
// It's present only if the request/reply backend uses a Pub/Sub for notifications (for example, PubSubBackend).
//
// Warning: NotificationMessage is nil if a timeout occurs.
NotificationMessage *message.Message
}
type Backend[Result any] interface {
ListenForNotifications(ctx context.Context, params BackendListenForNotificationsParams) (<-chan Reply[Result], error)
OnCommandProcessed(ctx context.Context, params BackendOnCommandProcessedParams[Result]) error
}
type BackendListenForNotificationsParams struct {
Command any
OperationID OperationID
}
type BackendOnCommandProcessedParams[Result any] struct {
Command any
CommandMessage *message.Message
HandlerResult Result
HandleErr error
}
// OperationID is a unique identifier of a command.
// It correlates commands with replies between the bus and the handler.
type OperationID string
// ReplyTimeoutError is returned when the reply timeout is exceeded.
type ReplyTimeoutError struct {
Duration time.Duration
Err error
}
func (e ReplyTimeoutError) Error() string {
return fmt.Sprintf("reply timeout after %s: %s", e.Duration, e.Err)
}
type ReplyUnmarshalError struct {
Err error
}
func (r ReplyUnmarshalError) Error() string {
return fmt.Sprintf("cannot unmarshal reply: %s", r.Err)
}
func (r ReplyUnmarshalError) Unwrap() error {
return r.Err
}
// CommandHandlerError is returned when the command handler returns an error.
type CommandHandlerError struct {
Err error
}
func (e CommandHandlerError) Error() string {
return e.Err.Error()
}
func (e CommandHandlerError) Unwrap() error {
return e.Err
}

View File

@@ -0,0 +1,772 @@
package requestreply_test
import (
"context"
"errors"
"fmt"
"sync"
"testing"
"time"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/components/cqrs"
"github.com/ThreeDotsLabs/watermill/components/requestreply"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type TestServices[Result any] struct {
Logger watermill.LoggerAdapter
Marshaler cqrs.CommandEventMarshaler
PubSub *gochannel.GoChannel
Router *message.Router
CommandBus *cqrs.CommandBus
CommandProcessor *cqrs.CommandProcessor
RequestReplyBackend *requestreply.PubSubBackend[Result]
BackendConfig requestreply.PubSubBackendConfig
}
type TestServicesConfig struct {
DoNotAckOnCommandErrors bool
ListenForReplyTimeout *time.Duration
AssertNotificationMessage func(t *testing.T, msg *message.Message)
DoNotBlockPublishUntilSubscriberAck bool
}
func NewTestServices[Result any](t *testing.T, c TestServicesConfig) TestServices[Result] {
t.Helper()
logger := watermill.NewStdLogger(true, true)
marshaler := cqrs.JSONMarshaler{}
pubSub := gochannel.NewGoChannel(
gochannel.Config{BlockPublishUntilSubscriberAck: false},
logger,
)
backendConfig := requestreply.PubSubBackendConfig{
Publisher: pubSub,
SubscriberConstructor: func(subscriberContext requestreply.PubSubBackendSubscribeParams) (message.Subscriber, error) {
assert.NotEmpty(t, subscriberContext.OperationID)
assert.NotEmpty(t, subscriberContext.Command)
return pubSub, nil
},
GenerateSubscribeTopic: func(subscriberContext requestreply.PubSubBackendSubscribeParams) (string, error) {
assert.NotEmpty(t, subscriberContext.OperationID)
assert.NotEmpty(t, subscriberContext.Command)
return "reply", nil
},
GeneratePublishTopic: func(subscriberContext requestreply.PubSubBackendPublishParams) (string, error) {
assert.NotEmpty(t, subscriberContext.OperationID)
assert.NotEmpty(t, subscriberContext.Command)
assert.NotEmpty(t, subscriberContext.CommandMessage)
return "reply", nil
},
Logger: logger,
ModifyNotificationMessage: func(msg *message.Message, params requestreply.PubSubBackendOnCommandProcessedParams) error {
// to make it deterministic
msg.UUID = "1"
assert.NotEmpty(t, params.OperationID)
assert.NotEmpty(t, params.Command)
assert.NotEmpty(t, params.CommandMessage)
// to ensure backward compatibility
if c.AssertNotificationMessage != nil {
c.AssertNotificationMessage(t, msg)
}
return nil
},
AckCommandErrors: !c.DoNotAckOnCommandErrors,
ListenForReplyTimeout: c.ListenForReplyTimeout,
}
backend, err := requestreply.NewPubSubBackend[Result](
backendConfig,
requestreply.BackendPubsubJSONMarshaler[Result]{},
)
require.NoError(t, err)
router, err := message.NewRouter(message.RouterConfig{}, logger)
require.NoError(t, err)
commandBus, err := cqrs.NewCommandBusWithConfig(pubSub, cqrs.CommandBusConfig{
GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) {
return "commands", nil
},
Marshaler: marshaler,
Logger: logger,
})
require.NoError(t, err)
commandProcessor, err := cqrs.NewCommandProcessorWithConfig(router, cqrs.CommandProcessorConfig{
GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) {
return "commands", nil
},
SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) {
return pubSub, nil
},
Marshaler: marshaler,
Logger: logger,
})
require.NoError(t, err)
return TestServices[Result]{
Logger: logger,
PubSub: gochannel.NewGoChannel(
gochannel.Config{BlockPublishUntilSubscriberAck: !c.DoNotBlockPublishUntilSubscriberAck},
logger,
),
Router: router,
RequestReplyBackend: backend,
CommandBus: commandBus,
CommandProcessor: commandProcessor,
Marshaler: marshaler,
BackendConfig: backendConfig,
}
}
func (ts TestServices[Result]) RunRouter() {
go func() {
err := ts.Router.Run(context.Background())
if err != nil {
panic(err)
}
}()
<-ts.Router.Running()
}
type TestCommand struct {
ID string `json:"id"`
}
type TestCommand2 struct {
ID string `json:"id"`
}
type TestCommandResult struct {
ID string `json:"id"`
}
func TestRequestReply_without_result_no_error(t *testing.T) {
ts := NewTestServices[requestreply.NoResult](t, TestServicesConfig{
AssertNotificationMessage: func(t *testing.T, msg *message.Message) {
assert.NotEmpty(t, msg.Metadata.Get(requestreply.HasErrorMetadataKey))
},
})
err := ts.CommandProcessor.AddHandlers(
requestreply.NewCommandHandler(
"test_handler",
ts.RequestReplyBackend,
func(ctx context.Context, cmd *TestCommand) error {
return nil
},
),
)
require.NoError(t, err)
ts.RunRouter()
replyCh, cancel, err := requestreply.SendWithReplies[requestreply.NoResult](
context.Background(),
ts.CommandBus,
ts.RequestReplyBackend,
&TestCommand{ID: "1"},
)
require.NoError(t, err)
require.NotNil(t, replyCh)
defer cancel()
select {
case reply := <-replyCh:
assert.Empty(t, reply.HandlerResult)
assert.NoError(t, reply.Error)
assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey))
case <-time.After(time.Millisecond * 100):
t.Fatal("timeout")
}
}
func TestRequestReply_without_result_with_error(t *testing.T) {
ts := NewTestServices[requestreply.NoResult](t, TestServicesConfig{
AssertNotificationMessage: func(t *testing.T, msg *message.Message) {
assert.NotEmpty(t, msg.Metadata.Get(requestreply.HasErrorMetadataKey))
assert.NotEmpty(t, msg.Metadata.Get(requestreply.ErrorMetadataKey))
},
})
expectedErr := errors.New("some error")
err := ts.CommandProcessor.AddHandlers(
requestreply.NewCommandHandler(
"test_handler",
ts.RequestReplyBackend,
func(ctx context.Context, cmd *TestCommand) error {
return expectedErr
},
),
)
require.NoError(t, err)
ts.RunRouter()
replyCh, cancel, err := requestreply.SendWithReplies[requestreply.NoResult](
context.Background(),
ts.CommandBus,
ts.RequestReplyBackend,
&TestCommand{ID: "1"},
)
require.NoError(t, err)
require.NotNil(t, replyCh)
defer cancel()
select {
case reply := <-replyCh:
assert.Empty(t, reply.HandlerResult)
require.Error(t, reply.Error)
assert.Equal(t, expectedErr.Error(), reply.Error.Error())
assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey))
case <-time.After(time.Millisecond * 100):
t.Fatal("timeout")
}
}
func TestRequestReply_with_result_no_error(t *testing.T) {
ts := NewTestServices[TestCommandResult](t, TestServicesConfig{
AssertNotificationMessage: func(t *testing.T, msg *message.Message) {
assert.NotEmpty(t, msg.Metadata.Get(requestreply.HasErrorMetadataKey))
},
})
expectedResult := TestCommandResult{ID: "123"}
err := ts.CommandProcessor.AddHandlers(
requestreply.NewCommandHandlerWithResult[TestCommand, TestCommandResult](
"test_handler",
ts.RequestReplyBackend,
func(ctx context.Context, cmd *TestCommand) (TestCommandResult, error) {
return expectedResult, nil
},
),
)
require.NoError(t, err)
ts.RunRouter()
replyCh, cancel, err := requestreply.SendWithReplies[TestCommandResult](
context.Background(),
ts.CommandBus,
ts.RequestReplyBackend,
&TestCommand{ID: "1"},
)
require.NoError(t, err)
require.NotNil(t, replyCh)
defer cancel()
select {
case reply := <-replyCh:
assert.EqualValues(t, expectedResult, reply.HandlerResult)
assert.NoError(t, reply.Error)
assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey))
case <-time.After(time.Millisecond * 100):
t.Fatal("timeout")
}
}
func TestRequestReply_with_result_with_error(t *testing.T) {
ts := NewTestServices[TestCommandResult](t, TestServicesConfig{})
expectedResult := TestCommandResult{ID: "123"}
expectedErr := errors.New("some error")
err := ts.CommandProcessor.AddHandlers(
requestreply.NewCommandHandlerWithResult[TestCommand, TestCommandResult](
"test_handler",
ts.RequestReplyBackend,
func(ctx context.Context, cmd *TestCommand) (TestCommandResult, error) {
return expectedResult, expectedErr
},
),
)
require.NoError(t, err)
ts.RunRouter()
replyCh, cancel, err := requestreply.SendWithReplies[TestCommandResult](
context.Background(),
ts.CommandBus,
ts.RequestReplyBackend,
&TestCommand{ID: "1"},
)
require.NoError(t, err)
require.NotNil(t, replyCh)
defer cancel()
select {
case reply := <-replyCh:
assert.EqualValues(t, TestCommandResult{ID: "123"}, reply.HandlerResult)
require.Error(t, reply.Error)
assert.Equal(t, expectedErr.Error(), reply.Error.Error())
assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey))
case <-time.After(time.Millisecond * 100):
t.Fatal("timeout")
}
}
func TestSendWithReply(t *testing.T) {
ts := NewTestServices[TestCommandResult](t, TestServicesConfig{})
expectedResult := TestCommandResult{ID: "123"}
expectedErr := errors.New("some error")
err := ts.CommandProcessor.AddHandlers(
requestreply.NewCommandHandlerWithResult[TestCommand, TestCommandResult](
"test_handler",
ts.RequestReplyBackend,
func(ctx context.Context, cmd *TestCommand) (TestCommandResult, error) {
return expectedResult, expectedErr
},
),
)
require.NoError(t, err)
ts.RunRouter()
reply, err := requestreply.SendWithReply[TestCommandResult](
context.Background(),
ts.CommandBus,
ts.RequestReplyBackend,
&TestCommand{ID: "1"},
)
require.NoError(t, err)
assert.EqualValues(t, TestCommandResult{ID: "123"}, reply.HandlerResult)
require.Error(t, reply.Error)
assert.Equal(t, expectedErr.Error(), reply.Error.Error())
assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey))
}
func TestRequestReply_without_result_multiple_replies(t *testing.T) {
ts := NewTestServices[TestCommandResult](t, TestServicesConfig{
DoNotAckOnCommandErrors: true,
})
type toSend struct {
ID string
Err error
}
toSendCh := make(chan toSend, 1)
toSendCh <- toSend{ID: "1", Err: fmt.Errorf("error 1")}
err := ts.CommandProcessor.AddHandlers(
requestreply.NewCommandHandlerWithResult[TestCommand, TestCommandResult](
"test_handler",
ts.RequestReplyBackend,
func(ctx context.Context, cmd *TestCommand) (TestCommandResult, error) {
toSend := <-toSendCh
return TestCommandResult{ID: toSend.ID}, toSend.Err
},
),
)
require.NoError(t, err)
ts.RunRouter()
replyCh, cancel, err := requestreply.SendWithReplies[TestCommandResult](
context.Background(),
ts.CommandBus,
ts.RequestReplyBackend,
&TestCommand{ID: "1"},
)
require.NoError(t, err)
require.NotNil(t, replyCh)
defer cancel()
select {
case reply := <-replyCh:
assert.EqualValues(t, TestCommandResult{ID: "1"}, reply.HandlerResult)
require.Error(t, reply.Error)
assert.Equal(t, "error 1", reply.Error.Error())
assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey))
case <-time.After(time.Millisecond * 100):
t.Fatal("timeout")
}
toSendCh <- toSend{ID: "2", Err: fmt.Errorf("error 2")}
select {
case reply := <-replyCh:
assert.EqualValues(t, TestCommandResult{ID: "2"}, reply.HandlerResult)
require.Error(t, reply.Error)
assert.Equal(t, "error 2", reply.Error.Error())
assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey))
case <-time.After(time.Millisecond * 100):
t.Fatal("timeout")
}
toSendCh <- toSend{ID: "3", Err: nil}
select {
case reply := <-replyCh:
assert.EqualValues(t, TestCommandResult{ID: "3"}, reply.HandlerResult)
require.NoError(t, reply.Error)
assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey))
case <-time.After(time.Millisecond * 100):
t.Fatal("timeout")
}
}
func TestRequestReply_timeout(t *testing.T) {
timeout := time.Millisecond * 10
ts := NewTestServices[requestreply.NoResult](t, TestServicesConfig{
ListenForReplyTimeout: &timeout,
})
err := ts.CommandProcessor.AddHandlers(
requestreply.NewCommandHandler[TestCommand](
"test_handler",
ts.RequestReplyBackend,
func(ctx context.Context, cmd *TestCommand) error {
time.Sleep(time.Second)
return nil
},
),
)
require.NoError(t, err)
ts.RunRouter()
replyCh, cancel, err := requestreply.SendWithReplies[requestreply.NoResult](
context.Background(),
ts.CommandBus,
ts.RequestReplyBackend,
&TestCommand{ID: "1"},
)
require.NoError(t, err)
require.NotNil(t, replyCh)
defer cancel()
select {
case reply := <-replyCh:
assert.Empty(t, reply.HandlerResult)
require.Error(t, reply.Error)
require.IsType(t, requestreply.ReplyTimeoutError{}, reply.Error)
replyTimeoutError := reply.Error.(requestreply.ReplyTimeoutError)
assert.Equal(t, context.DeadlineExceeded, replyTimeoutError.Err)
assert.NotEmpty(t, replyTimeoutError.Duration)
case <-time.After(time.Millisecond * 100):
t.Fatal("timeout")
}
}
func TestRequestReply_context_cancellation(t *testing.T) {
ts := NewTestServices[struct{}](t, TestServicesConfig{})
err := ts.CommandProcessor.AddHandlers(
requestreply.NewCommandHandler[TestCommand](
"test_handler",
ts.RequestReplyBackend,
func(ctx context.Context, cmd *TestCommand) error {
time.Sleep(time.Second)
return nil
},
),
)
require.NoError(t, err)
ts.RunRouter()
ctx, cancel := context.WithCancel(context.Background())
replyCh, _, err := requestreply.SendWithReplies[struct{}](
ctx,
ts.CommandBus,
ts.RequestReplyBackend,
&TestCommand{ID: "1"},
)
require.NoError(t, err)
require.NotNil(t, replyCh)
cancel()
select {
case reply := <-replyCh:
assert.Empty(t, reply.HandlerResult)
require.Error(t, reply.Error)
require.IsType(t, requestreply.ReplyTimeoutError{}, reply.Error)
replyTimeoutError := reply.Error.(requestreply.ReplyTimeoutError)
assert.Contains(
t,
// it depends on which switch will be executed first
[]string{"subscriber closed", context.Canceled.Error()},
replyTimeoutError.Err.Error(),
)
assert.NotEmpty(t, replyTimeoutError.Duration)
case <-time.After(time.Millisecond * 100):
t.Fatal("timeout")
}
}
func TestRequestReply_fn_cancellation(t *testing.T) {
ts := NewTestServices[struct{}](t, TestServicesConfig{})
err := ts.CommandProcessor.AddHandlers(
requestreply.NewCommandHandler[TestCommand](
"test_handler",
ts.RequestReplyBackend,
func(ctx context.Context, cmd *TestCommand) error {
time.Sleep(time.Second)
return nil
},
),
)
require.NoError(t, err)
ts.RunRouter()
replyCh, cancel, err := requestreply.SendWithReplies[requestreply.NoResult](
context.Background(),
ts.CommandBus,
ts.RequestReplyBackend,
&TestCommand{ID: "1"},
)
require.NoError(t, err)
require.NotNil(t, replyCh)
cancel()
select {
case reply := <-replyCh:
assert.Empty(t, reply.HandlerResult)
require.Error(t, reply.Error)
require.IsType(t, requestreply.ReplyTimeoutError{}, reply.Error)
replyTimeoutError := reply.Error.(requestreply.ReplyTimeoutError)
assert.Contains(
t,
// it depends on which switch will be executed first
[]string{"subscriber closed", context.Canceled.Error()},
replyTimeoutError.Err.Error(),
)
assert.NotEmpty(t, replyTimeoutError.Duration)
case <-time.After(time.Millisecond * 100):
t.Fatal("timeout")
}
}
func TestRequestReply_parallel_different_handlers(t *testing.T) {
ts := NewTestServices[TestCommandResult](t, TestServicesConfig{
DoNotAckOnCommandErrors: true,
})
err := ts.CommandProcessor.AddHandlers(
requestreply.NewCommandHandlerWithResult[TestCommand, TestCommandResult](
"test_handler_1",
ts.RequestReplyBackend,
func(ctx context.Context, cmd *TestCommand) (TestCommandResult, error) {
return TestCommandResult{ID: cmd.ID}, fmt.Errorf("error 1 %s", cmd.ID)
},
),
)
require.NoError(t, err)
err = ts.CommandProcessor.AddHandlers(
requestreply.NewCommandHandlerWithResult[TestCommand2, TestCommandResult](
"test_handler_2",
ts.RequestReplyBackend,
func(ctx context.Context, cmd *TestCommand2) (TestCommandResult, error) {
return TestCommandResult{ID: cmd.ID}, fmt.Errorf("error 2 %s", cmd.ID)
},
),
)
require.NoError(t, err)
ts.RunRouter()
start := make(chan struct{})
wg := sync.WaitGroup{}
wg.Add(1)
go func() {
defer wg.Done()
<-start
cmd := TestCommand{ID: watermill.NewUUID()}
replyCh, cancel, err := requestreply.SendWithReplies[TestCommandResult](
context.Background(),
ts.CommandBus,
ts.RequestReplyBackend,
&cmd,
)
require.NoError(t, err)
require.NotNil(t, replyCh)
defer cancel()
i := 0
for reply := range replyCh {
assert.EqualValues(t, TestCommandResult(cmd), reply.HandlerResult)
require.Error(t, reply.Error)
assert.Equal(t, fmt.Sprintf("error 1 %s", cmd.ID), reply.Error.Error())
i++
if i > 100 {
return
}
}
}()
wg.Add(1)
go func() {
defer wg.Done()
<-start
cmd := TestCommand2{ID: watermill.NewUUID()}
replyCh, cancel, err := requestreply.SendWithReplies[TestCommandResult](
context.Background(),
ts.CommandBus,
ts.RequestReplyBackend,
&cmd,
)
require.NoError(t, err)
require.NotNil(t, replyCh)
defer cancel()
i := 0
for reply := range replyCh {
assert.EqualValues(t, TestCommandResult(cmd), reply.HandlerResult)
require.Error(t, reply.Error)
assert.Equal(t, fmt.Sprintf("error 2 %s", cmd.ID), reply.Error.Error())
i++
if i > 100 {
return
}
}
}()
// sync workers
close(start)
wg.Wait()
}
func TestRequestReply_parallel_same_handler(t *testing.T) {
ts := NewTestServices[TestCommandResult](t, TestServicesConfig{
DoNotBlockPublishUntilSubscriberAck: true,
})
err := ts.CommandProcessor.AddHandlers(
requestreply.NewCommandHandlerWithResult[TestCommand, TestCommandResult](
"test_handler",
ts.RequestReplyBackend,
func(ctx context.Context, cmd *TestCommand) (TestCommandResult, error) {
return TestCommandResult{ID: cmd.ID}, nil
},
),
)
require.NoError(t, err)
ts.RunRouter()
count := 20
wg := sync.WaitGroup{}
wg.Add(count)
start := make(chan struct{})
for i := 0; i < count; i++ {
go func() {
defer wg.Done()
<-start
cmd := TestCommand{ID: uuid.NewString()}
replyCh, cancel, err := requestreply.SendWithReplies[TestCommandResult](
context.Background(),
ts.CommandBus,
ts.RequestReplyBackend,
&cmd,
)
require.NoError(t, err)
require.NotNil(t, replyCh)
defer cancel()
select {
case reply := <-replyCh:
assert.EqualValues(t, TestCommandResult(cmd), reply.HandlerResult)
assert.NoError(t, reply.Error)
case <-time.After(time.Millisecond * 100):
t.Error("timeout")
}
}()
}
// sync workers
close(start)
wg.Wait()
}
func TestNewPubSubBackend_missing_values(t *testing.T) {
t.Run("invalid_config", func(t *testing.T) {
invalidConfig := requestreply.PubSubBackendConfig{}
require.Error(t, invalidConfig.Validate())
backend, err := requestreply.NewPubSubBackend[requestreply.NoResult](
invalidConfig,
requestreply.BackendPubsubJSONMarshaler[requestreply.NoResult]{},
)
assert.Error(t, err)
assert.ErrorContains(t, err, "invalid config")
assert.Nil(t, backend)
})
t.Run("missing_marshaler", func(t *testing.T) {
ts := NewTestServices[struct{}](t, TestServicesConfig{})
require.NoError(t, ts.BackendConfig.Validate())
backend, err := requestreply.NewPubSubBackend[requestreply.NoResult](
ts.BackendConfig,
nil,
)
assert.Error(t, err)
assert.ErrorContains(t, err, "marshaler cannot be nil")
assert.Nil(t, backend)
})
}

View File

@@ -0,0 +1,158 @@
package requeuer
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
)
const RetriesKey = "_watermill_requeuer_retries"
// Requeuer is a component that moves messages from one topic to another.
// It can be used to requeue messages that failed to process.
type Requeuer struct {
config Config
}
// GeneratePublishTopicParams are the parameters passed to the GeneratePublishTopic function.
type GeneratePublishTopicParams struct {
Message *message.Message
}
// Config is the configuration for the Requeuer.
type Config struct {
// Subscriber is the subscriber to consume messages from. Required.
Subscriber message.Subscriber
// SubscribeTopic is the topic related to the Subscriber to consume messages from. Required.
SubscribeTopic string
// Publisher is the publisher to publish requeued messages to. Required.
Publisher message.Publisher
// GeneratePublishTopic is the topic related to the Publisher to publish the requeued message to.
// For example, it could be a constant, or taken from the message's metadata.
// Required.
GeneratePublishTopic func(params GeneratePublishTopicParams) (string, error)
// Delay is the duration to wait before requeuing the message. Optional.
// The default is no delay.
//
// This can be useful to avoid requeuing messages too quickly, for example, to avoid
// requeuing a message that failed to process due to a temporary issue.
//
// Avoid setting this to a very high value, as it will block the message processing.
Delay time.Duration
// Router is the custom router to run the requeue handler on. Optional.
Router *message.Router
}
func (c *Config) setDefaults(logger watermill.LoggerAdapter) error {
if c.Router == nil {
router, err := message.NewRouter(message.RouterConfig{}, logger)
if err != nil {
return fmt.Errorf("could not create router: %w", err)
}
c.Router = router
}
return nil
}
func (c *Config) validate() error {
if c.Subscriber == nil {
return errors.New("subscriber is required")
}
if c.SubscribeTopic == "" {
return errors.New("subscribe topic is required")
}
if c.Publisher == nil {
return errors.New("publisher is required")
}
if c.GeneratePublishTopic == nil {
return errors.New("generate publish topic is required")
}
return nil
}
// NewRequeuer creates a new Requeuer with the provided Config.
// It's not started automatically. You need to call Run on the returned Requeuer.
func NewRequeuer(
config Config,
logger watermill.LoggerAdapter,
) (*Requeuer, error) {
if logger == nil {
logger = watermill.NewStdLogger(false, false)
}
err := config.setDefaults(logger)
if err != nil {
return nil, err
}
err = config.validate()
if err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
r := &Requeuer{
config: config,
}
config.Router.AddConsumerHandler(
"requeuer",
config.SubscribeTopic,
config.Subscriber,
r.handler,
)
return r, nil
}
func (r *Requeuer) handler(msg *message.Message) error {
if r.config.Delay > 0 {
select {
case <-msg.Context().Done():
return msg.Context().Err()
case <-time.After(r.config.Delay):
}
}
topic, err := r.config.GeneratePublishTopic(GeneratePublishTopicParams{Message: msg})
if err != nil {
return err
}
retriesStr := msg.Metadata.Get(RetriesKey)
retries, err := strconv.Atoi(retriesStr)
if err != nil {
retries = 0
}
retries++
msg.Metadata.Set(RetriesKey, strconv.Itoa(retries))
err = r.config.Publisher.Publish(topic, msg)
if err != nil {
return err
}
return nil
}
// Run runs the Requeuer.
func (r *Requeuer) Run(ctx context.Context) error {
return r.config.Router.Run(ctx)
}

View File

@@ -0,0 +1,105 @@
package requeuer_test
import (
"context"
"errors"
"fmt"
"strconv"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/components/requeuer"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/ThreeDotsLabs/watermill/message/router/middleware"
"github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
)
func TestRequeue(t *testing.T) {
logger := watermill.NewStdLogger(false, false)
pubSub := gochannel.NewGoChannel(gochannel.Config{}, logger)
requeue, err := requeuer.NewRequeuer(requeuer.Config{
Subscriber: pubSub,
SubscribeTopic: "requeue",
Publisher: pubSub,
GeneratePublishTopic: func(params requeuer.GeneratePublishTopicParams) (string, error) {
return "test", nil
},
Delay: time.Millisecond * 200,
}, logger)
require.NoError(t, err)
go func() {
err := requeue.Run(context.Background())
require.NoError(t, err)
}()
router, err := message.NewRouter(message.RouterConfig{}, logger)
require.NoError(t, err)
pq, err := middleware.PoisonQueue(pubSub, "requeue")
require.NoError(t, err)
router.AddMiddleware(pq)
receivedMessages := make(chan int, 10)
lock := sync.Mutex{}
counter := 0
router.AddConsumerHandler(
"test",
"test",
pubSub,
func(msg *message.Message) error {
i, err := strconv.Atoi(string(msg.Payload))
if err != nil {
return err
}
lock.Lock()
defer lock.Unlock()
counter++
if counter < 10 && i%2 == 0 {
return errors.New("error")
}
receivedMessages <- i
return nil
},
)
go func() {
err := router.Run(context.Background())
require.NoError(t, err)
}()
time.Sleep(time.Second)
for i := 0; i < 10; i++ {
msg := message.NewMessage(watermill.NewUUID(), fmt.Append(nil, i))
err := pubSub.Publish("test", msg)
require.NoError(t, err)
}
var received []int
timeout := false
for !timeout {
select {
case i := <-receivedMessages:
received = append(received, i)
case <-time.After(5 * time.Second):
timeout = true
}
}
require.ElementsMatch(t, []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, received)
}