This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 9e62fa3aa3a2 CAMEL-24569: Support Salesforce Streaming API
Server-Initiated Disconnect Messages
9e62fa3aa3a2 is described below
commit 9e62fa3aa3a232d69d3e31051074774d6584fe1b
Author: Bartosz Popiela <[email protected]>
AuthorDate: Mon Aug 31 22:32:11 2026 +0200
CAMEL-24569: Support Salesforce Streaming API Server-Initiated Disconnect
Messages
Salesforce Streaming API 64.0 introduced server-initiated
/meta/disconnect messages, which SubscriptionHelper previously did not
handle, leaving subscriptions inactive once the server sent one.
Handle the /meta/disconnect message and reconnect to keep
subscriptions active. Wait for CometD to reach the disconnected state
before reconnecting, run that wait on the HTTP worker thread instead
of a single-threaded executor to avoid blocking it, verify channels
are resubscribed only once, and reduce the disconnect timeout to 10
seconds.
Closes #25908
---
.../internal/streaming/SubscriptionHelper.java | 331 +++++++++++++--------
.../streaming/SubscriptionHelperManualIT.java | 80 +++++
2 files changed, 290 insertions(+), 121 deletions(-)
diff --git
a/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/internal/streaming/SubscriptionHelper.java
b/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/internal/streaming/SubscriptionHelper.java
index 0960afa4b8d1..1e842fbe80aa 100644
---
a/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/internal/streaming/SubscriptionHelper.java
+++
b/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/internal/streaming/SubscriptionHelper.java
@@ -30,8 +30,10 @@ import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@@ -67,6 +69,7 @@ import static java.util.Collections.emptySet;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.cometd.bayeux.Channel.META_CONNECT;
+import static org.cometd.bayeux.Channel.META_DISCONNECT;
import static org.cometd.bayeux.Channel.META_HANDSHAKE;
import static org.cometd.bayeux.Channel.META_SUBSCRIBE;
import static org.cometd.bayeux.Message.ERROR_FIELD;
@@ -79,6 +82,7 @@ public class SubscriptionHelper extends ServiceSupport {
private static final Logger LOG =
LoggerFactory.getLogger(SubscriptionHelper.class);
private static final int HANDSHAKE_TIMEOUT_SEC = 120;
+ private static final int DISCONNECT_TIMEOUT_SEC = 10;
private static final String FAILURE_FIELD = "failure";
private static final String EXCEPTION_FIELD = "exception";
@@ -92,9 +96,9 @@ public class SubscriptionHelper extends ServiceSupport {
private static final String DENIED_BY_SEC_POLICY =
"403:denied_by_security_policy";
private static final String AUTHORIZATION_ERROR = "403::";
- BayeuxClient client;
+ volatile BayeuxClient client;
- private ScheduledExecutorService taskExecutor;
+ private volatile ScheduledExecutorService taskExecutor;
private final SalesforceComponent component;
private SalesforceSession session;
@@ -107,6 +111,8 @@ public class SubscriptionHelper extends ServiceSupport {
private final AtomicLong handshakeBackoff;
+ private final AtomicBoolean reconnecting = new AtomicBoolean(false);
+
private final Map<String, Set<StreamingApiConsumer>> channelToConsumers =
new ConcurrentHashMap<>();
private final Map<StreamingApiConsumer,
ClientSessionChannel.MessageListener> consumerToListener
@@ -121,6 +127,8 @@ public class SubscriptionHelper extends ServiceSupport {
private final ClientSessionChannel.MessageListener connectListener =
createConnectionListener();
+ private final ClientSessionChannel.MessageListener disconnectListener =
createDisconnectListener();
+
public SubscriptionHelper(final SalesforceComponent component) {
this.component = component;
handshakeBackoff = new AtomicLong();
@@ -129,108 +137,170 @@ public class SubscriptionHelper extends ServiceSupport {
}
private MessageListener createHandshakeListener() {
- return (channel, message) ->
component.getHttpClient().getWorkerPool().execute(() -> {
- LOG.debug("[CHANNEL:META_HANDSHAKE]: {}", message);
+ return (channel, message) -> component
+ .getHttpClient()
+ .getWorkerPool()
+ .execute(
+ () -> {
+ LOG.debug("[CHANNEL:META_HANDSHAKE]: {}", message);
+
+ if (!message.isSuccessful()) {
+ LOG.warn("Handshake failure: {}", message);
+ handshakeError = (String)
message.get(ERROR_FIELD);
+ handshakeException = getFailure(message);
+ if (handshakeError != null) {
+ if (handshakeError.startsWith("403::")) {
+ String failureReason =
getFailureReason(message);
+ if
(AUTHENTICATION_INVALID.equals(failureReason)) {
+ LOG.debug(
+ "attempting login due to
handshake error: 403 -> 401::Authentication invalid");
+
session.attemptLoginUntilSuccessful(backoffIncrement, maxBackoff);
+ }
+ }
+ }
+ // failed, so keep trying with backoff
+ final long backoff =
handshakeBackoff.getAndAdd(backoffIncrement);
+ if (backoff > maxBackoff) {
+ LOG.error("Handshake retry aborted after
exceeding {} msecs backoff", maxBackoff);
+ } else {
+ LOG.debug("Pausing for {} msecs before
handshake retry", backoff);
+ if (backoff > 0) {
+ Tasks.backgroundTask()
+
.withBudget(Budgets.iterationTimeBudget()
+ .withMaxIterations(1)
+
.withInitialDelay(Duration.ofMillis(backoff))
+
.withInterval(Duration.ofMillis(1))
+
.withUnlimitedDuration()
+ .build())
+
.withScheduledExecutor(taskExecutor)
+
.withName("SalesforceHandshakeRetryDelay")
+ .build()
+
.run(component.getCamelContext(), () -> true);
+ }
+ client.handshake();
+ }
+ } else if (!channelToConsumers.isEmpty()) {
+ channelsLock.lock();
+ try {
+ channelsToSubscribe.clear();
+
channelsToSubscribe.addAll(channelToConsumers.keySet());
+ } finally {
+ channelsLock.unlock();
+ }
+ LOG.info("Handshake successful. Channels to
subscribe: {}", channelsToSubscribe);
+ }
+ });
+ }
- if (!message.isSuccessful()) {
- LOG.warn("Handshake failure: {}", message);
- handshakeError = (String) message.get(ERROR_FIELD);
- handshakeException = getFailure(message);
- if (handshakeError != null) {
- if (handshakeError.startsWith("403::")) {
- String failureReason = getFailureReason(message);
- if (AUTHENTICATION_INVALID.equals(failureReason)) {
- LOG.debug(
- "attempting login due to handshake error:
403 -> 401::Authentication invalid");
-
session.attemptLoginUntilSuccessful(backoffIncrement, maxBackoff);
- }
- }
- }
- // failed, so keep trying with backoff
- final long backoff =
handshakeBackoff.getAndAdd(backoffIncrement);
- if (backoff > maxBackoff) {
- LOG.error("Handshake retry aborted after exceeding {}
msecs backoff", maxBackoff);
- } else {
- LOG.debug("Pausing for {} msecs before handshake retry",
backoff);
- if (backoff > 0) {
- Tasks.backgroundTask()
- .withBudget(Budgets.iterationTimeBudget()
- .withMaxIterations(1)
-
.withInitialDelay(Duration.ofMillis(backoff))
- .withInterval(Duration.ofMillis(1))
- .withUnlimitedDuration()
- .build())
- .withScheduledExecutor(taskExecutor)
- .withName("SalesforceHandshakeRetryDelay")
- .build()
- .run(component.getCamelContext(), () -> true);
- }
- client.handshake();
- }
- } else if (!channelToConsumers.isEmpty()) {
- channelsLock.lock();
- try {
- channelsToSubscribe.clear();
- channelsToSubscribe.addAll(channelToConsumers.keySet());
- } finally {
- channelsLock.unlock();
+ private MessageListener createConnectionListener() {
+ return (channel, message) -> component
+ .getHttpClient()
+ .getWorkerPool()
+ .execute(
+ () -> {
+ LOG.debug("[CHANNEL:META_CONNECT]: {}", message);
+ String reconnectAdvice = message.getAdvice() !=
null
+ ? (String)
message.getAdvice().get("reconnect")
+ : null;
+
+ if (!message.isSuccessful()) {
+ LOG.warn("Connect failure: {}", message);
+ connectError = (String)
message.get(ERROR_FIELD);
+ connectException = getFailure(message);
+
+ if (connectError != null &&
connectError.equals(AUTHENTICATION_INVALID)) {
+ LOG.debug("connectError: {}",
connectError);
+ LOG.debug("Attempting login...");
+
session.attemptLoginUntilSuccessful(backoffIncrement, maxBackoff);
+ }
+ // Per Bayeux spec: handshake on null advice,
"none", "handshake", or any non-"retry" value.
+ // When advice is "retry", the CometD client
handles reconnection automatically.
+ if (reconnectAdvice == null ||
!"retry".equals(reconnectAdvice)) {
+ LOG.debug("Reconnect advice [{}] on failed
connect, initiating handshake", reconnectAdvice);
+ client.handshake();
+ } else if (isTemporaryError(message)) {
+ LOG.debug("Initiating handshake after
temporary error: {}", message);
+ client.handshake();
+ }
+ } else if (reconnectAdvice != null &&
!"retry".equals(reconnectAdvice)) {
+ LOG.warn("Reconnect advice [{}] on successful
connect, initiating handshake", reconnectAdvice);
+ client.handshake();
+ } else {
+ Set<String> toSubscribe = null;
+ channelsLock.lock();
+ try {
+ if (!channelsToSubscribe.isEmpty()) {
+ toSubscribe = new
HashSet<>(channelsToSubscribe);
+ channelsToSubscribe.clear();
+ }
+ } finally {
+ channelsLock.unlock();
+ }
+ if (toSubscribe != null) {
+ LOG.info("Subscribing to channels: {}",
toSubscribe);
+ for (var channelName : toSubscribe) {
+ var consumers =
channelToConsumers.getOrDefault(channelName, emptySet());
+ for (var consumer : consumers) {
+ subscribe(consumer);
+ }
+ }
+ }
+ }
+ });
+ }
+
+ private MessageListener createDisconnectListener() {
+ return (channel, message) -> {
+ LOG.debug("[CHANNEL:META_DISCONNECT]: {}", message);
+
+ if (isStoppingOrStopped()) {
+ LOG.debug("Ignoring disconnect message while stopping");
+ return;
+ }
+ if (!reconnecting.compareAndSet(false, true)) {
+ LOG.debug("Reconnect already in progress");
+ return;
+ }
+
+ try {
+
component.getHttpClient().getWorkerPool().execute(this::reconnectAfterDisconnect);
+ } catch (RejectedExecutionException e) {
+ reconnecting.set(false);
+ if (!isStoppingOrStopped()) {
+ LOG.warn("Unable to schedule reconnect after server
disconnect", e);
}
- LOG.info("Handshake successful. Channels to subscribe: {}",
channelsToSubscribe);
}
- });
+ };
}
- private MessageListener createConnectionListener() {
- return (channel, message) ->
component.getHttpClient().getWorkerPool().execute(() -> {
- LOG.debug("[CHANNEL:META_CONNECT]: {}", message);
- String reconnectAdvice = message.getAdvice() != null
- ? (String) message.getAdvice().get("reconnect")
- : null;
+ private void reconnectAfterDisconnect() {
+ final BayeuxClient disconnectedClient = client;
+ try {
+ if (disconnectedClient == null || isStoppingOrStopped()) {
+ return;
+ }
- if (!message.isSuccessful()) {
- LOG.warn("Connect failure: {}", message);
- connectError = (String) message.get(ERROR_FIELD);
- connectException = getFailure(message);
-
- if (connectError != null &&
connectError.equals(AUTHENTICATION_INVALID)) {
- LOG.debug("connectError: {}", connectError);
- LOG.debug("Attempting login...");
- session.attemptLoginUntilSuccessful(backoffIncrement,
maxBackoff);
- }
- // Per Bayeux spec: handshake on null advice, "none",
"handshake", or any non-"retry" value.
- // When advice is "retry", the CometD client handles
reconnection automatically.
- if (reconnectAdvice == null ||
!"retry".equals(reconnectAdvice)) {
- LOG.debug("Reconnect advice [{}] on failed connect,
initiating handshake", reconnectAdvice);
- client.handshake();
- } else if (isTemporaryError(message)) {
- LOG.debug("Initiating handshake after temporary error:
{}", message);
- client.handshake();
- }
- } else if (reconnectAdvice != null &&
!"retry".equals(reconnectAdvice)) {
- LOG.warn("Reconnect advice [{}] on successful connect,
initiating handshake", reconnectAdvice);
- client.handshake();
- } else {
- Set<String> toSubscribe = null;
- channelsLock.lock();
- try {
- if (!channelsToSubscribe.isEmpty()) {
- toSubscribe = new HashSet<>(channelsToSubscribe);
- channelsToSubscribe.clear();
- }
- } finally {
- channelsLock.unlock();
- }
- if (toSubscribe != null) {
- LOG.info("Subscribing to channels: {}", toSubscribe);
- for (var channelName : toSubscribe) {
- var consumers =
channelToConsumers.getOrDefault(channelName, emptySet());
- for (var consumer : consumers) {
- subscribe(consumer);
- }
- }
+ final long waitMs = MILLISECONDS.convert(DISCONNECT_TIMEOUT_SEC,
SECONDS);
+ if (!disconnectedClient.waitFor(waitMs,
BayeuxClient.State.DISCONNECTED)) {
+ if (!isStoppingOrStopped()) {
+ LOG.warn("Timed out after {} seconds waiting for the
Streaming API client to disconnect",
+ DISCONNECT_TIMEOUT_SEC);
}
+ return;
}
- });
+ if (isStoppingOrStopped() || client != disconnectedClient) {
+ return;
+ }
+
+ LOG.info("Server disconnect received, reconnecting to Streaming
API");
+ disconnectedClient.handshake();
+ } catch (IllegalStateException e) {
+ if (!isStoppingOrStopped()) {
+ LOG.debug("Streaming API reconnect was superseded by another
state transition", e);
+ }
+ } finally {
+ reconnecting.set(false);
+ }
}
private MessageListener createSubscriptionListener() {
@@ -272,8 +342,9 @@ public class SubscriptionHelper extends ServiceSupport {
}
Exception failure = getFailure(message);
- String msg = String.format("Error subscribing to %s: %s",
firstConsumer.getTopicName(),
- failure != null ? failure.getMessage() : error);
+ String msg = String.format(
+ "Error subscribing to %s: %s",
+ firstConsumer.getTopicName(), failure != null ?
failure.getMessage() : error);
boolean abort = true;
LOG.warn(msg);
@@ -308,8 +379,7 @@ public class SubscriptionHelper extends ServiceSupport {
}
} else if (error.matches(INVALID_REPLAY_ID_PATTERN)) {
abort = false;
- long fallBackReplayId
- =
firstConsumer.getEndpoint().getConfiguration().getFallBackReplayId();
+ long fallBackReplayId =
firstConsumer.getEndpoint().getConfiguration().getFallBackReplayId();
LOG.warn(error);
LOG.warn("Falling back to replayId {} for channel {}",
fallBackReplayId, channelName);
replayExtension.setReplayId(channelName, fallBackReplayId);
@@ -350,6 +420,7 @@ public class SubscriptionHelper extends ServiceSupport {
client.getChannel(META_HANDSHAKE).addListener(handshakeListener);
client.getChannel(META_SUBSCRIBE).addListener(subscriptionListener);
client.getChannel(META_CONNECT).addListener(connectListener);
+ client.getChannel(META_DISCONNECT).addListener(disconnectListener);
}
private void handshake() throws CamelException {
@@ -360,18 +431,19 @@ public class SubscriptionHelper extends ServiceSupport {
if (!client.waitFor(waitMs, BayeuxClient.State.CONNECTED)) {
if (handshakeException != null) {
throw new CamelException(
- String.format("Exception during HANDSHAKE: %s",
handshakeException.getMessage()), handshakeException);
+ String.format("Exception during HANDSHAKE: %s",
handshakeException.getMessage()),
+ handshakeException);
} else if (handshakeError != null) {
throw new CamelException(String.format("Error during
HANDSHAKE: %s", handshakeError));
} else if (connectException != null) {
throw new CamelException(
- String.format("Exception during CONNECT: %s",
connectException.getMessage()), connectException);
+ String.format("Exception during CONNECT: %s",
connectException.getMessage()),
+ connectException);
} else if (connectError != null) {
throw new CamelException(String.format("Error during CONNECT:
%s", connectError));
} else {
throw new CamelException(
- String.format("Handshake request timeout after %s
seconds",
- HANDSHAKE_TIMEOUT_SEC));
+ String.format("Handshake request timeout after %s
seconds", HANDSHAKE_TIMEOUT_SEC));
}
}
}
@@ -412,6 +484,7 @@ public class SubscriptionHelper extends ServiceSupport {
}
closeChannel(META_CONNECT);
+ closeChannel(META_DISCONNECT);
closeChannel(META_SUBSCRIBE);
closeChannel(META_HANDSHAKE);
@@ -434,7 +507,8 @@ public class SubscriptionHelper extends ServiceSupport {
LOG.debug("Stopped the helper and destroyed the client");
}
- static BayeuxClient createClient(final SalesforceComponent component,
final SalesforceSession session)
+ static BayeuxClient createClient(
+ final SalesforceComponent component, final SalesforceSession
session)
throws SalesforceException {
// use default Jetty client from SalesforceComponent, it's shared by
all consumers
final SalesforceHttpClient httpClient =
component.getConfig().getHttpClient();
@@ -462,7 +536,7 @@ public class SubscriptionHelper extends ServiceSupport {
protected void customize(Request request) {
super.customize(request);
- //accessToken might be null due to lazy login
+ // accessToken might be null due to lazy login
String accessToken = session.getAccessToken();
if (accessToken == null) {
try {
@@ -510,17 +584,21 @@ public class SubscriptionHelper extends ServiceSupport {
try {
// create subscription for consumer
final String channelName = getChannelName(consumer.getTopicName());
- channelToConsumers.computeIfAbsent(channelName, key ->
ConcurrentHashMap.newKeySet()).add(consumer);
+ channelToConsumers
+ .computeIfAbsent(channelName, key ->
ConcurrentHashMap.newKeySet())
+ .add(consumer);
setReplayIdIfAbsent(consumer.getEndpoint());
// channel message listener
LOG.info("Subscribing to channel {}...", channelName);
- var messageListener = consumerToListener.computeIfAbsent(consumer,
key -> (channel, message) -> {
- LOG.debug("Received Message: {}", message);
- // convert CometD message to Camel Message
- consumer.processMessage(channel, message);
- });
+ var messageListener = consumerToListener.computeIfAbsent(
+ consumer,
+ key -> (channel, message) -> {
+ LOG.debug("Received Message: {}", message);
+ // convert CometD message to Camel Message
+ consumer.processMessage(channel, message);
+ });
// subscribe asynchronously
final ClientSessionChannel clientChannel =
client.getChannel(channelName);
@@ -569,7 +647,8 @@ public class SubscriptionHelper extends ServiceSupport {
}
}
- static Optional<Long> determineReplayIdFor(final SalesforceEndpoint
endpoint, final String topicName) {
+ static Optional<Long> determineReplayIdFor(
+ final SalesforceEndpoint endpoint, final String topicName) {
final String channelName = getChannelName(topicName);
final Long replayId = endpoint.getReplayId();
@@ -578,21 +657,27 @@ public class SubscriptionHelper extends ServiceSupport {
final SalesforceEndpointConfig endpointConfiguration =
endpoint.getConfiguration();
final Map<String, Long> endpointInitialReplayIdMap =
endpointConfiguration.getInitialReplayIdMap();
- final Long endpointReplayId
- = endpointInitialReplayIdMap.getOrDefault(topicName,
endpointInitialReplayIdMap.get(channelName));
+ final Long endpointReplayId = endpointInitialReplayIdMap.getOrDefault(
+ topicName, endpointInitialReplayIdMap.get(channelName));
final Long endpointDefaultReplayId =
endpointConfiguration.getDefaultReplayId();
final SalesforceEndpointConfig componentConfiguration =
component.getConfig();
final Map<String, Long> componentInitialReplayIdMap =
componentConfiguration.getInitialReplayIdMap();
- final Long componentReplayId
- = componentInitialReplayIdMap.getOrDefault(topicName,
componentInitialReplayIdMap.get(channelName));
+ final Long componentReplayId =
componentInitialReplayIdMap.getOrDefault(
+ topicName, componentInitialReplayIdMap.get(channelName));
final Long componentDefaultReplayId =
componentConfiguration.getDefaultReplayId();
// the endpoint values have priority over component values, and the
// default values priority
// over give topic values
- return Stream.of(replayId, endpointReplayId, componentReplayId,
endpointDefaultReplayId, componentDefaultReplayId)
- .filter(Objects::nonNull).findFirst();
+ return Stream.of(
+ replayId,
+ endpointReplayId,
+ componentReplayId,
+ endpointDefaultReplayId,
+ componentDefaultReplayId)
+ .filter(Objects::nonNull)
+ .findFirst();
}
static String getChannelName(final String topicName) {
@@ -646,9 +731,13 @@ public class SubscriptionHelper extends ServiceSupport {
boolean replayOptionsPresent =
component.getConfig().getDefaultReplayId() != null
||
!component.getConfig().getInitialReplayIdMap().isEmpty();
if (replayOptionsPresent) {
- return component.getSession().getInstanceUrl() +
"/cometd/replay/" + component.getConfig().getApiVersion();
+ return component.getSession().getInstanceUrl()
+ + "/cometd/replay/"
+ + component.getConfig().getApiVersion();
}
}
- return component.getSession().getInstanceUrl() + "/cometd/" +
component.getConfig().getApiVersion();
+ return component.getSession().getInstanceUrl()
+ + "/cometd/"
+ + component.getConfig().getApiVersion();
}
}
diff --git
a/components/camel-salesforce/camel-salesforce-component/src/test/java/org/apache/camel/component/salesforce/internal/streaming/SubscriptionHelperManualIT.java
b/components/camel-salesforce/camel-salesforce-component/src/test/java/org/apache/camel/component/salesforce/internal/streaming/SubscriptionHelperManualIT.java
index abbdcbacfabb..2e73104a4a68 100644
---
a/components/camel-salesforce/camel-salesforce-component/src/test/java/org/apache/camel/component/salesforce/internal/streaming/SubscriptionHelperManualIT.java
+++
b/components/camel-salesforce/camel-salesforce-component/src/test/java/org/apache/camel/component/salesforce/internal/streaming/SubscriptionHelperManualIT.java
@@ -271,6 +271,86 @@ public class SubscriptionHelperManualIT {
verifyNoMoreInteractions(consumer);
}
+ @Test
+ void shouldResubscribeOnDisconnectMessage() {
+ var consumer = createConsumer("Opportunity");
+ subscription.subscribe(consumer);
+ var resubscribeAttempts = new AtomicInteger();
+
+ messages.add("""
+ [
+ {
+ "data": {
+ "event": {
+ "createdDate": "2020-12-11T13:44:56.891Z",
+ "replayId": 1,
+ "type": "created"
+ },
+ "sobject": {
+ "Id": "0061n00002XWMgVAAX",
+ "Name": "shouldResubscribeOnDisconnectMessage 1"
+ }
+ },
+ "channel": "/topic/Opportunity"
+ },
+ {
+ "clientId": "5ra4927ikfky6cb12juthkpofeu8",
+ "channel": "/meta/connect",
+ "id": "$id",
+ "successful": true
+ }
+ ]""");
+ verify(consumer,
timeout(10000)).processMessage(any(ClientSessionChannel.class),
+ messageWithName("shouldResubscribeOnDisconnectMessage 1"));
+
+ subscription.client.getChannel("/meta/subscribe").addListener(
+ (MessageListener) (clientSessionChannel, message) -> {
+ var channel = (String) message.get("subscription");
+ if (channel != null && channel.contains("Opportunity")) {
+ if (resubscribeAttempts.incrementAndGet() == 1) {
+ messages.add("""
+ [
+ {
+ "data": {
+ "event": {
+ "createdDate":
"2020-12-11T13:44:57.891Z",
+ "replayId": 2,
+ "type": "created"
+ },
+ "sobject": {
+ "Id": "0061n00002XWMgVAAX",
+ "Name":
"shouldResubscribeOnDisconnectMessage 2"
+ }
+ },
+ "channel": "/topic/Opportunity"
+ },
+ {
+ "clientId":
"5ra4927ikfky6cb12juthkpofeu8",
+ "channel": "/meta/connect",
+ "id": "$id",
+ "successful": true
+ }
+ ]""");
+ }
+ }
+ });
+ messages.add("""
+ [
+ {
+ "channel": "/meta/disconnect",
+ "clientId": "5ra4927ikfky6cb12juthkpofeu8"
+ }
+ ]""");
+
+ verify(consumer,
timeout(20000)).processMessage(any(ClientSessionChannel.class),
+ messageWithName("shouldResubscribeOnDisconnectMessage 2"));
+ await().during(1, SECONDS).atMost(2, SECONDS).until(() ->
resubscribeAttempts.get() == 1);
+
+ verify(consumer, atLeastOnce()).getEndpoint();
+ verify(consumer, atLeastOnce()).getTopicName();
+ verifyNoMoreInteractions(consumer);
+ }
+
@Test
void shouldResubscribeOnSubscriptionFailure() {
var consumer = createConsumer("Contact");