org.eclipse.ditto.connectivity.service.messaging.rabbitmq.RabbitMQPublisherActor Maven / Gradle / Ivy
/*
* Copyright (c) 2017 Contributors to the Eclipse Foundation
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.ditto.connectivity.service.messaging.rabbitmq;
import static org.eclipse.ditto.connectivity.service.messaging.validation.ConnectionValidator.resolveConnectionIdPlaceholder;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Queue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.LongConsumer;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
import org.eclipse.ditto.base.model.auth.AuthorizationContext;
import org.eclipse.ditto.base.model.common.CharsetDeterminer;
import org.eclipse.ditto.base.model.common.HttpStatus;
import org.eclipse.ditto.base.model.entity.id.EntityId;
import org.eclipse.ditto.base.model.entity.id.WithEntityId;
import org.eclipse.ditto.base.model.headers.DittoHeaderDefinition;
import org.eclipse.ditto.base.model.signals.Signal;
import org.eclipse.ditto.base.model.signals.acks.Acknowledgement;
import org.eclipse.ditto.connectivity.api.ExternalMessage;
import org.eclipse.ditto.connectivity.model.Connection;
import org.eclipse.ditto.connectivity.model.ConnectivityModelFactory;
import org.eclipse.ditto.connectivity.model.ConnectivityStatus;
import org.eclipse.ditto.connectivity.model.GenericTarget;
import org.eclipse.ditto.connectivity.model.MessageSendingFailedException;
import org.eclipse.ditto.connectivity.model.ResourceStatus;
import org.eclipse.ditto.connectivity.model.Target;
import org.eclipse.ditto.connectivity.service.config.ConnectivityConfig;
import org.eclipse.ditto.connectivity.service.messaging.BasePublisherActor;
import org.eclipse.ditto.connectivity.service.messaging.ConnectivityStatusResolver;
import org.eclipse.ditto.connectivity.service.messaging.SendResult;
import org.eclipse.ditto.internal.utils.config.InstanceIdentifierSupplier;
import org.eclipse.ditto.json.JsonValue;
import org.eclipse.ditto.placeholders.ExpressionResolver;
import com.newmotion.akka.rabbitmq.ChannelCreated;
import com.newmotion.akka.rabbitmq.ChannelMessage;
import com.rabbitmq.client.AMQP;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.ConfirmListener;
import com.rabbitmq.client.ReturnListener;
import akka.actor.ActorRef;
import akka.actor.Props;
import akka.japi.pf.ReceiveBuilder;
/**
* Responsible for publishing {@link ExternalMessage}s into RabbitMQ / AMQP 0.9.1.
*
* To receive responses the {@code replyTo} header must be set. Responses are sent to the default exchange with the
* {@code replyTo} header as routing key.
*
* The {@code address} of the {@code targets} from the {@link Connection} are interpreted as follows:
*
* - no {@code targets} defined: signals are not published at all
* - {@code address="target/routingKey"}: signals are published to exchange {@code target} with routing key {@code
* routingKey}
*
*/
public final class RabbitMQPublisherActor extends BasePublisherActor {
/**
* The name of this Actor in the ActorSystem.
*/
static final String ACTOR_NAME = "rmqPublisherActor";
/**
* Lifetime of an entry in the cache 'outstandingAcks'.
*/
private final Duration pendingAckTTL;
private final ConcurrentSkipListMap outstandingAcks = new ConcurrentSkipListMap<>();
private final ConcurrentHashMap> outstandingAcksByTarget =
new ConcurrentHashMap<>();
private ConfirmMode confirmMode = ConfirmMode.UNKNOWN;
@Nullable private ActorRef channelActor;
@SuppressWarnings("unused")
private RabbitMQPublisherActor(final Connection connection,
final ConnectivityStatusResolver connectivityStatusResolver,
final ConnectivityConfig connectivityConfig) {
super(connection, connectivityStatusResolver, connectivityConfig);
pendingAckTTL = connectivityConfig.getConnectionConfig()
.getAmqp091Config()
.getPublisherPendingAckTTL();
}
/**
* Creates Akka configuration object {@link Props} for this {@code RabbitMQPublisherActor}.
*
* @param connection the connection this publisher belongs to
* @param connectivityStatusResolver connectivity status resolver to resolve occurred exceptions to a connectivity
* status.
* @param connectivityConfig the config of the connectivity service with potential overwrites.
* @return the Akka configuration Props object.
*/
static Props props(final Connection connection,
final ConnectivityStatusResolver connectivityStatusResolver,
final ConnectivityConfig connectivityConfig) {
return Props.create(RabbitMQPublisherActor.class,
connection,
connectivityStatusResolver,
connectivityConfig);
}
@Override
protected void preEnhancement(final ReceiveBuilder receiveBuilder) {
receiveBuilder
.match(ChannelCreated.class, channelCreated -> {
channelActor = channelCreated.channel();
final ChannelMessage channelMessage = ChannelMessage.apply(this::onChannelCreated, false);
channelCreated.channel().tell(channelMessage, getSelf());
})
.match(ChannelStatus.class, this::handleChannelStatus)
.build();
}
@Override
protected void postEnhancement(final ReceiveBuilder receiveBuilder) {
// noop
}
@Override
protected RabbitMQTarget toPublishTarget(final GenericTarget target) {
return RabbitMQTarget.fromTargetAddress(target.getAddress());
}
@Override
protected CompletionStage publishMessage(final Signal signal,
@Nullable final Target autoAckTarget,
final RabbitMQTarget publishTarget,
final ExternalMessage message,
final int maxTotalMessageSize,
final int ackSizeQuota,
@Nullable final AuthorizationContext targetAuthorizationContext) {
if (channelActor == null) {
return sendFailedFuture(signal, "No channel available, dropping response.");
}
if (publishTarget.getRoutingKey() == null) {
return sendFailedFuture(signal, "No routing key, dropping message.");
}
final Map messageHeaders = message.getHeaders();
final String contentType = messageHeaders.get(ExternalMessage.CONTENT_TYPE_HEADER);
final String correlationId = messageHeaders.get(DittoHeaderDefinition.CORRELATION_ID.getKey());
final Map stringObjectMap = messageHeaders.entrySet()
.stream()
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
final AMQP.BasicProperties basicProperties = new AMQP.BasicProperties.Builder()
.contentType(contentType)
.correlationId(correlationId)
.headers(stringObjectMap)
.build();
final byte[] body;
if (message.isTextMessage()) {
body = message.getTextPayload()
.map(text -> text.getBytes(CharsetDeterminer.getInstance().apply(contentType)))
.orElseThrow(() -> new IllegalArgumentException("Failed to convert text to bytes."));
} else {
body = message.getBytePayload()
.map(ByteBuffer::array)
.orElse(new byte[]{});
}
final CompletableFuture resultFuture = new CompletableFuture<>();
// create consumer outside channel message: need to check actor state and decide whether to handle acks.
final LongConsumer nextPublishSeqNoConsumer =
computeNextPublishSeqNoConsumer(signal, autoAckTarget, publishTarget, resultFuture);
final ChannelMessage channelMessage = ChannelMessage.apply(channel -> {
try {
logger.withCorrelationId(message.getInternalHeaders())
.debug("Publishing to exchange <{}> and routing key <{}>: {}", publishTarget.getExchange(),
publishTarget.getRoutingKey(), basicProperties);
nextPublishSeqNoConsumer.accept(channel.getNextPublishSeqNo());
channel.basicPublish(publishTarget.getExchange(), publishTarget.getRoutingKey(), true, basicProperties,
body);
} catch (final Exception e) {
final String errorMessage = String.format("Failed to publish message to RabbitMQ: %s", e.getMessage());
resultFuture.completeExceptionally(sendFailed(signal, errorMessage, e));
}
return null;
}, false);
channelActor.tell(channelMessage, getSelf());
return resultFuture;
}
// This method is NOT thread-safe, but its returned consumer MUST be thread-safe.
private LongConsumer computeNextPublishSeqNoConsumer(final Signal signal,
@Nullable final Target autoAckTarget,
final RabbitMQTarget publishTarget,
final CompletableFuture resultFuture) {
if (confirmMode == ConfirmMode.ACTIVE) {
return seqNo -> addOutstandingAck(seqNo, signal, resultFuture, autoAckTarget, publishTarget, pendingAckTTL);
} else {
final SendResult unsupportedAck = buildUnsupportedResponse(signal, autoAckTarget, connectionIdResolver);
return seqNo -> resultFuture.complete(unsupportedAck);
}
}
// Thread-safe
private void addOutstandingAck(final Long seqNo,
final Signal signal,
final CompletableFuture resultFuture,
@Nullable final Target autoAckTarget,
final RabbitMQTarget publishTarget,
final Duration timeoutDuration) {
final OutstandingResponse outstandingAck = new OutstandingResponse(signal, autoAckTarget, resultFuture,
connectionIdResolver);
final SendResult timeoutResponse = buildResponseWithTimeout(signal, autoAckTarget, connectionIdResolver);
// index the outstanding ack by delivery tag
outstandingAcks.put(seqNo, outstandingAck);
resultFuture.completeOnTimeout(timeoutResponse,
timeoutDuration.toMillis(), TimeUnit.MILLISECONDS)
// Only remove future from cache. Actual logging/reporting done elsewhere.
.whenComplete((ack, error) -> outstandingAcks.remove(seqNo));
// index the outstanding ack by publish target in order to generate negative acks on basic.return messages
outstandingAcksByTarget.compute(publishTarget, (key, queue) -> {
final Queue result = queue != null ? queue : new ConcurrentLinkedQueue<>();
result.offer(outstandingAck);
return result;
});
// maintain outstanding-acks-by-target. It need not be accurate because outstandingAcksByTarget is only used
// on basic.return, which affects all messages published to 1 target but is not precise.
resultFuture.whenComplete(
(ignoredAck, ignoredError) -> outstandingAcksByTarget.computeIfPresent(publishTarget, (key, queue) -> {
queue.poll();
return queue.isEmpty() ? null : queue;
}));
}
private void handleChannelStatus(final ChannelStatus channelStatus) {
if (channelStatus.confirmationException != null) {
logger.error(channelStatus.confirmationException, "Failed to enter confirm mode.");
confirmMode = ConfirmMode.INACTIVE;
} else {
confirmMode = ConfirmMode.ACTIVE;
}
resourceStatusMap.putAll(channelStatus.targetStatus);
}
// called by ChannelActor; must be thread-safe.
private Void onChannelCreated(final Channel channel) {
final IOException confirmationStatus =
tryToEnterConfirmationMode(channel, outstandingAcks, outstandingAcksByTarget).orElse(null);
final Map targetStatus =
declareExchangesPassive(channel, RabbitMQTarget::fromTargetAddress);
getSelf().tell(new ChannelStatus(confirmationStatus, targetStatus), ActorRef.noSender());
return null;
}
private static SendResult buildResponseWithTimeout(final Signal signal, @Nullable final Target autoAckTarget,
final ExpressionResolver connectionIdResolver) {
return buildResponse(signal, autoAckTarget, HttpStatus.REQUEST_TIMEOUT,
"No publisher confirm arrived.", connectionIdResolver);
}
private static SendResult buildUnsupportedResponse(final Signal signal, @Nullable final Target autoAckTarget,
final ExpressionResolver connectionIdResolver) {
if (autoAckTarget != null && autoAckTarget.getIssuedAcknowledgementLabel().isPresent()) {
// Not possible to recover without broker upgrade. Use status 400 to prevent redelivery at the source.
return buildResponse(signal, autoAckTarget, HttpStatus.BAD_REQUEST,
"The external broker does not support RabbitMQ publisher confirms. " +
"Acknowledgement is not possible.", connectionIdResolver);
} else {
return buildSuccessResponse(signal, autoAckTarget, connectionIdResolver);
}
}
private static SendResult buildSuccessResponse(final Signal signal, @Nullable final Target autoAckTarget,
final ExpressionResolver connectionIdResolver) {
return buildResponse(signal, autoAckTarget, HttpStatus.OK, null, connectionIdResolver);
}
private static SendResult buildResponse(final Signal signal,
@Nullable final Target autoAckTarget,
final HttpStatus httpStatus,
@Nullable final String message,
final ExpressionResolver connectionIdResolver) {
final var autoAckLabel = Optional.ofNullable(autoAckTarget)
.flatMap(Target::getIssuedAcknowledgementLabel)
.flatMap(ackLabel -> resolveConnectionIdPlaceholder(connectionIdResolver, ackLabel));
final Optional entityIdOptional =
WithEntityId.getEntityIdOfType(EntityId.class, signal);
final Acknowledgement issuedAck;
if (autoAckLabel.isPresent() && entityIdOptional.isPresent()) {
issuedAck = Acknowledgement.of(autoAckLabel.get(),
entityIdOptional.get(),
httpStatus,
signal.getDittoHeaders(),
message == null ? null : JsonValue.of(message));
} else {
issuedAck = null;
}
return new SendResult(issuedAck, signal.getDittoHeaders());
}
private Map declareExchangesPassive(final Channel channel,
final Function toPublishTarget) {
final List targets = connection.getTargets();
final Map exchanges = targets.stream()
.collect(Collectors.toMap(
t -> toPublishTarget.apply(t.getAddress()).getExchange(),
Function.identity()));
final Map declarationStatus = new HashMap<>();
exchanges.forEach((exchange, target) -> {
logger.debug("Checking for existence of exchange <{}>.", exchange);
try {
channel.exchangeDeclarePassive(exchange);
} catch (final IOException e) {
logger.warning("Failed to declare exchange <{}> passively.", exchange);
if (target != null) {
declarationStatus.put(target,
ConnectivityModelFactory.newTargetStatus(InstanceIdentifierSupplier.getInstance().get(),
ConnectivityStatus.MISCONFIGURED,
target.getAddress(),
"Exchange '" + exchange + "' was missing at " + Instant.now()));
}
}
});
return Collections.unmodifiableMap(declarationStatus);
}
private static Optional tryToEnterConfirmationMode(final Channel channel,
final ConcurrentSkipListMap outstandingAcks,
final ConcurrentHashMap> outstandingAcksByTarget) {
try {
enterConfirmationMode(channel, outstandingAcks, outstandingAcksByTarget);
return Optional.empty();
} catch (final IOException e) {
return Optional.of(e);
}
}
private static void enterConfirmationMode(final Channel channel,
final ConcurrentSkipListMap outstandingAcks,
final ConcurrentHashMap> outstandingAcksByTarget)
throws IOException {
channel.confirmSelect();
channel.clearConfirmListeners();
channel.clearReturnListeners();
final ActorConfirmListener confirmListener =
new ActorConfirmListener(outstandingAcks, outstandingAcksByTarget);
channel.addConfirmListener(confirmListener);
channel.addReturnListener(confirmListener);
}
private static CompletionStage sendFailedFuture(final Signal signal, final String errorMessage) {
return CompletableFuture.failedFuture(sendFailed(signal, errorMessage, null));
}
private static MessageSendingFailedException sendFailed(final Signal signal, final String errorMessage,
@Nullable final Throwable cause) {
return MessageSendingFailedException.newBuilder()
.message(errorMessage)
.dittoHeaders(signal.getDittoHeaders())
.cause(cause)
.build();
}
private static final class ChannelStatus {
@Nullable private final IOException confirmationException;
private final Map targetStatus;
private ChannelStatus(@Nullable final IOException confirmationException,
final Map targetStatus) {
this.confirmationException = confirmationException;
this.targetStatus = targetStatus;
}
}
private static final class ActorConfirmListener implements ConfirmListener, ReturnListener {
private final ConcurrentSkipListMap outstandingAcks;
private final ConcurrentHashMap> outstandingAcksByTarget;
private ActorConfirmListener(final ConcurrentSkipListMap outstandingAcks,
final ConcurrentHashMap> outstandingAcksByTarget) {
this.outstandingAcks = outstandingAcks;
this.outstandingAcksByTarget = outstandingAcksByTarget;
}
/**
* Handle ACK from the broker. ACK messages are always sent after a RETURN message.
* The channel actor runs this listener in its thread, which guarantees that this.handleAck is always
* called after this.handleReturn returns.
*
* @param deliveryTag The delivery tag of the acknowledged message.
* @param multiple Whether this acknowledgement applies to multiple messages.
*/
@Override
public void handleAck(final long deliveryTag, final boolean multiple) {
forEach(deliveryTag, multiple, OutstandingResponse::completeWithSuccess);
}
/**
* Handle NACK from the broker. NACK messages are always handled after this.handleReturn returns.
*
* @param deliveryTag The delivery tag of the acknowledged message.
* @param multiple Whether this acknowledgement applies to multiple messages.
*/
@Override
public void handleNack(final long deliveryTag, final boolean multiple) {
forEach(deliveryTag, multiple, OutstandingResponse::completeWithFailure);
}
/**
* Handle RETURN from the broker. RETURN messages are sent if a routing key does not exist for an existing
* exchange. Since users can set publish target for each message by the means of placeholders, and because
* queues can be created and deleted at any time, getting RETURN messages does not imply failure of the whole
* channel.
*
* Here all outstanding acks of an exchange, routing-key pair are completed exceptionally with the reply text
* and reply code.
*
* @param replyCode reply code of this RETURN message.
* @param replyText textual description of this RETURN message.
* @param exchange exchange of the outgoing message being returned.
* @param routingKey routing key of the outgoing message being returned.
* @param properties AMQP properties of the RETURN message; typically empty regardless of the properties of the
* outgoing message being returned.
* @param body body of the returned message.
*/
@Override
public void handleReturn(final int replyCode, final String replyText, final String exchange,
final String routingKey,
final AMQP.BasicProperties properties, final byte[] body) {
final RabbitMQTarget rabbitMQTarget = RabbitMQTarget.of(exchange, routingKey);
final Queue queue = outstandingAcksByTarget.get(rabbitMQTarget);
// cleanup handled in another thread on completion of each outstanding ack
if (queue != null) {
queue.forEach(outstandingAck -> outstandingAck.completeForReturn(replyCode, replyText));
}
}
private void forEach(final long deliveryTag, final boolean multiple,
final Consumer outstandingAckConsumer) {
if (multiple) {
// iterator is in key order because each ConcurrentSkipListMap is a SortedMap
final Iterator> it = outstandingAcks.entrySet().iterator();
while (it.hasNext()) {
final Map.Entry entry = it.next();
if (entry.getKey() > deliveryTag) {
break;
}
outstandingAckConsumer.accept(entry.getValue());
it.remove();
}
} else {
final OutstandingResponse outstandingAck = outstandingAcks.get(deliveryTag);
if (outstandingAck != null) {
outstandingAckConsumer.accept(outstandingAck);
outstandingAcks.remove(deliveryTag);
}
}
}
}
private static final class OutstandingResponse {
private final Signal signal;
@Nullable private final Target autoAckTarget;
private final CompletableFuture future;
private final ExpressionResolver connectionIdResolver;
private OutstandingResponse(final Signal signal, @Nullable final Target autoAckTarget,
final CompletableFuture future, final ExpressionResolver connectionIdResolver) {
this.signal = signal;
this.autoAckTarget = autoAckTarget;
this.future = future;
this.connectionIdResolver = connectionIdResolver;
}
private void completeWithSuccess() {
future.complete(buildSuccessResponse(signal, autoAckTarget, connectionIdResolver));
}
private void completeWithFailure() {
future.complete(buildFailureResponse(signal, autoAckTarget));
}
private SendResult buildFailureResponse(final Signal signal, @Nullable final Target target) {
return buildResponse(signal, target, HttpStatus.SERVICE_UNAVAILABLE,
"Received negative confirm from the external broker.", connectionIdResolver);
}
private void completeForReturn(final int replyCode, final String replyText) {
future.complete(buildReturnResponse(signal, autoAckTarget, replyCode, replyText));
}
private SendResult buildReturnResponse(final Signal signal,
@Nullable final Target autoAckTarget,
final int replyCode,
final String replyText) {
return buildResponse(signal, autoAckTarget, HttpStatus.SERVICE_UNAVAILABLE,
String.format("Received basic.return from the external broker: %d %s", replyCode, replyText),
connectionIdResolver);
}
}
private enum ConfirmMode {
UNKNOWN,
ACTIVE,
INACTIVE
}
}