snapshot of https://github.com/ThreeDotsLabs/watermill.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
master / ci (push) Failing after 0s
Some checks failed
master / ci (push) Failing after 0s
This commit is contained in:
298
components/requestreply/backend_pubsub.go
Normal file
298
components/requestreply/backend_pubsub.go
Normal 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
|
||||
}
|
||||
58
components/requestreply/backend_pubsub_marshaler.go
Normal file
58
components/requestreply/backend_pubsub_marshaler.go
Normal 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
|
||||
}
|
||||
128
components/requestreply/command_bus.go
Normal file
128
components/requestreply/command_bus.go
Normal 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
|
||||
}
|
||||
81
components/requestreply/handler.go
Normal file
81
components/requestreply/handler.go
Normal 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
|
||||
}
|
||||
91
components/requestreply/requestreply.go
Normal file
91
components/requestreply/requestreply.go
Normal 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
|
||||
}
|
||||
772
components/requestreply/requestreply_test.go
Normal file
772
components/requestreply/requestreply_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user