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,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)
})
}