Files
upstream-watermill/components/requestreply/backend_pubsub.go
2026-09-15 21:27:46 +05:00

299 lines
8.6 KiB
Go

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
}