/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.camel.component.salesforce.internal.streaming;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Stream;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.SECONDS;
import org.apache.camel.CamelException;
import org.apache.camel.component.salesforce.SalesforceComponent;
import org.apache.camel.component.salesforce.SalesforceConsumer;
import org.apache.camel.component.salesforce.SalesforceEndpoint;
import org.apache.camel.component.salesforce.SalesforceEndpointConfig;
import org.apache.camel.component.salesforce.SalesforceHttpClient;
import org.apache.camel.component.salesforce.api.SalesforceException;
import org.apache.camel.component.salesforce.internal.SalesforceSession;
import org.apache.camel.support.ServiceSupport;
import org.cometd.bayeux.Message;
import org.cometd.bayeux.client.ClientSessionChannel;
import org.cometd.client.BayeuxClient;
import org.cometd.client.transport.ClientTransport;
import org.cometd.client.transport.LongPollingTransport;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.http.HttpHeader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.Channel.META_UNSUBSCRIBE;
import static org.cometd.bayeux.Message.ERROR_FIELD;
import static org.cometd.bayeux.Message.SUBSCRIPTION_FIELD;
public class SubscriptionHelper extends ServiceSupport {
static final CometDReplayExtension REPLAY_EXTENSION = new CometDReplayExtension();
private static final Logger LOG = LoggerFactory.getLogger(SubscriptionHelper.class);
private static final int CONNECT_TIMEOUT = 110;
private static final int CHANNEL_TIMEOUT = 40;
private static final String FAILURE_FIELD = "failure";
private static final String EXCEPTION_FIELD = "exception";
private static final int DISCONNECT_INTERVAL = 5000;
BayeuxClient client;
private final SalesforceComponent component;
private final SalesforceSession session;
private final long timeout = 60 * 1000L;
private final Map<SalesforceConsumer, ClientSessionChannel.MessageListener> listenerMap;
private final long maxBackoff;
private final long backoffIncrement;
private ClientSessionChannel.MessageListener handshakeListener;
private ClientSessionChannel.MessageListener connectListener;
private ClientSessionChannel.MessageListener disconnectListener;
private volatile String handshakeError;
private volatile Exception handshakeException;
private volatile String connectError;
private volatile Exception connectException;
private volatile boolean reconnecting;
private final AtomicLong restartBackoff;
public SubscriptionHelper(final SalesforceComponent component) throws SalesforceException {
this.component = component;
this.session = component.getSession();
this.listenerMap = new ConcurrentHashMap<SalesforceConsumer, ClientSessionChannel.MessageListener>();
restartBackoff = new AtomicLong(0);
backoffIncrement = component.getConfig().getBackoffIncrement();
maxBackoff = component.getConfig().getMaxBackoff();
}
@Override
protected void doStart() throws Exception {
// create CometD client
this.client = createClient(component);
// reset all error conditions
handshakeError = null;
handshakeException = null;
connectError = null;
connectException = null;
// listener for handshake error or exception
if (handshakeListener == null) {
// first start
handshakeListener = new ClientSessionChannel.MessageListener() {
public void onMessage(ClientSessionChannel channel, Message message) {
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) {
// refresh oauth token, if it's a 401 error
if (handshakeError.startsWith("401::")) {
try {
LOG.info("Refreshing OAuth token...");
session.login(session.getAccessToken());
LOG.info("Refreshed OAuth token for re-handshake");
} catch (SalesforceException e) {
LOG.error("Error renewing OAuth token on 401 error: " + e.getMessage(), e);
}
}
}
// restart if handshake fails for any reason
restartClient();
} else if (!listenerMap.isEmpty()) {
reconnecting = true;
}
}
};
}
client.getChannel(META_HANDSHAKE).addListener(handshakeListener);
// listener for connect error
if (connectListener == null) {
connectListener = new ClientSessionChannel.MessageListener() {
public void onMessage(ClientSessionChannel channel, Message message) {
LOG.debug("[CHANNEL:META_CONNECT]: {}", message);
if (!message.isSuccessful()) {
LOG.warn("Connect failure: {}", message);
connectError = (String) message.get(ERROR_FIELD);
connectException = getFailure(message);
} else if (reconnecting) {
reconnecting = false;
LOG.debug("Refreshing subscriptions to {} channels on reconnect", listenerMap.size());
// reconnected to Salesforce, subscribe to existing channels
final Map<SalesforceConsumer, ClientSessionChannel.MessageListener> map =
new HashMap<SalesforceConsumer, ClientSessionChannel.MessageListener>();
map.putAll(listenerMap);
listenerMap.clear();
for (Map.Entry<SalesforceConsumer, ClientSessionChannel.MessageListener> entry : map.entrySet()) {
final SalesforceConsumer consumer = entry.getKey();
final String topicName = consumer.getTopicName();
subscribe(topicName, consumer);
}
}
}
};
}
client.getChannel(META_CONNECT).addListener(connectListener);
// handle fatal disconnects by reconnecting asynchronously
if (disconnectListener == null) {
disconnectListener = new ClientSessionChannel.MessageListener() {
@Override
public void onMessage(ClientSessionChannel clientSessionChannel, Message message) {
restartClient();
}
};
}
client.getChannel(META_DISCONNECT).addListener(disconnectListener);
// connect to Salesforce cometd endpoint
client.handshake();
final long waitMs = MILLISECONDS.convert(CONNECT_TIMEOUT, SECONDS);
if (!client.waitFor(waitMs, BayeuxClient.State.CONNECTED)) {
if (handshakeException != null) {
throw new CamelException(
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);
} 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", CONNECT_TIMEOUT));
}
}
}
// launch an async task to restart
private void restartClient() {
// launch a new restart command
final SalesforceHttpClient httpClient = component.getConfig().getHttpClient();
httpClient.getExecutor().execute(new Runnable() {
@Override
public void run() {
LOG.info("Restarting on unexpected disconnect from Salesforce...");
boolean abort = false;
// wait for disconnect
LOG.debug("Waiting to disconnect...");
while (!client.isDisconnected()) {
try {
Thread.sleep(DISCONNECT_INTERVAL);
} catch (InterruptedException e) {
LOG.error("Aborting restart on interrupt!");
abort = true;
}
}
if (!abort) {
// update restart attempt backoff
final long backoff = restartBackoff.getAndAdd(backoffIncrement);
if (backoff > maxBackoff) {
LOG.error("Restart aborted after exceeding {} msecs backoff", maxBackoff);
abort = true;
} else {
// pause before restart attempt
LOG.debug("Pausing for {} msecs before restart attempt", backoff);
try {
Thread.sleep(backoff);
} catch (InterruptedException e) {
LOG.error("Aborting restart on interrupt!");
abort = true;
}
}
if (!abort) {
Exception lastError = new SalesforceException("Unknown error", null);
try {
// reset client
doStop();
// register listeners and restart
doStart();
} catch (Exception e) {
LOG.error("Error restarting: " + e.getMessage(), e);
lastError = e;
}
if (client.isHandshook()) {
LOG.info("Successfully restarted!");
// reset backoff interval
restartBackoff.set(client.getBackoffIncrement());
} else {
LOG.error("Failed to restart after pausing for {} msecs", backoff);
if ((backoff + backoffIncrement) > maxBackoff) {
// notify all consumers
String abortMsg = "Aborting restart attempt due to: " + lastError.getMessage();
SalesforceException ex = new SalesforceException(abortMsg, lastError);
for (SalesforceConsumer consumer : listenerMap.keySet()) {
consumer.handleException(abortMsg, ex);
}
}
}
}
}
}
});
}
@SuppressWarnings("unchecked")
private Exception getFailure(Message message) {
Exception exception = null;
if (message.get(EXCEPTION_FIELD) != null) {
exception = (Exception) message.get(EXCEPTION_FIELD);
} else if (message.get(FAILURE_FIELD) != null) {
exception = (Exception) ((Map<String, Object>)message.get("failure")).get("exception");
}
return exception;
}
@Override
protected void doStop() throws Exception {
client.getChannel(META_DISCONNECT).removeListener(disconnectListener);
client.getChannel(META_CONNECT).removeListener(connectListener);
client.getChannel(META_HANDSHAKE).removeListener(handshakeListener);
boolean disconnected = client.disconnect(timeout);
if (!disconnected) {
LOG.warn("Could not disconnect client connected to: {} after: {} msec.", getEndpointUrl(component), timeout);
}
client = null;
}
static BayeuxClient createClient(final SalesforceComponent component) throws SalesforceException {
// use default Jetty client from SalesforceComponent, its shared by all consumers
final SalesforceHttpClient httpClient = component.getConfig().getHttpClient();
Map<String, Object> options = new HashMap<String, Object>();
options.put(ClientTransport.MAX_NETWORK_DELAY_OPTION, httpClient.getTimeout());
final SalesforceSession session = component.getSession();
// check login access token
if (session.getAccessToken() == null) {
// lazy login here!
session.login(null);
}
LongPollingTransport transport = new LongPollingTransport(options, httpClient) {
@Override
protected void customize(Request request) {
super.customize(request);
// add current security token obtained from session
// replace old token
request.getHeaders().put(HttpHeader.AUTHORIZATION, "OAuth " + session.getAccessToken());
}
};
BayeuxClient client = new BayeuxClient(getEndpointUrl(component), transport);
// added eagerly to check for support during handshake
client.addExtension(REPLAY_EXTENSION);
return client;
}
public void subscribe(final String topicName, final SalesforceConsumer consumer) {
// create subscription for consumer
final String channelName = getChannelName(topicName);
setupReplay((SalesforceEndpoint) consumer.getEndpoint());
// channel message listener
LOG.info("Subscribing to channel {}...", channelName);
final ClientSessionChannel.MessageListener listener = new ClientSessionChannel.MessageListener() {
@Override
public void onMessage(ClientSessionChannel channel, Message message) {
LOG.debug("Received Message: {}", message);
// convert CometD message to Camel Message
consumer.processMessage(channel, message);
}
};
final ClientSessionChannel clientChannel = client.getChannel(channelName);
// listener for subscription
final ClientSessionChannel.MessageListener subscriptionListener = new ClientSessionChannel.MessageListener() {
public void onMessage(ClientSessionChannel channel, Message message) {
LOG.debug("[CHANNEL:META_SUBSCRIBE]: {}", message);
final String subscribedChannelName = message.get(SUBSCRIPTION_FIELD).toString();
if (channelName.equals(subscribedChannelName)) {
if (!message.isSuccessful()) {
String error = (String) message.get(ERROR_FIELD);
if (error == null) {
error = "Missing error message";
}
Exception failure = getFailure(message);
String msg = String.format("Error subscribing to %s: %s", topicName,
failure != null ? failure.getMessage() : error);
consumer.handleException(msg, new SalesforceException(msg, failure));
} else {
// remember subscription
LOG.info("Subscribed to channel {}", subscribedChannelName);
listenerMap.put(consumer, listener);
}
// remove this subscription listener
client.getChannel(META_SUBSCRIBE).removeListener(this);
}
}
};
client.getChannel(META_SUBSCRIBE).addListener(subscriptionListener);
// subscribe asynchronously
clientChannel.subscribe(listener);
}
void setupReplay(final SalesforceEndpoint endpoint) {
final String topicName = endpoint.getTopicName();
final Optional<Long> replayId = determineReplayIdFor(endpoint, topicName);
if (replayId.isPresent()) {
final String channelName = getChannelName(topicName);
final Long replayIdValue = replayId.get();
LOG.info("Set Replay extension to replay from `{}` for channel `{}`", replayIdValue, channelName);
REPLAY_EXTENSION.addChannelReplayId(channelName, replayIdValue);
}
}
static Optional<Long> determineReplayIdFor(final SalesforceEndpoint endpoint, final String topicName) {
final String channelName = getChannelName(topicName);
final Long replayId = endpoint.getReplayId();
final SalesforceComponent component = endpoint.getComponent();
final SalesforceEndpointConfig endpointConfiguration = endpoint.getConfiguration();
final Map<String, Long> endpointInitialReplayIdMap = endpointConfiguration.getInitialReplayIdMap();
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 componentDefaultReplayId = componentConfiguration.getDefaultReplayId();
// the endpoint values have priority over component values, and the default values posteriority
// over give topic values
return Stream.of(replayId, endpointReplayId, componentReplayId, endpointDefaultReplayId, componentDefaultReplayId)
.filter(Objects::nonNull).findFirst();
}
static String getChannelName(String topicName) {
return "/topic/" + topicName;
}
public void unsubscribe(String topicName, SalesforceConsumer consumer) throws CamelException {
// channel name
final String channelName = getChannelName(topicName);
// listen for unsubscribe error
final CountDownLatch latch = new CountDownLatch(1);
final String[] unsubscribeError = {null};
final Exception[] unsubscribeFailure = {null};
final ClientSessionChannel.MessageListener unsubscribeListener = new ClientSessionChannel.MessageListener() {
public void onMessage(ClientSessionChannel channel, Message message) {
LOG.debug("[CHANNEL:META_UNSUBSCRIBE]: {}", message);
Object subscription = message.get(SUBSCRIPTION_FIELD);
if (subscription != null) {
String unsubscribedChannelName = subscription.toString();
if (channelName.equals(unsubscribedChannelName)) {
if (!message.isSuccessful()) {
unsubscribeError[0] = (String) message.get(ERROR_FIELD);
unsubscribeFailure[0] = getFailure(message);
} else {
// forget subscription
LOG.info("Unsubscribed from channel {}", unsubscribedChannelName);
}
latch.countDown();
}
}
}
};
client.getChannel(META_UNSUBSCRIBE).addListener(unsubscribeListener);
try {
// unsubscribe from channel
final ClientSessionChannel.MessageListener listener = listenerMap.remove(consumer);
if (listener != null) {
LOG.info("Unsubscribing from channel {}...", channelName);
final ClientSessionChannel clientChannel = client.getChannel(channelName);
clientChannel.unsubscribe(listener);
// confirm unsubscribe
try {
if (!latch.await(CHANNEL_TIMEOUT, SECONDS)) {
String message;
if (unsubscribeFailure[0] != null) {
message = String.format("Error unsubscribing from topic %s: %s",
topicName, unsubscribeFailure[0].getMessage());
} else if (unsubscribeError[0] != null) {
message = String.format("Error unsubscribing from topic %s: %s",
topicName, unsubscribeError[0]);
} else {
message = String.format("Timeout error unsubscribing from topic %s after %s seconds",
topicName, CHANNEL_TIMEOUT);
}
throw new CamelException(message, unsubscribeFailure[0]);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
// probably shutting down, forget unsubscribe and return
}
}
} finally {
client.getChannel(META_UNSUBSCRIBE).removeListener(unsubscribeListener);
}
}
static String getEndpointUrl(final SalesforceComponent component) {
// In version 36.0 replay is only enabled on a separate endpoint
if (Double.valueOf(component.getConfig().getApiVersion()) == 36.0) {
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/" + component.getConfig().getApiVersion();
}
}