diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2f7011a..78dcd78 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,7 +46,15 @@ jobs: check-latest: true - name: Check Go Version run: go version + - name: run go tool staticcheck + # use pinned version of staticcheck this need to match with the go version for compatibility + # Compatibility with go version is listed in the release description of https://github.com/dominikh/go-tools/releases + # need at least version v0.4.4 for go version 1.21 + run: | + go install honnef.co/go/tools/cmd/staticcheck@v0.4.6 + staticcheck -checks=all ./... - name: Compiles + if: ${{ success() }} run: go build ./... - name: Runs go fmt diff --git a/internal/ccsmp/ccsmp_callbacks.c b/internal/ccsmp/ccsmp_callbacks.c index a293443..3bd3959 100644 --- a/internal/ccsmp/ccsmp_callbacks.c +++ b/internal/ccsmp/ccsmp_callbacks.c @@ -22,6 +22,7 @@ #include "solclient/solClient.h" #include "solclient/solClientMsg.h" +#include "./ccsmp_helper.h" solClient_rxMsgCallback_returnCode_t messageReceiveCallback(solClient_opaqueSession_pt opaqueSession_p, solClient_opaqueMsg_pt msg_p, void *user_p) @@ -30,6 +31,19 @@ messageReceiveCallback(solClient_opaqueSession_pt opaqueSession_p, solClient_opa return goMessageReceiveCallback(opaqueSession_p, msg_p, user_p); } +solClient_rxMsgCallback_returnCode_t +requestResponseReplyMessageReceiveCallback(solClient_opaqueSession_pt opaqueSession_p, solClient_opaqueMsg_pt msg_p, void *user_p) { + solClient_rxMsgCallback_returnCode_t goReplyMessageReceiveCallback(solClient_opaqueSession_pt, solClient_opaqueMsg_pt, void *, char *); + char * correlationId = NULL; + // when receiving message that is not a reply deliver to subscription dispatch + if ( SOLCLIENT_OK != solClientgo_msg_isRequestReponseMsg(msg_p, &correlationId) ) { + // discard any message that is not a reply message + // note any subscription that matches the replyto topic will get an independent dispatch callback + return SOLCLIENT_CALLBACK_OK; + } + return goReplyMessageReceiveCallback(opaqueSession_p, msg_p, user_p, correlationId); +} + solClient_rxMsgCallback_returnCode_t defaultMessageReceiveCallback(solClient_opaqueSession_pt opaqueSession_p, solClient_opaqueMsg_pt msg_p, void *user_p) { diff --git a/internal/ccsmp/ccsmp_core.go b/internal/ccsmp/ccsmp_core.go index 62a6bb1..b5c4166 100644 --- a/internal/ccsmp/ccsmp_core.go +++ b/internal/ccsmp/ccsmp_core.go @@ -29,6 +29,7 @@ package ccsmp #include "./ccsmp_helper.h" solClient_rxMsgCallback_returnCode_t messageReceiveCallback ( solClient_opaqueSession_pt opaqueSession_p, solClient_opaqueMsg_pt msg_p, void *user_p ); +solClient_rxMsgCallback_returnCode_t requestResponseReplyMessageReceiveCallback ( solClient_opaqueSession_pt opaqueSession_p, solClient_opaqueMsg_pt msg_p, void *user_p ); solClient_rxMsgCallback_returnCode_t defaultMessageReceiveCallback ( solClient_opaqueSession_pt opaqueSession_p, solClient_opaqueMsg_pt msg_p, void *user_p ); void eventCallback ( solClient_opaqueSession_pt opaqueSession_p, solClient_session_eventCallbackInfo_pt eventInfo_p, void *user_p ); void handleLogCallback(solClient_log_callbackInfo_pt logInfo_p, void *user_p); @@ -69,6 +70,9 @@ type SolClientSessionRxMsgDispatchFuncInfo = C.solClient_session_rxMsgDispatchFu // SolClientVersionInfo is assigned a value type SolClientVersionInfo = C.solClient_version_info_t +// SolClientCorrelationID is assigned a value +type SolClientCorrelationID = *C.char + // Reexport various CCSMP variables // SolClientPropEnableVal is assigned a value @@ -77,16 +81,26 @@ var SolClientPropEnableVal = C.SOLCLIENT_PROP_ENABLE_VAL // SolClientPropDisableVal is assigned a value var SolClientPropDisableVal = C.SOLCLIENT_PROP_DISABLE_VAL +// Reexport solclientgo variables + +// SolClientGoPropCorrelationPrefix property value +// var SolClientGoPropCorrelationPrefix = C.GoString(C.SOLCLIENTGO_REPLY_CORRELATION_PREFIX) +var SolClientGoPropCorrelationPrefix = C.SOLCLIENTGO_REPLY_CORRELATION_PREFIX + // Callbacks // SolClientMessageCallback is assigned a function type SolClientMessageCallback = func(msgP SolClientMessagePt, userP unsafe.Pointer) bool +// SolClientReplyMessageCallback assigned a function +type SolClientReplyMessageCallback = func(msgP SolClientMessagePt, userP unsafe.Pointer, correlationP string) bool + // SolClientSessionEventCallback is assigned a function type SolClientSessionEventCallback = func(sessionEvent SolClientSessionEvent, responseCode SolClientResponseCode, info string, correlationP unsafe.Pointer, userP unsafe.Pointer) // maps to callbacks var sessionToRXCallbackMap sync.Map +var sessionToReplyRXCallbackMap sync.Map var sessionToEventCallbackMap sync.Map //export goMessageReceiveCallback @@ -101,6 +115,19 @@ func goMessageReceiveCallback(sessionP SolClientSessionPt, msgP SolClientMessage return C.SOLCLIENT_CALLBACK_OK } +//export goReplyMessageReceiveCallback +func goReplyMessageReceiveCallback(sessionP SolClientSessionPt, msgP SolClientMessagePt, userP unsafe.Pointer, correlationIDP SolClientCorrelationID) C.solClient_rxMsgCallback_returnCode_t { + // propagate to request reponse reply message handler + if callback, ok := sessionToReplyRXCallbackMap.Load(sessionP); ok { + if callback.(SolClientReplyMessageCallback)(msgP, userP, C.GoString(correlationIDP)) { + return C.SOLCLIENT_CALLBACK_TAKE_MSG + } + return C.SOLCLIENT_CALLBACK_OK + } + logging.Default.Error("Received reply message from core API without an associated session callback") + return C.SOLCLIENT_CALLBACK_OK +} + //export goDefaultMessageReceiveCallback func goDefaultMessageReceiveCallback(sessionP SolClientSessionPt, msgP SolClientMessagePt, userP unsafe.Pointer) C.solClient_rxMsgCallback_returnCode_t { logging.Default.Error("Received message from core API on the default session callback") @@ -207,6 +234,19 @@ func (session *SolClientSession) SetMessageCallback(callback SolClientMessageCal return nil } +// SetReplyMessageCallback sets the message callback to use +func (session *SolClientSession) SetReplyMessageCallback(callback SolClientReplyMessageCallback) error { + if session == nil || session.pointer == nil { + return fmt.Errorf("could not set message receive callback for nil session") + } + if callback == nil { + sessionToReplyRXCallbackMap.Delete(session.pointer) + } else { + sessionToReplyRXCallbackMap.Store(session.pointer, callback) + } + return nil +} + // SetEventCallback sets the event callback to use func (session *SolClientSession) SetEventCallback(callback SolClientSessionEventCallback) error { if session == nil || session.pointer == nil { @@ -289,6 +329,7 @@ func (session *SolClientSession) SolClientSessionDestroy() *SolClientErrorInfoWr // last line of defence to make sure everything is cleaned up sessionToEventCallbackMap.Delete(session.pointer) sessionToRXCallbackMap.Delete(session.pointer) + sessionToReplyRXCallbackMap.Delete(session.pointer) return handleCcsmpError(func() SolClientReturnCode { return C.solClient_session_destroy(&session.pointer) }) @@ -302,26 +343,82 @@ func (session *SolClientSession) SolClientSessionPublish(message SolClientMessag }) } -// SolClientSessionSubscribe wraps solClient_session_topicSubscribeWithDispatch -func (session *SolClientSession) SolClientSessionSubscribe(topic string, dispatch *SolClientSessionRxMsgDispatchFuncInfo, correlationID uintptr) *SolClientErrorInfoWrapper { +// solClientSessionSubscribeWithFlags wraps solClient_session_topicSubscribeWithDispatch +func (session *SolClientSession) solClientSessionSubscribeWithFlags(topic string, flags C.solClient_subscribeFlags_t, dispatchID uintptr, correlationID uintptr) *SolClientErrorInfoWrapper { return handleCcsmpError(func() SolClientReturnCode { cString := C.CString(topic) defer C.free(unsafe.Pointer(cString)) // This is not an unsafe usage of unsafe.Pointer as we are using correlationId as data, not as a pointer - return C.solClient_session_topicSubscribeWithDispatch(session.pointer, C.SOLCLIENT_SUBSCRIBE_FLAGS_REQUEST_CONFIRM, cString, dispatch, C.uintptr_to_void_p(C.solClient_uint64_t(correlationID))) + return C.SessionTopicSubscribeWithFlags(session.pointer, + cString, + flags, + C.uintptr_to_void_p(C.solClient_uint64_t(dispatchID)), + C.uintptr_to_void_p(C.solClient_uint64_t(correlationID))) }) } -// SolClientSessionUnsubscribe wraps solClient_session_topicUnsubscribeWithDispatch -func (session *SolClientSession) SolClientSessionUnsubscribe(topic string, dispatch *SolClientSessionRxMsgDispatchFuncInfo, correlationID uintptr) *SolClientErrorInfoWrapper { +// solClientSessionSubscribeWithFlags wraps solClient_session_topicSubscribeWithDispatch +func (session *SolClientSession) solClientSessionSubscribeReplyTopicWithFlags(topic string, flags C.solClient_subscribeFlags_t, dispatchID uintptr, correlationID uintptr) *SolClientErrorInfoWrapper { + return handleCcsmpError(func() SolClientReturnCode { + cString := C.CString(topic) + defer C.free(unsafe.Pointer(cString)) + // This is not an unsafe usage of unsafe.Pointer as we are using correlationId as data, not as a pointer + return C.SessionReplyTopicSubscribeWithFlags(session.pointer, + cString, + flags, + C.uintptr_to_void_p(C.solClient_uint64_t(dispatchID)), + C.uintptr_to_void_p(C.solClient_uint64_t(correlationID))) + }) +} + +// solClientSessionUnsubscribeWithFlags wraps solClient_session_topicUnsubscribeWithDispatch +func (session *SolClientSession) solClientSessionUnsubscribeWithFlags(topic string, flags C.solClient_subscribeFlags_t, dispatchID uintptr, correlationID uintptr) *SolClientErrorInfoWrapper { return handleCcsmpError(func() SolClientReturnCode { cString := C.CString(topic) defer C.free(unsafe.Pointer(cString)) // This is not an unsafe usage of unsafe.Pointer as we are using correlationId as data, not as a pointer - return C.solClient_session_topicUnsubscribeWithDispatch(session.pointer, C.SOLCLIENT_SUBSCRIBE_FLAGS_REQUEST_CONFIRM, cString, dispatch, C.uintptr_to_void_p(C.solClient_uint64_t(correlationID))) + return C.SessionTopicUnsubscribeWithFlags(session.pointer, + cString, + flags, + C.uintptr_to_void_p(C.solClient_uint64_t(dispatchID)), + C.uintptr_to_void_p(C.solClient_uint64_t(correlationID))) }) } +// solClientSessionUnsubscribeReplyTopicWithFlags wraps solClient_session_topicUnsubscribeWithDispatch +func (session *SolClientSession) solClientSessionUnsubscribeReplyTopicWithFlags(topic string, flags C.solClient_subscribeFlags_t, dispatchID uintptr, correlationID uintptr) *SolClientErrorInfoWrapper { + return handleCcsmpError(func() SolClientReturnCode { + cString := C.CString(topic) + defer C.free(unsafe.Pointer(cString)) + // This is not an unsafe usage of unsafe.Pointer as we are using correlationId as data, not as a pointer + return C.SessionReplyTopicUnsubscribeWithFlags(session.pointer, + cString, + flags, + C.uintptr_to_void_p(C.solClient_uint64_t(dispatchID)), + C.uintptr_to_void_p(C.solClient_uint64_t(correlationID))) + }) +} + +// SolClientSessionSubscribeReplyTopic wraps solClient_session_topicSubscribeWithDispatch +func (session *SolClientSession) SolClientSessionSubscribeReplyTopic(topic string, dispatchID uintptr, correlationID uintptr) *SolClientErrorInfoWrapper { + return session.solClientSessionSubscribeReplyTopicWithFlags(topic, C.SOLCLIENT_SUBSCRIBE_FLAGS_LOCAL_DISPATCH_ONLY, dispatchID, correlationID) +} + +// SolClientSessionUnsubscribeReplyTopic wraps solClient_session_topicUnsubscribeWithDispatch +func (session *SolClientSession) SolClientSessionUnsubscribeReplyTopic(topic string, dispatchID uintptr, correlationID uintptr) *SolClientErrorInfoWrapper { + return session.solClientSessionUnsubscribeReplyTopicWithFlags(topic, C.SOLCLIENT_SUBSCRIBE_FLAGS_LOCAL_DISPATCH_ONLY, dispatchID, correlationID) +} + +// SolClientSessionSubscribe wraps solClient_session_topicSubscribeWithDispatch +func (session *SolClientSession) SolClientSessionSubscribe(topic string, dispatchID uintptr, correlationID uintptr) *SolClientErrorInfoWrapper { + return session.solClientSessionSubscribeWithFlags(topic, C.SOLCLIENT_SUBSCRIBE_FLAGS_REQUEST_CONFIRM, dispatchID, correlationID) +} + +// SolClientSessionUnsubscribe wraps solClient_session_topicUnsubscribeWithDispatch +func (session *SolClientSession) SolClientSessionUnsubscribe(topic string, dispatchID uintptr, correlationID uintptr) *SolClientErrorInfoWrapper { + return session.solClientSessionUnsubscribeWithFlags(topic, C.SOLCLIENT_SUBSCRIBE_FLAGS_REQUEST_CONFIRM, dispatchID, correlationID) +} + // SolClientEndpointProvision wraps solClient_session_endpointProvision func (session *SolClientSession) SolClientEndpointProvision(properties []string) *SolClientErrorInfoWrapper { return handleCcsmpError(func() SolClientReturnCode { @@ -396,6 +493,39 @@ func (session *SolClientSession) SolClientSessionGetClientName() (string, *SolCl return string(clientName[:endIndex]), nil } +// SolClientSessionGetP2PTopicPrefix wraps solClient_session_getProperty +func (session *SolClientSession) SolClientSessionGetP2PTopicPrefix() (string, *SolClientErrorInfoWrapper) { + const maxTopicSize = 251 // max topic size including the nul terminal + p2pTopicInUseKey := C.CString(SolClientSessionPropP2pinboxInUse) + defer C.free(unsafe.Pointer(p2pTopicInUseKey)) + p2pTopicInUse := make([]byte, maxTopicSize) + // Get the P2P topic for this session/transport. + // It is used together with inbox request/reply MEP using + // native CCSMP inbox + // Example CCSMP session + // P2PINBOX_IN_USE: '#P2P/v:mybroker/mPuoLl8m/myhost/5221/00000001/oWxIwBFz28/#' + // This only works if the session is connected + errorInfo := handleCcsmpError(func() SolClientReturnCode { + return C.solClient_session_getProperty(session.pointer, p2pTopicInUseKey, (*C.char)(unsafe.Pointer(&p2pTopicInUse[0])), maxTopicSize) + }) + if errorInfo != nil { + return "", errorInfo + } + endIndex := maxTopicSize + for i := 0; i < maxTopicSize; i++ { + if p2pTopicInUse[i] == 0 { + endIndex = i + break + } + } + // truncate last character '#' + if endIndex > 0 { + endIndex = endIndex - 1 + p2pTopicInUse[endIndex] = 0 + } + return string(p2pTopicInUse[:endIndex]), nil +} + // SolClientVersionGet wraps solClient_version_get func SolClientVersionGet() (err *SolClientErrorInfoWrapper, version, dateTime, variant string) { var versionInfo *SolClientVersionInfo @@ -445,6 +575,14 @@ func NewSessionDispatch(id uint64) (*SolClientSessionRxMsgDispatchFuncInfo, uint }, ptr } +// NewSessionReplyDispatch function +func NewSessionReplyDispatch(id uint64) uintptr { + // This is not a misuse of unsafe.Pointer as we are not storing a pointer. + // CGO defines void* as unsafe.Pointer, however it is just arbitrary data. + // We want to store a number at void* + return uintptr(id) +} + // GetLastErrorInfo should NOT be called in most cases as it is dependent on the thread. // Unless you know that the goroutine running the code will not be interrupted, do NOT // call this function! diff --git a/internal/ccsmp/ccsmp_helper.c b/internal/ccsmp/ccsmp_helper.c index ab58951..35ba65c 100644 --- a/internal/ccsmp/ccsmp_helper.c +++ b/internal/ccsmp/ccsmp_helper.c @@ -15,8 +15,142 @@ // limitations under the License. #include "./ccsmp_helper.h" +#include "solclient/solClient.h" +#include "solclient/solClientMsg.h" + +// +// external callbacks defined in ccsmp_callbacks.c +// +solClient_rxMsgCallback_returnCode_t +messageReceiveCallback(solClient_opaqueSession_pt opaqueSession_p, solClient_opaqueMsg_pt msg_p, void *user_p); + +solClient_rxMsgCallback_returnCode_t +requestResponseReplyMessageReceiveCallback(solClient_opaqueSession_pt opaqueSession_p, solClient_opaqueMsg_pt msg_p, void *user_p); void *uintptr_to_void_p(solClient_uint64_t ptr) { return (void *)ptr; -} \ No newline at end of file +} + +solClient_returnCode_t +solClientgo_msg_isRequestReponseMsg(solClient_opaqueMsg_pt msg_p, char **correlationId_p) { + solClient_returnCode_t rc = SOLCLIENT_FAIL; + const char *correlationId = NULL; + if ( correlationId_p == NULL ) { + return rc; + } + if ( !solClient_msg_isReplyMsg(msg_p) ) { + return rc; + } + if ( SOLCLIENT_OK != (rc = solClient_msg_getCorrelationId(msg_p, &correlationId)) ) { + return rc; + } + if (!SOLCLIENTGO_HAS_REPLY_CORRELATION_ID_PREFIX(correlationId)) { + return SOLCLIENT_FAIL; + } + // This string is a direct read from the message backing memory and shoud be copied into go memory for persistent use. + *correlationId_p = (char *)correlationId; + return SOLCLIENT_OK; +} + +solClient_returnCode_t +_SessionTopicSubscribeWithFlags( solClient_opaqueSession_pt opaqueSession_p, + const char *topicSubscription_p, + solClient_subscribeFlags_t flags, + solClient_session_rxMsgCallbackFunc_t callback_p, + void *dispatchId_p, + void *correlationTag_p) +{ + solClient_session_rxMsgDispatchFuncInfo_t dispatchInfo; /* msg dispatch callback to set */ + dispatchInfo.dispatchType = SOLCLIENT_DISPATCH_TYPE_CALLBACK; + dispatchInfo.callback_p = callback_p; + dispatchInfo.user_p = dispatchId_p; + dispatchInfo.rfu_p = NULL; + return solClient_session_topicSubscribeWithDispatch ( opaqueSession_p, + flags, + topicSubscription_p, + &dispatchInfo, + correlationTag_p); +} + + +solClient_returnCode_t +SessionTopicSubscribeWithFlags( solClient_opaqueSession_pt opaqueSession_p, + const char *topicSubscription_p, + solClient_subscribeFlags_t flags, + void *dispatchId_p, + void *correlationTag_p) +{ + return _SessionTopicSubscribeWithFlags ( opaqueSession_p, + topicSubscription_p, + flags, + messageReceiveCallback, + dispatchId_p, + correlationTag_p ); +} + +solClient_returnCode_t +SessionReplyTopicSubscribeWithFlags( solClient_opaqueSession_pt opaqueSession_p, + const char *topicSubscription_p, + solClient_subscribeFlags_t flags, + void *dispatchId_p, + void *correlationTag_p) +{ + return _SessionTopicSubscribeWithFlags ( opaqueSession_p, + topicSubscription_p, + flags, + requestResponseReplyMessageReceiveCallback, + dispatchId_p, + correlationTag_p ); +} + +solClient_returnCode_t +_SessionTopicUnsubscribeWithFlags( solClient_opaqueSession_pt opaqueSession_p, + const char *topicSubscription_p, + solClient_subscribeFlags_t flags, + solClient_session_rxMsgCallbackFunc_t callback_p, + void *dispatchId_p, + void *correlationTag_p) +{ + solClient_session_rxMsgDispatchFuncInfo_t dispatchInfo; /* msg dispatch callback to set */ + dispatchInfo.dispatchType = SOLCLIENT_DISPATCH_TYPE_CALLBACK; + dispatchInfo.callback_p = callback_p; + dispatchInfo.user_p = dispatchId_p; + dispatchInfo.rfu_p = NULL; + return solClient_session_topicUnsubscribeWithDispatch ( opaqueSession_p, + flags, + topicSubscription_p, + &dispatchInfo, + correlationTag_p); +} + +solClient_returnCode_t +SessionTopicUnsubscribeWithFlags( solClient_opaqueSession_pt opaqueSession_p, + const char *topicSubscription_p, + solClient_subscribeFlags_t flags, + void *dispatchId_p, + void *correlationTag_p) +{ + return _SessionTopicUnsubscribeWithFlags ( opaqueSession_p, + topicSubscription_p, + flags, + messageReceiveCallback, + dispatchId_p, + correlationTag_p ); +} + +solClient_returnCode_t +SessionReplyTopicUnsubscribeWithFlags( solClient_opaqueSession_pt opaqueSession_p, + const char *topicSubscription_p, + solClient_subscribeFlags_t flags, + void *dispatchId_p, + void *correlationTag_p) +{ + return _SessionTopicUnsubscribeWithFlags ( opaqueSession_p, + topicSubscription_p, + flags, + requestResponseReplyMessageReceiveCallback, + dispatchId_p, + correlationTag_p ); +} + diff --git a/internal/ccsmp/ccsmp_helper.h b/internal/ccsmp/ccsmp_helper.h index 9412a96..1aeaf9e 100644 --- a/internal/ccsmp/ccsmp_helper.h +++ b/internal/ccsmp/ccsmp_helper.h @@ -49,4 +49,53 @@ typedef struct solClient_errorInfo_wrapper void * uintptr_to_void_p(solClient_uint64_t ptr); +solClient_returnCode_t SessionTopicSubscribeWithFlags( + solClient_opaqueSession_pt opaqueSession_p, + const char *topicSubscription_p, + solClient_subscribeFlags_t flags, + void *dispatchId_p, + void *correlationTag_p); + +solClient_returnCode_t SessionTopicUnsubscribeWithFlags( + solClient_opaqueSession_pt opaqueSession_p, + const char *topicSubscription_p, + solClient_subscribeFlags_t flags, + void *dispatchId_p, + void *correlationTag_p); + +solClient_returnCode_t SessionReplyTopicSubscribeWithFlags( + solClient_opaqueSession_pt opaqueSession_p, + const char *topicSubscription_p, + solClient_subscribeFlags_t flags, + void *dispatchId_p, + void *correlationTag_p); + +solClient_returnCode_t SessionReplyTopicUnsubscribeWithFlags( + solClient_opaqueSession_pt opaqueSession_p, + const char *topicSubscription_p, + solClient_subscribeFlags_t flags, + void *dispatchId_p, + void *correlationTag_p); + +/** + * Definition of solclientgo correlation prefix + */ +#define SOLCLIENTGO_REPLY_CORRELATION_PREFIX "#GOS" + +/** + * Macro for determining if a message correlation has the solclientgo correlation prefix + * corrId_p correlation id pointer/expression, must not be NULL. + * Should be a utf8 null terminal string, any string that is not null terminal must + * have a buffer size greater then 4. + */ +#define SOLCLIENTGO_HAS_REPLY_CORRELATION_ID_PREFIX(corrId_p) ( \ + (corrId_p)[0] == (SOLCLIENTGO_REPLY_CORRELATION_PREFIX)[0] && \ + (corrId_p)[1] == (SOLCLIENTGO_REPLY_CORRELATION_PREFIX)[1] && \ + (corrId_p)[2] == (SOLCLIENTGO_REPLY_CORRELATION_PREFIX)[2] && \ + (corrId_p)[3] == (SOLCLIENTGO_REPLY_CORRELATION_PREFIX)[3] \ + ) + +solClient_returnCode_t +solClientgo_msg_isRequestReponseMsg(solClient_opaqueMsg_pt msg_p, char **correlationId_p); + #endif diff --git a/internal/ccsmp/ccsmp_message.go b/internal/ccsmp/ccsmp_message.go index 61dd6e8..804f21d 100644 --- a/internal/ccsmp/ccsmp_message.go +++ b/internal/ccsmp/ccsmp_message.go @@ -272,6 +272,18 @@ func SolClientMessageGetDestinationName(messageP SolClientMessagePt) (destName s return destName, errorInfo } +// SolClientMessageGetReplyToDestinationName function +func SolClientMessageGetReplyToDestinationName(messageP SolClientMessagePt) (destName string, errorInfo *SolClientErrorInfoWrapper) { + var dest *SolClientDestination = &SolClientDestination{} + errorInfo = handleCcsmpError(func() SolClientReturnCode { + return C.solClient_msg_getReplyTo(messageP, dest, (C.size_t)(unsafe.Sizeof(*dest))) + }) + if errorInfo == nil { + destName = C.GoString(dest.dest) + } + return destName, errorInfo +} + // SolClientMessageSetDestination function func SolClientMessageSetDestination(messageP SolClientMessagePt, destinationString string) *SolClientErrorInfoWrapper { destination := &SolClientDestination{} @@ -283,6 +295,29 @@ func SolClientMessageSetDestination(messageP SolClientMessagePt, destinationStri }) } +// SolClientMessageSetReplyToDestination function +func SolClientMessageSetReplyToDestination(messageP SolClientMessagePt, replyToDestinationString string) *SolClientErrorInfoWrapper { + destination := &SolClientDestination{} + destination.destType = C.SOLCLIENT_TOPIC_DESTINATION + destination.dest = C.CString(replyToDestinationString) + defer C.free(unsafe.Pointer(destination.dest)) + return handleCcsmpError(func() SolClientReturnCode { + return C.solClient_msg_setReplyTo(messageP, destination, (C.size_t)(unsafe.Sizeof(*destination))) + }) +} + +// SolClientMessageSetAsReply function +func SolClientMessageSetAsReply(messageP SolClientMessagePt, val bool) *SolClientErrorInfoWrapper { + var isReply uint8 + if val { + isReply = 1 + } + errorInfo := handleCcsmpError(func() SolClientReturnCode { + return C.solClient_msg_setAsReplyMsg(messageP, C.solClient_bool_t(isReply)) + }) + return errorInfo +} + // SolClientMessageGetExpiration function func SolClientMessageGetExpiration(messageP SolClientMessagePt) (time.Time, *SolClientErrorInfoWrapper) { var cint64 C.longlong diff --git a/internal/ccsmp/ccsmp_message_tracing.go b/internal/ccsmp/ccsmp_message_tracing.go index e4a732e..7dad11f 100644 --- a/internal/ccsmp/ccsmp_message_tracing.go +++ b/internal/ccsmp/ccsmp_message_tracing.go @@ -52,6 +52,12 @@ const SolClientMessageTracingInjectionStandardTypeSMF = C.SOLCLIENT_INJECTION_ST // SolClientMessageTracingInjectionStandardTypeW3C is assigned a value const SolClientMessageTracingInjectionStandardTypeW3C = C.SOLCLIENT_INJECTION_STANDARD_W3C +// TraceIDSize is the expected size/length of the traceID +const TraceIDSize = 16 + +// SpanIDSize is the expected size/length of the spanID +const SpanIDSize = 8 + // TODO the calls to handleCcsmpError are slow since they lock the thread. // Ideally, we wrap these calls in C such that the golang scheduler cannot // interrupt us, and then there is no need to lock the thread. This should @@ -60,12 +66,14 @@ const SolClientMessageTracingInjectionStandardTypeW3C = C.SOLCLIENT_INJECTION_ST // Distributed tracing properties // SolClientMessageGetTraceContextTraceID function -func SolClientMessageGetTraceContextTraceID(messageP SolClientMessagePt, contextType SolClientMessageTracingContextType) ([16]byte, *SolClientErrorInfoWrapper) { - // to hold the traceID property - var cChar C.solClient_uint8_t +func SolClientMessageGetTraceContextTraceID(messageP SolClientMessagePt, contextType SolClientMessageTracingContextType) ([TraceIDSize]byte, *SolClientErrorInfoWrapper) { + // buffer to hold the traceID property + bufferSize := C.ulong(TraceIDSize) + traceIDBuffer := (*C.solClient_uint8_t)(C.malloc(bufferSize)) + defer C.free(unsafe.Pointer(traceIDBuffer)) errorInfo := handleCcsmpError(func() SolClientReturnCode { - return C.solClient_msg_tracing_getTraceIdByte(messageP, contextType, &cChar, C.size_t(16)) + return C.solClient_msg_tracing_getTraceIdByte(messageP, contextType, traceIDBuffer, bufferSize) }) if errorInfo != nil { if errorInfo.ReturnCode == SolClientReturnCodeFail { @@ -75,15 +83,16 @@ func SolClientMessageGetTraceContextTraceID(messageP SolClientMessagePt, context errorInfo.GetMessageAsString(), errorInfo.SubCode)) } - return [16]byte{}, errorInfo + return [TraceIDSize]byte{}, errorInfo } - traceID := *(*[16]byte)(unsafe.Pointer(&cChar)) + var traceID [TraceIDSize]byte = [TraceIDSize]byte{} // default value of traceID + copy(traceID[:], C.GoBytes(unsafe.Pointer(traceIDBuffer), C.int(bufferSize))) return traceID, errorInfo } // SolClientMessageSetTraceContextTraceID function -func SolClientMessageSetTraceContextTraceID(messageP SolClientMessagePt, traceID [16]byte, contextType SolClientMessageTracingContextType) *SolClientErrorInfoWrapper { +func SolClientMessageSetTraceContextTraceID(messageP SolClientMessagePt, traceID [TraceIDSize]byte, contextType SolClientMessageTracingContextType) *SolClientErrorInfoWrapper { if len(traceID) > 0 { cTraceID := (*C.solClient_uint8_t)(C.CBytes(traceID[:])) @@ -98,12 +107,14 @@ func SolClientMessageSetTraceContextTraceID(messageP SolClientMessagePt, traceID } // SolClientMessageGetTraceContextSpanID function -func SolClientMessageGetTraceContextSpanID(messageP SolClientMessagePt, contextType SolClientMessageTracingContextType) ([8]byte, *SolClientErrorInfoWrapper) { - // to hold the spanID property - var cChar C.solClient_uint8_t +func SolClientMessageGetTraceContextSpanID(messageP SolClientMessagePt, contextType SolClientMessageTracingContextType) ([SpanIDSize]byte, *SolClientErrorInfoWrapper) { + // buffer to hold the spanID property + bufferSize := C.ulong(SpanIDSize) + spanIDBuffer := (*C.solClient_uint8_t)(C.malloc(bufferSize)) + defer C.free(unsafe.Pointer(spanIDBuffer)) errorInfo := handleCcsmpError(func() SolClientReturnCode { - return C.solClient_msg_tracing_getSpanIdByte(messageP, contextType, &cChar, C.size_t(8)) + return C.solClient_msg_tracing_getSpanIdByte(messageP, contextType, spanIDBuffer, bufferSize) }) if errorInfo != nil { if errorInfo.ReturnCode == SolClientReturnCodeFail { @@ -113,15 +124,16 @@ func SolClientMessageGetTraceContextSpanID(messageP SolClientMessagePt, contextT errorInfo.GetMessageAsString(), errorInfo.SubCode)) } - return [8]byte{}, errorInfo + return [SpanIDSize]byte{}, errorInfo } - spanID := *(*[8]byte)(unsafe.Pointer(&cChar)) + var spanID [SpanIDSize]byte = [SpanIDSize]byte{} // default value of spanID + copy(spanID[:], C.GoBytes(unsafe.Pointer(spanIDBuffer), C.int(bufferSize))) return spanID, errorInfo } // SolClientMessageSetTraceContextSpanID function -func SolClientMessageSetTraceContextSpanID(messageP SolClientMessagePt, spanID [8]byte, contextType SolClientMessageTracingContextType) *SolClientErrorInfoWrapper { +func SolClientMessageSetTraceContextSpanID(messageP SolClientMessagePt, spanID [SpanIDSize]byte, contextType SolClientMessageTracingContextType) *SolClientErrorInfoWrapper { if len(spanID) > 0 { cSpanID := (*C.solClient_uint8_t)(C.CBytes(spanID[:])) @@ -212,25 +224,25 @@ func SolClientMessageSetTraceContextTraceState(messageP SolClientMessagePt, trac // For the Creation Context // SolClientMessageGetCreationTraceContextTraceID function -func SolClientMessageGetCreationTraceContextTraceID(messageP SolClientMessagePt) ([16]byte, *SolClientErrorInfoWrapper) { +func SolClientMessageGetCreationTraceContextTraceID(messageP SolClientMessagePt) ([TraceIDSize]byte, *SolClientErrorInfoWrapper) { // return the traceID property for the creation trace context return SolClientMessageGetTraceContextTraceID(messageP, SolClientContextTypeCreationContext) } // SolClientMessageSetCreationTraceContextTraceID function -func SolClientMessageSetCreationTraceContextTraceID(messageP SolClientMessagePt, traceID [16]byte) *SolClientErrorInfoWrapper { +func SolClientMessageSetCreationTraceContextTraceID(messageP SolClientMessagePt, traceID [TraceIDSize]byte) *SolClientErrorInfoWrapper { // Sets the traceID property for the creation trace context return SolClientMessageSetTraceContextTraceID(messageP, traceID, SolClientContextTypeCreationContext) } // SolClientMessageGetCreationTraceContextSpanID function -func SolClientMessageGetCreationTraceContextSpanID(messageP SolClientMessagePt) ([8]byte, *SolClientErrorInfoWrapper) { +func SolClientMessageGetCreationTraceContextSpanID(messageP SolClientMessagePt) ([SpanIDSize]byte, *SolClientErrorInfoWrapper) { // return the spanID property for the creation trace context return SolClientMessageGetTraceContextSpanID(messageP, SolClientContextTypeCreationContext) } // SolClientMessageSetCreationTraceContextSpanID function -func SolClientMessageSetCreationTraceContextSpanID(messageP SolClientMessagePt, spanID [8]byte) *SolClientErrorInfoWrapper { +func SolClientMessageSetCreationTraceContextSpanID(messageP SolClientMessagePt, spanID [SpanIDSize]byte) *SolClientErrorInfoWrapper { // Sets the spanID property for the creation trace context return SolClientMessageSetTraceContextSpanID(messageP, spanID, SolClientContextTypeCreationContext) } @@ -262,25 +274,25 @@ func SolClientMessageSetCreationTraceContextTraceState(messageP SolClientMessage // For the Transport Context // SolClientMessageGetTransportTraceContextTraceID function -func SolClientMessageGetTransportTraceContextTraceID(messageP SolClientMessagePt) ([16]byte, *SolClientErrorInfoWrapper) { +func SolClientMessageGetTransportTraceContextTraceID(messageP SolClientMessagePt) ([TraceIDSize]byte, *SolClientErrorInfoWrapper) { // return the traceID property for the transport trace context return SolClientMessageGetTraceContextTraceID(messageP, SolClientContextTypeTransportContext) } // SolClientMessageSetTransportTraceContextTraceID function -func SolClientMessageSetTransportTraceContextTraceID(messageP SolClientMessagePt, traceID [16]byte) *SolClientErrorInfoWrapper { +func SolClientMessageSetTransportTraceContextTraceID(messageP SolClientMessagePt, traceID [TraceIDSize]byte) *SolClientErrorInfoWrapper { // Sets the traceID property for the transport trace context return SolClientMessageSetTraceContextTraceID(messageP, traceID, SolClientContextTypeTransportContext) } // SolClientMessageGetTransportTraceContextSpanID function -func SolClientMessageGetTransportTraceContextSpanID(messageP SolClientMessagePt) ([8]byte, *SolClientErrorInfoWrapper) { +func SolClientMessageGetTransportTraceContextSpanID(messageP SolClientMessagePt) ([SpanIDSize]byte, *SolClientErrorInfoWrapper) { // return the spanID property for the transport trace context return SolClientMessageGetTraceContextSpanID(messageP, SolClientContextTypeTransportContext) } // SolClientMessageSetTransportTraceContextSpanID function -func SolClientMessageSetTransportTraceContextSpanID(messageP SolClientMessagePt, spanID [8]byte) *SolClientErrorInfoWrapper { +func SolClientMessageSetTransportTraceContextSpanID(messageP SolClientMessagePt, spanID [SpanIDSize]byte) *SolClientErrorInfoWrapper { // Sets the spanID property for the transport trace context return SolClientMessageSetTraceContextSpanID(messageP, spanID, SolClientContextTypeTransportContext) } diff --git a/internal/impl/constants/error_strings.go b/internal/impl/constants/error_strings.go index 504e997..cca64cf 100644 --- a/internal/impl/constants/error_strings.go +++ b/internal/impl/constants/error_strings.go @@ -46,6 +46,12 @@ const UnableToTerminatePublisher = "cannot terminate the publisher as it has not // UnableToStartPublisher error string const UnableToStartPublisher = "cannot start the publisher as it has already been terminated" +// RequestReplyPublisherCannotReceiveReplyAlreadyTerminated error string +const RequestReplyPublisherCannotReceiveReplyAlreadyTerminated = "publisher has been terminated, no reply messages to receive" + +// RequestReplyPublisherTimedOutWaitingForReply error string +const RequestReplyPublisherTimedOutWaitingForReply = "timed out waiting for reply message for request publish" + // UnableToStartPublisherParentServiceNotStarted error string const UnableToStartPublisherParentServiceNotStarted = "cannot start publisher unless parent MessagingService is connected" @@ -168,3 +174,9 @@ const CouldNotConfirmSubscriptionServiceUnavailable = "could not confirm subscri // InvalidConfiguration error string const InvalidConfiguration = "invalid configuration provided: " + +// MissingReplyMessageHandler error string +const MissingReplyMessageHandler = "got nil ReplyMessageHandler, ReplyMessageHandler is required for Publish" + +// ReplierFailureToPublishReply error string +const ReplierFailureToPublishReply = "Publish Reply Error: " diff --git a/internal/impl/core/publisher.go b/internal/impl/core/publisher.go index 2fa9374..2eaba6c 100644 --- a/internal/impl/core/publisher.go +++ b/internal/impl/core/publisher.go @@ -18,6 +18,8 @@ package core import ( "fmt" + "strconv" + "strings" "sync" "sync/atomic" "unsafe" @@ -43,8 +45,28 @@ type Publisher interface { IncrementMetric(metric NextGenMetric, amount uint64) // Acknowledgements returns the acknowledgement handler Acknowledgements() Acknowledgements + // Requestor returns the reply handler manager + Requestor() Requestor } +// Requestor interface +// User for making requests in the request reply model +type Requestor interface { + // CreateReplyToTopic return a replyto topic created from a publisher id + CreateReplyToTopic(publisherID string) string + // registers callback for response message for a returned replyTo topic with correlation id generator + AddRequestorReplyHandler(replyHandler RequestorReplyHandler) (string, func() (messageId uint64, correlationId string), ErrorInfo) + // deregisters replyHandler from core publisher + RemoveRequestorReplyHandler(replyToTopic string) ErrorInfo +} + +// Repliable interface, this is Repliable alias, repliable are structs that are received as a part of a reply not a struct that can send a reply +type Repliable = ccsmp.SolClientMessagePt + +type RequestorReplyHandler func(message Repliable, correlationId string) bool + +var RequestCorrelationPrefix = ccsmp.SolClientGoPropCorrelationPrefix + // Acknowledgements interface type Acknowledgements interface { // Registers a callback for correlation @@ -63,6 +85,13 @@ type SendTask func() type Publishable = ccsmp.SolClientMessagePt // Implementation +type ccsmpReplyCorrelation struct { + handler RequestorReplyHandler + replyToTopic string +} + +type RequestorReplyCorrelationEntry = *ccsmpReplyCorrelation + type ccsmpBackedPublisher struct { events *ccsmpBackedEvents metrics *ccsmpBackedMetrics @@ -82,6 +111,12 @@ type ccsmpBackedPublisher struct { acknowledgementHandlerID uint64 acknowledgementMap sync.Map + + // requestor fields + rxLock sync.RWMutex + replyToPrefix string + replyDispatchID uint64 + publisherRxReplyMap map[uintptr]RequestorReplyCorrelationEntry } func newCcsmpPublisher(session *ccsmp.SolClientSession, events *ccsmpBackedEvents, metrics *ccsmpBackedMetrics) *ccsmpBackedPublisher { @@ -94,6 +129,9 @@ func newCcsmpPublisher(session *ccsmp.SolClientSession, events *ccsmpBackedEvent publisher.terminationComplete = make(chan struct{}) publisher.canSend = make(chan bool, 1) publisher.isRunning = 0 + publisher.replyDispatchID = 0 + publisher.replyToPrefix = "" + publisher.publisherRxReplyMap = make(map[uintptr]RequestorReplyCorrelationEntry) return publisher } @@ -101,6 +139,160 @@ func (publisher *ccsmpBackedPublisher) Publish(message Publishable) ErrorInfo { return publisher.session.SolClientSessionPublish(ccsmp.SolClientMessagePt(message)) } +func (publisher *ccsmpBackedPublisher) Requestor() Requestor { + return publisher +} + +// Requestor functions + +func (publisher *ccsmpBackedPublisher) CreateReplyToTopic(pubID string) string { + return publisher.replyToPrefix + pubID +} + +func (publisher *ccsmpBackedPublisher) AddRequestorReplyHandler(replyHandler RequestorReplyHandler) (string, func() (messageID uint64, correlationID string), ErrorInfo) { + // create reply to topic + // add local dispatch only subscription for reply to topic, note subscribe with local dispatch only is not asynchronous + // create correlationId generator + + // create reply to topic + // create unique id to incorperate into replyTo topic + pubID := atomic.AddUint64(&publisher.replyDispatchID, 1) + replyToTopic := fmt.Sprintf("%s%016X", publisher.replyToPrefix, pubID) + + // add local dispatch only subscription after adding entry into publisherRxReplyMap to handle reply messages + + // create dispatch entry + dispatchPointer := ccsmp.NewSessionReplyDispatch(pubID) + replyEntry := &ccsmpReplyCorrelation{ + handler: replyHandler, + replyToTopic: replyToTopic, + } + + // add dispatch index into rx map + publisher.rxLock.Lock() + + publisher.publisherRxReplyMap[dispatchPointer] = replyEntry + + publisher.rxLock.Unlock() + + // subscribe using local dispatch only + errorInfo := publisher.session.SolClientSessionSubscribeReplyTopic(replyToTopic, dispatchPointer, 0) + + // handle subscription error + if errorInfo != nil { + // cleanup rx map entry + publisher.rxLock.Lock() + + delete(publisher.publisherRxReplyMap, dispatchPointer) + + publisher.rxLock.Unlock() + + return "", nil, errorInfo + } + // return reply to topic and correlation id generator + var messageID uint64 + return replyToTopic, func() (nextID uint64, correlationID string) { + nextID = atomic.AddUint64(&messageID, 1) + // use correlation prefix independent to avoid ccsmp reply message dispatch discards + // note the ccsmp prefix is "#SOL" + correlationID = fmt.Sprintf("%s%016X", RequestCorrelationPrefix, nextID) + return nextID, correlationID + }, nil +} + +func (publisher *ccsmpBackedPublisher) RemoveRequestorReplyHandler(replyToTopic string) ErrorInfo { + // convert replyToTopic into index for rx map + // unsubscribe from local dispatch only replyto subscription + // remove callback from rx map + + // convert replyToTopic into index for rx map + // split into parts + replyTopicParts := strings.Split(replyToTopic, "/") + // get last part with is the pubID + pubIDHex := replyTopicParts[len(replyTopicParts)-1] + // parse into uint64 + pubID, err := strconv.ParseUint(pubIDHex, 16, 64) + if err != nil { + return nil + } + // convert pubID into pubIndex by creating dispatch used to unsubscribe + pubIndex := ccsmp.NewSessionReplyDispatch(pubID) + + // call unsubscribe on reply to topic to halt messaging + errorInfo := publisher.session.SolClientSessionUnsubscribeReplyTopic(replyToTopic, pubIndex, 0) + + if errorInfo != nil { + return errorInfo + } + + // remove entry rx map + publisher.rxLock.Lock() + + delete(publisher.publisherRxReplyMap, pubIndex) + + publisher.rxLock.Unlock() + + return nil +} + +func (publisher *ccsmpBackedPublisher) onReplyMessage(msgP Repliable, userP unsafe.Pointer, correlationID string) bool { + publisher.rxLock.RLock() + + entry, ok := publisher.publisherRxReplyMap[uintptr(userP)] + if !ok { + if logging.Default.IsDebugEnabled() { + logging.Default.Debug(fmt.Sprintf("reply callback called but no reply function is registered for user pointer %v", userP)) + } + return false + } + callback := entry.handler + publisher.rxLock.RUnlock() + return callback(msgP, correlationID) +} + +func (publisher *ccsmpBackedPublisher) startRequestor() error { + // get reply to prefix + var errInfo ErrorInfo + if publisher.replyToPrefix, errInfo = publisher.session.SolClientSessionGetP2PTopicPrefix(); errInfo != nil { + return fmt.Errorf("error get client p2p inbox topic prefix. Error:%v", errInfo.String()) + } + // register with session for reply message callback + err := publisher.session.SetReplyMessageCallback(func(msgP ccsmp.SolClientMessagePt, userP unsafe.Pointer, correlationID string) bool { + return publisher.onReplyMessage(msgP, userP, correlationID) + }) + return err +} + +func (publisher *ccsmpBackedPublisher) terminateRequestor() { + // first deregister from session for reply mesasge callbacks + publisher.session.SetReplyMessageCallback(nil) + + // cleanup the rx reply map + publisher.rxLock.Lock() + + // exact entry list to unsubscribe after mutx unlock + unsubIDList := make([]uint64, len(publisher.publisherRxReplyMap)) + unsubEntryList := make([]RequestorReplyCorrelationEntry, len(publisher.publisherRxReplyMap)) + index := 0 + + for id, entry := range publisher.publisherRxReplyMap { + unsubEntryList[index] = entry + unsubIDList[index] = uint64(id) + delete(publisher.publisherRxReplyMap, id) + index++ + } + + publisher.rxLock.Unlock() + + // call unsubscribe for any outstanding publisher reply to topics + // note this shuold only occur if there is no call to RemoveRequestorReplyHandler + for index = 0; index < len(unsubIDList); index++ { + dispatchID := ccsmp.NewSessionReplyDispatch(unsubIDList[index]) + publisher.session.SolClientSessionUnsubscribeReplyTopic(unsubEntryList[index].replyToTopic, dispatchID, 0) + } + +} + func (publisher *ccsmpBackedPublisher) Events() Events { return publisher.events } @@ -215,6 +407,7 @@ func (publisher *ccsmpBackedPublisher) start() { publisher.rejectedEventID = publisher.Events().AddEventHandler(SolClientEventRejected, func(ei SessionEventInfo) { publisher.onAcknowledgement(ei.GetCorrelationPointer(), false, ei.GetError()) }) + publisher.startRequestor() go publisher.publishLoop() } @@ -239,6 +432,8 @@ func (publisher *ccsmpBackedPublisher) terminate() { publisher.events.RemoveEventHandler(publisher.canSendEventID) publisher.events.RemoveEventHandler(publisher.acknowledgementEventID) publisher.events.RemoveEventHandler(publisher.rejectedEventID) + // cleanup requestor + publisher.terminateRequestor() } func (publisher *ccsmpBackedPublisher) publishLoop() { diff --git a/internal/impl/core/receiver.go b/internal/impl/core/receiver.go index d7e2925..fafd06f 100644 --- a/internal/impl/core/receiver.go +++ b/internal/impl/core/receiver.go @@ -37,10 +37,18 @@ type SubscriptionEvent interface { GetError() error } +// Replier interface +type Replier interface { + // SendReply will send a reply publishable + SendReply(replyMsg ReplyPublishable) ErrorInfo +} + // Receiver interface type Receiver interface { // checks if the internal receiver is running IsRunning() bool + // Replier returns SolClientReplier + Replier() Replier // Events returns SolClientEvents Events() Events // Register an RX callback, returns a correlation pointer used when adding and removing subscriptions @@ -85,6 +93,9 @@ type PersistentReceiver interface { // Receivable type defined type Receivable = ccsmp.SolClientMessagePt +// ReplyPublishable type defined +type ReplyPublishable = ccsmp.SolClientMessagePt + // MessageID type defined type MessageID = ccsmp.SolClientMessageID @@ -106,10 +117,9 @@ type ccsmpBackedReceiver struct { session *ccsmp.SolClientSession running int32 // TODO if performance becomes a concern, consider substituting maps and mutex for sync.Map - rxLock sync.RWMutex - rxMap map[uintptr]RxCallback - dispatchMap map[uintptr]*ccsmp.SolClientSessionRxMsgDispatchFuncInfo - dispatchID uint64 + rxLock sync.RWMutex + rxMap map[uintptr]RxCallback + dispatchID uint64 // subscriptionCorrelationLock sync.Mutex subscriptionCorrelation map[SubscriptionCorrelationID]chan SubscriptionEvent @@ -125,7 +135,6 @@ func newCcsmpReceiver(session *ccsmp.SolClientSession, events *ccsmpBackedEvents receiver.session = session receiver.running = 0 receiver.rxMap = make(map[uintptr]RxCallback) - receiver.dispatchMap = make(map[uintptr]*ccsmp.SolClientSessionRxMsgDispatchFuncInfo) receiver.dispatchID = 0 receiver.subscriptionCorrelation = make(map[SubscriptionCorrelationID]chan SubscriptionEvent) receiver.subscriptionCorrelationID = 0 @@ -166,6 +175,16 @@ func (receiver *ccsmpBackedReceiver) IsRunning() bool { return atomic.LoadInt32(&receiver.running) == 1 } +func (receiver *ccsmpBackedReceiver) Replier() Replier { + // use ccsmpBackedReceiver struct for now seperate later if needed + return receiver +} + +// Send the ReplyPublishable through the ccsmp session +func (receiver *ccsmpBackedReceiver) SendReply(replyMsg ReplyPublishable) ErrorInfo { + return receiver.session.SolClientSessionPublish(replyMsg) +} + func (receiver *ccsmpBackedReceiver) Events() Events { return receiver.events } @@ -185,19 +204,17 @@ func (receiver *ccsmpBackedReceiver) rxCallback(msg Receivable, userP unsafe.Poi // Register an RX callback, returns a correlation pointer used when adding and removing subscriptions func (receiver *ccsmpBackedReceiver) RegisterRXCallback(msgCallback RxCallback) uintptr { - dispatch, dispatchPointer := ccsmp.NewSessionDispatch(atomic.AddUint64(&receiver.dispatchID, 1)) + dispatchPointer := atomic.AddUint64(&receiver.dispatchID, 1) receiver.rxLock.Lock() defer receiver.rxLock.Unlock() - receiver.dispatchMap[dispatchPointer] = dispatch - receiver.rxMap[dispatchPointer] = msgCallback - return dispatchPointer + receiver.rxMap[uintptr(dispatchPointer)] = msgCallback + return uintptr(dispatchPointer) } // Remove the callback allowing GC to cleanup the function registered func (receiver *ccsmpBackedReceiver) UnregisterRXCallback(ptr uintptr) { receiver.rxLock.Lock() defer receiver.rxLock.Unlock() - delete(receiver.dispatchMap, ptr) delete(receiver.rxMap, ptr) } @@ -205,9 +222,8 @@ func (receiver *ccsmpBackedReceiver) UnregisterRXCallback(ptr uintptr) { func (receiver *ccsmpBackedReceiver) Subscribe(topic string, ptr uintptr) (SubscriptionCorrelationID, <-chan SubscriptionEvent, ErrorInfo) { receiver.rxLock.RLock() defer receiver.rxLock.RUnlock() - dispatch := receiver.dispatchMap[ptr] id, c := receiver.newSubscriptionCorrelation() - errInfo := receiver.session.SolClientSessionSubscribe(topic, dispatch, id) + errInfo := receiver.session.SolClientSessionSubscribe(topic, ptr, id) if errInfo != nil { receiver.ClearSubscriptionCorrelation(id) return 0, nil, errInfo @@ -219,9 +235,8 @@ func (receiver *ccsmpBackedReceiver) Subscribe(topic string, ptr uintptr) (Subsc func (receiver *ccsmpBackedReceiver) Unsubscribe(topic string, ptr uintptr) (SubscriptionCorrelationID, <-chan SubscriptionEvent, ErrorInfo) { receiver.rxLock.RLock() defer receiver.rxLock.RUnlock() - dispatch := receiver.dispatchMap[ptr] id, c := receiver.newSubscriptionCorrelation() - errInfo := receiver.session.SolClientSessionUnsubscribe(topic, dispatch, id) + errInfo := receiver.session.SolClientSessionUnsubscribe(topic, ptr, id) if errInfo != nil { receiver.ClearSubscriptionCorrelation(id) return 0, nil, errInfo diff --git a/internal/impl/message/inbound_message_impl.go b/internal/impl/message/inbound_message_impl.go index 8259da4..c407351 100644 --- a/internal/impl/message/inbound_message_impl.go +++ b/internal/impl/message/inbound_message_impl.go @@ -191,3 +191,15 @@ func GetMessageID(message *InboundMessageImpl) (MessageID, bool) { } return id, true } + +// GetReplyToDestinationName function +func GetReplyToDestinationName(message *InboundMessageImpl) (string, bool) { + destName, errorInfo := ccsmp.SolClientMessageGetReplyToDestinationName(message.messagePointer) + if errorInfo != nil { + if errorInfo.ReturnCode == ccsmp.SolClientReturnCodeFail { + logging.Default.Debug(fmt.Sprintf("Unable to retrieve the reply to destination this message was published to: %s, subcode: %d", errorInfo.GetMessageAsString(), errorInfo.SubCode)) + } + return destName, false + } + return destName, true +} diff --git a/internal/impl/message/outbound_message_impl.go b/internal/impl/message/outbound_message_impl.go index 5b700b6..0a8344d 100644 --- a/internal/impl/message/outbound_message_impl.go +++ b/internal/impl/message/outbound_message_impl.go @@ -122,6 +122,24 @@ func SetDestination(message *OutboundMessageImpl, destName string) error { return nil } +// SetReplyToDestination function +func SetReplyToDestination(message *OutboundMessageImpl, destName string) error { + err := ccsmp.SolClientMessageSetReplyToDestination(message.messagePointer, destName) + if err != nil { + return core.ToNativeError(err, "error setting replyTo destination: ") + } + return nil +} + +// SetCorrelationID function +func SetCorrelationID(message *OutboundMessageImpl, correlationID string) error { + err := ccsmp.SolClientMessageSetCorrelationID(message.messagePointer, correlationID) + if err != nil { + return core.ToNativeError(err, "error setting correlationID: ") + } + return nil +} + // SetAckImmediately function func SetAckImmediately(message *OutboundMessageImpl) error { err := ccsmp.SolClientMessageSetAckImmediately(message.messagePointer, true) @@ -131,6 +149,20 @@ func SetAckImmediately(message *OutboundMessageImpl) error { return nil } +// SetAsReplyMessage function +func SetAsReplyMessage(message *OutboundMessageImpl, replyToDestination string, correlationID string) error { + if err := SetDestination(message, replyToDestination); err != nil { + return err + } + if err := SetCorrelationID(message, correlationID); err != nil { + return err + } + if errInfo := ccsmp.SolClientMessageSetAsReply(message.messagePointer, true); errInfo != nil { + return core.ToNativeError(errInfo, "error setting as reply message header: ") + } + return nil +} + // GetOutboundMessagePointer function func GetOutboundMessagePointer(message *OutboundMessageImpl) ccsmp.SolClientMessagePt { if message == nil { diff --git a/internal/impl/messaging_service_impl.go b/internal/impl/messaging_service_impl.go index e6423cb..70a5f01 100644 --- a/internal/impl/messaging_service_impl.go +++ b/internal/impl/messaging_service_impl.go @@ -101,6 +101,10 @@ type messagingServiceImpl struct { reconnectAttemptEventHandlersMutex sync.Mutex } +type requestReplyServiceImpl struct { + messagingService *messagingServiceImpl +} + func newMessagingServiceImpl(logger logging.LogLevelLogger) *messagingServiceImpl { messagingService := &messagingServiceImpl{ state: messagingServiceStateNotConnected, @@ -483,6 +487,12 @@ func (service *messagingServiceImpl) Info() metrics.APIInfo { } } +func (service *messagingServiceImpl) RequestReply() solace.RequestReplyMessagingService { + return &requestReplyServiceImpl{ + messagingService: service, + } +} + func (service *messagingServiceImpl) String() string { return fmt.Sprintf("solace.MessagingService at %p", service) } @@ -495,6 +505,14 @@ func (service *messagingServiceImpl) getSubState() messagingServiceSubState { return atomic.LoadInt32(&service.activeSubState) } +func (service *requestReplyServiceImpl) CreateRequestReplyMessagePublisherBuilder() solace.RequestReplyMessagePublisherBuilder { + return publisher.NewRequestReplyMessagePublisherBuilderImpl(service.messagingService.transport.Publisher()) +} + +func (service *requestReplyServiceImpl) CreateRequestReplyMessageReceiverBuilder() solace.RequestReplyMessageReceiverBuilder { + return receiver.NewRequestReplyMessageReceiverBuilderImpl(service.messagingService.transport.Receiver()) +} + type apiInfo struct { buildDate, version, vendor, userID string } diff --git a/internal/impl/publisher/message_publisher_impl_test.go b/internal/impl/publisher/message_publisher_impl_test.go index 01936ee..aea2af4 100644 --- a/internal/impl/publisher/message_publisher_impl_test.go +++ b/internal/impl/publisher/message_publisher_impl_test.go @@ -298,6 +298,7 @@ type result struct { type mockInternalPublisher struct { publish func(message core.Publishable) core.ErrorInfo events func() core.Events + requestor func() core.Requestor awaitWritable func(terminateSignal chan struct{}) error taskQueue func() chan core.SendTask isRunning func() bool @@ -320,6 +321,13 @@ func (mock *mockInternalPublisher) Events() core.Events { return &mockEvents{} } +func (mock *mockInternalPublisher) Requestor() core.Requestor { + if mock.requestor != nil { + return mock.requestor() + } + return &mockRequestor{} +} + func (mock *mockInternalPublisher) AwaitWritable(terminateSignal chan struct{}) error { if mock.awaitWritable != nil { return mock.awaitWritable(terminateSignal) @@ -375,3 +383,32 @@ func (events *mockEvents) AddEventHandler(sessionEvent core.Event, responseCode func (events *mockEvents) RemoveEventHandler(id uint) { } + +type mockRequestor struct { + createReplyToTopic func(publisherID string) string + addRequestorReplyHandler func(replyHandler core.RequestorReplyHandler) (string, func() (messageID uint64, correlationId string), core.ErrorInfo) + removeRequestorReplyHandler func(replyToTopic string) core.ErrorInfo +} + +func (requestor *mockRequestor) CreateReplyToTopic(publisherID string) string { + if requestor.createReplyToTopic != nil { + return requestor.createReplyToTopic(publisherID) + } + return "" +} + +func (requestor *mockRequestor) AddRequestorReplyHandler(replyHandler core.RequestorReplyHandler) (string, func() (messageID uint64, correlationID string), core.ErrorInfo) { + if requestor.addRequestorReplyHandler != nil { + return requestor.addRequestorReplyHandler(replyHandler) + } + return "", func() (uint64, string) { + return uint64(0), "" + }, nil +} + +func (requestor *mockRequestor) RemoveRequestorReplyHandler(replyToTopic string) core.ErrorInfo { + if requestor.removeRequestorReplyHandler != nil { + return requestor.removeRequestorReplyHandler(replyToTopic) + } + return nil +} diff --git a/internal/impl/publisher/request_reply_message_publisher_impl.go b/internal/impl/publisher/request_reply_message_publisher_impl.go new file mode 100644 index 0000000..3231d6d --- /dev/null +++ b/internal/impl/publisher/request_reply_message_publisher_impl.go @@ -0,0 +1,960 @@ +// pubsubplus-go-client +// +// Copyright 2024 Solace Corporation. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package publisher is defined below +package publisher + +import ( + "fmt" + "runtime/debug" + "sync" + "time" + + "solace.dev/go/messaging/internal/impl/constants" + "solace.dev/go/messaging/internal/impl/executor" + + "solace.dev/go/messaging/internal/impl/publisher/buffer" + + "solace.dev/go/messaging/internal/impl/logging" + + "solace.dev/go/messaging/internal/ccsmp" + + "solace.dev/go/messaging/internal/impl/core" + "solace.dev/go/messaging/internal/impl/message" + + "solace.dev/go/messaging/pkg/solace" + "solace.dev/go/messaging/pkg/solace/config" + apimessage "solace.dev/go/messaging/pkg/solace/message" + "solace.dev/go/messaging/pkg/solace/resource" +) + +type correlationEntryImpl struct { + userContext interface{} + handler solace.ReplyMessageHandler + timeout time.Duration + received bool + result chan core.Repliable + sentChan chan error +} + +type CorrelationEntry = *correlationEntryImpl + +type ReplyOutcome = func() (apimessage.InboundMessage, error) + +type requestReplyMessagePublisherImpl struct { + basicMessagePublisher + logger logging.LogLevelLogger + + downEventHandlerID uint + canSendEventHandlerID uint + + // the parameters for backpressure + backpressureConfiguration backpressureConfiguration + // buffers for backpressure + buffer chan *publishable + taskBuffer buffer.PublisherTaskBuffer + bufferPublishLock sync.Mutex + + // correlation management + rxLock sync.Mutex + requestCorrelationMap map[string]CorrelationEntry + nextCorrelationID func() (uint64, string) + correlationComplete chan struct{} + requestCorrelateComplete chan struct{} + + // replyto subcription management + replyToTopic string + + terminateWaitInterrupt chan struct{} +} + +func (publisher *requestReplyMessagePublisherImpl) construct(internalPublisher core.Publisher, backpressureConfig backpressureConfiguration, bufferSize int) { + publisher.basicMessagePublisher.construct(internalPublisher) + publisher.replyToTopic = "" + publisher.requestCorrelationMap = make(map[string]CorrelationEntry) + publisher.backpressureConfiguration = backpressureConfig + if publisher.backpressureConfiguration != backpressureConfigurationDirect { + // allocate buffers + publisher.buffer = make(chan *publishable, bufferSize) + publisher.taskBuffer = buffer.NewChannelBasedPublisherTaskBuffer(bufferSize, publisher.internalPublisher.TaskQueue) + } + publisher.terminateWaitInterrupt = make(chan struct{}) + publisher.correlationComplete = make(chan struct{}) + publisher.requestCorrelateComplete = make(chan struct{}) + publisher.logger = logging.For(publisher) +} + +func (publisher *requestReplyMessagePublisherImpl) onDownEvent(eventInfo core.SessionEventInfo) { + go publisher.unsolicitedTermination(eventInfo) +} + +func (publisher *requestReplyMessagePublisherImpl) onCanSend(eventInfo core.SessionEventInfo) { + // We want to offload from the context thread whenever possible, thus we will pass this + // task off to a new goroutine. This should be sufficient as you are guaranteed to get the + // can send, it is just not immediate. + go publisher.notifyReady() +} + +// Start will start the service synchronously. +// Before this function is called, the service is considered +// off-duty. To operate normally, this function must be called on +// a receiver or publisher instance. This function is idempotent. +// Returns an error if one occurred or nil if successful. +func (publisher *requestReplyMessagePublisherImpl) Start() (err error) { + // this will block until we are started if we are not first + if proceed, err := publisher.starting(); !proceed { + return err + } + publisher.logger.Debug("Start direct publisher start") + defer func() { + if err == nil { + publisher.started(err) + publisher.logger.Debug("Start publisher complete") + } else { + publisher.logger.Debug("Start complete with error: " + err.Error()) + publisher.internalPublisher.Events().RemoveEventHandler(publisher.downEventHandlerID) + publisher.internalPublisher.Events().RemoveEventHandler(publisher.canSendEventHandlerID) + publisher.terminated(nil) + publisher.startFuture.Complete(err) + } + }() + publisher.downEventHandlerID = publisher.internalPublisher.Events().AddEventHandler(core.SolClientEventDown, publisher.onDownEvent) + // startup functionality + if publisher.backpressureConfiguration != backpressureConfigurationDirect { + go publisher.taskBuffer.Run() + } else { + // if we are direct, we want to register to receive can send events + publisher.canSendEventHandlerID = publisher.internalPublisher.Events().AddEventHandler(core.SolClientEventCanSend, publisher.onCanSend) + } + var errorInfo core.ErrorInfo + publisher.replyToTopic, publisher.nextCorrelationID, errorInfo = publisher.internalPublisher.Requestor().AddRequestorReplyHandler(func(msg core.Repliable, correlationId string) bool { + return publisher.handleReplyMessage(msg, correlationId) + }) + if errorInfo != nil { + return core.ToNativeError(errorInfo, "encountered error while adding publisher reply message callback: ") + } + go publisher.eventExecutor.Run() + return nil +} + +// StartAsync will start the service asynchronously. +// Before this function is called, the service is considered +// off-duty. To operate normally, this function must be called on +// a receiver or publisher instance. This function is idempotent. +// Returns a channel that will receive an error if one occurred or +// nil if successful. Subsequent calls will return additional +// channels that can await an error, or nil if already started. +func (publisher *requestReplyMessagePublisherImpl) StartAsync() <-chan error { + result := make(chan error, 1) + go func() { + result <- publisher.Start() + close(result) + }() + return result +} + +// StartAsyncCallback will start the RequestReplyMessagePublisher asynchronously. +// Calls the callback when started with an error if one occurred or nil +// if successful. +func (publisher *requestReplyMessagePublisherImpl) StartAsyncCallback(callback func(solace.RequestReplyMessagePublisher, error)) { + go func() { + callback(publisher, publisher.Start()) + }() +} + +// Terminate will terminate the service gracefully and synchronously. +// This function is idempotent. The only way to resume operation +// after this function is called is to create a new instance. +// Any attempt to call this function renders the instance +// permanently terminated, even if this function completes. +// A graceful shutdown will be attempted within the grace period. +// A grace period of 0 implies a non-graceful shutdown that ignores +// unfinished tasks or in-flight messages. +// This function blocks until the service is terminated. +// If gracePeriod is less than 0, the function will wait indefinitely. +func (publisher *requestReplyMessagePublisherImpl) Terminate(gracePeriod time.Duration) (err error) { + if proceed, err := publisher.terminate(); !proceed { + return err + } + publisher.logger.Debug("Terminate direct publisher start") + // make sure the service is marked as terminated + defer func() { + publisher.terminated(err) + if err != nil { + publisher.logger.Debug("Terminate complete with error: " + err.Error()) + } else { + publisher.logger.Debug("Terminate complete") + } + }() + + defer func() { + publisher.logger.Debug("Awaiting termination of event executor") + // Allow the event executor to terminate, blocking until it does + publisher.eventExecutor.AwaitTermination() + }() + + // We're terminating, we do not care about the down event handler anymore + publisher.internalPublisher.Events().RemoveEventHandler(publisher.downEventHandlerID) + + // create timer if needed + var timer *time.Timer + if gracePeriod >= 0 { + timer = time.NewTimer(gracePeriod) + } + + // handle graceful shutdown + graceful := true + + publisher.logger.Debug("Have buffered backpressure, terminating the task buffer gracefully") + + if publisher.backpressureConfiguration != backpressureConfigurationDirect { + // First we interrupt all backpressure wait functions + close(publisher.terminateWaitInterrupt) + graceful = publisher.taskBuffer.Terminate(timer) + } else { + publisher.internalPublisher.Events().RemoveEventHandler(publisher.canSendEventHandlerID) + } + + // wait for the task buffer to drain + if !graceful { + publisher.logger.Debug("Task buffer terminated ungracefully, will not wait for acknowledgements to be processed") + } else { + publisher.logger.Debug("Waiting for correlation table to drain") + // wait for request reply outcomes + publisher.rxLock.Lock() + outstandingReplies := len(publisher.requestCorrelationMap) + close(publisher.requestCorrelateComplete) + publisher.rxLock.Unlock() + if outstandingReplies > 0 { + if timer != nil { + select { + case <-publisher.correlationComplete: + // success + case <-timer.C: + graceful = false + } + } else { + // Block forever as our grace period is negative + <-publisher.correlationComplete + } + } + } + // remove reply handler for publisher + publisher.internalPublisher.Requestor().RemoveRequestorReplyHandler(publisher.replyToTopic) + + // close the buffer, failing any publishes + // this must happen before counting the remaining publish message and replies + + publisherTerminatedError := solace.NewError(&solace.IncompleteMessageDeliveryError{}, constants.UnableToPublishAlreadyTerminated, nil) + var undeliveredCount uint64 = 0 + if publisher.backpressureConfiguration != backpressureConfigurationDirect { + // only drain the queue if we are in buffered backpressure scenarios + undeliveredCount = publisher.drainQueue(time.Now(), publisherTerminatedError) + } + + // close correlation channel + publisher.rxLock.Lock() + select { + case <-publisher.correlationComplete: + //already closed + default: + // close channel + close(publisher.correlationComplete) + } + // unblock all correlation for drain + + for _, entry := range publisher.requestCorrelationMap { + // close sent channel signal + select { + case <-entry.sentChan: + // already closed + default: + close(entry.sentChan) + } + // close result channel + close(entry.result) + } + publisher.rxLock.Unlock() + + // block until all replies are complete + <-publisher.correlationComplete + + if undeliveredCount > 0 { + // return an error if we have one + publisher.internalPublisher.IncrementMetric(core.MetricPublishMessagesTerminationDiscarded, uint64(undeliveredCount)) + err := solace.NewError(&solace.IncompleteMessageDeliveryError{}, fmt.Sprintf(constants.IncompleteMessageDeliveryMessage, undeliveredCount), nil) + return err + } + + // finish cleanup successfully + return nil +} + +func (publisher *requestReplyMessagePublisherImpl) unsolicitedTermination(errorInfo core.SessionEventInfo) { + if proceed, _ := publisher.terminate(); !proceed { + return + } + if publisher.logger.IsDebugEnabled() { + publisher.logger.Debug("Received unsolicited termination with event info " + errorInfo.GetInfoString()) + defer publisher.logger.Debug("Unsolicited termination complete") + } + timestamp := time.Now() + publisher.internalPublisher.Events().RemoveEventHandler(publisher.downEventHandlerID) + + var err error = nil + if publisher.backpressureConfiguration != backpressureConfigurationDirect { + close(publisher.terminateWaitInterrupt) + // Close the task buffer without waiting for any more tasks to be processed + publisher.taskBuffer.TerminateNow() + // check that all messages have been delivered, and return an error if they have not been + undeliveredCount := publisher.drainQueue(timestamp, errorInfo.GetError()) + if undeliveredCount > 0 { + if publisher.logger.IsDebugEnabled() { + publisher.logger.Debug(fmt.Sprintf("Terminated with %d undelivered messages", undeliveredCount)) + } + // return an error if we have one + err = solace.NewError(&solace.IncompleteMessageDeliveryError{}, fmt.Sprintf(constants.IncompleteMessageDeliveryMessage, undeliveredCount), nil) + publisher.internalPublisher.IncrementMetric(core.MetricPublishMessagesTerminationDiscarded, undeliveredCount) + } + } else { + publisher.internalPublisher.Events().RemoveEventHandler(publisher.canSendEventHandlerID) + } + // Terminate the event executor without waiting for the termination to complete + publisher.eventExecutor.Terminate() + publisher.terminated(err) + // Call the callback + if publisher.terminationListener != nil { + publisher.terminationListener(&publisherTerminationEvent{ + timestamp, + errorInfo.GetError(), + }) + } +} + +// TerminateAsync will terminate the service asynchronously. +// This function is idempotent. The only way to resume operation +// after this function is called is to create a new instance. +// Any attempt to call this function renders the instance +// permanently terminated, even if this function completes. +// A graceful shutdown will be attempted within the grace period. +// A grace period of 0 implies a non-graceful shutdown that ignores +// unfinished tasks or in-flight messages. +// Returns a channel that will receive an error if one occurred or +// nil if successfully and gracefully terminated. +// If gracePeriod is less than 0, the function will wait indefinitely. +func (publisher *requestReplyMessagePublisherImpl) TerminateAsync(gracePeriod time.Duration) <-chan error { + result := make(chan error, 1) + go func() { + result <- publisher.Terminate(gracePeriod) + close(result) + }() + return result +} + +// TerminateAsyncCallback will terminate the RequestReplyMessagePublisher asynchronously. +// Calls the callback when terminated with nil if successful or an error if +// one occurred. If gracePeriod is less than 0, the function will wait indefinitely. +func (publisher *requestReplyMessagePublisherImpl) TerminateAsyncCallback(gracePeriod time.Duration, callback func(error)) { + go func() { + callback(publisher.Terminate(gracePeriod)) + }() +} + +// IsReady checks if the publisher can publish messages. Returns true if the +// publisher can publish messages, false if the publisher is presvented from +// sending messages (e.g., full buffer or I/O problems) +func (publisher *requestReplyMessagePublisherImpl) IsReady() bool { + return publisher.IsRunning() && (publisher.backpressureConfiguration != backpressureConfigurationReject || len(publisher.buffer) != cap(publisher.buffer)) +} + +// NotifyWhenReady makes a request to notify the application when the +// publisher is ready. This function will block until the publisher +// is ready. +func (publisher *requestReplyMessagePublisherImpl) NotifyWhenReady() { + if publisher.IsReady() { + publisher.notifyReady() + } +} + +// queues a new ready event on the event executor +func (publisher *requestReplyMessagePublisherImpl) notifyReady() { + readinessListener := publisher.readinessListener + if readinessListener != nil { + publisher.eventExecutor.Submit(executor.Task(readinessListener)) + } +} + +// drainQueue will drain the message buffer and return the number of undelivered messages. For each remaining correlation entry push out replyHandler callback +func (publisher *requestReplyMessagePublisherImpl) drainQueue(shutdownTime time.Time, err error) uint64 { + close(publisher.buffer) + undeliveredCount := uint64(0) + for undelivered := range publisher.buffer { + underliveredRef := undelivered + undeliveredCount++ + // handle each unsent request + correlationID, ok := underliveredRef.message.GetCorrelationID() + if ok { + publisher.signalRequestCorrelationSent(correlationID, err) + } else { + publisher.logger.Info(fmt.Sprintf("Failed to unblock unsent publish %p without correlationID, is the publisher terminated?", underliveredRef.message)) + } + } + return undeliveredCount +} + +// PublishBytes will publish a message of type byte array to the given destination. +// Returns an error if one occurred while attempting to publish or if the publisher +// is not started/terminated. Returns an error if one occurred. Possible errors include +// - solace/solace.*PubSubPlusClientError if the message could not be sent and all retry attempts failed. +// - solace/solace.*PublisherOverflowError if publishing messages faster than publisher's I/O +// capabilities allow. When publishing can be resumed, registered PublisherReadinessListeners +// will be called. +func (publisher *requestReplyMessagePublisherImpl) PublishBytes(bytes []byte, replyMessageHandler solace.ReplyMessageHandler, dest *resource.Topic, replyTimeout time.Duration, userContext interface{}) error { + msg, err := publisher.messageBuilder.BuildWithByteArrayPayload(bytes) + if err != nil { + return err + } + // we built the message so it is safe to cast + outcomeHandler, err := publisher.publishAsync(msg.(*message.OutboundMessageImpl), replyMessageHandler, dest, replyTimeout, userContext) + if err != nil { + return err + } + go outcomeHandler() + return nil +} + +// PublishString will publish a message of type string to the given destination. +// Returns an error if one occurred. Possible errors include: +// - solace/solace.*PubSubPlusClientError if the message could not be sent and all retry attempts failed. +// - solace/solace.*PublisherOverflowError if publishing messages faster than publisher's I/O +// capabilities allow. When publishing can be resumed, registered PublisherReadinessListeners +// will be called. +func (publisher *requestReplyMessagePublisherImpl) PublishString(str string, replyMessageHandler solace.ReplyMessageHandler, dest *resource.Topic, replyTimeout time.Duration, userContext interface{}) error { + msg, err := publisher.messageBuilder.BuildWithStringPayload(str) + if err != nil { + return err + } + // we built the message so it is safe to cast + outcomeHandler, err := publisher.publishAsync(msg.(*message.OutboundMessageImpl), replyMessageHandler, dest, replyTimeout, userContext) + if err != nil { + return err + } + go outcomeHandler() + return nil +} + +// Publish will publish the given message of type OutboundMessage +// with the given properties. These properties will override the properties on +// the OutboundMessage instance if present. Possible errors include: +// - solace/solace.*PubSubPlusClientError if the message could not be sent and all retry attempts failed. +// - solace/solace.*PublisherOverflowError if publishing messages faster than publisher's I/O +// capabilities allow. When publishing can be resumed, registered PublisherReadinessListeners +// will be called. +func (publisher *requestReplyMessagePublisherImpl) Publish(msg apimessage.OutboundMessage, replyMessageHandler solace.ReplyMessageHandler, dest *resource.Topic, replyTimeout time.Duration, properties config.MessagePropertiesConfigurationProvider, userContext interface{}) error { + if err := publisher.checkStartedStateForPublish(); err != nil { + return err + } + msgImpl, ok := msg.(*message.OutboundMessageImpl) + if !ok { + return solace.NewError(&solace.IllegalArgumentError{}, fmt.Sprintf(constants.InvalidOutboundMessageType, msg), nil) + } + msgDup, err := message.DuplicateOutboundMessage(msgImpl) + if err != nil { + return err + } + if properties != nil { + err := message.SetProperties(msgDup, properties.GetConfiguration()) + if err != nil { + msgDup.Dispose() + return err + } + } + outcomeHandler, err := publisher.publishAsync(msgDup, replyMessageHandler, dest, replyTimeout, userContext) + if err != nil { + return err + } + go outcomeHandler() + return nil +} + +func (publisher *requestReplyMessagePublisherImpl) PublishAwaitResponse(msg apimessage.OutboundMessage, dest *resource.Topic, replyTimeout time.Duration, properties config.MessagePropertiesConfigurationProvider) (apimessage.InboundMessage, error) { + // stub for now + // need to work out usage with call to publisher.publish + if err := publisher.checkStartedStateForPublish(); err != nil { + return nil, err + } + msgImpl, ok := msg.(*message.OutboundMessageImpl) + if !ok { + return nil, solace.NewError(&solace.IllegalArgumentError{}, fmt.Sprintf(constants.InvalidOutboundMessageType, msg), nil) + } + msgDup, err := message.DuplicateOutboundMessage(msgImpl) + if err != nil { + return nil, err + } + if properties != nil { + err := message.SetProperties(msgDup, properties.GetConfiguration()) + if err != nil { + msgDup.Dispose() + return nil, err + } + } + outcomeHandler, err := publisher.publish(msgDup, nil, dest, replyTimeout, nil) + if err != nil { + return nil, err + } + return outcomeHandler() +} + +func (publisher *requestReplyMessagePublisherImpl) publishAsync(msg *message.OutboundMessageImpl, replyMessageHandler solace.ReplyMessageHandler, dest *resource.Topic, replyTimeout time.Duration, userContext interface{}) (retOutcome ReplyOutcome, ret error) { + if replyMessageHandler == nil { + err := solace.NewError(&solace.IllegalArgumentError{}, constants.MissingReplyMessageHandler, nil) + return nil, err + } + return publisher.publish(msg, replyMessageHandler, dest, replyTimeout, userContext) +} + +// publish impl taking a dup'd message, assuming state has been checked and we are running +func (publisher *requestReplyMessagePublisherImpl) publish(msg *message.OutboundMessageImpl, replyMessageHandler solace.ReplyMessageHandler, dest *resource.Topic, replyTimeout time.Duration, userContext interface{}) (retOutcome ReplyOutcome, ret error) { + // There is a potential race condition in this function in buffered scenarios whereby a message is pushed into backpressure + // after the publisher has moved from Started to Terminated if the routine is interrupted after the state check and not resumed + // until much much later. Therefore, it may be possible for a message to get into the publisher buffers but not actually + // be put out to the wire as the publisher's task buffer may shut down immediately after. This would result in an unpublished + // message that was submitted to publish successfully. In reality, this condition's window is so rediculously tiny that it + // can be considered a non-problem. Also (at the time of writing) this race condition is present in all other next-gen APIs. + + // Set the destination for the message which is assumed to be a dup'd message. + err := message.SetDestination(msg, dest.GetName()) + if err != nil { + msg.Dispose() + return nil, err + } + + // check the state once more before moving into the publish paths + if err := publisher.checkStartedStateForPublish(); err != nil { + return nil, err + } + + // set the message replyTo destination + if err := message.SetReplyToDestination(msg, publisher.replyToTopic); err != nil { + msg.Dispose() + return nil, err + } + + // under lock generate correlation information and store in management struct + correlationID, replyOutcome := publisher.createReplyCorrelation(userContext, replyTimeout, replyMessageHandler) + defer func() { + if ret != nil { + publisher.closeReplyCorrelation(correlationID) + retOutcome = nil + } + }() + + // set the message correlation id + if err := message.SetCorrelationID(msg, correlationID); err != nil { + msg.Dispose() + return nil, err + } + + // handle publish through back pressure + if publisher.backpressureConfiguration == backpressureConfigurationDirect { + defer msg.Dispose() + // publish directly with CCSMP + errorInfo := publisher.internalPublisher.Publish(message.GetOutboundMessagePointer(msg)) + if errorInfo != nil { + publisher.signalRequestCorrelationSent(correlationID, core.ToNativeError(errorInfo, "encountered error while publishing message: ")) + if errorInfo.ReturnCode == ccsmp.SolClientReturnCodeWouldBlock { + return nil, solace.NewError(&solace.PublisherOverflowError{}, constants.WouldBlock, nil) + } + return nil, core.ToNativeError(errorInfo) + } else { + publisher.signalRequestCorrelationSent(correlationID, nil) + } + } else { + // buffered backpressure scenarios + + // this section is to handle the case where a publish proceeds after we have moved to terminating, specifically + // in the ungraceful termination case, and we have decided that no more messages should be published, thus the + // message queue is closed. The window for this race is very small, but it is still worth handling. + channelWrite := false + defer func() { + if !channelWrite { + // we have not written to the channel yet, we may or may not have received panic, so check + if r := recover(); r != nil { + // if we have a panic, and that panic is send on closed channel, we can return an error by setting "ret", otherwise repanic + if err, ok := r.(error); ok && err.Error() == "send on closed channel" { + publisher.logger.Debug("Caught a channel closed panic when trying to write to the message buffer, publisher must be terminated.") + ret = solace.NewError(&solace.IllegalStateError{}, constants.UnableToPublishAlreadyTerminated, nil) + retOutcome = nil + } else { + // this shouldn't ever happen, but panics are unpredictable. We want this message to make it into the logs + publisher.logger.Error(fmt.Sprintf("Experienced panic while attempting to publish a message: %s", err)) + panic(r) + } + } + } + }() + publisher.bufferPublishLock.Lock() + defer publisher.bufferPublishLock.Unlock() + pub := &publishable{msg, dest} + if publisher.backpressureConfiguration == backpressureConfigurationReject { + select { + case publisher.buffer <- pub: + channelWrite = true // we successfully wrote the message to the channel + default: + return nil, solace.NewError(&solace.PublisherOverflowError{}, constants.WouldBlock, nil) + } + } else { + // wait forever + select { + case publisher.buffer <- pub: + channelWrite = true // we successfully wrote the message to the channel + case <-publisher.terminateWaitInterrupt: + return nil, solace.NewError(&solace.IllegalStateError{}, constants.UnableToPublishAlreadyTerminated, nil) + } + } + // if we successfully wrote to the channel (which should always be true at this point), submit the task and terminate. + if !publisher.taskBuffer.Submit(publisher.sendTask(msg, dest, correlationID)) { + // if we couldn't submit the task, log. This may happen on shutdown in a race between the task buffer shutting down + // and the message buffer being drained, at which point we are terminating ungracefully. + publisher.logger.Debug("Attempted to submit the message for publishing, but the task buffer rejected the task! Has the service been terminated?") + // At this point, we have a message that made it into the buffer but the task did not get submitted. + // This message will be counted as "not delivered" when terminate completes. + // This is very unlikely as the message buffer is closed much earlier than the task buffer, + // so this window is very small. It is best to handle this when we can though. + } + } + return replyOutcome, nil +} + +// sendTask represents the task that is submitted to the internal task buffer and ultimately the shared serialized publisher instance +// returned closure accepts a channel that will receive a notification when any waits should be interrupted +func (publisher *requestReplyMessagePublisherImpl) sendTask(msg *message.OutboundMessageImpl, dest resource.Destination, correlationID string) buffer.PublisherTask { + return func(terminateChannel chan struct{}) { + var errorInfo core.ErrorInfo + // main publish loop + for { + // attempt a publish + errorInfo = publisher.internalPublisher.Publish(message.GetOutboundMessagePointer(msg)) + if errorInfo != nil { + // if we got a would block, wait for ready and retry + if errorInfo.ReturnCode == ccsmp.SolClientReturnCodeWouldBlock { + err := publisher.internalPublisher.AwaitWritable(terminateChannel) + if err != nil { + // if we encountered an error while waiting for writable, the publisher will shut down + // and this task will not complete. The message queue will be drained by the caller of + // terminate, so we should not deal with the message. + publisher.signalRequestCorrelationSent(correlationID, err) + return + } + continue + // otherwise we got another error, should deal with it accordingly + } + publisher.signalRequestCorrelationSent(correlationID, core.ToNativeError(errorInfo, "encountered error while publishing message: ")) + } else { + // if there is no errorInfo then the message was sent. + publisher.signalRequestCorrelationSent(correlationID, nil) + } + // exit out of the loop if we succeeded or got an error + // we will only continue on would_block + AwaitWritable + break + } + isFull := len(publisher.buffer) == cap(publisher.buffer) + // remove msg from buffer, should be guaranteed to be there, but we don't want to deadlock in case something went wonky. + // shutdown is contingent on all active tasks completing. + select { + case pub, ok := <-publisher.buffer: + if ok { + // from the pub get the correlation id + // extract the correlation management state from the publisher with the correlation id + // This must be protected from access as the transport thread can update independently + // Only if we were the ones to drain the message from the buffer should we call the replyhandler from the correlation management if there is a callback + if errorInfo != nil /* && requestHandler != nil */ { + // Only if we were the ones to drain the message from the buffer should we call the the request handler + // if we call the replyhandler with an error, we should not dispose of the message + if !publisher.eventExecutor.Submit(func() { /* call request specific reply handler */ }) && + publisher.logger.IsInfoEnabled() { + publisher.logger.Info(fmt.Sprintf("Failed to submit publish reply handler callback for correlation %v. Is the publisher terminated?", correlationID)) + } + } else { + // clean up the message, we are finished with it in the direct messaging case + // slightly more efficient to dispose of the message than let GC clean it up + pub.message.Dispose() + } + // check if we should signal that the buffer has space + // we only have to call the publisher notification of being ready when we + // have successfully popped a message off the buffer + if isFull && publisher.backpressureConfiguration == backpressureConfigurationReject { + publisher.notifyReady() + } + + } + // We must have a closed buffer with no more messages. Since the buffer was closed, we can safely ignore the message. + default: + // should never happen as the message queue should always be drained after + publisher.logger.Error("published a message after publisher buffer was drained, this is unexpected") + } + } +} + +func (entry CorrelationEntry) construct(userContext interface{}, timeout time.Duration, handler solace.ReplyMessageHandler) { + entry.userContext = userContext + entry.timeout = timeout + entry.handler = handler + entry.received = false + entry.result = make(chan core.Repliable, 1) + entry.sentChan = make(chan error, 1) +} + +func (publisher *requestReplyMessagePublisherImpl) closeReplyCorrelation(correlationID string) { + publisher.rxLock.Lock() + defer publisher.rxLock.Unlock() + + entry, ok := publisher.requestCorrelationMap[correlationID] + if !ok { + return + } + delete(publisher.requestCorrelationMap, correlationID) + // handle signal for emptied CorrelationMap under lock + + // close channels + select { + case <-entry.result: + // already closed + default: + close(entry.result) + } + select { + case <-entry.sentChan: + // already closed + default: + close(entry.sentChan) + } + + // measure correlation table and signal 0 if needed + select { + case <-publisher.requestCorrelateComplete: + if len(publisher.requestCorrelationMap) == 0 { + select { + case <-publisher.correlationComplete: + // already closed + default: + // close the channel + close(publisher.correlationComplete) + } + } + default: + // termination not called do nothing + } +} + +func (publisher *requestReplyMessagePublisherImpl) signalRequestCorrelationSent(correlationID string, sentErr error) { + publisher.rxLock.Lock() + defer publisher.rxLock.Unlock() + entry, ok := publisher.requestCorrelationMap[correlationID] + if !ok { + return + } + entry.sentChan <- sentErr +} + +func (publisher *requestReplyMessagePublisherImpl) createReplyCorrelation(userContext interface{}, timeout time.Duration, handler solace.ReplyMessageHandler) (string, func() (apimessage.InboundMessage, error)) { + publisher.rxLock.Lock() + defer publisher.rxLock.Unlock() + // create correlation id + _, correlationID := publisher.nextCorrelationID() + + // create entry for id + entry := &correlationEntryImpl{} + entry.construct(userContext, timeout, handler) + + publisher.requestCorrelationMap[correlationID] = entry + + // return closure function that blocks until result or timeout or pulbisher invalid state + + return correlationID, func() (retMsg apimessage.InboundMessage, retErr error) { + retErr = nil + var ok bool = true + var sentErr error = nil + // wait for request send + select { + case sentErr, ok = <-entry.sentChan: + if !ok { + sentErr = solace.NewError(&solace.IllegalStateError{}, constants.RequestReplyPublisherCannotReceiveReplyAlreadyTerminated, nil) + } + case <-publisher.correlationComplete: + sentErr = solace.NewError(&solace.IllegalStateError{}, constants.RequestReplyPublisherCannotReceiveReplyAlreadyTerminated, nil) + } + + // if not sent dispatch outcome of reply + if sentErr != nil { + retErr = sentErr + goto DispatchOutcome + } + + if entry.timeout >= 0 { + timer := time.NewTimer(timeout) + select { + case msgP, ok := <-entry.result: + timer.Stop() + if ok { + retMsg = message.NewInboundMessage(msgP, false) + } else { + retErr = solace.NewError(&solace.IllegalStateError{}, constants.RequestReplyPublisherCannotReceiveReplyAlreadyTerminated, nil) + } + case <-timer.C: + retErr = solace.NewError(&solace.TimeoutError{}, constants.RequestReplyPublisherTimedOutWaitingForReply, nil) + case <-publisher.correlationComplete: + timer.Stop() + retErr = solace.NewError(&solace.IllegalStateError{}, constants.RequestReplyPublisherCannotReceiveReplyAlreadyTerminated, nil) + } + } else { + // timeout < 0 blocks forever + select { + case msgP, ok := <-entry.result: + //success + if ok { + retMsg = message.NewInboundMessage(msgP, false) + } else { + retErr = solace.NewError(&solace.IllegalStateError{}, constants.RequestReplyPublisherCannotReceiveReplyAlreadyTerminated, nil) + } + case <-publisher.correlationComplete: + retErr = solace.NewError(&solace.IllegalStateError{}, constants.RequestReplyPublisherCannotReceiveReplyAlreadyTerminated, nil) + } + } + // only check correlation if there is no error + if retErr == nil { + // check if the correlation is tracked in publisher + publisher.rxLock.Lock() + _, ok := publisher.requestCorrelationMap[correlationID] + if !ok { + publisher.logger.Debug(fmt.Sprintf("Got request reply result with no correlation entry for correlation id '%s'.", correlationID)) + retErr = solace.NewError(&solace.IllegalStateError{}, constants.RequestReplyPublisherCannotReceiveReplyAlreadyTerminated, nil) + if retMsg != nil { + retMsg.Dispose() + retMsg = nil + } + } + publisher.rxLock.Unlock() + } + DispatchOutcome: + if entry.handler != nil { + // ReplyHanlder callback + entry.handler(retMsg, entry.userContext, retErr) + } + // remove correlation + publisher.closeReplyCorrelation(correlationID) + return retMsg, retErr + } +} + +func (publisher *requestReplyMessagePublisherImpl) handleReplyMessage(msgP core.Repliable, correlationID string) (ret bool) { + defer func() { + if r := recover(); r != nil { + // there can be a race between pushing the reply to result channel + if err, rok := r.(error); rok && err.Error() == "send on closed channel" { + publisher.logger.Debug("Caught a channel closed panic when trying to write to the result channel, request must have been cancelled or timed out") + } else { + // this shouldn't ever happen, but panics are unpredictable. We want this message to make it into the logs + publisher.logger.Error(fmt.Sprintf("Caught panic in reply message callback! %s\n%s", err, string(debug.Stack()))) + } + ret = false + } + }() + publisher.rxLock.Lock() + defer publisher.rxLock.Unlock() + corEntry, ok := publisher.requestCorrelationMap[correlationID] + if !ok { + publisher.logger.Debug(fmt.Sprintf("Received reply message[%p] with correlationID[%s] without correlation entry for publisher", msgP, correlationID)) + return false + } + if corEntry.received { + // return false to return the message + publisher.logger.Debug(fmt.Sprintf("Received reply message[%p] with correlationID[%s] that already has response", msgP, correlationID)) + return false + } + corEntry.received = true + corEntry.result <- msgP + return true +} + +func (publisher *requestReplyMessagePublisherImpl) String() string { + return fmt.Sprintf("solace.RequestReplyMessagePublisher at %p", publisher) +} + +type requestReplyMessagePublisherBuilderImpl struct { + internalPublisher core.Publisher + properties map[config.PublisherProperty]interface{} +} + +// NewRequestReplyMessagePublisherBuilderImpl function +func NewRequestReplyMessagePublisherBuilderImpl(internalPublisher core.Publisher) solace.RequestReplyMessagePublisherBuilder { + return &requestReplyMessagePublisherBuilderImpl{ + internalPublisher: internalPublisher, + // default properties + // TODO change the default properties if necessary? + properties: constants.DefaultDirectPublisherProperties.GetConfiguration(), + } +} + +// Build will build a new RequestReplyMessagePublisher instance based on the configured properties. +// Returns solace/solace.*InvalidConfigurationError if an invalid configuration is provided. +func (builder *requestReplyMessagePublisherBuilderImpl) Build() (messagePublisher solace.RequestReplyMessagePublisher, err error) { + backpressureConfig, publisherBackpressureBufferSize, err := validateBackpressureConfig(builder.properties) + if err != nil { + return nil, err + } + publisher := &requestReplyMessagePublisherImpl{} + publisher.construct(builder.internalPublisher, backpressureConfig, publisherBackpressureBufferSize) + return publisher, nil +} + +// OnBackPressureReject will set the publisher backpressure strategy to reject +// where publish attempts will be rejected once the bufferSize, in number of messages, is reached. +// If bufferSize is 0, an error will be thrown when the transport is full when publishing. +// Valid bufferSize is >= 0. +func (builder *requestReplyMessagePublisherBuilderImpl) OnBackPressureReject(bufferSize uint) solace.RequestReplyMessagePublisherBuilder { + builder.properties[config.PublisherPropertyBackPressureStrategy] = config.PublisherPropertyBackPressureStrategyBufferRejectWhenFull + builder.properties[config.PublisherPropertyBackPressureBufferCapacity] = bufferSize + return builder +} + +// OnBackPressureWait will set the publisher backpressure strategy to wait where publish +// attempts will block until there is space in the buffer of size bufferSize in number of messages. +// Valid bufferSize is >= 1. +func (builder *requestReplyMessagePublisherBuilderImpl) OnBackPressureWait(bufferSize uint) solace.RequestReplyMessagePublisherBuilder { + builder.properties[config.PublisherPropertyBackPressureStrategy] = config.PublisherPropertyBackPressureStrategyBufferWaitWhenFull + builder.properties[config.PublisherPropertyBackPressureBufferCapacity] = bufferSize + return builder +} + +// FromConfigurationProvider will configure the direct publisher with the given properties. +// Built in PublisherPropertiesConfigurationProvider implementations include: +// +// PublisherPropertyMap, a map of PublisherProperty keys to values +// for loading of properties from a string configuration (files or other configuration source) +func (builder *requestReplyMessagePublisherBuilderImpl) FromConfigurationProvider(provider config.PublisherPropertiesConfigurationProvider) solace.RequestReplyMessagePublisherBuilder { + if provider == nil { + return builder + } + for key, value := range provider.GetConfiguration() { + builder.properties[key] = value + } + return builder +} + +func (builder *requestReplyMessagePublisherBuilderImpl) String() string { + return fmt.Sprintf("solace.RequestReplyMessagePublisherBuilder at %p", builder) +} diff --git a/internal/impl/publisher/request_reply_message_publisher_impl_test.go b/internal/impl/publisher/request_reply_message_publisher_impl_test.go new file mode 100644 index 0000000..24d5fab --- /dev/null +++ b/internal/impl/publisher/request_reply_message_publisher_impl_test.go @@ -0,0 +1,1723 @@ +// pubsubplus-go-client +// +// Copyright 2024 Solace Corporation. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package publisher + +import ( + "encoding/json" + "fmt" + "testing" + "time" + // "unsafe" + + "solace.dev/go/messaging/internal/ccsmp" + + "solace.dev/go/messaging/internal/impl/core" + "solace.dev/go/messaging/internal/impl/message" + + "solace.dev/go/messaging/internal/impl/constants" + + "solace.dev/go/messaging/internal/impl/executor" + "solace.dev/go/messaging/internal/impl/publisher/buffer" + "solace.dev/go/messaging/pkg/solace" + "solace.dev/go/messaging/pkg/solace/config" + apimessage "solace.dev/go/messaging/pkg/solace/message" + "solace.dev/go/messaging/pkg/solace/resource" + "solace.dev/go/messaging/pkg/solace/subcode" +) + +func TestRequestReplyMessagePublisherBuilderWithValidBackpressure(t *testing.T) { + backpressureConfigurations := []func(builder solace.RequestReplyMessagePublisherBuilder) (solace.RequestReplyMessagePublisherBuilder, backpressureConfiguration, int){ + func(builder solace.RequestReplyMessagePublisherBuilder) (solace.RequestReplyMessagePublisherBuilder, backpressureConfiguration, int) { + return builder.OnBackPressureReject(0), backpressureConfigurationDirect, 0 + + }, + func(builder solace.RequestReplyMessagePublisherBuilder) (solace.RequestReplyMessagePublisherBuilder, backpressureConfiguration, int) { + bufferSize := 1 + return builder.OnBackPressureReject(uint(bufferSize)), backpressureConfigurationReject, bufferSize + }, + func(builder solace.RequestReplyMessagePublisherBuilder) (solace.RequestReplyMessagePublisherBuilder, backpressureConfiguration, int) { + bufferSize := 1 + return builder.OnBackPressureWait(uint(bufferSize)), backpressureConfigurationWait, bufferSize + }, + } + shared := &mockInternalPublisher{} + for _, config := range backpressureConfigurations { + builder, backpressureConfig, capacity := config(NewRequestReplyMessagePublisherBuilderImpl(shared)) + publisher, err := builder.Build() + if err != nil { + t.Error(err) + } + if publisher == nil { + t.Error("expected publisher to not be nil") + } + publisherImpl := publisher.(*requestReplyMessagePublisherImpl) + if publisherImpl.backpressureConfiguration != backpressureConfig { + t.Errorf("expected backpressure config to equal %d, was %d", backpressureConfig, publisherImpl.backpressureConfiguration) + } + if cap(publisherImpl.buffer) != capacity { + t.Errorf("expected backpressure capacity to equal %d, was %d", capacity, cap(publisherImpl.buffer)) + } + } +} + +func TestRequestReplyMessagePublisherBuilderWithInvalidBackpressureWait(t *testing.T) { + publisher, err := NewRequestReplyMessagePublisherBuilderImpl(&mockInternalPublisher{}).OnBackPressureWait(0).Build() + // we should get an error saying that buffer must be > 0 for wait + if err == nil { + t.Error("expected error to not be nil") + } + if publisher != nil { + t.Error("expected publisher to be nil") + } +} + +func TestRequestReplyMessagePublisherBuilderWithCustomPropertiesStructFromJSON(t *testing.T) { + jsonData := `{"solace":{"messaging":{"publisher":{"back-pressure":{"strategy":"BUFFER_WAIT_WHEN_FULL","buffer-capacity": 100,"buffer-wait-timeout": 1000}}}}}` + baselineProperties := make(config.PublisherPropertyMap) + err := json.Unmarshal([]byte(jsonData), &baselineProperties) + if err != nil { + t.Error(err) + } + publisher, err := NewRequestReplyMessagePublisherBuilderImpl(&mockInternalPublisher{}).FromConfigurationProvider(baselineProperties).Build() + if publisher == nil { + t.Error("expected publisher to not be nil") + } + if err != nil { + t.Error(err) + } + publisherImpl := publisher.(*requestReplyMessagePublisherImpl) + if publisherImpl.backpressureConfiguration != backpressureConfigurationWait { + t.Errorf("expected backpressure config to equal %d, was %d", backpressureConfigurationWait, publisherImpl.backpressureConfiguration) + } + if cap(publisherImpl.buffer) != 100 { + t.Errorf("expected backpressure capacity to equal %d, was %d", 100, cap(publisherImpl.buffer)) + } +} + +func TestRequestReplyMessagePublisherBuilderWithInvalidCustomPropertiesMapNegativeBufferCapacity(t *testing.T) { + baselineProperties := config.PublisherPropertyMap{ + config.PublisherPropertyBackPressureBufferCapacity: -1, + config.PublisherPropertyBackPressureStrategy: config.PublisherPropertyBackPressureStrategyBufferRejectWhenFull, + } + publisher, err := NewRequestReplyMessagePublisherBuilderImpl(&mockInternalPublisher{}).FromConfigurationProvider(baselineProperties).Build() + if publisher != nil { + t.Error("expected publisher to be nil") + } + if err == nil { + t.Error("expected error when backpressure capacity is negative") + } +} + +func TestRequestReplyMessagePublisherBuilderWithInvalidCustomPropertiesMapWrongTypeBufferCapacity(t *testing.T) { + baselineProperties := config.PublisherPropertyMap{ + config.PublisherPropertyBackPressureBufferCapacity: "hello", + config.PublisherPropertyBackPressureStrategy: config.PublisherPropertyBackPressureStrategyBufferWaitWhenFull, + } + publisher, err := NewRequestReplyMessagePublisherBuilderImpl(&mockInternalPublisher{}).FromConfigurationProvider(baselineProperties).Build() + if publisher != nil { + t.Error("expected publisher to be nil") + } + if err == nil { + t.Error("expected error when backpressure capacity is a string") + } +} + +func TestRequestReplyMessagePublisherBuilderWithInvalidCustomPropertiesMapWrongTypeStrategy(t *testing.T) { + baselineProperties := config.PublisherPropertyMap{ + config.PublisherPropertyBackPressureBufferCapacity: 1, + config.PublisherPropertyBackPressureStrategy: 23, + } + publisher, err := NewRequestReplyMessagePublisherBuilderImpl(&mockInternalPublisher{}).FromConfigurationProvider(baselineProperties).Build() + if publisher != nil { + t.Error("expected publisher to be nil") + } + if err == nil { + t.Error("expected error when backpressure strategy is an integer") + } +} + +func TestRequestReplyMessagePublisherBuilderWithInvalidCustomPropertiesMapWrongStrategy(t *testing.T) { + baselineProperties := config.PublisherPropertyMap{ + config.PublisherPropertyBackPressureBufferCapacity: 1, + config.PublisherPropertyBackPressureStrategy: "hello world", + } + publisher, err := NewRequestReplyMessagePublisherBuilderImpl(&mockInternalPublisher{}).FromConfigurationProvider(baselineProperties).Build() + if publisher != nil { + t.Error("expected publisher to be nil") + } + if err == nil { + t.Error("expected error when backpressure strategy is an integer") + } +} + +func TestRequestReplyMessagePublisherImplLifecycle(t *testing.T) { + gracePeriod := 10 * time.Second + + // parameterize this test with the various start and terminate functions (sync/async) + startAndTerminatFunctions := []struct { + start func(publisher *requestReplyMessagePublisherImpl) + terminate func(publisher *requestReplyMessagePublisherImpl) + }{ + { + start: func(publisher *requestReplyMessagePublisherImpl) { + err := publisher.Start() + if err != nil { + t.Error("expected error to be nil, got " + err.Error()) + } + }, + terminate: func(publisher *requestReplyMessagePublisherImpl) { + err := publisher.Terminate(gracePeriod) + if err != nil { + t.Error("expected error to be nil, got " + err.Error()) + } + }, + }, + { + start: func(publisher *requestReplyMessagePublisherImpl) { + select { + case err := <-publisher.StartAsync(): + if err != nil { + t.Error("expected error to be nil, got " + err.Error()) + } + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for publisher to start") + } + }, + terminate: func(publisher *requestReplyMessagePublisherImpl) { + select { + case err := <-publisher.TerminateAsync(gracePeriod): + if err != nil { + t.Error("expected error to be nil, got " + err.Error()) + } + case <-time.After(gracePeriod + 5*time.Second): + t.Error("timed out waiting for publisher to terminate") + } + }, + }, + { + start: func(publisher *requestReplyMessagePublisherImpl) { + done := make(chan struct{}) + publisher.StartAsyncCallback(func(retPub solace.RequestReplyMessagePublisher, err error) { + if publisher != retPub { + t.Error("got a different publisher returned to the start callback") + } + if err != nil { + t.Error("expected error to be nil, got " + err.Error()) + } + close(done) + }) + select { + case <-done: + // success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for request reply publisher to start") + } + }, + terminate: func(publisher *requestReplyMessagePublisherImpl) { + done := make(chan struct{}) + publisher.TerminateAsyncCallback(gracePeriod, func(err error) { + if err != nil { + t.Error("expected error to be nil, got " + err.Error()) + } + close(done) + }) + select { + case <-done: + // success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for request reply publisher to start") + } + }, + }, + } + for _, fns := range startAndTerminatFunctions { + publisher := &requestReplyMessagePublisherImpl{} + publisher.construct(&mockInternalPublisher{}, backpressureConfigurationWait, 1) + eventExecutor := &mockEventExecutor{} + taskBuffer := &mockTaskBuffer{} + publisher.eventExecutor = eventExecutor + publisher.taskBuffer = taskBuffer + // pre start + if publisher.IsReady() { + t.Error("expected publisher to not be ready") + } + if publisher.IsRunning() { + t.Error("expected publisher to not be running") + } + if publisher.IsTerminating() { + t.Error("expected terminating to be false, was true") + } + if publisher.IsTerminated() { + t.Error("expected publisher to not yet be terminated") + } + + // start + eventExecutorStarted := make(chan struct{}) + eventExecutor.run = func() { + close(eventExecutorStarted) + } + taskBufferStarted := make(chan struct{}) + taskBuffer.run = func() { + close(taskBufferStarted) + } + fns.start(publisher) + // check started + select { + case <-eventExecutorStarted: + // success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for event executor to start") + } + select { + case <-taskBufferStarted: + // success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for event executor to start") + } + // check started states + if !publisher.IsReady() { + t.Error("expected publisher to be ready, it was not") + } + if !publisher.IsRunning() { + t.Error("expected publisher to be running, it was not") + } + if publisher.IsTerminating() { + t.Error("expected terminating to be false, was true") + } + if publisher.IsTerminated() { + t.Error("expected publisher to not yet be terminated") + } + + // terminate + eventExecutorTerminated := make(chan struct{}) + eventExecutor.awaitTermination = func() { + close(eventExecutorTerminated) + } + taskBufferTerminated := make(chan struct{}) + taskBuffer.terminate = func(timer *time.Timer) bool { + // this should be shutdown first + select { + case <-eventExecutorTerminated: + t.Error("expected task buffer to be shutdown first") + default: + // success + } + // check terminating state + if publisher.IsReady() { + t.Error("expected publisher to not be ready") + } + if publisher.IsRunning() { + t.Error("expected publisher to not be running") + } + if publisher.IsTerminated() { + t.Error("expected publisher to not yet be terminated") + } + if !publisher.IsTerminating() { + t.Error("expected publisher to be terminating") + } + close(taskBufferTerminated) + return true + } + fns.terminate(publisher) + // check terminated + select { + case <-eventExecutorTerminated: + // success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for event executor to terminate") + } + select { + case <-taskBufferTerminated: + // success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for task buffer to terminate") + } + select { + case <-publisher.requestCorrelateComplete: + //success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for correlation request to terminate") + } + select { + case <-publisher.correlationComplete: + //success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for correlation table empty channel to terminate") + } + // check terminated states + if publisher.IsReady() { + t.Error("expected publisher to not be ready") + } + if publisher.IsRunning() { + t.Error("expected publisher to not be running") + } + if publisher.IsTerminating() { + t.Error("expected publisher to not be terminating") + } + if !publisher.IsTerminated() { + t.Error("expected publisher to be terminated") + } + } +} + +func TestRequestReplyMessagePublisherImplLifecycleNoBuffer(t *testing.T) { + publisher := &requestReplyMessagePublisherImpl{} + publisher.construct(&mockInternalPublisher{}, backpressureConfigurationDirect, 1) + eventExecutor := &mockEventExecutor{} + taskBuffer := &mockTaskBuffer{} + publisher.eventExecutor = eventExecutor + publisher.taskBuffer = taskBuffer + + // start + eventExecutorStarted := make(chan struct{}) + eventExecutor.run = func() { + close(eventExecutorStarted) + } + taskBufferStarted := make(chan struct{}) + taskBuffer.run = func() { + close(taskBufferStarted) + } + err := publisher.Start() + if err != nil { + t.Error("expected error to be nil, got " + err.Error()) + } + // check started + select { + case <-eventExecutorStarted: + // success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for event executor to start") + } + select { + case <-taskBufferStarted: + t.Error("did not expect task buffer to start") + case <-time.After(100 * time.Millisecond): + // success + } + + // terminate + gracePeriod := 10 * time.Second + eventExecutorTerminated := make(chan struct{}) + eventExecutor.awaitTermination = func() { + close(eventExecutorTerminated) + } + taskBufferTerminated := make(chan struct{}) + taskBuffer.terminate = func(timer *time.Timer) bool { + // this should be shutdown first + select { + case <-eventExecutorTerminated: + t.Error("expected task buffer to be shutdown first") + default: + // success + } + close(taskBufferTerminated) + return true + } + err = publisher.Terminate(gracePeriod) + // check terminated + select { + case <-eventExecutorTerminated: + // success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for event executor to terminate") + } + select { + case <-taskBufferTerminated: + t.Error("did not expect task buffer to be terminated as it was never started") + case <-time.After(100 * time.Millisecond): + // success + } + select { + case <-publisher.requestCorrelateComplete: + //success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for correlation request to terminate") + } + select { + case <-publisher.correlationComplete: + //success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for correlation table empty channel to terminate") + } + // check terminated states + if err != nil { + t.Error("expected error to be nil, got " + err.Error()) + } +} + +func TestRequestReplyMessagePublisherLifecycleIdempotence(t *testing.T) { + publisher := &requestReplyMessagePublisherImpl{} + publisher.construct(&mockInternalPublisher{}, backpressureConfigurationWait, 1) + eventExecutor := &mockEventExecutor{} + taskBuffer := &mockTaskBuffer{} + publisher.eventExecutor = eventExecutor + publisher.taskBuffer = taskBuffer + + // expect channels to be closed on second call only + eventExecutorStarted := make(chan interface{}, 2) + eventExecutor.run = func() { + eventExecutorStarted <- nil + } + taskBufferStarted := make(chan interface{}, 2) + taskBuffer.run = func() { + taskBufferStarted <- nil + } + + // start + err := publisher.Start() + if err != nil { + t.Error("expected error to be nil, got " + err.Error()) + } + // check started + select { + case <-eventExecutorStarted: + // success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for event executor to start") + } + select { + case <-taskBufferStarted: + // success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for event executor to start") + } + + // start again + err = publisher.Start() + if err != nil { + t.Error("did not expect an error on subsequent start, got " + err.Error()) + } + select { + case <-eventExecutorStarted: + t.Error("did not expect event executor to be run on subsequent starts") + case <-time.After(100 * time.Millisecond): + // success + } + select { + case <-taskBufferStarted: + t.Error("did not expect task buffer to be run on subsequent starts") + case <-time.After(100 * time.Millisecond): + // success + } + + // check started states + if !publisher.IsReady() { + t.Error("expected publisher to be ready, it was not") + } + if !publisher.IsRunning() { + t.Error("expected publisher to be running, it was not") + } + if publisher.IsTerminating() { + t.Error("expected terminating to be false, was true") + } + if publisher.IsTerminated() { + t.Error("expected publisher to not yet be terminated") + } + + // terminate functions + eventExecutorTerminated := make(chan interface{}, 2) + eventExecutor.awaitTermination = func() { + eventExecutorTerminated <- nil + } + taskBufferTerminated := make(chan interface{}, 2) + taskBuffer.terminate = func(timer *time.Timer) bool { + taskBufferTerminated <- nil + return true + } + + gracePeriod := 10 * time.Second + // terminate + err = publisher.Terminate(gracePeriod) + if err != nil { + t.Error("expected error to be nil, got " + err.Error()) + } + // make sure termiante was called + select { + case <-eventExecutorTerminated: + // success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for event executor to termiante") + } + select { + case <-taskBufferTerminated: + // success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for event executor to terminate") + } + select { + case <-publisher.requestCorrelateComplete: + //success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for correlation request to terminate") + } + select { + case <-publisher.correlationComplete: + //success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for correlation table empty channel to terminate") + } + + err = publisher.Terminate(gracePeriod) + // check terminated states + if err != nil { + t.Error("expected error to be nil, got " + err.Error()) + } + // check terminated + select { + case <-eventExecutorTerminated: + t.Error("did not expect event executor to be terminated again") + case <-time.After(100 * time.Millisecond): + // success + } + select { + case <-taskBufferTerminated: + t.Error("did not expect task buffer to be terminated again") + case <-time.After(100 * time.Millisecond): + // success + } + select { + case <-publisher.requestCorrelateComplete: + //success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for correlation request to terminate") + } + select { + case <-publisher.correlationComplete: + //success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for correlation table empty channel to terminate") + } +} + +func TestRequestReplyMessagePublisherTerminateWithUnpublishedMessages(t *testing.T) { + internalPublisher := &mockInternalPublisher{} + publisher := &requestReplyMessagePublisherImpl{} + publisher.construct(internalPublisher, backpressureConfigurationWait, 10) + eventExecutor := &mockEventExecutor{} + taskBuffer := &mockTaskBuffer{} + publisher.eventExecutor = eventExecutor + publisher.taskBuffer = taskBuffer + + unpublishedCount := 2 + metricsIncremented := false + internalPublisher.incrementMetric = func(metric core.NextGenMetric, amount uint64) { + if metric != core.MetricPublishMessagesTerminationDiscarded { + t.Errorf("expected metric %d to be incremented, got %d", core.MetricPublishMessagesTerminationDiscarded, metric) + } + if amount != uint64(unpublishedCount) { + t.Errorf("expected %d unpublished messages, got %d", unpublishedCount, amount) + } + metricsIncremented = true + } + + publisher.Start() + for i := 0; i < unpublishedCount; i++ { + unpublished := &publishable{} + unpublished.message = &message.OutboundMessageImpl{} + publisher.buffer <- unpublished + } + err := publisher.Terminate(10 * time.Second) + expected := fmt.Sprintf(constants.IncompleteMessageDeliveryMessage, unpublishedCount) + if err == nil || err.Error() != expected { + t.Errorf("did not get expected error. Expected '%s', got '%s'", expected, err) + } + if !metricsIncremented { + t.Error("IncrementMetric not called") + } +} + +func TestRequestReplyMessagePublisherUnsolicitedTerminationWithUnpublishedMessages(t *testing.T) { + internalPublisher := &mockInternalPublisher{} + publisher := &requestReplyMessagePublisherImpl{} + publisher.construct(internalPublisher, backpressureConfigurationWait, 10) + eventExecutor := &mockEventExecutor{} + taskBuffer := &mockTaskBuffer{} + publisher.eventExecutor = eventExecutor + publisher.taskBuffer = taskBuffer + + unpublishedCount := 2 + metricsIncremented := false + internalPublisher.incrementMetric = func(metric core.NextGenMetric, amount uint64) { + if metric != core.MetricPublishMessagesTerminationDiscarded { + t.Errorf("expected metric %d to be incremented, got %d", core.MetricPublishMessagesTerminationDiscarded, metric) + } + if amount != uint64(unpublishedCount) { + t.Errorf("expected %d unpublished messages, got %d", unpublishedCount, amount) + } + metricsIncremented = true + } + + terminationListenerCalled := make(chan error) + publisher.SetTerminationNotificationListener(func(te solace.TerminationEvent) { + delta := time.Since(te.GetTimestamp()) + if delta < 0 || delta > 100*time.Millisecond { + t.Errorf("Timestamp delta too large! Timestamp: %s, now: %s", te.GetTimestamp(), time.Now()) + } + if !publisher.IsTerminated() { + t.Error("Expected publisher to be terminated when notification listener is called") + } + if te.GetMessage() == "" { + t.Error("Expected message in termination event") + } + terminationListenerCalled <- te.GetCause() + }) + + publisher.Start() + + eventExecutorTerminated := make(chan interface{}) + eventExecutor.terminate = func() { + close(eventExecutorTerminated) + } + taskBufferTerminated := make(chan interface{}) + taskBuffer.terminateNow = func() { + close(taskBufferTerminated) + } + + for i := 0; i < unpublishedCount; i++ { + unpublished := &publishable{} + unpublished.message = &message.OutboundMessageImpl{} + publisher.buffer <- unpublished + } + errForEvent := fmt.Errorf("some error") + publisher.onDownEvent(&mockEvent{err: errForEvent}) + + select { + case <-eventExecutorTerminated: + // success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for event executor to terminate") + } + select { + case <-taskBufferTerminated: + // success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for event executor to terminate") + } + select { + case err := <-terminationListenerCalled: + if err != errForEvent { + t.Errorf("expected %s, got %s", errForEvent, err) + } + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for termination listener to be called") + } + err := publisher.Terminate(100 * time.Millisecond) + if _, ok := err.(*solace.IncompleteMessageDeliveryError); !ok { + t.Error("expected to get incomplete message delivery error from subsequent calls to terminate") + } + if !metricsIncremented { + t.Error("IncrementMetrics not called") + } +} + +func TestRequestReplyCallPublishWhenNotStarted(t *testing.T) { + publisher := &requestReplyMessagePublisherImpl{} + publisher.construct(&mockInternalPublisher{}, backpressureConfigurationWait, 1) + eventExecutor := &mockEventExecutor{} + taskBuffer := &mockTaskBuffer{} + publisher.eventExecutor = eventExecutor + publisher.taskBuffer = taskBuffer + + testMessage, _ := message.NewOutboundMessage() + testTopic := resource.TopicOf("hello/world") + replyHandler := func(replyMsg apimessage.InboundMessage, context interface{}, err error) {} + err := publisher.Publish(testMessage, replyHandler, testTopic, 100*time.Millisecond, nil /* usercontext */, nil /* property provider */) + if err == nil { + t.Error("expected publish to fail when publisher not started") + } +} + +func TestRequestReplyCallPublishWhenAlreadyTerminated(t *testing.T) { + publisher := &requestReplyMessagePublisherImpl{} + publisher.construct(&mockInternalPublisher{}, backpressureConfigurationWait, 1) + eventExecutor := &mockEventExecutor{} + taskBuffer := &mockTaskBuffer{} + publisher.eventExecutor = eventExecutor + publisher.taskBuffer = taskBuffer + + publisher.Start() + publisher.Terminate(1 * time.Second) + + testMessage, _ := message.NewOutboundMessage() + testTopic := resource.TopicOf("hello/world") + replyHandler := func(replyMsg apimessage.InboundMessage, context interface{}, err error) {} + err := publisher.Publish(testMessage, replyHandler, testTopic, 100*time.Millisecond, nil /* usercontext */, nil /* property provider */) + if err == nil { + t.Error("expected publish to fail when publisher already terminated") + } +} + +func TestRequestReplyStartFailedToGetReplyTo(t *testing.T) { + subCode := 58 // MissingReplyTo, note this subcode does not matter and does not represent a real scenario + + publisher := &requestReplyMessagePublisherImpl{} + + internalPublisher := &mockInternalPublisher{} + internalPublisher.requestor = func() core.Requestor { + mock := &mockRequestor{} + mock.addRequestorReplyHandler = func(handler core.RequestorReplyHandler) (string, func() (messageID uint64, correlationID string), core.ErrorInfo) { + return "", nil, &ccsmp.SolClientErrorInfoWrapper{ + ReturnCode: ccsmp.SolClientReturnCodeFail, + SubCode: ccsmp.SolClientSubCode(subCode), + } + } + return mock + } + + publisher.construct(internalPublisher, backpressureConfigurationWait, 1) + //eventExecutor := &mockEventExecutor{} + //taskBuffer := &mockTaskBuffer{} + //publisher.eventExecutor = eventExecutor + //publisher.taskBuffer = taskBuffer + + err := publisher.Start() + if err == nil { + t.Error("Failed to get error on start when replyto topic was not acquired by publisher") + } + publisher.Terminate(1 * time.Second) +} + +func TestRequestReplyCallPublishWithBadPayload(t *testing.T) { + publisher := &requestReplyMessagePublisherImpl{} + publisher.construct(&mockInternalPublisher{}, backpressureConfigurationWait, 1) + eventExecutor := &mockEventExecutor{} + taskBuffer := &mockTaskBuffer{} + publisher.eventExecutor = eventExecutor + publisher.taskBuffer = taskBuffer + + publisher.Start() + + testTopic := resource.TopicOf("hello/world") + replyHandler := func(replyMsg apimessage.InboundMessage, context interface{}, err error) {} + err := publisher.Publish(nil, replyHandler, testTopic, 100*time.Millisecond, nil /* usercontext */, nil /* property provider */) + if err == nil { + t.Error("expected publish to fail when message parameter is nil") + } + _, err = publisher.PublishAwaitResponse(nil, testTopic, 100*time.Millisecond, nil /* property provider */) + if err == nil { + t.Error("expected publish await response to fail when message parameter is nil") + } +} + +func TestRequestReplyCallPublishWithBadReplyHandler(t *testing.T) { + publisher := &requestReplyMessagePublisherImpl{} + publisher.construct(&mockInternalPublisher{}, backpressureConfigurationWait, 1) + eventExecutor := &mockEventExecutor{} + taskBuffer := &mockTaskBuffer{} + publisher.eventExecutor = eventExecutor + publisher.taskBuffer = taskBuffer + + publisher.Start() + + testTopic := resource.TopicOf("hello/world") + timeoutDuration := 100 * time.Millisecond + pubFuncs := []func(pub solace.RequestReplyMessagePublisher) (string, error){ + func(pub solace.RequestReplyMessagePublisher) (string, error) { + // pub message + testMessage, _ := message.NewOutboundMessage() + return "Message", pub.Publish(testMessage, nil /* replyHandler */, testTopic, timeoutDuration, nil /* usercontext */, nil /* property provider */) + }, + func(pub solace.RequestReplyMessagePublisher) (string, error) { + // pub string + testString := "RequestReply" + return "String", pub.PublishString(testString, nil /* replyHandler */, testTopic, timeoutDuration, nil /* usercontext */) + }, + func(pub solace.RequestReplyMessagePublisher) (string, error) { + // pub bytes + testBytes := []byte{0x01, 0x02, 0x03} + return "Bytes", pub.PublishBytes(testBytes, nil /* replyHandler */, testTopic, timeoutDuration, nil /* usercontext */) + }, + } + + for _, pubFunc := range pubFuncs { + description, err := pubFunc(publisher) + if err == nil { + t.Errorf("expected publish %s to fail when replyHandler parameter is nil", description) + } + } +} + +func TestRequestReplyCallPublishWithNegativeDuration(t *testing.T) { + // test wait beahviour for negative timeouts + // expects no timeout error for any response with no replier + publisherReplyToTopic := "testReplyTopic" + testTopic := resource.TopicOf("hello/world") + var coreReplyHandler core.RequestorReplyHandler = nil + messagePublishedChan := make(chan bool, 1) + corePublisher := &mockInternalPublisher{} + corePublisher.requestor = func() core.Requestor { + mock := &mockRequestor{} + mock.addRequestorReplyHandler = func(handler core.RequestorReplyHandler) (string, func() (messageID uint64, correlationID string), core.ErrorInfo) { + count := uint64(0) + coreReplyHandler = handler + return publisherReplyToTopic, func() (uint64, string) { + count += 1 + return count, fmt.Sprintf("TEST%d", count) + }, nil + } + return mock + } + corePublisher.publish = func(message core.Publishable) core.ErrorInfo { + messagePublishedChan <- true + return nil + } + publisher := &requestReplyMessagePublisherImpl{} + publisher.construct(corePublisher, backpressureConfigurationDirect, 0) + + publisher.Start() + + if coreReplyHandler == nil { + t.Error("coreReplyHandler was not set from call to start") + } + + if publisher.replyToTopic != publisherReplyToTopic { + t.Error("replyToTopic was not set from start") + } + + replyMessageResponseChan := make(chan error, 1) + + replyHandler := func(replyMsg apimessage.InboundMessage, context interface{}, err error) { + replyMessageResponseChan <- err + } + + err := publisher.PublishString("testPayload", replyHandler, testTopic, time.Duration(-1), nil /* usercontext */) + if err != nil { + t.Errorf("Error publishing request message with negative timeout, error: %s", err) + } + + select { + case sent := <-messagePublishedChan: + if !sent { + t.Error("Message was not sent") + } + case <-time.After(100 * time.Millisecond): + t.Error("Timeout waiting for request message to publish") + } + + terminateChan := publisher.TerminateAsync(1 * time.Second) + + select { + case replyError := <-replyMessageResponseChan: + if replyError == nil { + t.Error("Publisher did not receive reply error response") + } else if _, ok := replyError.(*solace.TimeoutError); ok { + t.Error("Publisher received timeout error response") + } + case <-time.After(2 * time.Second): + t.Error("did not receive response after terminate completes") + } + + termError := <-terminateChan + // terminate compelete + if termError != nil { + t.Errorf("Got unexpected termination error %s", termError) + } + +} + +func TestRequestReplyMessagePublisherPublishFailureFromTimeout(t *testing.T) { + // test wait beahviour for negative timeouts + // expects no timeout error for any response with no replier + publisherReplyToTopic := "testReplyTopic" + testTopic := resource.TopicOf("hello/world") + var coreReplyHandler core.RequestorReplyHandler = nil + messagePublishedChan := make(chan bool, 1) + corePublisher := &mockInternalPublisher{} + corePublisher.requestor = func() core.Requestor { + mock := &mockRequestor{} + mock.addRequestorReplyHandler = func(handler core.RequestorReplyHandler) (string, func() (messageID uint64, correlationID string), core.ErrorInfo) { + count := uint64(0) + coreReplyHandler = handler + return publisherReplyToTopic, func() (uint64, string) { + count += 1 + return count, fmt.Sprintf("TEST%d", count) + }, nil + } + return mock + } + corePublisher.publish = func(message core.Publishable) core.ErrorInfo { + messagePublishedChan <- true + return nil + } + publisher := &requestReplyMessagePublisherImpl{} + publisher.construct(corePublisher, backpressureConfigurationDirect, 0) + + publisher.Start() + + if coreReplyHandler == nil { + t.Error("coreReplyHandler was not set from call to start") + } + + if publisher.replyToTopic != publisherReplyToTopic { + t.Error("replyToTopic was not set from start") + } + + timeout := 1 * time.Millisecond // set to timeout imemdiately + + // test timeout from reply handler + replyMessageResponseChan := make(chan error, 1) + + replyHandler := func(replyMsg apimessage.InboundMessage, context interface{}, err error) { + replyMessageResponseChan <- err + } + + err := publisher.PublishString("testPayload", replyHandler, testTopic, timeout, nil /* usercontext */) + if err != nil { + t.Errorf("Error publishing request message with negative timeout, error: %s", err) + } + + select { + case sent := <-messagePublishedChan: + if !sent { + t.Error("Message was not sent") + } + case <-time.After(100 * time.Millisecond): + t.Error("Timeout waiting for request message to publish") + } + + select { + case replyError := <-replyMessageResponseChan: + if replyError == nil { + t.Error("Publisher did not receive reply error response") + } else if _, ok := replyError.(*solace.TimeoutError); !ok { + t.Errorf("Publisher received error response that was not timeout. Error %s", err) + } + case <-time.After(2 * time.Second): + t.Error("did not receive response after expected timeout") + } + + // test timeout for publishAwaitResponse + // make message + builder := message.NewOutboundMessageBuilder() + outMsg, err := builder.BuildWithStringPayload("testpayload", nil) + if err != nil { + t.Error("Failed to build message to publish with PublishAwaitResponse") + } + + go func() { + _, pubErr := publisher.PublishAwaitResponse(outMsg, testTopic, timeout, nil /* properties */) + replyMessageResponseChan <- pubErr + }() + + select { + case sent := <-messagePublishedChan: + if !sent { + t.Error("Message was not sent") + } + case <-time.After(100 * time.Millisecond): + t.Error("Timeout waiting for request message to publish") + } + + select { + case replyError := <-replyMessageResponseChan: + if replyError == nil { + t.Error("Publisher did not receive reply error response") + } else if _, ok := replyError.(*solace.TimeoutError); !ok { + t.Errorf("Publisher received error response that was not timeout. Error %s", replyError) + } + case <-time.After(2 * time.Second): + t.Error("did not receive response after expected timout") + } + + // terminate the publisher + terminateChan := publisher.TerminateAsync(1 * time.Second) + + termError := <-terminateChan + // terminate compelete + if termError != nil { + t.Errorf("Got unexpected termination error %s", termError) + } +} + +func TestRequestReplyMessagePublisherPublishReplyByCorrelation(t *testing.T) { + // publish request message + // then receive a reply message without matching correlation id, expect no reply in handler + // then receive a reply message with matching correlation id, expect reply message in handler + // then receive a reply message the matching correlation id, expect no reply in handler + // this test can mock a reply message by calling publisher the reply handler from addRequestHandler in core requestor. + // the mock reply messages can be constructed by setting the following fields: + // - correlation id + // in real scenario more fields are required: + // - smf header bit + // - destination set as the reply to destination for the publisher + + publisherReplyToTopic := "testReplyTopic" + testTopic := resource.TopicOf("hello/world") + var coreReplyHandler core.RequestorReplyHandler = nil + messagePublishedChan := make(chan string, 1) + corePublisher := &mockInternalPublisher{} + corePublisher.requestor = func() core.Requestor { + mock := &mockRequestor{} + mock.addRequestorReplyHandler = func(handler core.RequestorReplyHandler) (string, func() (messageID uint64, correlationID string), core.ErrorInfo) { + count := uint64(0) + coreReplyHandler = handler + return publisherReplyToTopic, func() (uint64, string) { + count += 1 + return count, fmt.Sprintf("TEST%d", count) + }, nil + } + return mock + } + corePublisher.publish = func(message core.Publishable) core.ErrorInfo { + id, errinfo := ccsmp.SolClientMessageGetCorrelationID(message) + if errinfo == nil { + messagePublishedChan <- id + } + return nil + } + + publisher := &requestReplyMessagePublisherImpl{} + publisher.construct(corePublisher, backpressureConfigurationDirect, 0) + + createRepliable := func(correlationID string, destination string, setReplyHeaderFlag bool) core.Repliable { + // construct inbound message with both fields + // use outbound message to construct the message + builder := message.NewOutboundMessageBuilder() + // set correlationID + outMsg, err := builder.WithCorrelationID(correlationID).BuildWithStringPayload("testpayload", nil) + if err != nil { + t.Error("Failed to build message with correlation id") + } + outMsgImpl, ok := outMsg.(*message.OutboundMessageImpl) + if !ok { + t.Error("Failed to access internal outbound message impl struct") + } + // set reply to destination + err = message.SetDestination(outMsgImpl, destination) + if err != nil { + t.Error("Failed to set the replyTo destination") + } + msgP, errInfo := ccsmp.SolClientMessageDup(message.GetOutboundMessagePointer(outMsgImpl)) + if errInfo != nil { + t.Error("Failed to extract duplicate core message from constructed message") + } + return msgP + } + + publisher.Start() + + if coreReplyHandler == nil { + t.Error("coreReplyHandler was not set from call to start") + } + + if publisher.replyToTopic != publisherReplyToTopic { + t.Error("replyToTopic was not set from start") + } + + replyMessageResponseChan := make(chan apimessage.InboundMessage, 1) + replyMessageErrorResponseChan := make(chan error, 1) + + replyHandler := func(replyMsg apimessage.InboundMessage, context interface{}, err error) { + if err != nil { + replyMessageErrorResponseChan <- err + } + if replyMsg != nil { + replyMessageResponseChan <- replyMsg + } + } + + err := publisher.PublishString("testPayload", replyHandler, testTopic, time.Duration(-1), nil /* usercontext */) + if err != nil { + t.Errorf("Error publishing request message with negative timeout, error: %s", err) + } + + coreReplyHandlerChannel := make(chan string, 1) + + // mock ccsmp context thread pushing replies + go func() { + for { + replyCorrelationID, ok := <-coreReplyHandlerChannel + if !ok { + return + } + repliable := createRepliable(replyCorrelationID, publisherReplyToTopic, true) + takeMsg := coreReplyHandler(repliable, replyCorrelationID) + if !takeMsg { + // cleanup repliable like ccsmp context thread would + ccsmp.SolClientMessageFree(&repliable) + } + } + }() + + var publishedID string + select { + case publishedID = <-messagePublishedChan: + if len(publishedID) < 1 { + t.Error("Message was not sent and could not get correlation ID") + } + case <-time.After(100 * time.Millisecond): + t.Error("Timeout waiting for request message to publish") + } + + // push reply with mismatch correlation id + coreReplyHandlerChannel <- "NotAPublisherCorrelationID" + + select { + case replyError := <-replyMessageErrorResponseChan: + if replyError != nil { + t.Errorf("Publisher did receive reply error response. Error %s", replyError) + } + case replyMessage := <-replyMessageResponseChan: + if cID, present := replyMessage.GetCorrelationID(); present { + t.Errorf("Received replyMessage for published correlation ID[%s] for mismatch reply correlation ID[%s]", publishedID, cID) + } else { + t.Errorf("Got replyMessage for correlationID[%s] without a correlation id", publishedID) + } + case <-time.After(100 * time.Millisecond): + // success did not receive any reply message + } + + // push reply with mathcing correlation id + coreReplyHandlerChannel <- publishedID + select { + case replyError := <-replyMessageErrorResponseChan: + if replyError != nil { + t.Errorf("Publisher did receive reply error response. Error %s", replyError) + } + case replyMessage := <-replyMessageResponseChan: + if cID, present := replyMessage.GetCorrelationID(); present { + if cID != publishedID { + t.Errorf("Received replyMessage for published correlation ID[%s] for matching reply correlation ID[%s] did not match", publishedID, cID) + } + } else { + t.Errorf("Got replyMessage for correlationID[%s] without a correlation id", publishedID) + } + case <-time.After(100 * time.Millisecond): + t.Error("Failed to get reply outcome in time") + } + + // push reply with matching correlation id again should get no reply + coreReplyHandlerChannel <- publishedID + select { + case replyError := <-replyMessageErrorResponseChan: + if replyError != nil { + t.Errorf("Publisher did receive unexpected reply error response. Error %s", replyError) + } + case replyMessage := <-replyMessageResponseChan: + if cID, present := replyMessage.GetCorrelationID(); present { + t.Errorf("Received second replyMessage for published correlation ID[%s] for matching reply correlation ID[%s]", publishedID, cID) + } else { + t.Errorf("Got second replyMessage for correlationID[%s] without a correlation id", publishedID) + } + case <-time.After(100 * time.Millisecond): + // success did not receive any reply message + } + + close(coreReplyHandlerChannel) + publisher.Terminate(1) +} + +func TestRequestReplyMessagePublisherPublishFunctionalityBufferedWait(t *testing.T) { + publisher := &requestReplyMessagePublisherImpl{} + publisher.construct(&mockInternalPublisher{}, backpressureConfigurationWait, 1) + eventExecutor := &mockEventExecutor{} + taskBuffer := &mockTaskBuffer{} + publisher.eventExecutor = eventExecutor + publisher.taskBuffer = taskBuffer + + publisher.Start() + + taskBufferSubmitCalled := make(chan interface{}, 10) + taskBuffer.submit = func(task buffer.PublisherTask) bool { + taskBufferSubmitCalled <- nil + return true + } + + testMessage, _ := message.NewOutboundMessage() + testTopic := resource.TopicOf("hello/world") + testReplyHandler := func(msg apimessage.InboundMessage, usercontext interface{}, err error) {} + testTimeout := time.Duration(-1) // forever + err := publisher.Publish(testMessage, testReplyHandler, testTopic, testTimeout, nil /*properties*/, nil /*usercontext*/) + if err != nil { + t.Error(err) + } + + select { + case <-taskBufferSubmitCalled: + // success + default: + t.Error("Expect task buffer submit to be called") + } + + publishComplete := make(chan struct{}) + go func() { + // this should block + err := publisher.Publish(testMessage, testReplyHandler, testTopic, testTimeout, nil /*properties*/, nil /*usercontext*/) + if err != nil { + t.Error(err) + } + close(publishComplete) + }() + + select { + case <-publishComplete: + t.Error("expected publish to block a while") + case <-time.After(50 * time.Millisecond): + // success + } + select { + case <-publisher.buffer: + default: + t.Error("expected message to be present in publisher buffer") + } + + select { + case <-publishComplete: + // success + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for publish to complete") + } + + publishFailed := make(chan error) + go func() { + publishFailed <- publisher.Publish(testMessage, testReplyHandler, testTopic, testTimeout, nil /*properties*/, nil /*usercontext*/) + }() + select { + case <-publishFailed: + t.Error("expected to block indefinitely, returned") + case <-time.After(1 * time.Second): + // success + } + + select { + case <-publisher.buffer: + default: + t.Error("expected message to be present in publisher buffer") + } + select { + case <-publishFailed: + // success + case <-time.After(1 * time.Second): + t.Error("expected long running block to successfully push message") + } + select { + case <-publisher.buffer: + default: + t.Error("expected message to be present in publisher buffer") + } + err = publisher.Publish(testMessage, testReplyHandler, testTopic, testTimeout, nil /*properties*/, nil /*usercontext*/) + if err != nil { + t.Error(err) + } +} + +func TestRequestReplyMessagePublisherPublishFunctionalityBufferedReject(t *testing.T) { + publisher := &requestReplyMessagePublisherImpl{} + publisher.construct(&mockInternalPublisher{}, backpressureConfigurationReject, 1) + eventExecutor := &mockEventExecutor{} + taskBuffer := &mockTaskBuffer{} + publisher.eventExecutor = eventExecutor + publisher.taskBuffer = taskBuffer + + publisher.Start() + + taskBufferSubmitCalled := make(chan interface{}, 10) + taskBuffer.submit = func(task buffer.PublisherTask) bool { + taskBufferSubmitCalled <- nil + return true + } + + testMessage, _ := message.NewOutboundMessage() + testTopic := resource.TopicOf("hello/world") + testReplyHandler := func(msg apimessage.InboundMessage, usercontext interface{}, err error) {} + testTimeout := time.Duration(-1) // forever + err := publisher.Publish(testMessage, testReplyHandler, testTopic, testTimeout, nil /*properties*/, nil /*usercontext*/) + if err != nil { + t.Error(err) + } + + select { + case <-taskBufferSubmitCalled: + // success + default: + t.Error("Expect task buffer submit to be called") + } + + err = publisher.Publish(testMessage, testReplyHandler, testTopic, testTimeout, nil /*properties*/, nil /*usercontext*/) + if err == nil { + t.Error("expected error, got nil") + } + if _, ok := err.(*solace.PublisherOverflowError); !ok { + t.Errorf("expected would block error, got %s", err) + } + + select { + case <-publisher.buffer: + default: + t.Error("expected message to be present in publisher buffer") + } + + err = publisher.Publish(testMessage, testReplyHandler, testTopic, testTimeout, nil /*properties*/, nil /*usercontext*/) + if err != nil { + t.Error(err) + } +} + +func TestRequestReplyMessagePublisherPublishFunctionalityDirect(t *testing.T) { + publisher := &requestReplyMessagePublisherImpl{} + internalPublisher := &mockInternalPublisher{} + publisher.construct(internalPublisher, backpressureConfigurationDirect, 1) + eventExecutor := &mockEventExecutor{} + taskBuffer := &mockTaskBuffer{} + publisher.eventExecutor = eventExecutor + publisher.taskBuffer = taskBuffer + + publisher.Start() + + taskBufferSubmitCalled := make(chan struct{}) + taskBuffer.submit = func(task buffer.PublisherTask) bool { + close(taskBufferSubmitCalled) + return true + } + + publishCalled := false + internalPublisher.publish = func(message ccsmp.SolClientMessagePt) core.ErrorInfo { + publishCalled = true + return nil + } + + testMessage, _ := message.NewOutboundMessage() + testTopic := resource.TopicOf("hello/world") + testReplyHandler := func(msg apimessage.InboundMessage, usercontext interface{}, err error) {} + testTimeout := time.Duration(-1) // forever + err := publisher.Publish(testMessage, testReplyHandler, testTopic, testTimeout, nil /*properties*/, nil /*usercontext*/) + if err != nil { + t.Error(err) + } + if !publishCalled { + t.Error("expected internal publisher's publish function to be called directly") + } + + select { + case <-taskBufferSubmitCalled: + t.Error("Expect task buffer submit to not be called") + default: + // success + } + + internalPublisher.publish = func(message ccsmp.SolClientMessagePt) core.ErrorInfo { + return &ccsmp.SolClientErrorInfoWrapper{ + ReturnCode: ccsmp.SolClientReturnCodeWouldBlock, + } + } + err = publisher.Publish(testMessage, testReplyHandler, testTopic, testTimeout, nil /*properties*/, nil /*usercontext*/) + if err == nil { + t.Error("expected error, got nil") + } + if _, ok := err.(*solace.PublisherOverflowError); !ok { + t.Errorf("expected would block error, got %s", err) + } + + subCode := 21 // ClientDeleteInProgress , note this subcode does not matter just need a subcode that is not OK. + internalPublisher.publish = func(message ccsmp.SolClientMessagePt) core.ErrorInfo { + return &ccsmp.SolClientErrorInfoWrapper{ + ReturnCode: ccsmp.SolClientReturnCodeFail, + SubCode: ccsmp.SolClientSubCode(subCode), + } + } + + err = publisher.Publish(testMessage, testReplyHandler, testTopic, testTimeout, nil /*properties*/, nil /*usercontext*/) + if err == nil { + t.Error("expected error, got nil") + } + if msg, ok := err.(*solace.NativeError); ok { + if msg.SubCode() != subcode.Code(subCode) { + t.Errorf("expected sub code to be %d, got %d", subCode, msg.SubCode()) + } + } else { + t.Errorf("expected pubsubplus client error, got %s", err) + } +} + +func TestRequestReplyMessagePublisherTask(t *testing.T) { + publisher := &requestReplyMessagePublisherImpl{} + internalPublisher := &mockInternalPublisher{} + publisher.construct(internalPublisher, backpressureConfigurationWait, 1) + eventExecutor := &mockEventExecutor{} + taskBuffer := &mockTaskBuffer{} + publisher.eventExecutor = eventExecutor + publisher.taskBuffer = taskBuffer + + err := publisher.Start() + if err != nil { + t.Error(err) + } + + publishCalled := false + internalPublisher.publish = func(message ccsmp.SolClientMessagePt) core.ErrorInfo { + publishCalled = true + return nil + } + + testMessage, _ := message.NewOutboundMessage() + testTopic := resource.TopicOf("hello/world") + testReplyHandler := func(msg apimessage.InboundMessage, usercontext interface{}, err error) {} + testTimeout := time.Duration(-1) // forever + + sendTaskChannel := make(chan buffer.PublisherTask, 1) + taskBuffer.submit = func(task buffer.PublisherTask) bool { + sendTaskChannel <- task + return true + } + err = publisher.Publish(testMessage, testReplyHandler, testTopic, testTimeout, nil /*properties*/, nil /*usercontext*/) + + if err != nil { + t.Error(err) + } + + var sendTask buffer.PublisherTask + select { + case sendTask = <-sendTaskChannel: + default: + t.Error("did not encounter a send task") + } + sendTask(make(chan struct{})) + if !publishCalled { + t.Error("internal publisher publish was never called") + } +} + +func TestRequestReplyMessagePublisherTaskWithWouldBlock(t *testing.T) { + publisher := &requestReplyMessagePublisherImpl{} + internalPublisher := &mockInternalPublisher{} + publisher.construct(internalPublisher, backpressureConfigurationWait, 1) + eventExecutor := &mockEventExecutor{} + taskBuffer := &mockTaskBuffer{} + publisher.eventExecutor = eventExecutor + publisher.taskBuffer = taskBuffer + + publisher.Start() + + publishCalled := false + publishRecalled := false + internalPublisher.publish = func(message ccsmp.SolClientMessagePt) core.ErrorInfo { + if !publishCalled { + publishCalled = true + return &ccsmp.SolClientErrorInfoWrapper{ + ReturnCode: ccsmp.SolClientReturnCodeWouldBlock, + } + } + publishRecalled = true + // subsequent calls are successful + return nil + } + + interruptChannel := make(chan struct{}) + awaitWritableCalled := false + internalPublisher.awaitWritable = func(terminateSignal chan struct{}) error { + if interruptChannel != terminateSignal { + t.Error("expected terminate signal passed to awaitWritable to be the event executors terminate signal, it was not") + } + awaitWritableCalled = true + return nil + } + + testMessage, _ := message.NewOutboundMessage() + testTopic := resource.TopicOf("hello/world") + testReplyHandler := func(msg apimessage.InboundMessage, usercontext interface{}, err error) {} + testTimeout := time.Duration(-1) // forever + + taskBuffer.submit = func(task buffer.PublisherTask) bool { + task(interruptChannel) + return true + } + + err := publisher.Publish(testMessage, testReplyHandler, testTopic, testTimeout, nil /*properties*/, nil /*usercontext*/) + if err != nil { + t.Error(err) + } + if !awaitWritableCalled { + t.Error("await writable not called despite being passed would block") + } + if !publishRecalled { + t.Error("expected redelivery to be attempted, it was not") + } +} + +func TestRequestReplyMessagePublisherTaskWithWouldBlockInterrupted(t *testing.T) { + publisher := &requestReplyMessagePublisherImpl{} + internalPublisher := &mockInternalPublisher{} + publisher.construct(internalPublisher, backpressureConfigurationWait, 1) + eventExecutor := &mockEventExecutor{} + taskBuffer := &mockTaskBuffer{} + publisher.eventExecutor = eventExecutor + publisher.taskBuffer = taskBuffer + + publisher.Start() + + publishCalled := false + publishRecalled := false + internalPublisher.publish = func(message ccsmp.SolClientMessagePt) core.ErrorInfo { + if !publishCalled { + publishCalled = true + return &ccsmp.SolClientErrorInfoWrapper{ + ReturnCode: ccsmp.SolClientReturnCodeWouldBlock, + } + } + t.Error("did not expect publisher's publish to be reattempted after returning error from awaitWritable") + // subsequent calls are successful + return nil + } + + interruptChannel := make(chan struct{}) + awaitWritableCalled := false + internalPublisher.awaitWritable = func(terminateSignal chan struct{}) error { + if interruptChannel != terminateSignal { + t.Error("expected terminate signal passed to awaitWritable to be the event executors terminate signal, it was not") + } + awaitWritableCalled = true + return fmt.Errorf("some error") + } + + testMessage, _ := message.NewOutboundMessage() + testTopic := resource.TopicOf("hello/world") + testReplyHandler := func(msg apimessage.InboundMessage, usercontext interface{}, err error) {} + testTimeout := time.Duration(-1) // forever + + taskBuffer.submit = func(task buffer.PublisherTask) bool { + task(interruptChannel) + return true + } + + err := publisher.Publish(testMessage, testReplyHandler, testTopic, testTimeout, nil /*properties*/, nil /*usercontext*/) + if err != nil { + t.Error(err) + } + if !awaitWritableCalled { + t.Error("await writable not called despite being passed would block") + } + if publishRecalled { + t.Error("expected redelivery to not be attempted when an error was received from awaitWritable") + } +} + +func TestRequestReplyMessagePublisherTaskFailureReplyOutcome(t *testing.T) { + publisherReplyToTopic := "testReplyTopic" + + publisher := &requestReplyMessagePublisherImpl{} + + internalPublisher := &mockInternalPublisher{} + internalPublisher.requestor = func() core.Requestor { + mock := &mockRequestor{} + mock.addRequestorReplyHandler = func(handler core.RequestorReplyHandler) (string, func() (messageID uint64, correlationID string), core.ErrorInfo) { + count := uint64(0) + return publisherReplyToTopic, func() (uint64, string) { + count += 1 + return count, fmt.Sprintf("TEST%d", count) + }, nil + } + return mock + } + + publisher.construct(internalPublisher, backpressureConfigurationWait, 1) + eventExecutor := &mockEventExecutor{} + taskBuffer := &mockTaskBuffer{} + publisher.eventExecutor = eventExecutor + publisher.taskBuffer = taskBuffer + + publisher.Start() + + subCode := 58 // MissingReplyTo, note this subcode does not matter and does not represent a real scenario + internalPublisher.publish = func(message ccsmp.SolClientMessagePt) core.ErrorInfo { + return &ccsmp.SolClientErrorInfoWrapper{ + ReturnCode: ccsmp.SolClientReturnCodeFail, + SubCode: ccsmp.SolClientSubCode(subCode), + } + } + + testMessage, _ := message.NewOutboundMessage() + testTopic := resource.TopicOf("hello/world") + testTimeout := time.Duration(-1) // forever + testUserContext := uint64(27182) + + testReplyHandler := func(msg apimessage.InboundMessage, usercontext interface{}, err error) { + if msg != nil { + t.Error("expected inbound reply message to be nil") + } + if usercontext != testUserContext { + t.Error("expected usercontext to match published context") + } + + if err == nil { + t.Error("expected error to not be nil") + } else { + if casted, ok := err.(*solace.NativeError); ok { + if casted.SubCode() != subcode.Code(subCode) { + t.Errorf("expected sub code %d, got %d", subCode, casted.SubCode()) + } + } else { + t.Errorf("expected to get a PubSubPlusClientError, got %T", err) + } + } + } + + taskBuffer.submit = func(task buffer.PublisherTask) bool { + task(make(chan struct{})) + return true + } + + eventExecutor.submit = func(event executor.Task) bool { + event() + return true + } + + err := publisher.Publish(testMessage, testReplyHandler, testTopic, testTimeout, nil /*properties*/, testUserContext) + if err != nil { + t.Error(err) + } + publisher.Terminate(1 * time.Second) +} + +func TestRequestReplyMessagePublisherReadinessListener(t *testing.T) { + publisher := &requestReplyMessagePublisherImpl{} + internalPublisher := &mockInternalPublisher{} + publisher.construct(internalPublisher, backpressureConfigurationReject, 1) + eventExecutor := &mockEventExecutor{} + taskBuffer := &mockTaskBuffer{} + publisher.eventExecutor = eventExecutor + publisher.taskBuffer = taskBuffer + + publisher.Start() + + testMessage, _ := message.NewOutboundMessage() + testTopic := resource.TopicOf("hello/world") + testReplyHandler := func(msg apimessage.InboundMessage, usercontext interface{}, err error) {} + testTimeout := time.Duration(-1) // forever + + taskBuffer.submit = func(task buffer.PublisherTask) bool { + task(make(chan struct{})) + return true + } + + eventExecutor.submit = func(event executor.Task) bool { + event() + return true + } + + readinessCalled := false + publisher.SetPublisherReadinessListener(func() { + readinessCalled = true + }) + + err := publisher.Publish(testMessage, testReplyHandler, testTopic, testTimeout, nil /*properties*/, nil /*usercontext*/) + if err != nil { + t.Error(err) + } + if !readinessCalled { + t.Error("expected readiness listener to be called, it was not") + } +} diff --git a/internal/impl/receiver/message_receiver_impl_test.go b/internal/impl/receiver/message_receiver_impl_test.go index 43a3fb2..939ab9d 100644 --- a/internal/impl/receiver/message_receiver_impl_test.go +++ b/internal/impl/receiver/message_receiver_impl_test.go @@ -284,6 +284,7 @@ type result struct { type mockInternalReceiver struct { events func() core.Events + replier func() core.Replier isRunning func() bool registerRxCallback func(callback core.RxCallback) uintptr unregisterRxCallback func(ptr uintptr) @@ -300,6 +301,13 @@ func (mock *mockInternalReceiver) Events() core.Events { return &mockEvents{} } +func (mock *mockInternalReceiver) Replier() core.Replier { + if mock.replier != nil { + return mock.replier() + } + return &mockReplier{} +} + func (mock *mockInternalReceiver) IsRunning() bool { if mock.isRunning != nil { return mock.isRunning() @@ -435,6 +443,17 @@ func (events *mockEvents) AddEventHandler(sessionEvent core.Event, responseCode func (events *mockEvents) RemoveEventHandler(id uint) { } +type mockReplier struct { + sendReply func(replyMsg core.ReplyPublishable) core.ErrorInfo +} + +func (replier *mockReplier) SendReply(replyMsg core.ReplyPublishable) core.ErrorInfo { + if replier.sendReply != nil { + return replier.sendReply(replyMsg) + } + return nil +} + type notASubscription struct{} func (sub *notASubscription) GetName() string { diff --git a/internal/impl/receiver/request_reply_message_receiver_impl.go b/internal/impl/receiver/request_reply_message_receiver_impl.go new file mode 100644 index 0000000..7e81318 --- /dev/null +++ b/internal/impl/receiver/request_reply_message_receiver_impl.go @@ -0,0 +1,337 @@ +// pubsubplus-go-client +// +// Copyright 2024 Solace Corporation. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package receiver + +import ( + "fmt" + "runtime" + "time" + + "solace.dev/go/messaging/internal/ccsmp" + "solace.dev/go/messaging/internal/impl/constants" + "solace.dev/go/messaging/internal/impl/core" + "solace.dev/go/messaging/internal/impl/message" + "solace.dev/go/messaging/pkg/solace" + "solace.dev/go/messaging/pkg/solace/config" + apimessage "solace.dev/go/messaging/pkg/solace/message" + "solace.dev/go/messaging/pkg/solace/resource" +) + +type requestReplyMessageReceiverImpl struct { + directReceiver *directMessageReceiverImpl +} + +func (*requestReplyMessageReceiverImpl) construct() { +} + +// common functions signature for MessageReceiver interface + +// IsRunning checks if the process was successfully started and not yet stopped. +// Returns true if running, false otherwise. +func (receiver *requestReplyMessageReceiverImpl) IsRunning() bool { + return receiver.directReceiver.IsRunning() +} + +// IsTerminates checks if message delivery process is terminated. +// Returns true if terminated, false otherwise. +func (receiver *requestReplyMessageReceiverImpl) IsTerminated() bool { + return receiver.directReceiver.IsTerminated() +} + +// IsTerminating checks if the delivery process termination is ongoing. +// Returns true if the message delivery process is being terminated, +// but termination is not yet complete, otherwise false. +func (receiver *requestReplyMessageReceiverImpl) IsTerminating() bool { + return receiver.directReceiver.IsTerminating() +} + +// SetTerminationNotificationListener adds a callback to listen for +// non-recoverable interruption events. +func (receiver *requestReplyMessageReceiverImpl) SetTerminationNotificationListener(listener solace.TerminationNotificationListener) { + receiver.directReceiver.SetTerminationNotificationListener(listener) +} + +// Lifecycle and subscription functions that can use the original direct receiver + +// Start will start the service synchronously. +// Before this function is called, the service is considered +// off-duty. To operate normally, this function must be called on +// a receiver or publisher instance. This function is idempotent. +// Returns an error if one occurred or nil if successful. +func (receiver *requestReplyMessageReceiverImpl) Start() (err error) { + return receiver.directReceiver.Start() +} + +// StartAsync will start the service asynchronously. +// Before this function is called, the service is considered +// off-duty. To operate normally, this function must be called on +// a receiver or publisher instance. This function is idempotent. +// Returns a channel that will receive an error if one occurred or +// nil if successful. Subsequent calls will return additional +// channels that can await an error, or nil if already started. +func (receiver *requestReplyMessageReceiverImpl) StartAsync() <-chan error { + return receiver.directReceiver.StartAsync() +} + +// StartAsyncCallback will start the DirectMessageReceiver asynchronously. +// Calls the callback when started with an error if one occurred or nil +// if successful. +func (receiver *requestReplyMessageReceiverImpl) StartAsyncCallback(callback func(solace.RequestReplyMessageReceiver, error)) { + receiver.directReceiver.StartAsyncCallback(func(_ solace.DirectMessageReceiver, err error) { + callback(receiver, err) + }) +} + +// Terminate will terminate the service gracefully and synchronously. +// This function is idempotent. The only way to resume operation +// after this function is called is to create a new instance. +// Any attempt to call this function renders the instance +// permanently terminated, even if this function completes. +// A graceful shutdown will be attempted within the grace period. +// A grace period of 0 implies a non-graceful shutdown that ignores +// unfinished tasks or in-flight messages. +// This function blocks until the service is terminated. +// If gracePeriod is less than 0, the function will wait indefinitely. +func (receiver *requestReplyMessageReceiverImpl) Terminate(gracePeriod time.Duration) (err error) { + return receiver.directReceiver.Terminate(gracePeriod) +} + +// TerminateAsync will terminate the service asynchronously. +// This function is idempotent. The only way to resume operation +// after this function is called is to create a new instance. +// Any attempt to call this function renders the instance +// permanently terminated, even if this function completes. +// A graceful shutdown will be attempted within the grace period. +// A grace period of 0 implies a non-graceful shutdown that ignores +// unfinished tasks or in-flight messages. +// Returns a channel that will receive an error if one occurred or +// nil if successfully and gracefully terminated. +// If gracePeriod is less than 0, the function will wait indefinitely. +func (receiver *requestReplyMessageReceiverImpl) TerminateAsync(gracePeriod time.Duration) <-chan error { + return receiver.directReceiver.TerminateAsync(gracePeriod) +} + +// TerminateAsyncCallback will terminate the RequestReplyMessageReceiver asynchronously. +// Calls the callback when terminated with nil if successful or an error if +// one occurred. If gracePeriod is less than 0, the function will wait indefinitely. +func (receiver *requestReplyMessageReceiverImpl) TerminateAsyncCallback(gracePeriod time.Duration, callback func(error)) { + receiver.directReceiver.TerminateAsyncCallback(gracePeriod, callback) +} + +// AddSubscription will subscribe to another message source on a PubSub+ Broker to receive messages from. +// Will block until subscription is added. +// Returns a solace/errors.*IllegalStateError if the service is not running. +// Returns a solace/errors.*IllegalArgumentError if unsupported Subscription type is passed. +// Returns nil if successful. +func (receiver *requestReplyMessageReceiverImpl) AddSubscription(subscription resource.Subscription) error { + return receiver.directReceiver.AddSubscription(subscription) +} + +// RemoveSubscription will unsubscribe from a previously subscribed message source on a broker +// such that no more messages will be received from it. +// Will block until subscription is removed. +// Returns an solace/errors.*IllegalStateError if the service is not running. +// Returns a solace/errors.*IllegalArgumentError if unsupported Subscription type is passed. +// Returns nil if successful. +func (receiver *requestReplyMessageReceiverImpl) RemoveSubscription(subscription resource.Subscription) error { + return receiver.directReceiver.RemoveSubscription(subscription) +} + +// AddSubscriptionAsync will subscribe to another message source on a PubSub+ Broker to receive messages from. +// Will block until subscription is added. +// Returns a solace/errors.*IllegalStateError if the service is not running. +// Returns a solace/errors.*IllegalArgumentError if unsupported Subscription type is passed. +// Returns nil if successful. +func (receiver *requestReplyMessageReceiverImpl) AddSubscriptionAsync(subscription resource.Subscription, listener solace.SubscriptionChangeListener) error { + return receiver.directReceiver.AddSubscriptionAsync(subscription, listener) +} + +// RemoveSubscriptionAsync will unsubscribe from a previously subscribed message source on a broker +// such that no more messages will be received from it. Will block until subscription is removed. +// Returns an solace/errors.*IllegalStateError if the service is not running. +// Returns a solace/errors.*IllegalArgumentError if unsupported Subscription type is passed. +// Returns nil if successful. +func (receiver *requestReplyMessageReceiverImpl) RemoveSubscriptionAsync(subscription resource.Subscription, listener solace.SubscriptionChangeListener) error { + return receiver.directReceiver.RemoveSubscriptionAsync(subscription, listener) +} + +func (receiver *requestReplyMessageReceiverImpl) ReceiveMessage(timeout time.Duration) (apimessage.InboundMessage, solace.Replier, error) { + inboundMessage, err := receiver.directReceiver.ReceiveMessage(timeout) + if err != nil { + return nil, nil, err + } + // check inbound message for ReplyTo destination and correlation id and create replier + if replier, hasReply := NewReplierImpl(inboundMessage, receiver.directReceiver.internalReceiver); hasReply { + return inboundMessage, replier, nil + } + return inboundMessage, nil, nil +} + +// ReceiveAsync will register a callback to be called when new messages +// are received. Returns an error one occurred while registering the callback. +// If a callback is already registered, it will be replaced by the given +// callback. +func (receiver *requestReplyMessageReceiverImpl) ReceiveAsync(callback solace.RequestMessageHandler) (err error) { + if callback != nil { + return receiver.directReceiver.ReceiveAsync(func(msg apimessage.InboundMessage) { + // create Replier from Inbound Message + if replier, hasReply := NewReplierImpl(msg, receiver.directReceiver.internalReceiver); hasReply { + callback(msg, replier) + } else { + if receiver.directReceiver.logger.IsDebugEnabled() { + receiver.directReceiver.logger.Debug("Received message without request fields [correlationId or reply destination]") + } + callback(msg, nil) + } + }) + } + return receiver.directReceiver.ReceiveAsync(nil) +} + +func (receiver *requestReplyMessageReceiverImpl) String() string { + return fmt.Sprintf("solace.RequestReplyMessageReceiver at %p", receiver) +} + +// Replier impl struct +type replierImpl struct { + internalReplier core.Replier + correlationID string + replyToDestination string +} + +// NewReplierImpl function +func NewReplierImpl(requestMsg apimessage.InboundMessage, internalReceiver core.Receiver) (solace.Replier, bool) { + replyToDestination, ok := message.GetReplyToDestinationName(requestMsg.(*message.InboundMessageImpl)) + if !ok { + return nil, ok + } + correlationID, ok := requestMsg.GetCorrelationID() + if !ok { + return nil, ok + } + replier := &replierImpl{} + replier.construct(correlationID, replyToDestination, internalReceiver.Replier()) + return replier, true +} + +func (replier *replierImpl) construct(correlationID string, replyToDestination string, coreReplier core.Replier) { + replier.internalReplier = coreReplier + replier.correlationID = correlationID + replier.replyToDestination = replyToDestination +} + +func (replier *replierImpl) Reply(msg apimessage.OutboundMessage) error { + if msg == nil { + return solace.NewError(&solace.IllegalArgumentError{}, "Replier must have OutboundMessage not nil", nil) + } + msgImpl, ok := msg.(*message.OutboundMessageImpl) + if !ok { + return solace.NewError(&solace.IllegalArgumentError{}, "Replier must have OutboundMessage from OutboundMessageBuilder", nil) + } + replyMsg, err := message.DuplicateOutboundMessage(msgImpl) + if err != nil { + return err + } + defer func() { + replyMsg.Dispose() + }() + if err = message.SetAsReplyMessage(replyMsg, replier.replyToDestination, replier.correlationID); err != nil { + return err + } + if errInfo := replier.internalReplier.SendReply(message.GetOutboundMessagePointer(replyMsg)); errInfo != nil { + // error info would block return code for simple transport reject back pressure from replier + if errInfo.ReturnCode == ccsmp.SolClientReturnCodeWouldBlock { + err = solace.NewError(&solace.PublisherOverflowError{}, constants.WouldBlock, nil) + } else { + err = core.ToNativeError(errInfo, constants.ReplierFailureToPublishReply) + } + } + + runtime.KeepAlive(replyMsg) + return err +} + +// Builder impl struct +type requestReplyMessageReceiverBuilderImpl struct { + directReceiverBuilder solace.DirectMessageReceiverBuilder +} + +// NewRequestReplyMessageReceiverBuilderImpl function +func NewRequestReplyMessageReceiverBuilderImpl(internalReceiver core.Receiver) solace.RequestReplyMessageReceiverBuilder { + return &requestReplyMessageReceiverBuilderImpl{ + directReceiverBuilder: NewDirectMessageReceiverBuilderImpl(internalReceiver), + } +} + +// Build will build a new RequestReplyMessageReceiver with the given properties. +// Returns solace/errors.*InvalidConfigurationError if an invalid configuration is provided. +func (builder *requestReplyMessageReceiverBuilderImpl) Build(requestTopicSubscription resource.Subscription) (messageReceiver solace.RequestReplyMessageReceiver, err error) { + return builder.BuildWithSharedSubscription(requestTopicSubscription, nil) +} + +func (builder *requestReplyMessageReceiverBuilderImpl) BuildWithSharedSubscription(requestTopicSubscription resource.Subscription, shareName *resource.ShareName) (messageReceiver solace.RequestReplyMessageReceiver, err error) { + if requestTopicSubscription == nil { + return nil, solace.NewError(&solace.InvalidConfigurationError{}, "RequestReplyReceiverBuilder must have a requestTopicSubscription subscription", nil) + } + builder.withSubscriptions(requestTopicSubscription) + receiver, err := builder.directReceiverBuilder.BuildWithShareName(shareName) + if err != nil { + return nil, err + } + rrReceiver := &requestReplyMessageReceiverImpl{ + directReceiver: receiver.(*directMessageReceiverImpl), + } + rrReceiver.construct() + return rrReceiver, nil +} + +// FromConfigurationProvider will configure the request reply receiver with the given properties. +// Built in ReceiverPropertiesConfigurationProvider implementations include: +// +// ReceiverPropertyMap, a map of ReceiverProperty keys to values +func (builder *requestReplyMessageReceiverBuilderImpl) FromConfigurationProvider(provider config.ReceiverPropertiesConfigurationProvider) solace.RequestReplyMessageReceiverBuilder { + builder.directReceiverBuilder.FromConfigurationProvider(provider) + return builder +} + +// OnBackPressureDropLatest will configure the receiver with the given buffer size. If the buffer +// is full and a message arrives, the incoming message will be discarded. +// bufferCapacity must be >= 1 +func (builder *requestReplyMessageReceiverBuilderImpl) OnBackPressureDropLatest(bufferCapacity uint) solace.RequestReplyMessageReceiverBuilder { + builder.directReceiverBuilder.OnBackPressureDropLatest(bufferCapacity) + return builder +} + +// OnBackPressureDropOldest will configure the receiver with the given buffer size. If the buffer +// is full and a message arrives, the oldest message in the buffer will be discarded. +// bufferCapacity must be >= 1 +func (builder *requestReplyMessageReceiverBuilderImpl) OnBackPressureDropOldest(bufferCapacity uint) solace.RequestReplyMessageReceiverBuilder { + builder.directReceiverBuilder.OnBackPressureDropOldest(bufferCapacity) + return builder +} + +// withSubscriptions will set a list of TopicSubscriptions to subscribe +// to when starting the receiver. +// for internal use for now +func (builder *requestReplyMessageReceiverBuilderImpl) withSubscriptions(topics ...resource.Subscription) solace.RequestReplyMessageReceiverBuilder { + builder.directReceiverBuilder.WithSubscriptions(topics[0]) + return builder +} + +func (builder *requestReplyMessageReceiverBuilderImpl) String() string { + return fmt.Sprintf("solace.RequestReplyMessageReceiverBuilder at %p", builder) +} diff --git a/internal/impl/receiver/request_reply_message_receiver_impl_test.go b/internal/impl/receiver/request_reply_message_receiver_impl_test.go new file mode 100644 index 0000000..0a967cf --- /dev/null +++ b/internal/impl/receiver/request_reply_message_receiver_impl_test.go @@ -0,0 +1,360 @@ +// pubsubplus-go-client +// +// Copyright 2024 Solace Corporation. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package receiver + +// As the requestReplyMessageReceiverImpl and requestReplyMessageReceiverBuilderImpl wraps there corresponding +// this testing module will focus on the replierImpl struct testing and builder configuration testing. + +import ( + //"fmt" + "runtime" + "testing" + + "solace.dev/go/messaging/internal/ccsmp" + + "solace.dev/go/messaging/internal/impl/constants" + "solace.dev/go/messaging/internal/impl/core" + "solace.dev/go/messaging/internal/impl/message" + + "solace.dev/go/messaging/pkg/solace" + "solace.dev/go/messaging/pkg/solace/config" + "solace.dev/go/messaging/pkg/solace/resource" +) + +func TestReplierWithInvalidReplyMessage(t *testing.T) { + testCorrelationID := "#TEST0" + testReplyToTopic := "testReplyToDestination" + replier := &replierImpl{} + replier.construct(testCorrelationID, testReplyToTopic, &mockReplier{}) + + //testReplyMessage := message.NewOutboundMessage() + + err := replier.Reply(nil) + if err == nil { + t.Error("Expected error from Reply call with nil message") + } +} + +func TestReplierFailedSendReply(t *testing.T) { + subCode := 67 + testCorrelationID := "#TEST0" + testReplyToTopic := "testReplyToDestination" + replier := &replierImpl{} + internalReplier := &mockReplier{} + + replyErrInfo := &ccsmp.SolClientErrorInfoWrapper{ + ReturnCode: ccsmp.SolClientReturnCodeFail, + SubCode: ccsmp.SolClientSubCode(subCode), + } + + internalReplier.sendReply = func(replyMsg core.ReplyPublishable) core.ErrorInfo { + return replyErrInfo + } + replier.construct(testCorrelationID, testReplyToTopic, internalReplier) + testReplyMessage, _ := message.NewOutboundMessage() + err := replier.Reply(testReplyMessage) + if err == nil { + t.Error("Expected error from Reply call with failure to publish") + } + if _, ok := err.(*solace.NativeError); !ok { + t.Error("Error returned was not the expected error type") + } else if err.Error() != constants.ReplierFailureToPublishReply { + t.Errorf("Error returned was not the expect error message. Expected: '%s' got: '%s'", constants.ReplierFailureToPublishReply, err.Error()) + } +} + +func TestReplierWouldBlockSendReply(t *testing.T) { + subCode := 0 // ok as last error is not set on solclient would block return + testCorrelationID := "#TEST0" + testReplyToTopic := "testReplyToDestination" + replier := &replierImpl{} + internalReplier := &mockReplier{} + + replyErrInfo := &ccsmp.SolClientErrorInfoWrapper{ + ReturnCode: ccsmp.SolClientReturnCodeWouldBlock, + SubCode: ccsmp.SolClientSubCode(subCode), + } + + internalReplier.sendReply = func(replyMsg core.ReplyPublishable) core.ErrorInfo { + return replyErrInfo + } + replier.construct(testCorrelationID, testReplyToTopic, internalReplier) + testReplyMessage, _ := message.NewOutboundMessage() + err := replier.Reply(testReplyMessage) + if err == nil { + t.Error("Expected error from Reply call with failure to publish") + } + + if _, ok := err.(*solace.PublisherOverflowError); !ok { + t.Error("Error returned was not the expected error type PublisherOverflowError") + } else if err.Error() != constants.WouldBlock { + t.Errorf("Error returned was not the expect error message. Expected: '%s' got: '%s'", constants.WouldBlock, err.Error()) + } +} + +func TestReplierMessageParametersOnReply(t *testing.T) { + testCorrelationID := "#TEST0" + testReplyToTopic := "testReplyToDestination" + replier := &replierImpl{} + internalReplier := &mockReplier{} + var replyMsgDestination string + var replyMsgCorrelationID string + var replyMsgCorrelationIDOk bool = false + // extract fields in the sendReplyFunction + internalReplier.sendReply = func(replyMsg core.ReplyPublishable) core.ErrorInfo { + // grab the prepare reply message and turn into a message that can access fields + testReplyMsg := message.NewInboundMessage(replyMsg, false) + replyMsgDestination = testReplyMsg.GetDestinationName() + replyMsgCorrelationID, replyMsgCorrelationIDOk = testReplyMsg.GetCorrelationID() + // detach from free as the caller will free the message + runtime.SetFinalizer(testReplyMsg, nil) + return nil + } + replier.construct(testCorrelationID, testReplyToTopic, internalReplier) + testReplyMessage, _ := message.NewOutboundMessage() + err := replier.Reply(testReplyMessage) + if err != nil { + t.Errorf("Got error calling replier.Reply: %s", err) + } + + if replyMsgDestination != testReplyToTopic { + t.Errorf("Replier.Reply did not set the expected replyTo destination name[%s], instead set destination[%s]", testReplyToTopic, replyMsgDestination) + } + + if !replyMsgCorrelationIDOk { + t.Error("Replier.Reply did not set the correlationID on the reply message") + } else if replyMsgCorrelationID != testCorrelationID { + t.Errorf("Replier.Reply did not set the expected correlationID[%s], instead set correlationID[%s]", testCorrelationID, replyMsgCorrelationID) + } +} + +func TestNewReplierWithoutReplyDestination(t *testing.T) { + builder := message.NewOutboundMessageBuilder() + outMsg, err := builder.WithCorrelationID("#Test0").BuildWithStringPayload("testpayload", nil) + if err != nil { + t.Error("Failed to build message with correlation id") + } + outMsgImpl, ok := outMsg.(*message.OutboundMessageImpl) + if !ok { + t.Error("Failed to access internal outbound message impl struct") + } + msgP, errInfo := ccsmp.SolClientMessageDup(message.GetOutboundMessagePointer(outMsgImpl)) + if errInfo != nil { + t.Error("Failed to extract duplicate core message from constructed message") + } + // create inbound message from constructed outbound message + internalReceiver := &mockInternalReceiver{} + receivedMsg := message.NewInboundMessage(msgP, false) + _, hasReply := NewReplierImpl(receivedMsg, internalReceiver) + if hasReply { + t.Error("Created replier for message without replyTo destination") + } +} + +func TestNewReplierWithoutCorrelationID(t *testing.T) { + builder := message.NewOutboundMessageBuilder() + outMsg, err := builder.BuildWithStringPayload("testpayload", nil) + if err != nil { + t.Error("Failed to build message") + } + outMsgImpl, ok := outMsg.(*message.OutboundMessageImpl) + if !ok { + t.Error("Failed to access internal outbound message impl struct") + } + err = message.SetReplyToDestination(outMsgImpl, "testReplyTopic") + if err != nil { + t.Error("Failed to set the replyTo destination") + } + msgP, errInfo := ccsmp.SolClientMessageDup(message.GetOutboundMessagePointer(outMsgImpl)) + if errInfo != nil { + t.Error("Failed to extract duplicate core message from constructed message") + } + // create inbound message from constructed outbound message + internalReceiver := &mockInternalReceiver{} + receivedMsg := message.NewInboundMessage(msgP, false) + _, hasReply := NewReplierImpl(receivedMsg, internalReceiver) + if hasReply { + t.Error("Created replier for message without correlationID") + } +} + +func TestNewReplierFromRequestMessage(t *testing.T) { + // a request message has two message fields to allow for the request to be replied to + // these are: + // - the ReplyTo Destination, to route the reply back to the originator (publisher making the request) + // - the correlation, to uniquely identify the request to reply to and correlate the response at the origin + // NewReplierImpl should only return hasReply == true if and only if both fields are set on a Inbound Message + + // construct inbound message with both fields + // use outbound message to construct the message + builder := message.NewOutboundMessageBuilder() + // set correlationID + outMsg, err := builder.WithCorrelationID("#Test0").BuildWithStringPayload("testpayload", nil) + if err != nil { + t.Error("Failed to build message with correlation id") + } + outMsgImpl, ok := outMsg.(*message.OutboundMessageImpl) + if !ok { + t.Error("Failed to access internal outbound message impl struct") + } + // set reply to destination + err = message.SetReplyToDestination(outMsgImpl, "testReplyTopic") + if err != nil { + t.Error("Failed to set the replyTo destination") + } + msgP, errInfo := ccsmp.SolClientMessageDup(message.GetOutboundMessagePointer(outMsgImpl)) + if errInfo != nil { + t.Error("Failed to extract duplicate core message from constructed message") + } + // create inbound message from constructed outbound message + internalReceiver := &mockInternalReceiver{} + receivedMsg := message.NewInboundMessage(msgP, false) + _, hasReply := NewReplierImpl(receivedMsg, internalReceiver) + if !hasReply { + t.Error("Failed to create replier from request message with both reply to destination and correlationID") + } +} + +func TestRequestReplyReceiverBuilderWithInvalidBackpressureStrategy(t *testing.T) { + subscription := resource.TopicSubscriptionOf("testTopic") + builder := NewRequestReplyMessageReceiverBuilderImpl(nil) + builder.FromConfigurationProvider(config.ReceiverPropertyMap{ + config.ReceiverPropertyDirectBackPressureStrategy: "notastrategy", + }) + _, err := builder.Build(subscription) + if err == nil { + t.Error("expected to get error when building with invalid backpressure strategy") + } +} + +func TestRequestReplyReceiverBuilderWithInvalidBackpressureBufferSize(t *testing.T) { + subscription := resource.TopicSubscriptionOf("testTopic") + builder := NewRequestReplyMessageReceiverBuilderImpl(nil) + builder.FromConfigurationProvider(config.ReceiverPropertyMap{ + config.ReceiverPropertyDirectBackPressureBufferCapacity: 0, + }) + _, err := builder.Build(subscription) + if err == nil { + t.Error("expected to get error when building with invalid backpressure buffer size") + } +} + +func TestRequestReplyReceiverBuilderWithInvalidBackpressureBufferSizeType(t *testing.T) { + subscription := resource.TopicSubscriptionOf("testTopic") + builder := NewRequestReplyMessageReceiverBuilderImpl(nil) + builder.FromConfigurationProvider(config.ReceiverPropertyMap{ + config.ReceiverPropertyDirectBackPressureBufferCapacity: "huh this isn't an int", + }) + _, err := builder.Build(subscription) + if err == nil { + t.Error("expected to get error when building with invalid backpressure buffer size") + } +} + +func TestRequestReplyReceiverBuilderWithValidConfigurationMapLatestDrop(t *testing.T) { + subscription := resource.TopicSubscriptionOf("testTopic") + builder := NewRequestReplyMessageReceiverBuilderImpl(nil) + builder.FromConfigurationProvider(config.ReceiverPropertyMap{ + config.ReceiverPropertyDirectBackPressureBufferCapacity: 2, + config.ReceiverPropertyDirectBackPressureStrategy: config.ReceiverBackPressureStrategyDropLatest, + }) + receiver, err := builder.Build(subscription) + if err != nil { + t.Error("did not expect to get an error when building with valid properties") + } + receiverImpl, ok := receiver.(*requestReplyMessageReceiverImpl) + if !ok { + t.Error("expected to get directMessageReceiverImpl back") + } + if receiverImpl.directReceiver.backpressureStrategy != strategyDropLatest { + t.Error("expected to get backpressure drop oldest") + } + if cap(receiverImpl.directReceiver.buffer) != 2 { + t.Error("expected buffer size of 2") + } +} + +func TestRequestReplyReceiverBuilderWithValidConfigurationMapOldestDrop(t *testing.T) { + subscription := resource.TopicSubscriptionOf("testTopic") + builder := NewRequestReplyMessageReceiverBuilderImpl(nil) + builder.FromConfigurationProvider(config.ReceiverPropertyMap{ + config.ReceiverPropertyDirectBackPressureBufferCapacity: 2, + config.ReceiverPropertyDirectBackPressureStrategy: config.ReceiverBackPressureStrategyDropOldest, + }) + receiver, err := builder.Build(subscription) + if err != nil { + t.Error("did not expect to get an error when building with valid properties") + } + receiverImpl, ok := receiver.(*requestReplyMessageReceiverImpl) + if !ok { + t.Error("expected to get directMessageReceiverImpl back") + } + if receiverImpl.directReceiver.backpressureStrategy != strategyDropOldest { + t.Error("expected to get backpressure drop oldest") + } + if cap(receiverImpl.directReceiver.buffer) != 2 { + t.Error("expected buffer size of 2") + } +} + +func TestRequestReplyReceiverBuilderBuildWithConfigurationOldestDrop(t *testing.T) { + subscription := resource.TopicSubscriptionOf("testTopic") + builder := NewRequestReplyMessageReceiverBuilderImpl(nil) + builder.OnBackPressureDropOldest(2) + receiver, err := builder.Build(subscription) + if err != nil { + t.Error("did not expect to get an error when building with valid properties") + } + receiverImpl, ok := receiver.(*requestReplyMessageReceiverImpl) + if !ok { + t.Error("expected to get directMessageReceiverImpl back") + } + if receiverImpl.directReceiver.backpressureStrategy != strategyDropOldest { + t.Error("expected to get backpressure drop oldest") + } + if cap(receiverImpl.directReceiver.buffer) != 2 { + t.Error("expected buffer size of 2") + } +} + +func TestRequestReplyReceiverBuilderBuildWithConfigurationLatestDrop(t *testing.T) { + subscription := resource.TopicSubscriptionOf("testTopic") + builder := NewRequestReplyMessageReceiverBuilderImpl(nil) + builder.OnBackPressureDropLatest(2) + receiver, err := builder.Build(subscription) + if err != nil { + t.Error("did not expect to get an error when building with valid properties") + } + receiverImpl, ok := receiver.(*requestReplyMessageReceiverImpl) + if !ok { + t.Error("expected to get directMessageReceiverImpl back") + } + if receiverImpl.directReceiver.backpressureStrategy != strategyDropLatest { + t.Error("expected to get backpressure drop oldest") + } + if cap(receiverImpl.directReceiver.buffer) != 2 { + t.Error("expected buffer size of 2") + } +} + +func TestRequestReplyReceiverBuilderBuildWithoutSubscription(t *testing.T) { + builder := NewRequestReplyMessageReceiverBuilderImpl(nil) + _, err := builder.Build(nil) + if err == nil { + t.Error("Did not get error for missing topic subscription on build") + } +} diff --git a/pkg/solace/messaging_service.go b/pkg/solace/messaging_service.go index 72f6cf9..f559922 100644 --- a/pkg/solace/messaging_service.go +++ b/pkg/solace/messaging_service.go @@ -65,6 +65,10 @@ type MessagingService interface { // used to build messages to send via a message publisher. MessageBuilder() OutboundMessageBuilder + // RequestReply creates a RequestReplyMessagingService that inherits + // the configuration of this MessagingService instance. + RequestReply() RequestReplyMessagingService + // Disconnect disconnects the messaging service. // The messaging service must be connected to disconnect. // This function blocks until the disconnection attempt is completed. @@ -144,6 +148,17 @@ type MessagingService interface { UpdateProperty(property config.ServiceProperty, value interface{}) error } +// RequestReplyMessagingService allows access to request reply behaviour. +type RequestReplyMessagingService interface { + // CreateRequestReplyMessagePublisherBuilder creates a new request reply message publisher + // builder that can be used to configure request reply publisher instances. + CreateRequestReplyMessagePublisherBuilder() RequestReplyMessagePublisherBuilder + + // CreateRequestReplyMessageReceiverBuilder creates a new request reply message receiver + // builder that can be used to configure request reply receiver instances. + CreateRequestReplyMessageReceiverBuilder() RequestReplyMessageReceiverBuilder +} + // MessagingServiceBuilder is used to configure and build MessagingService instances. type MessagingServiceBuilder interface { diff --git a/pkg/solace/request_reply_message_publisher.go b/pkg/solace/request_reply_message_publisher.go new file mode 100644 index 0000000..6449899 --- /dev/null +++ b/pkg/solace/request_reply_message_publisher.go @@ -0,0 +1,121 @@ +package solace + +import ( + "time" + + "solace.dev/go/messaging/pkg/solace/config" + "solace.dev/go/messaging/pkg/solace/message" + "solace.dev/go/messaging/pkg/solace/resource" +) + +// RequestReplyMessagePublisher allows for publishing of request-reply messages +// with handling for reply messages. +type RequestReplyMessagePublisher interface { + MessagePublisher + MessagePublisherHealthCheck + + // StartAsyncCallback will start the RequestReplyMessagePublisher asynchronously. + // Before this function is called, the service is considered + // off-duty. To operate normally, this function must be called on + // the RequestReplyMessageReceiver instance. This function is idempotent. + // Returns immediately and will call the callback function when ready + // passing the started RequestReplyMessageReceiver instance, or nil and + // an error if one occurred. Subsequent calls will register additional + // callbacks that will be called immediately if already started. + StartAsyncCallback(callback func(RequestReplyMessagePublisher, error)) + + // TerminateAsyncCallback will terminate the message publisher asynchronously. + // This function is idempotent. The only way to resume operation + // after this function is called is to create a new instance. + // Any attempt to call this function renders the instance + // permanently terminated, even if this function completes. + // A graceful shutdown will be attempted within the grace period. + // A grace period of 0 implies a non-graceful shutdown that ignores + // unfinished tasks or in-flight messages. + // Returns immediately and registers a callback that will receive an + // error if one occurred or nil if successfully and gracefully terminated. + // If gracePeriod is less than 0, the function will wait indefinitely. + TerminateAsyncCallback(gracePeriod time.Duration, callback func(error)) + + // PublishBytes sends a request for a reply of type byte array to the specified destination. + // The API will handle correlation of messages so no additional work is requried. + // Takes a requestMessage to send, a replyMessageHandler function to handle the + // response, a requestsDestination to deliver the requestMessage to, a replyTimeout + // indicating the maximum wait time for a response message and an optional + // userContext object given to the replyMessageHandler (may be nil). + // Returns an error if one occurred. If replyTimeout is less than 0, the function + // will wait indefinitely. Possible errors include: + // - solace/errors.*PubSubPlusClientError - If the message could not be sent and all retry attempts failed. + // - solace/errors.*PublisherOverflowError - If messages are published faster than publisher's I/O + // capabilities allow. When publishing can be resumed, the registered PublisherReadinessListeners + // are called. + PublishBytes(message []byte, replyMessageHandler ReplyMessageHandler, destination *resource.Topic, replyTimeout time.Duration, userContext interface{}) error + + // PublishString sends a request for a reply of type string to the specified destination. + // The API will handle correlation of messages so no additional work is requried. + // Takes a requestMessage to send, a replyMessageHandler function to handle the + // response, a requestsDestination to deliver the requestMessage to, a replyTimeout + // indicating the maximum wait time for a response message and an optional + // userContext object given to the replyMessageHandler (may be nil). + // Returns an error if one occurred. If replyTimeout is less than 0, the function + // will wait indefinitely. Possible errors include: + // - solace/errors.*PubSubPlusClientError - If the message could not be sent and all retry attempts failed. + // - solace/errors.*PublisherOverflowError - If messages are published faster than publisher's I/O + // capabilities allow. When publishing can be resumed, the registered PublisherReadinessListeners + // are called. + PublishString(message string, replyMessageHandler ReplyMessageHandler, destination *resource.Topic, replyTimeout time.Duration, userContext interface{}) error + + // Publish sends a request for a reply non-blocking with optional user context. + // The API will handle correlation of messages so no additional work is requried. + // Takes a requestMessage to send, a replyMessageHandler function to handle the + // response, a requestsDestination to deliver the requestMessage to, a replyTimeout + // indicating the maximum wait time for a response message and an optional + // userContext object given to the replyMessageHandler (may be nil). + // Returns an error if one occurred. If replyTimeout is less than 0, the function + // will wait indefinitely. Possible errors include: + // - solace/errors.*PubSubPlusClientError if the message could not be sent and all retry attempts failed. + // - solace/errors.*PublisherOverflowError if publishing messages faster than publisher's I/O + // capabilities allow. When publishing can be resumed, registered PublisherReadinessListeners + // will be called. + Publish(requestMessage message.OutboundMessage, replyMessageHandler ReplyMessageHandler, + requestsDestination *resource.Topic, replyTimeout time.Duration, + properties config.MessagePropertiesConfigurationProvider, userContext interface{}) error + + // PublishAwaitResponse will send a request for a reply blocking until a response is + // received. The API will handle correlation of messages so no additional work is required. + // Takes a requestMessage to send, a requestDestination to deliver the requestMessage to, + // and a replyTimeout indicating the maximum wait time for a response message. + // Will return the response and an error if one occurred. If replyTimeout is less than 0, + // the function will wait indefinitely. Possible errors include: + // - solace/errors.*PubSubPlusClientError if the message could not be sent and all retry attempts failed. + // - solace/errors.*PublisherOverflowError if publishing messages faster than publisher's I/O + // capabilities allow. When publishing can be resumed, registered PublisherReadinessListeners + // will be called. + PublishAwaitResponse(requestMessage message.OutboundMessage, requestDestination *resource.Topic, + replyTimeout time.Duration, properties config.MessagePropertiesConfigurationProvider) (message.InboundMessage, error) +} + +// ReplyMessageHandler is a callback to handle a reply message. The function will be +// called with a message received or nil, the user context if it was set when calling +// RequestReplyMessagePublisher.Publish, and an error if one was thrown. +type ReplyMessageHandler func(message message.InboundMessage, userContext interface{}, err error) + +// RequestReplyMessagePublisherBuilder allows for configuration of request reply message publisher instances +type RequestReplyMessagePublisherBuilder interface { + // Build will build a new RequestReplyMessagePublisher instance based on the configured properties. + // Returns solace/errors.*InvalidConfigurationError if an invalid configuration is provided. + Build() (messagePublisher RequestReplyMessagePublisher, err error) + // OnBackPressureReject will set the publisher backpressure strategy to reject + // where publish attempts will be rejected once the bufferSize, in number of messages, is reached. + // If bufferSize is 0, an error will be thrown when the transport is full when publishing. + // Valid bufferSize is >= 0. + OnBackPressureReject(bufferSize uint) RequestReplyMessagePublisherBuilder + // OnBackPressureWait will set the publisher backpressure strategy to wait where publish + // attempts will block until there is space in the buffer of size bufferSize in number of messages. + // Valid bufferSize is >= 1. + OnBackPressureWait(bufferSize uint) RequestReplyMessagePublisherBuilder + // FromConfigurationProvider will configure the persistent publisher with the given properties. + // Built in PublisherPropertiesConfigurationProvider implementations include: + // PublisherPropertyMap, a map of PublisherProperty keys to values + FromConfigurationProvider(provider config.PublisherPropertiesConfigurationProvider) RequestReplyMessagePublisherBuilder +} diff --git a/pkg/solace/request_reply_message_receiver.go b/pkg/solace/request_reply_message_receiver.go new file mode 100644 index 0000000..7de893c --- /dev/null +++ b/pkg/solace/request_reply_message_receiver.go @@ -0,0 +1,95 @@ +package solace + +import ( + "time" + + "solace.dev/go/messaging/pkg/solace/config" + "solace.dev/go/messaging/pkg/solace/message" + "solace.dev/go/messaging/pkg/solace/resource" +) + +// Replier allows for received request-reply messages to be replied to. The +// destination of these messages is automatically determined by the +// InboundMessage passed to a RequestMessageHandler. +type Replier interface { + // Reply publishes a reply or response message. + Reply(message message.OutboundMessage) error +} + +// RequestMessageHandler is a callback called when a message is received. +// It is passed the request message as well as a replier allowing for the +// publishing of a reply message. The replier argument may be nil indicating +// that a NON-Request-Reply message has been received on the topic subscription +// given when building the RequestReplyMessageReceiver instance. +type RequestMessageHandler func(message message.InboundMessage, replier Replier) + +// RequestReplyMessageReceiver allows receiving of request-reply messages +// with handling for sending reply messages. +type RequestReplyMessageReceiver interface { + MessageReceiver + + // StartAsyncCallback will start the message receiver asynchronously. + // Before this function is called, the service is considered + // off-duty. To operate normally, this function must be called on + // the RequestReplyMessageReceiver instance. This function is idempotent. + // Returns immediately and will call the callback function when ready + // passing the started RequestReplyMessageReceiver instance, or nil and + // an error if one occurred. Subsequent calls will register additional + // callbacks that will be called immediately if already started. + StartAsyncCallback(callback func(RequestReplyMessageReceiver, error)) + + // TerminateAsyncCallback will terminate the message receiver asynchronously. + // This function is idempotent. The only way to resume operation + // after this function is called is to create a new instance. + // Any attempt to call this function renders the instance + // permanently terminated, even if this function completes. + // A graceful shutdown will be attempted within the grace period. + // A grace period of 0 implies a non-graceful shutdown that ignores + // unfinished tasks or in-flight messages. + // Returns immediately and registers a callback that will receive an + // error if one occurred or nil if successfully and gracefully terminated. + // If gracePeriod is less than 0, the function will wait indefinitely. + TerminateAsyncCallback(gracePeriod time.Duration, callback func(error)) + + // ReceiveAsync registers an asynchronous message handler. The given + // messageHandler will handle an ordered sequence of inbound request messages. + // This function is mutually exclusive to ReceiveMessage. + // Returns an error one occurred while registering the callback. + // If a callback is already registered, it will be replaced by the given + // callback. + ReceiveAsync(messageHandler RequestMessageHandler) error + + // ReceiveMessage receives a message and replier synchronously from the receiver. + // Returns a nil replier if the message can not be replied to. + // Returns an error if the receiver is not started or already terminated. + // This function waits until the specified timeout to receive a message or waits + // forever if timeout value is negative. If a timeout occurs, a solace.TimeoutError + // is returned. + ReceiveMessage(timeout time.Duration) (message.InboundMessage, Replier, error) +} + +// RequestReplyMessageReceiverBuilder allows for configuration of RequestReplyMessageReceiver instances +type RequestReplyMessageReceiverBuilder interface { + // Build will build a new RequestReplyMessageReceiver with the given properties. + // The message receiver will subscribe to the specified topic subscription. + // Accepts TopicSubscription instances as Subscriptions. See solace.TopicSubscriptionOf. + // Returns solace/errors.*InvalidConfigurationError if an invalid configuration is provided. + Build(requestTopicSubscription resource.Subscription) (messageReceiver RequestReplyMessageReceiver, err error) + // BuildWithSharedSubscription will build a new RequestReplyMessageReceiver with + // the given properties using a shared topic subscription and the shared name. + BuildWithSharedSubscription(requestTopicSubscription resource.Subscription, shareName *resource.ShareName) (messageReceiver RequestReplyMessageReceiver, err error) + // OnBackPressureDropLatest configures the receiver with the specified buffer size. If the buffer + // is full and a message arrives, the incoming message is discarded. + // A buffer of the given size will be statically allocated when the receiver is built. + // The bufferCapacity must be greater than or equal to 1. + OnBackPressureDropLatest(bufferCapacity uint) RequestReplyMessageReceiverBuilder + // OnBackPressureDropOldest configures the receiver with the specified buffer size, bufferCapacity. If the buffer + // is full and a message arrives, the oldest message in the buffer is discarded. + // A buffer of the given size will be statically allocated when the receiver is built. + // The value of bufferCapacity must be greater than or equal to 1. + OnBackPressureDropOldest(bufferCapacity uint) RequestReplyMessageReceiverBuilder + // FromConfigurationProvider will configure the request reply receiver with the given properties. + // Built in ReceiverPropertiesConfigurationProvider implementations include: + // ReceiverPropertyMap, a map of ReceiverProperty keys to values + FromConfigurationProvider(provider config.ReceiverPropertiesConfigurationProvider) RequestReplyMessageReceiverBuilder +} diff --git a/test/helpers/builder_helpers.go b/test/helpers/builder_helpers.go index b71e3d7..e47f5dd 100644 --- a/test/helpers/builder_helpers.go +++ b/test/helpers/builder_helpers.go @@ -104,3 +104,46 @@ func NewMessage(messagingService solace.MessagingService, payload ...string) mes ExpectWithOffset(1, err).ToNot(HaveOccurred(), "An error occurred while building a new outbound message") return msg } + +// NewRequestReplyMessageReceiver function +func NewRequestReplyMessageReceiver( + messagingService solace.MessagingService, + subscription *resource.TopicSubscription, + configurationProviders ...config.ReceiverPropertiesConfigurationProvider, +) solace.RequestReplyMessageReceiver { + builder := messagingService.RequestReply().CreateRequestReplyMessageReceiverBuilder() + if len(configurationProviders) > 0 { + for _, configurationProvider := range configurationProviders { + builder.FromConfigurationProvider(configurationProvider) + } + } + receiver, err := builder.Build(subscription) + ExpectWithOffset(1, err).ToNot(HaveOccurred(), "Encountered error while building receiver") + return receiver +} + +// StartRequestReplyMessageReceiverWithDefault function +func StartRequestReplyMessageReceiverWithDefault( + messagingService solace.MessagingService, + receiver solace.RequestReplyMessageReceiver, +) { + + err := receiver.Start() + ExpectWithOffset(1, err).ToNot(HaveOccurred(), "Encountered error while starting receiver") + Expect(receiver.IsRunning()).To(BeTrue()) // running state should be true + messageHandler := func(message message.InboundMessage, replier solace.Replier) { + if replier == nil { // the replier is only set when received message is request message that would be replied to + return + } + builder := messagingService.MessageBuilder() + replyMsg, err := builder.BuildWithStringPayload("hello world reply") + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + + replyErr := replier.Reply(replyMsg) + ExpectWithOffset(1, replyErr).ToNot(HaveOccurred()) + } + // have receiver push request messages to request message handler + regErr := receiver.ReceiveAsync(messageHandler) + ExpectWithOffset(1, regErr).ToNot(HaveOccurred()) + // return receiver +} diff --git a/test/helpers/messaging_service_helpers.go b/test/helpers/messaging_service_helpers.go index c1f3959..0006379 100644 --- a/test/helpers/messaging_service_helpers.go +++ b/test/helpers/messaging_service_helpers.go @@ -332,6 +332,119 @@ func PublishNPersistentMessages(messagingService solace.MessagingService, topic ExpectWithOffset(1, publisher.Terminate(10*time.Second)).ToNot(HaveOccurred(), "Expected publisher to terminate gracefully") } +// PublishNRequestReplyMessages will publish N request-reply messages to the given topic using the given messaging service with an +// optional template attached as a string template. If no string template is provided, "hello world %d" is used. +func PublishNRequestReplyMessages(messagingService solace.MessagingService, topic string, timeOut time.Duration, n int, template ...string) chan message.InboundMessage { + // A handler for the request-reply publisher + replyChannel := make(chan message.InboundMessage) + replyHandler := func(inboundMessage message.InboundMessage, userContext interface{}, err error) { + go func() { + replyChannel <- inboundMessage + }() + } + + publisher, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().OnBackPressureReject(0).Build() + ExpectWithOffset(1, err).ToNot(HaveOccurred(), "Expected request-reply publisher to build without error") + ExpectWithOffset(1, publisher.Start()).ToNot(HaveOccurred(), "Expected request-reply publisher to start without error") + + builder := messagingService.MessageBuilder() + + for i := 0; i < n; i++ { + + msgPayload := fmt.Sprintf("hello world %d", i) + if len(template) > 0 { + msgPayload = template[0] + } + + msg, err := builder.BuildWithStringPayload(msgPayload) + ExpectWithOffset(1, err).ToNot(HaveOccurred(), "Expected message to build without error") + + err = publisher.Publish(msg, replyHandler, resource.TopicOf(topic), timeOut, config.MessagePropertyMap{ + config.MessagePropertyCorrelationID: fmt.Sprint(i), + }, nil /* usercontext */) + ExpectWithOffset(1, err).ToNot(HaveOccurred(), "Expected publish to be successful") + } + ExpectWithOffset(1, publisher.Terminate(10*time.Second)).ToNot(HaveOccurred(), "Expected request-reply publisher to terminate gracefully") + return replyChannel +} + +// PublishRequestReplyMessages will publish N request-reply messages to the given topic using the given messaging service with an +// optional template attached as a string template. If no string template is provided, "hello world %d" is used. +func PublishRequestReplyMessages(messagingService solace.MessagingService, topic string, timeOut time.Duration, bufferSize uint, publishedMessages *int, template ...string) (publisherReplies chan message.InboundMessage, publisherSaturated, publisherComplete chan struct{}) { + isSaturated := false + publisherReplies = make(chan message.InboundMessage) + publisherSaturated = make(chan struct{}) + publisherComplete = make(chan struct{}) + testComplete := make(chan struct{}) + + // A handler for the request-reply publisher + publisherReplyHandler := func(message message.InboundMessage, userContext interface{}, err error) { + if err == nil { // Good, a reply was received + ExpectWithOffset(1, message).ToNot(BeNil()) + publisherReplies <- message + } else { + // message should be nil + ExpectWithOffset(1, message).To(BeNil()) + ExpectWithOffset(1, err).ToNot(BeNil()) + } + } + + publisher, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().OnBackPressureReject(0).Build() + ExpectWithOffset(1, err).ToNot(HaveOccurred(), "Expected request-reply publisher to build without error") + ExpectWithOffset(1, publisher.Start()).ToNot(HaveOccurred(), "Expected request-reply publisher to start without error") + + builder := messagingService.MessageBuilder() + + go func() { + defer GinkgoRecover() + loop: + for { + select { + case <-testComplete: + break loop + default: + } + + msgPayload := fmt.Sprintf("hello world %d", *publishedMessages) + if len(template) > 0 { + msgPayload = template[0] + } + + msg, err := builder.BuildWithStringPayload(msgPayload) + ExpectWithOffset(1, err).ToNot(HaveOccurred(), "Expected message to build without error") + + err = publisher.Publish(msg, publisherReplyHandler, resource.TopicOf(topic), timeOut, config.MessagePropertyMap{ + config.MessagePropertyCorrelationID: fmt.Sprint(*publishedMessages), + }, nil /* usercontext */) + ExpectWithOffset(1, err).ToNot(HaveOccurred(), "Expected publish to be successful") + + if err != nil { + ExpectWithOffset(1, err).To(BeAssignableToTypeOf(&solace.IllegalStateError{})) + break loop + } else { + (*publishedMessages)++ + } + + // buffer is at least halfway filled + if !isSaturated && (uint(*publishedMessages) > bufferSize/2) { + close(publisherSaturated) + isSaturated = true + } + + // all message have been published now + if uint(*publishedMessages) >= bufferSize { + close(testComplete) + } + } + close(publisherComplete) + // terminate the publisher after closing the channel + ExpectWithOffset(1, publisher.Terminate(10*time.Second)).ToNot(HaveOccurred(), "Expected request-reply publisher to terminate gracefully") + + }() + + return publisherReplies, publisherSaturated, publisherComplete +} + // ReceiveOneMessage function func ReceiveOneMessage(messagingService solace.MessagingService, topic string) chan message.InboundMessage { receiver, err := messagingService.CreateDirectMessageReceiverBuilder().WithSubscriptions(resource.TopicSubscriptionOf(topic)).Build() diff --git a/test/request_reply_message_publisher_test.go b/test/request_reply_message_publisher_test.go new file mode 100644 index 0000000..4240fe2 --- /dev/null +++ b/test/request_reply_message_publisher_test.go @@ -0,0 +1,576 @@ +// pubsubplus-go-client +// +// Copyright 2021-2024 Solace Corporation. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package test + +import ( + "fmt" + "time" + + "solace.dev/go/messaging" + "solace.dev/go/messaging/pkg/solace" + "solace.dev/go/messaging/pkg/solace/config" + "solace.dev/go/messaging/pkg/solace/message" + "solace.dev/go/messaging/pkg/solace/metrics" + "solace.dev/go/messaging/pkg/solace/resource" + "solace.dev/go/messaging/test/helpers" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("RequestReplyPublisher", func() { + + var messagingService solace.MessagingService + + BeforeEach(func() { + builder := messaging.NewMessagingServiceBuilder().FromConfigurationProvider(helpers.DefaultConfiguration()) + messagingService = helpers.BuildMessagingService(builder) + }) + + Describe("Builder verification", func() { + invalidConfigurations := map[string]config.PublisherPropertyMap{ + "invalid backpressure configuration": { + config.PublisherPropertyBackPressureStrategy: "not a strategy", + }, + "invalid backpressure configuration type": { + config.PublisherPropertyBackPressureStrategy: 1234, + }, + "invalid backpressure buffer size type": { + config.PublisherPropertyBackPressureBufferCapacity: "asdf", + }, + } + for key, val := range invalidConfigurations { + invalidConfiguration := val + It("fails to build with "+key, func() { + _, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().FromConfigurationProvider(invalidConfiguration).Build() + helpers.ValidateError(err, &solace.IllegalArgumentError{}) + }) + } + invalidBufferCapacity := map[string]config.PublisherPropertyMap{ + "invalid backpressure capacity in reject": { + config.PublisherPropertyBackPressureStrategy: config.PublisherPropertyBackPressureStrategyBufferRejectWhenFull, + config.PublisherPropertyBackPressureBufferCapacity: -1, + }, + "invalid backpressure capacity in wait": { + config.PublisherPropertyBackPressureStrategy: config.PublisherPropertyBackPressureStrategyBufferWaitWhenFull, + config.PublisherPropertyBackPressureBufferCapacity: 0, + }, + } + for key, val := range invalidBufferCapacity { + invalidConfiguration := val + It("fails to build with "+key, func() { + _, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().FromConfigurationProvider(invalidConfiguration).Build() + helpers.ValidateError(err, &solace.InvalidConfigurationError{}) + }) + } + requiredProperties := map[string]config.PublisherPropertyMap{ + "nil backpressure configuration": { + config.PublisherPropertyBackPressureStrategy: nil, + }, + "nil buffer size": { + config.PublisherPropertyBackPressureBufferCapacity: nil, + }, + } + for key, value := range requiredProperties { + invalidConfiguration := value + It("should error with "+key, func() { + _, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().FromConfigurationProvider(invalidConfiguration).Build() + helpers.ValidateError(err, &solace.InvalidConfigurationError{}, "required property") + }) + } + + validConfigurations := map[string]config.PublisherPropertyMap{ + "valid backpressure configuration": { + config.PublisherPropertyBackPressureStrategy: config.PublisherPropertyBackPressureStrategyBufferWaitWhenFull, + }, + "valid backpressure configuration type": { + config.PublisherPropertyBackPressureStrategy: config.PublisherPropertyBackPressureStrategyBufferRejectWhenFull, + }, + "valid backpressure buffer size type": { + config.PublisherPropertyBackPressureBufferCapacity: 50, + }, + } + for key, val := range validConfigurations { + validConfiguration := val + It("succeeds to build with "+key, func() { + publisher, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().FromConfigurationProvider(validConfiguration).Build() + + expected := &solace.IllegalArgumentError{} + Expect(publisher).ShouldNot(Equal(nil)) + Expect(publisher.IsRunning()).To(BeFalse()) // running state should be false + ExpectWithOffset(2, err).ToNot(HaveOccurred(), "Expected error to not have occurred") + ExpectWithOffset(2, err).ToNot(BeAssignableToTypeOf(expected), fmt.Sprintf("Expected error of type %T to not be assignable of type %T", err, expected)) + }) + } + + It("can print the builder to a string and see the pointer", func() { + builder := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder() + str := fmt.Sprint(builder) + Expect(str).ToNot(BeEmpty()) + Expect(str).To(ContainSubstring(fmt.Sprintf("%p", builder))) + }) + + // this may be a duplicate test but won't hurt to add it here for Request-Reply + It("can print a topic to a string", func() { + topicString := "request-reply-try-me" + topic := resource.TopicOf(topicString) + Expect(topic.String()).To(ContainSubstring(topicString)) + }) + + }) + + Context("with a connected messaging service", func() { + var messageBuilder solace.OutboundMessageBuilder + BeforeEach(func() { + helpers.ConnectMessagingService(messagingService) + messageBuilder = messagingService.MessageBuilder() + }) + + AfterEach(func() { + if messagingService.IsConnected() { + helpers.DisconnectMessagingService(messagingService) + } + }) + + It("can print the publisher to a string and see the pointer", func() { + publisher, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().Build() + Expect(err).ToNot(HaveOccurred()) + str := fmt.Sprint(publisher) + Expect(str).ToNot(BeEmpty()) + Expect(str).To(ContainSubstring(fmt.Sprintf("%p", publisher))) + }) + + Describe("request-reply publisher termination tests", func() { + publishTopic := resource.TopicOf("hello/world") + topicSubscription := resource.TopicSubscriptionOf("hello/world") + largeByteArray := make([]byte, 16384) + timeOut := 5 * time.Second + + // A handler for the request-reply publisher + publisherReplyHandler := func(message message.InboundMessage, userContext interface{}, err error) { + if err == nil { // Good, a reply was received + Expect(message).ToNot(BeNil()) + } else { + // message should be nil + Expect(message).To(BeNil()) + Expect(err).ToNot(BeNil()) + } + } + + // A helper function to saturate a given publisher (fill up its internal buffers). + // Counts the number of published messages at the given int pointer + // Returns a channel that is closed when the publisher receives an error from a call to Publish + // Returns a channel that can be closed when the test completes, ie. if an error occurred + publisherSaturation := func(publisher solace.RequestReplyMessagePublisher, bufferSize uint, publishedMessages *int) (publisherSaturated, publisherComplete, testComplete chan struct{}) { + isSaturated := false + publisherSaturated = make(chan struct{}) + publisherComplete = make(chan struct{}) + testComplete = make(chan struct{}) + + toPublish, err := messageBuilder.BuildWithByteArrayPayload(largeByteArray) + Expect(err).ToNot(HaveOccurred()) + + go func() { + defer GinkgoRecover() + loop: + for { + select { + case <-testComplete: + break loop + default: + } + err := publisher.Publish(toPublish, publisherReplyHandler, publishTopic, timeOut, nil /* properties */, nil /* usercontext */) + + if err != nil { + Expect(err).To(BeAssignableToTypeOf(&solace.IllegalStateError{})) + break loop + } else { + (*publishedMessages)++ + } + + // buffer is at least halfway filled + if !isSaturated && (uint(*publishedMessages) > bufferSize/2) { + close(publisherSaturated) + isSaturated = true + } + } + close(publisherComplete) + }() + return publisherSaturated, publisherComplete, testComplete + } + + It("should publish all messages on graceful termination (no waiting for reply messages)", func() { + publishedMessages := 0 + + bufferSize := uint(1000) + publisher, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().OnBackPressureWait(bufferSize).Build() + Expect(err).ToNot(HaveOccurred()) + + err = publisher.Start() + Expect(err).ToNot(HaveOccurred()) + defer publisher.Terminate(0) + + publisherSaturated, publisherComplete, testComplete := publisherSaturation(publisher, bufferSize, &publishedMessages) + defer close(testComplete) + + // allow the goroutine above to saturate the publisher + select { + case <-publisherComplete: + // block until publish complete + Fail("Expected publisher to not be complete") + case <-publisherSaturated: + // allow the goroutine above to saturate the publisher (at least halfway filled) + case <-time.After(100 * time.Millisecond): + // should not timeout while saturating the publisher + Fail("Not expected to timeout while saturating publisher; Should not get here") + } + + publisherTerminate := publisher.TerminateAsync(30 * time.Second) + + select { + case <-publisherComplete: + // success + case <-publisherTerminate: + Fail("expected publisher to complete prior to termination completion") + case <-time.After(15 * time.Second): + Fail("timed out waiting for publisher to complete") + } + select { + case err := <-publisherTerminate: + Expect(err).ToNot(HaveOccurred()) + case <-time.After(15 * time.Second): + Fail("timed out waiting for publisher to terminate") + } + Expect(publisher.IsTerminated()).To(BeTrue()) + // to account for the reply messages too + Expect(messagingService.Metrics().GetValue(metrics.DirectMessagesSent)).To(BeNumerically("==", publishedMessages)) + }) + + It("should have undelivered messages on ungraceful termination (no waiting for reply messages)", func() { + publishedMessages := 0 + + bufferSize := uint(10000) + publisher, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().OnBackPressureWait(bufferSize).Build() + Expect(err).ToNot(HaveOccurred()) + + err = publisher.Start() + Expect(err).ToNot(HaveOccurred()) + defer publisher.Terminate(0) + + publisherSaturated, publisherComplete, testComplete := publisherSaturation(publisher, bufferSize, &publishedMessages) + defer close(testComplete) + + // allow the goroutine above to saturate the publisher + select { + case <-publisherComplete: + // block until publish complete + Fail("Expected publisher to not be complete") + case <-publisherSaturated: + // allow the goroutine above to saturate the publisher (at least halfway filled) + case <-time.After(1 * time.Second): + // should not timeout while saturating the publisher + Fail("Not expected to timeout while saturating publisher; Should not get here") + } + + publisherTerminate := publisher.TerminateAsync(0 * time.Second) + Eventually(publisherTerminate).Should(Receive(&err)) + helpers.ValidateError(err, &solace.IncompleteMessageDeliveryError{}) + + Eventually(publisherComplete).Should(BeClosed()) + Expect(publisher.IsTerminated()).To(BeTrue()) + + directSent := messagingService.Metrics().GetValue(metrics.DirectMessagesSent) + directDropped := messagingService.Metrics().GetValue(metrics.PublishMessagesTerminationDiscarded) + Expect(directSent).To(BeNumerically("<", publishedMessages)) + Expect(directDropped).To(BeNumerically(">", 0)) + Expect(directSent + directDropped).To(BeNumerically("==", publishedMessages)) + }) + + It("should have undelivered messages on unsolicited termination of messaging service", func() { + publishedMessages := 0 + bufferSize := uint(10000) + publisher, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().OnBackPressureWait(bufferSize).Build() + Expect(err).ToNot(HaveOccurred()) + + err = publisher.Start() + Expect(err).ToNot(HaveOccurred()) + defer publisher.Terminate(0) + + terminationListenerCalled := make(chan solace.TerminationEvent) + publisher.SetTerminationNotificationListener(func(te solace.TerminationEvent) { + terminationListenerCalled <- te + }) + + _, publisherComplete, testComplete := publisherSaturation(publisher, bufferSize, &publishedMessages) + defer close(testComplete) + + shutdownTime := time.Now() + helpers.ForceDisconnectViaSEMPv2(messagingService) + + Eventually(publisherComplete).Should(BeClosed()) + Eventually(publisher.IsTerminated).Should(BeTrue()) + + select { + case te := <-terminationListenerCalled: + Expect(te.GetTimestamp()).To(BeTemporally(">", shutdownTime)) + Expect(te.GetTimestamp()).To(BeTemporally("<", time.Now())) + // Expect(te.GetCause()).To(BeAssignableToTypeOf(&solace.NativeError{})) + // SOL-66163: a race condition in CCSMP may cause the error to be nil + // helpers.ValidateNativeError(te.GetCause(), subcode.CommunicationError) + Expect(te.GetMessage()).To(ContainSubstring("Publisher")) + case <-time.After(100 * time.Millisecond): + Fail("timed out waiting for termination listener to be called") + } + + publishSent := messagingService.Metrics().GetValue(metrics.DirectMessagesSent) + Expect(publishSent).To(BeNumerically("<", publishedMessages)) + }) + + It("should have undelivered messages on messaging service shutdown/disconnection", func() { + publishedMessages := 0 + bufferSize := uint(100) + publisher, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().OnBackPressureWait(bufferSize).Build() + Expect(err).ToNot(HaveOccurred()) + + err = publisher.Start() + Expect(err).ToNot(HaveOccurred()) + defer publisher.Terminate(0) + + terminationListenerCalled := make(chan solace.TerminationEvent) + publisher.SetTerminationNotificationListener(func(te solace.TerminationEvent) { + terminationListenerCalled <- te + }) + + _, publisherComplete, testComplete := publisherSaturation(publisher, bufferSize, &publishedMessages) + defer close(testComplete) + + shutdownTime := time.Now() + helpers.DisconnectMessagingService(messagingService) + + Eventually(publisherComplete).Should(BeClosed()) + Eventually(publisher.IsTerminated).Should(BeTrue()) + + select { + case te := <-terminationListenerCalled: + Expect(te.GetTimestamp()).To(BeTemporally(">", shutdownTime)) + Expect(te.GetTimestamp()).To(BeTemporally("<", time.Now())) + Expect(te.GetCause()).To(BeAssignableToTypeOf(&solace.ServiceUnreachableError{})) + case <-time.After(100 * time.Millisecond): + Fail("timed out waiting for termination listener to be called") + } + + publishSent := messagingService.Metrics().GetValue(metrics.DirectMessagesSent) + Expect(publishSent).To(BeNumerically("<", publishedMessages)) + }) + + startFunctions := map[string](func(publisher solace.RequestReplyMessagePublisher) <-chan error){ + "sync": func(publisher solace.RequestReplyMessagePublisher) <-chan error { + c := make(chan error) + go func() { + c <- publisher.Start() + }() + return c + }, + "async": func(publisher solace.RequestReplyMessagePublisher) <-chan error { + return publisher.StartAsync() + }, + "callback": func(publisher solace.RequestReplyMessagePublisher) <-chan error { + c := make(chan error) + publisher.StartAsyncCallback(func(dmp solace.RequestReplyMessagePublisher, e error) { + defer GinkgoRecover() + Expect(dmp).To(Equal(publisher)) + c <- e + }) + return c + }, + } + + // gracePeriod := 5 * time.Second + terminateFunctions := map[string](func(publisher solace.RequestReplyMessagePublisher, gracePeriod time.Duration) <-chan error){ + "sync": func(publisher solace.RequestReplyMessagePublisher, gracePeriod time.Duration) <-chan error { + c := make(chan error) + go func() { + c <- publisher.Terminate(gracePeriod) + }() + return c + }, + "async": func(publisher solace.RequestReplyMessagePublisher, gracePeriod time.Duration) <-chan error { + return publisher.TerminateAsync(gracePeriod) + }, + "callback": func(publisher solace.RequestReplyMessagePublisher, gracePeriod time.Duration) <-chan error { + c := make(chan error) + publisher.TerminateAsyncCallback(gracePeriod, func(e error) { + c <- e + }) + return c + }, + } + + // for the success paths + for startName, fn := range startFunctions { + for terminateName, fn2 := range terminateFunctions { + start := fn + terminate := fn2 + It("can start and terminate with start "+startName+" and terminate "+terminateName+" with subscribed reply receiver on topic", func() { + // build a request replier + receiver := helpers.NewRequestReplyMessageReceiver(messagingService, topicSubscription) + helpers.StartRequestReplyMessageReceiverWithDefault(messagingService, receiver) + publisher, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().Build() + Expect(err).ToNot(HaveOccurred()) + // check that not started + helpers.ValidateReadyState(publisher, false, false, false, false) + // start and check state + Eventually(start(publisher)).Should(Receive(Not(HaveOccurred()))) + helpers.ValidateReadyState(publisher, true, true, false, false) + // try using publisher + publisher.PublishString("hello world", publisherReplyHandler, publishTopic, timeOut, nil /* usercontext */) + Eventually(func() int64 { + return helpers.GetClient(messagingService).DataRxMsgCount + }).Should(BeNumerically("==", 2)) + // terminate and check state + Eventually(terminate(publisher, 5*time.Second)).Should(Receive(Not(HaveOccurred()))) + helpers.ValidateReadyState(publisher, false, false, false, true) + receiver.Terminate(1 * time.Second) // terminate the receiver + }) + It("can start and terminate idempotently with start "+startName+" and terminate "+terminateName+" with subscribed reply receiver on topic", func() { + // build a request replier + receiver := helpers.NewRequestReplyMessageReceiver(messagingService, topicSubscription) + helpers.StartRequestReplyMessageReceiverWithDefault(messagingService, receiver) + publisher, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().Build() + Expect(err).ToNot(HaveOccurred()) + // check that not started + helpers.ValidateReadyState(publisher, false, false, false, false) + // start and check state + c1 := start(publisher) + c2 := start(publisher) + Eventually(c1).Should(Receive(Not(HaveOccurred()))) + Eventually(c2).Should(Receive(Not(HaveOccurred()))) + helpers.ValidateReadyState(publisher, true, true, false, false) + // try using publisher + publisher.PublishString("hello world", publisherReplyHandler, publishTopic, timeOut, nil /* usercontext */) + Eventually(func() int64 { + return helpers.GetClient(messagingService).DataRxMsgCount + }).Should(BeNumerically("==", 2)) + // terminate and check state + c1 = terminate(publisher, 5*time.Second) + c2 = terminate(publisher, 5*time.Second) + Eventually(c1).Should(Receive(Not(HaveOccurred()))) + Eventually(c2).Should(Receive(Not(HaveOccurred()))) + helpers.ValidateReadyState(publisher, false, false, false, true) + c1 = start(publisher) + c2 = start(publisher) + Eventually(c1).Should(Receive(BeAssignableToTypeOf(&solace.IllegalStateError{}))) + Eventually(c2).Should(Receive(BeAssignableToTypeOf(&solace.IllegalStateError{}))) + receiver.Terminate(1 * time.Second) // terminate the receiver + }) + It("can start and terminate with start "+startName+" and terminate "+terminateName+" without subscribed reply receiver and zero grace period", func() { + publisher, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().Build() + Expect(err).ToNot(HaveOccurred()) + // check that not started + helpers.ValidateReadyState(publisher, false, false, false, false) + // start and check state + Eventually(start(publisher)).Should(Receive(Not(HaveOccurred()))) + helpers.ValidateReadyState(publisher, true, true, false, false) + // try using publisher + publisher.PublishString("hello world", publisherReplyHandler, publishTopic, timeOut, nil /* usercontext */) + Eventually(func() int64 { + return helpers.GetClient(messagingService).DataRxMsgCount + }).Should(BeNumerically("==", 1)) + // terminate and check state + Eventually(terminate(publisher, 0*time.Second)).Should(Receive(Not(HaveOccurred()))) + helpers.ValidateReadyState(publisher, false, false, false, true) + }) + It("can start and terminate with start "+startName+" and terminate "+terminateName+" without subscribed reply receiver and > 0 grace period", func() { + publisher, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().Build() + Expect(err).ToNot(HaveOccurred()) + // check that not started + helpers.ValidateReadyState(publisher, false, false, false, false) + // start and check state + Eventually(start(publisher)).Should(Receive(Not(HaveOccurred()))) + helpers.ValidateReadyState(publisher, true, true, false, false) + // try using publisher + publisher.PublishString("hello world", publisherReplyHandler, publishTopic, timeOut, nil /* usercontext */) + Eventually(func() int64 { + return helpers.GetClient(messagingService).DataRxMsgCount + }).Should(BeNumerically("==", 1)) + // terminate and check state + terminateChan := terminate(publisher, 5*time.Second) + Eventually(terminateChan, "5000ms").Should(Receive(BeNil())) + helpers.ValidateReadyState(publisher, false, false, false, true) + }) + } + } + + // more success paths + for terminateName, fn := range terminateFunctions { + terminate := fn + It("should be able to terminate when not started using terminate "+terminateName, func() { + publisher, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().Build() + Expect(err).ToNot(HaveOccurred(), "Encountered error while building request-reply publisher") + Eventually(terminate(publisher, 5*time.Second)).Should(Receive(BeNil())) + Expect(publisher.IsTerminated()).To(BeTrue()) + helpers.ValidateError(publisher.Start(), &solace.IllegalStateError{}) + }) + } + + // failure paths + It("should fail to publish on unstarted publisher", func() { + publisher, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().Build() + Expect(err).ToNot(HaveOccurred(), "Encountered error while building request-reply publisher") + helpers.ValidateError( + publisher.Publish(helpers.NewMessage(messagingService), publisherReplyHandler, publishTopic, timeOut, nil /* properties */, nil /* usercontext */), + &solace.IllegalStateError{}, + ) + }) + It("should fail to publish on terminated publisher", func() { + publisher, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().Build() + Expect(err).ToNot(HaveOccurred(), "Encountered error while building request-reply publisher") + + Expect(publisher.Start()).ToNot(HaveOccurred()) + Expect(publisher.Terminate(gracePeriod)).ToNot(HaveOccurred()) + + helpers.ValidateError(publisher.Publish(helpers.NewMessage(messagingService), publisherReplyHandler, publishTopic, timeOut, nil /* properties */, nil /* usercontext */), &solace.IllegalStateError{}) + }) + + // for the failure paths + for startName, fn := range startFunctions { + start := fn + It("should fail to start when messaging service is disconnected using start "+startName, func() { + publisher, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().Build() + Expect(err).ToNot(HaveOccurred()) + + messagingService.Disconnect() + Eventually(start(publisher)).Should(Receive(&err)) + Expect(err).To(HaveOccurred()) + Expect(err).To(BeAssignableToTypeOf(&solace.IllegalStateError{})) + }) + It("should fail to start when messaging service is down using start "+startName, func() { + publisher, err := messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().Build() + Expect(err).ToNot(HaveOccurred()) + + helpers.ForceDisconnectViaSEMPv2(messagingService) + Eventually(messagingService.IsConnected).Should(BeFalse()) + Expect(messagingService.Disconnect()).ToNot(HaveOccurred()) + + helpers.ValidateChannelError(start(publisher), &solace.IllegalStateError{}) + }) + } + }) + + }) + +}) diff --git a/test/request_reply_message_receiver_test.go b/test/request_reply_message_receiver_test.go new file mode 100644 index 0000000..49aaef8 --- /dev/null +++ b/test/request_reply_message_receiver_test.go @@ -0,0 +1,1071 @@ +// pubsubplus-go-client +// +// Copyright 2021-2024 Solace Corporation. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package test + +import ( + "fmt" + "net/url" + "sync/atomic" + "time" + + "solace.dev/go/messaging" + "solace.dev/go/messaging/pkg/solace" + "solace.dev/go/messaging/pkg/solace/config" + "solace.dev/go/messaging/pkg/solace/message" + "solace.dev/go/messaging/pkg/solace/metrics" + "solace.dev/go/messaging/pkg/solace/resource" + "solace.dev/go/messaging/test/helpers" + "solace.dev/go/messaging/test/testcontext" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const subscriptionString = "some-subscription" + +var subscription = resource.TopicSubscriptionOf(subscriptionString) + +var _ = Describe("RequestReplyReceiver", func() { + + var messagingService solace.MessagingService + + BeforeEach(func() { + var err error + messagingService, err = messaging.NewMessagingServiceBuilder().FromConfigurationProvider(helpers.DefaultConfiguration()).Build() + Expect(err).To(BeNil()) + }) + + Describe("Builder verification", func() { + + It("fails to build when given an invalid backpressure type", func() { + receiver, err := messagingService.RequestReply().CreateRequestReplyMessageReceiverBuilder().FromConfigurationProvider(config.ReceiverPropertyMap{ + config.ReceiverPropertyDirectBackPressureStrategy: "not a strategy", + }).Build(subscription) + Expect(err).To(HaveOccurred()) + Expect(err).To(BeAssignableToTypeOf(&solace.IllegalArgumentError{})) + Expect(receiver).To(BeNil()) + }) + + It("fails to build when given an invalid subscription", func() { + badSubscription := &myCustomRequestReplySubscription{} + receiver, err := messagingService.RequestReply().CreateRequestReplyMessageReceiverBuilder().Build(badSubscription) + Expect(err).To(HaveOccurred()) + Expect(err).To(BeAssignableToTypeOf(&solace.IllegalArgumentError{})) + Expect(err.Error()).To(ContainSubstring(fmt.Sprintf("%T", badSubscription))) + Expect(receiver).To(BeNil()) + }) + + It("fails to start on unconnected messaging service", func() { + receiver, err := messagingService.RequestReply().CreateRequestReplyMessageReceiverBuilder().Build(subscription) + Expect(err).ToNot(HaveOccurred()) + + err = receiver.Start() + Expect(err).To(HaveOccurred()) + Expect(err).To(BeAssignableToTypeOf(&solace.IllegalStateError{})) + }) + + It("fails to start on disconnected messaging service", func() { + receiver, err := messagingService.RequestReply().CreateRequestReplyMessageReceiverBuilder().Build(subscription) + Expect(err).ToNot(HaveOccurred()) + + helpers.ConnectMessagingService(messagingService) + helpers.DisconnectMessagingService(messagingService) + + err = receiver.Start() + Expect(err).To(HaveOccurred()) + Expect(err).To(BeAssignableToTypeOf(&solace.IllegalStateError{})) + }) + + It("fails to receive a message on an unconnected receiver", func() { + receiver, err := messagingService.RequestReply().CreateRequestReplyMessageReceiverBuilder().Build(subscription) + Expect(err).ToNot(HaveOccurred()) + + msg, _, err := receiver.ReceiveMessage(-1) // blocking call to receive messages + Expect(msg).To(BeNil()) + helpers.ValidateError(err, &solace.IllegalStateError{}) + }) + + validConfigurations := map[string]config.ReceiverPropertyMap{ + "valid backpressure configuration": { + config.ReceiverPropertyDirectBackPressureStrategy: config.ReceiverBackPressureStrategyDropLatest, + }, + "valid backpressure configuration type": { + config.ReceiverPropertyDirectBackPressureStrategy: config.ReceiverBackPressureStrategyDropOldest, + }, + } + for key, val := range validConfigurations { + validConfiguration := val + It("succeeds to build with "+key, func() { + receiver, err := messagingService.RequestReply().CreateRequestReplyMessageReceiverBuilder().FromConfigurationProvider(validConfiguration).Build(subscription) + Expect(err).ToNot(HaveOccurred(), "Expected error to not have occurred") + Expect(receiver).ToNot(BeNil()) + Expect(receiver.IsRunning()).To(BeFalse()) // running state should be false + + expected := &solace.IllegalArgumentError{} + Expect(err).ToNot(BeAssignableToTypeOf(expected), fmt.Sprintf("Expected error of type %T to not be assignable of type %T", err, expected)) + }) + } + + }) + + Context("with a messaging service that will be disconnected", func() { + const topicString = "terminate/me" + const messagesPublished = 3 + const publishTimeOut = 3 * time.Second + var terminationChannel chan solace.TerminationEvent + var receiver solace.RequestReplyMessageReceiver + + BeforeEach(func() { + helpers.ConnectMessagingService(messagingService) + var err error + receiver, err = messagingService.RequestReply().CreateRequestReplyMessageReceiverBuilder().Build(resource.TopicSubscriptionOf(topicString)) + Expect(err).ToNot(HaveOccurred()) + err = receiver.Start() + Expect(err).ToNot(HaveOccurred()) + + terminationChannel = make(chan solace.TerminationEvent) + startTime := time.Now() + receiver.SetTerminationNotificationListener(func(te solace.TerminationEvent) { + defer GinkgoRecover() + Expect(te.GetTimestamp()).To(BeTemporally(">=", startTime)) + Expect(te.GetTimestamp()).To(BeTemporally("<=", time.Now())) + Expect(te.GetCause()).ToNot(BeNil()) + Expect(te.GetMessage()).ToNot(BeEmpty()) + terminationChannel <- te + }) + }) + + AfterEach(func() { + if !receiver.IsTerminated() { + receiver.Terminate(1 * time.Second) + } + if messagingService.IsConnected() { + messagingService.Disconnect() + } + }) + + forceDisconnectFunctions := map[string]func(messagingService solace.MessagingService){ + "messaging service disconnect": func(messagingService solace.MessagingService) { + helpers.DisconnectMessagingService(messagingService) + }, + "SEMPv2 disconnect": func(messagingService solace.MessagingService) { + helpers.ForceDisconnectViaSEMPv2(messagingService) + }, + } + + for testCase, disconnectFunctionRef := range forceDisconnectFunctions { + disconnectFunction := disconnectFunctionRef + It("terminates the receiver using async receive when disconnecting with "+testCase, func() { + blocker := make(chan struct{}) + msgsReceived := make(chan message.InboundMessage, messagesPublished) + receiver.ReceiveAsync(func(inboundMessage message.InboundMessage, replier solace.Replier) { + <-blocker + msgsReceived <- inboundMessage + + payload, _ := inboundMessage.GetPayloadAsString() + if replier != nil { + err := replier.Reply(helpers.NewMessage(messagingService, "Reply for: "+payload)) + Expect(err).ToNot(BeNil()) // because the messaging service is diconnected + } + }) + + publishReplyMsgChan := helpers.PublishNRequestReplyMessages(messagingService, topicString, publishTimeOut, messagesPublished) + Eventually(publishReplyMsgChan).Should(Receive()) // something was published + + helpers.ValidateMetric(messagingService, metrics.DirectMessagesReceived, messagesPublished) + + disconnectFunction(messagingService) + + Eventually(receiver.IsTerminated(), 10*time.Second).Should(BeTrue()) + Expect(receiver.IsRunning()).To(BeFalse()) // should not be in running state now + + // unblock the receiver callback after we are marked as terminated + close(blocker) + + var expectedMsg message.InboundMessage + Eventually(msgsReceived).Should(Receive(&expectedMsg)) + payload, ok := expectedMsg.GetPayloadAsString() + Expect(ok).To(BeTrue()) + Expect(payload).To(Equal("hello world 0")) // check that only first message was received + + Consistently(msgsReceived).ShouldNot(Receive()) // no more messages since the receiver has been terminated + Eventually(terminationChannel).Should(Receive()) + helpers.ValidateMetric(messagingService, metrics.ReceivedMessagesTerminationDiscarded, messagesPublished-1) // less the one successfully received + }) + It("terminates the receiver using sync receive when disconnecting with "+testCase, func() { + publishReplyMsgChan := helpers.PublishNRequestReplyMessages(messagingService, topicString, publishTimeOut, messagesPublished) + Eventually(publishReplyMsgChan).Should(Receive()) // something was published + + helpers.ValidateMetric(messagingService, metrics.DirectMessagesReceived, messagesPublished) + + discardOffset := 0 + disconnectFunction(messagingService) + + // Try and race with the unsolicited termination + var err error = nil + var replier solace.Replier = nil + for err == nil { + var racingMessage message.InboundMessage + racingMessage, replier, err = receiver.ReceiveMessage(-1) // blocking receive call + if racingMessage != nil { + discardOffset++ + } + if replier != nil { + payload, _ := racingMessage.GetPayloadAsString() + err = replier.Reply(helpers.NewMessage(messagingService, "Reply for: "+payload)) + Expect(err).To(BeNil()) + } + } + + Eventually(receiver.IsTerminated(), 10*time.Second).Should(BeTrue()) + Expect(receiver.IsRunning()).To(BeFalse()) // should not be in running state now + Eventually(terminationChannel).Should(Receive()) + + msg, replier, err := receiver.ReceiveMessage(-1) + Expect(msg).To(BeNil()) + Expect(replier).To(BeNil()) // no more messages to reply to + Expect(err).ToNot(BeNil()) + helpers.ValidateError(err, &solace.IllegalStateError{}) + + helpers.ValidateMetric(messagingService, metrics.ReceivedMessagesTerminationDiscarded, messagesPublished-uint64(discardOffset)) + }) + } + }) + + Context("with a connected messaging service", func() { + var messageBuilder solace.OutboundMessageBuilder + var publisher solace.RequestReplyMessagePublisher + var builder solace.RequestReplyMessageReceiverBuilder + const publishTimeOut = 3 * time.Second + const gracePeriod = 5 * time.Second + + BeforeEach(func() { + var err error + err = messagingService.Connect() + Expect(err).To(BeNil()) + + messageBuilder = messagingService.MessageBuilder() + + publisher, err = messagingService.RequestReply().CreateRequestReplyMessagePublisherBuilder().Build() + Expect(err).ToNot(HaveOccurred()) + err = publisher.Start() + Expect(err).ToNot(HaveOccurred()) + + builder = messagingService.RequestReply().CreateRequestReplyMessageReceiverBuilder() + }) + + AfterEach(func() { + var err error + err = publisher.Terminate(10 * time.Second) // 30 second + Expect(err).To(BeNil()) + + err = messagingService.Disconnect() + Expect(err).To(BeNil()) + }) + + startFunctions := map[string](func(solace.RequestReplyMessageReceiver) error){ + "Start": func(rrmr solace.RequestReplyMessageReceiver) error { + return rrmr.Start() + }, + "StartAsync": func(rrmr solace.RequestReplyMessageReceiver) error { + return <-rrmr.StartAsync() + }, + "StartAsyncCallback": func(rrmr solace.RequestReplyMessageReceiver) error { + startChan := make(chan error) + rrmr.StartAsyncCallback(func(passedRrmr solace.RequestReplyMessageReceiver, e error) { + Expect(passedRrmr).To(Equal(rrmr)) + startChan <- e + }) + return <-startChan + }, + } + for startFunctionName, startFunction := range startFunctions { + start := startFunction + It("should be able to receive a message successfully using start function "+startFunctionName, func() { + topicString := "try-me" + receiver, err := builder.Build(resource.TopicSubscriptionOf(topicString)) + Expect(err).ToNot(HaveOccurred()) + + msgChannel := make(chan message.InboundMessage) + messageHandler := func(msg message.InboundMessage, replier solace.Replier) { + reply, err := messageBuilder.BuildWithStringPayload("Pong") // send reply back + Expect(err).ToNot(HaveOccurred()) + err = replier.Reply(reply) + Expect(err).ToNot(HaveOccurred()) + + msgChannel <- msg // put into channel + } + receiver.ReceiveAsync(messageHandler) + + defer func() { + err = receiver.Terminate(gracePeriod) + Expect(err).To(BeNil()) + }() + + err = start(receiver) + Expect(err).ToNot(HaveOccurred()) + + // get the reply message + replyChannel := make(chan message.InboundMessage, 1) + replyMessageHandler := func(msg message.InboundMessage, userContext interface{}, err error) { + replyChannel <- msg + } + + // send message + payload := "Ping" + msg, err := messageBuilder.BuildWithStringPayload(payload) + Expect(err).To(BeNil()) + topic := resource.TopicOf(topicString) + publisher.Publish(msg, replyMessageHandler, topic, publishTimeOut, nil, nil) + + // check that the message & reply was sent via semp + client := helpers.GetClient(messagingService) + Expect(client.DataTxMsgCount).To(Equal(int64(2))) + + select { + case receivedMessage := <-msgChannel: + Expect(receivedMessage).ToNot(BeNil()) + content, ok := receivedMessage.GetPayloadAsString() + Expect(ok).To(BeTrue()) + Expect(content).To(Equal(payload)) + + // for the reply message + var replyMessage message.InboundMessage + Eventually(replyChannel).Should(Receive(&replyMessage)) + Expect(replyMessage).ToNot(BeNil()) + content, ok = replyMessage.GetPayloadAsString() + Expect(ok).To(BeTrue()) + Expect(content).To(Equal("Pong")) + case <-time.After(gracePeriod): + Fail("Timed out waiting to receive message") + } + Expect(messagingService.Metrics().GetValue(metrics.DirectMessagesReceived)).To(Equal(uint64(2))) // send & reply + }) + It("should be able to synchronously receive a message successfully using start function "+startFunctionName, func() { + topicString := "try-me" + receiver, err := builder.Build(resource.TopicSubscriptionOf(topicString)) + Expect(err).ToNot(HaveOccurred()) + + msgChannel := make(chan message.InboundMessage, 1) + + defer func() { + err = receiver.Terminate(gracePeriod) + Expect(err).To(BeNil()) + }() + err = start(receiver) + Expect(err).ToNot(HaveOccurred()) + + // send message + payload := "Ping" + msg, err := messageBuilder.BuildWithStringPayload(payload) + Expect(err).To(BeNil()) + topic := resource.TopicOf(topicString) + replyChannel := make(chan message.InboundMessage, 1) + replyMessageHandler := func(msg message.InboundMessage, userContext interface{}, err error) { + replyChannel <- msg + } + publisher.Publish(msg, replyMessageHandler, topic, publishTimeOut, nil, nil) + + // check that the message was sent via semp + client := helpers.GetClient(messagingService) + Expect(client.DataTxMsgCount).To(Equal(int64(1))) + + go func() { + defer GinkgoRecover() + msg, replier, err := receiver.ReceiveMessage(-1) + Expect(err).ToNot(HaveOccurred()) + Expect(replier).ToNot(BeNil()) + + reply, err := messageBuilder.BuildWithStringPayload("Pong") // send reply back + Expect(err).ToNot(HaveOccurred()) + err = replier.Reply(reply) + Expect(err).ToNot(HaveOccurred()) + msgChannel <- msg + }() + + select { + case receivedMessage := <-msgChannel: + Expect(receivedMessage).ToNot(BeNil()) + content, ok := receivedMessage.GetPayloadAsString() + Expect(ok).To(BeTrue()) + Expect(content).To(Equal(payload)) + + // for the reply message + var replyMessage message.InboundMessage + Eventually(replyChannel).Should(Receive(&replyMessage)) + Expect(replyMessage).ToNot(BeNil()) + content, ok = replyMessage.GetPayloadAsString() + Expect(ok).To(BeTrue()) + Expect(content).To(Equal("Pong")) + case <-time.After(gracePeriod): + Fail("Timed out waiting to receive message") + } + Expect(messagingService.Metrics().GetValue(metrics.DirectMessagesReceived)).To(Equal(uint64(2))) // message & reply + }) + } + + It("should start idempotently", func() { + topicString := "try-me" + receiver, err := builder.Build(resource.TopicSubscriptionOf(topicString)) + Expect(err).ToNot(HaveOccurred()) + + msgChannel := make(chan message.InboundMessage) + messageHandler := func(msg message.InboundMessage, replier solace.Replier) { + reply, err := messageBuilder.BuildWithStringPayload("Pong") // send reply back + Expect(err).ToNot(HaveOccurred()) + err = replier.Reply(reply) + Expect(err).ToNot(HaveOccurred()) + + msgChannel <- msg // put into channel + } + receiver.ReceiveAsync(messageHandler) + + defer func() { + err = receiver.Terminate(gracePeriod) + Expect(err).To(BeNil()) + }() + errChan1 := receiver.StartAsync() + errChan2 := receiver.StartAsync() + select { + case <-errChan1: + // success + case <-time.After(100 * time.Millisecond): + Fail("timed out waiting for receiver to start asynchronously") + } + select { + case <-errChan2: + // success + case <-time.After(1 * time.Millisecond): + Fail("did not get second start signal") + } + Expect(receiver.IsRunning()).To(BeTrue()) + + // send message + payload := "Ping" + msg, err := messageBuilder.BuildWithStringPayload(payload) + Expect(err).To(BeNil()) + topic := resource.TopicOf(topicString) + replyChannel := make(chan message.InboundMessage, 1) + replyMessageHandler := func(msg message.InboundMessage, userContext interface{}, err error) { + replyChannel <- msg + } + publisher.Publish(msg, replyMessageHandler, topic, publishTimeOut, nil, nil) + + // check that the message was sent via semp + clientResponse, _, err := testcontext.SEMP().Monitor().MsgVpnApi. + GetMsgVpnClient(testcontext.SEMP().MonitorCtx(), testcontext.Messaging().VPN, url.QueryEscape(messagingService.GetApplicationID()), nil) + Expect(err).To(BeNil()) + Expect(clientResponse.Data.DataTxMsgCount).To(Equal(int64(2))) // for message & reply + + select { + case receivedMessage := <-msgChannel: + Expect(receivedMessage).ToNot(BeNil()) + content, ok := receivedMessage.GetPayloadAsString() + Expect(ok).To(BeTrue()) + Expect(content).To(Equal(payload)) + case <-time.After(gracePeriod): + Fail("Timed out waiting to receive message") + } + Expect(messagingService.Metrics().GetValue(metrics.DirectMessagesReceived)).To(Equal(uint64(2))) // message & reply + + select { + case <-msgChannel: + Fail("did not expect to receive another message from msgChannel") + case <-time.After(100 * time.Millisecond): + // no more messages were received + } + }) + + terminateFunctions := map[string](func(solace.RequestReplyMessageReceiver) error){ + "Terminate": func(rrmr solace.RequestReplyMessageReceiver) error { + return rrmr.Terminate(gracePeriod) + }, + "TerminateAsync": func(rrmr solace.RequestReplyMessageReceiver) error { + return <-rrmr.TerminateAsync(gracePeriod) + }, + "TerminateAsyncCallback": func(rrmr solace.RequestReplyMessageReceiver) error { + errChan := make(chan error) + rrmr.TerminateAsyncCallback(gracePeriod, func(e error) { + errChan <- e + }) + return <-errChan + }, + } + + for terminateFunctionName, terminateFunction := range terminateFunctions { + terminate := terminateFunction + It("can terminate using "+terminateFunctionName, func() { + topicString := "try-me" + receiver, err := builder.Build(resource.TopicSubscriptionOf(topicString)) + Expect(err).ToNot(HaveOccurred()) + + messageHandler := func(msg message.InboundMessage, replier solace.Replier) { + // we aren't doing anything in the handler + } + + Expect(receiver.ReceiveAsync(messageHandler)).ToNot(HaveOccurred()) + + defer func() { + // defer is okay since terminate is idempotent + err = receiver.Terminate(gracePeriod) + Expect(err).To(BeNil()) + }() + err = receiver.Start() + Expect(err).ToNot(HaveOccurred()) + + Expect(receiver.IsRunning()).To(BeTrue()) + + err = terminate(receiver) + Expect(err).ToNot(HaveOccurred()) + Expect(receiver.IsTerminated()).To(BeTrue()) + }) + } + + It("gives correct state for terminating with blocked receiver", func() { + topicString := "try-me" + receiver, err := builder.Build(resource.TopicSubscriptionOf(topicString)) + Expect(err).ToNot(HaveOccurred()) + + blocker := make(chan struct{}) + received := make(chan struct{}) + messageHandler := func(msg message.InboundMessage, replier solace.Replier) { + close(received) + <-blocker + } + receiver.ReceiveAsync(messageHandler) + + defer func() { + // defer is okay since terminate is idempotent + err = receiver.Terminate(gracePeriod) + Expect(err).To(BeNil()) + }() + + helpers.ValidateState(receiver, false, false, false) + err = receiver.Start() + Expect(err).ToNot(HaveOccurred()) + helpers.ValidateState(receiver, true, false, false) + + // send message + payload := "Hello World" + msg, err := messageBuilder.BuildWithStringPayload(payload) + Expect(err).To(BeNil()) + topic := resource.TopicOf(topicString) + handler := func(msg message.InboundMessage, userContext interface{}, err error) {} // empty handler + publisher.Publish(msg, handler, topic, publishTimeOut, nil, nil) + // we should now have messageHandler blocked + select { + case <-received: + // success + case <-time.After(1 * time.Second): + Fail("timed out waiting for message to be received") + } + + errChan := receiver.TerminateAsync(gracePeriod) + // allow termination to start + select { + case <-errChan: + Fail("did not expect to receive error when callback is still running") + case <-time.After(20 * time.Millisecond): + // allow termination to start + helpers.ValidateState(receiver, false, true, false) + } + + select { + case <-errChan: + Fail("did not expect to receive error when callback is still running") + case <-time.After(100 * time.Millisecond): + // success + } + + close(blocker) + select { + case err = <-errChan: + // success + Expect(err).ToNot(HaveOccurred()) + case <-time.After(100 * time.Millisecond): + Fail("timed out waiting for terminate channel to receive a result") + } + helpers.ValidateState(receiver, false, false, true) + }) + + It("should terminate idempotently", func() { + topicString := "try-me" + receiver, err := builder.Build(resource.TopicSubscriptionOf(topicString)) + Expect(err).ToNot(HaveOccurred()) + + msgChannel := make(chan message.InboundMessage) + messageHandler := func(msg message.InboundMessage, replier solace.Replier) { + msgChannel <- msg // put into channel + } + receiver.ReceiveAsync(messageHandler) + + defer func() { + err = receiver.Terminate(gracePeriod) + Expect(err).To(BeNil()) + }() + err = receiver.Start() + Expect(err).ToNot(HaveOccurred()) + + errChan1 := receiver.TerminateAsync(gracePeriod) + errChan2 := receiver.TerminateAsync(gracePeriod) + select { + case <-errChan1: + // success + case <-time.After(100 * time.Millisecond): + Fail("timed out waiting for receiver to start asynchronously") + } + select { + case <-errChan2: + // success + case <-time.After(1 * time.Millisecond): + Fail("did not get second start signal") + } + Expect(receiver.IsRunning()).To(BeFalse()) + }) + + Context("with a started and subscribed receiver", func() { + const topicString = "try-me" + var receiver solace.RequestReplyMessageReceiver + var publishTimeOut = 3 * time.Second + + BeforeEach(func() { + var err error + receiver, err = builder.Build(resource.TopicSubscriptionOf(topicString)) + Expect(err).ToNot(HaveOccurred()) + err = receiver.Start() + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + if !receiver.IsTerminated() { + receiver.Terminate(1 * time.Second) + } + }) + + It("should terminate with undelivered messages without an async callback", func() { + undeliveredCount := 10 + + publishReplyChan := helpers.PublishNRequestReplyMessages(messagingService, topicString, publishTimeOut, undeliveredCount) + Eventually(publishReplyChan).Should(Receive()) // something was published + + // wait for stats to indicate that the messages have arrived + Eventually(func() uint64 { + return messagingService.Metrics().GetValue(metrics.DirectMessagesReceived) + }).Should(BeNumerically(">=", undeliveredCount)) + + terminateChannel := receiver.TerminateAsync(1 * time.Second) + + select { + case <-terminateChannel: + Fail("did not expect receiver to terminate before grace period") + case <-time.After(500 * time.Millisecond): + // we have expired the grace period + } + + select { + case err := <-terminateChannel: + // success + Expect(err).To(HaveOccurred()) + Expect(err).To(BeAssignableToTypeOf(&solace.IncompleteMessageDeliveryError{})) + case <-time.After(1 * time.Second): + Fail("timed out waiting for terminate to complete") + } + + Expect(messagingService.Metrics().GetValue(metrics.ReceivedMessagesTerminationDiscarded)).To(Equal(uint64(undeliveredCount))) + }) + + It("should wait to terminate until all messages are processed with synchronous receive", func() { + publishChan := helpers.PublishNRequestReplyMessages(messagingService, topicString, publishTimeOut, 1) + Eventually(publishChan).Should(Receive()) // something was published + + helpers.ValidateMetric(messagingService, metrics.DirectMessagesReceived, 1) // reply not yet sent + terminateChannel := receiver.TerminateAsync(10 * time.Second) + Consistently(terminateChannel).ShouldNot(Receive()) + msg, replier, err := receiver.ReceiveMessage(-1) + Expect(err).ToNot(HaveOccurred()) + Expect(msg).ToNot(BeNil()) + Expect(replier).ToNot(BeNil()) + err = replier.Reply(helpers.NewMessage(messagingService, "Pong")) + Expect(err).ToNot(HaveOccurred()) + helpers.ValidateMetric(messagingService, metrics.DirectMessagesReceived, 2) // the reply has been sent now + Eventually(terminateChannel).Should(Receive(BeNil())) + }) + + It("should wait to terminate until all messages are processed with async receive", func() { + blocker := make(chan struct{}) + receiver.ReceiveAsync(func(inboundMessage message.InboundMessage, replier solace.Replier) { + <-blocker + }) + publishChan1 := helpers.PublishNRequestReplyMessages(messagingService, topicString, publishTimeOut, 1) + publishChan2 := helpers.PublishNRequestReplyMessages(messagingService, topicString, publishTimeOut, 1) + Eventually(publishChan1).Should(Receive()) // something was published + Eventually(publishChan2).Should(Receive()) // something was published + helpers.ValidateMetric(messagingService, metrics.DirectMessagesReceived, 2) + terminateChannel := receiver.TerminateAsync(10 * time.Second) + Consistently(terminateChannel).ShouldNot(Receive()) + close(blocker) + Eventually(terminateChannel).Should(Receive(BeNil())) + }) + + It("should reject asynchronous callback registration while terminating", func() { + publishChan := helpers.PublishNRequestReplyMessages(messagingService, topicString, publishTimeOut, 1) + Eventually(publishChan).Should(Receive()) // something was published + helpers.ValidateMetric(messagingService, metrics.DirectMessagesReceived, 1) + terminateChannel := receiver.TerminateAsync(10 * time.Second) + Consistently(terminateChannel).ShouldNot(Receive()) + + err := receiver.ReceiveAsync(func(inboundMessage message.InboundMessage, replier solace.Replier) {}) + helpers.ValidateError(err, &solace.IllegalStateError{}) + + msg, replier, err := receiver.ReceiveMessage(-1) + Expect(err).ToNot(HaveOccurred()) + Expect(msg).ToNot(BeNil()) + Expect(replier).ToNot(BeNil()) + Eventually(terminateChannel).Should(Receive(BeNil())) + }) + + It("should wait to terminate when all messages are processed but asynchronous callback is blocked", func() { + blocker := make(chan struct{}) + blocking := make(chan struct{}) + receiver.ReceiveAsync(func(inboundMessage message.InboundMessage, replier solace.Replier) { + close(blocking) + <-blocker + }) + publishChan := helpers.PublishNRequestReplyMessages(messagingService, topicString, publishTimeOut, 1) + Eventually(publishChan).Should(Receive()) // something was published + Eventually(blocking).Should(BeClosed()) + publishChan = helpers.PublishNRequestReplyMessages(messagingService, topicString, publishTimeOut, 1) + Eventually(publishChan).Should(Receive()) // something was published + helpers.ValidateMetric(messagingService, metrics.DirectMessagesReceived, 2) + terminateChannel := receiver.TerminateAsync(10 * time.Second) + Consistently(terminateChannel).ShouldNot(Receive()) + msg, replier, err := receiver.ReceiveMessage(500 * time.Millisecond) + Expect(err).ToNot(HaveOccurred()) + Expect(msg).ToNot(BeNil()) + Expect(replier).ToNot(BeNil()) + Consistently(terminateChannel).ShouldNot(Receive()) + close(blocker) + Eventually(terminateChannel).Should(Receive(BeNil())) + }) + + // SOL-112355 - the tests to cover request/reply processing using RequestReply Publisher/Receiver + Describe("for request/reply processing", func() { + + It("should time out waiting for a message", func() { + done := make(chan struct{}) + go func() { + defer GinkgoRecover() + msg, replier, err := receiver.ReceiveMessage(1 * time.Second) + Expect(msg).To(BeNil()) + Expect(replier).To(BeNil()) + helpers.ValidateError(err, &solace.TimeoutError{}) + close(done) + }() + // We want to make sure that it does not close for at least 500ms + Consistently(done, 500*time.Millisecond).ShouldNot(BeClosed()) + // we want it to be closed after 1 second + Eventually(done).Should(BeClosed()) + }) + + It("should be able to continue message delivery when a receive async panics", func() { + msgReceived := make(chan message.InboundMessage) + receiver.ReceiveAsync(func(inboundMessage message.InboundMessage, replier solace.Replier) { + msgReceived <- inboundMessage + panic("this should still pass even though this Panic occurred") + }) + const payloadOne = "one" + const payloadTwo = "two" + helpers.PublishNRequestReplyMessages(messagingService, topicString, publishTimeOut, 1, payloadOne) + + // we should receive a message + var msg message.InboundMessage + Eventually(msgReceived).Should(Receive(&msg)) + payload, ok := msg.GetPayloadAsString() + Expect(ok).To(BeTrue()) + Expect(payload).To(Equal(payloadOne)) + helpers.PublishNRequestReplyMessages(messagingService, topicString, publishTimeOut, 1, payloadTwo) + // we should continue to receive messages + Eventually(msgReceived).Should(Receive(&msg)) + payload, ok = msg.GetPayloadAsString() + Expect(ok).To(BeTrue()) + Expect(payload).To(Equal(payloadTwo)) + // we should be able to terminate + Expect(receiver.Terminate(10 * time.Second)).ToNot(HaveOccurred()) + }) + + It("should wait indefinitely for a message", func() { + done := make(chan struct{}) + go func() { + defer GinkgoRecover() + msg, replier, err := receiver.ReceiveMessage(-1) + Expect(err).ToNot(HaveOccurred()) + Expect(msg).ToNot(BeNil()) + Expect(replier).ToNot(BeNil()) + time.Sleep(3 * time.Second) + close(done) + }() + Consistently(done, 2*time.Second).ShouldNot(BeClosed()) // less than receive function's sleep + helpers.PublishNRequestReplyMessages(messagingService, topicString, publishTimeOut, 1) + Eventually(done).Should(BeClosed()) + }) + + It("should be interrupted while waiting for a message", func() { + done := make(chan struct{}) + go func() { + defer GinkgoRecover() + msg, replier, err := receiver.ReceiveMessage(-1) + Expect(msg).To(BeNil()) + Expect(replier).To(BeNil()) + helpers.ValidateError(err, &solace.IllegalStateError{}, "terminated") + close(done) + }() + Consistently(done, 1*time.Second).ShouldNot(BeClosed()) + terminateChannel := receiver.TerminateAsync(10 * time.Second) + Eventually(terminateChannel).Should(Receive(BeNil())) + Eventually(done).Should(BeClosed()) + }) + + receiverFunctions := map[string](func(solace.RequestReplyMessageReceiver, int) chan message.InboundMessage){ + "Receive": func(rrmr solace.RequestReplyMessageReceiver, count int) chan message.InboundMessage { + msgChannel := make(chan message.InboundMessage, count) + for i := 0; i < count; i++ { + go func() { + defer GinkgoRecover() + msg, replier, err := rrmr.ReceiveMessage(-1) + Expect(err).ToNot(HaveOccurred()) + Expect(msg).ToNot(BeNil()) + Expect(replier).ToNot(BeNil()) + err = replier.Reply(helpers.NewMessage(messagingService, "Pong")) // send reply back + Expect(err).ToNot(HaveOccurred()) + msgChannel <- msg // put into channel + }() + } + return msgChannel + }, + "ReceiveAsync": func(rrmr solace.RequestReplyMessageReceiver, count int) chan message.InboundMessage { + msgChannel := make(chan message.InboundMessage, count) + rrmr.ReceiveAsync(func(msg message.InboundMessage, replier solace.Replier) { + go func() { // send reply in a new go routine + err := replier.Reply(helpers.NewMessage(messagingService, "Pong")) // send reply back + Expect(err).ToNot(HaveOccurred()) + msgChannel <- msg // put into channel + }() + }) + return msgChannel + }, + } + + for receiverFunctionName, receiverFunction := range receiverFunctions { + receiverFunc := receiverFunction + + It("should be able to receive a message successfully with "+receiverFunctionName+"() function", func() { + // publish the request messages + publishedMessages := 0 + publisherReplies, _, publisherComplete := helpers.PublishRequestReplyMessages(messagingService, topicString, publishTimeOut, uint(1), &publishedMessages, "Ping") + + // allow the goroutine above to saturate the publisher + select { + case <-publisherComplete: + // allow the publishing to complete before proceeding + case <-time.After(100 * time.Millisecond): + // should not timeout before publishing is complete + Fail("Not expected to timeout before publishing is complete; Should not get here") + } + + // receive the request message and send back a reply + receivedMsgChannel := receiverFunc(receiver, 1) + + // check that the message & reply was sent via semp + client := helpers.GetClient(messagingService) + Expect(client.DataTxMsgCount).To(Equal(int64(2))) + + select { + case receivedMessage := <-receivedMsgChannel: + Expect(receivedMessage).ToNot(BeNil()) + content, ok := receivedMessage.GetPayloadAsString() + Expect(ok).To(BeTrue()) + Expect(content).To(Equal("Ping")) + + // for the reply message + var replyMessage message.InboundMessage + Eventually(publisherReplies).Should(Receive(&replyMessage)) // something was published + Expect(replyMessage).ToNot(BeNil()) + content, ok = replyMessage.GetPayloadAsString() + Expect(ok).To(BeTrue()) + Expect(content).To(Equal("Pong")) + case <-time.After(gracePeriod): + Fail("Timed out waiting to receive message") + } + + Expect(messagingService.Metrics().GetValue(metrics.DirectMessagesReceived)).To(Equal(uint64(2))) // send & reply + }) + + It("should be able to receive multiple messages successfully with "+receiverFunctionName+"() function", func() { + // send messages + sentMessage := 500 + const publishTimeOut = 3 * time.Second + + var err error + // we need to properly configure the receiver's buffer to handle > 50 published messages + receiver, err = builder.OnBackPressureDropLatest(uint(sentMessage)).Build(resource.TopicSubscriptionOf(topicString)) + Expect(err).ToNot(HaveOccurred()) + err = receiver.Start() + Expect(err).ToNot(HaveOccurred()) + + // publish the request messages + publishedMessages := 0 + publisherReplies, publisherSaturated, publisherComplete := helpers.PublishRequestReplyMessages(messagingService, topicString, publishTimeOut, uint(sentMessage), &publishedMessages, "Ping") + + // allow the goroutine above to saturate the publisher + select { + case <-publisherComplete: + // block until publish complete + Fail("Expected publisher to not be complete") + case <-publisherSaturated: + // allow the goroutine above to saturate the publisher (at least halfway filled) + case <-time.After(100 * time.Millisecond): + // should not timeout while saturating the publisher + Fail("Not expected to timeout while saturating publisher; Should not get here") + } + + receivedMsgChannel := receiverFunc(receiver, sentMessage) + Eventually(receivedMsgChannel).Should(HaveLen(sentMessage)) // replies should be sent back + // message in the channel should be a request message + receivedMessage := <-receivedMsgChannel + content, ok := receivedMessage.GetPayloadAsString() + Expect(ok).To(BeTrue()) + Expect(content).To(Equal("Ping")) + + select { + // message in the reply channel should be a reply message + case replyMessage := <-publisherReplies: + Expect(replyMessage).ToNot(BeNil()) + content, ok := replyMessage.GetPayloadAsString() + Expect(ok).To(BeTrue()) + Expect(content).To(Equal("Pong")) + case <-time.After(20 * time.Second): + Fail("Timed out waiting to receive reply message") + } + + // check that the message & reply was sent via semp + client := helpers.GetClient(messagingService) + Expect(client.DataTxMsgCount).To(Equal(int64(sentMessage * 2))) // requests & replies + Expect(messagingService.Metrics().GetValue(metrics.DirectMessagesSent)).To(BeNumerically(">", uint64(sentMessage))) // send & reply + }) + } + + It("should properly handle direct massages published to request-reply topic with the Receive() function", func() { + helpers.PublishOneMessage(messagingService, topicString, "Ping") // publish direct message + // block and wait for direct message + msg, replier, err := receiver.ReceiveMessage(-1) + Expect(err).ToNot(HaveOccurred()) // not expecting an error + Expect(msg).ToNot(BeNil()) // we should receive a message + Expect(replier).To(BeNil()) // but no replier since this should be a direct message + + // publish the request messages on another thread to same topic + helpers.PublishNRequestReplyMessages(messagingService, topicString, publishTimeOut, 1, "Ping") + + // block and wait for request message + msg, replier, err = receiver.ReceiveMessage(-1) + Expect(err).ToNot(HaveOccurred()) + Expect(msg).ToNot(BeNil()) + Expect(replier).ToNot(BeNil()) + err = replier.Reply(helpers.NewMessage(messagingService, "Pong")) // send reply back + Expect(err).ToNot(HaveOccurred()) + + // check that the message & reply was sent via semp + client := helpers.GetClient(messagingService) + Expect(client.DataTxMsgCount).To(Equal(int64(3))) // 1 direct, 1 request and 1 reply message // requests & replies + Expect(messagingService.Metrics().GetValue(metrics.DirectMessagesReceived)).To(Equal(uint64(3))) // 3 messages + }) + + It("should properly handle direct messages published to request-reply topic with the ReceiveAsync() function", func() { + rRMessagesCount := uint64(0) + directMessagesCount := uint64(0) + + publishedRRMessages := 250 + publishedDirectMessages := 250 + receiverBackPressureBufferLength := publishedRRMessages + publishedDirectMessages + + // create a receiver with an adequate buffer size + var err error + // we need to properly configure the receiver's buffer to handle > 50 published messages + receiver, err = builder.OnBackPressureDropLatest(uint(receiverBackPressureBufferLength)).Build(resource.TopicSubscriptionOf(topicString)) + Expect(err).ToNot(HaveOccurred()) + err = receiver.Start() + Expect(err).ToNot(HaveOccurred()) + + receiver.ReceiveAsync(func(inboundMessage message.InboundMessage, replier solace.Replier) { + Expect(inboundMessage).ToNot(BeNil()) // we should receive a message + if replier != nil { + // request-reply message received + err := replier.Reply(helpers.NewMessage(messagingService, "Pong")) // send reply back + Expect(err).ToNot(HaveOccurred()) + atomic.AddUint64(&rRMessagesCount, 1) // increment + } else { + // direct message received + atomic.AddUint64(&directMessagesCount, 1) // increment + } + }) + + for i := 0; i < publishedDirectMessages; i++ { + helpers.PublishOneMessage(messagingService, topicString, "Ping") // publish direct message + } + + // publish the request messages on another thread to same topic + publishChan := helpers.PublishNRequestReplyMessages(messagingService, topicString, publishTimeOut, publishedRRMessages, "Ping") + + select { + // last message (any message) in the reply channel should be a reply message + case replyMessage := <-publishChan: + Expect(replyMessage).ToNot(BeNil()) + content, ok := replyMessage.GetPayloadAsString() + Expect(ok).To(BeTrue()) + Expect(content).To(Equal("Pong")) + case <-time.After(20 * time.Second): + Fail("Timed out waiting to receive reply message") + } + + // check that the message & reply was sent via semp + Eventually(func() int64 { + return helpers.GetClient(messagingService).DataTxMsgCount + }).Should(BeNumerically("==", int64(publishedRRMessages*2)+int64(publishedDirectMessages))) + + Eventually(func() uint64 { + return messagingService.Metrics().GetValue(metrics.DirectMessagesReceived) + }).Should(BeNumerically("==", uint64(publishedRRMessages*2)+uint64(publishedDirectMessages))) // the messages + + Expect(atomic.LoadUint64(&rRMessagesCount)).To(BeNumerically("==", publishedRRMessages)) + Expect(atomic.LoadUint64(&directMessagesCount)).To(BeNumerically("==", publishedDirectMessages)) + }) + }) + }) + + }) + +}) + +type myCustomRequestReplySubscription struct { +} + +func (sub *myCustomRequestReplySubscription) GetName() string { + return "some string" +} + +func (sub *myCustomRequestReplySubscription) GetSubscriptionType() string { + return "some type" +} diff --git a/version.go b/version.go index 2cf33bb..1b6edb3 100644 --- a/version.go +++ b/version.go @@ -23,4 +23,4 @@ func init() { core.SetVersion(version) } -const version = "1.5.0" +const version = "1.6.0"