github.com/wfusion/gofusion@v1.1.14/common/infra/watermill/components/requestreply/backend_pubsub.go (about)

     1  package requestreply
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"time"
     7  
     8  	"github.com/pkg/errors"
     9  	"go.uber.org/multierr"
    10  
    11  	"github.com/wfusion/gofusion/common/infra/watermill"
    12  	"github.com/wfusion/gofusion/common/infra/watermill/message"
    13  )
    14  
    15  // PubSubBackend is a Backend that uses Pub/Sub to transport commands and replies.
    16  type PubSubBackend[Result any] struct {
    17  	config    PubSubBackendConfig
    18  	marshaler BackendPubsubMarshaler[Result]
    19  }
    20  
    21  // NewPubSubBackend creates a new PubSubBackend.
    22  //
    23  // If you want to use backend together with `NewCommandHandler` (without result), you should pass `NoResult` or `struct{}` as Result type.
    24  func NewPubSubBackend[Result any](
    25  	config PubSubBackendConfig,
    26  	marshaler BackendPubsubMarshaler[Result],
    27  ) (*PubSubBackend[Result], error) {
    28  	config.setDefaults()
    29  
    30  	if err := config.Validate(); err != nil {
    31  		return nil, errors.Wrap(err, "invalid config")
    32  	}
    33  	if marshaler == nil {
    34  		return nil, errors.New("marshaler cannot be nil")
    35  	}
    36  
    37  	return &PubSubBackend[Result]{
    38  		config:    config,
    39  		marshaler: marshaler,
    40  	}, nil
    41  }
    42  
    43  type PubSubBackendSubscribeParams struct {
    44  	Command any
    45  
    46  	OperationID OperationID
    47  }
    48  
    49  type PubSubBackendSubscriberConstructorFn func(PubSubBackendSubscribeParams) (message.Subscriber, error)
    50  
    51  type PubSubBackendGenerateSubscribeTopicFn func(PubSubBackendSubscribeParams) (string, error)
    52  
    53  type PubSubBackendPublishParams struct {
    54  	Command any
    55  
    56  	CommandMessage *message.Message
    57  
    58  	OperationID OperationID
    59  }
    60  
    61  type PubSubBackendGeneratePublishTopicFn func(PubSubBackendPublishParams) (string, error)
    62  
    63  type PubSubBackendOnCommandProcessedParams struct {
    64  	HandleErr error
    65  
    66  	PubSubBackendPublishParams
    67  }
    68  
    69  type PubSubBackendModifyNotificationMessageFn func(msg *message.Message, params PubSubBackendOnCommandProcessedParams) error
    70  
    71  type PubSubBackendOnListenForReplyFinishedFn func(ctx context.Context, params PubSubBackendSubscribeParams)
    72  
    73  type PubSubBackendConfig struct {
    74  	Publisher             message.Publisher
    75  	SubscriberConstructor PubSubBackendSubscriberConstructorFn
    76  
    77  	GeneratePublishTopic   PubSubBackendGeneratePublishTopicFn
    78  	GenerateSubscribeTopic PubSubBackendGenerateSubscribeTopicFn
    79  
    80  	Logger watermill.LoggerAdapter
    81  
    82  	ListenForReplyTimeout *time.Duration
    83  
    84  	ModifyNotificationMessage PubSubBackendModifyNotificationMessageFn
    85  
    86  	OnListenForReplyFinished PubSubBackendOnListenForReplyFinishedFn
    87  
    88  	// AckCommandErrors determines if the command should be acked or nacked when handler returns an error.
    89  	// Command will be always nacked, when sending reply fails.
    90  	// You should use this option instead of cqrs.CommandProcessorConfig.AckCommandHandlingErrors, as it's aware
    91  	// if error was returned by handler or sending reply failed.
    92  	AckCommandErrors bool
    93  }
    94  
    95  func (p *PubSubBackendConfig) setDefaults() {
    96  	if p.Logger == nil {
    97  		p.Logger = watermill.NopLogger{}
    98  	}
    99  }
   100  
   101  func (p *PubSubBackendConfig) Validate() error {
   102  	var err error
   103  
   104  	if p.Publisher == nil {
   105  		err = multierr.Append(err, errors.New("publisher cannot be nil"))
   106  	}
   107  	if p.SubscriberConstructor == nil {
   108  		err = multierr.Append(err, errors.New("subscriber constructor cannot be nil"))
   109  	}
   110  	if p.GeneratePublishTopic == nil {
   111  		err = multierr.Append(err, errors.New("GeneratePublishTopic cannot be nil"))
   112  	}
   113  	if p.GenerateSubscribeTopic == nil {
   114  		err = multierr.Append(err, errors.New("GenerateSubscribeTopic cannot be nil"))
   115  	}
   116  
   117  	return err
   118  }
   119  
   120  func (p PubSubBackend[Result]) ListenForNotifications(
   121  	ctx context.Context,
   122  	params BackendListenForNotificationsParams,
   123  ) (<-chan Reply[Result], error) {
   124  	start := time.Now()
   125  
   126  	replyContext := PubSubBackendSubscribeParams(params)
   127  
   128  	// this needs to be done before publishing the message to avoid race condition
   129  	notificationsSubscriber, err := p.config.SubscriberConstructor(replyContext)
   130  	if err != nil {
   131  		return nil, errors.Wrap(err, "cannot create request/reply notifications subscriber")
   132  	}
   133  
   134  	replyNotificationTopic, err := p.config.GenerateSubscribeTopic(replyContext)
   135  	if err != nil {
   136  		return nil, errors.Wrap(err, "cannot generate request/reply notifications topic")
   137  	}
   138  
   139  	var cancel context.CancelFunc
   140  	if p.config.ListenForReplyTimeout != nil {
   141  		ctx, cancel = context.WithTimeout(ctx, *p.config.ListenForReplyTimeout)
   142  	} else {
   143  		ctx, cancel = context.WithCancel(ctx)
   144  	}
   145  
   146  	notifyMsgs, err := notificationsSubscriber.Subscribe(ctx, replyNotificationTopic)
   147  	if err != nil {
   148  		cancel()
   149  		return nil, errors.Wrap(err, "cannot subscribe to request/reply notifications topic")
   150  	}
   151  
   152  	p.config.Logger.Debug(
   153  		"Subscribed to request/reply notifications topic",
   154  		watermill.LogFields{
   155  			"request_reply_topic": replyNotificationTopic,
   156  		},
   157  	)
   158  
   159  	replyChan := make(chan Reply[Result], 1)
   160  
   161  	go func() {
   162  		defer func() {
   163  			if p.config.OnListenForReplyFinished == nil {
   164  				return
   165  			}
   166  
   167  			p.config.OnListenForReplyFinished(ctx, replyContext)
   168  		}()
   169  		defer close(replyChan)
   170  		defer cancel()
   171  
   172  		for {
   173  			select {
   174  			case <-ctx.Done():
   175  				replyChan <- Reply[Result]{
   176  					Error: ReplyTimeoutError{time.Since(start), ctx.Err()},
   177  				}
   178  				return
   179  			case notifyMsg, ok := <-notifyMsgs:
   180  				if !ok {
   181  					// subscriber is closed
   182  					replyChan <- Reply[Result]{
   183  						Error: ReplyTimeoutError{time.Since(start), fmt.Errorf("subscriber closed")},
   184  					}
   185  					return
   186  				}
   187  
   188  				resp, ok, unmarshalErr := p.handleNotifyMsg(notifyMsg, string(params.OperationID), p.marshaler)
   189  				if unmarshalErr != nil {
   190  					replyChan <- Reply[Result]{
   191  						Error: ReplyUnmarshalError{unmarshalErr},
   192  					}
   193  				} else if ok {
   194  					replyChan <- Reply[Result]{
   195  						HandlerResult:       resp.HandlerResult,
   196  						Error:               resp.Error,
   197  						NotificationMessage: notifyMsg,
   198  					}
   199  				}
   200  
   201  				// we assume that more messages may arrive (in case of fan-out commands handling) - we don't exit yet
   202  			}
   203  		}
   204  	}()
   205  
   206  	return replyChan, nil
   207  }
   208  
   209  const OperationIDMetadataKey = "_watermill_requestreply_op_id"
   210  
   211  func (p PubSubBackend[Result]) OnCommandProcessed(ctx context.Context,
   212  	params BackendOnCommandProcessedParams[Result]) error {
   213  	p.config.Logger.Debug("Sending request reply", nil)
   214  
   215  	notificationMsg, err := p.marshaler.MarshalReply(params)
   216  	if err != nil {
   217  		return errors.Wrap(err, "cannot marshal request reply notification")
   218  	}
   219  	notificationMsg.SetContext(ctx)
   220  
   221  	operationID, err := operationIDFromMetadata(params.CommandMessage)
   222  	if err != nil {
   223  		return err
   224  	}
   225  	notificationMsg.Metadata.Set(OperationIDMetadataKey, string(operationID))
   226  
   227  	if p.config.ModifyNotificationMessage != nil {
   228  		processedContext := PubSubBackendOnCommandProcessedParams{
   229  			HandleErr: params.HandleErr,
   230  			PubSubBackendPublishParams: PubSubBackendPublishParams{
   231  				Command:        params.Command,
   232  				CommandMessage: params.CommandMessage,
   233  				OperationID:    operationID,
   234  			},
   235  		}
   236  		if err := p.config.ModifyNotificationMessage(notificationMsg, processedContext); err != nil {
   237  			return errors.Wrap(err, "cannot modify notification message")
   238  		}
   239  	}
   240  
   241  	replyTopic, err := p.config.GeneratePublishTopic(PubSubBackendPublishParams{
   242  		Command:        params.Command,
   243  		CommandMessage: params.CommandMessage,
   244  		OperationID:    operationID,
   245  	})
   246  	if err != nil {
   247  		return errors.Wrap(err, "cannot generate request/reply notify topic")
   248  	}
   249  
   250  	if err := p.config.Publisher.Publish(ctx, replyTopic, notificationMsg); err != nil {
   251  		return errors.Wrap(err, "cannot publish command executed message")
   252  	}
   253  
   254  	if p.config.AckCommandErrors {
   255  		// we are ignoring handler error - message will be acked
   256  		return nil
   257  	} else {
   258  		// if handler returned error, it will nack the message
   259  		// if params.HandleErr is nil, message will be acked
   260  		return params.HandleErr
   261  	}
   262  }
   263  
   264  func operationIDFromMetadata(msg *message.Message) (OperationID, error) {
   265  	operationID := msg.Metadata.Get(OperationIDMetadataKey)
   266  	if operationID == "" {
   267  		return "", errors.Errorf("cannot get notification ID from command message metadata, key: %s", OperationIDMetadataKey)
   268  	}
   269  
   270  	return OperationID(operationID), nil
   271  }
   272  
   273  func (p PubSubBackend[Result]) handleNotifyMsg(
   274  	msg *message.Message,
   275  	expectedCommandUuid string,
   276  	marshaler BackendPubsubMarshaler[Result],
   277  ) (Reply[Result], bool, error) {
   278  	defer msg.Ack()
   279  
   280  	if msg.Metadata.Get(OperationIDMetadataKey) != expectedCommandUuid {
   281  		p.config.Logger.Debug("Received notify message with different command UUID", nil)
   282  		return Reply[Result]{}, false, nil
   283  	}
   284  
   285  	res, unmarshalErr := marshaler.UnmarshalReply(msg)
   286  	return res, true, unmarshalErr
   287  }