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 }