From 47bceb59dc954f67d72181d09aaad1ce58231202 Mon Sep 17 00:00:00 2001 From: Connie Yau Date: Fri, 21 Jun 2019 13:51:26 -0700 Subject: [PATCH] Track 2: Event Hubs Client Library (#3655) * Adding azure-messaging-eventhubs and azure-core-amqp pom.xml. * Adding EventHubClient, EventHubClientBuilder, EventHubConsumer, EventHubConsumerOptions, EventHubProducer, and EventHubProducerOptions. * Adding configuration options for EventHubClientBuilder. * Adding azure-core-amqp with common classes to AMQP for Exceptions, Sessions, Links, Connections, TransportTypes, etc. * Adding authorization with CBS node and getting Event Hub metadata. * Adding functionality for EventHubConsumer and EventHubProducer. * Adding support for Azure Identity and TokenCredential. * Adding a bare set of tests for Azure Event Hubs client library. --- core/azure-core-amqp/README.md | 42 ++ core/azure-core-amqp/pom.xml | 78 +++ .../com/azure/core/amqp/AmqpConnection.java | 65 +++ .../azure/core/amqp/AmqpEndpointState.java | 22 + .../azure/core/amqp/AmqpExceptionHandler.java | 37 ++ .../java/com/azure/core/amqp/AmqpLink.java | 25 + .../java/com/azure/core/amqp/AmqpSession.java | 58 +++ .../azure/core/amqp/AmqpShutdownSignal.java | 56 +++ .../java/com/azure/core/amqp/CBSNode.java | 27 + .../core/amqp/EndpointStateNotifier.java | 40 ++ .../com/azure/core/amqp/ExponentialRetry.java | 61 +++ .../com/azure/core/amqp/MessageConstant.java | 132 +++++ .../main/java/com/azure/core/amqp/Retry.java | 149 ++++++ .../com/azure/core/amqp/TransportType.java | 52 ++ .../core/amqp/exception/AmqpException.java | 144 ++++++ .../core/amqp/exception/AmqpResponseCode.java | 54 ++ .../core/amqp/exception/ErrorCondition.java | 136 +++++ .../core/amqp/exception/ErrorContext.java | 60 +++ .../core/amqp/exception/ExceptionUtil.java | 112 +++++ .../core/amqp/exception/LinkErrorContext.java | 78 +++ .../OperationCancelledException.java | 33 ++ .../amqp/exception/SessionErrorContext.java | 47 ++ .../core/amqp/exception/package-info.java | 7 + .../com/azure/core/amqp/package-info.java | 7 + .../java/com/azure/core/amqp/RetryTest.java | 124 +++++ .../OperationCancelledExceptionTest.java | 31 ++ .../core/implementation/util/ImplUtils.java | 18 +- core/pom.xml | 1 + eng/jacoco-test-coverage/pom.xml | 10 + eng/spotbugs-aggregate-report/pom.xml | 13 + eventhubs/client/azure-eventhubs/pom.xml | 34 ++ .../azure/messaging/eventhubs/EventData.java | 333 ++++++++++++ .../messaging/eventhubs/EventDataBatch.java | 188 +++++++ .../messaging/eventhubs/EventHubClient.java | 284 +++++++++++ .../eventhubs/EventHubClientBuilder.java | 301 +++++++++++ .../messaging/eventhubs/EventHubConsumer.java | 111 ++++ .../eventhubs/EventHubConsumerOptions.java | 219 ++++++++ .../messaging/eventhubs/EventHubProducer.java | 314 ++++++++++++ .../eventhubs/EventHubProducerOptions.java | 113 +++++ .../eventhubs/EventHubProperties.java | 56 +++ .../EventHubSharedAccessKeyCredential.java | 116 +++++ .../messaging/eventhubs/EventPosition.java | 189 +++++++ .../eventhubs/PartitionProperties.java | 99 ++++ .../eventhubs/ProxyAuthenticationType.java | 22 + .../eventhubs/ProxyConfiguration.java | 128 +++++ .../messaging/eventhubs/SendOptions.java | 64 +++ .../ActiveClientTokenManager.java | 132 +++++ .../implementation/AmqpConstants.java | 22 + .../implementation/AmqpErrorCode.java | 42 ++ .../implementation/AmqpReceiveLink.java | 53 ++ .../implementation/AmqpResponseMapper.java | 30 ++ .../implementation/AmqpSendLink.java | 41 ++ .../implementation/CBSAuthorizationType.java | 38 ++ .../eventhubs/implementation/CBSChannel.java | 92 ++++ .../implementation/ClientConstants.java | 45 ++ .../implementation/ConnectionOptions.java | 87 ++++ .../ConnectionStringProperties.java | 118 +++++ .../EndpointStateNotifierBase.java | 97 ++++ .../implementation/ErrorContextProvider.java | 24 + .../implementation/EventDataUtil.java | 242 +++++++++ .../implementation/EventHubConnection.java | 19 + .../EventHubManagementNode.java | 31 ++ .../implementation/EventHubSession.java | 39 ++ .../implementation/ManagementChannel.java | 162 ++++++ .../implementation/ReactorConnection.java | 228 +++++++++ .../implementation/ReactorDispatcher.java | 175 +++++++ .../implementation/ReactorExecutor.java | 184 +++++++ .../ReactorHandlerProvider.java | 87 ++++ .../implementation/ReactorProvider.java | 79 +++ .../implementation/ReactorReceiver.java | 133 +++++ .../implementation/ReactorSender.java | 473 ++++++++++++++++++ .../implementation/ReactorSession.java | 252 ++++++++++ .../RequestResponseChannel.java | 199 ++++++++ .../implementation/RetriableWorkItem.java | 80 +++ .../eventhubs/implementation/StringUtil.java | 49 ++ .../implementation/TimeoutTracker.java | 47 ++ .../implementation/TokenResourceProvider.java | 38 ++ .../handler/ConnectionHandler.java | 281 +++++++++++ .../handler/CustomIOHandler.java | 38 ++ .../handler/DispatchHandler.java | 38 ++ .../implementation/handler/Handler.java | 57 +++ .../implementation/handler/LinkHandler.java | 128 +++++ .../handler/ReactorHandler.java | 43 ++ .../handler/ReceiveLinkHandler.java | 103 ++++ .../handler/SendLinkHandler.java | 104 ++++ .../handler/SessionHandler.java | 148 ++++++ .../implementation/handler/package-info.java | 7 + .../implementation/package-info.java | 7 + .../messaging/eventhubs/package-info.java | 8 + .../eventhubs/EventHubClientBuilderTest.java | 70 +++ .../eventhubs/EventHubClientTest.java | 332 ++++++++++++ .../EventHubConsumerOptionsTest.java | 102 ++++ .../eventhubs/EventHubProducerTest.java | 182 +++++++ .../eventhubs/EventHubPropertiesTest.java | 58 +++ ...EventHubSharedAccessKeyCredentialTest.java | 90 ++++ .../eventhubs/PartitionPropertiesTest.java | 39 ++ .../eventhubs/ProxyConfigurationTest.java | 112 +++++ .../ActiveClientTokenManagerTest.java | 143 ++++++ .../eventhubs/implementation/ApiTestBase.java | 138 +++++ .../implementation/CBSChannelTest.java | 118 +++++ .../EndpointStateNotifierBaseTest.java | 113 +++++ .../MockReactorHandlerProvider.java | 48 ++ .../implementation/MockReactorProvider.java | 31 ++ .../ReactorConnectionIntegrationTest.java | 71 +++ .../implementation/ReactorConnectionTest.java | 322 ++++++++++++ .../implementation/ReactorReceiverTest.java | 88 ++++ .../implementation/ReactorSessionTest.java | 105 ++++ .../TokenResourceProviderTest.java | 54 ++ .../handler/ConnectionHandlerTest.java | 118 +++++ .../handler/DispatchHandlerTest.java | 35 ++ .../implementation/handler/HandlerTest.java | 69 +++ eventhubs/client/pom.xml | 86 ++++ parent/pom.xml | 15 + pom.client.xml | 9 + 114 files changed, 11129 insertions(+), 9 deletions(-) create mode 100644 core/azure-core-amqp/README.md create mode 100644 core/azure-core-amqp/pom.xml create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpConnection.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpEndpointState.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpExceptionHandler.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpLink.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpSession.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpShutdownSignal.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/CBSNode.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/EndpointStateNotifier.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/ExponentialRetry.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/MessageConstant.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/Retry.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/TransportType.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/AmqpException.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/AmqpResponseCode.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/ErrorCondition.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/ErrorContext.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/ExceptionUtil.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/LinkErrorContext.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/OperationCancelledException.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/SessionErrorContext.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/package-info.java create mode 100644 core/azure-core-amqp/src/main/java/com/azure/core/amqp/package-info.java create mode 100644 core/azure-core-amqp/src/test/java/com/azure/core/amqp/RetryTest.java create mode 100644 core/azure-core-amqp/src/test/java/com/azure/core/amqp/exception/OperationCancelledExceptionTest.java create mode 100644 eventhubs/client/azure-eventhubs/pom.xml create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventData.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventDataBatch.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubClient.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubClientBuilder.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubConsumer.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubConsumerOptions.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubProducer.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubProducerOptions.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubProperties.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubSharedAccessKeyCredential.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventPosition.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/PartitionProperties.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/ProxyAuthenticationType.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/ProxyConfiguration.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/SendOptions.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ActiveClientTokenManager.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpConstants.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpErrorCode.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpReceiveLink.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpResponseMapper.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpSendLink.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/CBSAuthorizationType.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/CBSChannel.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ClientConstants.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ConnectionOptions.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ConnectionStringProperties.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EndpointStateNotifierBase.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ErrorContextProvider.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EventDataUtil.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EventHubConnection.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EventHubManagementNode.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EventHubSession.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ManagementChannel.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorConnection.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorDispatcher.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorExecutor.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorHandlerProvider.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorProvider.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorReceiver.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorSender.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorSession.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/RequestResponseChannel.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/RetriableWorkItem.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/StringUtil.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/TimeoutTracker.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/TokenResourceProvider.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/ConnectionHandler.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/CustomIOHandler.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/DispatchHandler.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/Handler.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/LinkHandler.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/ReactorHandler.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/ReceiveLinkHandler.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/SendLinkHandler.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/SessionHandler.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/package-info.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/package-info.java create mode 100644 eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/package-info.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubClientBuilderTest.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubClientTest.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubConsumerOptionsTest.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubProducerTest.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubPropertiesTest.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubSharedAccessKeyCredentialTest.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/PartitionPropertiesTest.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/ProxyConfigurationTest.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ActiveClientTokenManagerTest.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ApiTestBase.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/CBSChannelTest.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/EndpointStateNotifierBaseTest.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/MockReactorHandlerProvider.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/MockReactorProvider.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ReactorConnectionIntegrationTest.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ReactorConnectionTest.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ReactorReceiverTest.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ReactorSessionTest.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/TokenResourceProviderTest.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/handler/ConnectionHandlerTest.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/handler/DispatchHandlerTest.java create mode 100644 eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/handler/HandlerTest.java create mode 100644 eventhubs/client/pom.xml diff --git a/core/azure-core-amqp/README.md b/core/azure-core-amqp/README.md new file mode 100644 index 000000000000..baf975788a87 --- /dev/null +++ b/core/azure-core-amqp/README.md @@ -0,0 +1,42 @@ +# Azure Core AMQP client library for Java + +Azure Core AMQP client library is a collection of classes common to the AMQP protocol. It help developers create their +own AMQP client library that abstracts from the underlying transport library's implementation. + +## Getting started + +### Prerequisites + +- Java Development Kit (JDK) with version 8 or above + +### Adding the package to your product + +```xml + + com.azure + azure-core-amqp + 1.0.0-SNAPSHOT + +``` + +## Key concepts + +The concepts for AMQP are well documented in [OASIS Advanced Messaging Queuing Protocol (AMQP) Version +1.0](http://docs.oasis-open.org/amqp/core/v1.0/os/amqp-core-overview-v1.0-os.html). + +## Examples + +## Troubleshooting + +## Next steps + +## Contributing + +If you would like to become an active contributor to this project please follow the instructions provided in [Microsoft +Azure Projects Contribution Guidelines](http://azure.github.io/guidelines.html). + +1. Fork it +1. Create your feature branch (`git checkout -b my-new-feature`) +1. Commit your changes (`git commit -am 'Add some feature'`) +1. Push to the branch (`git push origin my-new-feature`) +1. Create new Pull Request diff --git a/core/azure-core-amqp/pom.xml b/core/azure-core-amqp/pom.xml new file mode 100644 index 000000000000..3e94064e0fd7 --- /dev/null +++ b/core/azure-core-amqp/pom.xml @@ -0,0 +1,78 @@ + + + 4.0.0 + + com.azure + azure-core-parent + 1.0.0-SNAPSHOT + ../pom.xml + + + com.azure + azure-core-amqp + 1.0.0-SNAPSHOT + jar + + Microsoft Azure Java Core AMQP Library + This package contains core types for Azure Java AMQP clients. + https://github.com/Azure/azure-sdk-for-java + + + + The MIT License (MIT) + http://opensource.org/licenses/MIT + repo + + + + + + azure-java-build-docs + ${site.url}/site/${project.artifactId} + + + + + scm:git:https://github.com/Azure/azure-sdk-for-java + + + + UTF-8 + + + + + + microsoft + Microsoft + + + + + + com.azure + azure-core + + + + io.projectreactor + reactor-test + test + + + + junit + junit + test + + + org.slf4j + slf4j-simple + test + + + diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpConnection.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpConnection.java new file mode 100644 index 000000000000..0c7a5fc1bd11 --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpConnection.java @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp; + +import reactor.core.publisher.Mono; + +import java.io.Closeable; +import java.util.Map; + +/** + * Represents a TCP connection between the client and a service that uses the AMQP protocol. + */ +public interface AmqpConnection extends EndpointStateNotifier, Closeable { + /** + * Gets the connection identifier. + * + * @return The connection identifier. + */ + String getIdentifier(); + + /** + * Gets the host for the AMQP connection. + * + * @return The host for the AMQP connection. + */ + String getHost(); + + /** + * Gets the maximum frame size for the connection. + * + * @return The maximum frame size for the connection. + */ + int getMaxFrameSize(); + + /** + * Gets the connection properties. + * + * @return Properties associated with this connection. + */ + Map getConnectionProperties(); + + /** + * Gets the claims-based security (CBS) node that authorizes access to resources. + * + * @return Provider that authorizes access to AMQP resources. + */ + Mono getCBSNode(); + + /** + * Creates a new session with the given session name. + * + * @param sessionName Name of the session. + * @return The AMQP session that was created. + */ + Mono createSession(String sessionName); + + /** + * Removes a session with the {@code sessionName} from the AMQP connection. + * + * @param sessionName Name of the session to remove. + * @return {@code true} if a session with the name was removed; {@code false} otherwise. + */ + boolean removeSession(String sessionName); +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpEndpointState.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpEndpointState.java new file mode 100644 index 000000000000..86a078f0f9dc --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpEndpointState.java @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp; + +/** + * Represents a state for a connection, session, or link. + */ +public enum AmqpEndpointState { + /** + * The endpoint has not been initialized. + */ + UNINITIALIZED, + /** + * The endpoint is active. + */ + ACTIVE, + /** + * The endpoint is closed. + */ + CLOSED +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpExceptionHandler.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpExceptionHandler.java new file mode 100644 index 000000000000..876c4b46713b --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpExceptionHandler.java @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp; + +import com.azure.core.util.logging.ClientLogger; + +/** + * Handles exceptions generated by AMQP connections, sessions, and/or links. + */ +public abstract class AmqpExceptionHandler { + private final ClientLogger logger = new ClientLogger(AmqpExceptionHandler.class); + + /** + * Creates a new instance of the exception handler. + */ + protected AmqpExceptionHandler() { + } + + /** + * Notifies the exception handler of an exception. + * + * @param exception The exception that caused the connection error. + */ + public void onConnectionError(Throwable exception) { + logger.asWarning().log("Connection exception encountered: " + exception.toString(), exception); + } + + /** + * Notifies the exception handler that a shutdown signal occurred. + * + * @param shutdownSignal The shutdown signal that was received. + */ + public void onConnectionShutdown(AmqpShutdownSignal shutdownSignal) { + logger.asInfo().log("Shutdown received: {}", shutdownSignal); + } +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpLink.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpLink.java new file mode 100644 index 000000000000..f696cf6f7840 --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpLink.java @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp; + +import java.io.Closeable; + +/** + * Represents a unidirectional AMQP link. + */ +public interface AmqpLink extends EndpointStateNotifier, Closeable { + /** + * Gets the name of the link. + * + * @return The name of the link. + */ + String getLinkName(); + + /** + * The remote endpoint path this link is connected to. + * + * @return The remote endpoint path this link is connected to. + */ + String getEntityPath(); +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpSession.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpSession.java new file mode 100644 index 000000000000..2ee596fd0390 --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpSession.java @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp; + +import reactor.core.publisher.Mono; + +import java.io.Closeable; +import java.time.Duration; + +/** + * An AMQP session representing bidirectional communication that supports multiple {@link AmqpLink AMQP links}. + */ +public interface AmqpSession extends EndpointStateNotifier, Closeable { + /** + * Gets the name for this AMQP session. + * + * @return The name for the AMQP session. + */ + String getSessionName(); + + /** + * Gets the operation timeout for starting the AMQP session. + * + * @return The timeout for starting the AMQP session. + */ + Duration getOperationTimeout(); + + /** + * Creates a new AMQP link that publishes events to the message broker. + * + * @param linkName Name of the link. + * @param entityPath The entity path this link connects to when producing events. + * @param timeout Timeout required for creating and opening AMQP link. + * @param retry The retry policy to use when sending messages. + * @return A newly created AMQP link. + */ + Mono createProducer(String linkName, String entityPath, Duration timeout, Retry retry); + + /** + * Creates a new AMQP link that consumes events from the message broker. + * + * @param linkName Name of the link. + * @param entityPath The entity path this link connects to, so that it may read events from the message broker. + * @param timeout Timeout required for creating and opening an AMQP link. + * @param retry The retry policy to use when consuming messages. + * @return A newly created AMQP link. + */ + Mono createConsumer(String linkName, String entityPath, Duration timeout, Retry retry); + + /** + * Removes an {@link AmqpLink} with the given {@code linkName}. + * + * @param linkName Name of the link to remove. + * @return {@code true} if the link was removed; {@code false} otherwise. + */ + boolean removeLink(String linkName); +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpShutdownSignal.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpShutdownSignal.java new file mode 100644 index 000000000000..59107914d55a --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/AmqpShutdownSignal.java @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp; + +import java.util.Locale; + +/** + * Represents a signal that caused the AMQP connection to shutdown. + */ +public class AmqpShutdownSignal { + private final boolean isTransient; + private final boolean isInitiatedByClient; + private final String message; + + /** + * Creates a new instance of the AmqpShutdownSignal. + * + * @param isTransient Whether the shutdown signal can be retried or not. + * @param isInitiatedByClient {@code true} if the shutdown was initiated by the client; {@code false} otherwise. + * @param message Message associated with the shutdown. + */ + public AmqpShutdownSignal(boolean isTransient, boolean isInitiatedByClient, String message) { + this.isTransient = isTransient; + this.isInitiatedByClient = isInitiatedByClient; + this.message = message; + } + + /** + * Gets whether or not this shutdown signal is transient or if it can be restarted. + * + * @return {@code true} if the shutdown signal is transient and the connection, session, or link can be recreated. + * {@code false} otherwise. + */ + public boolean isTransient() { + return isTransient; + } + + /** + * Gets whether or not this shutdown signal was initiated by the client. + * + * @return {@code true} if the shutdown signal was initiated by the client, {@code false} if the shutdown signal + * occurred in the underlying AMQP layer or from the AMQP message broker. + */ + public boolean isInitiatedByClient() { + return isInitiatedByClient; + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() { + return String.format(Locale.US, "%s, isTransient[%s], initiatedByClient[%s]", message, isTransient, isInitiatedByClient); + } +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/CBSNode.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/CBSNode.java new file mode 100644 index 000000000000..93aa8e746ecc --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/CBSNode.java @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp; + +import reactor.core.publisher.Mono; + +import java.io.Closeable; +import java.time.OffsetDateTime; + +/** + * Claims-based security (CBS) node that authorizes connections with AMQP services. + * + * @see + * AMPQ Claims-based Security v1.0 + */ +public interface CBSNode extends EndpointStateNotifier, Closeable { + /** + * Authorizes the caller with the CBS node to access resources for the {@code audience}. + * + * @param audience Resource that the callee needs access to. + * @return A Mono that completes with the callee's expiration date if it is successful and errors if + * authorization was unsuccessful. Once the expiration date has elapsed, the callee needs to reauthorize with the + * CBS node. + */ + Mono authorize(String audience); +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/EndpointStateNotifier.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/EndpointStateNotifier.java new file mode 100644 index 000000000000..56ebb9b50ee2 --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/EndpointStateNotifier.java @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp; + +import reactor.core.publisher.Flux; + +/** + * Notifies subscribers of the endpoint state and any errors that occur with the object. + */ +public interface EndpointStateNotifier { + + /** + * Gets the current state of the endpoint. + * + * @return The current state of the endpoint. + */ + AmqpEndpointState getCurrentState(); + + /** + * Gets the errors that occurred in the AMQP endpoint. + * + * @return A stream of errors that occurred in the AMQP endpoint. + */ + Flux getErrors(); + + /** + * Gets the endpoint states for the AMQP endpoint. + * + * @return A stream of endpoint states as they occur in the endpoint. + */ + Flux getConnectionStates(); + + /** + * Gets any shutdown signals that occur in the AMQP endpoint. + * + * @return A stream of shutdown signals that occur in the AMQP endpoint. + */ + Flux getShutdownSignals(); +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/ExponentialRetry.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/ExponentialRetry.java new file mode 100644 index 000000000000..3e86faa719c8 --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/ExponentialRetry.java @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp; + +import java.time.Duration; + +/** + * A policy to govern retrying of messaging operations in which the delay between retries + * will grow in an exponential manner, allowing more time to recover as the number of retries increases. + */ +public final class ExponentialRetry extends Retry { + private static final Duration TIMER_TOLERANCE = Duration.ofSeconds(1); + + private final Duration minBackoff; + private final Duration maxBackoff; + private final double retryFactor; + + /** + * Creates a new instance with a minimum and maximum retry period in addition to maximum number of retry attempts. + * + * @param minBackoff The minimum time period permissible for backing off between retries. + * @param maxBackoff The maximum time period permissible for backing off between retries. + * @param maxRetryCount The maximum number of retries allowed. + */ + public ExponentialRetry(Duration minBackoff, Duration maxBackoff, int maxRetryCount) { + super(maxRetryCount); + this.minBackoff = minBackoff; + this.maxBackoff = maxBackoff; + + this.retryFactor = computeRetryFactor(); + } + + /** + * {@inheritDoc} + */ + @Override + protected Duration calculateNextRetryInterval(final Exception lastException, + final Duration remainingTime, + final int baseWaitSeconds, + final int retryCount) { + final double nextRetryInterval = Math.pow(retryFactor, (double) retryCount); + final long nextRetryIntervalSeconds = (long) nextRetryInterval; + final long nextRetryIntervalNano = (long) ((nextRetryInterval - (double) nextRetryIntervalSeconds) * 1000000000); + + if (remainingTime.getSeconds() < Math.max(nextRetryInterval, TIMER_TOLERANCE.getSeconds())) { + return null; + } + + final Duration retryAfter = minBackoff.plus(Duration.ofSeconds(nextRetryIntervalSeconds, nextRetryIntervalNano)); + return retryAfter.plus(Duration.ofSeconds(baseWaitSeconds)); + } + + private double computeRetryFactor() { + final long deltaBackoff = maxBackoff.minus(minBackoff).getSeconds(); + if (deltaBackoff <= 0 || super.getMaxRetryCount() <= 0) { + return 0; + } + return Math.log(deltaBackoff) / Math.log(super.getMaxRetryCount()); + } +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/MessageConstant.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/MessageConstant.java new file mode 100644 index 000000000000..2f769d5cf5ac --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/MessageConstant.java @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Reserved well-known constants from AMQP protocol. + * + * @see + * AMQP 1.0: Messaging Properties + */ +public enum MessageConstant { + /** + * Message-id, if set, uniquely identifies a message within the message system. The message producer is usually + * responsible for setting the message-id in such a way that it is assured to be globally unique. A broker MAY + * discard a message as a duplicate if the value of the message-id matches that of a previously received message + * sent to the same node. + */ + MESSAGE_ID("message-id"), + /** + * The identity of the user responsible for producing the message. The client sets this value, and it MAY be + * authenticated by intermediaries. + */ + USER_ID("user-id"), + /** + * The to field identifies the node that is the intended destination of the message. On any given transfer this + * might not be the node at the receiving end of the link. + */ + TO("to"), + /** + * A common field for summary information about the message content and purpose. + */ + SUBJECT("subject"), + /** + * The address of the node to send replies to. + */ + REPLY_TO("reply-to"), + /** + * This is a client-specific id that can be used to mark or identify messages between clients. + */ + CORRELATION_ID("correlation-id"), + /** + * The RFC-2046 MIME type for the message's application-data section (body). As per RFC-2046 this can contain a + * charset parameter defining the character encoding used: e.g., 'text/plain), charset="utf-8"'. + */ + CONTENT_TYPE("content-type"), + /** + * The content-encoding property is used as a modifier to the content-type. When present, its value indicates what + * additional content encodings have been applied to the application-data, and thus what decoding mechanisms need to + * be applied in order to obtain the media-type referenced by the content-type header field. + */ + CONTENT_ENCODING("content-encoding"), + /** + * An absolute time when this message is considered to be expired. + */ + ABSOLUTE_EXPRITY_TIME("absolute-expiry-time"), + /** + * An absolute time when this message was created. + */ + CREATION_TIME("creation-time"), + /** + * Identifies the group the message belongs to. + */ + GROUP_ID("group-id"), + /** + * The relative position of this message within its group. + */ + GROUP_SEQUENCE("group-sequence"), + /** + * This is a client-specific id that is used so that client can send replies to this message to a specific group. + */ + REPLY_TO_GROUP_ID("reply-to-group-id"), + /** + * The offset of a message within a given partition. + */ + OFFSET_ANNOTATION_NAME("x-opt-offset"), + /** + * The date and time, in UTC, that a message was enqueued. + */ + ENQUEUED_TIME_UTC_ANNOTATION_NAME("x-opt-enqueued-time"), + /** + * The identifier associated with a given partition. + */ + PARTITION_KEY_ANNOTATION_NAME("x-opt-partition-key"), + /** + * The sequence number assigned to a message. + */ + SEQUENCE_NUMBER_ANNOTATION_NAME("x-opt-sequence-number"), + /** + * The name of the entity that published a message. + */ + PUBLISHER_ANNOTATION_NAME("x-opt-publisher"); + + private static final Map RESERVED_CONSTANTS_MAP = new HashMap<>(); + private final String constant; + + static { + for (MessageConstant error : MessageConstant.values()) { + RESERVED_CONSTANTS_MAP.put(error.getValue(), error); + } + } + + MessageConstant(String value) { + this.constant = value; + } + + /** + * Gets the AMQP messaging header value. + * + * @return The AMQP header value for this messaging constant. + */ + public String getValue() { + return constant; + } + + /** + * Parses an header value to its message constant. + * + * @param headerValue the messaging header value to parse. + * @return the parsed MessageConstant object, or {@code null} if unable to parse. + * @throws NullPointerException if {@code constant} is {@code null}. + */ + public static MessageConstant fromString(String headerValue) { + Objects.requireNonNull(headerValue); + + return RESERVED_CONSTANTS_MAP.get(headerValue); + } +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/Retry.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/Retry.java new file mode 100644 index 000000000000..39f969e8bdd7 --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/Retry.java @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp; + +import com.azure.core.amqp.exception.AmqpException; +import com.azure.core.amqp.exception.ErrorCondition; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * An abstract representation of a policy to govern retrying of messaging operations. + */ +public abstract class Retry { + /** + * Default for the minimum time between retry attempts. + */ + public static final Duration DEFAULT_RETRY_MIN_BACKOFF = Duration.ofSeconds(0); + /** + * Default for the maximum time between retry attempts. + */ + public static final Duration DEFAULT_RETRY_MAX_BACKOFF = Duration.ofSeconds(30); + /** + * Default for the maximum number of retry attempts. + */ + public static final int DEFAULT_MAX_RETRY_COUNT = 10; + + /** + * Base sleep wait time. + */ + private static final int SERVER_BUSY_BASE_SLEEP_TIME_IN_SECS = 4; + + private final AtomicInteger retryCount = new AtomicInteger(); + private final int maxRetryCount; + + /** + * Creates a new instance of Retry with the maximum retry count of {@code maxRetryCount} + * + * @param maxRetryCount The maximum number of retries allowed. + */ + public Retry(int maxRetryCount) { + this.maxRetryCount = maxRetryCount; + } + + /** + * Check if the existing exception is a retriable exception. + * + * @param exception An exception that was observed for the operation to be retried. + * @return true if the exception is a retriable exception, otherwise false. + */ + public static boolean isRetriableException(Exception exception) { + return (exception instanceof AmqpException) && ((AmqpException) exception).isTransient(); + } + + /** + * Creates a Retry policy that does not retry failed requests. + * + * @return A new Retry policy that does not retry failed requests. + */ + public static Retry getNoRetry() { + return new ExponentialRetry(Duration.ZERO, Duration.ZERO, 0); + } + + /** + * Creates a Retry policy that retries failed requests up to {@link #DEFAULT_MAX_RETRY_COUNT 10} times. As the + * number of retry attempts increase, the period between retry attempts increases. + * + * @return A new instance with the default Retry values configured. + */ + public static Retry getDefaultRetry() { + return new ExponentialRetry(DEFAULT_RETRY_MIN_BACKOFF, DEFAULT_RETRY_MAX_BACKOFF, DEFAULT_MAX_RETRY_COUNT); + } + + /** + * Increments the number of retry attempts and returns the previous number of retry counts. + * + * @return The number of retry attempts before it was incremented. + */ + public int incrementRetryCount() { + return retryCount.getAndIncrement(); + } + + /** + * Gets the current number of retry attempts for this instance. + * + * @return The current number of retry attempts. + */ + public int getRetryCount() { + return retryCount.get(); + } + + /** + * Resets the number of retry attempts for this instance. + */ + public void resetRetryInterval() { + retryCount.set(0); + } + + /** + * Gets the maximum number of retry attempts that are allowed. + * + * @return The maximum number of retry attempts. + */ + public int getMaxRetryCount() { + return maxRetryCount; + } + + /** + * Calculates the amount of time to delay before the next retry attempt. + * + * @param lastException The last exception that was observed for the operation to be retried. + * @param remainingTime The amount of time remaining for the cumulative timeout across retry attempts. + * @return The amount of time to delay before retrying the associated operation; if {@code null}, + * then the operation is no longer eligible to be retried. + */ + public Duration getNextRetryInterval(Exception lastException, Duration remainingTime) { + int baseWaitTime = 0; + + if (!isRetriableException(lastException) || retryCount.get() >= maxRetryCount) { + return null; + } + + if (!(lastException instanceof AmqpException)) { + return null; + } + + if (((AmqpException) lastException).getErrorCondition() == ErrorCondition.SERVER_BUSY_ERROR) { + baseWaitTime += SERVER_BUSY_BASE_SLEEP_TIME_IN_SECS; + } + + return this.calculateNextRetryInterval(lastException, remainingTime, baseWaitTime, this.getRetryCount()); + } + + /** + * Allows a concrete retry policy implementation to offer a base retry interval to be used in + * the calculations performed by 'Retry.GetNextRetryInterval'. + * + * @param lastException The last exception that was observed for the operation to be retried. + * @param remainingTime The amount of time remaining for the cumulative timeout across retry attempts. + * @param baseWaitSeconds The number of seconds to base the suggested retry interval on; + * this should be used as the minimum interval returned under normal circumstances. + * @param retryCount The number of retries that have already been attempted. + * @return The amount of time to delay before retrying the associated operation; if {@code null}, + * then the operation is no longer eligible to be retried. + */ + protected abstract Duration calculateNextRetryInterval(Exception lastException, Duration remainingTime, + int baseWaitSeconds, int retryCount); +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/TransportType.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/TransportType.java new file mode 100644 index 000000000000..cd0b53697aca --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/TransportType.java @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp; + +import java.util.Locale; + +/** + * All TransportType switches available for AMQP protocol. + */ +public enum TransportType { + /** + * AMQP over TCP. Uses port 5671 - assigned by IANA for secure AMQP (AMQPS). + */ + AMQP("Amqp"), + + /** + * AMQP over Web Sockets. Uses port 443. + */ + AMQP_WEB_SOCKETS("AmqpWebSockets"); + + private final String value; + + TransportType(final String value) { + this.value = value; + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() { + return this.value; + } + + /** + * Creates an TransportType from its display value. + * + * @param value The string value of the TransportType. + * @return The TransportType represented by the value. + * @throws IllegalArgumentException If a TransportType cannot be parsed from the string value. + */ + public static TransportType fromString(final String value) { + for (TransportType transportType : values()) { + if (transportType.value.equalsIgnoreCase(value)) { + return transportType; + } + } + + throw new IllegalArgumentException(String.format(Locale.US, "Could not convert %s to a TransportType", value)); + } +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/AmqpException.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/AmqpException.java new file mode 100644 index 000000000000..67cbc344929b --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/AmqpException.java @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp.exception; + +import com.azure.core.exception.AzureException; +import com.azure.core.implementation.util.ImplUtils; + +import java.util.Locale; + +/** + * General exception for AMQP related failures. + * + * @see ErrorCondition + * @see Azure Messaging Exceptions + */ +public class AmqpException extends AzureException { + private static final long serialVersionUID = -3654294093967132325L; + + private final ErrorContext errorContext; + private final boolean isTransient; + private final ErrorCondition errorCondition; + + /** + * Initializes a new instance of the AmqpException class. + * + * @param isTransient A boolean indicating if the exception is a transient error or not. If true, then the request + * can be retried; otherwise not. + * @param message Text containing any supplementary details of the exception. + * @param errorContext The context that caused this AMQP error. + */ + public AmqpException(boolean isTransient, String message, ErrorContext errorContext) { + this(isTransient, null, message, errorContext); + } + + /** + * Initializes a new instance of the AmqpException class. + * + * @param isTransient A boolean indicating if the exception is a transient error or not. If true, then the request + * can be retried; otherwise not. + * @param errorCondition The symbolic value indicating the error condition. + * @param message Text containing any supplementary details not indicated by the condition field. This text can + * be logged as an aid to resolving issues. + * @param errorContext The context that caused this AMQP error. + */ + public AmqpException(boolean isTransient, ErrorCondition errorCondition, String message, ErrorContext errorContext) { + super(message); + this.errorCondition = errorCondition; + this.isTransient = isTransient; + this.errorContext = errorContext; + } + + /** + * Initializes a new instance of the AmqpException class. + * + * @param isTransient A boolean indicating if the exception is a transient error or not. If true, then the request + * can be retried; otherwise not. + * @param errorCondition The symbolic value indicating the error condition. + * @param message Text containing any supplementary details not indicated by the condition field. This text can + * be logged as an aid to resolving issues. + * @param cause The Throwable which caused the creation of this AmqpException. + * @param errorContext The context that caused this AMQP error. + */ + public AmqpException(boolean isTransient, ErrorCondition errorCondition, String message, Throwable cause, + ErrorContext errorContext) { + super(message, cause); + this.errorCondition = errorCondition; + this.isTransient = isTransient; + this.errorContext = errorContext; + } + + /** + * Initializes a new instance of the AmqpException class. + * + * @param isTransient A boolean indicating if the exception is a transient error or not. If true, then the request + * can be retried; otherwise not. + * @param errorCondition The symbolic value indicating the error condition. + * @param cause The Throwable which caused the creation of this AmqpException. + * @param errorContext The context that caused this AMQP error. + */ + public AmqpException(boolean isTransient, ErrorCondition errorCondition, Throwable cause, ErrorContext errorContext) { + super(cause.getMessage(), cause); + this.errorCondition = errorCondition; + this.isTransient = isTransient; + this.errorContext = errorContext; + } + + /** + * Initializes a new instance of the AmqpException class. + * + * @param isTransient A boolean indicating if the exception is a transient error or not. If true, then the request + * can be retried; otherwise not. + * @param message Text containing any supplementary details not indicated by the condition field. This text can + * be logged as an aid to resolving issues. + * @param cause The Throwable which caused the creation of this AmqpException. + * @param errorContext The context that caused this AMQP error. + */ + public AmqpException(boolean isTransient, String message, Throwable cause, ErrorContext errorContext) { + super(message, cause); + this.errorCondition = null; + this.isTransient = isTransient; + this.errorContext = errorContext; + } + + @Override + public String getMessage() { + final String baseMessage = super.getMessage(); + + if (this.errorContext == null) { + return super.getMessage(); + } + + return !ImplUtils.isNullOrEmpty(baseMessage) + ? String.format(Locale.US, "%s, %s[%s]", baseMessage, "errorContext", errorContext.toString()) + : String.format(Locale.US, "%s[%s]", "errorContext", errorContext.toString()); + } + + /** + * A boolean indicating if the exception is a transient error or not. + * + * @return returns true when user can retry the operation that generated the exception without additional intervention. + */ + public boolean isTransient() { + return this.isTransient; + } + + /** + * Gets the ErrorCondition for this exception. + * + * @return The ErrorCondition for this exception, or {@code null} if nothing was set. + */ + public ErrorCondition getErrorCondition() { + return this.errorCondition; + } + + /** + * Gets the context for this exception. + * + * @return The context for this exception. + */ + public ErrorContext getContext() { + return this.errorContext; + } +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/AmqpResponseCode.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/AmqpResponseCode.java new file mode 100644 index 000000000000..6b78e2fcfd87 --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/AmqpResponseCode.java @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp.exception; + +import java.util.HashMap; +import java.util.Map; + +/** + * Error response codes returned from AMQP. + */ +public enum AmqpResponseCode { + ACCEPTED(0xca), + OK(200), + BAD_REQUEST(400), + NOT_FOUND(0x194), + FORBIDDEN(0x193), + INTERNAL_SERVER_ERROR(500), + UNAUTHORIZED(0x191); + + private static Map valueMap = new HashMap<>(); + + static { + for (AmqpResponseCode code : AmqpResponseCode.values()) { + valueMap.put(code.value, code); + } + } + + private final int value; + + AmqpResponseCode(final int value) { + this.value = value; + } + + /** + * Creates an AmqpResponseCode for the provided integer {@code value}. + * + * @param value The integer value representing an error code. + * @return The corresponding AmqpResponseCode for the provided value, or {@code null} if no matching response code + * is found. + */ + public static AmqpResponseCode fromValue(final int value) { + return valueMap.get(value); + } + + /** + * Gets the integer value of the AmqpResponseCode + * + * @return The integer value of the AmqpResponseCode + */ + public int getValue() { + return this.value; + } +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/ErrorCondition.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/ErrorCondition.java new file mode 100644 index 000000000000..0a36df50653f --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/ErrorCondition.java @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp.exception; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Contains constants common to the AMQP protocol and constants shared by Azure services. + * + * @see AMQP + * 1.0: Transport Errors + * @see Azure Messaging Exceptions + */ +public enum ErrorCondition { + /** + * A peer attempted to work with a remote entity that does not exist. + */ + NOT_FOUND("amqp:not-found"), + /** + * A peer attempted to work with a remote entity to which it has no access due to security settings. + */ + UNAUTHORIZED_ACCESS("amqp:unauthorized-access"), + /** + * A peer exceeded its resource allocation. + */ + RESOURCE_LIMIT_EXCEEDED("amqp:resource-limit-exceeded"), + /** + * The peer tried to use a frame in a manner that is inconsistent with the semantics defined in the specification. + */ + NOT_ALLOWED("amqp:not-allowed"), + /** + * An internal error occurred. Operator intervention might be necessary to resume normal operation. + */ + INTERNAL_ERROR("amqp:internal-error"), + /** + * The peer sent a frame that is not permitted in the current state. + */ + ILLEGAL_STATE("amqp:illegal-state"), + /** + * The peer tried to use functionality that is not implemented in its partner. + */ + NOT_IMPLEMENTED("amqp:not-implemented"), + + /** + * The link has been attached elsewhere, causing the existing attachment to be forcibly closed. + */ + LINK_STOLEN("amqp:link:stolen"), + /** + * The peer sent a larger message than is supported on the link. + */ + LINK_PAYLOAD_SIZE_EXCEEDED("amqp:link:message-size-exceeded"), + /** + * An operator intervened to detach for some reason. + */ + LINK_DETACH_FORCED("amqp:link:detach-forced"), + + /** + * An operator intervened to close the connection for some reason. The client could retry at some later date. + */ + CONNECTION_FORCED("amqp:connection:forced"), + + // These are errors that are specific to Azure services. + SERVER_BUSY_ERROR("com.microsoft:server-busy"), + /** + * One or more arguments supplied to the method are invalid. + */ + ARGUMENT_ERROR("com.microsoft:argument-error"), + /** + * One or more arguments supplied to the method are invalid. + */ + ARGUMENT_OUT_OF_RANGE_ERROR("com.microsoft:argument-out-of-range"), + /** + * Request for a runtime operation on a disabled entity. + */ + ENTITY_DISABLED_ERROR("com.microsoft:entity-disabled"), + /** + * Partition is not owned. + */ + PARTITION_NOT_OWNED_ERROR("com.microsoft:partition-not-owned"), + /** + * Lock token associated with the message or session has expired, or the lock token is not found. + */ + STORE_LOCK_LOST_ERROR("com.microsoft:store-lock-lost"), + /** + * The TokenProvider object could not acquire a token, the token is invalid, or the token does not contain the + * claims required to perform the operation. + */ + PUBLISHER_REVOKED_ERROR("com.microsoft:publisher-revoked"), + /** + * The server did not respond to the requested operation within the specified time. The server may have completed + * the requested operation. This can happen due to network or other infrastructure delays. + */ + TIMEOUT_ERROR("com.microsoft:timeout"), + /** + * Tracking Id for an exception. + */ + TRACKING_ID_PROPERTY("com.microsoft:tracking-id"); + + private static final Map ERROR_CONSTANT_MAP = new HashMap<>(); + private final String errorCondition; + + static { + for (ErrorCondition error : ErrorCondition.values()) { + ERROR_CONSTANT_MAP.put(error.getErrorCondition(), error); + } + } + + ErrorCondition(String errorCondition) { + this.errorCondition = errorCondition; + } + + /** + * Gets the AMQP header value for this error condition. + * + * @return AMQP header value for this error condition. + */ + public String getErrorCondition() { + return errorCondition; + } + + /** + * Parses a serialized value to an ErrorCondition instance. + * + * @param errorCondition the serialized value to parse. + * @return the parsed ErrorCondition object, or null if unable to parse. + * @throws NullPointerException if {@code errorCondition} is {@code null}. + */ + public static ErrorCondition fromString(String errorCondition) { + Objects.requireNonNull(errorCondition); + + return ERROR_CONSTANT_MAP.get(errorCondition); + } +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/ErrorContext.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/ErrorContext.java new file mode 100644 index 000000000000..6bf5776ac742 --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/ErrorContext.java @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.core.amqp.exception; + +import com.azure.core.amqp.AmqpConnection; +import com.azure.core.amqp.AmqpLink; +import com.azure.core.amqp.AmqpSession; +import com.azure.core.implementation.util.ImplUtils; + +import java.io.Serializable; +import java.util.Locale; + +/** + * Provides context for an {@link AmqpException} that occurs in an {@link AmqpConnection}, {@link AmqpSession}, + * or {@link AmqpLink}. + * + * @see AmqpException + * @see SessionErrorContext + * @see LinkErrorContext + */ +public class ErrorContext implements Serializable { + static final String MESSAGE_PARAMETER_DELIMITER = ", "; + + private static final long serialVersionUID = -2819764407122954922L; + + private final String namespace; + + /** + * Creates a new instance with the provided {@code namespace}. + * + * @param namespace The service namespace of the error. + * @throws IllegalArgumentException when {@code namespace} is {@code null} or empty. + */ + public ErrorContext(String namespace) { + if (ImplUtils.isNullOrEmpty(namespace)) { + throw new IllegalArgumentException("'namespace' cannot be null or empty"); + } + + this.namespace = namespace; + } + + /** + * Gets the namespace for this error. + * + * @return The namespace for this error. + */ + public String getNamespace() { + return namespace; + } + + /** + * Creates a string representation of this ErrorContext. + * + * @return A string representation of this ErrorContext. + */ + @Override + public String toString() { + return String.format(Locale.US, "NAMESPACE: %s", getNamespace()); + } +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/ExceptionUtil.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/ExceptionUtil.java new file mode 100644 index 000000000000..4033340f1185 --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/ExceptionUtil.java @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp.exception; + +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Utility class to map AMQP status codes and error conditions to an exception. + */ +public final class ExceptionUtil { + private static final String AMQP_REQUEST_FAILED_ERROR = "status-code: %s, status-description: %s"; + private static final Pattern ENTITY_NOT_FOUND_PATTERN = Pattern.compile("The messaging entity .* could not be found"); + + /** + * Creates an {@link AmqpException} or Exception based on the {@code errorCondition} from the AMQP request. + * + * @param errorCondition The error condition string. + * @param description The error message. + * @param errorContext The context that this error occurred in. + * @return An exception that maps to the {@code errorCondition} provided. + * @throws IllegalArgumentException when 'errorCondition' is {@code null} or empty, cannot be translated into an + * {@link ErrorCondition}, or cannot be determined whether the {@link ErrorCondition} is transient or not. + * @see ErrorCondition + */ + public static Exception toException(String errorCondition, String description, ErrorContext errorContext) { + if (errorCondition == null) { + throw new IllegalArgumentException("'null' errorCondition cannot be translated to EventHubException"); + } + + final ErrorCondition condition = ErrorCondition.fromString(errorCondition); + + if (condition == null) { + throw new IllegalArgumentException(String.format(Locale.ROOT, "'%s' is not a known ErrorCondition.", errorCondition)); + } + + boolean isTransient; + switch (condition) { + case TIMEOUT_ERROR: + case SERVER_BUSY_ERROR: + case INTERNAL_ERROR: + case LINK_DETACH_FORCED: + case CONNECTION_FORCED: + isTransient = true; + break; + case ENTITY_DISABLED_ERROR: + case LINK_STOLEN: + case UNAUTHORIZED_ACCESS: + case LINK_PAYLOAD_SIZE_EXCEEDED: + case ARGUMENT_ERROR: + case ARGUMENT_OUT_OF_RANGE_ERROR: + case PARTITION_NOT_OWNED_ERROR: + case STORE_LOCK_LOST_ERROR: + case RESOURCE_LIMIT_EXCEEDED: + isTransient = false; + break; + case NOT_IMPLEMENTED: + case NOT_ALLOWED: + return new UnsupportedOperationException(description); + case NOT_FOUND: + return distinguishNotFound(description, errorContext); + default: + throw new IllegalArgumentException(String.format(Locale.ROOT, "This condition '%s' is not known.", condition)); + } + + return new AmqpException(isTransient, condition, description, errorContext); + } + + /** + * Given an AMQP response code, it maps it to an exception. + * + * @param statusCode AMQP response code. + * @param statusDescription Message associated with response. + * @param errorContext The context that this error occurred in. + * @return An exception that maps to that status code. + */ + public static Exception amqpResponseCodeToException(int statusCode, String statusDescription, + ErrorContext errorContext) { + final AmqpResponseCode amqpResponseCode = AmqpResponseCode.fromValue(statusCode); + final String message = String.format(AMQP_REQUEST_FAILED_ERROR, statusCode, statusDescription); + + if (amqpResponseCode == null) { + return new AmqpException(true, message, errorContext); + } + + switch (amqpResponseCode) { + case BAD_REQUEST: + return new IllegalArgumentException(message); + case NOT_FOUND: + return distinguishNotFound(statusDescription, errorContext); + case FORBIDDEN: + return new AmqpException(false, ErrorCondition.RESOURCE_LIMIT_EXCEEDED, message, errorContext); + case UNAUTHORIZED: + return new AmqpException(false, ErrorCondition.UNAUTHORIZED_ACCESS, message, errorContext); + default: + return new AmqpException(true, message, errorContext); + } + } + + private static AmqpException distinguishNotFound(String message, ErrorContext errorContext) { + final Matcher m = ENTITY_NOT_FOUND_PATTERN.matcher(message); + if (m.find()) { + return new AmqpException(false, ErrorCondition.NOT_FOUND, message, errorContext); + } else { + return new AmqpException(true, ErrorCondition.NOT_FOUND, + String.format(AMQP_REQUEST_FAILED_ERROR, AmqpResponseCode.NOT_FOUND, message), + errorContext); + } + } +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/LinkErrorContext.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/LinkErrorContext.java new file mode 100644 index 000000000000..91398212abe7 --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/LinkErrorContext.java @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp.exception; + +import java.util.Locale; + +/** + * Represents the context for an AMQP link when an {@link AmqpException} occurs. + * + * @see AmqpException + * @see ErrorContext + */ +public class LinkErrorContext extends SessionErrorContext { + private static final long serialVersionUID = 2581371351997722504L; + + private final String trackingId; + private final Integer linkCredit; + + /** + * Creates a new instance with the AMQP link's {@code namespace} and {@code entityPath} information. Allows for + * optional information about the link if it was successfully opened such as {@code linkCredit} and + * {@code trackingId}. + * + * @param namespace The service namespace of the error context. + * @param entityPath The remote container the AMQP receive link is fetching messages from. + * @param trackingId The tracking id for the error. Tracking id can be {@code null} if the error was not thrown from + * the remote AMQP message broker. + * @param linkCredit the number of link credits the current AMQP link has when this error occurred, can be + * {@code null} if the receive link has not opened yet. + * @throws IllegalArgumentException if {@code namespace} or {@code entityPath} is {@code null} or empty. + */ + public LinkErrorContext(String namespace, String entityPath, String trackingId, Integer linkCredit) { + super(namespace, entityPath); + + this.trackingId = trackingId; + this.linkCredit = linkCredit; + } + + /** + * Gets the unique tracking identifier for this error. It is possible to be {@code null} if the error was not thrown + * from the AMQP message broker. + * + * @return The unique tracking identifier for this error. + */ + public String getTrackingId() { + return trackingId; + } + + /** + * Gets the number of credits on the link when the error occurred. Can be {@code null} if the link is not opened. + * + * @return The number of credits on the link when the error occurred. + */ + public Integer getLinkCredit() { + return linkCredit; + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() { + final StringBuilder builder = new StringBuilder(super.toString()); + + if (getTrackingId() != null) { + builder.append(MESSAGE_PARAMETER_DELIMITER); + builder.append(String.format(Locale.US, "REFERENCE_ID: %s", getTrackingId())); + } + + if (getLinkCredit() != null) { + builder.append(MESSAGE_PARAMETER_DELIMITER); + builder.append(String.format(Locale.US, "LINK_CREDIT: %s", getLinkCredit())); + } + + return builder.toString(); + } +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/OperationCancelledException.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/OperationCancelledException.java new file mode 100644 index 000000000000..ca087cd9e0ce --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/OperationCancelledException.java @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp.exception; + +/** + * This exception is thrown when the underlying AMQP layer encounters an abnormal link abort or the connection is + * disconnected in an unexpected fashion. + */ +public class OperationCancelledException extends AmqpException { + private static final long serialVersionUID = 1L; + + /** + * Creates an instance of this exception with provided {@code message}. + * + * @param message Message associated with this exception. + * @param context The context that caused this OperationCancelledException. + */ + public OperationCancelledException(String message, ErrorContext context) { + super(false, message, context); + } + + /** + * Creates an instance of this exception with provided {@code message} and underlying {@code cause}. + * + * @param message Message associated with this exception. + * @param cause The throwable that caused this exception to be thrown. + * @param context The context that caused this OperationCancelledException. + */ + public OperationCancelledException(final String message, final Throwable cause, ErrorContext context) { + super(false, message, cause, context); + } +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/SessionErrorContext.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/SessionErrorContext.java new file mode 100644 index 000000000000..098d25ea6009 --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/SessionErrorContext.java @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp.exception; + +import com.azure.core.implementation.util.ImplUtils; + +import java.util.Locale; + +/** + * Context for an error that occurs in an AMQP session when an {@link AmqpException} occurs. + */ +public class SessionErrorContext extends ErrorContext { + private static final long serialVersionUID = -6595933736672371232L; + private final String entityPath; + + /** + * Creates a new instance with the {@code namespace} and {@code entityPath}. + * + * @param namespace The service namespace of the error. + * @param entityPath The remote endpoint this AMQP session is connected to when the error occurred. + * @throws IllegalArgumentException if {@code namespace} or {@code entityPath} is {@code null} or empty. + */ + public SessionErrorContext(String namespace, String entityPath) { + super(namespace); + if (ImplUtils.isNullOrEmpty(entityPath)) { + throw new IllegalArgumentException("'entityPath' cannot be null or empty"); + } + + this.entityPath = entityPath; + } + + /** + * Gets the remote path this AMQP entity was connected to when the error occurred. + * + * @return the remote path this AMQP entity was connected to when the error occurred. + */ + public String getEntityPath() { + return entityPath; + } + + @Override + public String toString() { + return String.join(MESSAGE_PARAMETER_DELIMITER, super.toString(), + String.format(Locale.US, "PATH: %s", getEntityPath())); + } +} diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/package-info.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/package-info.java new file mode 100644 index 000000000000..c566a417b8d1 --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/exception/package-info.java @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Package contains classes related to AMQP exceptions. + */ +package com.azure.core.amqp.exception; diff --git a/core/azure-core-amqp/src/main/java/com/azure/core/amqp/package-info.java b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/package-info.java new file mode 100644 index 000000000000..97b600ef1900 --- /dev/null +++ b/core/azure-core-amqp/src/main/java/com/azure/core/amqp/package-info.java @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Package contains classes common to AMQP protocol. + */ +package com.azure.core.amqp; diff --git a/core/azure-core-amqp/src/test/java/com/azure/core/amqp/RetryTest.java b/core/azure-core-amqp/src/test/java/com/azure/core/amqp/RetryTest.java new file mode 100644 index 000000000000..fa30f6d5cce2 --- /dev/null +++ b/core/azure-core-amqp/src/test/java/com/azure/core/amqp/RetryTest.java @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp; + +import com.azure.core.amqp.exception.AmqpException; +import com.azure.core.amqp.exception.ErrorCondition; +import com.azure.core.amqp.exception.ErrorContext; +import org.junit.Assert; +import org.junit.Test; + +import java.time.Duration; + +public class RetryTest { + private final ErrorContext errorContext = new ErrorContext("test-namespace"); + + /** + * Verifies that when the service is busy and we retry an exception multiple times, the retry duration gets longer. + */ + @Test + public void defaultRetryPolicy() { + // Arrange + final Retry retry = Retry.getDefaultRetry(); + final AmqpException exception = new AmqpException(true, ErrorCondition.SERVER_BUSY_ERROR, "error message", errorContext); + final Duration remainingTime = Duration.ofSeconds(60); + + // Act + retry.incrementRetryCount(); + final Duration firstRetryInterval = retry.getNextRetryInterval(exception, remainingTime); + Assert.assertNotNull(firstRetryInterval); + + retry.incrementRetryCount(); + final Duration leftoverTime = remainingTime.minus(firstRetryInterval); + final Duration secondRetryInterval = retry.getNextRetryInterval(exception, leftoverTime); + + // Assert + Assert.assertNotNull(secondRetryInterval); + Assert.assertTrue(secondRetryInterval.toNanos() > firstRetryInterval.toNanos()); + } + + /** + * Verifies we can increment the retry count. + */ + @Test + public void canIncrementRetryCount() { + Retry retry = Retry.getDefaultRetry(); + Assert.assertEquals(0, retry.getRetryCount()); + Assert.assertEquals(0, retry.incrementRetryCount()); + + Assert.assertEquals(1, retry.getRetryCount()); + Assert.assertEquals(1, retry.incrementRetryCount()); + + Assert.assertEquals(2, retry.getRetryCount()); + Assert.assertEquals(2, retry.incrementRetryCount()); + + retry.resetRetryInterval(); + + Assert.assertEquals(0, retry.getRetryCount()); + Assert.assertEquals(0, retry.incrementRetryCount()); + + Assert.assertEquals(1, retry.getRetryCount()); + } + + @Test + public void isRetriableException() { + final Exception exception = new AmqpException(true, "error message", errorContext); + Assert.assertTrue(Retry.isRetriableException(exception)); + } + + @Test + public void notRetriableException() { + final Exception invalidException = new RuntimeException("invalid exception"); + Assert.assertFalse(Retry.isRetriableException(invalidException)); + } + + @Test + public void notRetriableExceptionNotTransient() { + final Exception invalidException = new AmqpException(false, "Some test exception", errorContext); + Assert.assertFalse(Retry.isRetriableException(invalidException)); + } + + /** + * Verifies that using no retry policy does not allow us to retry a failed request. + */ + @Test + public void noRetryPolicy() { + // Arrange + final Retry noRetry = Retry.getNoRetry(); + final Exception exception = new AmqpException(true, "error message", errorContext); + final Duration remainingTime = Duration.ofSeconds(60); + + // Act + final Duration nextRetryInterval = noRetry.getNextRetryInterval(exception, remainingTime); + int retryCount = noRetry.incrementRetryCount(); + + // Assert + Assert.assertEquals(0, retryCount); + Assert.assertNull(nextRetryInterval); + } + + /** + * Verifies that if we exceed the number of allowed retry attempts, the next retry interval, even if there is time + * remaining, is null. + */ + @Test + public void excessMaxRetry() { + // Arrange + final Retry retry = Retry.getDefaultRetry(); + final Exception exception = new AmqpException(true, "error message", errorContext); + final Duration sixtySec = Duration.ofSeconds(60); + + // Simulates that we've tried to retry the max number of requests this allows. + for (int i = 0; i < retry.getMaxRetryCount(); i++) { + retry.incrementRetryCount(); + } + + // Act + final Duration nextRetryInterval = retry.getNextRetryInterval(exception, sixtySec); + + // Assert + Assert.assertEquals(Retry.DEFAULT_MAX_RETRY_COUNT, retry.getRetryCount()); + Assert.assertNull(nextRetryInterval); + } +} diff --git a/core/azure-core-amqp/src/test/java/com/azure/core/amqp/exception/OperationCancelledExceptionTest.java b/core/azure-core-amqp/src/test/java/com/azure/core/amqp/exception/OperationCancelledExceptionTest.java new file mode 100644 index 000000000000..fb77190d9ea6 --- /dev/null +++ b/core/azure-core-amqp/src/test/java/com/azure/core/amqp/exception/OperationCancelledExceptionTest.java @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.amqp.exception; + +import org.junit.Assert; +import org.junit.Test; + +public class OperationCancelledExceptionTest { + @Test + public void correctMessage() { + final String message = "A test message."; + final OperationCancelledException exception = new OperationCancelledException(message, null); + + Assert.assertEquals(message, exception.getMessage()); + } + + @Test + public void correctMessageAndThrowable() { + // Arrange + final String message = "A test message."; + final Throwable innerException = new IllegalArgumentException("An argument"); + + // Act + final OperationCancelledException exception = new OperationCancelledException(message, innerException, null); + + // Arrange + Assert.assertEquals(message, exception.getMessage()); + Assert.assertEquals(innerException, exception.getCause()); + } +} diff --git a/core/azure-core/src/main/java/com/azure/core/implementation/util/ImplUtils.java b/core/azure-core/src/main/java/com/azure/core/implementation/util/ImplUtils.java index 8049c040d0cd..272b93e2d0db 100644 --- a/core/azure-core/src/main/java/com/azure/core/implementation/util/ImplUtils.java +++ b/core/azure-core/src/main/java/com/azure/core/implementation/util/ImplUtils.java @@ -94,6 +94,15 @@ public static boolean isNullOrEmpty(Map map) { return map == null || map.isEmpty(); } + /* + * Checks if the character sequence is null or empty. + * @param charSequence Character sequence being checked for nullness or emptiness. + * @return True if the character sequence is null or empty, false otherwise. + */ + public static boolean isNullOrEmpty(CharSequence charSequence) { + return charSequence == null || charSequence.length() == 0; + } + /* * Turns an array into a string mapping each element to a string and delimits them using a coma. * @param array Array being formatted to a string. @@ -130,15 +139,6 @@ public static T findFirstOfType(Object[] args, Class clazz) { return null; } - /* - * Checks if the character sequence is null or empty. - * @param charSequence Character sequence being checked for nullness or emptiness. - * @return True if the character sequence is null or empty, false otherwise. - */ - public static boolean isNullOrEmpty(CharSequence charSequence) { - return charSequence == null || charSequence.length() == 0; - } - /* * Extracts and combines the generic items from all the pages linked together. * @param page The paged response from server holding generic items. diff --git a/core/pom.xml b/core/pom.xml index 13e4172c9c6d..a5286d01f928 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -54,6 +54,7 @@ azure-core + azure-core-amqp azure-core-auth azure-core-management azure-core-test diff --git a/eng/jacoco-test-coverage/pom.xml b/eng/jacoco-test-coverage/pom.xml index 55614025ffa7..a32bd442783d 100644 --- a/eng/jacoco-test-coverage/pom.xml +++ b/eng/jacoco-test-coverage/pom.xml @@ -41,6 +41,11 @@ azure-core ${version} + + com.azure + azure-core-amqp + ${azure-core-amqp.version} + com.azure azure-core-auth @@ -66,6 +71,11 @@ azure-keyvault-secrets ${version} + + com.azure + azure-messaging-eventhubs + ${version} + com.azure tracing-opentelemetry diff --git a/eng/spotbugs-aggregate-report/pom.xml b/eng/spotbugs-aggregate-report/pom.xml index 4fdbbcb3c357..ae37e1cb9744 100644 --- a/eng/spotbugs-aggregate-report/pom.xml +++ b/eng/spotbugs-aggregate-report/pom.xml @@ -20,6 +20,7 @@ 2.0.0 10.5.0 1.0.0-SNAPSHOT + 1.0.0-SNAPSHOT @@ -46,9 +47,11 @@ ..\..\appconfiguration\client\src\samples\java ..\..\core\azure-core\src\main\java ..\..\core\azure-core\src\samples\java + ..\..\core\azure-core-amqp\src\main\java ..\..\core\azure-core-auth\src\main\java ..\..\core\azure-core-management\src\main\java ..\..\core\azure-core-test\src\main\java + ..\..\eventhubs\client\src\main\java @@ -113,6 +116,11 @@ azure-core ${azure-core.version} + + com.azure + azure-core-amqp + ${azure-core-amqp.version} + com.azure azure-core-auth @@ -138,6 +146,11 @@ azure-data-appconfiguration ${azure-data-appconfiguration.version} + + com.azure + azure-messaging-eventhubs + ${azure-messaging-eventhubs.version} + generate-sources diff --git a/eventhubs/client/azure-eventhubs/pom.xml b/eventhubs/client/azure-eventhubs/pom.xml new file mode 100644 index 000000000000..d84cd4f65d10 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + com.azure + azure-messaging-eventhubs-parent + 1.0.0-SNAPSHOT + ../pom.xml + + + com.azure + azure-messaging-eventhubs + 1.0.0-SNAPSHOT + + Microsoft Azure client library for Event Hubs + Libraries built on Microsoft Azure Event Hubs + https://github.com/Azure/azure-sdk-for-java + + + + azure-java-build-docs + ${site.url}/site/${project.artifactId} + + + + + scm:git:https://github.com/Azure/azure-sdk-for-java + + + diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventData.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventData.java new file mode 100644 index 000000000000..00a38e5a0fd7 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventData.java @@ -0,0 +1,333 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import com.azure.core.amqp.MessageConstant; +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.messaging.Data; +import org.apache.qpid.proton.amqp.messaging.Section; +import org.apache.qpid.proton.message.Message; + +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import static com.azure.core.amqp.MessageConstant.ENQUEUED_TIME_UTC_ANNOTATION_NAME; +import static com.azure.core.amqp.MessageConstant.OFFSET_ANNOTATION_NAME; +import static com.azure.core.amqp.MessageConstant.PARTITION_KEY_ANNOTATION_NAME; +import static com.azure.core.amqp.MessageConstant.PUBLISHER_ANNOTATION_NAME; +import static com.azure.core.amqp.MessageConstant.SEQUENCE_NUMBER_ANNOTATION_NAME; + +/** + * The data structure encapsulating the event being sent-to and received-from Event Hubs. Each Event Hub partition can + * be visualized as a stream of {@link EventData}. + * + *

+ * Here's how AMQP message sections map to {@link EventData}. For reference, the specification can be found here: + * AMQP 1.0 specification + * + *

    + *
  1. {@link #properties()} - AMQPMessage.ApplicationProperties section
  2. + *
  3. {@link #body()} - if AMQPMessage.Body has Data section
  4. + *
+ * + *

+ * Serializing a received {@link EventData} with AMQP sections other than ApplicationProperties (with primitive Java + * types) and Data section is not supported. + *

+ */ +public class EventData implements Comparable { + /* + * These are properties owned by the service and set when a message is received. + */ + public static final Set RESERVED_SYSTEM_PROPERTIES; + + private final Map properties; + private final ByteBuffer body; + private final SystemProperties systemProperties; + + static { + final Set properties = new HashSet<>(); + properties.add(OFFSET_ANNOTATION_NAME.getValue()); + properties.add(PARTITION_KEY_ANNOTATION_NAME.getValue()); + properties.add(SEQUENCE_NUMBER_ANNOTATION_NAME.getValue()); + properties.add(ENQUEUED_TIME_UTC_ANNOTATION_NAME.getValue()); + properties.add(PUBLISHER_ANNOTATION_NAME.getValue()); + + RESERVED_SYSTEM_PROPERTIES = Collections.unmodifiableSet(properties); + } + + /** + * Creates an event containing the {@code data}. + * + * @param body The data to set for this event. + */ + public EventData(byte[] body) { + this(ByteBuffer.wrap(body)); + } + + /** + * Creates an event containing the {@code body}. + * + * @param body The data to set for this event. + * @throws NullPointerException if {@code body} is {@code null}. + */ + public EventData(ByteBuffer body) { + Objects.requireNonNull(body); + + this.body = body; + this.properties = new HashMap<>(); + this.systemProperties = new SystemProperties(Collections.emptyMap()); + } + + /* + * Creates an event from a message + */ + EventData(Message message) { + if (message == null) { + throw new IllegalArgumentException("'message' cannot be null"); + } + + final Map messageAnnotations = message.getMessageAnnotations().getValue(); + final HashMap receiveProperties = new HashMap<>(); + + for (Map.Entry annotation : messageAnnotations.entrySet()) { + receiveProperties.put(annotation.getKey().toString(), annotation.getValue()); + } + + if (message.getProperties() != null) { + addMapEntry(receiveProperties, MessageConstant.MESSAGE_ID, message.getMessageId()); + addMapEntry(receiveProperties, MessageConstant.USER_ID, message.getUserId()); + addMapEntry(receiveProperties, MessageConstant.TO, message.getAddress()); + addMapEntry(receiveProperties, MessageConstant.SUBJECT, message.getSubject()); + addMapEntry(receiveProperties, MessageConstant.REPLY_TO, message.getReplyTo()); + addMapEntry(receiveProperties, MessageConstant.CORRELATION_ID, message.getCorrelationId()); + addMapEntry(receiveProperties, MessageConstant.CONTENT_TYPE, message.getContentType()); + addMapEntry(receiveProperties, MessageConstant.CONTENT_ENCODING, message.getContentEncoding()); + addMapEntry(receiveProperties, MessageConstant.ABSOLUTE_EXPRITY_TIME, message.getExpiryTime()); + addMapEntry(receiveProperties, MessageConstant.CREATION_TIME, message.getCreationTime()); + addMapEntry(receiveProperties, MessageConstant.GROUP_ID, message.getGroupId()); + addMapEntry(receiveProperties, MessageConstant.GROUP_SEQUENCE, message.getGroupSequence()); + addMapEntry(receiveProperties, MessageConstant.REPLY_TO_GROUP_ID, message.getReplyToGroupId()); + } + + this.systemProperties = new SystemProperties(receiveProperties); + this.properties = message.getApplicationProperties() == null + ? new HashMap<>() + : message.getApplicationProperties().getValue(); + + final Section bodySection = message.getBody(); + if (bodySection instanceof Data) { + Data bodyData = (Data) bodySection; + this.body = bodyData.getValue().asByteBuffer(); + } else { + this.body = null; + } + + message.clear(); + } + + /** + * Adds an application property associated with this event. If the {@code key} exists in the map, its existing value + * is overwritten. + * + * @param key The key for this application property + * @param value The value for this application property. + * @return The updated EventData object. + * @throws NullPointerException if {@code key} or {@code value} is null. + */ + public EventData addProperty(String key, Object value) { + Objects.requireNonNull(key); + Objects.requireNonNull(value); + + properties.put(key, value); + return this; + } + + /** + * Gets the application property bag + * + * @return Application properties associated with this EventData. + */ + public Map properties() { + return properties; + } + + /** + * Properties that are populated by EventHubService. As these are populated by Service, they are only present on a + * received EventData. + * + * @return an encapsulation of all SystemProperties appended by EventHubs service into EventData. {@code null} if + * the {@link EventData} is not received and is created by the public constructors. + */ + public Map systemProperties() { + return systemProperties; + } + + /** + * Gets the actual payload/data wrapped by EventData. + * + * @return ByteBuffer representing the data. + */ + public ByteBuffer body() { + return body; + } + + /** + * Gets the offset of the event when it was received from the associated Event Hub partition. + * + * @return The offset within the Event Hub partition. + */ + public String offset() { + return systemProperties.offset(); + } + + /** + * Gets a partition key used for message partitioning. If it exists, this value was used to compute a hash to + * select a partition to send the message to. + * + * @return A partition key for this Event Data. + */ + public String partitionKey() { + return systemProperties.partitionKey(); + } + + /** + * Gets the instant, in UTC, of when the event was enqueued in the Event Hub partition. + * + * @return The instant, in UTC, this was enqueued in the Event Hub partition. + */ + public Instant enqueuedTime() { + return systemProperties.enqueuedTime(); + } + + /** + * Gets the sequence number assigned to the event when it was enqueued in the associated Event Hub partition. This + * is unique for every message received in the Event Hub partition. + * + * @return Sequence number for this event. + * @throws IllegalStateException if {@link #systemProperties()} does not contain the sequence number in a retrieved + * event. + */ + public long sequenceNumber() { + return systemProperties.sequenceNumber(); + } + + private void addMapEntry(Map map, MessageConstant key, Object content) { + if (content == null) { + return; + } + + map.put(key.getValue(), content); + } + + /** + * {@inheritDoc} + */ + @Override + public int compareTo(EventData other) { + return Long.compare( + this.sequenceNumber(), + other.sequenceNumber() + ); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + EventData eventData = (EventData) o; + return Objects.equals(body, eventData.body); + } + + /** + * {@inheritDoc} + */ + @Override + public int hashCode() { + return Objects.hash(body); + } + + /** + * A collection of properties populated by Azure Event Hubs service. + */ + private static class SystemProperties extends HashMap { + private static final long serialVersionUID = -2827050124966993723L; + + SystemProperties(final Map map) { + super(Collections.unmodifiableMap(map)); + } + + /** + * Gets the offset within the Event Hubs stream. + * + * @return The offset within the Event Hubs stream. + */ + private String offset() { + return this.getSystemProperty(OFFSET_ANNOTATION_NAME.getValue()); + } + + /** + * Gets a partition key used for message partitioning. If it exists, this value was used to compute a hash to + * select a partition to send the message to. + * + * @return A partition key for this Event Data. + */ + private String partitionKey() { + return this.getSystemProperty(PARTITION_KEY_ANNOTATION_NAME.getValue()); + } + + /** + * Gets the time this event was enqueued in the Event Hub. + * + * @return The time this was enqueued in the service. + */ + private Instant enqueuedTime() { + final Date enqueuedTimeValue = this.getSystemProperty(ENQUEUED_TIME_UTC_ANNOTATION_NAME.getValue()); + return enqueuedTimeValue != null ? enqueuedTimeValue.toInstant() : null; + } + + /** + * Gets the sequence number in the event stream for this event. This is unique for every message received in the + * Event Hub. + * + * @return Sequence number for this event. + * @throws IllegalStateException if {@link SystemProperties} does not contain the sequence number in a retrieved + * event. + */ + private long sequenceNumber() { + final Long sequenceNumber = this.getSystemProperty(SEQUENCE_NUMBER_ANNOTATION_NAME.getValue()); + + if (sequenceNumber == null) { + throw new IllegalStateException(String.format(Locale.US, "sequenceNumber: %s should always be in map.", SEQUENCE_NUMBER_ANNOTATION_NAME.getValue())); + } + + return sequenceNumber; + } + + @SuppressWarnings("unchecked") + private T getSystemProperty(final String key) { + if (this.containsKey(key)) { + return (T) (this.get(key)); + } + + return null; + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventDataBatch.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventDataBatch.java new file mode 100644 index 000000000000..f305a5edc904 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventDataBatch.java @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import com.azure.core.amqp.MessageConstant; +import com.azure.core.amqp.exception.AmqpException; +import com.azure.core.amqp.exception.ErrorCondition; +import com.azure.messaging.eventhubs.implementation.AmqpConstants; +import com.azure.messaging.eventhubs.implementation.ErrorContextProvider; +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.amqp.Binary; +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.messaging.ApplicationProperties; +import org.apache.qpid.proton.amqp.messaging.Data; +import org.apache.qpid.proton.amqp.messaging.MessageAnnotations; +import org.apache.qpid.proton.message.Message; + +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +/* + * A class for aggregating EventData into a single, size-limited, batch that will be treated as a single message when + * sent to the Azure Event Hubs service. + */ +final class EventDataBatch { + private final int maxMessageSize; + private final String partitionKey; + private final ErrorContextProvider contextProvider; + private final List events; + private final byte[] eventBytes; + private int currentSize = 0; + + EventDataBatch(int maxMessageSize, String partitionKey, ErrorContextProvider contextProvider) { + this.maxMessageSize = maxMessageSize; + this.partitionKey = partitionKey; + this.contextProvider = contextProvider; + this.events = new LinkedList<>(); + this.currentSize = (maxMessageSize / 65536) * 1024; // reserve 1KB for every 64KB + this.eventBytes = new byte[maxMessageSize]; + } + + int getSize() { + return events.size(); + } + + boolean tryAdd(final EventData eventData) { + + if (eventData == null) { + throw new IllegalArgumentException("eventData cannot be null"); + } + + final int size; + try { + size = getSize(eventData, events.isEmpty()); + } catch (java.nio.BufferOverflowException exception) { + throw new AmqpException(false, ErrorCondition.LINK_PAYLOAD_SIZE_EXCEEDED, + String.format(Locale.US, "Size of the payload exceeded Maximum message size: %s kb", maxMessageSize / 1024), + contextProvider.getErrorContext()); + } + + if (this.currentSize + size > this.maxMessageSize) { + return false; + } + + this.events.add(eventData); + this.currentSize += size; + return true; + } + + List getEvents() { + return events; + } + + String getPartitionKey() { + return this.partitionKey; + } + + private int getSize(final EventData eventData, final boolean isFirst) { + Objects.requireNonNull(eventData); + + final Message amqpMessage = createAmqpMessage(eventData, partitionKey); + int eventSize = amqpMessage.encode(this.eventBytes, 0, maxMessageSize); // actual encoded bytes size + eventSize += 16; // data section overhead + + if (isFirst) { + amqpMessage.setBody(null); + amqpMessage.setApplicationProperties(null); + amqpMessage.setProperties(null); + amqpMessage.setDeliveryAnnotations(null); + + eventSize += amqpMessage.encode(this.eventBytes, 0, maxMessageSize); + } + + return eventSize; + } + + /* + * Creates the AMQP message represented by the event data + */ + private static Message createAmqpMessage(EventData event, String partitionKey) { + final Message message = Proton.message(); + + if (event.properties() != null && !event.properties().isEmpty()) { + final ApplicationProperties applicationProperties = new ApplicationProperties(event.properties()); + message.setApplicationProperties(applicationProperties); + } + + if (event.systemProperties() != null) { + event.systemProperties().forEach((key, value) -> { + if (EventData.RESERVED_SYSTEM_PROPERTIES.contains(key)) { + return; + } + + final MessageConstant constant = MessageConstant.fromString(key); + + if (constant != null) { + switch (constant) { + case MESSAGE_ID: + message.setMessageId(value); + break; + case USER_ID: + message.setUserId((byte[]) value); + break; + case TO: + message.setAddress((String) value); + break; + case SUBJECT: + message.setSubject((String) value); + break; + case REPLY_TO: + message.setReplyTo((String) value); + break; + case CORRELATION_ID: + message.setCorrelationId(value); + break; + case CONTENT_TYPE: + message.setContentType((String) value); + break; + case CONTENT_ENCODING: + message.setContentEncoding((String) value); + break; + case ABSOLUTE_EXPRITY_TIME: + message.setExpiryTime((long) value); + break; + case CREATION_TIME: + message.setCreationTime((long) value); + break; + case GROUP_ID: + message.setGroupId((String) value); + break; + case GROUP_SEQUENCE: + message.setGroupSequence((long) value); + break; + case REPLY_TO_GROUP_ID: + message.setReplyToGroupId((String) value); + break; + default: + throw new IllegalArgumentException(String.format(Locale.US, "Property is not a recognized reserved property name: %s", key)); + } + } else { + final MessageAnnotations messageAnnotations = (message.getMessageAnnotations() == null) + ? new MessageAnnotations(new HashMap<>()) + : message.getMessageAnnotations(); + messageAnnotations.getValue().put(Symbol.getSymbol(key), value); + message.setMessageAnnotations(messageAnnotations); + } + }); + } + + if (partitionKey != null) { + final MessageAnnotations messageAnnotations = (message.getMessageAnnotations() == null) + ? new MessageAnnotations(new HashMap<>()) + : message.getMessageAnnotations(); + messageAnnotations.getValue().put(AmqpConstants.PARTITION_KEY, partitionKey); + message.setMessageAnnotations(messageAnnotations); + } + + if (event.body() != null) { + message.setBody(new Data(Binary.create(event.body()))); + } + + return message; + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubClient.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubClient.java new file mode 100644 index 000000000000..fd82b9f207e1 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubClient.java @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import com.azure.core.amqp.AmqpConnection; +import com.azure.core.amqp.exception.AmqpException; +import com.azure.core.amqp.exception.ErrorContext; +import com.azure.core.implementation.util.ImplUtils; +import com.azure.messaging.eventhubs.implementation.AmqpReceiveLink; +import com.azure.messaging.eventhubs.implementation.AmqpResponseMapper; +import com.azure.messaging.eventhubs.implementation.AmqpSendLink; +import com.azure.messaging.eventhubs.implementation.ConnectionOptions; +import com.azure.messaging.eventhubs.implementation.EventHubConnection; +import com.azure.messaging.eventhubs.implementation.EventHubManagementNode; +import com.azure.messaging.eventhubs.implementation.EventHubSession; +import com.azure.messaging.eventhubs.implementation.ManagementChannel; +import com.azure.messaging.eventhubs.implementation.ReactorConnection; +import com.azure.messaging.eventhubs.implementation.ReactorHandlerProvider; +import com.azure.messaging.eventhubs.implementation.ReactorProvider; +import com.azure.messaging.eventhubs.implementation.StringUtil; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.io.Closeable; +import java.io.IOException; +import java.util.Date; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * The main point of interaction with Azure Event Hubs, the client offers a connection to a specific Event Hub within + * the Event Hubs namespace and offers operations for sending event data, receiving events, and inspecting the connected + * Event Hub. + */ +public class EventHubClient implements Closeable { + /** + * The name of the default consumer group in the Event Hubs service. + */ + public static final String DEFAULT_CONSUMER_GROUP_NAME = "$Default"; + + private static final String RECEIVER_ENTITY_PATH_FORMAT = "%s/ConsumerGroups/%s/Partitions/%s"; + private static final String SENDER_ENTITY_PATH_FORMAT = "%s/Partitions/%s"; + + private final String connectionId; + private final Mono connectionMono; + private final AtomicBoolean hasConnection = new AtomicBoolean(false); + private final ConnectionOptions connectionOptions; + private final String eventHubPath; + private final EventHubProducerOptions defaultProducerOptions; + private final EventHubConsumerOptions defaultConsumerOptions; + + EventHubClient(ConnectionOptions connectionOptions, ReactorProvider provider, ReactorHandlerProvider handlerProvider) { + Objects.requireNonNull(connectionOptions); + Objects.requireNonNull(provider); + Objects.requireNonNull(handlerProvider); + + this.connectionOptions = connectionOptions; + this.eventHubPath = connectionOptions.eventHubPath(); + this.connectionId = StringUtil.getRandomString("MF"); + this.connectionMono = Mono.fromCallable(() -> { + return (EventHubConnection) new ReactorConnection(connectionId, connectionOptions, provider, handlerProvider, new ResponseMapper()); + }).doOnSubscribe(c -> hasConnection.set(true)) + .cache(); + + this.defaultProducerOptions = new EventHubProducerOptions() + .retry(connectionOptions.retryPolicy()) + .timeout(connectionOptions.timeout()); + this.defaultConsumerOptions = new EventHubConsumerOptions() + .retry(connectionOptions.retryPolicy()) + .scheduler(connectionOptions.scheduler()); + } + + /** + * Retrieves information about an Event Hub, including the number of partitions present and their identifiers. + * + * @return The set of information for the Event Hub that this client is associated with. + */ + public Mono getProperties() { + return connectionMono.flatMap(connection -> connection.getManagementNode().flatMap(EventHubManagementNode::getEventHubProperties)); + } + + /** + * Retrieves the identifiers for the partitions of an Event Hub. + * + * @return A Flux of identifiers for the partitions of an Event Hub. + */ + public Flux getPartitionIds() { + return getProperties().flatMapMany(properties -> Flux.fromArray(properties.partitionIds())); + } + + /** + * Retrieves information about a specific partition for an Event Hub, including elements that describe the available + * events in the partition event stream. + * + * @param partitionId The unique identifier of a partition associated with the Event Hub. + * @return The set of information for the requested partition under the Event Hub this client is associated with. + */ + public Mono getPartitionProperties(String partitionId) { + return connectionMono.flatMap( + connection -> connection.getManagementNode().flatMap(node -> { + return node.getPartitionProperties(partitionId); + })); + } + + /** + * Creates an Event Hub producer responsible for transmitting {@link EventData} to the Event Hub, grouped together + * in batches. Event data is automatically routed to an available partition. + * + * @return A new {@link EventHubProducer}. + */ + public EventHubProducer createProducer() { + return createProducer(defaultProducerOptions); + } + + /** + * Creates an Event Hub producer responsible for transmitting {@link EventData} to the Event Hub, grouped together + * in batches. If {@link EventHubProducerOptions#partitionId() options.partitionId()} is not {@code null}, the + * events are routed to that specific partition. Otherwise, events are automatically routed to an available + * partition. + * + * @param options The set of options to apply when creating the producer. + * @return A new {@link EventHubProducer}. + * @throws NullPointerException if {@code options} is {@code null}. + */ + public EventHubProducer createProducer(EventHubProducerOptions options) { + Objects.requireNonNull(options); + + final EventHubProducerOptions clonedOptions = options.clone(); + if (clonedOptions.timeout() == null) { + clonedOptions.timeout(connectionOptions.timeout()); + } + if (clonedOptions.retry() == null) { + clonedOptions.retry(connectionOptions.retryPolicy()); + } + + final String entityPath; + final String linkName; + + if (ImplUtils.isNullOrEmpty(options.partitionId())) { + entityPath = eventHubPath; + linkName = StringUtil.getRandomString("EC"); + } else { + entityPath = String.format(Locale.US, SENDER_ENTITY_PATH_FORMAT, eventHubPath, options.partitionId()); + linkName = StringUtil.getRandomString("PS"); + } + + final Mono amqpLinkMono = connectionMono.flatMap(connection -> connection.createSession(entityPath) + .flatMap(session -> session.createProducer(linkName, entityPath, clonedOptions.timeout(), clonedOptions.retry()) + .cast(AmqpSendLink.class))) + .publish(x -> x); + + return new EventHubProducer(amqpLinkMono, clonedOptions); + } + + /** + * Creates an Event Hub consumer responsible for reading {@link EventData} from a specific Event Hub partition, + * as a member of the specified consumer group, and begins reading events from the {@code eventPosition}. + * + * The consumer created is non-exclusive, allowing multiple consumers from the same consumer group to be actively + * reading events from the partition. These non-exclusive consumers are sometimes referred to as "Non-epoch + * Consumers". + * + * @param consumerGroup The name of the consumer group this consumer is associated with. Events are read in the + * context of this group. The name of the consumer group that is created by default is + * {@link #DEFAULT_CONSUMER_GROUP_NAME "$Default"}. + * @param partitionId The identifier of the Event Hub partition. + * @param eventPosition The position within the partition where the consumer should begin reading events. + * @return A new {@link EventHubConsumer} that receives events from the partition at the given position. + * @throws NullPointerException If {@code eventPosition}, or {@code options} is {@code null}. + * @throws IllegalArgumentException If {@code consumerGroup} or {@code partitionId} is {@code null} or an empty + * string. + */ + public EventHubConsumer createConsumer(String consumerGroup, String partitionId, EventPosition eventPosition) { + return createConsumer(consumerGroup, partitionId, eventPosition, defaultConsumerOptions); + } + + /** + * Creates an Event Hub consumer responsible for reading {@link EventData} from a specific Event Hub partition, + * as a member of the configured consumer group, and begins reading events from the specified {@code eventPosition}. + * + *

+ * A consumer may be exclusive, which asserts ownership over the partition for the consumer group to ensure that + * only one consumer from that group is reading the from the partition. These exclusive consumers are sometimes + * referred to as "Epoch Consumers." + * + * A consumer may also be non-exclusive, allowing multiple consumers from the same consumer group to be actively + * reading events from the partition. These non-exclusive consumers are sometimes referred to as "Non-epoch + * Consumers." + * + * Designating a consumer as exclusive may be specified in the {@code options}, by setting + * {@link EventHubConsumerOptions#ownerLevel(Long)} to a non-null value. By default, consumers are + * created as non-exclusive. + *

+ * + * @param consumerGroup The name of the consumer group this consumer is associated with. Events are read in the + * context of this group. The name of the consumer group that is created by default is + * {@link #DEFAULT_CONSUMER_GROUP_NAME "$Default"}. + * @param partitionId The identifier of the Event Hub partition from which events will be received. + * @param eventPosition The position within the partition where the consumer should begin reading events. + * @param options The set of options to apply when creating the consumer. + * @return An new {@link EventHubConsumer} that receives events from the partition with all configured + * {@link EventHubConsumerOptions}. + * @throws NullPointerException If {@code eventPosition}, or {@code options} is {@code null}. + * @throws IllegalArgumentException If {@code consumerGroup} or {@code partitionId} is {@code null} or an empty + * string. + */ + public EventHubConsumer createConsumer(String consumerGroup, String partitionId, EventPosition eventPosition, + EventHubConsumerOptions options) { + Objects.requireNonNull(eventPosition); + Objects.requireNonNull(options); + + if (ImplUtils.isNullOrEmpty(consumerGroup)) { + throw new IllegalArgumentException("'consumerGroup' cannot be null or empty."); + } + if (ImplUtils.isNullOrEmpty(partitionId)) { + throw new IllegalArgumentException("'partitionId' cannot be null or empty."); + } + + final EventHubConsumerOptions clonedOptions = options.clone(); + if (clonedOptions.scheduler() == null) { + clonedOptions.scheduler(connectionOptions.scheduler()); + } + if (clonedOptions.retry() == null) { + clonedOptions.retry(connectionOptions.retryPolicy()); + } + + final String linkName = StringUtil.getRandomString("PR"); + final String entityPath = String.format(Locale.US, RECEIVER_ENTITY_PATH_FORMAT, eventHubPath, consumerGroup, partitionId); + + final Mono receiveLinkMono = connectionMono.flatMap(connection -> connection.createSession(entityPath)) + .cast(EventHubSession.class) + .flatMap(session -> { + return session.createConsumer(linkName, entityPath, eventPosition.getExpression(), connectionOptions.timeout(), + clonedOptions.retry(), options.ownerLevel(), options.identifier()); + }) + .cast(AmqpReceiveLink.class); + + return new EventHubConsumer(receiveLinkMono, clonedOptions, connectionOptions.timeout()); + } + + /** + * Closes and disposes of connection to service. Any {@link EventHubConsumer EventHubConsumers} and + * {@link EventHubProducer EventHubProducers} created with this instance will have their connections closed. + */ + @Override + public void close() { + if (hasConnection.getAndSet(false)) { + try { + final AmqpConnection connection = connectionMono.block(connectionOptions.timeout()); + if (connection != null) { + connection.close(); + } + } catch (IOException exception) { + throw new AmqpException(false, "Unable to close connection to service", exception, + new ErrorContext(connectionOptions.host())); + } + } + } + + private static class ResponseMapper implements AmqpResponseMapper { + @Override + public EventHubProperties toEventHubProperties(Map amqpBody) { + return new EventHubProperties( + (String) amqpBody.get(ManagementChannel.MANAGEMENT_ENTITY_NAME_KEY), + ((Date) amqpBody.get(ManagementChannel.MANAGEMENT_RESULT_CREATED_AT)).toInstant(), + (String[]) amqpBody.get(ManagementChannel.MANAGEMENT_RESULT_PARTITION_IDS)); + } + + @Override + public PartitionProperties toPartitionProperties(Map amqpBody) { + return new PartitionProperties( + (String) amqpBody.get(ManagementChannel.MANAGEMENT_ENTITY_NAME_KEY), + (String) amqpBody.get(ManagementChannel.MANAGEMENT_PARTITION_NAME_KEY), + (Long) amqpBody.get(ManagementChannel.MANAGEMENT_RESULT_BEGIN_SEQUENCE_NUMBER), + (Long) amqpBody.get(ManagementChannel.MANAGEMENT_RESULT_LAST_ENQUEUED_SEQUENCE_NUMBER), + (String) amqpBody.get(ManagementChannel.MANAGEMENT_RESULT_LAST_ENQUEUED_OFFSET), + ((Date) amqpBody.get(ManagementChannel.MANAGEMENT_RESULT_LAST_ENQUEUED_TIME_UTC)).toInstant(), + (Boolean) amqpBody.get(ManagementChannel.MANAGEMENT_RESULT_PARTITION_IS_EMPTY)); + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubClientBuilder.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubClientBuilder.java new file mode 100644 index 000000000000..61d5b94210ba --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubClientBuilder.java @@ -0,0 +1,301 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import com.azure.core.amqp.Retry; +import com.azure.core.amqp.TransportType; +import com.azure.core.credentials.TokenCredential; +import com.azure.core.exception.AzureException; +import com.azure.core.implementation.util.ImplUtils; +import com.azure.core.util.configuration.BaseConfigurations; +import com.azure.core.util.configuration.Configuration; +import com.azure.core.util.configuration.ConfigurationManager; +import com.azure.messaging.eventhubs.implementation.CBSAuthorizationType; +import com.azure.messaging.eventhubs.implementation.ClientConstants; +import com.azure.messaging.eventhubs.implementation.ConnectionOptions; +import com.azure.messaging.eventhubs.implementation.ConnectionStringProperties; +import com.azure.messaging.eventhubs.implementation.ReactorHandlerProvider; +import com.azure.messaging.eventhubs.implementation.ReactorProvider; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.Locale; +import java.util.Objects; + +/** + * Builder to create an {@link EventHubClient}. + */ +public class EventHubClientBuilder { + + private static final String AZURE_EVENT_HUBS_CONNECTION_STRING = "AZURE_EVENT_HUBS_CONNECTION_STRING"; + + private TokenCredential credentials; + private Configuration configuration; + private Duration timeout; + private ProxyConfiguration proxyConfiguration; + private Retry retry; + private Scheduler scheduler; + private TransportType transport; + private String host; + private String eventHubPath; + + /** + * Creates a new instance with the default transport {@link TransportType#AMQP}. + */ + public EventHubClientBuilder() { + transport = TransportType.AMQP; + } + + /** + * Sets the credential information given a connection string to the Event Hub instance. + * + *

+ * If the connection string is copied from the Event Hubs namespace, it will likely not contain the path to the + * desired Event Hub, which is needed. In this case, the path can be added manually by adding + * {@literal "EntityPath=EVENT_HUB_NAME"} to the end of the connection string. For example, + * "EntityPath=telemetry-hub". + *

+ * + *

+ * If you have defined a shared access policy directly on the Event Hub itself, then copying the connection string + * from that Event Hub will result in a connection string that contains the path. + *

+ * + * @param connectionString The connection string to use for connecting to the Event Hub instance. It is expected + * that the Event Hub path and the shared access key properties are contained in this connection string. + * @return The updated EventHubClientBuilder object. + * @throws IllegalArgumentException if {@code connectionString} is null or empty. Or, the {@code connectionString} + * does not contain the "EntityPath" key, which is the name of the Event Hub instance. + * @throws AzureException If the shared access signature token credential could not be created using the connection + * string. + */ + public EventHubClientBuilder connectionString(String connectionString) { + final ConnectionStringProperties properties = new ConnectionStringProperties(connectionString); + final TokenCredential tokenCredential; + try { + tokenCredential = new EventHubSharedAccessKeyCredential(properties.sharedAccessKeyName(), + properties.sharedAccessKey(), ClientConstants.TOKEN_VALIDITY); + } catch (InvalidKeyException | NoSuchAlgorithmException e) { + throw new AzureException("Could not create the EventHubSharedAccessKeyCredential.", e); + } + + return credential(properties.endpoint().getHost(), properties.eventHubPath(), tokenCredential); + } + + /** + * Sets the credential information given a connection string to the Event Hubs namespace and a path to a specific + * Event Hub instance. + * + * @param connectionString The connection string to use for connecting to the Event Hubs namespace; it is expected + * that the shared access key properties are contained in this connection string, but not the Event Hub path. + * @param eventHubPath The path of the specific Event Hub to connect the client to. + * @return The updated EventHubClientBuilder object. + * @throws IllegalArgumentException if {@code connectionString} or {@code eventHubPath} is null or empty. Or, if the + * {@code connectionString} contains the Event Hub path. + * @throws AzureException If the shared access signature token credential could not be created using the connection + * string. + */ + public EventHubClientBuilder connectionString(String connectionString, String eventHubPath) { + if (ImplUtils.isNullOrEmpty(eventHubPath)) { + throw new IllegalArgumentException("'eventHubPath' cannot be null or empty"); + } + + final ConnectionStringProperties properties = new ConnectionStringProperties(connectionString); + final TokenCredential tokenCredential; + try { + tokenCredential = new EventHubSharedAccessKeyCredential(properties.sharedAccessKeyName(), + properties.sharedAccessKey(), ClientConstants.TOKEN_VALIDITY); + } catch (InvalidKeyException | NoSuchAlgorithmException e) { + throw new AzureException("Could not create the EventHubSharedAccessKeyCredential.", e); + } + + if (!ImplUtils.isNullOrEmpty(properties.eventHubPath())) { + throw new IllegalArgumentException(String.format(Locale.US, + "'connectionString' contains an Event Hub path [%s]. Please use the" + + " credentials(String connectionString) overload. Or supply a 'connectionString' without" + + " 'EntityPath' in it.", properties.eventHubPath())); + } + + return credential(properties.endpoint().getHost(), eventHubPath, tokenCredential); + } + + /** + * Sets the configuration store that is used during construction of the service client. + * + * The default configuration store is a clone of the {@link ConfigurationManager#getConfiguration() global + * configuration store}, use {@link Configuration#NONE} to bypass using configuration settings during construction. + * + * @param configuration The configuration store used to + * @return The updated EventHubClientBuilder object. + */ + public EventHubClientBuilder configuration(Configuration configuration) { + this.configuration = configuration; + return this; + } + + /** + * Sets the credential information for which Event Hub instance to connect to, and how to authorize against it. + * + * @param host The fully qualified host name for the Event Hubs namespace. This is likely to be similar to + * {@literal "{your-namespace}.servicebus.windows.net}". + * @param eventHubPath The path of the specific Event Hub to connect the client to. + * @param credential The token credential to use for authorization. Access controls may be specified by the Event + * Hubs namespace or the requested Event Hub, depending on Azure configuration. + * @return The updated EventHubClientBuilder object. + * @throws IllegalArgumentException if {@code host} or {@code eventHubPath} is null or empty. + * @throws NullPointerException if {@code credentials} is null. + */ + public EventHubClientBuilder credential(String host, String eventHubPath, TokenCredential credential) { + if (ImplUtils.isNullOrEmpty(host)) { + throw new IllegalArgumentException("'host' cannot be null or empty"); + } + if (ImplUtils.isNullOrEmpty(eventHubPath)) { + throw new IllegalArgumentException("'eventHubPath' cannot be null or empty."); + } + + Objects.requireNonNull(credential); + + this.host = host; + this.credentials = credential; + this.eventHubPath = eventHubPath; + return this; + } + + /** + * Sets the proxy configuration for EventHubClient. + * + * @param proxyConfiguration The proxy configuration to use. + * @return The updated EventHubClientBuilder object. + */ + public EventHubClientBuilder proxyConfiguration(ProxyConfiguration proxyConfiguration) { + this.proxyConfiguration = proxyConfiguration; + return this; + } + + /** + * Sets the scheduler for operations such as connecting to and receiving or sending data to Event Hubs. If none is + * specified, an elastic pool is used. + * + * @param scheduler The scheduler for operations such as connecting to and receiving or sending data to Event Hubs. + * @return The updated EventHubClientBuilder object. + */ + public EventHubClientBuilder scheduler(Scheduler scheduler) { + this.scheduler = scheduler; + return this; + } + + /** + * Sets the transport type by which all the communication with Azure Event Hubs occurs. + * Default value is {@link TransportType#AMQP}. + * + * @param transport The transport type to use. + * @return The updated EventHubClientBuilder object. + */ + public EventHubClientBuilder transportType(TransportType transport) { + this.transport = transport; + return this; + } + + /** + * Sets the timeout for each connection, link, and session. + * + * @param timeout Duration for timeout. + * @return The updated EventHubClientBuilder object. + */ + public EventHubClientBuilder timeout(Duration timeout) { + this.timeout = timeout; + return this; + } + + /** + * Sets the retry policy for EventHubClient. + * + * @param retry The retry policy to use. + * @return The updated EventHubClientBuilder object. + */ + public EventHubClientBuilder retry(Retry retry) { + this.retry = retry; + return this; + } + + /** + * Creates a new {@link EventHubClient} based on the configuration set in this builder. + * Use the default not null values if the Connection parameters are not provided. + * + * @return A new {@link EventHubClient} instance. + * @throws IllegalArgumentException if the credentials have not been set using either + * {@link #connectionString(String)} or {@link #credential(String, String, TokenCredential)}. + */ + public EventHubClient build() { + configuration = configuration == null ? ConfigurationManager.getConfiguration().clone() : configuration; + + if (credentials == null) { + final String connectionString = configuration.get(AZURE_EVENT_HUBS_CONNECTION_STRING); + + if (ImplUtils.isNullOrEmpty(connectionString)) { + throw new IllegalArgumentException("Credentials have not been set using 'EventHubClientBuilder.credentials(String)'" + + "EventHubClientBuilder.credentials(String, String, TokenCredential). And the connection string is" + + "not set in the '" + AZURE_EVENT_HUBS_CONNECTION_STRING + "' environment variable."); + } + + connectionString(connectionString); + } + + if (timeout == null) { + timeout = ClientConstants.OPERATION_TIMEOUT; + } + + if (retry == null) { + retry = Retry.getDefaultRetry(); + } + + if (proxyConfiguration == null) { + proxyConfiguration = constructDefaultProxyConfiguration(configuration); + } + + if (scheduler == null) { + scheduler = Schedulers.elastic(); + } + + final ReactorProvider provider = new ReactorProvider(); + final ReactorHandlerProvider handlerProvider = new ReactorHandlerProvider(provider); + final CBSAuthorizationType authorizationType = credentials instanceof EventHubSharedAccessKeyCredential + ? CBSAuthorizationType.SHARED_ACCESS_SIGNATURE + : CBSAuthorizationType.JSON_WEB_TOKEN; + final ConnectionOptions parameters = new ConnectionOptions(host, eventHubPath, credentials, + authorizationType, timeout, transport, retry, proxyConfiguration, scheduler); + + return new EventHubClient(parameters, provider, handlerProvider); + } + + private ProxyConfiguration constructDefaultProxyConfiguration(Configuration configuration) { + ProxyAuthenticationType authentication = ProxyAuthenticationType.NONE; + if (proxyConfiguration != null) { + authentication = proxyConfiguration.authentication(); + } + + String proxyAddress = configuration.get(BaseConfigurations.HTTP_PROXY); + Proxy proxy = null; + if (proxyAddress != null) { + final String[] hostPort = proxyAddress.split(":"); + if (hostPort.length < 2) { + throw new IllegalArgumentException("HTTP_PROXY cannot be parsed into a proxy"); + } + + final String host = hostPort[0]; + final int port = Integer.parseInt(hostPort[1]); + proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(host, port)); + } + + final String username = configuration.get(ProxyConfiguration.PROXY_USERNAME); + final String password = configuration.get(ProxyConfiguration.PROXY_PASSWORD); + + return new ProxyConfiguration(authentication, proxy, username, password); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubConsumer.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubConsumer.java new file mode 100644 index 000000000000..63412c55ea16 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubConsumer.java @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import com.azure.core.util.logging.ClientLogger; +import com.azure.messaging.eventhubs.implementation.AmqpReceiveLink; +import reactor.core.publisher.EmitterProcessor; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.io.Closeable; +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; + +/** + * This is a logical representation of receiving from an Event Hub partition. + * + *

+ * A {@link EventHubConsumer#receive()} is tied to a Event Hub partitionId + consumer group combination. + * + *

    + *
  • If {@link EventHubConsumer} is created where {@link EventHubConsumerOptions#ownerLevel()} has a + * value, then Event Hubs service will guarantee only one active receiver exists per partitionId and consumer group + * combination. This is the recommended approach to create a {@link EventHubConsumer}.
  • + *
  • Multiple consumers per partitionId and consumer group combination can be created by not setting + * {@link EventHubConsumerOptions#ownerLevel()} when creating receivers.
  • + *
+ * + * @see EventHubClient#createConsumer(String, String, EventPosition) + * @see EventHubClient#createConsumer(String, String, EventPosition, EventHubConsumerOptions) + */ +public class EventHubConsumer implements Closeable { + private static final AtomicReferenceFieldUpdater RECEIVE_LINK_FIELD_UPDATER = + AtomicReferenceFieldUpdater.newUpdater(EventHubConsumer.class, AmqpReceiveLink.class, "receiveLink"); + + private final Mono receiveLinkMono; + private final Duration operationTimeout; + private final AtomicInteger creditsToRequest = new AtomicInteger(1); + private final AtomicBoolean isDisposed = new AtomicBoolean(); + private final ClientLogger logger = new ClientLogger(EventHubConsumer.class); + private final EmitterProcessor emitterProcessor; + private final Flux messageFlux; + + private volatile AmqpReceiveLink receiveLink; + + EventHubConsumer(Mono receiveLinkMono, EventHubConsumerOptions options, Duration operationTimeout) { + this.receiveLinkMono = receiveLinkMono; + this.emitterProcessor = EmitterProcessor.create(options.prefetchCount(), false); + this.operationTimeout = operationTimeout; + + // Caching the created link so we don't invoke another link creation. + this.messageFlux = receiveLinkMono.flatMapMany(link -> { + if (RECEIVE_LINK_FIELD_UPDATER.compareAndSet(this, null, link)) { + logger.asInfo().log("Created AMQP receive link. Initialising prefetch credit: {}", options.prefetchCount()); + link.addCredits(options.prefetchCount()); + + link.setEmptyCreditListener(() -> { + if (emitterProcessor.hasDownstreams()) { + return creditsToRequest.get(); + } else { + logger.asVerbose().log("Emitter has no downstream subscribers. Not adding credits."); + return 0; + } + }); + } + + return link.receive().map(EventData::new); + }).subscribeWith(emitterProcessor); + + emitterProcessor.doOnSubscribe(subscription -> { + if (receiveLink.getCredits() == 0) { + logger.asInfo().log("Subscription received and there are no remaining credits on the link. Adding more."); + receiveLink.addCredits(creditsToRequest.get()); + } + }).doOnRequest(request -> { + logger.asInfo().log("Back pressure requested. Old value: {}. New value: {}", creditsToRequest.get(), request); + creditsToRequest.set((int) request); + }); + } + + /** + * Disposes of the consumer by closing the underlying connection to the service. + * + * @throws IOException if the underlying transport and its resources could not be disposed. + */ + @Override + public void close() throws IOException { + if (!isDisposed.getAndSet(true)) { + final AmqpReceiveLink receiveLink = RECEIVE_LINK_FIELD_UPDATER.getAndSet(this, null); + if (receiveLink != null) { + receiveLink.close(); + } + + emitterProcessor.dispose(); + } + } + + /** + * Begin receiving events until there are no longer any subscribers, or the parent + * {@link EventHubClient#close() EventHubClient.close()} is called. + * + * @return A stream of events for this receiver. + */ + public Flux receive() { + return messageFlux; + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubConsumerOptions.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubConsumerOptions.java new file mode 100644 index 000000000000..2e99fe896d28 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubConsumerOptions.java @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import com.azure.core.amqp.Retry; +import com.azure.core.implementation.util.ImplUtils; +import reactor.core.scheduler.Scheduler; + +import java.time.Duration; +import java.util.Locale; +import java.util.Optional; + +/** + * Options when receiving events from Event Hubs. + */ +public class EventHubConsumerOptions implements Cloneable { + + /** + * The maximum length, in characters, for the identifier assigned to an {@link EventHubConsumer}. + */ + public static final int MAXIMUM_IDENTIFIER_LENGTH = 64; + /** + * The minimum value allowed for the prefetch count of the consumer. + */ + public static final int MINIMUM_PREFETCH_COUNT = 1; + /** + * The maximum value allowed for the prefetch count of the consumer. + */ + public static final int MAXIMUM_PREFETCH_COUNT = 8000; + + // Default number of events to fetch when creating the consumer. + static final int DEFAULT_PREFETCH_COUNT = 500; + + private String identifier; + private Long ownerLevel; + private Retry retryPolicy; + private Scheduler scheduler; + private int prefetchCount; + + /** + * Creates a new instance with the default prefetch amount. + */ + public EventHubConsumerOptions() { + this.prefetchCount = DEFAULT_PREFETCH_COUNT; + } + + /** + * Sets an optional text-based identifier label to assign to an event consumer. + * + * @param identifier The receiver name. + * @return The updated {@link EventHubConsumerOptions} object. + * @throws IllegalArgumentException if {@code identifier} is greater than {@link #MAXIMUM_IDENTIFIER_LENGTH}. + */ + public EventHubConsumerOptions identifier(String identifier) { + if (!ImplUtils.isNullOrEmpty(identifier) && identifier.length() > MAXIMUM_IDENTIFIER_LENGTH) { + throw new IllegalArgumentException(String.format(Locale.US, + "identifier length cannot exceed %s", MAXIMUM_IDENTIFIER_LENGTH)); + } + + this.identifier = identifier; + return this; + } + + + /** + * Sets the {@code ownerLevel} value on this consumer. When populated, the level indicates that a consumer is + * intended to be the only reader of events for the requested partition and an associated consumer group. To do so, + * this consumer will attempt to assert ownership over the partition; in the case where more than one exclusive + * consumer attempts to assert ownership for the same partition/consumer group pair, the one having a larger + * {@link EventHubConsumerOptions#ownerLevel()} value will "win". + * + *

+ * When an exclusive consumer is used, those consumers which are not exclusive or which have a lower priority will + * either not be allowed to be created, if they already exist, will encounter an exception during the next attempted + * operation. + *

+ * + * @param priority The priority associated with an exclusive consumer; for a non-exclusive consumer, this value + * should be {@code null}. + * @return The updated {@link EventHubConsumerOptions} object. + * @throws IllegalArgumentException if {@code priority} is not {@code null} and is less than 0. + */ + public EventHubConsumerOptions ownerLevel(Long priority) { + if (priority != null && priority < 0) { + throw new IllegalArgumentException("'priority' cannot be a negative value. Please specify a zero or positive long value."); + } + + this.ownerLevel = priority; + return this; + } + + /** + * Sets the retry policy used to govern retry attempts for receiving events. If not specified, the retry policy + * configured on the associated {@link EventHubClient} is used. + * + * @param retry The retry policy to use when receiving events. + * @return The updated {@link EventHubConsumerOptions} object. + */ + public EventHubConsumerOptions retry(Retry retry) { + this.retryPolicy = retry; + return this; + } + + /** + * Sets the count used by the receiver to control the number of events this receiver will actively receive and queue + * locally without regard to whether a receive operation is currently active. + * + * @param prefetchCount The amount of events to queue locally. + * @return The updated {@link EventHubConsumerOptions} object. + * @throws IllegalArgumentException if {@code prefetchCount} is less than the {@link #MINIMUM_PREFETCH_COUNT} or + * greater than {@link #MAXIMUM_PREFETCH_COUNT}. + */ + public EventHubConsumerOptions prefetchCount(int prefetchCount) { + if (prefetchCount < MINIMUM_PREFETCH_COUNT) { + throw new IllegalArgumentException(String.format(Locale.US, + "PrefetchCount, '%s' has to be above %s", prefetchCount, MINIMUM_PREFETCH_COUNT)); + } + + if (prefetchCount > MAXIMUM_PREFETCH_COUNT) { + throw new IllegalArgumentException(String.format(Locale.US, + "PrefetchCount, '%s', has to be below %s", prefetchCount, MAXIMUM_PREFETCH_COUNT)); + } + + this.prefetchCount = prefetchCount; + return this; + } + + /** + * Sets the scheduler for receiving events from Event Hubs. If not specified, the scheduler configured with the + * associated {@link EventHubClient} is used. + * + * @param scheduler The scheduler for receiving events. + * @return The updated EventHubClientBuilder object. + */ + public EventHubConsumerOptions scheduler(Scheduler scheduler) { + this.scheduler = scheduler; + return this; + } + + /** + * Gets the optional text-based identifier label to assign to an event receiver. + * The identifier is used for informational purposes only. If not specified, the receiver will have no assigned + * identifier label. + * + * @return The identifier of the receiver. + */ + public String identifier() { + return identifier; + } + + /** + * Gets the retry policy when receiving events. If not specified, the retry policy configured on the associated + * {@link EventHubClient} is used. + * + * @return The retry policy when receiving events. + */ + public Retry retry() { + return retryPolicy; + } + + /** + * Gets the owner level for this consumer. If {@link Optional#isPresent()} is {@code false}, then this is not an + * exclusive consumer. Otherwise, it is an exclusive consumer, and there can only be one active consumer for each + * partition and consumer group combination. + * + * @return An optional owner level for this consumer. + */ + public Long ownerLevel() { + return ownerLevel; + } + + /** + * Gets the scheduler for reading events from Event Hubs. If not specified, the scheduler configured with the + * associated {@link EventHubClient} is used. + * + * @return The scheduler for reading events. + */ + public Scheduler scheduler() { + return scheduler; + } + + /** + * Gets the count used by the consumer to control the number of events this receiver will actively receive and queue + * locally without regard to whether a receive operation is currently active. + * + * @return The prefetch count receiver will receive and queue locally regardless of whether or not a receive + * operation is active. + */ + public int prefetchCount() { + return prefetchCount; + } + + /** + * Creates a shallow clone of this instance. + * + * The object is cloned, but this instance's fields are not cloned. {@link Duration} and {@link String} are + * immutable objects and are not an issue. The implementation of {@link Retry} could be mutable. In addition, the + * {@link #scheduler()} set is not cloned. + * + * @return A shallow clone of this object. + */ + public EventHubConsumerOptions clone() { + EventHubConsumerOptions clone; + try { + clone = (EventHubConsumerOptions) super.clone(); + } catch (CloneNotSupportedException e) { + clone = new EventHubConsumerOptions(); + } + + clone.scheduler(this.scheduler()); + clone.identifier(this.identifier()); + clone.prefetchCount(this.prefetchCount()); + clone.retry(this.retry()); + clone.ownerLevel(this.ownerLevel()); + + return clone; + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubProducer.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubProducer.java new file mode 100644 index 000000000000..3f9d221077cd --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubProducer.java @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import com.azure.core.amqp.exception.AmqpException; +import com.azure.core.amqp.exception.ErrorCondition; +import com.azure.core.implementation.util.ImplUtils; +import com.azure.core.util.logging.ClientLogger; +import com.azure.messaging.eventhubs.implementation.AmqpSendLink; +import com.azure.messaging.eventhubs.implementation.ErrorContextProvider; +import com.azure.messaging.eventhubs.implementation.EventDataUtil; +import org.apache.qpid.proton.message.Message; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.io.Closeable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; +import java.util.function.BinaryOperator; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collector; + +/** + * A producer responsible for transmitting {@link EventData} to a specific Event Hub, grouped together in batches. + * Depending on the options specified at creation, the producer may be created to allow event data to be automatically + * routed to an available partition or specific to a partition. + * + *

+ * Allowing automatic routing of partitions is recommended when: + *

    + *
  • The sending of events needs to be highly available.
  • + *
  • The event data should be evenly distributed among all available partitions.
  • + *
+ *

+ * + *

+ * If no partition is specified, the following rules are used for automatically selecting one: + *

    + *
  1. Distribute the events equally amongst all available partitions using a round-robin approach.
  2. + *
  3. If a partition becomes unavailable, the Event Hubs service will automatically detect it and forward the + * message to another available partition.
  4. + *
+ *

+ * + * @see EventHubClient#createProducer() + */ +public class EventHubProducer implements Closeable { + /** + * The default maximum allowable size, in bytes, for a batch to be sent. + */ + public static final int MAX_MESSAGE_LENGTH_BYTES = 256 * 1024; + + private static final int MAX_PARTITION_KEY_LENGTH = 128; + private static final SendOptions DEFAULT_SEND_OPTIONS = new SendOptions(); + + private final ClientLogger logger = new ClientLogger(EventHubProducer.class); + private final AtomicBoolean isDisposed = new AtomicBoolean(); + private final EventHubProducerOptions senderOptions; + private final Mono sendLinkMono; + private final boolean isPartitionSender; + + /** + * Creates a new instance of this {@link EventHubProducer} that sends messages to + * {@link EventHubProducerOptions#partitionId() options.partitionId()} if it is not {@code null} or an empty string, + * otherwise, allows the service to load balance the messages amongst available partitions. + */ + EventHubProducer(Mono amqpSendLinkMono, EventHubProducerOptions options) { + // Caching the created link so we don't invoke another link creation. + this.sendLinkMono = amqpSendLinkMono.cache(); + this.senderOptions = options; + this.isPartitionSender = !ImplUtils.isNullOrEmpty(options.partitionId()); + } + + /** + * Sends a single event to the associated Event Hub. If the size of the single event exceeds the maximum size + * allowed, an exception will be triggered and the send will fail. + * + * For more information regarding the maximum event size allowed, see + * Azure Event Hubs Quotas and Limits. + * + * @param event Event to send to the service. + * @return A {@link Mono} that completes when the event is pushed to the service. + */ + public Mono send(EventData event) { + Objects.requireNonNull(event); + + return send(Flux.just(event)); + } + + /** + * Sends a single event to the associated Event Hub with the send options. If the size of the single event exceeds + * the maximum size allowed, an exception will be triggered and the send will fail. + * + * For more information regarding the maximum event size allowed, see + * Azure Event Hubs Quotas and Limits. + * + * @param event Event to send to the service. + * @param options The set of options to consider when sending this event. + * @return A {@link Mono} that completes when the event is pushed to the service. + */ + public Mono send(EventData event, SendOptions options) { + Objects.requireNonNull(event); + Objects.requireNonNull(options); + + return send(Flux.just(event), options); + } + + /** + * Sends a set of events to the associated Event Hub using a batched approach. If the size of events exceed the + * maximum size of a single batch, an exception will be triggered and the send will fail. By default, the message + * size is the max amount allowed on the link. + * + * @param events Events to send to the service. + * @return A {@link Mono} that completes when all events are pushed to the service. + */ + public Mono send(Iterable events) { + Objects.requireNonNull(events); + + return send(Flux.fromIterable(events)); + } + + /** + * Sends a set of events to the associated Event Hub using a batched approach. If the size of events exceed the + * maximum size of a single batch, an exception will be triggered and the send will fail. By default, the message + * size is the max amount allowed on the link. + * + * @param events Events to send to the service. + * @param options The set of options to consider when sending this batch. + * @return A {@link Mono} that completes when all events are pushed to the service. + */ + public Mono send(Iterable events, SendOptions options) { + Objects.requireNonNull(events); + + return send(Flux.fromIterable(events), options); + } + + /** + * Sends a set of events to the associated Event Hub using a batched approach. If the size of events exceed the + * maximum size of a single batch, an exception will be triggered and the send will fail. By default, the message + * size is the max amount allowed on the link. + * + * @param events Events to send to the service. + * @return A {@link Mono} that completes when all events are pushed to the service. + */ + public Mono send(Publisher events) { + Objects.requireNonNull(events); + + return sendInternal(Flux.from(events), DEFAULT_SEND_OPTIONS); + } + + /** + * Sends a set of events to the associated Event Hub using a batched approach. If the size of events exceed the + * maximum size of a single batch, an exception will be triggered and the send will fail. By default, the message + * size is the max amount allowed on the link. + * + * @param events Events to send to the service. + * @param options The set of options to consider when sending this batch. + * @return A {@link Mono} that completes when all events are pushed to the service. + */ + public Mono send(Publisher events, SendOptions options) { + Objects.requireNonNull(events); + Objects.requireNonNull(options); + + return sendInternal(Flux.from(events), options); + } + + private Mono sendInternal(Flux events, SendOptions options) { + final String partitionKey = options.partitionKey(); + + if (!ImplUtils.isNullOrEmpty(partitionKey)) { + if (isPartitionSender) { + throw new IllegalArgumentException(String.format(Locale.US, + "SendOptions.partitionKey() cannot be set when an EventSender is " + + "created with EventSenderOptions.partitionId() set. This EventSender can only send events to partition '%s'.", + senderOptions.partitionId())); + } else if (partitionKey.length() > MAX_PARTITION_KEY_LENGTH) { + throw new IllegalArgumentException(String.format(Locale.US, + "PartitionKey '%s' exceeds the maximum allowed length: '%s'.", partitionKey, MAX_PARTITION_KEY_LENGTH)); + } + } + + //TODO (conniey): When we implement partial success, update the maximum number of batches or remove it completely. + return sendLinkMono.flatMap(link -> { + return events.collect(new EventDataCollector(options, 1, link::getErrorContext)) + .flatMap(list -> send(Flux.fromIterable(list))); + }); + } + + private Mono send(Flux eventBatches) { + return eventBatches + .flatMap(this::send) + .then() + .doOnError(error -> { + logger.asError().log("Error sending batch.", error); + }); + } + + private Mono send(EventDataBatch batch) { + if (batch.getEvents().isEmpty()) { + logger.asInfo().log("Cannot send an EventBatch that is empty."); + return Mono.empty(); + } + + logger.asInfo().log("Sending with partitionKey[{}], batch size[{}]", batch.getPartitionKey(), batch.getSize()); + + final List messages = EventDataUtil.toAmqpMessage(batch.getPartitionKey(), batch.getEvents()); + + return sendLinkMono.flatMap(link -> messages.size() == 1 + ? link.send(messages.get(0)) + : link.send(messages)); + } + + /** + * Disposes of the {@link EventHubProducer} by closing the underlying connection to the service. + * + * @throws IOException if the underlying transport could not be closed and its resources could not be disposed. + */ + @Override + public void close() throws IOException { + if (!isDisposed.getAndSet(true)) { + final AmqpSendLink block = sendLinkMono.block(senderOptions.timeout()); + if (block != null) { + block.close(); + } + } + } + + /** + * Collects EventData into EventDataBatch to send to Event Hubs. If {@code maxNumberOfBatches} is {@code null} then + * it'll collect as many batches as possible. Otherwise, if there are more events than can fit into + * {@code maxNumberOfBatches}, then the collector throws a {@link AmqpException} with + * {@link ErrorCondition#LINK_PAYLOAD_SIZE_EXCEEDED}. + */ + private static class EventDataCollector implements Collector, List> { + private final String partitionKey; + private final int maxMessageSize; + private final Integer maxNumberOfBatches; + private final ErrorContextProvider contextProvider; + + private volatile EventDataBatch currentBatch; + + EventDataCollector(SendOptions options, Integer maxNumberOfBatches, ErrorContextProvider contextProvider) { + this.maxNumberOfBatches = maxNumberOfBatches; + this.maxMessageSize = options.maximumSizeInBytes(); + this.partitionKey = options.partitionKey(); + this.contextProvider = contextProvider; + + currentBatch = new EventDataBatch(options.maximumSizeInBytes(), options.partitionKey(), contextProvider); + } + + @Override + public Supplier> supplier() { + return ArrayList::new; + } + + @Override + public BiConsumer, EventData> accumulator() { + return (list, event) -> { + EventDataBatch batch = currentBatch; + if (batch.tryAdd(event)) { + return; + } + + if (maxNumberOfBatches != null && list.size() == maxNumberOfBatches) { + final String message = String.format(Locale.US, + "EventData does not fit into maximum number of batches. '%s'", maxNumberOfBatches); + + throw new AmqpException(false, ErrorCondition.LINK_PAYLOAD_SIZE_EXCEEDED, message, contextProvider.getErrorContext()); + } + + currentBatch = new EventDataBatch(maxMessageSize, partitionKey, contextProvider); + currentBatch.tryAdd(event); + list.add(batch); + }; + } + + @Override + public BinaryOperator> combiner() { + return (existing, another) -> { + existing.addAll(another); + return existing; + }; + } + + @Override + public Function, List> finisher() { + return list -> { + EventDataBatch batch = currentBatch; + currentBatch = null; + + if (batch != null) { + list.add(batch); + } + + return list; + }; + } + + @Override + public Set characteristics() { + return Collections.emptySet(); + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubProducerOptions.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubProducerOptions.java new file mode 100644 index 000000000000..4993e27b4ba6 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubProducerOptions.java @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import com.azure.core.amqp.Retry; + +import java.time.Duration; + +/** + * The set of options that can be specified when creating an {@link EventHubProducer} to configure its behavior. + */ +public class EventHubProducerOptions implements Cloneable { + private String partitionId; + private Retry retry; + private Duration timeout; + + /** + * Sets the identifier of the Event Hub partition that the {@link EventHubProducer} will be bound to, limiting it to + * sending events to only that partition. + * + * If the identifier is not specified, the Event Hubs service will be responsible for routing events that are sent + * to an available partition. + * + * @param partitionId The identifier of the Event Hub partition that the {@link EventHubProducer} will be bound to. + * If the producer wishes the events to be automatically to partitions, {@code null}; otherwise, the + * identifier of the desired partition. + * @return The updated {@link EventHubProducerOptions} object. + */ + public EventHubProducerOptions partitionId(String partitionId) { + this.partitionId = partitionId; + return this; + } + + /** + * Sets the retry policy used to govern retry attempts when an issue is encountered while sending. + * + * @param retry The retry policy used to govern retry attempts when an issue is encountered while sending. + * @return The updated SenderOptions object. + */ + public EventHubProducerOptions retry(Retry retry) { + this.retry = retry; + return this; + } + + /** + * Sets the default timeout to apply when sending events. If the timeout is reached, before the Event Hub + * acknowledges receipt of the event data being sent, the attempt will be considered failed and will be retried. + * + * @param timeout The timeout to apply when sending events. + * @return The updated {@link EventHubProducerOptions} object. + */ + public EventHubProducerOptions timeout(Duration timeout) { + this.timeout = timeout; + return this; + } + + /** + * Gets the retry policy used to govern retry attempts when an issue is encountered while sending. + * + * @return the retry policy used to govern retry attempts when an issue is encountered while sending. If + * {@code null}, then the retry policy configured on the associated {@link EventHubClient} is used. + */ + public Retry retry() { + return retry; + } + + /** + * Gets the identifier of the Event Hub partition that the {@link EventHubProducer} will be bound to, limiting it to + * sending events to only that partition. + * + * If the identifier is not specified, the Event Hubs service will be responsible for routing events that are sent + * to an available partition. + * + * @return the identifier of the Event Hub partition that the {@link EventHubProducer} will be bound to. + */ + public String partitionId() { + return partitionId; + } + + /** + * Gets the default timeout when sending events. + * + * @return The default timeout when sending events. + */ + public Duration timeout() { + return timeout; + } + + /** + * Creates a shallow clone of this instance. + * + * The object is cloned, but the objects {@link #retry()}, {@link #timeout()} and {@link #partitionId()} are not + * cloned. {@link Duration} and {@link String} are immutable objects and are not an issue. However, the + * implementation of {@link Retry} could be mutable. + * + * @return A shallow clone of this object. + */ + public EventHubProducerOptions clone() { + EventHubProducerOptions clone; + try { + clone = (EventHubProducerOptions) super.clone(); + } catch (CloneNotSupportedException e) { + clone = new EventHubProducerOptions(); + } + + clone.partitionId(this.partitionId); + clone.retry(this.retry); + clone.timeout(this.timeout); + + return clone; + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubProperties.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubProperties.java new file mode 100644 index 000000000000..8e8060827733 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubProperties.java @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import java.time.Instant; +import java.util.Arrays; + +/** + * Holds information about Event Hubs which can come handy while performing data-plane operations like + * {@link EventHubClient#createConsumer(String, String, EventPosition)} and + * {@link EventHubClient#createConsumer(String, String, EventPosition, EventHubConsumerOptions)}. + */ +public final class EventHubProperties { + private final String path; + private final Instant createdAt; + private final String[] partitionIds; + + EventHubProperties( + final String path, + final Instant createdAt, + final String[] partitionIds) { + this.path = path; + this.createdAt = createdAt; + this.partitionIds = partitionIds != null + ? Arrays.copyOf(partitionIds, partitionIds.length) + : new String[0]; + } + + /** + * Gets the Event Hub name + * + * @return Name of the Event Hub. + */ + public String path() { + return path; + } + + /** + * Gets the instant, in UTC, at which Event Hub was created at. + * + * @return The instant, in UTC, at which the Event Hub was created. + */ + public Instant createdAt() { + return createdAt; + } + + /** + * Gets the list of partition identifiers of the Event Hub. + * + * @return The list of partition identifiers of the Event Hub. + */ + public String[] partitionIds() { + return partitionIds; + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubSharedAccessKeyCredential.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubSharedAccessKeyCredential.java new file mode 100644 index 000000000000..42222b70ba0f --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventHubSharedAccessKeyCredential.java @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import com.azure.core.credentials.AccessToken; +import com.azure.core.credentials.TokenCredential; +import com.azure.core.implementation.util.ImplUtils; +import reactor.core.publisher.Mono; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.Locale; +import java.util.Objects; + +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * Authorizes with Azure Event Hubs service using a shared access key from either an Event Hubs namespace or a specific + * Event Hub. + */ +public class EventHubSharedAccessKeyCredential implements TokenCredential { + private static final String SHARED_ACCESS_SIGNATURE_FORMAT = "SharedAccessSignature sr=%s&sig=%s&se=%s&skn=%s"; + private static final String HASH_ALGORITHM = "HMACSHA256"; + + private final String keyName; + private final Mac hmac; + private final Duration tokenValidity; + + /** + * Creates an instance that authorizes using the {@code keyName} and {@code sharedAccessKey}. The authorization + * lasts for a period of {@code tokenValidity} before another token must be requested. + * + * @param keyName Name of the shared access key policy. + * @param sharedAccessKey Value of the shared access key. + * @param tokenValidity The duration for which the shared access signature is valid. + * @throws IllegalArgumentException if {@code keyName}, {@code sharedAccessKey} is null or empty. Or the duration of + * {@code tokenValidity} is zero or a negative value. + * @throws NoSuchAlgorithmException If the hashing algorithm cannot be instantiated, which is used to generate the + * shared access signatures. + * @throws InvalidKeyException If the {@code sharedAccessKey} is an invalid value for the hashing algorithm. + * @throws NullPointerException if {@code tokenValidity} is null. + */ + public EventHubSharedAccessKeyCredential(String keyName, String sharedAccessKey, Duration tokenValidity) + throws NoSuchAlgorithmException, InvalidKeyException { + + if (ImplUtils.isNullOrEmpty(keyName)) { + throw new IllegalArgumentException("keyName cannot be null or empty"); + } + if (ImplUtils.isNullOrEmpty(sharedAccessKey)) { + throw new IllegalArgumentException("sharedAccessKey cannot be null or empty."); + } + + Objects.requireNonNull(tokenValidity); + if (tokenValidity.isZero() || tokenValidity.isNegative()) { + throw new IllegalArgumentException("tokenTimeToLive has to positive and in the order-of seconds"); + } + + this.keyName = keyName; + this.tokenValidity = tokenValidity; + + hmac = Mac.getInstance(HASH_ALGORITHM); + + final byte[] sasKeyBytes = sharedAccessKey.getBytes(UTF_8); + final SecretKeySpec finalKey = new SecretKeySpec(sasKeyBytes, HASH_ALGORITHM); + hmac.init(finalKey); + } + + /** + * Retrieves the token, given the audience/resources requested, for use in authorization against an Event Hubs + * namespace or a specific Event Hub instance. + * + * @param scopes The name of the resource or token audience to obtain a token for. + * @return A Mono that completes and returns the shared access signature. + * @throws IllegalArgumentException if {@code scopes} does not contain a single value, which is the token audience. + */ + @Override + public Mono getToken(String... scopes) { + if (scopes.length != 1) { + throw new IllegalArgumentException("'scopes' should only contain a single argument that is the token audience or resource name."); + } + + return Mono.fromCallable(() -> generateSharedAccessSignature(scopes[0])); + } + + private AccessToken generateSharedAccessSignature(final String resource) throws UnsupportedEncodingException { + if (ImplUtils.isNullOrEmpty(resource)) { + throw new IllegalArgumentException("resource cannot be empty"); + } + + final String utf8Encoding = UTF_8.name(); + final OffsetDateTime expiresOn = OffsetDateTime.now(ZoneOffset.UTC).plus(tokenValidity); + final String expiresOnEpochSeconds = Long.toString(expiresOn.toEpochSecond()); + final String audienceUri = URLEncoder.encode(resource, utf8Encoding); + final String secretToSign = audienceUri + "\n" + expiresOnEpochSeconds; + + final byte[] signatureBytes = hmac.doFinal(secretToSign.getBytes(utf8Encoding)); + final String signature = Base64.getEncoder().encodeToString(signatureBytes); + + final String token = String.format(Locale.US, SHARED_ACCESS_SIGNATURE_FORMAT, + audienceUri, + URLEncoder.encode(signature, utf8Encoding), + URLEncoder.encode(expiresOnEpochSeconds, utf8Encoding), + URLEncoder.encode(keyName, utf8Encoding)); + + return new AccessToken(token, expiresOn); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventPosition.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventPosition.java new file mode 100644 index 000000000000..048f1bef6e25 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/EventPosition.java @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import com.azure.core.util.logging.ClientLogger; +import com.azure.messaging.eventhubs.implementation.AmqpConstants; + +import java.time.Instant; +import java.util.Locale; +import java.util.Objects; + +import static com.azure.core.amqp.MessageConstant.ENQUEUED_TIME_UTC_ANNOTATION_NAME; +import static com.azure.core.amqp.MessageConstant.OFFSET_ANNOTATION_NAME; +import static com.azure.core.amqp.MessageConstant.SEQUENCE_NUMBER_ANNOTATION_NAME; + +/** + * Defines a position of an {@link EventData} in the event hub partition. + * The position can be an Offset, Sequence Number, or EnqueuedTime. + */ +public final class EventPosition { + /** + * This is a constant defined to represent the start of a partition stream in EventHub. + */ + private static final String START_OF_STREAM = "-1"; + + /** + * This is a constant defined to represent the current end of a partition stream in EventHub. + * This can be used as an offset argument in receiver creation to start receiving from the latest + * event, instead of a specific offset or point in time. + */ + private static final String END_OF_STREAM = "@latest"; + + private static final EventPosition EARLIEST = fromOffset(START_OF_STREAM, false); + private static final EventPosition LATEST = fromOffset(END_OF_STREAM, false); + + private final ClientLogger logger = new ClientLogger(EventPosition.class); + private final boolean isInclusive; + private String offset; + private Long sequenceNumber; + private Instant enqueuedDateTime; + + private EventPosition(boolean isInclusive) { + this.isInclusive = isInclusive; + } + + /** + * Returns the position for the start of a stream. Provide this position in receiver creation + * to start receiving from the first available (earliest) event in the partition. + * + * @return An {@link EventPosition} set to the start of an Event Hubs stream. + */ + public static EventPosition earliest() { + return EARLIEST; + } + + /** + * Corresponds to the end of the partition, where no more events are currently enqueued. Use this position to begin + * receiving from the next event to be enqueued in the partition after an {@link EventHubConsumer} is created with this + * position. + * + * @return An {@link EventPosition} set to the end of an Event Hubs stream and listens for new events. + */ + public static EventPosition latest() { + return LATEST; + } + + /** + * Creates a position at the given {@link Instant}. Corresponds to a specific instance within a partition to begin + * looking for an event. The event enqueued after the requested {@code enqueuedDateTime} becomes the current + * position. + * + * @param enqueuedDateTime The instant, in UTC, from which the next available event should be chosen. + * @return An {@link EventPosition} object. + */ + public static EventPosition fromEnqueuedTime(Instant enqueuedDateTime) { + EventPosition position = new EventPosition(false); + position.enqueuedDateTime = enqueuedDateTime; + return position; + } + + /** + * Corresponds to the event in the partition at the provided offset, inclusive of that event. + * + * @param offset The offset of the event within that partition. + * @return An {@link EventPosition} object. + */ + public static EventPosition fromOffset(String offset) { + return fromOffset(offset, true); + } + + /** + * Creates a position to an event in the partition at the provided offset. If {@code isInclusive} is true, the + * event with the same offset is returned. Otherwise, the next event is received. + * + * @param offset The offset of an event with respect to its relative position in the + * @param isInclusive If true, the event with the {@code offset} is included; otherwise, the next event will be + * received. + * @return An {@link EventPosition} object. + */ + public static EventPosition fromOffset(String offset, boolean isInclusive) { + Objects.requireNonNull(offset); + + EventPosition position = new EventPosition(isInclusive); + position.offset = offset; + return position; + } + + /** + * Creates a position at the given sequence number. The specified event will not be included. Instead, the next + * event is returned. + * + * @param sequenceNumber is the sequence number of the event. + * @return An {@link EventPosition} object. + */ + public static EventPosition fromSequenceNumber(long sequenceNumber) { + return fromSequenceNumber(sequenceNumber, false); + } + + /** + * Creates a position at the given sequence number. If {@code isInclusive} is true, the event with the same sequence + * number is returned. Otherwise, the next event in the sequence is received. + * + * @param sequenceNumber is the sequence number of the event. + * @param isInclusive If true, the event with the {@code sequenceNumber} is included; otherwise, the next event will + * be received. + * @return An {@link EventPosition} object. + */ + public static EventPosition fromSequenceNumber(long sequenceNumber, boolean isInclusive) { + EventPosition position = new EventPosition(isInclusive); + position.sequenceNumber = sequenceNumber; + return position; + } + + String getExpression() { + final String isInclusiveFlag = isInclusive ? "=" : ""; + + // order of preference + if (this.offset != null) { + return String.format(AmqpConstants.AMQP_ANNOTATION_FORMAT, OFFSET_ANNOTATION_NAME.getValue(), isInclusiveFlag, this.offset); + } + + if (this.sequenceNumber != null) { + return String.format(AmqpConstants.AMQP_ANNOTATION_FORMAT, SEQUENCE_NUMBER_ANNOTATION_NAME.getValue(), isInclusiveFlag, this.sequenceNumber); + } + + if (this.enqueuedDateTime != null) { + String ms; + try { + ms = Long.toString(this.enqueuedDateTime.toEpochMilli()); + } catch (ArithmeticException ex) { + ms = Long.toString(Long.MAX_VALUE); + logger.asWarning().log( + "Receiver not yet created, action[createReceiveLink], warning[starting receiver from epoch+Long.Max]"); + } + + return String.format(AmqpConstants.AMQP_ANNOTATION_FORMAT, ENQUEUED_TIME_UTC_ANNOTATION_NAME.getValue(), isInclusiveFlag, ms); + } + + throw new IllegalArgumentException("No starting position was set."); + } + + @Override + public String toString() { + return String.format(Locale.US, "offset[%s], sequenceNumber[%s], enqueuedTime[%s], isInclusive[%s]", + offset, sequenceNumber, + enqueuedDateTime != null ? enqueuedDateTime.toEpochMilli() : "null", + isInclusive); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof EventPosition)) { + return false; + } + + final EventPosition other = (EventPosition) obj; + + return Objects.equals(isInclusive, other.isInclusive) + && Objects.equals(offset, other.offset) + && Objects.equals(sequenceNumber, other.sequenceNumber) + && Objects.equals(enqueuedDateTime, other.enqueuedDateTime); + } + + @Override + public int hashCode() { + return Objects.hash(isInclusive, offset, sequenceNumber, enqueuedDateTime); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/PartitionProperties.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/PartitionProperties.java new file mode 100644 index 000000000000..f478690b2486 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/PartitionProperties.java @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import java.time.Instant; + +/** + * Contains runtime information about an Event Hub partition. + */ +public final class PartitionProperties { + private final String eventHubPath; + private final String id; + private final long beginningSequenceNumber; + private final long lastEnqueuedSequenceNumber; + private final String lastEnqueuedOffset; + private final Instant lastEnqueuedTime; + private final boolean isEmpty; + + PartitionProperties( + final String eventHubPath, + final String id, + final long beginningSequenceNumber, + final long lastEnqueuedSequenceNumber, + final String lastEnqueuedOffset, + final Instant lastEnqueuedTime, + final boolean isEmpty) { + this.eventHubPath = eventHubPath; + this.id = id; + this.beginningSequenceNumber = beginningSequenceNumber; + this.lastEnqueuedSequenceNumber = lastEnqueuedSequenceNumber; + this.lastEnqueuedOffset = lastEnqueuedOffset; + this.lastEnqueuedTime = lastEnqueuedTime; + this.isEmpty = isEmpty; + } + + /** + * Gets the Event Hub path for this partition. + * + * @return The Event Hub path for this partition. + */ + public String eventHubPath() { + return this.eventHubPath; + } + + /** + * Gets the identifier of the partition within the Event Hub. + * + * @return The identifier of the partition within the Event Hub. + */ + public String id() { + return this.id; + } + + /** + * Gets the starting sequence number of the partition's message stream. + * + * @return The starting sequence number of the partition's message stream. + */ + public long beginningSequenceNumber() { + return this.beginningSequenceNumber; + } + + /** + * Gets the last sequence number of the partition's message stream. + * + * @return the last sequence number of the partition's message stream. + */ + public long lastEnqueuedSequenceNumber() { + return this.lastEnqueuedSequenceNumber; + } + + /** + * Gets the offset of the last enqueued message in the partition's stream. + * + * @return the offset of the last enqueued message in the partition's stream. + */ + public String lastEnqueuedOffset() { + return this.lastEnqueuedOffset; + } + + /** + * Gets the instant, in UTC, of the last enqueued message in the partition's stream. + * + * @return the instant, in UTC, of the last enqueued message in the partition's stream. + */ + public Instant lastEnqueuedTime() { + return this.lastEnqueuedTime; + } + + /** + * Indicates whether or not there are events in the partition. + * + * @return true if there are no events, and false otherwise. + */ + public boolean isEmpty() { + return this.isEmpty; + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/ProxyAuthenticationType.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/ProxyAuthenticationType.java new file mode 100644 index 000000000000..9e3b77f46234 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/ProxyAuthenticationType.java @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +/** + * Supported methods of proxy authentication + */ +public enum ProxyAuthenticationType { + /** + * Proxy requires no authentication. Service calls will fail if proxy demands authentication. + */ + NONE, + /** + * Authenticates against proxy with basic authentication scheme. + */ + BASIC, + /** + * Authenticates against proxy with digest access authentication. + */ + DIGEST, +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/ProxyConfiguration.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/ProxyConfiguration.java new file mode 100644 index 000000000000..d3ba4ea04a2b --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/ProxyConfiguration.java @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import com.azure.core.util.logging.ClientLogger; + +import java.net.PasswordAuthentication; +import java.net.Proxy; +import java.util.Arrays; +import java.util.Objects; + +/** + * Properties for configuring proxies with Event Hubs. + */ +public class ProxyConfiguration implements AutoCloseable { + /** + * The configuration key for containing the username who authenticates with the proxy. + */ + public static final String PROXY_USERNAME = "PROXY_USERNAME"; + /** + * The configuration key for containing the password for the username who authenticates with the proxy. + */ + public static final String PROXY_PASSWORD = "PROXY_PASSWORD"; + + private final ClientLogger logger = new ClientLogger(ProxyConfiguration.class); + private final PasswordAuthentication credentials; + private final Proxy proxyAddress; + private final ProxyAuthenticationType authentication; + + /** + * Gets the system defaults for proxy configuration and authentication. + */ + public static final ProxyConfiguration SYSTEM_DEFAULTS = new ProxyConfiguration(); + + private ProxyConfiguration() { + this.credentials = null; + this.proxyAddress = null; + this.authentication = null; + } + + /** + * Creates a proxy configuration that uses the {@code proxyAddress} and authenticates with provided + * {@code username}, {@code password} and {@code authentication}. + * + * @param authentication Authentication method to preemptively use with proxy. + * @param proxyAddress Proxy to use. If {@code null} is passed in, then the system configured {@link java.net.Proxy} + * is used. + * @param username Optional. Username used to authenticate with proxy. If not specified, the system-wide + * {@link java.net.Authenticator} is used to fetch credentials. + * @param password Optional. Password used to authenticate with proxy. + * + * @throws NullPointerException if {@code authentication} is {@code null}. + * @throws IllegalArgumentException if {@code authentication} is {@link ProxyAuthenticationType#BASIC} or + * {@link ProxyAuthenticationType#DIGEST} and {@code username} or {@code password} are {@code null}. + */ + public ProxyConfiguration(ProxyAuthenticationType authentication, Proxy proxyAddress, String username, String password) { + Objects.requireNonNull(authentication); + this.authentication = authentication; + this.proxyAddress = proxyAddress; + + if (username != null && password != null) { + this.credentials = new PasswordAuthentication(username, password.toCharArray()); + } else { + logger.asInfo().log("Username or password is null. Using system-wide authentication."); + this.credentials = null; + } + } + + /** + * Gets the proxy authentication type. + * + * @return the proxy authentication type to use. Returns {@code null} if no authentication type was set. + * This occurs when user uses {@link ProxyConfiguration#SYSTEM_DEFAULTS}. + */ + public ProxyAuthenticationType authentication() { + return this.authentication; + } + + /** + * Gets the proxy address. + * + * @return the proxy address. Return {@code null} if no proxy address was set + * This occurs when user uses {@link ProxyConfiguration#SYSTEM_DEFAULTS}. + */ + public Proxy proxyAddress() { + return this.proxyAddress; + } + + /** + * Gets the credentials user provided for authentication of proxy server. + * + * @return the username and password to use. Return {@code null} if no credential was set. + * This occurs when user uses {@link ProxyConfiguration#SYSTEM_DEFAULTS}. + */ + public PasswordAuthentication credential() { + return this.credentials; + } + + /** + * Gets whether the user has defined credentials. + * + * @return true if the user has defined the credentials to use, false otherwise. + */ + public boolean hasUserDefinedCredentials() { + return credentials != null; + } + + /** + * Gets whether the proxy address has been configured. Used to determine whether to use system-defined or + * user-defined proxy. + * + * @return true if the proxy url has been set, and false otherwise. + */ + public boolean isProxyAddressConfigured() { + return proxyAddress != null && proxyAddress.address() != null; + } + + /** + * Disposes of the configuration along with potential credentials. + */ + @Override + public void close() { + if (credentials != null) { + Arrays.fill(credentials.getPassword(), '\0'); + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/SendOptions.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/SendOptions.java new file mode 100644 index 000000000000..7d6a4deabf08 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/SendOptions.java @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +/** + * The set of options that can be specified when sending a set of events to configure how the event data is packaged + * into batches. + */ +public class SendOptions { + private int maximumSizeInBytes; + private String partitionKey; + + /** + * Creates an instance with the maximum message size set to the maximum amount allowed by the protocol. + */ + public SendOptions() { + this.maximumSizeInBytes = EventHubProducer.MAX_MESSAGE_LENGTH_BYTES; + } + + /** + * Sets the maximum size to allow for a single batch of events, in bytes. If this size is exceeded, an exception + * will be thrown and the send operation will fail. + * + * @param maximumSizeInBytes The maximum size to allow for a single batch of events. + * @return The updated EventBatchingOptions object. + */ + SendOptions maximumSizeInBytes(int maximumSizeInBytes) { + this.maximumSizeInBytes = maximumSizeInBytes; + return this; + } + + /** + * Gets the maximum size to allow for a single batch of events, in bytes. If this size is exceeded, an exception + * will be thrown and the send operation will fail. + * + * @return The maximum size to allow for a single batch of events, in bytes. + */ + int maximumSizeInBytes() { + return maximumSizeInBytes; + } + + /** + * Sets a partition key on an event batch, which tells the Event Hubs service to send all events with that partition + * routing key to the same partition. + * + * @param partitionKey The label of an event batch. + * @return The updated EventBatchingOptions object. + */ + public SendOptions partitionKey(String partitionKey) { + this.partitionKey = partitionKey; + return this; + } + + /** + * Gets the partition routing key on an event batch. If specified, tells the Event Hubs service that these events + * belong to the same group and should belong to the same partition. + * + * @return The partition key on an event batch. + */ + public String partitionKey() { + return partitionKey; + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ActiveClientTokenManager.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ActiveClientTokenManager.java new file mode 100644 index 000000000000..537bb1cfbf5a --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ActiveClientTokenManager.java @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.CBSNode; +import com.azure.core.amqp.exception.AmqpException; +import com.azure.core.amqp.exception.AmqpResponseCode; +import com.azure.core.exception.AzureException; +import com.azure.core.util.logging.ClientLogger; +import reactor.core.publisher.Flux; +import reactor.core.publisher.FluxSink; +import reactor.core.publisher.Mono; + +import java.io.Closeable; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Manages the re-authorization of the client to the token audience against the CBS node. + */ +class ActiveClientTokenManager implements Closeable { + private final ClientLogger logger = new ClientLogger(ActiveClientTokenManager.class); + private final AtomicBoolean hasScheduled = new AtomicBoolean(); + private final AtomicBoolean hasDisposed = new AtomicBoolean(); + private final Mono cbsNode; + private final String tokenAudience; + private final Timer timer; + private final Flux authorizationResults; + private FluxSink sink; + + // last refresh interval in milliseconds. + private AtomicLong lastRefreshInterval = new AtomicLong(); + + ActiveClientTokenManager(Mono cbsNode, String tokenAudience) { + this.timer = new Timer(tokenAudience + "-tokenManager"); + this.cbsNode = cbsNode; + this.tokenAudience = tokenAudience; + this.authorizationResults = Flux.create(sink -> { + if (hasDisposed.get()) { + sink.complete(); + } else { + this.sink = sink; + } + }); + + lastRefreshInterval.set(Duration.ofMinutes(1).getSeconds() * 1000); + } + + /** + * Gets a flux of the periodic authorization results from the CBS node. Errors are returned on the Flux if + * authorization is unsuccessful. + * + * @return A Flux of authorization results from the CBS node. + */ + Flux getAuthorizationResults() { + return authorizationResults; + } + + /** + * Invokes an authorization call on the CBS node. + */ + Mono authorize() { + if (hasDisposed.get()) { + return Mono.error(new AzureException("Cannot authorize with CBS node when this token manager has been disposed of.")); + } + + return cbsNode.flatMap(cbsNode -> cbsNode.authorize(tokenAudience)) + .map(expiresOn -> { + if (!hasScheduled.getAndSet(true)) { + logger.asInfo().log("Scheduling refresh token."); + Duration between = Duration.between(OffsetDateTime.now(ZoneOffset.UTC), expiresOn); + + // We want to refresh the token when 90% of the time before expiry has elapsed. + long refreshSeconds = (long) Math.floor(between.getSeconds() * 0.9); + long refreshIntervalMS = refreshSeconds * 1000; + + lastRefreshInterval.set(refreshIntervalMS); + + // This converts it to milliseconds + this.timer.schedule(new RefreshAuthorizationToken(), refreshIntervalMS); + } + + return expiresOn; + }).then(); + } + + @Override + public void close() { + if (!hasDisposed.getAndSet(true)) { + this.timer.cancel(); + + if (this.sink != null) { + this.sink.complete(); + } + } + } + + private class RefreshAuthorizationToken extends TimerTask { + @Override + public void run() { + logger.asInfo().log("Refreshing authorization token."); + authorize().subscribe( + (Void response) -> { + logger.asInfo().log("Response acquired."); + }, error -> { + if ((error instanceof AmqpException) && ((AmqpException) error).isTransient()) { + logger.asError().log("Error is transient. Rescheduling authorization task.", error); + timer.schedule(new RefreshAuthorizationToken(), lastRefreshInterval.get()); + } else { + logger.asError().log("Error occurred while refreshing token that is not retriable. Not scheduling" + + " refresh task. Use ActiveClientTokenManager.authorize() to schedule task again.", error); + hasScheduled.set(false); + } + + sink.error(error); + }, () -> { + logger.asInfo().log("Success. Rescheduling refresh authorization task."); + sink.next(AmqpResponseCode.ACCEPTED); + + if (hasScheduled.getAndSet(true)) { + timer.schedule(new RefreshAuthorizationToken(), lastRefreshInterval.get()); + } + }); + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpConstants.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpConstants.java new file mode 100644 index 000000000000..55ca4745029f --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpConstants.java @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import org.apache.qpid.proton.amqp.Symbol; + +import static com.azure.core.amqp.MessageConstant.PARTITION_KEY_ANNOTATION_NAME; + +public final class AmqpConstants { + public static final String APACHE = "apache.org"; + public static final String PROTON = "proton"; + public static final String AMQP_ANNOTATION_FORMAT = "amqp.annotation.%s >%s '%s'"; + public static final Symbol PARTITION_KEY = Symbol.getSymbol(PARTITION_KEY_ANNOTATION_NAME.getValue()); + + static final String VENDOR = "com.microsoft"; + + static final Symbol STRING_FILTER = Symbol.getSymbol(APACHE + ":selector-filter:string"); + + static final int AMQP_BATCH_MESSAGE_FORMAT = 0x80013700; // 2147563264L; + +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpErrorCode.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpErrorCode.java new file mode 100644 index 000000000000..7f53459decff --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpErrorCode.java @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.exception.ErrorCondition; +import org.apache.qpid.proton.amqp.Symbol; + +/** + * AMQP error conditions mapped to proton-j symbols. + */ +public final class AmqpErrorCode { + public static final Symbol NOT_FOUND = Symbol.getSymbol(ErrorCondition.NOT_FOUND.getErrorCondition()); + public static final Symbol UNAUTHORIZED_ACCESS = Symbol.getSymbol(ErrorCondition.UNAUTHORIZED_ACCESS.getErrorCondition()); + public static final Symbol RESOURCE_LIMIT_EXCEEDED = Symbol.getSymbol(ErrorCondition.RESOURCE_LIMIT_EXCEEDED.getErrorCondition()); + public static final Symbol NOT_ALLOWED = Symbol.getSymbol(ErrorCondition.NOT_ALLOWED.getErrorCondition()); + public static final Symbol INTERNAL_ERROR = Symbol.getSymbol(ErrorCondition.INTERNAL_ERROR.getErrorCondition()); + public static final Symbol ILLEGAL_STATE = Symbol.getSymbol(ErrorCondition.ILLEGAL_STATE.getErrorCondition()); + public static final Symbol NOT_IMPLEMENTED = Symbol.getSymbol(ErrorCondition.NOT_IMPLEMENTED.getErrorCondition()); + + // link errors + public static final Symbol LINK_STOLEN = Symbol.getSymbol(ErrorCondition.LINK_STOLEN.getErrorCondition()); + public static final Symbol LINK_PAYLOAD_SIZE_EXCEEDED = Symbol.getSymbol(ErrorCondition.LINK_PAYLOAD_SIZE_EXCEEDED.getErrorCondition()); + public static final Symbol LINK_DETACH_FORCED = Symbol.getSymbol(ErrorCondition.LINK_DETACH_FORCED.getErrorCondition()); + + // connection errors + public static final Symbol CONNECTION_FORCED = Symbol.getSymbol(ErrorCondition.CONNECTION_FORCED.getErrorCondition()); + + // proton library introduced this AMQP symbol in their code-base to communicate IOExceptions + // while performing operations on SocketChannel (in IOHandler.java) + public static final Symbol PROTON_IO_ERROR = Symbol.getSymbol("proton:io"); + + public static final Symbol SERVER_BUSY_ERROR = Symbol.getSymbol(ErrorCondition.SERVER_BUSY_ERROR.getErrorCondition()); + public static final Symbol ARGUMENT_ERROR = Symbol.getSymbol(ErrorCondition.ARGUMENT_ERROR.getErrorCondition()); + public static final Symbol ARGUMENT_OUT_OF_RANGE_ERROR = Symbol.getSymbol(ErrorCondition.ARGUMENT_OUT_OF_RANGE_ERROR.getErrorCondition()); + public static final Symbol ENTITY_DISABLED_ERROR = Symbol.getSymbol(ErrorCondition.ENTITY_DISABLED_ERROR.getErrorCondition()); + public static final Symbol PARTITION_NOT_OWNED_ERROR = Symbol.getSymbol(ErrorCondition.PARTITION_NOT_OWNED_ERROR.getErrorCondition()); + public static final Symbol STORE_LOCK_LOST_ERROR = Symbol.getSymbol(ErrorCondition.STORE_LOCK_LOST_ERROR.getErrorCondition()); + public static final Symbol PUBLISHER_REVOKED_ERROR = Symbol.getSymbol(ErrorCondition.PUBLISHER_REVOKED_ERROR.getErrorCondition()); + public static final Symbol TIMEOUT_ERROR = Symbol.getSymbol(ErrorCondition.TIMEOUT_ERROR.getErrorCondition()); + public static final Symbol TRACKING_ID_PROPERTY = Symbol.getSymbol(ErrorCondition.TRACKING_ID_PROPERTY.getErrorCondition()); +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpReceiveLink.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpReceiveLink.java new file mode 100644 index 000000000000..b06f34b7cce4 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpReceiveLink.java @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.AmqpLink; +import org.apache.qpid.proton.message.Message; +import reactor.core.publisher.Flux; + +import java.io.Closeable; +import java.util.function.Supplier; + +/** + * A unidirectional link from the client to the message broker that listens for messages. + * + * @see AMQP + * Specification 1.0: Links + */ +public interface AmqpReceiveLink extends AmqpLink { + /** + * Initialises the link from the client to the message broker and begins to receive messages from the broker. + * + * @return A Flux of AMQP messages which completes when the client calls + * {@link Closeable#close() AmqpReceiveLink.close()} or an unrecoverable error occurs on the AMQP link. + */ + Flux receive(); + + /** + * Adds the specified number of credits to the link. + * + * The number of link credits initialises to zero. It is the application's responsibility to call this method to + * allow the receiver to receive {@code credits} more deliveries. + * + * @param credits Number of credits to add to the receive link. + */ + void addCredits(int credits); + + /** + * Gets the current number of credits this link has. + * + * @return The number of credits (deliveries) this link has. + */ + int getCredits(); + + /** + * Sets an event listener that is invoked when there are no credits on the link left. If the supplier returns an + * integer that is {@code null} or less than 1, then no credits are added to the link and no more messages are + * received on the link. + * + * @param creditSupplier Supplier that returns the number of credits to add to the link. + */ + void setEmptyCreditListener(Supplier creditSupplier); +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpResponseMapper.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpResponseMapper.java new file mode 100644 index 000000000000..564ec98a2773 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpResponseMapper.java @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.messaging.eventhubs.EventHubProperties; +import com.azure.messaging.eventhubs.PartitionProperties; + +import java.util.Map; + +/** + * Mapper to help deserialize an AMQP message. + */ +public interface AmqpResponseMapper { + /** + * Deserialize the AMQP body to {@link EventHubProperties}. + * + * @param amqpBody AMQP response body to deserialize. + * @return The {@link EventHubProperties} represented by the AMQP body. + */ + EventHubProperties toEventHubProperties(Map amqpBody); + + /** + * Deserialize the AMQP body to {@link PartitionProperties}. + * + * @param amqpBody AMQP response body to deserialize. + * @return The {@link PartitionProperties} represented by the AMQP body. + */ + PartitionProperties toPartitionProperties(Map amqpBody); +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpSendLink.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpSendLink.java new file mode 100644 index 000000000000..4cbfed48a979 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/AmqpSendLink.java @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.AmqpLink; +import com.azure.core.amqp.exception.AmqpException; +import com.azure.core.amqp.exception.ErrorContext; +import org.apache.qpid.proton.message.Message; +import reactor.core.publisher.Mono; + +import java.util.List; + +/** + * An AMQP link that sends information to the remote endpoint. + */ +public interface AmqpSendLink extends AmqpLink { + /** + * Sends a single message to the remote endpoint. + * + * @param message Message to send. + * @return A Mono that completes when the message has been sent. + * @throws AmqpException if the serialized {@code message} exceed the links capacity for a single message. + */ + Mono send(Message message); + + /** + * Batches the messages given into a single proton-j message that is sent down the wire. + * + * @param messageBatch The batch of messages to send to the service. + * @return A Mono that completes when all the batched messages are successfully transmitted to Event Hub. + * @throws AmqpException if the serialized contents of {@code messageBatch} exceed the link's capacity for a single + * message. + */ + Mono send(List messageBatch); + + /** + * Gets the context for this AMQP send link. + */ + ErrorContext getErrorContext(); +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/CBSAuthorizationType.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/CBSAuthorizationType.java new file mode 100644 index 000000000000..1824f4d17051 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/CBSAuthorizationType.java @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.CBSNode; + +/** + * An enumeration of supported authorization methods with the {@link CBSNode}. + */ +public enum CBSAuthorizationType { + /** + * Authorize with CBS through a shared access signature. + */ + SHARED_ACCESS_SIGNATURE("sastoken"), + /** + * Authorize with CBS using a JSON web token. + * + * This is used in the case where Azure Active Directory is used for authentication and the authenticated user + * wants to authorize with Azure Event Hubs. + */ + JSON_WEB_TOKEN("jwt"); + + private final String scheme; + + CBSAuthorizationType(String scheme) { + this.scheme = scheme; + } + + /** + * Gets the token type scheme. + * + * @return The token type scheme. + */ + public String getTokenType() { + return scheme; + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/CBSChannel.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/CBSChannel.java new file mode 100644 index 000000000000..d41405e1d585 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/CBSChannel.java @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.AmqpConnection; +import com.azure.core.amqp.CBSNode; +import com.azure.core.credentials.TokenCredential; +import com.azure.core.util.logging.ClientLogger; +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.amqp.messaging.AmqpValue; +import org.apache.qpid.proton.amqp.messaging.ApplicationProperties; +import org.apache.qpid.proton.message.Message; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +class CBSChannel extends EndpointStateNotifierBase implements CBSNode { + static final String SESSION_NAME = "cbs-session"; + static final String CBS_ADDRESS = "$cbs"; + + private static final String LINK_NAME = "cbs"; + private static final String PUT_TOKEN_OPERATION = "operation"; + private static final String PUT_TOKEN_OPERATION_VALUE = "put-token"; + private static final String PUT_TOKEN_TYPE = "type"; + private static final String PUT_TOKEN_TYPE_VALUE_FORMAT = "servicebus.windows.net:%s"; + private static final String PUT_TOKEN_AUDIENCE = "name"; + + private final AmqpConnection connection; + private final TokenCredential credential; + private final Mono cbsChannelMono; + private final ReactorProvider provider; + private final Duration operationTimeout; + private final CBSAuthorizationType authorizationType; + + CBSChannel(AmqpConnection connection, TokenCredential tokenCredential, CBSAuthorizationType authorizationType, + ReactorProvider provider, ReactorHandlerProvider handlerProvider, Duration operationTimeout) { + super(new ClientLogger(CBSChannel.class)); + + Objects.requireNonNull(connection); + Objects.requireNonNull(tokenCredential); + Objects.requireNonNull(authorizationType); + Objects.requireNonNull(provider); + Objects.requireNonNull(operationTimeout); + Objects.requireNonNull(handlerProvider); + + this.authorizationType = authorizationType; + this.operationTimeout = operationTimeout; + this.connection = connection; + this.credential = tokenCredential; + this.provider = provider; + this.cbsChannelMono = connection.createSession(SESSION_NAME) + .cast(ReactorSession.class) + .map(session -> new RequestResponseChannel(connection.getIdentifier(), connection.getHost(), LINK_NAME, + CBS_ADDRESS, session.session(), handlerProvider)); + } + + @Override + public Mono authorize(final String tokenAudience) { + final Message request = Proton.message(); + final Map properties = new HashMap<>(); + properties.put(PUT_TOKEN_OPERATION, PUT_TOKEN_OPERATION_VALUE); + properties.put(PUT_TOKEN_TYPE, String.format(Locale.ROOT, PUT_TOKEN_TYPE_VALUE_FORMAT, authorizationType.getTokenType())); + properties.put(PUT_TOKEN_AUDIENCE, tokenAudience); + final ApplicationProperties applicationProperties = new ApplicationProperties(properties); + request.setApplicationProperties(applicationProperties); + + return credential.getToken(tokenAudience).flatMap(accessToken -> { + request.setBody(new AmqpValue(accessToken.token())); + + return cbsChannelMono.flatMap(x -> x.sendWithAck(request, provider.getReactorDispatcher())) + .then(Mono.fromCallable(() -> accessToken.expiresOn())); + }); + } + + @Override + public void close() { + final RequestResponseChannel channel = cbsChannelMono.block(operationTimeout); + if (channel != null) { + channel.close(); + } + + if (!connection.removeSession(SESSION_NAME)) { + logger.asInfo().log("Unable to remove CBSChannel {} from connection", SESSION_NAME); + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ClientConstants.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ClientConstants.java new file mode 100644 index 000000000000..24bea7a6cd9a --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ClientConstants.java @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import java.time.Duration; + +public final class ClientConstants { + public static final Duration OPERATION_TIMEOUT = Duration.ofSeconds(60); + public static final String NOT_APPLICABLE = "n/a"; + public static final int HTTPS_PORT = 443; + public static final int MAX_EVENTHUB_AMQP_HEADER_SIZE_BYTES = 512; + public static final Duration TOKEN_VALIDITY = Duration.ofMinutes(20); + public static final int SERVER_BUSY_BASE_SLEEP_TIME_IN_SECS = 4; + + public static final String PRODUCT_NAME = "azsdk-java-eventhubs"; + public static final String CURRENT_JAVACLIENT_VERSION = "1.0.0-SNAPSHOT"; + public static final String PLATFORM_INFO = getOSInformation(); + public static final String FRAMEWORK_INFO = getFrameworkInfo(); + + /** + * Gets the USER AGENT string as defined in: + * $/core/azure-core/src/main/java/com/azure/core/http/policy/UserAgentPolicy.java + * TODO (conniey): Extract logic from UserAgentPolicy into something we can use here. + */ + public static final String USER_AGENT = String.format("%s/%s %s;%s", + PRODUCT_NAME, CURRENT_JAVACLIENT_VERSION, System.getProperty("java.version"), PLATFORM_INFO); + public static final String HTTPS_URI_FORMAT = "https://%s:%s"; + public static final String ENDPOINT_FORMAT = "sb://%s.%s"; + + private ClientConstants() { + } + + private static String getOSInformation() { + return String.join(" ", System.getProperty("os.name"), System.getProperty("os.version")); + } + + private static String getFrameworkInfo() { + final Package javaRuntimeClassPkg = Runtime.class.getPackage(); + + return "jre:" + javaRuntimeClassPkg.getImplementationVersion() + + ";vendor:" + javaRuntimeClassPkg.getImplementationVendor() + + ";jvm" + System.getProperty("java.vm.version"); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ConnectionOptions.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ConnectionOptions.java new file mode 100644 index 000000000000..2f2d5745a820 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ConnectionOptions.java @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.Retry; +import com.azure.core.amqp.TransportType; +import com.azure.core.credentials.TokenCredential; +import com.azure.messaging.eventhubs.ProxyConfiguration; +import reactor.core.scheduler.Scheduler; + +import java.time.Duration; +import java.util.Objects; + +/** + * A wrapper class that contains all parameters that are needed to establish a connection to an Event Hub. + */ +public class ConnectionOptions { + private final Duration timeout; + private final TokenCredential tokenCredential; + private final TransportType transport; + private final Retry retryPolicy; + private final ProxyConfiguration proxyConfiguration; + private final Scheduler scheduler; + private final String host; + private final String eventHubPath; + private final CBSAuthorizationType authorizationType; + + public ConnectionOptions(String host, String eventHubPath, TokenCredential tokenCredential, + CBSAuthorizationType authorizationType, Duration timeout, TransportType transport, + Retry retryPolicy, ProxyConfiguration proxyConfiguration, Scheduler scheduler) { + Objects.requireNonNull(host); + Objects.requireNonNull(eventHubPath); + Objects.requireNonNull(timeout); + Objects.requireNonNull(tokenCredential); + Objects.requireNonNull(transport); + Objects.requireNonNull(retryPolicy); + Objects.requireNonNull(proxyConfiguration); + Objects.requireNonNull(scheduler); + + this.host = host; + this.eventHubPath = eventHubPath; + this.timeout = timeout; + this.tokenCredential = tokenCredential; + this.authorizationType = authorizationType; + this.transport = transport; + this.retryPolicy = retryPolicy; + this.proxyConfiguration = proxyConfiguration; + this.scheduler = scheduler; + } + + public String host() { + return host; + } + + public String eventHubPath() { + return eventHubPath; + } + + public Duration timeout() { + return timeout; + } + + public TokenCredential tokenCredential() { + return tokenCredential; + } + + public CBSAuthorizationType authorizationType() { + return authorizationType; + } + + public TransportType transportType() { + return transport; + } + + public Retry retryPolicy() { + return retryPolicy; + } + + public ProxyConfiguration proxyConfiguration() { + return proxyConfiguration; + } + + public Scheduler scheduler() { + return scheduler; + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ConnectionStringProperties.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ConnectionStringProperties.java new file mode 100644 index 000000000000..efd6eae2626a --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ConnectionStringProperties.java @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.implementation.util.ImplUtils; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; + +/** + * The set of properties that comprise a connection string from the Azure portal. + */ +public class ConnectionStringProperties { + private static final String TOKEN_VALUE_SEPARATOR = "="; + private static final String TOKEN_VALUE_PAIR_DELIMITER = ";"; + private static final String ENDPOINT = "Endpoint"; + private static final String SHARED_ACCESS_KEY_NAME = "SharedAccessKeyName"; + private static final String SHARED_ACCESS_KEY = "SharedAccessKey"; + private static final String ENTITY_PATH = "EntityPath"; + private static final String ERROR_MESSAGE_FORMAT = "Could not parse 'connectionString'. Expected format: " + + "'Endpoint={endpoint};SharedAccessKeyName={sharedAccessKeyName};" + + "SharedAccessKey={sharedAccessKey};EntityPath={eventHubPath}'. Actual: %s"; + + private final URI endpoint; + private final String eventHubPath; + private final String sharedAccessKeyName; + private final String sharedAccessKey; + + /** + * Creates a new instance by parsing the {@code connectionString} into its components. + * + * @param connectionString The connection string to the Event Hub instance. + * @throws IllegalArgumentException if {@code connectionString} is {@code null} or empty, the connection string has + * an invalid format. + */ + public ConnectionStringProperties(String connectionString) { + if (ImplUtils.isNullOrEmpty(connectionString)) { + throw new IllegalArgumentException("'connectionString' cannot be null or empty"); + } + + final String[] tokenValuePairs = connectionString.split(TOKEN_VALUE_PAIR_DELIMITER); + URI endpoint = null; + String eventHubPath = null; + String sharedAccessKeyName = null; + String sharedAccessKeyValue = null; + + for (String tokenValuePair : tokenValuePairs) { + final String[] pair = tokenValuePair.split(TOKEN_VALUE_SEPARATOR, 2); + if (pair.length != 2) { + throw new IllegalArgumentException(String.format(Locale.US, "Connection string has invalid key value pair: %s", tokenValuePair)); + } + + final String key = pair[0].trim(); + final String value = pair[1].trim(); + + if (key.equalsIgnoreCase(ENDPOINT)) { + try { + endpoint = new URI(value); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(String.format(Locale.US, "Invalid endpoint: %s", tokenValuePair), e); + } + } else if (key.equalsIgnoreCase(SHARED_ACCESS_KEY_NAME)) { + sharedAccessKeyName = value; + } else if (key.equalsIgnoreCase(SHARED_ACCESS_KEY)) { + sharedAccessKeyValue = value; + } else if (key.equalsIgnoreCase(ENTITY_PATH)) { + eventHubPath = value; + } + } + + if (endpoint == null || sharedAccessKeyName == null || sharedAccessKeyValue == null || eventHubPath == null) { + throw new IllegalArgumentException(String.format(Locale.US, ERROR_MESSAGE_FORMAT, connectionString)); + } + + this.endpoint = endpoint; + this.eventHubPath = eventHubPath; + this.sharedAccessKeyName = sharedAccessKeyName; + this.sharedAccessKey = sharedAccessKeyValue; + } + + /** + * Gets the endpoint to be used for connecting to the Event Hubs namespace. + * + * @return The endpoint address, including protocol, from the connection string. + */ + public URI endpoint() { + return endpoint; + } + + /** + * Gets the name of the specific Event Hub under the namespace. + * + * @return The name of the specific Event Hub under the namespace. + */ + public String eventHubPath() { + return eventHubPath; + } + + /** + * Gets the name of the shared access key, either for the Event Hubs namespace or the Event Hub instance. + * + * @return The name of the shared access key. + */ + public String sharedAccessKeyName() { + return sharedAccessKeyName; + } + + /** + * The value of the shared access key, either for the Event Hubs namespace or the Event Hub. + * + * @return The value of the shared access key. + */ + public String sharedAccessKey() { + return sharedAccessKey; + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EndpointStateNotifierBase.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EndpointStateNotifierBase.java new file mode 100644 index 000000000000..9d221fa09175 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EndpointStateNotifierBase.java @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.AmqpEndpointState; +import com.azure.core.amqp.AmqpShutdownSignal; +import com.azure.core.amqp.EndpointStateNotifier; +import com.azure.core.util.logging.ClientLogger; +import org.apache.qpid.proton.engine.EndpointState; +import reactor.core.Disposable; +import reactor.core.publisher.DirectProcessor; +import reactor.core.publisher.Flux; +import reactor.core.publisher.ReplayProcessor; + +import java.io.Closeable; +import java.util.Objects; + +abstract class EndpointStateNotifierBase implements EndpointStateNotifier, Closeable { + private final ReplayProcessor connectionStateProcessor = ReplayProcessor.cacheLastOrDefault(AmqpEndpointState.UNINITIALIZED); + private final DirectProcessor errorContextProcessor = DirectProcessor.create(); + private final DirectProcessor shutdownSignalProcessor = DirectProcessor.create(); + private final Disposable subscription; + + protected ClientLogger logger; + private volatile AmqpEndpointState state; + + EndpointStateNotifierBase(ClientLogger logger) { + Objects.requireNonNull(logger); + + this.logger = logger; + this.subscription = connectionStateProcessor.subscribe(s -> this.state = s); + } + + @Override + public AmqpEndpointState getCurrentState() { + return state; + } + + @Override + public Flux getErrors() { + return errorContextProcessor; + } + + @Override + public Flux getConnectionStates() { + return connectionStateProcessor; + } + + @Override + public Flux getShutdownSignals() { + return shutdownSignalProcessor; + } + + void notifyError(Throwable error) { + Objects.requireNonNull(error); + + logger.asInfo().log("Notify error: {}", error); + errorContextProcessor.onNext(error); + } + + void notifyShutdown(AmqpShutdownSignal shutdownSignal) { + Objects.requireNonNull(shutdownSignal); + + logger.asInfo().log("Notify shutdown signal: {}", shutdownSignal); + shutdownSignalProcessor.onNext(shutdownSignal); + } + + void notifyEndpointState(EndpointState endpointState) { + Objects.requireNonNull(endpointState); + + logger.asVerbose().log("Connection state: {}", endpointState); + final AmqpEndpointState state = getConnectionState(endpointState); + connectionStateProcessor.onNext(state); + } + + private static AmqpEndpointState getConnectionState(EndpointState state) { + switch (state) { + case ACTIVE: + return AmqpEndpointState.ACTIVE; + case UNINITIALIZED: + return AmqpEndpointState.UNINITIALIZED; + case CLOSED: + return AmqpEndpointState.CLOSED; + default: + throw new UnsupportedOperationException("This endpoint state is not supported. State:" + state); + } + } + + @Override + public void close() { + subscription.dispose(); + connectionStateProcessor.onComplete(); + errorContextProcessor.onComplete(); + shutdownSignalProcessor.onComplete(); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ErrorContextProvider.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ErrorContextProvider.java new file mode 100644 index 000000000000..ccaa4bb70a30 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ErrorContextProvider.java @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.exception.ErrorContext; +import com.azure.core.amqp.exception.LinkErrorContext; +import com.azure.core.amqp.exception.SessionErrorContext; + +/** + * Generates contexts for AMQP errors that occur on a connection, link, or session handler. + * + * @see SessionErrorContext + * @see LinkErrorContext + */ +@FunctionalInterface +public interface ErrorContextProvider { + /** + * Gets the context this error occurred on. + * + * @return The context where this exception occurred. + */ + ErrorContext getErrorContext(); +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EventDataUtil.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EventDataUtil.java new file mode 100644 index 000000000000..58cf8c9c76d9 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EventDataUtil.java @@ -0,0 +1,242 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.MessageConstant; +import com.azure.core.implementation.util.ImplUtils; +import com.azure.messaging.eventhubs.EventData; +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.amqp.Binary; +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.messaging.AmqpValue; +import org.apache.qpid.proton.amqp.messaging.ApplicationProperties; +import org.apache.qpid.proton.amqp.messaging.Data; +import org.apache.qpid.proton.amqp.messaging.MessageAnnotations; +import org.apache.qpid.proton.message.Message; + +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Utility class for converting {@link EventData} to {@link Message}. + */ +public class EventDataUtil { + /** + * Maps the set of events given to a collection of AMQP messages. + */ + public static List toAmqpMessage(String partitionKey, List events) { + return events.stream().map(event -> toAmqpMessage(partitionKey, event)).collect(Collectors.toList()); + } + + /** + * Gets the serialized size of the AMQP message. + */ + static int getDataSerializedSize(Message amqpMessage) { + + if (amqpMessage == null) { + return 0; + } + + int payloadSize = getPayloadSize(amqpMessage); + + // EventData - accepts only PartitionKey - which is a String & stuffed into MessageAnnotation + final MessageAnnotations messageAnnotations = amqpMessage.getMessageAnnotations(); + final ApplicationProperties applicationProperties = amqpMessage.getApplicationProperties(); + + int annotationsSize = 0; + int applicationPropertiesSize = 0; + + if (messageAnnotations != null) { + final Map map = messageAnnotations.getValue(); + + for (Map.Entry entry : map.entrySet()) { + final int size = sizeof(entry.getKey()) + sizeof(entry.getValue()); + annotationsSize += size; + } + } + + if (applicationProperties != null) { + final Map map = applicationProperties.getValue(); + + for (Map.Entry entry : map.entrySet()) { + final int size = sizeof(entry.getKey()) + sizeof(entry.getValue()); + applicationPropertiesSize += size; + } + } + + return annotationsSize + applicationPropertiesSize + payloadSize; + } + + /** + * Creates the AMQP message represented by this EventData. + * + * @return A new AMQP message for this EventData. + */ + private static Message toAmqpMessage(String partitionKey, EventData eventData) { + final Message message = Proton.message(); + + if (eventData.properties() != null && !eventData.properties().isEmpty()) { + message.setApplicationProperties(new ApplicationProperties(eventData.properties())); + } + + if (!ImplUtils.isNullOrEmpty(partitionKey)) { + final MessageAnnotations messageAnnotations = message.getMessageAnnotations() == null + ? new MessageAnnotations(new HashMap<>()) + : message.getMessageAnnotations(); + messageAnnotations.getValue().put(AmqpConstants.PARTITION_KEY, partitionKey); + message.setMessageAnnotations(messageAnnotations); + } + + setSystemProperties(eventData, message); + + if (eventData.body() != null) { + message.setBody(new Data(Binary.create(eventData.body()))); + } + + return message; + } + + /* + * Sets AMQP protocol header values on the AMQP message. + */ + private static void setSystemProperties(EventData eventData, Message message) { + if (eventData.systemProperties() == null || eventData.systemProperties().isEmpty()) { + return; + } + + eventData.systemProperties().forEach((key, value) -> { + if (EventData.RESERVED_SYSTEM_PROPERTIES.contains(key)) { + return; + } + + final MessageConstant constant = MessageConstant.fromString(key); + + if (constant != null) { + switch (constant) { + case MESSAGE_ID: + message.setMessageId(value); + break; + case USER_ID: + message.setUserId((byte[]) value); + break; + case TO: + message.setAddress((String) value); + break; + case SUBJECT: + message.setSubject((String) value); + break; + case REPLY_TO: + message.setReplyTo((String) value); + break; + case CORRELATION_ID: + message.setCorrelationId(value); + break; + case CONTENT_TYPE: + message.setContentType((String) value); + break; + case CONTENT_ENCODING: + message.setContentEncoding((String) value); + break; + case ABSOLUTE_EXPRITY_TIME: + message.setExpiryTime((long) value); + break; + case CREATION_TIME: + message.setCreationTime((long) value); + break; + case GROUP_ID: + message.setGroupId((String) value); + break; + case GROUP_SEQUENCE: + message.setGroupSequence((long) value); + break; + case REPLY_TO_GROUP_ID: + message.setReplyToGroupId((String) value); + break; + default: + throw new IllegalArgumentException(String.format(Locale.US, "Property is not a recognized reserved property name: %s", key)); + } + } else { + final MessageAnnotations messageAnnotations = (message.getMessageAnnotations() == null) + ? new MessageAnnotations(new HashMap<>()) + : message.getMessageAnnotations(); + messageAnnotations.getValue().put(Symbol.getSymbol(key), value); + message.setMessageAnnotations(messageAnnotations); + } + }); + } + + private static int getPayloadSize(Message msg) { + + if (msg == null || msg.getBody() == null) { + return 0; + } + + if (msg.getBody() instanceof Data) { + final Data payloadSection = (Data) msg.getBody(); + if (payloadSection == null) { + return 0; + } + + final Binary payloadBytes = payloadSection.getValue(); + if (payloadBytes == null) { + return 0; + } + + return payloadBytes.getLength(); + } + + if (msg.getBody() instanceof AmqpValue) { + final AmqpValue amqpValue = (AmqpValue) msg.getBody(); + if (amqpValue == null) { + return 0; + } + + return amqpValue.getValue().toString().length() * 2; + } + + return 0; + } + + private static int sizeof(Object obj) { + if (obj instanceof String) { + return obj.toString().length() << 1; + } + + if (obj instanceof Symbol) { + return ((Symbol) obj).length() << 1; + } + + if (obj instanceof Integer) { + return Integer.BYTES; + } + + if (obj instanceof Long) { + return Long.BYTES; + } + + if (obj instanceof Short) { + return Short.BYTES; + } + + if (obj instanceof Character) { + return Character.BYTES; + } + + if (obj instanceof Float) { + return Float.BYTES; + } + + if (obj instanceof Double) { + return Double.BYTES; + } + + throw new IllegalArgumentException(String.format(Locale.US, "Encoding Type: %s is not supported", obj.getClass())); + } + + private EventDataUtil() { + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EventHubConnection.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EventHubConnection.java new file mode 100644 index 000000000000..21a0e017f9e7 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EventHubConnection.java @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.AmqpConnection; +import reactor.core.publisher.Mono; + +/** + * A connection to a specific Event Hub resource in Azure Event Hubs. + */ +public interface EventHubConnection extends AmqpConnection { + /** + * Gets the management node for fetching metadata about the Event Hub and performing management operations. + * + * @return A Mono that completes with a session to the Event Hub's management node. + */ + Mono getManagementNode(); +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EventHubManagementNode.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EventHubManagementNode.java new file mode 100644 index 000000000000..871ec49a8a0f --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EventHubManagementNode.java @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.EndpointStateNotifier; +import com.azure.messaging.eventhubs.EventHubProperties; +import com.azure.messaging.eventhubs.PartitionProperties; +import reactor.core.publisher.Mono; + +import java.io.Closeable; + +/** + * The management node for fetching metadata about the Event Hub and its partitions. + */ +public interface EventHubManagementNode extends EndpointStateNotifier, Closeable { + /** + * Gets the metadata associated with the Event Hub. + * + * @return Metadata associated with the Event Hub. + */ + Mono getEventHubProperties(); + + /** + * Gets the metadata associated with a particular partition in the Event Hub. + * + * @param partitionId The identifier of the partition. + * @return The metadata associated with the partition. + */ + Mono getPartitionProperties(String partitionId); +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EventHubSession.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EventHubSession.java new file mode 100644 index 000000000000..8b1760894cb0 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/EventHubSession.java @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.AmqpLink; +import com.azure.core.amqp.AmqpSession; +import com.azure.core.amqp.Retry; +import com.azure.messaging.eventhubs.EventHubConsumer; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +/** + * Represents an AMQP session that supports vendor specific properties and capabilities. For example, creating a + * receiver that exclusively listens to a partition + consumer group combination, or getting snapshots of partition + * information. + * + * @see AmqpSession + * @see ReactorSession + */ +public interface EventHubSession extends AmqpSession { + /** + * Creates a new AMQP consumer. + * + * @param linkName Name of the sender link. + * @param entityPath The entity path this link connects to receive events. + * @param eventPositionExpression The position within the partition where the consumer should begin reading events. + * @param timeout Timeout required for creating and opening AMQP link. + * @param retry The retry policy to use when receiving messages. + * @param ownerLevel {@code null} if multiple {@link EventHubConsumer EventHubConsumers} can listen to the same + * partition and consumer group. Otherwise, the {@code receiverPriority} that is the highest will listen to + * that partition exclusively. + * @param consumerIdentifier Identifier for the consumer that is sent to the service. + * @return A newly created AMQP link. + */ + Mono createConsumer(String linkName, String entityPath, String eventPositionExpression, Duration timeout, + Retry retry, Long ownerLevel, String consumerIdentifier); +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ManagementChannel.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ManagementChannel.java new file mode 100644 index 000000000000..61bdab1667c0 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ManagementChannel.java @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.AmqpConnection; +import com.azure.core.credentials.TokenCredential; +import com.azure.core.util.logging.ClientLogger; +import com.azure.messaging.eventhubs.EventHubProperties; +import com.azure.messaging.eventhubs.PartitionProperties; +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.amqp.messaging.AmqpValue; +import org.apache.qpid.proton.amqp.messaging.ApplicationProperties; +import org.apache.qpid.proton.message.Message; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; + +/** + * Channel responsible for Event Hubs related metadata and management plane operations. Management plane operations + * include another partition, increasing quotas, etc. + */ +public class ManagementChannel extends EndpointStateNotifierBase implements EventHubManagementNode { + // Well-known keys from the management service responses and requests. + public static final String MANAGEMENT_ENTITY_NAME_KEY = "name"; + public static final String MANAGEMENT_PARTITION_NAME_KEY = "partition"; + public static final String MANAGEMENT_RESULT_PARTITION_IDS = "partition_ids"; + public static final String MANAGEMENT_RESULT_CREATED_AT = "created_at"; + public static final String MANAGEMENT_RESULT_BEGIN_SEQUENCE_NUMBER = "begin_sequence_number"; + public static final String MANAGEMENT_RESULT_LAST_ENQUEUED_SEQUENCE_NUMBER = "last_enqueued_sequence_number"; + public static final String MANAGEMENT_RESULT_LAST_ENQUEUED_OFFSET = "last_enqueued_offset"; + public static final String MANAGEMENT_RESULT_LAST_ENQUEUED_TIME_UTC = "last_enqueued_time_utc"; + public static final String MANAGEMENT_RESULT_PARTITION_IS_EMPTY = "is_partition_empty"; + + private static final String SESSION_NAME = "mgmt-session"; + private static final String LINK_NAME = "mgmt"; + private static final String ADDRESS = "$management"; + + // Well-known keys for management plane service requests. + private static final String MANAGEMENT_ENTITY_TYPE_KEY = "type"; + private static final String MANAGEMENT_OPERATION_KEY = "operation"; + private static final String MANAGEMENT_SECURITY_TOKEN_KEY = "security_token"; + + // Well-known values for the service request. + private static final String READ_OPERATION_VALUE = "READ"; + private static final String MANAGEMENT_EVENTHUB_ENTITY_TYPE = AmqpConstants.VENDOR + ":eventhub"; + private static final String MANAGEMENT_PARTITION_ENTITY_TYPE = AmqpConstants.VENDOR + ":partition"; + + private final AmqpConnection connection; + private final TokenCredential tokenProvider; + private final Mono channelMono; + private final ReactorProvider provider; + private final String eventHubPath; + private final AmqpResponseMapper mapper; + private final TokenResourceProvider audienceProvider; + + /** + * Creates an instance that is connected to the {@code eventHubPath}'s management node. + * + * @param eventHubPath The name of the Event Hub. + * @param tokenProvider A provider that generates authorization tokens. + * @param provider The dispatcher to execute work on Reactor. + */ + ManagementChannel(AmqpConnection connection, String eventHubPath, TokenCredential tokenProvider, + TokenResourceProvider audienceProvider, ReactorProvider provider, + ReactorHandlerProvider handlerProvider, AmqpResponseMapper mapper) { + super(new ClientLogger(ManagementChannel.class)); + + Objects.requireNonNull(connection); + Objects.requireNonNull(eventHubPath); + Objects.requireNonNull(tokenProvider); + Objects.requireNonNull(audienceProvider); + Objects.requireNonNull(provider); + Objects.requireNonNull(handlerProvider); + Objects.requireNonNull(mapper); + + this.audienceProvider = audienceProvider; + this.connection = connection; + this.tokenProvider = tokenProvider; + this.provider = provider; + this.eventHubPath = eventHubPath; + this.mapper = mapper; + this.channelMono = connection.createSession(SESSION_NAME) + .cast(ReactorSession.class) + .map(session -> new RequestResponseChannel(connection.getIdentifier(), connection.getHost(), LINK_NAME, + ADDRESS, session.session(), handlerProvider)) + .cache(); + } + + /** + * {@inheritDoc} + */ + @Override + public Mono getEventHubProperties() { + final Map properties = new HashMap<>(); + properties.put(MANAGEMENT_ENTITY_TYPE_KEY, MANAGEMENT_EVENTHUB_ENTITY_TYPE); + properties.put(MANAGEMENT_ENTITY_NAME_KEY, eventHubPath); + properties.put(MANAGEMENT_OPERATION_KEY, READ_OPERATION_VALUE); + + return getProperties(properties, mapper::toEventHubProperties); + } + + /** + * {@inheritDoc} + */ + @Override + public Mono getPartitionProperties(String partitionId) { + final Map properties = new HashMap<>(); + properties.put(MANAGEMENT_ENTITY_TYPE_KEY, MANAGEMENT_PARTITION_ENTITY_TYPE); + properties.put(MANAGEMENT_ENTITY_NAME_KEY, eventHubPath); + properties.put(MANAGEMENT_PARTITION_NAME_KEY, partitionId); + properties.put(MANAGEMENT_OPERATION_KEY, READ_OPERATION_VALUE); + + return getProperties(properties, mapper::toPartitionProperties); + } + + private Mono getProperties(Map properties, Function, T> mapper) { + final String tokenAudience = audienceProvider.getResourceString(eventHubPath); + + return tokenProvider.getToken(tokenAudience).flatMap(accessToken -> { + properties.put(MANAGEMENT_SECURITY_TOKEN_KEY, accessToken.token()); + + final Message request = Proton.message(); + final ApplicationProperties applicationProperties = new ApplicationProperties(properties); + request.setApplicationProperties(applicationProperties); + + return channelMono.flatMap(x -> x.sendWithAck(request, provider.getReactorDispatcher())).map(message -> { + if (!(message.getBody() instanceof AmqpValue)) { + throw new IllegalArgumentException("Expected message.getBody() to be AmqpValue, but is: " + message.getBody()); + } + + AmqpValue body = (AmqpValue) message.getBody(); + if (!(body.getValue() instanceof Map)) { + throw new IllegalArgumentException("Expected message.getBody().getValue() to be of type Map"); + } + + Map map = (Map) body.getValue(); + + return mapper.apply(map); + }); + }); + } + + /** + * {@inheritDoc} + */ + @Override + public void close() { + final RequestResponseChannel channel = channelMono.block(Duration.ofSeconds(60)); + if (channel != null) { + channel.close(); + } + + if (!connection.removeSession(SESSION_NAME)) { + logger.asInfo().log("Unable to remove CBSChannel {} from connection", SESSION_NAME); + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorConnection.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorConnection.java new file mode 100644 index 000000000000..7d6b1693e95b --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorConnection.java @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.AmqpEndpointState; +import com.azure.core.amqp.AmqpExceptionHandler; +import com.azure.core.amqp.AmqpSession; +import com.azure.core.amqp.CBSNode; +import com.azure.core.util.logging.ClientLogger; +import com.azure.messaging.eventhubs.implementation.handler.ConnectionHandler; +import com.azure.messaging.eventhubs.implementation.handler.SessionHandler; +import org.apache.qpid.proton.engine.BaseHandler; +import org.apache.qpid.proton.engine.Connection; +import org.apache.qpid.proton.engine.EndpointState; +import org.apache.qpid.proton.engine.Session; +import org.apache.qpid.proton.reactor.Reactor; +import reactor.core.Disposable; +import reactor.core.Disposables; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; + +public class ReactorConnection extends EndpointStateNotifierBase implements EventHubConnection { + private static final AtomicReferenceFieldUpdater CBS_CHANNEL_FIELD_UPDATER = + AtomicReferenceFieldUpdater.newUpdater(ReactorConnection.class, CBSChannel.class, "cbsChannel"); + private static final AtomicReferenceFieldUpdater CONNECTION_FIELD_UPDATER = + AtomicReferenceFieldUpdater.newUpdater(ReactorConnection.class, Connection.class, "connection"); + + private volatile CBSChannel cbsChannel; + private volatile Connection connection; + + private final ConcurrentMap sessionMap = new ConcurrentHashMap<>(); + private final AtomicBoolean hasConnection = new AtomicBoolean(); + + private final String connectionId; + private final Mono connectionMono; + private final ConnectionHandler handler; + private final ReactorHandlerProvider handlerProvider; + private final ConnectionOptions connectionOptions; + private final ReactorProvider reactorProvider; + private final Disposable.Composite subscriptions; + private final Mono managementChannelMono; + private final TokenResourceProvider tokenResourceProvider; + + private ReactorExecutor executor; + //TODO (conniey): handle failures and recreating the Reactor. Resubscribing the handlers, etc. + private ReactorExceptionHandler reactorExceptionHandler; + + /** + * Creates a new AMQP connection that uses proton-j. + * + * @param connectionId Identifier for the connection. + * @param connectionOptions A set of options used to create the AMQP connection. + * @param reactorProvider Provides proton-j Reactor instances. + * @param handlerProvider Provides {@link BaseHandler} to listen to proton-j reactor events. + */ + public ReactorConnection(String connectionId, ConnectionOptions connectionOptions, + ReactorProvider reactorProvider, ReactorHandlerProvider handlerProvider, AmqpResponseMapper mapper) { + super(new ClientLogger(ReactorConnection.class)); + + this.connectionOptions = connectionOptions; + this.reactorProvider = reactorProvider; + this.connectionId = connectionId; + this.handlerProvider = handlerProvider; + this.handler = handlerProvider.createConnectionHandler(connectionId, connectionOptions.host(), connectionOptions.transportType()); + + this.connectionMono = Mono.fromCallable(() -> { + if (CONNECTION_FIELD_UPDATER.compareAndSet(this, null, this.createConnectionAndStart())) { + logger.asInfo().log("Creating and starting connection to {}:{}", handler.getHostname(), handler.getProtocolPort()); + } + + return CONNECTION_FIELD_UPDATER.get(this); + }).doOnSubscribe(c -> hasConnection.set(true)); + + this.subscriptions = Disposables.composite( + this.handler.getEndpointStates().subscribe( + this::notifyEndpointState, + this::notifyError, + () -> notifyEndpointState(EndpointState.CLOSED)), + this.handler.getErrors().subscribe( + this::notifyError, + this::notifyError, + () -> notifyEndpointState(EndpointState.CLOSED))); + + tokenResourceProvider = new TokenResourceProvider(connectionOptions.authorizationType(), connectionOptions.host()); + + this.managementChannelMono = connectionMono.then( + Mono.fromCallable(() -> (EventHubManagementNode) new ManagementChannel(this, + connectionOptions.eventHubPath(), connectionOptions.tokenCredential(), tokenResourceProvider, + reactorProvider, handlerProvider, mapper))).cache(); + } + + /** + * {@inheritDoc} + */ + @Override + public Mono getCBSNode() { + final Mono cbsNodeMono = getConnectionStates().takeUntil(x -> x == AmqpEndpointState.ACTIVE) + .timeout(connectionOptions.timeout()) + .then(Mono.fromCallable(() -> { + if (CBS_CHANNEL_FIELD_UPDATER.compareAndSet(this, null, + new CBSChannel(this, connectionOptions.tokenCredential(), + connectionOptions.authorizationType(), reactorProvider, handlerProvider, + connectionOptions.timeout()))) { + logger.asInfo().log("Setting CBS channel."); + } + + return CBS_CHANNEL_FIELD_UPDATER.get(this); + })); + + return hasConnection.get() + ? cbsNodeMono + : connectionMono.then(cbsNodeMono); + } + + @Override + public Mono getManagementNode() { + return managementChannelMono; + } + + @Override + public String getIdentifier() { + return connectionId; + } + + /** + * {@inheritDoc} + */ + @Override + public String getHost() { + return handler.getHostname(); + } + + /** + * {@inheritDoc} + */ + @Override + public int getMaxFrameSize() { + return handler.getMaxFrameSize(); + } + + /** + * {@inheritDoc} + */ + @Override + public Map getConnectionProperties() { + return handler.getConnectionProperties(); + } + + /** + * {@inheritDoc} + */ + @Override + public Mono createSession(String sessionName) { + AmqpSession existingSession = sessionMap.get(sessionName); + if (existingSession != null) { + return Mono.just(existingSession); + } + + return connectionMono.map(connection -> sessionMap.computeIfAbsent(sessionName, key -> { + final SessionHandler handler = + handlerProvider.createSessionHandler(connectionId, getHost(), sessionName, connectionOptions.timeout()); + final Session session = connection.session(); + + BaseHandler.setHandler(session, handler); + return new ReactorSession(session, handler, sessionName, reactorProvider, handlerProvider, + this.getCBSNode(), tokenResourceProvider, connectionOptions.timeout()); + })); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean removeSession(String sessionName) { + return sessionName != null && sessionMap.remove(sessionName) != null; + } + + /** + * {@inheritDoc} + */ + @Override + public void close() { + if (executor != null) { + executor.close(); + } + + subscriptions.dispose(); + sessionMap.forEach((name, session) -> { + try { + session.close(); + } catch (IOException e) { + logger.asError().log("Could not close session: " + name, e); + } + }); + super.close(); + } + + private Connection createConnectionAndStart() throws IOException { + final Reactor reactor = reactorProvider.createReactor(connectionId, handler.getMaxFrameSize()); + final Connection connection = reactor.connectionToHost(handler.getHostname(), handler.getProtocolPort(), handler); + + reactorExceptionHandler = new ReactorExceptionHandler(); + executor = new ReactorExecutor(reactor, connectionOptions.scheduler(), connectionId, reactorExceptionHandler, + connectionOptions.timeout(), connectionOptions.host()); + + executor.start(); + + return connection; + } + + private static final class ReactorExceptionHandler extends AmqpExceptionHandler { + private ReactorExceptionHandler() { + super(); + } + + @Override + public void onConnectionError(Throwable exception) { + super.onConnectionError(exception); + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorDispatcher.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorDispatcher.java new file mode 100644 index 000000000000..d6d2a0e8e7f4 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorDispatcher.java @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.util.logging.ClientLogger; +import com.azure.messaging.eventhubs.implementation.handler.DispatchHandler; +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.reactor.Reactor; +import org.apache.qpid.proton.reactor.Selectable; +import org.apache.qpid.proton.reactor.Selectable.Callback; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; +import java.nio.channels.Pipe; +import java.time.Duration; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.RejectedExecutionException; + +/** + * {@link Reactor} is not thread-safe - all calls to {@link Proton} APIs should be on the Reactor Thread. + * {@link Reactor} works out-of-box for all event driven API - ex: onReceive - which could raise upon onSocketRead. + * {@link Reactor} doesn't support APIs like send() out-of-box - which could potentially run on different thread to that + * of the Reactor thread. + * + *

+ * The following utility class is used to generate an Event to hook into {@link Reactor}'s event delegation pattern. + * It uses a {@link Pipe} as the IO on which Reactor listens to. + *

+ * + *

+ * Cardinality: Multiple {@link ReactorDispatcher}'s could be attached to 1 {@link Reactor}. + * Each {@link ReactorDispatcher} should be initialized synchronously - as it calls API in {@link Reactor} which is not + * thread-safe. + *

+ */ +public final class ReactorDispatcher { + private final ClientLogger logger = new ClientLogger(ReactorDispatcher.class); + private final CloseHandler onClose; + private final Reactor reactor; + private final Pipe ioSignal; + private final ConcurrentLinkedQueue workQueue; + private final WorkScheduler workScheduler; + + ReactorDispatcher(final Reactor reactor) throws IOException { + this.reactor = reactor; + this.ioSignal = Pipe.open(); + this.workQueue = new ConcurrentLinkedQueue<>(); + this.onClose = new CloseHandler(); + this.workScheduler = new WorkScheduler(); + + initializeSelectable(); + } + + private void initializeSelectable() { + Selectable schedulerSelectable = this.reactor.selectable(); + + schedulerSelectable.setChannel(this.ioSignal.source()); + schedulerSelectable.onReadable(this.workScheduler); + schedulerSelectable.onFree(this.onClose); + + schedulerSelectable.setReading(true); + this.reactor.update(schedulerSelectable); + } + + public void invoke(final Runnable work) throws IOException { + this.throwIfSchedulerError(); + + this.workQueue.offer(new Work(work)); + this.signalWorkQueue(); + } + + public void invoke(final Runnable work, final Duration delay) throws IOException { + this.throwIfSchedulerError(); + + this.workQueue.offer(new Work(work, delay)); + this.signalWorkQueue(); + } + + private void throwIfSchedulerError() { + // throw when the scheduler on which Reactor is running is already closed + final RejectedExecutionException rejectedException = this.reactor.attachments() + .get(RejectedExecutionException.class, RejectedExecutionException.class); + if (rejectedException != null) { + throw new RejectedExecutionException(rejectedException.getMessage(), rejectedException); + } + + // throw when the pipe is in closed state - in which case, + // signalling the new event-dispatch will fail + if (!this.ioSignal.sink().isOpen()) { + throw new RejectedExecutionException("ReactorDispatcher instance is closed."); + } + } + + private void signalWorkQueue() throws IOException { + try { + ByteBuffer oneByteBuffer = ByteBuffer.allocate(1); + while (this.ioSignal.sink().write(oneByteBuffer) == 0) { + oneByteBuffer = ByteBuffer.allocate(1); + } + } catch (ClosedChannelException ignorePipeClosedDuringReactorShutdown) { + logger.asInfo().log("signalWorkQueue failed with an error: %s", ignorePipeClosedDuringReactorShutdown); + } + } + + // Schedules work to be executed in reactor. + private final class WorkScheduler implements Callback { + @Override + public void run(Selectable selectable) { + try { + ByteBuffer oneKbByteBuffer = ByteBuffer.allocate(1024); + while (ioSignal.source().read(oneKbByteBuffer) > 0) { + // read until the end of the stream + oneKbByteBuffer = ByteBuffer.allocate(1024); + } + } catch (ClosedChannelException ignorePipeClosedDuringReactorShutdown) { + logger.asInfo().log("WorkScheduler.run() failed with an error: %s", ignorePipeClosedDuringReactorShutdown); + } catch (IOException ioException) { + logger.asError().log("WorkScheduler.run() failed with an error: %s", ioException); + throw new RuntimeException(ioException); + } + + Work topWork; + while ((topWork = workQueue.poll()) != null) { + if (topWork.delay != null) { + reactor.schedule((int) topWork.delay.toMillis(), topWork.dispatchHandler); + } else { + topWork.dispatchHandler.onTimerTask(null); + } + } + } + } + + // Disposes of the IO pipe when the reactor closes. + private final class CloseHandler implements Callback { + + @Override + public void run(Selectable selectable) { + try { + if (ioSignal.sink().isOpen()) { + ioSignal.sink().close(); + } + } catch (IOException ioException) { + logger.asError().log("CloseHandler.run() sink().close() failed with an error. %s", ioException); + } + + workScheduler.run(null); + + try { + if (ioSignal.source().isOpen()) { + ioSignal.source().close(); + } + } catch (IOException ioException) { + logger.asError().log("CloseHandler.run() source().close() failed with an error %s", ioException); + } + } + } + + // Work items that are dispatched to reactor. + private static final class Work { + // The work item that is dispatched to Reactor. + private final DispatchHandler dispatchHandler; + private final Duration delay; + + Work(Runnable work) { + this(work, null); + } + + Work(Runnable work, Duration delay) { + this.dispatchHandler = new DispatchHandler(work); + this.delay = delay; + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorExecutor.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorExecutor.java new file mode 100644 index 000000000000..89cae41ed5c3 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorExecutor.java @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.AmqpExceptionHandler; +import com.azure.core.amqp.AmqpShutdownSignal; +import com.azure.core.amqp.exception.AmqpException; +import com.azure.core.amqp.exception.ErrorContext; +import com.azure.core.implementation.util.ImplUtils; +import com.azure.core.util.logging.ClientLogger; +import org.apache.qpid.proton.engine.HandlerException; +import org.apache.qpid.proton.reactor.Reactor; +import reactor.core.scheduler.Scheduler; + +import java.io.Closeable; +import java.nio.channels.UnresolvedAddressException; +import java.time.Duration; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +class ReactorExecutor implements Closeable { + private static final String LOG_MESSAGE = "connectionId[{}], message[{}]"; + + private final ClientLogger logger = new ClientLogger(ReactorExecutor.class); + private final AtomicBoolean hasStarted = new AtomicBoolean(); + private final Object lock = new Object(); + private final Reactor reactor; + private final Scheduler scheduler; + private final String connectionId; + private final Duration timeout; + private final AmqpExceptionHandler exceptionHandler; + private final String hostname; + + ReactorExecutor(Reactor reactor, Scheduler scheduler, String connectionId, AmqpExceptionHandler exceptionHandler, + Duration timeout, String hostname) { + Objects.requireNonNull(reactor); + Objects.requireNonNull(scheduler); + Objects.requireNonNull(connectionId); + Objects.requireNonNull(exceptionHandler); + Objects.requireNonNull(timeout); + + this.reactor = reactor; + this.scheduler = scheduler; + this.connectionId = connectionId; + this.timeout = timeout; + this.exceptionHandler = exceptionHandler; + this.hostname = hostname; + } + + /** + * Starts the reactor and will begin processing any reactor events until there are no longer any left or + * {@link #close()} is called. + */ + void start() { + if (hasStarted.get()) { + logger.asWarning().log("ReactorExecutor has already started."); + return; + } + + logger.asInfo().log(LOG_MESSAGE, connectionId, "Starting reactor."); + + hasStarted.set(true); + synchronized (lock) { + reactor.start(); + } + + scheduler.schedule(this::run); + } + + boolean hasStarted() { + return hasStarted.get(); + } + + /** + * Worker loop that tries to process events in the reactor. If there are pending items to process, will reschedule + * the run() method again. + */ + private void run() { + if (!hasStarted.get()) { + logger.asWarning().log("Cannot start ReactorExecutor if ReactorExecutor.start() has not invoked."); + return; + } + + boolean rescheduledReactor = false; + + try { + final boolean shouldReschedule; + synchronized (lock) { + shouldReschedule = hasStarted.get() && !Thread.interrupted() && reactor.process(); + } + + if (shouldReschedule) { + try { + scheduler.schedule(this::run); + rescheduledReactor = true; + } catch (RejectedExecutionException exception) { + logger.asWarning().log(LOG_MESSAGE, connectionId, + StringUtil.toStackTraceString(exception, "Scheduling reactor failed because the executor has been shut down")); + + this.reactor.attachments().set(RejectedExecutionException.class, RejectedExecutionException.class, exception); + } + } + } catch (HandlerException handlerException) { + Throwable cause = handlerException.getCause() == null + ? handlerException + : handlerException.getCause(); + + logger.asWarning().log(LOG_MESSAGE, connectionId, StringUtil.toStackTraceString(handlerException, + "Unhandled exception while processing events in reactor, report this error.")); + + final String message = !ImplUtils.isNullOrEmpty(cause.getMessage()) + ? cause.getMessage() + : !ImplUtils.isNullOrEmpty(handlerException.getMessage()) + ? handlerException.getMessage() + : "Reactor encountered unrecoverable error"; + + final AmqpException exception; + final ErrorContext errorContext = new ErrorContext(hostname); + + if (cause instanceof UnresolvedAddressException) { + exception = new AmqpException(true, + String.format(Locale.US, "%s. This is usually caused by incorrect hostname or network configuration. Check correctness of namespace information. %s", + message, StringUtil.getTrackingIDAndTimeToLog()), + cause, errorContext); + } else { + exception = new AmqpException(true, + String.format(Locale.US, "%s, %s", message, StringUtil.getTrackingIDAndTimeToLog()), + cause, errorContext); + } + + this.exceptionHandler.onConnectionError(exception); + } finally { + if (!rescheduledReactor) { + if (hasStarted.get()) { + scheduleCompletePendingTasks(); + } else { + final String reason = "Stopping the reactor because thread was interrupted or the reactor has no more events to process."; + + logger.asInfo().log(LOG_MESSAGE, connectionId, reason); + close(false, reason); + } + } + } + } + + private void scheduleCompletePendingTasks() { + hasStarted.set(false); + + this.scheduler.schedule(() -> { + logger.asInfo().log(LOG_MESSAGE, connectionId, "Processing all pending tasks and closing old reactor."); + try { + reactor.stop(); + reactor.process(); + } catch (HandlerException e) { + logger.asWarning().log(LOG_MESSAGE, connectionId, + StringUtil.toStackTraceString(e, "scheduleCompletePendingTasks - exception occurred while processing events.")); + } finally { + reactor.free(); + } + }, timeout.toMillis(), TimeUnit.MILLISECONDS); + } + + @Override + public void close() { + close(true, "Dispose called."); + } + + private void close(boolean isUserInitialized, String reason) { + if (hasStarted.getAndSet(false)) { + logger.asInfo().log(LOG_MESSAGE, connectionId, "Stopping the reactor."); + + synchronized (lock) { + this.reactor.stop(); + this.reactor.free(); + } + + exceptionHandler.onConnectionShutdown(new AmqpShutdownSignal(false, isUserInitialized, reason)); + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorHandlerProvider.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorHandlerProvider.java new file mode 100644 index 000000000000..851ad1b4e726 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorHandlerProvider.java @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.TransportType; +import com.azure.messaging.eventhubs.implementation.handler.ConnectionHandler; +import com.azure.messaging.eventhubs.implementation.handler.ReceiveLinkHandler; +import com.azure.messaging.eventhubs.implementation.handler.SendLinkHandler; +import com.azure.messaging.eventhubs.implementation.handler.SessionHandler; +import org.apache.qpid.proton.reactor.Reactor; + +import java.time.Duration; +import java.util.Locale; + +/** + * Provides handlers for the various types of links. + */ +public class ReactorHandlerProvider { + private final ReactorProvider provider; + + /** + * Creates a new instance with the reactor provider to handle {@link ReactorDispatcher ReactorDispatchers} to its + * generated handlers. + * + * @param provider The provider that creates and manages {@link Reactor} instances. + */ + public ReactorHandlerProvider(ReactorProvider provider) { + this.provider = provider; + } + + /** + * Creates a new connection handler with the given {@code connectionId} and {@code hostname}. + * + * @param connectionId Identifier associated with this connection. + * @param hostname Host for the connection handler. + * @param transportType Transport type used for the connection. + * @return A new {@link ConnectionHandler}. + */ + ConnectionHandler createConnectionHandler(String connectionId, String hostname, TransportType transportType) { + switch (transportType) { + case AMQP: + return new ConnectionHandler(connectionId, hostname); + case AMQP_WEB_SOCKETS: + default: + throw new IllegalArgumentException(String.format(Locale.US, "This transport type '%s' is not supported yet.", transportType)); + } + } + + /** + * Creates a new session handler with the given {@code connectionId}, {@code host}, and {@code sessionName}. + * + * @param connectionId Identifier of the parent connection that created this session. + * @param host Host of the parent connection. + * @param sessionName Name of the session. + * @param openTimeout Duration to wait for the session to open. + * @return A new {@link SessionHandler}. + */ + SessionHandler createSessionHandler(String connectionId, String host, String sessionName, Duration openTimeout) { + return new SessionHandler(connectionId, host, sessionName, provider.getReactorDispatcher(), openTimeout); + } + + + /** + * Creates a new link handler for sending messages. + * + * @param connectionId Identifier of the parent connection that created this session. + * @param host Host of the parent connection. + * @param senderName Name of the send link. + * @return A new {@link SendLinkHandler}. + */ + SendLinkHandler createSendLinkHandler(String connectionId, String host, String senderName, String entityPath) { + return new SendLinkHandler(connectionId, host, senderName, entityPath); + } + + /** + * Creates a new link handler for receiving messages. + * + * @param connectionId Identifier of the parent connection that created this session. + * @param host Host of the parent connection. + * @param receiverName Name of the send link. + * @return A new {@link ReceiveLinkHandler}. + */ + ReceiveLinkHandler createReceiveLinkHandler(String connectionId, String host, String receiverName, String entityPath) { + return new ReceiveLinkHandler(connectionId, host, receiverName, entityPath); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorProvider.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorProvider.java new file mode 100644 index 000000000000..82657ca04de0 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorProvider.java @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.messaging.eventhubs.implementation.handler.CustomIOHandler; +import com.azure.messaging.eventhubs.implementation.handler.ReactorHandler; +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.engine.BaseHandler; +import org.apache.qpid.proton.engine.Handler; +import org.apache.qpid.proton.reactor.Reactor; +import org.apache.qpid.proton.reactor.ReactorOptions; + +import java.io.IOException; +import java.util.Objects; + +public class ReactorProvider { + private final Object lock = new Object(); + private Reactor reactor; + private ReactorDispatcher reactorDispatcher; + + Reactor getReactor() { + synchronized (lock) { + return reactor; + } + } + + ReactorDispatcher getReactorDispatcher() { + synchronized (lock) { + return reactorDispatcher; + } + } + + /** + * Creates a reactor and replaces the existing instance of it. + * + * @param connectionId Identifier for Reactor. + * @return The newly created reactor instance. + * @throws IOException If the service could not create a Reactor instance. + */ + Reactor createReactor(String connectionId, int maxFrameSize) throws IOException { + final CustomIOHandler globalHandler = new CustomIOHandler(connectionId); + final ReactorHandler reactorHandler = new ReactorHandler(connectionId); + + return createReactor(maxFrameSize, globalHandler, reactorHandler); + } + + /** + * Creates a new reactor with the given reactor handler and IO handler. + * + * @param globalHandler The global handler for reactor instance. Useful for logging events that were missed. + * @param baseHandlers Handler for reactor instance. Usually: {@link ReactorHandler} + * @return A new reactor instance. + */ + private Reactor createReactor(final int maxFrameSize, final Handler globalHandler, final BaseHandler... baseHandlers) throws IOException { + Objects.requireNonNull(baseHandlers); + Objects.requireNonNull(globalHandler); + + if (maxFrameSize <= 0) { + throw new IllegalArgumentException("'maxFrameSize' must be a positive number."); + } + + final ReactorOptions reactorOptions = new ReactorOptions(); + reactorOptions.setMaxFrameSize(maxFrameSize); + reactorOptions.setEnableSaslByDefault(true); + + final Reactor reactor = Proton.reactor(reactorOptions, baseHandlers); + reactor.setGlobalHandler(globalHandler); + + final ReactorDispatcher dispatcher = new ReactorDispatcher(reactor); + + synchronized (lock) { + this.reactor = reactor; + this.reactorDispatcher = dispatcher; + } + + return this.reactor; + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorReceiver.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorReceiver.java new file mode 100644 index 000000000000..cee536e7f32e --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorReceiver.java @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.util.logging.ClientLogger; +import com.azure.messaging.eventhubs.implementation.handler.ReceiveLinkHandler; +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.engine.Delivery; +import org.apache.qpid.proton.engine.EndpointState; +import org.apache.qpid.proton.engine.Receiver; +import org.apache.qpid.proton.message.Message; +import reactor.core.Disposable; +import reactor.core.Disposables; +import reactor.core.publisher.DirectProcessor; +import reactor.core.publisher.Flux; +import reactor.core.publisher.FluxSink; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; + +/** + * Handles receiving events from Event Hubs service and translating them to proton-j messages. + */ +public class ReactorReceiver extends EndpointStateNotifierBase implements AmqpReceiveLink { + // Initial value is true because we could not have created this receiver without authorising against the CBS node first. + private final AtomicBoolean hasAuthorized = new AtomicBoolean(true); + + private final String entityPath; + private final Receiver receiver; + private final ReceiveLinkHandler handler; + private final ActiveClientTokenManager tokenManager; + private final Disposable.Composite subscriptions; + private final DirectProcessor messagesProcessor = DirectProcessor.create(); + private FluxSink messageSink = messagesProcessor.sink(); + + private volatile Supplier creditSupplier; + + ReactorReceiver(String entityPath, Receiver receiver, ReceiveLinkHandler handler, ActiveClientTokenManager tokenManager) { + super(new ClientLogger(ReactorReceiver.class)); + this.entityPath = entityPath; + this.receiver = receiver; + this.handler = handler; + this.tokenManager = tokenManager; + + this.subscriptions = Disposables.composite( + handler.getDeliveredMessages().subscribe(this::decodeDelivery), + + handler.getEndpointStates().subscribe( + this::notifyEndpointState, + error -> logger.asError().log("Error encountered getting endpointState", error), + () -> { + logger.asVerbose().log("getEndpointStates completed."); + notifyEndpointState(EndpointState.CLOSED); + }), + + handler.getErrors().subscribe(this::notifyError), + + tokenManager.getAuthorizationResults().subscribe( + response -> { + logger.asVerbose().log("Token refreshed: {}", response); + hasAuthorized.set(true); + }, error -> { + logger.asInfo().log("clientId[{}], path[{}], linkName[{}] - tokenRenewalFailure[{}]", + handler.getConnectionId(), this.entityPath, getLinkName(), error.getMessage()); + hasAuthorized.set(false); + }, () -> hasAuthorized.set(false)) + ); + } + + @Override + public Flux receive() { + return messagesProcessor; + } + + @Override + public void addCredits(int credits) { + receiver.flow(credits); + } + + @Override + public int getCredits() { + return receiver.getRemoteCredit(); + } + + @Override + public void setEmptyCreditListener(Supplier creditSupplier) { + Objects.requireNonNull(creditSupplier); + this.creditSupplier = creditSupplier; + } + + @Override + public String getLinkName() { + return receiver.getName(); + } + + @Override + public String getEntityPath() { + return entityPath; + } + + @Override + public void close() { + subscriptions.dispose(); + tokenManager.close(); + messagesProcessor.dispose(); + handler.close(); + super.close(); + } + + private void decodeDelivery(Delivery delivery) { + final int messageSize = delivery.pending(); + final byte[] buffer = new byte[messageSize]; + final int read = receiver.recv(buffer, 0, messageSize); + receiver.advance(); + + final Message message = Proton.message(); + message.decode(buffer, 0, read); + + delivery.settle(); + + messageSink.next(message); + + if (receiver.getRemoteCredit() == 0 && creditSupplier != null) { + final Integer credits = creditSupplier.get(); + + if (credits != null && credits > 0) { + addCredits(credits); + } + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorSender.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorSender.java new file mode 100644 index 000000000000..9f45fd7e151f --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorSender.java @@ -0,0 +1,473 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.Retry; +import com.azure.core.amqp.exception.AmqpException; +import com.azure.core.amqp.exception.ErrorCondition; +import com.azure.core.amqp.exception.ErrorContext; +import com.azure.core.amqp.exception.ExceptionUtil; +import com.azure.core.amqp.exception.OperationCancelledException; +import com.azure.core.util.logging.ClientLogger; +import com.azure.messaging.eventhubs.implementation.handler.SendLinkHandler; +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.amqp.Binary; +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.UnsignedLong; +import org.apache.qpid.proton.amqp.messaging.Accepted; +import org.apache.qpid.proton.amqp.messaging.Data; +import org.apache.qpid.proton.amqp.messaging.Rejected; +import org.apache.qpid.proton.amqp.messaging.Released; +import org.apache.qpid.proton.amqp.transport.DeliveryState; +import org.apache.qpid.proton.engine.Delivery; +import org.apache.qpid.proton.engine.EndpointState; +import org.apache.qpid.proton.engine.Sender; +import org.apache.qpid.proton.engine.impl.DeliveryImpl; +import org.apache.qpid.proton.message.Message; +import reactor.core.Disposable; +import reactor.core.Disposables; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.io.Serializable; +import java.nio.BufferOverflowException; +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.PriorityQueue; +import java.util.Timer; +import java.util.TimerTask; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.azure.messaging.eventhubs.implementation.EventDataUtil.getDataSerializedSize; +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * Handles scheduling and transmitting events through proton-j to Event Hubs service. + */ +class ReactorSender extends EndpointStateNotifierBase implements AmqpSendLink { + private final String entityPath; + private final Sender sender; + private final SendLinkHandler handler; + private final ReactorProvider reactorProvider; + private final Disposable.Composite subscriptions; + + private final AtomicBoolean hasConnected = new AtomicBoolean(); + private final AtomicBoolean hasAuthorized = new AtomicBoolean(true); + + private final Object pendingSendLock = new Object(); + private final ConcurrentHashMap pendingSendsMap = new ConcurrentHashMap<>(); + private final PriorityQueue pendingSendsQueue = new PriorityQueue<>(1000, new DeliveryTagComparator()); + + private final ActiveClientTokenManager tokenManager; + private final Retry retry; + private final Duration timeout; + private final Timer sendTimeoutTimer = new Timer("SendTimeout-timer"); + + private final Object errorConditionLock = new Object(); + private volatile Exception lastKnownLinkError; + private volatile Instant lastKnownErrorReportedAt; + + /** + * Max message size can change from its initial value. When the send link is opened, we query for the remote link + * capacity. + */ + private volatile int maxMessageSize; + + ReactorSender(String entityPath, Sender sender, SendLinkHandler handler, ReactorProvider reactorProvider, + ActiveClientTokenManager tokenManager, Duration timeout, Retry retry, int maxMessageSize) { + super(new ClientLogger(ReactorSender.class)); + this.entityPath = entityPath; + this.sender = sender; + this.handler = handler; + this.reactorProvider = reactorProvider; + this.tokenManager = tokenManager; + this.retry = retry; + this.timeout = timeout; + this.maxMessageSize = maxMessageSize; + + this.subscriptions = Disposables.composite( + handler.getDeliveredMessages().subscribe(this::processDeliveredMessage), + + handler.getLinkCredits().subscribe(credit -> { + logger.asVerbose().log("Credits added: {}", credit); + this.scheduleWorkOnDispatcher(); + }), + + handler.getEndpointStates().subscribe( + endpoint -> { + final boolean isActive = endpoint == EndpointState.ACTIVE; + if (!hasConnected.getAndSet(isActive)) { + final UnsignedLong remoteMaxMessageSize = sender.getRemoteMaxMessageSize(); + + if (remoteMaxMessageSize != null) { + this.maxMessageSize = remoteMaxMessageSize.intValue(); + } + } + + notifyEndpointState(endpoint); + }, + error -> logger.asError().log("Error encountered getting endpointState", error), + () -> { + logger.asVerbose().log("getLinkCredits completed."); + hasConnected.set(false); + }), + + tokenManager.getAuthorizationResults().subscribe( + response -> { + logger.asVerbose().log("Token refreshed: {}", response); + hasAuthorized.set(true); + }, + error -> { + logger.asInfo().log("clientId[{}], path[{}], linkName[{}] - tokenRenewalFailure[{}]", + handler.getConnectionId(), this.entityPath, getLinkName(), error.getMessage()); + hasAuthorized.set(false); + }, () -> hasAuthorized.set(false)) + ); + } + + @Override + public Mono send(Message message) { + final int payloadSize = getDataSerializedSize(message); + final int allocationSize = Math.min(payloadSize + ClientConstants.MAX_EVENTHUB_AMQP_HEADER_SIZE_BYTES, maxMessageSize); + final byte[] bytes = new byte[allocationSize]; + + int encodedSize; + try { + encodedSize = message.encode(bytes, 0, allocationSize); + } catch (BufferOverflowException exception) { + final String errorMessage = String.format(Locale.US, "Error sending. Size of the payload exceeded Maximum message size: %s kb", maxMessageSize / 1024); + final Throwable error = new AmqpException(false, ErrorCondition.LINK_PAYLOAD_SIZE_EXCEEDED, errorMessage, + exception, handler.getErrorContext(sender)); + + return Mono.error(error); + } + + return send(bytes, encodedSize, DeliveryImpl.DEFAULT_MESSAGE_FORMAT); + } + + @Override + public Mono send(List messageBatch) { + if (messageBatch.size() == 1) { + return send(messageBatch.get(0)); + } + + final Message firstMessage = messageBatch.get(0); + + // proton-j doesn't support multiple dataSections to be part of AmqpMessage + // here's the alternate approach provided by them: https://github.com/apache/qpid-proton/pull/54 + final Message batchMessage = Proton.message(); + batchMessage.setMessageAnnotations(firstMessage.getMessageAnnotations()); + + final int maxMessageSizeTemp = this.maxMessageSize; + + final byte[] bytes = new byte[maxMessageSizeTemp]; + int encodedSize = batchMessage.encode(bytes, 0, maxMessageSizeTemp); + int byteArrayOffset = encodedSize; + + for (final Message amqpMessage : messageBatch) { + final Message messageWrappedByData = Proton.message(); + + int payloadSize = getDataSerializedSize(amqpMessage); + int allocationSize = Math.min(payloadSize + ClientConstants.MAX_EVENTHUB_AMQP_HEADER_SIZE_BYTES, maxMessageSizeTemp); + + byte[] messageBytes = new byte[allocationSize]; + int messageSizeBytes = amqpMessage.encode(messageBytes, 0, allocationSize); + messageWrappedByData.setBody(new Data(new Binary(messageBytes, 0, messageSizeBytes))); + + try { + encodedSize = messageWrappedByData.encode(bytes, byteArrayOffset, maxMessageSizeTemp - byteArrayOffset - 1); + } catch (BufferOverflowException exception) { + final String message = String.format(Locale.US, "Size of the payload exceeded Maximum message size: %s kb", maxMessageSizeTemp / 1024); + final AmqpException error = new AmqpException(false, ErrorCondition.LINK_PAYLOAD_SIZE_EXCEEDED, message, + exception, handler.getErrorContext(sender)); + + return Mono.error(error); + } + + byteArrayOffset = byteArrayOffset + encodedSize; + } + + return send(bytes, byteArrayOffset, AmqpConstants.AMQP_BATCH_MESSAGE_FORMAT); + } + + @Override + public ErrorContext getErrorContext() { + return handler.getErrorContext(sender); + } + + @Override + public String getLinkName() { + return sender.getName(); + } + + @Override + public String getEntityPath() { + return entityPath; + } + + @Override + public void close() { + subscriptions.dispose(); + tokenManager.close(); + super.close(); + } + + private Mono send(byte[] bytes, int arrayOffset, int messageFormat) { + return Mono.create(sink -> { + send(new RetriableWorkItem(bytes, arrayOffset, messageFormat, sink, timeout)); + }); + } + + private void send(RetriableWorkItem workItem) { + final String deliveryTag = UUID.randomUUID().toString().replace("-", ""); + + synchronized (pendingSendLock) { + this.pendingSendsMap.put(deliveryTag, workItem); + this.pendingSendsQueue.offer(new WeightedDeliveryTag(deliveryTag, workItem.hasBeenRetried() ? 1 : 0)); + } + + this.scheduleWorkOnDispatcher(); + } + + /** + * Invokes work on the Reactor. Should only be called from ReactorDispatcher.invoke() + */ + private void processSendWork() { + if (!hasConnected.get()) { + logger.asWarning().log("Not connected. Not processing send work."); + return; + } + + while (hasConnected.get() && sender.getCredit() > 0) { + final WeightedDeliveryTag weightedDelivery; + final RetriableWorkItem workItem; + final String deliveryTag; + synchronized (pendingSendLock) { + weightedDelivery = this.pendingSendsQueue.poll(); + if (weightedDelivery != null) { + deliveryTag = weightedDelivery.getDeliveryTag(); + workItem = this.pendingSendsMap.get(deliveryTag); + } else { + workItem = null; + deliveryTag = null; + } + } + + if (workItem == null) { + if (deliveryTag != null) { + logger.asVerbose().log("clientId[{}]. path[{}], linkName[{}], deliveryTag[{}]: sendData not found for this delivery.", + handler.getConnectionId(), entityPath, getLinkName(), deliveryTag); + } + + //TODO (conniey): Should we update to continue rather than break? + break; + } + + Delivery delivery = null; + boolean linkAdvance = false; + int sentMsgSize = 0; + Exception sendException = null; + + try { + delivery = sender.delivery(deliveryTag.getBytes(UTF_8)); + delivery.setMessageFormat(workItem.messageFormat()); + + sentMsgSize = sender.send(workItem.message(), 0, workItem.encodedMessageSize()); + assert sentMsgSize == workItem.encodedMessageSize() : "Contract of the ProtonJ library for Sender.Send API changed"; + + linkAdvance = sender.advance(); + } catch (Exception exception) { + sendException = exception; + } + + if (linkAdvance) { + logger.asVerbose().log("entityPath[{}], clinkName[{}], deliveryTag[{}]: Sent message", entityPath, getLinkName(), deliveryTag); + + workItem.setIsWaitingForAck(); + sendTimeoutTimer.schedule(new SendTimeout(deliveryTag), timeout.toMillis()); + } else { + logger.asVerbose().log( + "clientId[{}]. path[{}], linkName[{}], deliveryTag[{}], sentMessageSize[{}], payloadActualSize[{}]: sendlink advance failed", + handler.getConnectionId(), entityPath, getLinkName(), deliveryTag, sentMsgSize, workItem.encodedMessageSize()); + + if (delivery != null) { + delivery.free(); + } + + final ErrorContext context = handler.getErrorContext(sender); + final Throwable exception = sendException != null + ? new OperationCancelledException(String.format(Locale.US, "Entity(%s): send operation failed. Please see cause for more details", entityPath), sendException, context) + : new OperationCancelledException(String.format(Locale.US, "Entity(%s): send operation failed while advancing delivery(tag: %s).", entityPath, deliveryTag), context); + + workItem.sink().error(exception); + } + } + } + + private void processDeliveredMessage(Delivery delivery) { + final DeliveryState outcome = delivery.getRemoteState(); + final String deliveryTag = new String(delivery.getTag(), UTF_8); + + logger.asVerbose().log("entityPath[{}], clinkName[{}], deliveryTag[{}]: process delivered message", + entityPath, getLinkName(), deliveryTag); + + final RetriableWorkItem workItem = pendingSendsMap.remove(deliveryTag); + + if (workItem == null) { + logger.asVerbose().log("clientId[{}]. path[{}], linkName[{}], delivery[{}] - mismatch (or send timed out)", + handler.getConnectionId(), entityPath, getLinkName(), deliveryTag); + return; + } + + if (outcome instanceof Accepted) { + synchronized (errorConditionLock) { + this.lastKnownLinkError = null; + this.retry.resetRetryInterval(); + } + + workItem.sink().success(); + } else if (outcome instanceof Rejected) { + final Rejected rejected = (Rejected) outcome; + final org.apache.qpid.proton.amqp.transport.ErrorCondition error = rejected.getError(); + final Exception exception = ExceptionUtil.toException(error.getCondition().toString(), + error.getDescription(), handler.getErrorContext(sender)); + + if (isGeneralSendError(error.getCondition())) { + synchronized (errorConditionLock) { + this.lastKnownLinkError = exception; + this.retry.incrementRetryCount(); + } + } + + final Duration retryInterval = retry.getNextRetryInterval(exception, workItem.timeoutTracker().remaining()); + + if (retryInterval == null) { + this.cleanupFailedSend(workItem, exception); + } else { + workItem.lastKnownException(exception); + try { + reactorProvider.getReactorDispatcher().invoke(() -> send(workItem), retryInterval); + } catch (IOException | RejectedExecutionException schedulerException) { + exception.initCause(schedulerException); + this.cleanupFailedSend( + workItem, + new AmqpException(false, + String.format(Locale.US, "Entity(%s): send operation failed while scheduling a" + + " retry on Reactor, see cause for more details.", entityPath), + schedulerException, handler.getErrorContext(sender))); + } + } + } else if (outcome instanceof Released) { + this.cleanupFailedSend(workItem, new OperationCancelledException(outcome.toString(), + handler.getErrorContext(sender))); + } else { + this.cleanupFailedSend(workItem, new AmqpException(false, outcome.toString(), + handler.getErrorContext(sender))); + } + } + + private void scheduleWorkOnDispatcher() { + try { + reactorProvider.getReactorDispatcher().invoke(this::processSendWork); + } catch (IOException e) { + logger.asError().log("Error scheduling work on reactor.", e); + notifyError(e); + } + } + + private void cleanupFailedSend(final RetriableWorkItem workItem, final Exception exception) { + //TODO (conniey): is there some timeout task I should handle? + workItem.sink().error(exception); + } + + private static boolean isGeneralSendError(Symbol amqpError) { + return (amqpError == AmqpErrorCode.SERVER_BUSY_ERROR || amqpError == AmqpErrorCode.TIMEOUT_ERROR + || amqpError == AmqpErrorCode.RESOURCE_LIMIT_EXCEEDED); + } + + private static class WeightedDeliveryTag { + private final String deliveryTag; + private final int priority; + + WeightedDeliveryTag(final String deliveryTag, final int priority) { + this.deliveryTag = deliveryTag; + this.priority = priority; + } + + private String getDeliveryTag() { + return this.deliveryTag; + } + + private int getPriority() { + return this.priority; + } + } + + private static class DeliveryTagComparator implements Comparator, Serializable { + private static final long serialVersionUID = -7057500582037295635L; + + @Override + public int compare(WeightedDeliveryTag deliveryTag0, WeightedDeliveryTag deliveryTag1) { + return deliveryTag1.getPriority() - deliveryTag0.getPriority(); + } + } + + /** + * Keeps track of Messages that have been sent, but may not have been acknowledged by the service. + */ + private class SendTimeout extends TimerTask { + private final String deliveryTag; + + SendTimeout(String deliveryTag) { + this.deliveryTag = deliveryTag; + } + + @Override + public void run() { + final RetriableWorkItem workItem = pendingSendsMap.remove(deliveryTag); + if (workItem == null) { + return; + } + + Exception exceptionUsed = lastKnownLinkError; + final Exception lastError; + final Instant lastErrorTime; + + synchronized (errorConditionLock) { + lastError = lastKnownLinkError; + lastErrorTime = lastKnownErrorReportedAt; + } + + if (lastError != null) { + final Instant now = Instant.now(); + final Instant duration = now.minusSeconds(ClientConstants.SERVER_BUSY_BASE_SLEEP_TIME_IN_SECS); + final boolean isServerBusy = (lastError instanceof AmqpException) && lastErrorTime.isAfter(duration); + + final Instant timedOut = now.minusMillis(timeout.toMillis()); + exceptionUsed = isServerBusy || lastErrorTime.isAfter(timedOut) + ? lastError + : null; + } + + // If it is a type of AmqpException, we received this error from the service, otherwise, it is a client-side + // error. + final AmqpException exception; + if (exceptionUsed instanceof AmqpException) { + exception = (AmqpException) exceptionUsed; + } else { + exception = new AmqpException(true, ErrorCondition.TIMEOUT_ERROR, + String.format(Locale.US, "Entity(%s): Send operation timed out", entityPath), + handler.getErrorContext(sender)); + } + + workItem.sink().error(exception); + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorSession.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorSession.java new file mode 100644 index 000000000000..4ce72fa7ad67 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/ReactorSession.java @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.AmqpEndpointState; +import com.azure.core.amqp.AmqpLink; +import com.azure.core.amqp.CBSNode; +import com.azure.core.amqp.Retry; +import com.azure.core.implementation.util.ImplUtils; +import com.azure.core.util.logging.ClientLogger; +import com.azure.messaging.eventhubs.EventHubProducer; +import com.azure.messaging.eventhubs.implementation.handler.ReceiveLinkHandler; +import com.azure.messaging.eventhubs.implementation.handler.SendLinkHandler; +import com.azure.messaging.eventhubs.implementation.handler.SessionHandler; +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.UnknownDescribedType; +import org.apache.qpid.proton.amqp.messaging.Source; +import org.apache.qpid.proton.amqp.messaging.Target; +import org.apache.qpid.proton.amqp.transport.ReceiverSettleMode; +import org.apache.qpid.proton.amqp.transport.SenderSettleMode; +import org.apache.qpid.proton.engine.BaseHandler; +import org.apache.qpid.proton.engine.EndpointState; +import org.apache.qpid.proton.engine.Receiver; +import org.apache.qpid.proton.engine.Sender; +import org.apache.qpid.proton.engine.Session; +import reactor.core.Disposable; +import reactor.core.Disposables; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +class ReactorSession extends EndpointStateNotifierBase implements EventHubSession { + private static final Symbol EPOCH = Symbol.valueOf(AmqpConstants.VENDOR + ":epoch"); + private static final Symbol RECEIVER_IDENTIFIER_NAME = Symbol.valueOf(AmqpConstants.VENDOR + ":receiver-name"); + + private final ConcurrentMap openSendLinks = new ConcurrentHashMap<>(); + private final ConcurrentMap openReceiveLinks = new ConcurrentHashMap<>(); + + private final Session session; + private final SessionHandler sessionHandler; + private final String sessionName; + private final ReactorProvider provider; + private final TokenResourceProvider audienceProvider; + private final Duration openTimeout; + private final Disposable.Composite subscriptions; + private final ReactorHandlerProvider handlerProvider; + private final Mono cbsNodeSupplier; + + ReactorSession(Session session, SessionHandler sessionHandler, String sessionName, ReactorProvider provider, + ReactorHandlerProvider handlerProvider, Mono cbsNodeSupplier, + TokenResourceProvider audienceProvider, Duration openTimeout) { + super(new ClientLogger(ReactorSession.class)); + this.session = session; + this.sessionHandler = sessionHandler; + this.handlerProvider = handlerProvider; + this.sessionName = sessionName; + this.provider = provider; + this.cbsNodeSupplier = cbsNodeSupplier; + this.audienceProvider = audienceProvider; + this.openTimeout = openTimeout; + + this.subscriptions = Disposables.composite( + this.sessionHandler.getEndpointStates().subscribe( + this::notifyEndpointState, + this::notifyError, + () -> notifyEndpointState(EndpointState.CLOSED)), + this.sessionHandler.getErrors().subscribe( + this::notifyError, + this::notifyError, + () -> notifyEndpointState(EndpointState.CLOSED))); + + session.open(); + } + + Session session() { + return this.session; + } + + @Override + public void close() { + openReceiveLinks.forEach((key, link) -> { + try { + link.close(); + } catch (IOException e) { + logger.asError().log("Error closing send link: " + key, e); + } + }); + openReceiveLinks.clear(); + + openSendLinks.forEach((key, link) -> { + try { + link.close(); + } catch (IOException e) { + logger.asError().log("Error closing receive link: " + key, e); + } + }); + openSendLinks.clear(); + subscriptions.dispose(); + super.close(); + } + + @Override + public String getSessionName() { + return sessionName; + } + + @Override + public Duration getOperationTimeout() { + return openTimeout; + } + + @Override + public Mono createProducer(String linkName, String entityPath, Duration timeout, Retry retry) { + final ActiveClientTokenManager tokenManager = createTokenManager(entityPath); + + return getConnectionStates().takeUntil(state -> state == AmqpEndpointState.ACTIVE) + .timeout(timeout) + .then(tokenManager.authorize().then(Mono.create(sink -> { + final AmqpSendLink existingSender = openSendLinks.get(linkName); + if (existingSender != null) { + sink.success(existingSender); + return; + } + + final Sender sender = session.sender(linkName); + final Target target = new Target(); + + target.setAddress(entityPath); + sender.setTarget(target); + + final Source source = new Source(); + sender.setSource(source); + sender.setSenderSettleMode(SenderSettleMode.UNSETTLED); + + final SendLinkHandler sendLinkHandler = handlerProvider.createSendLinkHandler( + sessionHandler.getConnectionId(), sessionHandler.getHostname(), linkName, entityPath); + BaseHandler.setHandler(sender, sendLinkHandler); + + try { + provider.getReactorDispatcher().invoke(() -> { + sender.open(); + final ReactorSender reactorSender = new ReactorSender(entityPath, sender, sendLinkHandler, provider, tokenManager, timeout, retry, EventHubProducer.MAX_MESSAGE_LENGTH_BYTES); + openSendLinks.put(linkName, reactorSender); + sink.success(reactorSender); + }); + } catch (IOException e) { + sink.error(e); + } + }))); + } + + @Override + public Mono createConsumer(String linkName, String entityPath, Duration timeout, Retry retry) { + return createConsumer(linkName, entityPath, "", timeout, retry, null, null); + } + + @Override + public Mono createConsumer(String linkName, String entityPath, String eventPositionExpression, + Duration timeout, Retry retry, Long ownerLevel, String consumerIdentifier) { + final ActiveClientTokenManager tokenManager = createTokenManager(entityPath); + + return getConnectionStates().takeUntil(state -> state == AmqpEndpointState.ACTIVE) + .timeout(timeout) + .then(tokenManager.authorize().then(Mono.create(sink -> { + final AmqpReceiveLink existingReceiver = openReceiveLinks.get(linkName); + if (existingReceiver != null) { + sink.success(existingReceiver); + return; + } + + final Receiver receiver = session.receiver(linkName); + + final Source source = new Source(); + source.setAddress(entityPath); + + if (!ImplUtils.isNullOrEmpty(eventPositionExpression)) { + final Map filter = new HashMap<>(); + filter.put(AmqpConstants.STRING_FILTER, new UnknownDescribedType(AmqpConstants.STRING_FILTER, eventPositionExpression)); + source.setFilter(filter); + } + + //TODO (conniey): support creating a filter when we've already received some events. I believe this in + // the cause of recreating a failing link. + // final Map filterMap = MessageReceiver.this.settingsProvider.getFilter(MessageReceiver.this.lastReceivedMessage); + // if (filterMap != null) { + // source.setFilter(filterMap); + // } + + receiver.setSource(source); + + final Target target = new Target(); + receiver.setTarget(target); + + // Use explicit settlement via dispositions (not pre-settled) + receiver.setSenderSettleMode(SenderSettleMode.UNSETTLED); + receiver.setReceiverSettleMode(ReceiverSettleMode.SECOND); + + Map properties = new HashMap<>(); + if (ownerLevel != null) { + properties.put(EPOCH, ownerLevel); + } + if (!ImplUtils.isNullOrEmpty(consumerIdentifier)) { + properties.put(RECEIVER_IDENTIFIER_NAME, consumerIdentifier); + } + if (!properties.isEmpty()) { + receiver.setProperties(properties); + } + + // TODO (conniey): After preview 1 feature to enable keeping partition information updated. + // static final Symbol ENABLE_RECEIVER_RUNTIME_METRIC_NAME = Symbol.valueOf(VENDOR + ":enable-receiver-runtime-metric"); + // if (keepPartitionInformationUpdated) { + // receiver.setDesiredCapabilities(new Symbol[]{ENABLE_RECEIVER_RUNTIME_METRIC_NAME}); + // } + + final ReceiveLinkHandler receiveLinkHandler = handlerProvider.createReceiveLinkHandler( + sessionHandler.getConnectionId(), sessionHandler.getHostname(), linkName, entityPath); + BaseHandler.setHandler(receiver, receiveLinkHandler); + + try { + provider.getReactorDispatcher().invoke(() -> { + receiver.open(); + + final ReactorReceiver reactorReceiver = new ReactorReceiver(entityPath, receiver, receiveLinkHandler, tokenManager); + + openReceiveLinks.put(linkName, reactorReceiver); + sink.success(reactorReceiver); + }); + } catch (IOException e) { + sink.error(e); + } + }))); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean removeLink(String linkName) { + return (openSendLinks.remove(linkName) != null) || openReceiveLinks.remove(linkName) != null; + } + + private ActiveClientTokenManager createTokenManager(String entityPath) { + final String tokenAudience = audienceProvider.getResourceString(entityPath); + return new ActiveClientTokenManager(cbsNodeSupplier, tokenAudience); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/RequestResponseChannel.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/RequestResponseChannel.java new file mode 100644 index 000000000000..b5d052eef906 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/RequestResponseChannel.java @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.exception.AmqpException; +import com.azure.core.amqp.exception.AmqpResponseCode; +import com.azure.core.amqp.exception.ExceptionUtil; +import com.azure.core.util.logging.ClientLogger; +import com.azure.messaging.eventhubs.implementation.handler.ReceiveLinkHandler; +import com.azure.messaging.eventhubs.implementation.handler.SendLinkHandler; +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.amqp.UnsignedLong; +import org.apache.qpid.proton.amqp.messaging.Source; +import org.apache.qpid.proton.amqp.messaging.Target; +import org.apache.qpid.proton.amqp.transport.ReceiverSettleMode; +import org.apache.qpid.proton.amqp.transport.SenderSettleMode; +import org.apache.qpid.proton.engine.BaseHandler; +import org.apache.qpid.proton.engine.Delivery; +import org.apache.qpid.proton.engine.EndpointState; +import org.apache.qpid.proton.engine.Receiver; +import org.apache.qpid.proton.engine.Sender; +import org.apache.qpid.proton.engine.Session; +import org.apache.qpid.proton.message.Message; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoSink; + +import java.io.Closeable; +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.ConcurrentSkipListMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +import static java.nio.charset.StandardCharsets.UTF_8; + +class RequestResponseChannel implements Closeable { + private static final String STATUS_CODE = "status-code"; + private static final String STATUS_DESCRIPTION = "status-description"; + + private final ConcurrentSkipListMap> unconfirmedSends = new ConcurrentSkipListMap<>(); + private final ClientLogger logger = new ClientLogger(RequestResponseChannel.class); + + private final Sender sendLink; + private final Receiver receiveLink; + private final String replyTo; + private final AtomicBoolean hasOpened = new AtomicBoolean(); + private final AtomicLong requestId = new AtomicLong(0); + private final SendLinkHandler sendLinkHandler; + private final ReceiveLinkHandler receiveLinkHandler; + private final Disposable subscription; + + RequestResponseChannel(String connectionId, String host, String linkName, String path, Session session, + ReactorHandlerProvider handlerProvider) { + this.replyTo = path.replace("$", "") + "-client-reply-to"; + this.sendLink = session.sender(linkName + ":sender"); + final Target target = new Target(); + target.setAddress(path); + this.sendLink.setTarget(target); + sendLink.setSource(new Source()); + this.sendLink.setSenderSettleMode(SenderSettleMode.SETTLED); + this.sendLinkHandler = handlerProvider.createSendLinkHandler(connectionId, host, linkName, path); + BaseHandler.setHandler(sendLink, sendLinkHandler); + + this.receiveLink = session.receiver(linkName + ":receiver"); + final Source source = new Source(); + source.setAddress(path); + this.receiveLink.setSource(source); + final Target receiverTarget = new Target(); + receiverTarget.setAddress(replyTo); + this.receiveLink.setTarget(receiverTarget); + this.receiveLink.setSenderSettleMode(SenderSettleMode.SETTLED); + this.receiveLink.setReceiverSettleMode(ReceiverSettleMode.SECOND); + this.receiveLinkHandler = handlerProvider.createReceiveLinkHandler(connectionId, host, linkName, path); + BaseHandler.setHandler(this.receiveLink, receiveLinkHandler); + + this.subscription = receiveLinkHandler.getDeliveredMessages().map(this::decodeDelivery).subscribe(message -> { + logger.asVerbose().log("Settling message: {}", message.getCorrelationId()); + settleMessage(message); + }, this::handleException); + } + + @Override + public void close() { + this.subscription.dispose(); + + if (hasOpened.getAndSet(false)) { + sendLink.close(); + receiveLink.close(); + } + } + + Mono sendWithAck(final Message message, final ReactorDispatcher dispatcher) { + start(); + + if (message == null) { + throw new IllegalArgumentException("message cannot be null"); + } + if (message.getMessageId() != null) { + throw new IllegalArgumentException("message.getMessageId() should be null"); + } + if (message.getReplyTo() != null) { + throw new IllegalArgumentException("message.getReplyTo() should be null"); + } + + final UnsignedLong messageId = UnsignedLong.valueOf(requestId.incrementAndGet()); + message.setMessageId(messageId); + message.setReplyTo(replyTo); + + //TODO (conniey): timeout here if we can't get the link handlers to pass an "Active" state. + return Mono.when( + sendLinkHandler.getEndpointStates().takeUntil(x -> x == EndpointState.ACTIVE), + receiveLinkHandler.getEndpointStates().takeUntil(x -> x == EndpointState.ACTIVE)).then( + Mono.create(sink -> { + try { + logger.asVerbose().log("Scheduling on dispatcher. Message Id {}", messageId); + + dispatcher.invoke(() -> { + unconfirmedSends.putIfAbsent(messageId, sink); + send(message); + }); + } catch (IOException e) { + sink.error(e); + } + })); + } + + private void start() { + if (!hasOpened.getAndSet(true)) { + sendLink.open(); + receiveLink.open(); + } + } + + // Not thread-safe This must be invoked from reactor/dispatcher thread. And assumes that this is run on a link + // that is open. + private void send(final Message message) { + sendLink.delivery(UUID.randomUUID().toString().replace("-", "").getBytes(UTF_8)); + + final int payloadSize = EventDataUtil.getDataSerializedSize(message) + ClientConstants.MAX_EVENTHUB_AMQP_HEADER_SIZE_BYTES; + final byte[] bytes = new byte[payloadSize]; + final int encodedSize = message.encode(bytes, 0, payloadSize); + + receiveLink.flow(1); + sendLink.send(bytes, 0, encodedSize); + sendLink.advance(); + } + + private Message decodeDelivery(Delivery delivery) { + final Message response = Proton.message(); + final int msgSize = delivery.pending(); + final byte[] buffer = new byte[msgSize]; + + final int read = receiveLink.recv(buffer, 0, msgSize); + + response.decode(buffer, 0, read); + delivery.settle(); + + return response; + } + + private void settleMessage(Message message) { + final String id = String.valueOf(message.getCorrelationId()); + final UnsignedLong correlationId = UnsignedLong.valueOf(id); + final MonoSink sink = unconfirmedSends.remove(correlationId); + + if (sink == null) { + logger.asWarning().log("Received a delivery that was not a known pending message: {}", id); + return; + } + + final int statusCode = (int) message.getApplicationProperties().getValue().get(STATUS_CODE); + + if (statusCode != AmqpResponseCode.ACCEPTED.getValue() && statusCode != AmqpResponseCode.OK.getValue()) { + final String statusDescription = (String) message.getApplicationProperties().getValue().get(STATUS_DESCRIPTION); + + sink.error(ExceptionUtil.amqpResponseCodeToException(statusCode, statusDescription, + receiveLinkHandler.getErrorContext(receiveLink))); + } else { + sink.success(message); + } + } + + private void handleException(Throwable error) { + if (error instanceof AmqpException) { + AmqpException exception = (AmqpException) error; + + if (!exception.isTransient()) { + logger.asError().log("Exception encountered. Closing channel and clearing unconfirmed sends.", exception); + close(); + + unconfirmedSends.forEach((key, value) -> { + value.error(error); + }); + } + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/RetriableWorkItem.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/RetriableWorkItem.java new file mode 100644 index 000000000000..4d0cc52b61d5 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/RetriableWorkItem.java @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import reactor.core.publisher.MonoSink; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Represents a work item that can be scheduled multiple times. + */ +class RetriableWorkItem { + private final AtomicInteger retryAttempts = new AtomicInteger(); + private final MonoSink monoSink; + private final TimeoutTracker timeoutTracker; + private final byte[] amqpMessage; + private final int messageFormat; + private final int encodedMessageSize; + + private boolean waitingForAck; + private Exception lastKnownException; + + RetriableWorkItem(byte[] amqpMessage, int encodedMessageSize, int messageFormat, MonoSink monoSink, Duration timeout) { + this(amqpMessage, encodedMessageSize, messageFormat, monoSink, new TimeoutTracker(timeout, false)); + } + + private RetriableWorkItem(byte[] amqpMessage, int encodedMessageSize, int messageFormat, MonoSink monoSink, TimeoutTracker timeout) { + this.amqpMessage = amqpMessage; + this.encodedMessageSize = encodedMessageSize; + this.messageFormat = messageFormat; + this.monoSink = monoSink; + this.timeoutTracker = timeout; + } + + byte[] message() { + return amqpMessage; + } + + TimeoutTracker timeoutTracker() { + return timeoutTracker; + } + + MonoSink sink() { + return monoSink; + } + + int incrementRetryAttempts() { + return retryAttempts.incrementAndGet(); + } + + boolean hasBeenRetried() { + return retryAttempts.get() == 0; + } + + int encodedMessageSize() { + return encodedMessageSize; + } + + int messageFormat() { + return messageFormat; + } + + Exception lastKnownException() { + return this.lastKnownException; + } + + void lastKnownException(Exception exception) { + this.lastKnownException = exception; + } + + void setIsWaitingForAck() { + this.waitingForAck = true; + } + + boolean isWaitingForAck() { + return this.waitingForAck; + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/StringUtil.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/StringUtil.java new file mode 100644 index 000000000000..fef19437d7b2 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/StringUtil.java @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.implementation.util.ImplUtils; + +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.Locale; +import java.util.UUID; + +public final class StringUtil { + public static String getRandomString(String prefix) { + return String.format(Locale.US, "%s_%s_%s", prefix, UUID.randomUUID().toString().substring(0, 6), Instant.now().toEpochMilli()); + } + + public static String toStackTraceString(final Throwable exception, final String customErrorMessage) { + final StringBuilder builder = new StringBuilder(); + + if (!ImplUtils.isNullOrEmpty(customErrorMessage)) { + builder.append(customErrorMessage); + builder.append(System.lineSeparator()); + } + + builder.append(exception.getMessage()); + final StackTraceElement[] stackTraceElements = exception.getStackTrace(); + for (final StackTraceElement ste : stackTraceElements) { + builder.append(System.lineSeparator()); + builder.append(ste.toString()); + } + + final Throwable innerException = exception.getCause(); + if (innerException != null) { + builder.append("Cause: ").append(innerException.getMessage()); + final StackTraceElement[] innerStackTraceElements = innerException.getStackTrace(); + for (final StackTraceElement ste : innerStackTraceElements) { + builder.append(System.lineSeparator()); + builder.append(ste.toString()); + } + } + + return builder.toString(); + } + + public static String getTrackingIDAndTimeToLog() { + return String.format(Locale.US, "TrackingId: %s, at: %s", UUID.randomUUID().toString(), ZonedDateTime.now()); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/TimeoutTracker.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/TimeoutTracker.java new file mode 100644 index 000000000000..fc8012ce7c5a --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/TimeoutTracker.java @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import java.time.Duration; +import java.time.Instant; + +class TimeoutTracker { + private final Duration originalTimeout; + private boolean isTimerStarted; + private Instant startTime; + + /** + * Creates an instance to keep track of timed out sends. + * + * @param timeout original operationTimeout + * @param startTrackingTimeout whether/not to start the timeout tracking - right now. If not started now, timer + * tracking will start upon the first call to {@link TimeoutTracker#elapsed()}/{@link TimeoutTracker#remaining()} + */ + TimeoutTracker(Duration timeout, boolean startTrackingTimeout) { + if (timeout.compareTo(Duration.ZERO) < 0) { + throw new IllegalArgumentException("timeout should be non-negative"); + } + + this.originalTimeout = timeout; + + if (startTrackingTimeout) { + this.startTime = Instant.now(); + } + + this.isTimerStarted = startTrackingTimeout; + } + + Duration remaining() { + return this.originalTimeout.minus(elapsed()); + } + + Duration elapsed() { + if (!this.isTimerStarted) { + this.startTime = Instant.now(); + this.isTimerStarted = true; + } + + return Duration.between(this.startTime, Instant.now()); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/TokenResourceProvider.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/TokenResourceProvider.java new file mode 100644 index 000000000000..47a03bd1d7ff --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/TokenResourceProvider.java @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import java.util.Locale; +import java.util.Objects; + +/** + * Generates the correct resource scope to access Event Hubs resources given the authorization type. + */ +class TokenResourceProvider { + private static final String TOKEN_AUDIENCE_FORMAT = "amqp://%s/%s"; + private static final String AZURE_ACTIVE_DIRECTORY_SCOPE = "https://eventhubs.azure.net//.default"; + + private final CBSAuthorizationType authorizationType; + private final String host; + + TokenResourceProvider(CBSAuthorizationType authorizationType, String host) { + Objects.requireNonNull(authorizationType); + Objects.requireNonNull(host); + + this.host = host; + this.authorizationType = authorizationType; + } + + String getResourceString(String resource) { + switch (authorizationType) { + case JSON_WEB_TOKEN: + return AZURE_ACTIVE_DIRECTORY_SCOPE; + case SHARED_ACCESS_SIGNATURE: + return String.format(Locale.US, TOKEN_AUDIENCE_FORMAT, host, resource); + default: + throw new IllegalArgumentException(String.format(Locale.US, + "'%s' is not supported authorization type for token audience.", authorizationType)); + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/ConnectionHandler.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/ConnectionHandler.java new file mode 100644 index 000000000000..f74092b11483 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/ConnectionHandler.java @@ -0,0 +1,281 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation.handler; + +import com.azure.core.amqp.exception.ErrorContext; +import com.azure.core.amqp.exception.ExceptionUtil; +import com.azure.core.util.logging.ClientLogger; +import com.azure.messaging.eventhubs.implementation.ClientConstants; +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.transport.ErrorCondition; +import org.apache.qpid.proton.engine.Connection; +import org.apache.qpid.proton.engine.EndpointState; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.engine.SslDomain; +import org.apache.qpid.proton.engine.Transport; +import org.apache.qpid.proton.engine.impl.TransportInternal; +import org.apache.qpid.proton.reactor.Handshaker; + +import java.util.HashMap; +import java.util.Map; + +public class ConnectionHandler extends Handler { + static final Symbol PRODUCT = Symbol.valueOf("product"); + static final Symbol VERSION = Symbol.valueOf("version"); + static final Symbol PLATFORM = Symbol.valueOf("platform"); + static final Symbol FRAMEWORK = Symbol.valueOf("framework"); + static final Symbol USER_AGENT = Symbol.valueOf("user-agent"); + + static final int MAX_USER_AGENT_LENGTH = 128; + static final int AMQPS_PORT = 5671; + static final int MAX_FRAME_SIZE = 65536; + + private final ClientLogger logger; + private final Map connectionProperties; + + /** + * Creates a handler that handles proton-j's connection events. + * + * @param connectionId Identifier for this connection. + * @param hostname Hostname to use for socket creation. If there is a proxy configured, this could be a proxy's IP + * address. + */ + public ConnectionHandler(final String connectionId, final String hostname) { + this(connectionId, hostname, new ClientLogger(ConnectionHandler.class)); + } + + /** + * Creates a handler that handles proton-j's connection events. + * + * @param connectionId Identifier for this connection. + * @param hostname Hostname to use for socket creation. If there is a proxy configured, this could be a proxy's IP + * address. + * @param logger The service logger to use. + */ + protected ConnectionHandler(final String connectionId, final String hostname, final ClientLogger logger) { + super(connectionId, hostname); + + add(new Handshaker()); + this.logger = logger; + + this.connectionProperties = new HashMap<>(); + this.connectionProperties.put(PRODUCT.toString(), ClientConstants.PRODUCT_NAME); + this.connectionProperties.put(VERSION.toString(), ClientConstants.CURRENT_JAVACLIENT_VERSION); + this.connectionProperties.put(PLATFORM.toString(), ClientConstants.PLATFORM_INFO); + this.connectionProperties.put(FRAMEWORK.toString(), ClientConstants.FRAMEWORK_INFO); + + final String userAgent = ClientConstants.USER_AGENT.length() <= MAX_USER_AGENT_LENGTH + ? ClientConstants.USER_AGENT + : ClientConstants.USER_AGENT.substring(0, MAX_USER_AGENT_LENGTH); + + this.connectionProperties.put(USER_AGENT.toString(), userAgent); + } + + public Map getConnectionProperties() { + return connectionProperties; + } + + protected void addTransportLayers(final Event event, final TransportInternal transport) { + final SslDomain domain = createSslDomain(SslDomain.Mode.CLIENT); + transport.ssl(domain); + } + + @Override + public void onConnectionInit(Event event) { + logger.asInfo().log("onConnectionInit hostname[{}], connectionId[{}]", getHostname(), getConnectionId()); + + final Connection connection = event.getConnection(); + final String hostName = getHostname() + ":" + getProtocolPort(); + + connection.setHostname(hostName); + connection.setContainer(getConnectionId()); + + final Map properties = new HashMap<>(); + connectionProperties.forEach((key, value) -> properties.put(Symbol.getSymbol(key), value)); + + connection.setProperties(properties); + connection.open(); + } + + /** + * Gets the port used when opening connection. + * + * @return The port used to open connection. + */ + public int getProtocolPort() { + return AMQPS_PORT; + } + + /** + * Gets the max frame size for this connection. + * + * @return The max frame size for this connection. + */ + public int getMaxFrameSize() { + return MAX_FRAME_SIZE; + } + + @Override + public void onConnectionBound(Event event) { + logger.asInfo().log("onConnectionBound hostname[{}], connectionId[{}]", getHostname(), getConnectionId()); + + final Transport transport = event.getTransport(); + + this.addTransportLayers(event, (TransportInternal) transport); + + final Connection connection = event.getConnection(); + if (connection != null) { + onNext(connection.getRemoteState()); + } + } + + @Override + public void onConnectionUnbound(Event event) { + final Connection connection = event.getConnection(); + logger.asInfo().log("onConnectionUnbound hostname[{}], connectionId[{}], state[{}], remoteState[{}]", + connection.getHostname(), getConnectionId(), connection.getLocalState(), connection.getRemoteState()); + + // if failure happened while establishing transport - nothing to free up. + if (connection.getRemoteState() != EndpointState.UNINITIALIZED) { + connection.free(); + } + + onNext(connection.getRemoteState()); + } + + @Override + public void onTransportError(Event event) { + final Connection connection = event.getConnection(); + final Transport transport = event.getTransport(); + final ErrorCondition condition = transport.getCondition(); + + logger.asWarning().log("onTransportError hostname[{}], connectionId[{}], error[{}]", + connection != null ? connection.getHostname() : ClientConstants.NOT_APPLICABLE, + getConnectionId(), + condition != null ? condition.getDescription() : ClientConstants.NOT_APPLICABLE); + + if (connection != null) { + notifyErrorContext(connection, condition); + onNext(connection.getRemoteState()); + } + + // onTransportError event is not handled by the global IO Handler for cleanup + transport.unbind(); + } + + @Override + public void onTransportClosed(Event event) { + final Connection connection = event.getConnection(); + final Transport transport = event.getTransport(); + final ErrorCondition condition = transport.getCondition(); + + logger.asInfo().log("onTransportClosed hostname[{}], connectionId[{}], error[{}]", + connection != null ? connection.getHostname() : ClientConstants.NOT_APPLICABLE, + getConnectionId(), + condition != null ? condition.getDescription() : ClientConstants.NOT_APPLICABLE); + + if (connection != null) { + notifyErrorContext(connection, condition); + onNext(connection.getRemoteState()); + } + } + + @Override + public void onConnectionLocalOpen(Event event) { + final Connection connection = event.getConnection(); + final ErrorCondition error = connection.getCondition(); + + logErrorCondition("onConnectionLocalOpen", connection, error); + } + + @Override + public void onConnectionRemoteOpen(Event event) { + final Connection connection = event.getConnection(); + + logger.asInfo().log("onConnectionRemoteOpen hostname[{}], connectionId[{}], remoteContainer[{}]", + connection.getHostname(), getConnectionId(), connection.getRemoteContainer()); + + onNext(connection.getRemoteState()); + } + + @Override + public void onConnectionLocalClose(Event event) { + final Connection connection = event.getConnection(); + final ErrorCondition error = connection.getCondition(); + + logErrorCondition("onConnectionLocalClose", connection, error); + + if (connection.getRemoteState() == EndpointState.CLOSED) { + // This means that the CLOSE origin is Service + final Transport transport = connection.getTransport(); + if (transport != null) { + transport.unbind(); // we proactively dispose IO even if service fails to close + } + } + + onNext(connection.getRemoteState()); + } + + @Override + public void onConnectionRemoteClose(Event event) { + final Connection connection = event.getConnection(); + final ErrorCondition error = connection.getRemoteCondition(); + + logErrorCondition("onConnectionRemoteClose", connection, error); + + onNext(connection.getRemoteState()); + notifyErrorContext(connection, error); + } + + @Override + public void onConnectionFinal(Event event) { + final Connection connection = event.getConnection(); + final ErrorCondition error = connection.getCondition(); + + logErrorCondition("onConnectionFinal", connection, error); + onNext(connection.getRemoteState()); + + // Complete the processors because they no longer have any work to do. + close(); + } + + public ErrorContext getErrorContext() { + return new ErrorContext(getHostname()); + } + + private static SslDomain createSslDomain(SslDomain.Mode mode) { + final SslDomain domain = Proton.sslDomain(); + domain.init(mode); + + // TODO: VERIFY_PEER_NAME support + domain.setPeerAuthentication(SslDomain.VerifyMode.ANONYMOUS_PEER); + return domain; + } + + private void notifyErrorContext(Connection connection, ErrorCondition condition) { + if (connection == null || connection.getRemoteState() == EndpointState.CLOSED) { + return; + } + + if (condition == null) { + throw new IllegalStateException("notifyErrorContext does not have an ErrorCondition."); + } + + // if the remote-peer abruptly closes the connection without issuing close frame issue one + final Throwable exception = ExceptionUtil.toException(condition.getCondition().toString(), + condition.getDescription(), getErrorContext()); + + onNext(exception); + } + + private void logErrorCondition(String eventName, Connection connection, ErrorCondition error) { + logger.asInfo().log("{} hostname[{}], connectionId[{}], errorCondition[{}], errorDescription[{}]", + eventName, + connection.getHostname(), + getConnectionId(), + error != null ? error.getCondition() : ClientConstants.NOT_APPLICABLE, + error != null ? error.getDescription() : ClientConstants.NOT_APPLICABLE); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/CustomIOHandler.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/CustomIOHandler.java new file mode 100644 index 000000000000..f788c60faa29 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/CustomIOHandler.java @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation.handler; + +import com.azure.core.util.logging.ClientLogger; +import org.apache.qpid.proton.engine.Connection; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.engine.Transport; +import org.apache.qpid.proton.reactor.impl.IOHandler; + +public class CustomIOHandler extends IOHandler { + private final ClientLogger logger = new ClientLogger(CustomIOHandler.class); + private final String connectionId; + + public CustomIOHandler(final String connectionId) { + this.connectionId = connectionId; + } + + @Override + public void onTransportClosed(Event event) { + final Transport transport = event.getTransport(); + final Connection connection = event.getConnection(); + + logger.asInfo().log("onTransportClosed name[{}], hostname[{}]", + connectionId, (connection != null ? connection.getHostname() : "n/a")); + + if (transport != null && connection != null && connection.getTransport() != null) { + transport.unbind(); + } + } + + @Override + public void onUnhandled(Event event) { + // logger.asVerbose().log("Unhandled event: {}, {}", event.getEventType(), event.toString()); + super.onUnhandled(event); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/DispatchHandler.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/DispatchHandler.java new file mode 100644 index 000000000000..7d365514c540 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/DispatchHandler.java @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation.handler; + +import com.azure.core.util.logging.ClientLogger; +import org.apache.qpid.proton.engine.BaseHandler; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.reactor.Reactor; + +import java.util.Objects; + +/** + * Base class that executes work on reactor. + */ +public class DispatchHandler extends BaseHandler { + private final ClientLogger logger = new ClientLogger(DispatchHandler.class); + private final Runnable work; + + /** + * Creates a handler that runs work on a {@link Reactor}. + * + * @param work The work to run on the {@link Reactor}. + */ + public DispatchHandler(Runnable work) { + Objects.requireNonNull(work); + this.work = work; + } + + /** + * {@inheritDoc} + */ + @Override + public void onTimerTask(Event e) { + logger.asVerbose().log("Running task for event: %s", e); + this.work.run(); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/Handler.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/Handler.java new file mode 100644 index 000000000000..0a1688f0d052 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/Handler.java @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation.handler; + +import org.apache.qpid.proton.engine.BaseHandler; +import org.apache.qpid.proton.engine.EndpointState; +import reactor.core.publisher.Flux; +import reactor.core.publisher.FluxSink; +import reactor.core.publisher.ReplayProcessor; +import reactor.core.publisher.UnicastProcessor; + +import java.io.Closeable; + +public abstract class Handler extends BaseHandler implements Closeable { + private final ReplayProcessor endpointStateProcessor = ReplayProcessor.cacheLastOrDefault(EndpointState.UNINITIALIZED); + private final UnicastProcessor errorContextProcessor = UnicastProcessor.create(); + private final FluxSink endpointSink = endpointStateProcessor.sink(); + private final FluxSink errorSink = errorContextProcessor.sink(); + private final String connectionId; + private final String hostname; + + Handler(final String connectionId, final String hostname) { + this.connectionId = connectionId; + this.hostname = hostname; + } + + public String getConnectionId() { + return connectionId; + } + + public String getHostname() { + return hostname; + } + + public Flux getEndpointStates() { + return endpointStateProcessor.distinct(); + } + + public Flux getErrors() { + return errorContextProcessor; + } + + void onNext(EndpointState state) { + endpointSink.next(state); + } + + void onNext(Throwable context) { + errorSink.next(context); + } + + @Override + public void close() { + endpointSink.complete(); + errorSink.complete(); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/LinkHandler.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/LinkHandler.java new file mode 100644 index 000000000000..1d75ebb42c42 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/LinkHandler.java @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation.handler; + +import com.azure.core.amqp.exception.ErrorContext; +import com.azure.core.amqp.exception.ExceptionUtil; +import com.azure.core.amqp.exception.LinkErrorContext; +import com.azure.core.util.logging.ClientLogger; +import com.azure.messaging.eventhubs.implementation.ClientConstants; +import org.apache.qpid.proton.amqp.transport.ErrorCondition; +import org.apache.qpid.proton.engine.EndpointState; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.engine.Link; +import org.apache.qpid.proton.engine.Session; + +import static com.azure.messaging.eventhubs.implementation.AmqpErrorCode.TRACKING_ID_PROPERTY; + +abstract class LinkHandler extends Handler { + + private final String entityPath; + ClientLogger logger; + + LinkHandler(String connectionId, String hostname, String entityPath, ClientLogger logger) { + super(connectionId, hostname); + this.entityPath = entityPath; + this.logger = logger; + } + + @Override + public void onLinkLocalClose(Event event) { + final Link link = event.getLink(); + final ErrorCondition condition = link.getCondition(); + + logger.asInfo().log("onLinkLocalClose connectionId[{}], linkName[{}], errorCondition[{}], errorDescription[{}]", + getConnectionId(), link.getName(), + condition != null ? condition.getCondition() : ClientConstants.NOT_APPLICABLE, + condition != null ? condition.getDescription() : ClientConstants.NOT_APPLICABLE); + + closeSession(link, link.getCondition()); + } + + @Override + public void onLinkRemoteClose(Event event) { + final Link link = event.getLink(); + final ErrorCondition condition = link.getRemoteCondition(); + + logger.asInfo().log("onLinkRemoteClose connectionId[{}], linkName[{}], errorCondition[{}], errorDescription[{}]", + getConnectionId(), link.getName(), + condition != null ? condition.getCondition() : ClientConstants.NOT_APPLICABLE, + condition != null ? condition.getDescription() : ClientConstants.NOT_APPLICABLE); + + handleRemoteLinkClosed(event); + } + + @Override + public void onLinkRemoteDetach(Event event) { + final Link link = event.getLink(); + final ErrorCondition condition = link.getCondition(); + + logger.asInfo().log("onLinkRemoteClose connectionId[{}], linkName[{}], errorCondition[{}], errorDescription[{}]", + getConnectionId(), link.getName(), + condition != null ? condition.getCondition() : ClientConstants.NOT_APPLICABLE, + condition != null ? condition.getDescription() : ClientConstants.NOT_APPLICABLE); + + handleRemoteLinkClosed(event); + } + + @Override + public void onLinkFinal(Event event) { + logger.asInfo().log("onLinkFinal clientName[{}], linkName[{}]", getConnectionId(), event.getLink().getName()); + close(); + } + + public ErrorContext getErrorContext(Link link) { + final String referenceId; + if (link.getRemoteProperties() != null && link.getRemoteProperties().containsKey(TRACKING_ID_PROPERTY)) { + referenceId = link.getRemoteProperties().get(TRACKING_ID_PROPERTY).toString(); + } else { + referenceId = link.getName(); + } + + return new LinkErrorContext(getHostname(), entityPath, referenceId, link.getCredit()); + } + + private void processOnClose(Link link, ErrorCondition condition) { + logger.asInfo().log("processOnClose connectionId[{}], linkName[{}], errorCondition[{}], errorDescription[{}]", + getConnectionId(), link.getName(), + condition != null ? condition.getCondition() : ClientConstants.NOT_APPLICABLE, + condition != null ? condition.getDescription() : ClientConstants.NOT_APPLICABLE); + + if (condition != null) { + final Throwable exception = ExceptionUtil.toException(condition.getCondition().toString(), + condition.getDescription(), getErrorContext(link)); + + onNext(exception); + } + + onNext(EndpointState.CLOSED); + } + + private void closeSession(Link link, ErrorCondition condition) { + final Session session = link.getSession(); + + if (session != null && session.getLocalState() != EndpointState.CLOSED) { + logger.asInfo().log("closeSession connectionId[{}], linkName[{}], errorCondition[{}], errorDescription[{}]", + getConnectionId(), link.getName(), + condition != null ? condition.getCondition() : ClientConstants.NOT_APPLICABLE, + condition != null ? condition.getDescription() : ClientConstants.NOT_APPLICABLE); + + session.setCondition(condition); + session.close(); + } + } + + private void handleRemoteLinkClosed(final Event event) { + final Link link = event.getLink(); + final ErrorCondition condition = link.getRemoteCondition(); + + if (link.getLocalState() != EndpointState.CLOSED) { + link.setCondition(condition); + link.close(); + } + + processOnClose(link, condition); + closeSession(link, condition); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/ReactorHandler.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/ReactorHandler.java new file mode 100644 index 000000000000..143879e53ae5 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/ReactorHandler.java @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation.handler; + +import com.azure.core.util.logging.ClientLogger; +import org.apache.qpid.proton.engine.BaseHandler; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.reactor.Reactor; + +import java.util.Objects; + +/** + * Handler that sets the timeout period for waiting for Selectables. + */ +public class ReactorHandler extends BaseHandler { + /** + * The specified timeout period (in milliseconds) for one or more Reactor Selectables to become ready for a + * send/receive operation. + */ + private static final int REACTOR_IO_POLL_TIMEOUT = 20; + + private final ClientLogger logger = new ClientLogger(ReactorHandler.class); + private final String name; + + public ReactorHandler(final String name) { + Objects.requireNonNull(name); + this.name = name; + } + + @Override + public void onReactorInit(Event e) { + logger.asInfo().log("name[{}] reactor.onReactorInit", name); + + final Reactor reactor = e.getReactor(); + reactor.setTimeout(REACTOR_IO_POLL_TIMEOUT); + } + + @Override + public void onReactorFinal(Event e) { + logger.asInfo().log("name[{}] reactor.onReactorFinal", name); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/ReceiveLinkHandler.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/ReceiveLinkHandler.java new file mode 100644 index 000000000000..0d95e0b4c31e --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/ReceiveLinkHandler.java @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation.handler; + +import com.azure.core.util.logging.ClientLogger; +import org.apache.qpid.proton.engine.Delivery; +import org.apache.qpid.proton.engine.EndpointState; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.engine.Link; +import org.apache.qpid.proton.engine.Receiver; +import reactor.core.publisher.Flux; +import reactor.core.publisher.FluxSink; + +import java.util.concurrent.atomic.AtomicBoolean; + +public class ReceiveLinkHandler extends LinkHandler { + private final String receiverName; + private AtomicBoolean isFirstResponse = new AtomicBoolean(true); + private final Flux deliveries; + private FluxSink deliverySink; + + public ReceiveLinkHandler(String connectionId, String host, String receiverName, String entityPath) { + super(connectionId, host, entityPath, new ClientLogger(ReceiveLinkHandler.class)); + this.deliveries = Flux.create(sink -> { + deliverySink = sink; + }); + this.receiverName = receiverName; + } + + public Flux getDeliveredMessages() { + return deliveries; + } + + @Override + public void close() { + deliverySink.complete(); + super.close(); + } + + @Override + public void onLinkLocalOpen(Event event) { + final Link link = event.getLink(); + if (link instanceof Receiver) { + logger.asInfo().log("onLinkLocalOpen receiverName[{}], linkName[{}], localSource[{}]", + receiverName, link.getName(), link.getSource()); + } + } + + @Override + public void onLinkRemoteOpen(Event event) { + final Link link = event.getLink(); + if (link instanceof Receiver) { + if (link.getRemoteSource() != null) { + logger.asInfo().log("onLinkRemoteOpen receiverName[{}], linkName[{}], remoteSource[{}]", + receiverName, link.getName(), link.getRemoteSource()); + + if (isFirstResponse.getAndSet(false)) { + onNext(EndpointState.ACTIVE); + } + } else { + logger.asInfo().log("onLinkRemoteOpen receiverName[{}], linkName[{}], action[waitingForError]", + receiverName, link.getName()); + } + } + } + + @Override + public void onDelivery(Event event) { + if (isFirstResponse.getAndSet(false)) { + onNext(EndpointState.ACTIVE); + } + + final Delivery delivery = event.getDelivery(); + final Receiver link = (Receiver) delivery.getLink(); + + // If a message spans across deliveries (for ex: 200k message will be 4 frames (deliveries) 64k 64k 64k 8k), + // all until "last-1" deliveries will be partial + // reactor will raise onDelivery event for all of these - we only need the last one + if (!delivery.isPartial()) { + // One of our customers hit an issue - where duplicate 'Delivery' events are raised to Reactor in proton-j layer + // While processing the duplicate event - reactor hits an IllegalStateException in proton-j layer + // before we fix proton-j - this work around ensures that we ignore the duplicate Delivery event + if (delivery.isSettled()) { + if (link != null) { + logger.asInfo().log("onDelivery receiverName[{}], linkName[{}], updatedLinkCredit[{}], remoteCredit[{}], " + + "remoteCondition[{}], delivery.isSettled[{}]", + receiverName, link.getName(), link.getCredit(), link.getRemoteCredit(), link.getRemoteCondition(), delivery.isSettled()); + } else { + logger.asWarning().log("delivery.isSettled[{}]", delivery.isSettled()); + } + } else { + deliverySink.next(delivery); + } + } + + if (link != null) { + logger.asVerbose().log("onDelivery receiverName[{}], linkName[{}], updatedLinkCredit[{}], remoteCredit[{}], " + + "remoteCondition[{}], delivery.isPartial[{}]", + receiverName, link.getName(), link.getCredit(), link.getRemoteCredit(), link.getRemoteCondition(), delivery.isPartial()); + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/SendLinkHandler.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/SendLinkHandler.java new file mode 100644 index 000000000000..2a3a1c846047 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/SendLinkHandler.java @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation.handler; + +import com.azure.core.util.logging.ClientLogger; +import org.apache.qpid.proton.engine.Delivery; +import org.apache.qpid.proton.engine.EndpointState; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.engine.Link; +import org.apache.qpid.proton.engine.Sender; +import reactor.core.publisher.DirectProcessor; +import reactor.core.publisher.Flux; +import reactor.core.publisher.FluxSink; +import reactor.core.publisher.UnicastProcessor; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicBoolean; + +public class SendLinkHandler extends LinkHandler { + private final String senderName; + private final AtomicBoolean isFirstFlow = new AtomicBoolean(true); + private final UnicastProcessor creditProcessor = UnicastProcessor.create(); + private final DirectProcessor deliveryProcessor = DirectProcessor.create(); + private final FluxSink creditSink = creditProcessor.sink(); + private final FluxSink deliverySink = deliveryProcessor.sink(); + + public SendLinkHandler(String connectionId, String hostname, String senderName, String entityPath) { + super(connectionId, hostname, entityPath, new ClientLogger(SendLinkHandler.class)); + this.senderName = senderName; + } + + public Flux getLinkCredits() { + return creditProcessor; + } + + public Flux getDeliveredMessages() { + return deliveryProcessor; + } + + @Override + public void close() { + creditSink.complete(); + deliverySink.complete(); + super.close(); + } + + @Override + public void onLinkLocalOpen(Event event) { + final Link link = event.getLink(); + if (link instanceof Sender) { + logger.asInfo().log("onLinkLocalOpen senderName[{}], linkName[{}], localTarget[{}]", + senderName, link.getName(), link.getTarget()); + } + } + + @Override + public void onLinkRemoteOpen(Event event) { + final Link link = event.getLink(); + if (link instanceof Sender) { + if (link.getRemoteTarget() != null) { + logger.asInfo().log("onLinkRemoteOpen senderName[{}], linkName[{}], remoteTarget[{}]", + senderName, link.getName(), link.getRemoteTarget()); + + if (isFirstFlow.getAndSet(false)) { + onNext(EndpointState.ACTIVE); + } + } else { + logger.asInfo().log("onLinkRemoteOpen senderName[{}], linkName[{}], remoteTarget[null], remoteSource[null], action[waitingForError]", + senderName, link.getName()); + } + } + } + + @Override + public void onLinkFlow(Event event) { + if (isFirstFlow.getAndSet(false)) { + onNext(EndpointState.ACTIVE); + } + + final Sender sender = event.getSender(); + creditSink.next(sender.getRemoteCredit()); + + logger.asVerbose().log("onLinkFlow senderName[{}], linkName[{}], unsettled[{}], credit[{}]", + senderName, sender.getName(), sender.getUnsettled(), sender.getCredit()); + } + + @Override + public void onDelivery(Event event) { + Delivery delivery = event.getDelivery(); + + while (delivery != null) { + Sender sender = (Sender) delivery.getLink(); + + logger.asInfo().log("onDelivery senderName[{}], linkName[{}], unsettled[{}], credit[{}], deliveryState[{}], delivery.isBuffered[{}], delivery.id[{}]", + senderName, sender.getName(), sender.getUnsettled(), sender.getRemoteCredit(), + delivery.getRemoteState(), delivery.isBuffered(), new String(delivery.getTag(), StandardCharsets.UTF_8)); + + deliverySink.next(delivery); + delivery.settle(); + delivery = sender.current(); + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/SessionHandler.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/SessionHandler.java new file mode 100644 index 000000000000..d112dc563f90 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/SessionHandler.java @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation.handler; + +import com.azure.core.amqp.exception.AmqpException; +import com.azure.core.amqp.exception.ErrorContext; +import com.azure.core.amqp.exception.ExceptionUtil; +import com.azure.core.amqp.exception.SessionErrorContext; +import com.azure.core.util.logging.ClientLogger; +import com.azure.messaging.eventhubs.implementation.ReactorDispatcher; +import com.azure.messaging.eventhubs.implementation.ClientConstants; +import org.apache.qpid.proton.amqp.transport.ErrorCondition; +import org.apache.qpid.proton.engine.EndpointState; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.engine.Session; + +import java.io.IOException; +import java.time.Duration; +import java.util.Locale; + +public class SessionHandler extends Handler { + private final ClientLogger logger = new ClientLogger(SessionHandler.class); + + private final String entityName; + private final Duration openTimeout; + private final ReactorDispatcher reactorDispatcher; + + public SessionHandler(String connectionId, String hostname, String entityName, ReactorDispatcher reactorDispatcher, + Duration openTimeout) { + super(connectionId, hostname); + this.entityName = entityName; + this.openTimeout = openTimeout; + this.reactorDispatcher = reactorDispatcher; + } + + public ErrorContext getErrorContext() { + return new SessionErrorContext(getHostname(), entityName); + } + + @Override + public void onSessionLocalOpen(Event e) { + logger.asInfo().log("onSessionLocalOpen connectionId[{}], entityName[{}], condition[{}]", + getConnectionId(), this.entityName, + e.getSession().getCondition() == null ? ClientConstants.NOT_APPLICABLE : e.getSession().getCondition().toString()); + + final Session session = e.getSession(); + + try { + reactorDispatcher.invoke(this::onSessionTimeout, this.openTimeout); + } catch (IOException ioException) { + logger.asWarning().log("onSessionLocalOpen connectionId[{}], entityName[{}], reactorDispatcherError[{}]", + getConnectionId(), this.entityName, + ioException.getMessage()); + + session.close(); + + final String message = String.format(Locale.US, "onSessionLocalOpen connectionId[%s], entityName[%s], underlying IO of reactorDispatcher faulted with error: %s", + getConnectionId(), this.entityName, ioException.getMessage()); + final Throwable exception = new AmqpException(false, message, ioException, getErrorContext()); + + onNext(exception); + } + } + + @Override + public void onSessionRemoteOpen(Event e) { + final Session session = e.getSession(); + + logger.asInfo().log( + "onSessionRemoteOpen connectionId[{}], entityName[{}], sessionIncCapacity[{}], sessionOutgoingWindow[{}]", + getConnectionId(), entityName, session.getIncomingCapacity(), session.getOutgoingWindow()); + + if (session.getLocalState() == EndpointState.UNINITIALIZED) { + session.open(); + } + + onNext(EndpointState.ACTIVE); + } + + @Override + public void onSessionLocalClose(Event e) { + final ErrorCondition condition = e.getSession().getCondition(); + + logger.asInfo().log("onSessionLocalClose connectionId[{}], entityName[{}], condition[{}]", + entityName, getConnectionId(), + condition == null ? ClientConstants.NOT_APPLICABLE : condition.toString()); + } + + @Override + public void onSessionRemoteClose(Event e) { + final Session session = e.getSession(); + + logger.asInfo().log("onSessionRemoteClose connectionId[{}], entityName[{}], condition[{}]", + entityName, getConnectionId(), + session == null || session.getRemoteCondition() == null ? ClientConstants.NOT_APPLICABLE : session.getRemoteCondition().toString()); + + ErrorCondition condition = session != null ? session.getRemoteCondition() : null; + + if (session != null && session.getLocalState() != EndpointState.CLOSED) { + logger.asInfo().log( + "onSessionRemoteClose closing a local session for connectionId[{}], entityName[{}], condition[{}], description[{}]", + getConnectionId(), entityName, + condition != null ? condition.getCondition() : ClientConstants.NOT_APPLICABLE, + condition != null ? condition.getDescription() : ClientConstants.NOT_APPLICABLE); + + session.setCondition(session.getRemoteCondition()); + session.close(); + } + + onNext(EndpointState.CLOSED); + + if (condition != null) { + final Exception exception = ExceptionUtil.toException(condition.getCondition().toString(), + String.format(Locale.US, "onSessionRemoteClose connectionId[%s], entityName[%s]", getConnectionId(), entityName), + getErrorContext()); + + onNext(exception); + } + } + + @Override + public void onSessionFinal(Event e) { + final Session session = e.getSession(); + final ErrorCondition condition = session != null ? session.getCondition() : null; + + logger.asInfo().log("onSessionFinal connectionId[{}], entityName[{}], condition[{}], description[{}]", + getConnectionId(), entityName, + condition != null ? condition.getCondition() : ClientConstants.NOT_APPLICABLE, + condition != null ? condition.getDescription() : ClientConstants.NOT_APPLICABLE); + + close(); + } + + private void onSessionTimeout() { + // It is supposed to close a local session to handle timeout exception. + // However, closing the session can result in NPE because of proton-j bug (https://issues.apache.org/jira/browse/PROTON-1939). + // And the bug will cause the reactor thread to stop processing pending tasks scheduled on the reactor and + // as a result task won't be completed at all. + + // TODO: handle timeout error once the proton-j bug is fixed. + // if (!sessionCreated && !sessionOpenErrorDispatched) { + // logger.asWarning().log( + // "SessionTimeoutHandler.onEvent - connectionId[{}], entityName[{}], session open timed out.", + // this.connectionId, this.entityName); + // } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/package-info.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/package-info.java new file mode 100644 index 000000000000..72c544eebbb4 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/handler/package-info.java @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * This package contains implementation classes for handling events from the transport library. + */ +package com.azure.messaging.eventhubs.implementation.handler; diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/package-info.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/package-info.java new file mode 100644 index 000000000000..038e04271126 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/implementation/package-info.java @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * This package contains implementation classes for interacting with Azure Event Hubs. + */ +package com.azure.messaging.eventhubs.implementation; diff --git a/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/package-info.java b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/package-info.java new file mode 100644 index 000000000000..f5c0b6ae16a2 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/main/java/com/azure/messaging/eventhubs/package-info.java @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * This package contains the classes for creating a {@link com.azure.messaging.eventhubs.EventHubClient} to + * perform operations on Azure Event Hubs. + */ +package com.azure.messaging.eventhubs; diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubClientBuilderTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubClientBuilderTest.java new file mode 100644 index 000000000000..f116aaa5c15b --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubClientBuilderTest.java @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import com.azure.messaging.eventhubs.implementation.ClientConstants; +import org.junit.Assert; +import org.junit.Test; + +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; + +public class EventHubClientBuilderTest { + private static final String NAMESPACE_NAME = "dummyNamespaceName"; + private static final String DEFAULT_DOMAIN_NAME = "servicebus.windows.net/"; + + private static final String ENTITY_PATH = "dummyEntityPath"; + private static final String SHARED_ACCESS_KEY_NAME = "dummySasKeyName"; + private static final String SHARED_ACCESS_KEY = "dummySasKey"; + private static final String ENDPOINT = getURI(ClientConstants.ENDPOINT_FORMAT, NAMESPACE_NAME, DEFAULT_DOMAIN_NAME).toString(); + + private static final String PROXY_HOST = "127.0.0.1"; + private static final String PROXY_PORT = "3128"; + + private static final String CORRECT_CONNECTION_STRING = String.format("Endpoint=%s;SharedAccessKeyName=%s;SharedAccessKey=%s;EntityPath=%s", + ENDPOINT, SHARED_ACCESS_KEY_NAME, SHARED_ACCESS_KEY, ENTITY_PATH); + private static final Proxy PROXY_ADDRESS = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(PROXY_HOST, Integer.parseInt(PROXY_PORT))); + + @Test(expected = IllegalArgumentException.class) + public void missingConnStrBuilder() { + EventHubClientBuilder builder = new EventHubClientBuilder(); + builder.build(); + } + + @Test + public void defaultProxyConfigurationBuilder() { + final EventHubClientBuilder builder = new EventHubClientBuilder(); + final EventHubClient client = builder.connectionString(CORRECT_CONNECTION_STRING).build(); + + Assert.assertNotNull(client); + } + + @Test + public void customNoneProxyConfigurationBuilder() { + // Arrange + final ProxyConfiguration proxyConfig = new ProxyConfiguration(ProxyAuthenticationType.NONE, PROXY_ADDRESS, null, null); + + // Act + final EventHubClientBuilder builder = new EventHubClientBuilder() + .connectionString(CORRECT_CONNECTION_STRING) + .proxyConfiguration(proxyConfig); + + // Assert + Assert.assertNotNull(builder.build()); + } + + private static URI getURI(String endpointFormat, String namespace, String domainName) { + try { + return new URI(String.format(Locale.US, endpointFormat, namespace, domainName)); + } catch (URISyntaxException exception) { + throw new IllegalArgumentException(String.format(Locale.US, + "Invalid namespace name: %s", namespace), exception); + } + } + + // TODO: add test for retry(), scheduler(), timeout(), transportType() +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubClientTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubClientTest.java new file mode 100644 index 000000000000..d8eeeb76e24d --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubClientTest.java @@ -0,0 +1,332 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import com.azure.core.amqp.Retry; +import com.azure.core.amqp.TransportType; +import com.azure.core.amqp.exception.AmqpException; +import com.azure.core.amqp.exception.ErrorCondition; +import com.azure.core.credentials.TokenCredential; +import com.azure.core.implementation.util.ImplUtils; +import com.azure.core.test.TestMode; +import com.azure.core.util.logging.ClientLogger; +import com.azure.messaging.eventhubs.implementation.ApiTestBase; +import com.azure.messaging.eventhubs.implementation.ConnectionOptions; +import com.azure.messaging.eventhubs.implementation.ConnectionStringProperties; +import com.azure.messaging.eventhubs.implementation.ReactorHandlerProvider; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestName; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import java.io.IOException; +import java.net.URI; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * Tests scenarios on {@link EventHubClient}. + */ +public class EventHubClientTest extends ApiTestBase { + private static final String PARTITION_ID = "0"; + + private final ClientLogger logger = new ClientLogger(EventHubClientTest.class); + + private EventHubClient client; + private ExpectedData data; + private ReactorHandlerProvider handlerProvider; + + @Rule + public TestName testName = new TestName(); + + @Override + protected void beforeTest() { + logger.asInfo().log("[{}]: Performing test set-up.", testName.getMethodName()); + + handlerProvider = new ReactorHandlerProvider(getReactorProvider()); + client = new EventHubClient(getConnectionOptions(), getReactorProvider(), handlerProvider); + data = new ExpectedData(getTestMode(), getConnectionStringProperties()); + } + + @Override + protected void afterTest() { + logger.asInfo().log("[{}]: Performing test clean-up.", testName.getMethodName()); + + if (client != null) { + client.close(); + } + } + + @Test(expected = NullPointerException.class) + public void nullConstructor() { + new EventHubClient(null, null, null); + } + + /** + * Verifies that we can get the metadata about an Event Hub + */ + @Test + public void getEventHubProperties() { + skipIfNotRecordMode(); + + // Act & Assert + StepVerifier.create(client.getProperties()) + .assertNext(properties -> { + Assert.assertNotNull(properties); + Assert.assertEquals(data.getProperties().path(), properties.path()); + Assert.assertEquals(data.getProperties().partitionIds().length, properties.partitionIds().length); + }).verifyComplete(); + } + + /** + * Verifies that we can get the partition identifiers of an Event Hub. + */ + @Test + public void getPartitionIds() { + skipIfNotRecordMode(); + + // Act & Assert + StepVerifier.create(client.getPartitionIds()) + .expectNextCount(data.properties.partitionIds().length) + .verifyComplete(); + } + + /** + * Verifies that we can get partition information for each of the partitions in an Event Hub. + */ + @Test + public void getPartitionProperties() { + skipIfNotRecordMode(); + + // Act & Assert + for (String partitionId : data.properties.partitionIds()) { + StepVerifier.create(client.getPartitionProperties(partitionId)) + .assertNext(properties -> { + final PartitionProperties expected = data.getPartitionProperties(properties.id()); + Assert.assertNotNull(expected); + Assert.assertEquals(expected.eventHubPath(), properties.eventHubPath()); + Assert.assertEquals(partitionId, properties.id()); + }) + .verifyComplete(); + } + } + + /** + * Verifies that we can make multiple service calls one after the other. This is a typical user scenario when + * consumers want to create a receiver. + * 1. Gets information about the Event Hub + * 2. Queries for partition information about each partition. + */ + @Test + public void getPartitionPropertiesMultipleCalls() { + skipIfNotRecordMode(); + + // Act + final Flux partitionProperties = client.getPartitionIds() + .flatMap(partitionId -> client.getPartitionProperties(partitionId)); + + // Assert + StepVerifier.create(partitionProperties) + .assertNext(properties -> { + final PartitionProperties expected = data.getPartitionProperties(properties.id()); + Assert.assertNotNull(expected); + Assert.assertEquals(expected.eventHubPath(), properties.eventHubPath()); + }) + .assertNext(properties -> { + final PartitionProperties expected = data.getPartitionProperties(properties.id()); + Assert.assertNotNull(expected); + Assert.assertEquals(expected.eventHubPath(), properties.eventHubPath()); + }) + .verifyComplete(); + } + + /** + * Verifies that error conditions are handled for fetching Event Hub metadata. + */ + @Test + public void getPartitionPropertiesInvalidToken() throws InvalidKeyException, NoSuchAlgorithmException { + skipIfNotRecordMode(); + + // Arrange + final ConnectionStringProperties original = getConnectionStringProperties(); + final ConnectionStringProperties invalidCredentials = getCredentials(original.endpoint(), original.eventHubPath(), + original.sharedAccessKeyName(), "invalid-sas-key-value"); + final TokenCredential badTokenProvider = new EventHubSharedAccessKeyCredential( + invalidCredentials.sharedAccessKeyName(), invalidCredentials.sharedAccessKey(), Duration.ofSeconds(40)); + final ConnectionOptions connectionOptions = new ConnectionOptions(original.endpoint().getHost(), + original.eventHubPath(), badTokenProvider, getAuthorizationType(), Duration.ofSeconds(45), + TransportType.AMQP, Retry.getNoRetry(), ProxyConfiguration.SYSTEM_DEFAULTS, getConnectionOptions().scheduler()); + final EventHubClient client = new EventHubClient(connectionOptions, getReactorProvider(), handlerProvider); + + // Act & Assert + StepVerifier.create(client.getProperties()) + .expectErrorSatisfies(error -> { + Assert.assertTrue(error instanceof AmqpException); + + AmqpException exception = (AmqpException) error; + Assert.assertEquals(ErrorCondition.UNAUTHORIZED_ACCESS, exception.getErrorCondition()); + Assert.assertFalse(exception.isTransient()); + Assert.assertFalse(ImplUtils.isNullOrEmpty(exception.getMessage())); + }) + .verify(); + } + + /** + * Verifies that error conditions are handled for fetching partition metadata. + */ + @Test + public void getPartitionPropertiesNonExistentHub() { + skipIfNotRecordMode(); + + // Arrange + // Arrange + final ConnectionStringProperties original = getConnectionStringProperties(); + final ConnectionOptions connectionOptions = new ConnectionOptions(original.endpoint().getHost(), + "invalid-event-hub", getTokenCredential(), getAuthorizationType(), Duration.ofSeconds(45), + TransportType.AMQP, Retry.getNoRetry(), ProxyConfiguration.SYSTEM_DEFAULTS, getConnectionOptions().scheduler()); + final EventHubClient client = new EventHubClient(connectionOptions, getReactorProvider(), handlerProvider); + + // Act & Assert + StepVerifier.create(client.getPartitionIds()) + .expectErrorSatisfies(error -> { + Assert.assertTrue(error instanceof AmqpException); + + AmqpException exception = (AmqpException) error; + Assert.assertEquals(ErrorCondition.NOT_FOUND, exception.getErrorCondition()); + Assert.assertFalse(exception.isTransient()); + Assert.assertFalse(ImplUtils.isNullOrEmpty(exception.getMessage())); + }) + .verify(); + } + + /** + * Verifies that we can create and send a message to an Event Hub partition. + */ + @Test + public void sendMessageToPartition() throws IOException { + skipIfNotRecordMode(); + + // Arrange + final EventHubProducerOptions senderOptions = new EventHubProducerOptions().partitionId(PARTITION_ID); + final List events = Arrays.asList( + new EventData("Event 1".getBytes(UTF_8)), + new EventData("Event 2".getBytes(UTF_8)), + new EventData("Event 3".getBytes(UTF_8))); + + // Act & Assert + try (EventHubProducer sender = client.createProducer(senderOptions)) { + StepVerifier.create(sender.send(events)) + .expectComplete() + .verify(); + } + } + + /** + * Verifies that we can create an {@link EventHubProducer} that does not care about partitions and lets the service + * distribute the events. + */ + @Test + public void sendMessage() throws IOException { + skipIfNotRecordMode(); + + // Arrange + final List events = Arrays.asList( + new EventData("Event 1".getBytes(UTF_8)), + new EventData("Event 2".getBytes(UTF_8)), + new EventData("Event 3".getBytes(UTF_8))); + + // Act & Assert + try (EventHubProducer sender = client.createProducer()) { + StepVerifier.create(sender.send(events)) + .expectComplete() + .verify(); + } + } + + @Test + public void receiveMessage() { + skipIfNotRecordMode(); + + // Arrange + final int numberOfEvents = 10; + final EventHubConsumerOptions options = new EventHubConsumerOptions() + .prefetchCount(2); + final EventHubConsumer receiver = client.createConsumer(EventHubClient.DEFAULT_CONSUMER_GROUP_NAME, + PARTITION_ID, EventPosition.earliest(), options); + + // Act & Assert + StepVerifier.create(receiver.receive().take(numberOfEvents)) + .expectNextCount(numberOfEvents) + .expectComplete() + .verify(); + } + + @Override + protected String testName() { + return testName.getMethodName(); + } + + private static ConnectionStringProperties getCredentials(URI endpoint, String eventHubPath, String sasKeyName, String sasKeyValue) { + final String connectionString = String.format(Locale.ROOT, + "Endpoint=%s;SharedAccessKeyName=%s;SharedAccessKey=%s;EntityPath=%s;", endpoint.toString(), + sasKeyName, sasKeyValue, eventHubPath); + + return new ConnectionStringProperties(connectionString); + } + + /** + * Holds expected data based on the test-mode. + */ + private static class ExpectedData { + private final EventHubProperties properties; + private final Map partitionPropertiesMap; + + ExpectedData(TestMode testMode, ConnectionStringProperties connectionStringProperties) { + final String eventHubPath; + final String[] partitionIds; + switch (testMode) { + case PLAYBACK: + eventHubPath = "test-event-hub"; + partitionIds = new String[]{"test-1", "test-2"}; + break; + case RECORD: + eventHubPath = connectionStringProperties.eventHubPath(); + partitionIds = new String[]{"0", "1"}; + break; + default: + throw new IllegalArgumentException("Test mode not recognized."); + } + + this.properties = new EventHubProperties(eventHubPath, Instant.EPOCH, partitionIds); + this.partitionPropertiesMap = new HashMap<>(); + + for (int i = 0; i < partitionIds.length; i++) { + final String key = String.valueOf(i); + + this.partitionPropertiesMap.put(key, new PartitionProperties( + eventHubPath, key, -1, -1, + "lastEnqueued", Instant.now(), true)); + } + } + + EventHubProperties getProperties() { + return properties; + } + + PartitionProperties getPartitionProperties(String id) { + return partitionPropertiesMap.get(id); + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubConsumerOptionsTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubConsumerOptionsTest.java new file mode 100644 index 000000000000..a7ee3620c1e5 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubConsumerOptionsTest.java @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import org.junit.Assert; +import org.junit.Test; + +public class EventHubConsumerOptionsTest { + /** + * Verifies we set the correct defaults. + */ + @Test + public void defaults() { + // Act + final EventHubConsumerOptions options = new EventHubConsumerOptions(); + + // Assert + Assert.assertEquals(EventHubConsumerOptions.DEFAULT_PREFETCH_COUNT, options.prefetchCount()); + } + + @Test + public void invalidIdentifier() { + // Arrange + final int length = EventHubConsumerOptions.MAXIMUM_IDENTIFIER_LENGTH + 1; + final String longIdentifier = new String(new char[length]).replace("\0", "f"); + final String identifier = "An Identifier"; + final EventHubConsumerOptions options = new EventHubConsumerOptions() + .identifier(identifier); + + // Act + try { + options.identifier(longIdentifier); + Assert.fail("Setting this should have failed."); + } catch (IllegalArgumentException e) { + // This is what we expect. + } + + // Assert + Assert.assertEquals(identifier, options.identifier()); + } + + @Test + public void invalidPrefetchMinimum() { + // Arrange + final int prefetch = 235; + final int invalid = EventHubConsumerOptions.MINIMUM_PREFETCH_COUNT - 1; + final EventHubConsumerOptions options = new EventHubConsumerOptions() + .prefetchCount(prefetch); + + // Act + try { + options.prefetchCount(invalid); + Assert.fail("Setting this should have failed."); + } catch (IllegalArgumentException e) { + // This is what we expect. + } + + // Assert + Assert.assertEquals(prefetch, options.prefetchCount()); + } + + @Test + public void invalidPrefetchMaximum() { + // Arrange + final int prefetch = 235; + final int invalid = EventHubConsumerOptions.MAXIMUM_PREFETCH_COUNT + 1; + final EventHubConsumerOptions options = new EventHubConsumerOptions() + .prefetchCount(prefetch); + + // Act + try { + options.prefetchCount(invalid); + Assert.fail("Setting this should have failed."); + } catch (IllegalArgumentException e) { + // This is what we expect. + } + + // Assert + Assert.assertEquals(prefetch, options.prefetchCount()); + } + + @Test + public void invalidOwnerLevel() { + // Arrange + final long ownerLevel = 14; + final long invalidOwnerLevel = -1; + final EventHubConsumerOptions options = new EventHubConsumerOptions() + .ownerLevel(ownerLevel); + + // Act + try { + options.ownerLevel(invalidOwnerLevel); + Assert.fail("Setting this should have failed."); + } catch (IllegalArgumentException e) { + // This is what we expect. + } + + // Assert + Assert.assertEquals(Long.valueOf(ownerLevel), options.ownerLevel()); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubProducerTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubProducerTest.java new file mode 100644 index 000000000000..ed8f998f44bb --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubProducerTest.java @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import com.azure.core.amqp.Retry; +import com.azure.core.amqp.exception.AmqpException; +import com.azure.core.amqp.exception.ErrorCondition; +import com.azure.messaging.eventhubs.implementation.AmqpSendLink; +import org.apache.qpid.proton.amqp.messaging.Section; +import org.apache.qpid.proton.message.Message; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.util.List; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; + +public class EventHubProducerTest { + + @Mock + private AmqpSendLink sendLink; + + @Captor + ArgumentCaptor singleMessageCaptor; + + @Captor + ArgumentCaptor> messagesCaptor; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + } + + @After + public void teardown() { + Mockito.framework().clearInlineMocks(); + sendLink = null; + singleMessageCaptor = null; + messagesCaptor = null; + } + + /** + * Verifies that sending multiple events will result in calling sender.send(List). + */ + @Test + public void sendMultipleMessages() { + // Arrange + final int count = 4; + final byte[] contents = CONTENTS.getBytes(UTF_8); + final Flux testData = Flux.range(0, count).flatMap(number -> { + final EventData data = new EventData(contents); + return Flux.just(data); + }); + + when(sendLink.send(anyList())).thenReturn(Mono.empty()); + + final int maxMessageSize = 16 * 1024; + final SendOptions options = new SendOptions().maximumSizeInBytes(maxMessageSize); + final EventHubProducerOptions senderOptions = new EventHubProducerOptions().retry(Retry.getNoRetry()).timeout(Duration.ofSeconds(30)); + final EventHubProducer sender = new EventHubProducer(Mono.just(sendLink), senderOptions); + + // Act + StepVerifier.create(sender.send(testData, options)) + .verifyComplete(); + + // Assert + verify(sendLink).send(messagesCaptor.capture()); + + final List messagesSent = messagesCaptor.getValue(); + Assert.assertEquals(count, messagesSent.size()); + + messagesSent.forEach(message -> { + Assert.assertEquals(Section.SectionType.Data, message.getBody().getType()); + }); + } + + /** + * Verifies that sending a single event data will result in calling sender.send(Message). + */ + @Test + public void sendSingleMessage() { + // Arrange + final EventData testData = new EventData(CONTENTS.getBytes(UTF_8)); + + when(sendLink.send(any(Message.class))).thenReturn(Mono.empty()); + + final int maxMessageSize = 16 * 1024; + final SendOptions options = new SendOptions().maximumSizeInBytes(maxMessageSize); + final EventHubProducerOptions senderOptions = new EventHubProducerOptions().retry(Retry.getNoRetry()).timeout(Duration.ofSeconds(30)); + final EventHubProducer sender = new EventHubProducer(Mono.just(sendLink), senderOptions); + + // Act + StepVerifier.create(sender.send(testData, options)) + .verifyComplete(); + + // Assert + verify(sendLink, times(1)).send(any(Message.class)); + verify(sendLink).send(singleMessageCaptor.capture()); + + final Message message = singleMessageCaptor.getValue(); + Assert.assertEquals(Section.SectionType.Data, message.getBody().getType()); + } + + /** + * Verifies that a partitioned sender cannot also send events with a partition key. + */ + @Test + public void partitionSenderCannotSendWithPartitionKey() { + // Arrange + final Flux testData = Flux.just( + new EventData(CONTENTS.getBytes(UTF_8)), + new EventData(CONTENTS.getBytes(UTF_8))); + + when(sendLink.send(anyList())).thenReturn(Mono.empty()); + + final SendOptions options = new SendOptions().partitionKey("Some partition key"); + final EventHubProducerOptions senderOptions = new EventHubProducerOptions() + .retry(Retry.getNoRetry()) + .timeout(Duration.ofSeconds(30)) + .partitionId("my-partition-id"); + + final EventHubProducer sender = new EventHubProducer(Mono.just(sendLink), senderOptions); + + // Act & Assert + try { + sender.send(testData, options).block(Duration.ofSeconds(10)); + Assert.fail("Should have thrown an exception."); + } catch (IllegalArgumentException e) { + // This is what we expect. + } + + verifyZeroInteractions(sendLink); + } + + /** + * Verifies that it fails if we try to send multiple messages that cannot fit in a single message batch. + */ + @Test + public void sendTooManyMessages() { + final Flux testData = Flux.range(0, 20).flatMap(number -> { + final EventData data = new EventData(CONTENTS.getBytes(UTF_8)); + return Flux.just(data); + }); + + final AmqpSendLink sendLink = mock(AmqpSendLink.class); + final int maxMessageSize = 16 * 1024; + final SendOptions options = new SendOptions().maximumSizeInBytes(maxMessageSize); + final EventHubProducerOptions senderOptions = new EventHubProducerOptions().retry(Retry.getNoRetry()).timeout(Duration.ofSeconds(30)); + final EventHubProducer sender = new EventHubProducer(Mono.just(sendLink), senderOptions); + + StepVerifier.create(sender.send(testData, options)) + .verifyErrorMatches(error -> error instanceof AmqpException + && ((AmqpException) error).getErrorCondition() == ErrorCondition.LINK_PAYLOAD_SIZE_EXCEEDED); + + verify(sendLink, times(0)).send(any(Message.class)); + } + + private static final String CONTENTS = "SSLorem ipsum dolor sit amet, consectetur adipiscing elit. Donec vehicula posuere lobortis. Aliquam finibus volutpat dolor, faucibus pellentesque ipsum bibendum vitae. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut sit amet urna hendrerit, dapibus justo a, sodales justo. Mauris finibus augue id pulvinar congue. Nam maximus luctus ipsum, at commodo ligula euismod ac. Phasellus vitae lacus sit amet diam porta placerat. \n" + + "Ut sodales efficitur sapien ut posuere. Morbi sed tellus est. Proin eu erat purus. Proin massa nunc, condimentum id iaculis dignissim, consectetur et odio. Cras suscipit sem eu libero aliquam tincidunt. Nullam ut arcu suscipit, eleifend velit in, cursus libero. Ut eleifend facilisis odio sit amet feugiat. Phasellus at nunc sit amet elit sagittis commodo ac in nisi. Fusce vitae aliquam quam. Integer vel nibh euismod, tempus elit vitae, pharetra est. Duis vulputate enim a elementum dignissim. Morbi dictum enim id elit scelerisque, in elementum nulla pharetra. \n" + + "Aenean aliquet aliquet condimentum. Proin dapibus dui id libero tempus feugiat. Sed commodo ligula a lectus mattis, vitae tincidunt velit auctor. Fusce quis semper dui. Phasellus eu efficitur sem. Ut non sem sit amet enim condimentum venenatis id dictum massa. Nullam sagittis lacus a neque sodales, et ultrices arcu mattis. Aliquam erat volutpat. \n" + + "Aenean fringilla quam elit, id mattis purus vestibulum nec. Praesent porta eros in dapibus molestie. Vestibulum orci libero, tincidunt et turpis eget, condimentum lobortis enim. Fusce suscipit ante et mauris consequat cursus nec laoreet lorem. Maecenas in sollicitudin diam, non tincidunt purus. Nunc mauris purus, laoreet eget interdum vitae, placerat a sapien. In mi risus, blandit eu facilisis nec, molestie suscipit leo. Pellentesque molestie urna vitae dui faucibus bibendum. \n" + + "Donec quis ipsum ultricies, imperdiet ex vel, scelerisque eros. Ut at urna arcu. Vestibulum rutrum odio dolor, vitae cursus nunc pulvinar vel. Donec accumsan sapien in malesuada tempor. Maecenas in condimentum eros. Sed vestibulum facilisis massa a iaculis. Etiam et nibh felis. Donec maximus, sem quis vestibulum gravida, turpis risus congue dolor, pharetra tincidunt lectus nisi at velit."; +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubPropertiesTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubPropertiesTest.java new file mode 100644 index 000000000000..7a0b3e852932 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubPropertiesTest.java @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import org.junit.Assert; +import org.junit.Test; + +import java.time.Instant; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +public class EventHubPropertiesTest { + /** + * Verifies that the properties on {@link EventHubProperties} are set properly. + */ + @Test + public void setsProperties() { + // Arrange + final String path = "Some-event-hub-path"; + final Instant instant = Instant.ofEpochSecond(145620); + final String[] partitionIds = new String[]{"one-partition", "two-partition", "three-partition"}; + + // Act + final EventHubProperties eventHubProperties = new EventHubProperties(path, instant, partitionIds); + + // Assert + Assert.assertEquals(path, eventHubProperties.path()); + Assert.assertEquals(instant, eventHubProperties.createdAt()); + Assert.assertEquals(partitionIds.length, eventHubProperties.partitionIds().length); + + final Set actual = new HashSet<>(Arrays.asList(eventHubProperties.partitionIds())); + for (String id : partitionIds) { + Assert.assertTrue(actual.contains(id)); + } + } + + /** + * Verifies that the {@link EventHubProperties#partitionIds()} array is not {@code null} when we pass {@code null} + * to the constructor. + */ + @Test + public void setsPropertiesNoPartitions() { + // Arrange + final String path = "Some-event-hub-path"; + final Instant instant = Instant.ofEpochSecond(145620); + + // Act + final EventHubProperties eventHubProperties = new EventHubProperties(path, instant, null); + + // Assert + Assert.assertEquals(path, eventHubProperties.path()); + Assert.assertEquals(instant, eventHubProperties.createdAt()); + Assert.assertNotNull(eventHubProperties.partitionIds()); + Assert.assertEquals(0, eventHubProperties.partitionIds().length); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubSharedAccessKeyCredentialTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubSharedAccessKeyCredentialTest.java new file mode 100644 index 000000000000..095b3a4cb034 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/EventHubSharedAccessKeyCredentialTest.java @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import org.junit.Assert; +import org.junit.Test; +import reactor.test.StepVerifier; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.Map; + +public class EventHubSharedAccessKeyCredentialTest { + private static final String KEY_NAME = "some-key-name"; + private static final String KEY_VALUE = "ctzMq410TV3wS7upTBcunJTDLEJwMAZuFPfr0mrrA08="; + private static final Duration TOKEN_DURATION = Duration.ofMinutes(10); + + @Test(expected = NullPointerException.class) + public void constructorNullDuration() throws InvalidKeyException, NoSuchAlgorithmException { + new EventHubSharedAccessKeyCredential(KEY_NAME, KEY_VALUE, null); + } + + @Test(expected = IllegalArgumentException.class) + public void constructorNullKey() throws InvalidKeyException, NoSuchAlgorithmException { + new EventHubSharedAccessKeyCredential(null, KEY_VALUE, TOKEN_DURATION); + } + + @Test(expected = IllegalArgumentException.class) + public void constructorNullValue() throws InvalidKeyException, NoSuchAlgorithmException { + new EventHubSharedAccessKeyCredential(KEY_NAME, null, TOKEN_DURATION); + } + + @Test + public void constructsToken() throws InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException { + // Arrange + final String signatureExpires = "se"; + final EventHubSharedAccessKeyCredential credential = + new EventHubSharedAccessKeyCredential(KEY_NAME, KEY_VALUE, TOKEN_DURATION); + final String resource = "some resource name"; + final String resourceUriEncode = URLEncoder.encode(resource, StandardCharsets.UTF_8.toString()); + final Map expected = new HashMap<>(); + expected.put("sr", resourceUriEncode); + expected.put("sig", null); + expected.put(signatureExpires, null); + expected.put("skn", KEY_NAME); + + // Act & Assert + StepVerifier.create(credential.getToken(resource)) + .assertNext(accessToken -> { + Assert.assertNotNull(accessToken); + + Assert.assertFalse(accessToken.isExpired()); + Assert.assertTrue(accessToken.expiresOn().isAfter(OffsetDateTime.now(ZoneOffset.UTC))); + + final String[] split = accessToken.token().split(" "); + Assert.assertEquals(2, split.length); + Assert.assertEquals("SharedAccessSignature", split[0].trim()); + + final String[] components = split[1].split("&"); + for (String component : components) { + final String[] pair = component.split("="); + final String key = pair[0]; + final String value = pair[1]; + final String expectedValue = expected.get(key); + + Assert.assertTrue(expected.containsKey(key)); + + // These are the values that are random, but we expect the expiration to be after this date. + if (signatureExpires.equals(key)) { + final Instant instant = Instant.ofEpochSecond(Long.valueOf(value)); + Assert.assertTrue(instant.isAfter(Instant.now())); + } else if (expectedValue == null) { + Assert.assertNotNull(value); + } else { + Assert.assertEquals(expectedValue, value); + } + } + }) + .verifyComplete(); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/PartitionPropertiesTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/PartitionPropertiesTest.java new file mode 100644 index 000000000000..501ec9189c82 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/PartitionPropertiesTest.java @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import org.junit.Assert; +import org.junit.Test; + +import java.time.Instant; + +public class PartitionPropertiesTest { + /** + * Verifies that the properties on {@link PartitionProperties} are set properly. + */ + @Test + public void setsProperties() { + // Arrange + final String eventHub = "The event hub path"; + final String id = "the partition id"; + final long beginningSequence = 1235; + final long endSequence = 8763; + final String lastEnqueuedOffset = "Last-Enqueued"; + final Instant lastEnqueuedTime = Instant.ofEpochSecond(1560639208); + final boolean isEmpty = true; + + // Act + final PartitionProperties properties = new PartitionProperties(eventHub, id, beginningSequence, endSequence, + lastEnqueuedOffset, lastEnqueuedTime, isEmpty); + + // Assert + Assert.assertEquals(eventHub, properties.eventHubPath()); + Assert.assertEquals(id, properties.id()); + Assert.assertEquals(beginningSequence, properties.beginningSequenceNumber()); + Assert.assertEquals(endSequence, properties.lastEnqueuedSequenceNumber()); + Assert.assertEquals(lastEnqueuedOffset, properties.lastEnqueuedOffset()); + Assert.assertEquals(lastEnqueuedTime, properties.lastEnqueuedTime()); + Assert.assertEquals(isEmpty, properties.isEmpty()); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/ProxyConfigurationTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/ProxyConfigurationTest.java new file mode 100644 index 000000000000..5ab9fc2ca002 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/ProxyConfigurationTest.java @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.experimental.theories.DataPoints; +import org.junit.experimental.theories.FromDataPoints; +import org.junit.experimental.theories.Theories; +import org.junit.experimental.theories.Theory; +import org.junit.runner.RunWith; + +import java.net.InetSocketAddress; +import java.net.Proxy; + +import static com.azure.messaging.eventhubs.ProxyConfiguration.SYSTEM_DEFAULTS; + +@RunWith(Theories.class) +public class ProxyConfigurationTest { + + private static final String PROXY_HOST = "/127.0.0.1"; // InetAddressHolder's address starts with '/' + private static final String PROXY_PORT = "3128"; + private static final String HTTP_PROXY = String.join(":", PROXY_HOST, PROXY_PORT); + private static final String PROXY_USERNAME = "dummyUsername"; + private static final String PROXY_PASSWORD = "dummyPassword"; + + private static Proxy proxyAddress = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(PROXY_HOST, Integer.parseInt(PROXY_PORT))); + + @DataPoints("Proxy Configuration Types") + public static ProxyAuthenticationType[] proxyAuthenticationTypes() { + return new ProxyAuthenticationType[] { + ProxyAuthenticationType.BASIC, ProxyAuthenticationType.DIGEST, ProxyAuthenticationType.NONE + }; + } + + @Test + public void nullProxyConfiguration() { + Assert.assertNull(SYSTEM_DEFAULTS.authentication()); + Assert.assertNull(SYSTEM_DEFAULTS.credential()); + Assert.assertNull(SYSTEM_DEFAULTS.proxyAddress()); + } + + @Theory + public void validateProxyConfiguration(@FromDataPoints("Proxy Configuration Types") ProxyAuthenticationType proxyAuthenticationType) { + ProxyConfiguration proxyConfiguration = new ProxyConfiguration(proxyAuthenticationType, proxyAddress, PROXY_USERNAME, PROXY_PASSWORD); + validateProxyConfiguration(proxyConfiguration, proxyAuthenticationType); + } + + @Theory + public void testIsProxyAddressConfigured(@FromDataPoints("Proxy Configuration Types") ProxyAuthenticationType proxyAuthenticationType) { + ProxyConfiguration proxyConfiguration = new ProxyConfiguration(proxyAuthenticationType, proxyAddress, PROXY_USERNAME, PROXY_PASSWORD); + Assert.assertTrue(proxyConfiguration.isProxyAddressConfigured()); + + proxyConfiguration = new ProxyConfiguration(proxyAuthenticationType, proxyAddress, null, PROXY_PASSWORD); + Assert.assertTrue(proxyConfiguration.isProxyAddressConfigured()); + + proxyConfiguration = new ProxyConfiguration(proxyAuthenticationType, proxyAddress, PROXY_USERNAME, null); + Assert.assertTrue(proxyConfiguration.isProxyAddressConfigured()); + + proxyConfiguration = new ProxyConfiguration(proxyAuthenticationType, proxyAddress, null, null); + Assert.assertTrue(proxyConfiguration.isProxyAddressConfigured()); + + proxyConfiguration = new ProxyConfiguration(proxyAuthenticationType, null, PROXY_USERNAME, PROXY_PASSWORD); + Assert.assertFalse(proxyConfiguration.isProxyAddressConfigured()); + + proxyConfiguration = new ProxyConfiguration(proxyAuthenticationType, null, null, PROXY_PASSWORD); + Assert.assertFalse(proxyConfiguration.isProxyAddressConfigured()); + + proxyConfiguration = new ProxyConfiguration(proxyAuthenticationType, null, PROXY_USERNAME, null); + Assert.assertFalse(proxyConfiguration.isProxyAddressConfigured()); + + proxyConfiguration = new ProxyConfiguration(proxyAuthenticationType, null, null, null); + Assert.assertFalse(proxyConfiguration.isProxyAddressConfigured()); + } + + @Theory + public void testHasUserDefinedCredentials(@FromDataPoints("Proxy Configuration Types") ProxyAuthenticationType proxyAuthenticationType) { + ProxyConfiguration proxyConfiguration = new ProxyConfiguration(proxyAuthenticationType, proxyAddress, PROXY_USERNAME, PROXY_PASSWORD); + Assert.assertTrue(proxyConfiguration.hasUserDefinedCredentials()); + + proxyConfiguration = new ProxyConfiguration(proxyAuthenticationType, proxyAddress, null, PROXY_PASSWORD); + Assert.assertFalse(proxyConfiguration.hasUserDefinedCredentials()); + + proxyConfiguration = new ProxyConfiguration(proxyAuthenticationType, proxyAddress, PROXY_USERNAME, null); + Assert.assertFalse(proxyConfiguration.hasUserDefinedCredentials()); + + proxyConfiguration = new ProxyConfiguration(proxyAuthenticationType, proxyAddress, null, null); + Assert.assertFalse(proxyConfiguration.hasUserDefinedCredentials()); + + proxyConfiguration = new ProxyConfiguration(proxyAuthenticationType, null, PROXY_USERNAME, PROXY_PASSWORD); + Assert.assertTrue(proxyConfiguration.hasUserDefinedCredentials()); + + proxyConfiguration = new ProxyConfiguration(proxyAuthenticationType, null, null, PROXY_PASSWORD); + Assert.assertFalse(proxyConfiguration.hasUserDefinedCredentials()); + + proxyConfiguration = new ProxyConfiguration(proxyAuthenticationType, null, PROXY_USERNAME, null); + Assert.assertFalse(proxyConfiguration.hasUserDefinedCredentials()); + + proxyConfiguration = new ProxyConfiguration(proxyAuthenticationType, null, null, null); + Assert.assertFalse(proxyConfiguration.hasUserDefinedCredentials()); + } + + private static void validateProxyConfiguration(ProxyConfiguration proxyConfiguration, ProxyAuthenticationType proxyAuthenticationType) { + String proxyAddressStr = proxyConfiguration.proxyAddress().address().toString(); + ProxyAuthenticationType authentication = proxyConfiguration.authentication(); + Assert.assertEquals(HTTP_PROXY, proxyAddressStr); + Assert.assertEquals(PROXY_USERNAME, proxyConfiguration.credential().getUserName()); + Assert.assertEquals(PROXY_PASSWORD, new String(proxyConfiguration.credential().getPassword())); + Assert.assertTrue(proxyAuthenticationType.equals(authentication)); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ActiveClientTokenManagerTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ActiveClientTokenManagerTest.java new file mode 100644 index 000000000000..3333538c32c6 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ActiveClientTokenManagerTest.java @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.CBSNode; +import com.azure.core.amqp.exception.AmqpException; +import com.azure.core.amqp.exception.AmqpResponseCode; +import com.azure.core.amqp.exception.ErrorCondition; +import com.azure.core.amqp.exception.ErrorContext; +import com.azure.core.exception.AzureException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +public class ActiveClientTokenManagerTest { + private static final String AUDIENCE = "an-audience-test"; + private static final Duration TIMEOUT = Duration.ofSeconds(4); + + @Mock + private CBSNode cbsNode; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + } + + @After + public void teardown() { + Mockito.framework().clearInlineMocks(); + cbsNode = null; + } + + /** + * Verify that we can get successes and errors from CBS node. + */ + @Test + public void getAuthorizationResults() { + // Arrange + final Mono cbsNodeMono = Mono.fromCallable(() -> cbsNode); + when(cbsNode.authorize(any())).thenReturn(getNextExpiration(3)); + + final ActiveClientTokenManager tokenManager = new ActiveClientTokenManager(cbsNodeMono, AUDIENCE); + + // Act & Assert + StepVerifier.create(tokenManager.getAuthorizationResults()) + .then(() -> tokenManager.authorize().block(TIMEOUT)) + .expectNext(AmqpResponseCode.ACCEPTED) + .expectNext(AmqpResponseCode.ACCEPTED) + .then(tokenManager::close) + .verifyComplete(); + } + + /** + * Verify that we can get successes and errors from CBS node. This un-retriable error will not allow it to be + * rescheduled. + */ + @SuppressWarnings("unchecked") + @Test + public void getAuthorizationResultsSuccessFailure() { + // Arrange + final Mono cbsNodeMono = Mono.fromCallable(() -> cbsNode); + final IllegalArgumentException error = new IllegalArgumentException("Some error"); + + when(cbsNode.authorize(any())).thenReturn(getNextExpiration(2), + getNextExpiration(2), Mono.error(error)); + + // Act & Assert + try (ActiveClientTokenManager tokenManager = new ActiveClientTokenManager(cbsNodeMono, AUDIENCE)) { + StepVerifier.create(tokenManager.getAuthorizationResults()) + .then(() -> tokenManager.authorize().block(TIMEOUT)) + .expectNext(AmqpResponseCode.ACCEPTED) + .expectError(IllegalArgumentException.class) + .verifyThenAssertThat() + .hasNotDroppedElements() + .hasNotDroppedElements() + .hasNotDroppedErrors(); + } + } + + /** + * Verify that we cannot authorize with CBS node when it has already been disposed of. + */ + @Test + public void cannotAuthorizeDisposedInstance() { + // Arrange + final Mono cbsNodeMono = Mono.fromCallable(() -> cbsNode); + when(cbsNode.authorize(any())).thenReturn(getNextExpiration(2)); + + final ActiveClientTokenManager tokenManager = new ActiveClientTokenManager(cbsNodeMono, AUDIENCE); + tokenManager.authorize().then(Mono.fromRunnable(tokenManager::close)).block(); + + // Act & Assert + StepVerifier.create(tokenManager.authorize()) + .expectError(AzureException.class) + .verify(); + } + + /** + * Verify that the ActiveClientTokenManager reschedules the authorization task. + */ + @SuppressWarnings("unchecked") + @Test + public void getAuthorizationResultsRetriableError() { + // Arrange + final Mono cbsNodeMono = Mono.fromCallable(() -> cbsNode); + final AmqpException error = new AmqpException(true, ErrorCondition.TIMEOUT_ERROR, "Timed out", + new ErrorContext("Test-context-namespace")); + + when(cbsNode.authorize(any())).thenReturn(getNextExpiration(2), Mono.error(error), + getNextExpiration(1), getNextExpiration(1), getNextExpiration(1)); + + // Act & Assert + try (ActiveClientTokenManager tokenManager = new ActiveClientTokenManager(cbsNodeMono, AUDIENCE)) { + StepVerifier.create(tokenManager.getAuthorizationResults()) + .then(() -> tokenManager.authorize().block(TIMEOUT)) + .expectError(AmqpException.class) + .verify(); + + StepVerifier.create(tokenManager.getAuthorizationResults()) + .expectNext(AmqpResponseCode.ACCEPTED) + .expectNext(AmqpResponseCode.ACCEPTED) + .then(tokenManager::close) + .verifyComplete(); + } + } + + private Mono getNextExpiration(long secondsToWait) { + return Mono.fromCallable(() -> OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(secondsToWait)); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ApiTestBase.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ApiTestBase.java new file mode 100644 index 000000000000..aa83db1ae139 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ApiTestBase.java @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.Retry; +import com.azure.core.amqp.TransportType; +import com.azure.core.credentials.TokenCredential; +import com.azure.core.implementation.util.ImplUtils; +import com.azure.core.test.TestBase; +import com.azure.core.test.TestMode; +import com.azure.messaging.eventhubs.EventHubSharedAccessKeyCredential; +import com.azure.messaging.eventhubs.ProxyConfiguration; +import org.apache.qpid.proton.reactor.Reactor; +import org.apache.qpid.proton.reactor.Selectable; +import org.junit.After; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.Before; +import org.mockito.Mockito; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Test base for running live and offline tests. + */ +public abstract class ApiTestBase extends TestBase { + private static final String EVENT_HUB_CONNECTION_STRING_ENV_NAME = "AZURE_EVENTHUBS_CONNECTION_STRING"; + private static final String CONNECTION_STRING = System.getenv(EVENT_HUB_CONNECTION_STRING_ENV_NAME); + private static final String TEST_CONNECTION_STRING = "Endpoint=sb://test-event-hub.servicebus.windows.net/;SharedAccessKeyName=myaccount;SharedAccessKey=ctzMq410TV3wS7upTBcunJTDLEJwMAZuFPfr0mrrA08=;EntityPath=eventhub1;"; + + private ConnectionStringProperties properties; + private Reactor reactor = mock(Reactor.class); + private TokenCredential tokenCredential; + private ReactorProvider reactorProvider; + private ConnectionOptions connectionOptions; + + // These are overridden because we don't use the Interceptor Manager. + @Override + @Before + public void setupTest() { + final Scheduler scheduler = Schedulers.newElastic("AMQPConnection"); + final String connectionString = getTestMode() == TestMode.RECORD + ? CONNECTION_STRING + : TEST_CONNECTION_STRING; + + properties = new ConnectionStringProperties(connectionString); + reactorProvider = new ReactorProvider(); + + try { + tokenCredential = new EventHubSharedAccessKeyCredential(properties.sharedAccessKeyName(), + properties.sharedAccessKey(), ClientConstants.TOKEN_VALIDITY); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + Assert.fail("Could not create tokenProvider :" + e.toString()); + } + + if (getTestMode() != TestMode.RECORD) { + when(reactor.selectable()).thenReturn(mock(Selectable.class)); + ReactorDispatcher reactorDispatcher = null; + try { + reactorDispatcher = new ReactorDispatcher(reactor); + } catch (IOException e) { + Assert.fail("Could not create dispatcher."); + } + reactorProvider = new MockReactorProvider(reactor, reactorDispatcher); + } + + connectionOptions = new ConnectionOptions(properties.endpoint().getHost(), properties.eventHubPath(), + tokenCredential, getAuthorizationType(), Duration.ofSeconds(45), TransportType.AMQP, + Retry.getDefaultRetry(), ProxyConfiguration.SYSTEM_DEFAULTS, scheduler); + + beforeTest(); + } + + // These are overridden because we don't use the Interceptor Manager. + @Override + @After + public void teardownTest() { + afterTest(); + + // Tear down any inline mocks to avoid memory leaks. + // https://github.com/mockito/mockito/wiki/What's-new-in-Mockito-2#mockito-2250 + Mockito.framework().clearInlineMocks(); + } + + /** + * Gets the test mode for this API test. If AZURE_TEST_MODE equals {@link TestMode#RECORD} and Event Hubs connection + * string is set, then we return {@link TestMode#RECORD}. Otherwise, {@link TestMode#PLAYBACK} is returned. + */ + @Override + public TestMode getTestMode() { + if (super.getTestMode() == TestMode.PLAYBACK) { + return TestMode.PLAYBACK; + } + + return ImplUtils.isNullOrEmpty(CONNECTION_STRING) ? TestMode.PLAYBACK : TestMode.RECORD; + } + + protected String getConnectionString() { + return getTestMode() == TestMode.RECORD ? CONNECTION_STRING : TEST_CONNECTION_STRING; + } + + protected void skipIfNotRecordMode() { + Assume.assumeTrue(getTestMode() == TestMode.RECORD); + } + + protected ConnectionOptions getConnectionOptions() { + return connectionOptions; + } + + protected ConnectionStringProperties getConnectionStringProperties() { + return properties; + } + + protected TokenCredential getTokenCredential() { + return tokenCredential; + } + + protected Reactor getReactor() { + return reactor; + } + + protected ReactorProvider getReactorProvider() { + return reactorProvider; + } + + protected CBSAuthorizationType getAuthorizationType() { + return CBSAuthorizationType.SHARED_ACCESS_SIGNATURE; + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/CBSChannelTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/CBSChannelTest.java new file mode 100644 index 000000000000..e02ca4190290 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/CBSChannelTest.java @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.AmqpConnection; +import com.azure.core.amqp.CBSNode; +import com.azure.core.amqp.exception.AmqpException; +import com.azure.core.amqp.exception.ErrorCondition; +import com.azure.core.credentials.TokenCredential; +import com.azure.core.implementation.util.ImplUtils; +import com.azure.messaging.eventhubs.EventHubSharedAccessKeyCredential; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestName; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import reactor.test.StepVerifier; + +import java.io.IOException; +import java.time.Duration; +import java.time.OffsetDateTime; + +public class CBSChannelTest extends ApiTestBase { + private static final String CONNECTION_ID = "CbsChannelTest-Connection"; + + @Mock + private AmqpResponseMapper mapper; + + @Rule + public TestName testName = new TestName(); + + private AmqpConnection connection; + private CBSChannel cbsChannel; + private ConnectionStringProperties credentials; + private ReactorHandlerProvider handlerProvider; + private TokenResourceProvider tokenResourceProvider; + + @Override + protected String testName() { + return testName.getMethodName(); + } + + @Override + protected void beforeTest() { + MockitoAnnotations.initMocks(this); + + credentials = getConnectionStringProperties(); + tokenResourceProvider = new TokenResourceProvider(CBSAuthorizationType.SHARED_ACCESS_SIGNATURE, credentials.endpoint().getHost()); + + handlerProvider = new ReactorHandlerProvider(getReactorProvider()); + connection = new ReactorConnection(CONNECTION_ID, getConnectionOptions(), getReactorProvider(), + handlerProvider, mapper); + + cbsChannel = new CBSChannel(connection, getTokenCredential(), getAuthorizationType(), getReactorProvider(), + handlerProvider, Duration.ofMinutes(5)); + } + + @Override + protected void afterTest() { + mapper = null; + + if (cbsChannel != null) { + cbsChannel.close(); + } + + try { + if (connection != null) { + connection.close(); + } + } catch (IOException e) { + Assert.fail("Could not close connection." + e.toString()); + } + } + + @Test + public void successfullyAuthorizes() { + // Arrange + final String tokenAudience = tokenResourceProvider.getResourceString(credentials.eventHubPath()); + + // Act & Assert + StepVerifier.create(cbsChannel.authorize(tokenAudience)) + .assertNext(expiration -> OffsetDateTime.now().isBefore(expiration)) + .verifyComplete(); + } + + @Test + public void unsuccessfulAuthorize() { + skipIfNotRecordMode(); + + // Arrange + final String tokenAudience = tokenResourceProvider.getResourceString(credentials.eventHubPath()); + final Duration duration = Duration.ofMinutes(10); + + TokenCredential tokenProvider = null; + try { + tokenProvider = new EventHubSharedAccessKeyCredential(credentials.sharedAccessKeyName(), "Invalid shared access key.", duration); + } catch (Exception e) { + Assert.fail("Could not create token provider: " + e.toString()); + } + + final CBSNode node = new CBSChannel(connection, tokenProvider, getAuthorizationType(), getReactorProvider(), + handlerProvider, Duration.ofMinutes(5)); + + // Act & Assert + StepVerifier.create(node.authorize(tokenAudience)) + .expectErrorSatisfies(error -> { + Assert.assertTrue(error instanceof AmqpException); + + AmqpException exception = (AmqpException) error; + Assert.assertEquals(ErrorCondition.UNAUTHORIZED_ACCESS, exception.getErrorCondition()); + Assert.assertFalse(exception.isTransient()); + Assert.assertFalse(ImplUtils.isNullOrEmpty(exception.getMessage())); + }) + .verify(); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/EndpointStateNotifierBaseTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/EndpointStateNotifierBaseTest.java new file mode 100644 index 000000000000..36151a6c5892 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/EndpointStateNotifierBaseTest.java @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.AmqpEndpointState; +import com.azure.core.amqp.AmqpShutdownSignal; +import com.azure.core.amqp.exception.AmqpException; +import com.azure.core.amqp.exception.ErrorContext; +import com.azure.core.util.logging.ClientLogger; +import org.apache.qpid.proton.engine.EndpointState; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import reactor.test.StepVerifier; + +public class EndpointStateNotifierBaseTest { + private EndpointStateNotifierBase notifier; + + @Before + public void setup() { + notifier = new TestEndpointStateNotifierBase(); + } + + @After + public void teardown() { + notifier.close(); + } + + /** + * Verify ErrorContexts are propagated to subscribers. + */ + @Test + public void notifyError() { + // Arrange + final Throwable error1 = new IllegalStateException("bad state"); + final Throwable error2 = new AmqpException(false, "test error", new ErrorContext("test-namespace2")); + + // Act & Assert + StepVerifier.create(notifier.getErrors()) + .then(() -> notifier.notifyError(error1)) + .expectNext(error1) + .then(() -> notifier.notifyError(error2)) + .expectNext(error2) + .then(() -> notifier.close()) + .verifyComplete(); + } + + /** + * Verify AmqpShutdownSignals are propagated to subscribers. + */ + @Test + public void notifyShutdown() { + // Arrange + final AmqpShutdownSignal shutdownSignal = new AmqpShutdownSignal(false, true, "test-shutdown"); + final AmqpShutdownSignal shutdownSignal2 = new AmqpShutdownSignal(true, false, "test-shutdown2"); + + // Act & Assert + StepVerifier.create(notifier.getShutdownSignals()) + .then(() -> { + notifier.notifyShutdown(shutdownSignal); + notifier.notifyShutdown(shutdownSignal2); + }) + .expectNext(shutdownSignal, shutdownSignal2) + .then(() -> notifier.close()) + .verifyComplete(); + } + + /** + * Verify endpoint states are propagated to subscribers and the connection state property is updated. + */ + @Test + public void notifyEndpointState() { + Assert.assertEquals(AmqpEndpointState.UNINITIALIZED, notifier.getCurrentState()); + + StepVerifier.create(notifier.getConnectionStates()) + .expectNext(AmqpEndpointState.UNINITIALIZED) + .then(() -> notifier.notifyEndpointState(EndpointState.ACTIVE)) + .assertNext(state -> { + Assert.assertEquals(AmqpEndpointState.ACTIVE, state); + Assert.assertEquals(AmqpEndpointState.ACTIVE, notifier.getCurrentState()); + }) + .then(() -> { + notifier.notifyEndpointState(EndpointState.CLOSED); + notifier.notifyEndpointState(EndpointState.UNINITIALIZED); + }) + .expectNext(AmqpEndpointState.CLOSED, AmqpEndpointState.UNINITIALIZED) + .then(() -> notifier.close()) + .verifyComplete(); + } + + @Test(expected = NullPointerException.class) + public void notifyErrorNull() { + notifier.notifyError(null); + } + + @Test(expected = NullPointerException.class) + public void notifyShutdownNull() { + notifier.notifyShutdown(null); + } + + @Test(expected = NullPointerException.class) + public void notifyEndpointStateStateNull() { + notifier.notifyEndpointState(null); + } + + private static class TestEndpointStateNotifierBase extends EndpointStateNotifierBase { + TestEndpointStateNotifierBase() { + super(new ClientLogger(TestEndpointStateNotifierBase.class)); + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/MockReactorHandlerProvider.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/MockReactorHandlerProvider.java new file mode 100644 index 000000000000..f13539975b8d --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/MockReactorHandlerProvider.java @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.TransportType; +import com.azure.messaging.eventhubs.implementation.handler.ConnectionHandler; +import com.azure.messaging.eventhubs.implementation.handler.ReceiveLinkHandler; +import com.azure.messaging.eventhubs.implementation.handler.SendLinkHandler; +import com.azure.messaging.eventhubs.implementation.handler.SessionHandler; + +import java.time.Duration; + +class MockReactorHandlerProvider extends ReactorHandlerProvider { + private final ConnectionHandler connectionHandler; + private final SessionHandler sessionHandler; + private final SendLinkHandler sendLinkHandler; + private final ReceiveLinkHandler receiveLinkHandler; + + MockReactorHandlerProvider(ReactorProvider provider, ConnectionHandler connectionHandler, SessionHandler sessionHandler, + SendLinkHandler sendLinkHandler, ReceiveLinkHandler receiveLinkHandler) { + super(provider); + this.connectionHandler = connectionHandler; + this.sessionHandler = sessionHandler; + this.sendLinkHandler = sendLinkHandler; + this.receiveLinkHandler = receiveLinkHandler; + } + + @Override + SessionHandler createSessionHandler(String connectionId, String host, String sessionName, Duration openTimeout) { + return sessionHandler; + } + + @Override + ConnectionHandler createConnectionHandler(String connectionId, String hostname, TransportType transportType) { + return connectionHandler; + } + + @Override + SendLinkHandler createSendLinkHandler(String connectionId, String host, String senderName, String entityPath) { + return sendLinkHandler; + } + + @Override + ReceiveLinkHandler createReceiveLinkHandler(String connectionId, String host, String receiverName, String entityPath) { + return receiveLinkHandler; + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/MockReactorProvider.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/MockReactorProvider.java new file mode 100644 index 000000000000..132475619cb5 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/MockReactorProvider.java @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import org.apache.qpid.proton.reactor.Reactor; + +class MockReactorProvider extends ReactorProvider { + private final Reactor reactor; + private final ReactorDispatcher dispatcher; + + MockReactorProvider(Reactor reactor, ReactorDispatcher dispatcher) { + this.reactor = reactor; + this.dispatcher = dispatcher; + } + + @Override + Reactor createReactor(String connectionId, int maxFrameSize) { + return reactor; + } + + @Override + Reactor getReactor() { + return reactor; + } + + @Override + ReactorDispatcher getReactorDispatcher() { + return dispatcher; + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ReactorConnectionIntegrationTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ReactorConnectionIntegrationTest.java new file mode 100644 index 000000000000..2300fe32247d --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ReactorConnectionIntegrationTest.java @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestName; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import reactor.test.StepVerifier; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +public class ReactorConnectionIntegrationTest extends ApiTestBase { + private ReactorHandlerProvider handlerProvider; + + @Mock + private AmqpResponseMapper responseMapper; + + @Rule + public TestName testName = new TestName(); + private ReactorConnection connection; + + @Override + protected String testName() { + return testName.getMethodName(); + } + + @Override + protected void beforeTest() { + skipIfNotRecordMode(); + + MockitoAnnotations.initMocks(this); + + handlerProvider = new ReactorHandlerProvider(getReactorProvider()); + connection = new ReactorConnection("test-connection-id", getConnectionOptions(), + getReactorProvider(), handlerProvider, responseMapper); + } + + @Override + protected void afterTest() { + if (connection != null) { + connection.close(); + } + } + + @Test + public void getCbsNode() { + // Act & Assert + StepVerifier.create(connection.getCBSNode()) + .assertNext(node -> Assert.assertTrue(node instanceof CBSChannel)) + .verifyComplete(); + } + + @Test + public void getCbsNodeAuthorize() { + // Arrange + final TokenResourceProvider provider = new TokenResourceProvider(CBSAuthorizationType.SHARED_ACCESS_SIGNATURE, + getConnectionStringProperties().endpoint().getHost()); + + final String tokenAudience = provider.getResourceString(getConnectionStringProperties().eventHubPath()); + + // Act & Assert + StepVerifier.create(connection.getCBSNode().flatMap(node -> node.authorize(tokenAudience))) + .assertNext(expiration -> OffsetDateTime.now(ZoneOffset.UTC).isBefore(expiration)) + .verifyComplete(); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ReactorConnectionTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ReactorConnectionTest.java new file mode 100644 index 000000000000..449fe21e82b7 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ReactorConnectionTest.java @@ -0,0 +1,322 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.AmqpConnection; +import com.azure.core.amqp.AmqpEndpointState; +import com.azure.core.amqp.Retry; +import com.azure.core.amqp.TransportType; +import com.azure.core.credentials.TokenCredential; +import com.azure.messaging.eventhubs.ProxyConfiguration; +import com.azure.messaging.eventhubs.implementation.handler.ConnectionHandler; +import com.azure.messaging.eventhubs.implementation.handler.SessionHandler; +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.transport.ErrorCondition; +import org.apache.qpid.proton.engine.Connection; +import org.apache.qpid.proton.engine.EndpointState; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.engine.Handler; +import org.apache.qpid.proton.engine.Record; +import org.apache.qpid.proton.engine.Session; +import org.apache.qpid.proton.engine.Transport; +import org.apache.qpid.proton.reactor.Reactor; +import org.apache.qpid.proton.reactor.Selectable; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; +import reactor.test.StepVerifier; + +import java.io.IOException; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeoutException; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ReactorConnectionTest { + private static final String CONNECTION_ID = "test-connection-id"; + private static final String SESSION_NAME = "test-session-name"; + private static final Duration TEST_DURATION = Duration.ofSeconds(30); + private static final ConnectionStringProperties CREDENTIAL_INFO = new ConnectionStringProperties("Endpoint=sb://test-event-hub.servicebus.windows.net/;SharedAccessKeyName=dummySharedKeyName;SharedAccessKey=dummySharedKeyValue;EntityPath=eventhub1;"); + private static final String HOSTNAME = CREDENTIAL_INFO.endpoint().getHost(); + private static final Scheduler SCHEDULER = Schedulers.elastic(); + + private AmqpConnection connection; + private SessionHandler sessionHandler; + + @Mock + private Reactor reactor; + @Mock + private Selectable selectable; + @Mock + private TokenCredential tokenProvider; + @Mock + private AmqpResponseMapper responseMapper; + @Mock + private Connection connectionProtonJ = mock(Connection.class); + @Mock + private Session session = mock(Session.class); + @Mock + private Record record = mock(Record.class); + + private MockReactorProvider reactorProvider; + private MockReactorHandlerProvider reactorHandlerProvider; + private ConnectionHandler connectionHandler; + + @Before + public void setup() throws IOException { + MockitoAnnotations.initMocks(this); + + when(reactor.selectable()).thenReturn(selectable); + + connectionHandler = new ConnectionHandler(CONNECTION_ID, HOSTNAME); + + final ReactorDispatcher reactorDispatcher = new ReactorDispatcher(reactor); + reactorProvider = new MockReactorProvider(reactor, reactorDispatcher); + sessionHandler = new SessionHandler(CONNECTION_ID, HOSTNAME, SESSION_NAME, reactorDispatcher, TEST_DURATION); + reactorHandlerProvider = new MockReactorHandlerProvider(reactorProvider, connectionHandler, sessionHandler, null, null); + + final ConnectionOptions connectionOptions = new ConnectionOptions(CREDENTIAL_INFO.endpoint().getHost(), + CREDENTIAL_INFO.eventHubPath(), tokenProvider, CBSAuthorizationType.SHARED_ACCESS_SIGNATURE, TEST_DURATION, + TransportType.AMQP, Retry.getDefaultRetry(), ProxyConfiguration.SYSTEM_DEFAULTS, SCHEDULER); + connection = new ReactorConnection(CONNECTION_ID, connectionOptions, reactorProvider, reactorHandlerProvider, responseMapper); + } + + @After + public void teardown() { + reactor = null; + selectable = null; + tokenProvider = null; + responseMapper = null; + connectionProtonJ = null; + session = null; + record = null; + + // Tear down any inline mocks to avoid memory leaks. + // https://github.com/mockito/mockito/wiki/What's-new-in-Mockito-2#mockito-2250 + Mockito.framework().clearInlineMocks(); + } + + /** + * Can create a ReactorConnection and the appropriate properties are set for TransportType.AMQP + */ + @Test + public void createConnection() { + // Arrange + final Map expectedProperties = new HashMap<>(connectionHandler.getConnectionProperties()); + + // Assert + Assert.assertTrue(connection instanceof ReactorConnection); + Assert.assertEquals(CONNECTION_ID, connection.getIdentifier()); + Assert.assertEquals(HOSTNAME, connection.getHost()); + + Assert.assertEquals(connectionHandler.getMaxFrameSize(), connection.getMaxFrameSize()); + + Assert.assertNotNull(connection.getConnectionProperties()); + Assert.assertEquals(expectedProperties.size(), connection.getConnectionProperties().size()); + + expectedProperties.forEach((key, value) -> { + final Object removed = connection.getConnectionProperties().remove(key); + Assert.assertNotNull(removed); + + final String expected = String.valueOf(value); + final String actual = String.valueOf(removed); + Assert.assertEquals(expected, actual); + }); + Assert.assertTrue(connection.getConnectionProperties().isEmpty()); + } + + /** + * Creates a session with the given name and set handler. + */ + @Test + public void createSession() { + // Arrange + // We want to ensure that the ReactorExecutor does not shutdown unexpectedly. There are still items to still process. + when(reactor.process()).thenReturn(true); + when(reactor.connectionToHost(connectionHandler.getHostname(), connectionHandler.getProtocolPort(), connectionHandler)).thenReturn(connectionProtonJ); + when(connectionProtonJ.session()).thenReturn(session); + when(session.attachments()).thenReturn(record); + + // Act & Assert + StepVerifier.create(connection.createSession(SESSION_NAME)) + .assertNext(s -> { + Assert.assertNotNull(s); + Assert.assertEquals(SESSION_NAME, s.getSessionName()); + Assert.assertTrue(s instanceof ReactorSession); + Assert.assertSame(session, ((ReactorSession) s).session()); + }).verifyComplete(); + + // Assert that the same instance is obtained and we don't get a new session with the same name. + StepVerifier.create(connection.createSession(SESSION_NAME)) + .assertNext(s -> { + Assert.assertNotNull(s); + Assert.assertEquals(SESSION_NAME, s.getSessionName()); + Assert.assertTrue(s instanceof ReactorSession); + Assert.assertSame(session, ((ReactorSession) s).session()); + }).verifyComplete(); + + verify(record, Mockito.times(1)).set(Handler.class, Handler.class, sessionHandler); + } + + /** + * Creates a session with the given name and set handler. + */ + @Test + public void removeSessionThatExists() { + // Arrange + // We want to ensure that the ReactorExecutor does not shutdown unexpectedly. There are still items to still process. + when(reactor.process()).thenReturn(true); + when(reactor.connectionToHost(connectionHandler.getHostname(), connectionHandler.getProtocolPort(), connectionHandler)).thenReturn(connectionProtonJ); + when(connectionProtonJ.session()).thenReturn(session); + when(session.attachments()).thenReturn(record); + + // Act & Assert + StepVerifier.create(connection.createSession(SESSION_NAME).map(s -> connection.removeSession(s.getSessionName()))) + .expectNext(true) + .verifyComplete(); + + verify(record, Mockito.times(1)).set(Handler.class, Handler.class, sessionHandler); + } + + /** + * Creates a session with the given name and set handler. + */ + @Test + public void removeSessionThatDoesNotExist() { + // Arrange + // We want to ensure that the ReactorExecutor does not shutdown unexpectedly. There are still items to still process. + when(reactor.process()).thenReturn(true); + when(reactor.connectionToHost(connectionHandler.getHostname(), connectionHandler.getProtocolPort(), connectionHandler)).thenReturn(connectionProtonJ); + when(connectionProtonJ.session()).thenReturn(session); + when(session.attachments()).thenReturn(record); + + // Act & Assert + StepVerifier.create(connection.createSession(SESSION_NAME).map(s -> connection.removeSession("does-not-exist"))) + .expectNext(false) + .verifyComplete(); + + verify(record, Mockito.times(1)).set(Handler.class, Handler.class, sessionHandler); + } + + /** + * Verifies initial endpoint state is uninitialized and completes when the connection is closed. + */ + @Test + public void initialConnectionState() { + // Assert + StepVerifier.create(connection.getConnectionStates()) + .expectNext(AmqpEndpointState.UNINITIALIZED) + .then(() -> { + try { + connection.close(); + } catch (IOException e) { + Assert.fail("Should not have thrown an error."); + } + }) + .verifyComplete(); + } + + /** + * Verifies Connection state reports correct status when ConnectionHandler updates its state. + */ + @Test + public void onConnectionStateOpen() { + // Arrange + final Event event = mock(Event.class); + when(event.getConnection()).thenReturn(connectionProtonJ); + when(connectionProtonJ.getHostname()).thenReturn(HOSTNAME); + when(connectionProtonJ.getRemoteContainer()).thenReturn("remote-container"); + when(connectionProtonJ.getRemoteState()).thenReturn(EndpointState.ACTIVE); + + // Act & Assert + StepVerifier.create(connection.getConnectionStates()) + .expectNext(AmqpEndpointState.UNINITIALIZED) + .then(() -> connectionHandler.onConnectionRemoteOpen(event)) + .expectNext(AmqpEndpointState.ACTIVE) + // getConnectionStates is distinct. We don't expect to see another event with the same status. + .then(() -> connectionHandler.onConnectionRemoteOpen(event)) + .then(() -> { + try { + connection.close(); + } catch (IOException e) { + Assert.fail("Should not have thrown an error."); + } + }) + .verifyComplete(); + } + + /** + * Verifies that we can get the CBS node. + */ + @Test + public void createCBSNode() { + // Arrange + when(connectionProtonJ.getRemoteState()).thenReturn(EndpointState.ACTIVE); + final Event mock = mock(Event.class); + when(mock.getConnection()).thenReturn(connectionProtonJ); + connectionHandler.onConnectionRemoteOpen(mock); + + // Act and Assert + StepVerifier.create(this.connection.getCBSNode()) + .assertNext(node -> { + Assert.assertTrue(node instanceof CBSChannel); + }).verifyComplete(); + } + + /** + * Verifies that if the connection cannot be created within the timeout period, it errors. + */ + @Test + public void createCBSNodeTimeoutException() { + // Arrange + Duration timeout = Duration.ofSeconds(5); + ConnectionOptions parameters = new ConnectionOptions(CREDENTIAL_INFO.endpoint().getHost(), + CREDENTIAL_INFO.eventHubPath(), tokenProvider, CBSAuthorizationType.SHARED_ACCESS_SIGNATURE, timeout, + TransportType.AMQP, Retry.getDefaultRetry(), ProxyConfiguration.SYSTEM_DEFAULTS, Schedulers.elastic()); + + // Act and Assert + try (ReactorConnection connectionBad = new ReactorConnection(CONNECTION_ID, parameters, reactorProvider, reactorHandlerProvider, responseMapper)) { + StepVerifier.create(connectionBad.getCBSNode()) + .verifyError(TimeoutException.class); + } + } + + /** + * Verifies if the ConnectionHandler transport fails, then we are unable to create the CBS node or sessions. + */ + @Test + public void cannotCreateResourcesOnFailure() { + // Arrange + final Event event = mock(Event.class); + final Transport transport = mock(Transport.class); + final ErrorCondition errorCondition = new ErrorCondition(Symbol.getSymbol("amqp:not-found"), "Not found"); + + when(event.getTransport()).thenReturn(transport); + when(event.getConnection()).thenReturn(connectionProtonJ); + when(transport.getCondition()).thenReturn(errorCondition); + when(connectionProtonJ.getHostname()).thenReturn(HOSTNAME); + when(connectionProtonJ.getRemoteContainer()).thenReturn("remote-container"); + when(connectionProtonJ.getRemoteState()).thenReturn(EndpointState.ACTIVE); + + connectionHandler.onTransportError(event); + + StepVerifier.create(connection.getCBSNode()) + .assertNext(node -> { + Assert.assertTrue(node instanceof CBSChannel); + }).verifyComplete(); + + verify(transport, times(1)).unbind(); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ReactorReceiverTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ReactorReceiverTest.java new file mode 100644 index 000000000000..6815eac6d6ff --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ReactorReceiverTest.java @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.AmqpEndpointState; +import com.azure.core.amqp.CBSNode; +import com.azure.messaging.eventhubs.implementation.handler.ReceiveLinkHandler; +import org.apache.qpid.proton.amqp.messaging.Source; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.engine.Receiver; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ReactorReceiverTest { + @Mock + private Receiver receiver; + @Mock + private CBSNode cbsNode; + @Mock + private Event event; + + private ReceiveLinkHandler receiverHandler; + private ActiveClientTokenManager tokenManager; + private ReactorReceiver reactorReceiver; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + + when(cbsNode.authorize(any())).thenReturn(Mono.empty()); + + when(event.getLink()).thenReturn(receiver); + when(receiver.getRemoteSource()).thenReturn(new Source()); + + final String entityPath = "test-entity-path"; + receiverHandler = new ReceiveLinkHandler("test-connection-id", "test-host", + "test-receiver-name", entityPath); + tokenManager = new ActiveClientTokenManager(Mono.just(cbsNode), "test-tokenAudience"); + reactorReceiver = new ReactorReceiver(entityPath, receiver, receiverHandler, tokenManager); + } + + @After + public void teardown() { + Mockito.framework().clearInlineMocks(); + + receiver = null; + cbsNode = null; + event = null; + } + + /** + * Verify we can add credits to the link. + */ + @Test + public void addCredits() { + final int credits = 15; + reactorReceiver.addCredits(credits); + + verify(receiver, times(1)).flow(credits); + } + + /** + * Verifies EndpointStates are propagated. + */ + @Test + public void updateEndpointState() { + StepVerifier.create(reactorReceiver.getConnectionStates()) + .expectNext(AmqpEndpointState.UNINITIALIZED) + .then(() -> receiverHandler.onLinkRemoteOpen(event)) + .expectNext(AmqpEndpointState.ACTIVE) + .then(() -> receiverHandler.close()) + .expectNext(AmqpEndpointState.CLOSED) + .then(() -> reactorReceiver.close()) + .verifyComplete(); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ReactorSessionTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ReactorSessionTest.java new file mode 100644 index 000000000000..988041fa4137 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/ReactorSessionTest.java @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import com.azure.core.amqp.AmqpEndpointState; +import com.azure.core.amqp.CBSNode; +import com.azure.messaging.eventhubs.implementation.handler.SessionHandler; +import org.apache.qpid.proton.engine.EndpointState; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.engine.Session; +import org.apache.qpid.proton.reactor.Reactor; +import org.apache.qpid.proton.reactor.Selectable; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.io.IOException; +import java.time.Duration; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ReactorSessionTest { + private static final String ID = "test-connection-id"; + private static final String HOST = "test-host"; + private static final String ENTITY_PATH = "test-entity-path"; + private static final String NAME = "test-session-name"; + private static final Duration TIMEOUT = Duration.ofSeconds(45); + + private SessionHandler handler; + private ReactorSession reactorSession; + private MockReactorProvider reactorProvider; + private TokenResourceProvider tokenResourceProvider; + private MockReactorHandlerProvider handlerProvider; + + @Mock + private Session session; + @Mock + private Reactor reactor; + @Mock + private Selectable selectable; + @Mock + private Event event; + @Mock + private CBSNode cbsNode; + + @Before + public void setup() throws IOException { + MockitoAnnotations.initMocks(this); + when(reactor.selectable()).thenReturn(selectable); + when(event.getSession()).thenReturn(session); + + ReactorDispatcher dispatcher = new ReactorDispatcher(reactor); + this.handler = new SessionHandler(ID, HOST, ENTITY_PATH, dispatcher, Duration.ofSeconds(60)); + this.reactorProvider = new MockReactorProvider(reactor, dispatcher); + this.handlerProvider = new MockReactorHandlerProvider(reactorProvider, null, handler, null, null); + this.tokenResourceProvider = new TokenResourceProvider(CBSAuthorizationType.SHARED_ACCESS_SIGNATURE, HOST); + this.reactorSession = new ReactorSession(session, handler, NAME, reactorProvider, handlerProvider, + Mono.just(cbsNode), tokenResourceProvider, TIMEOUT); + } + + @After + public void teardown() { + session = null; + reactor = null; + selectable = null; + event = null; + cbsNode = null; + + Mockito.framework().clearInlineMocks(); + } + + @Test + public void verifyConstructor() { + // Assert + verify(session, times(1)).open(); + + Assert.assertSame(session, reactorSession.session()); + Assert.assertEquals(NAME, reactorSession.getSessionName()); + Assert.assertEquals(TIMEOUT, reactorSession.getOperationTimeout()); + } + + @Test + public void verifyEndpointStates() { + when(session.getLocalState()).thenReturn(EndpointState.ACTIVE); + + StepVerifier.create(reactorSession.getConnectionStates()) + .expectNext(AmqpEndpointState.UNINITIALIZED) + .then(() -> handler.onSessionRemoteOpen(event)) + .expectNext(AmqpEndpointState.ACTIVE) + .then(() -> handler.close()) + // Expect two close notifications. One for getErrors() subscription and getEndpointStates(); + .expectNext(AmqpEndpointState.CLOSED, AmqpEndpointState.CLOSED) + .then(() -> reactorSession.close()) + .verifyComplete(); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/TokenResourceProviderTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/TokenResourceProviderTest.java new file mode 100644 index 000000000000..6c894354c67e --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/TokenResourceProviderTest.java @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.experimental.theories.DataPoints; +import org.junit.experimental.theories.Theories; +import org.junit.experimental.theories.Theory; +import org.junit.runner.RunWith; + +@RunWith(Theories.class) +public class TokenResourceProviderTest { + private static final String HOST_NAME = "foobar.windows.net"; + + @Test(expected = NullPointerException.class) + public void constructorNullType() { + new TokenResourceProvider(null, HOST_NAME); + } + + @Test(expected = NullPointerException.class) + public void constructorNullHost() { + new TokenResourceProvider(CBSAuthorizationType.JSON_WEB_TOKEN, null); + } + + @DataPoints + public static CBSAuthorizationType[] getAuthorizationTypes() { + return CBSAuthorizationType.values(); + } + + @Theory + public void getResourceString(CBSAuthorizationType authorizationType) { + // Arrange + final TokenResourceProvider provider = new TokenResourceProvider(authorizationType, HOST_NAME); + final String entityPath = "event-hub-test-2/partition/2"; + + // Act + final String actual = provider.getResourceString(entityPath); + + // Assert + switch (authorizationType) { + case SHARED_ACCESS_SIGNATURE: + final String expected = "amqp://" + HOST_NAME + "/" + entityPath; + Assert.assertEquals(expected, actual); + break; + case JSON_WEB_TOKEN: + Assert.assertEquals("https://eventhubs.azure.net//.default", actual); + break; + default: + Assert.fail("This authorization type is unknown: " + authorizationType); + } + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/handler/ConnectionHandlerTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/handler/ConnectionHandlerTest.java new file mode 100644 index 000000000000..59be023ec35d --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/handler/ConnectionHandlerTest.java @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation.handler; + +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.engine.Connection; +import org.apache.qpid.proton.engine.EndpointState; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.engine.impl.TransportInternal; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.MockitoAnnotations; +import reactor.test.StepVerifier; + +import java.util.HashMap; +import java.util.Map; + +import static com.azure.messaging.eventhubs.implementation.handler.ConnectionHandler.AMQPS_PORT; +import static com.azure.messaging.eventhubs.implementation.handler.ConnectionHandler.FRAMEWORK; +import static com.azure.messaging.eventhubs.implementation.handler.ConnectionHandler.MAX_FRAME_SIZE; +import static com.azure.messaging.eventhubs.implementation.handler.ConnectionHandler.PLATFORM; +import static com.azure.messaging.eventhubs.implementation.handler.ConnectionHandler.PRODUCT; +import static com.azure.messaging.eventhubs.implementation.handler.ConnectionHandler.USER_AGENT; +import static com.azure.messaging.eventhubs.implementation.handler.ConnectionHandler.VERSION; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ConnectionHandlerTest { + private static final String CONNECTION_ID = "some-random-id"; + private static final String HOSTNAME = "hostname-random"; + private ConnectionHandler handler; + + @Captor + ArgumentCaptor> argumentCaptor; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + handler = new ConnectionHandler(CONNECTION_ID, HOSTNAME); + } + + @Test + public void createHandler() { + // Assert + Assert.assertEquals(HOSTNAME, handler.getHostname()); + Assert.assertEquals(MAX_FRAME_SIZE, handler.getMaxFrameSize()); + Assert.assertEquals(AMQPS_PORT, handler.getProtocolPort()); + + final Map properties = handler.getConnectionProperties(); + Assert.assertTrue(properties.containsKey(PRODUCT.toString())); + Assert.assertTrue(properties.containsKey(VERSION.toString())); + Assert.assertTrue(properties.containsKey(PLATFORM.toString())); + Assert.assertTrue(properties.containsKey(FRAMEWORK.toString())); + Assert.assertTrue(properties.containsKey(USER_AGENT.toString())); + } + + @Test + public void addsSslLayer() { + // Arrange + final TransportInternal transport = mock(TransportInternal.class); + final Connection connection = mock(Connection.class); + when(connection.getRemoteState()).thenReturn(EndpointState.CLOSED); + + final Event event = mock(Event.class); + when(event.getTransport()).thenReturn(transport); + when(event.getConnection()).thenReturn(connection); + + // Act + handler.onConnectionBound(event); + + StepVerifier.create(handler.getEndpointStates()) + .expectNext(EndpointState.CLOSED) + .then(handler::close) + .verifyComplete(); + + // Assert + verify(transport, times(1)).ssl(any()); + } + + @Test + public void onConnectionInit() { + // Arrange + final String expectedHostname = String.join(":", HOSTNAME, String.valueOf(AMQPS_PORT)); + final Map expectedProperties = new HashMap<>(handler.getConnectionProperties()); + final Connection connection = mock(Connection.class); + final Event event = mock(Event.class); + when(event.getConnection()).thenReturn(connection); + + // Act + handler.onConnectionInit(event); + + // Assert + verify(connection, times(1)).setHostname(expectedHostname); + verify(connection, times(1)).setContainer(CONNECTION_ID); + verify(connection, times(1)).open(); + + verify(connection).setProperties(argumentCaptor.capture()); + Map actualProperties = argumentCaptor.getValue(); + Assert.assertEquals(expectedProperties.size(), actualProperties.size()); + expectedProperties.forEach((key, value) -> { + final Symbol symbol = Symbol.getSymbol(key); + final Object removed = actualProperties.remove(symbol); + Assert.assertNotNull(removed); + + final String expected = String.valueOf(value); + final String actual = String.valueOf(removed); + Assert.assertEquals(expected, actual); + }); + Assert.assertTrue(actualProperties.isEmpty()); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/handler/DispatchHandlerTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/handler/DispatchHandlerTest.java new file mode 100644 index 000000000000..f63130bc1185 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/handler/DispatchHandlerTest.java @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation.handler; + +import org.apache.qpid.proton.engine.Event; +import org.junit.Assert; +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.mockito.Mockito.mock; + +public class DispatchHandlerTest { + @Test(expected = NullPointerException.class) + public void exceptionsConstructor() { + new DispatchHandler(null); + } + + @Test + public void runsWork() { + // Arrange + final AtomicBoolean hasSet = new AtomicBoolean(); + final DispatchHandler handler = new DispatchHandler(() -> { + hasSet.compareAndSet(false, true); + }); + final Event event = mock(Event.class); + + // Act + handler.onTimerTask(event); + + // Assert + Assert.assertTrue(hasSet.get()); + } +} diff --git a/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/handler/HandlerTest.java b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/handler/HandlerTest.java new file mode 100644 index 000000000000..01c619be93d5 --- /dev/null +++ b/eventhubs/client/azure-eventhubs/src/test/java/com/azure/messaging/eventhubs/implementation/handler/HandlerTest.java @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.messaging.eventhubs.implementation.handler; + +import com.azure.core.amqp.exception.AmqpException; +import com.azure.core.amqp.exception.ErrorContext; +import org.apache.qpid.proton.engine.EndpointState; +import org.junit.Before; +import org.junit.Test; +import reactor.test.StepVerifier; + +public class HandlerTest { + private Handler handler; + + @Before + public void setup() { + handler = new TestHandler(); + } + + @Test + public void initialHandlerState() { + // Act & Assert + StepVerifier.create(handler.getEndpointStates()) + .expectNext(EndpointState.UNINITIALIZED) + .then(handler::close) + .verifyComplete(); + } + + @Test + public void initialErrors() { + // Act & Assert + StepVerifier.create(handler.getErrors()) + .then(handler::close) + .verifyComplete(); + } + + @Test + public void propagatesStates() { + // Act & Assert + StepVerifier.create(handler.getEndpointStates()) + .expectNext(EndpointState.UNINITIALIZED) + .then(() -> handler.onNext(EndpointState.ACTIVE)) + .expectNext(EndpointState.ACTIVE) + .then(() -> handler.onNext(EndpointState.ACTIVE)) + .then(handler::close) + .verifyComplete(); + } + + @Test + public void propagatesErrors() { + // Arrange + final ErrorContext context = new ErrorContext("test namespace."); + final Throwable exception = new AmqpException(false, "Some test message.", context); + + // Act & Assert + StepVerifier.create(handler.getErrors()) + .then(() -> handler.onNext(exception)) + .expectNext(exception) + .then(handler::close) + .verifyComplete(); + } + + private static class TestHandler extends Handler { + TestHandler() { + super("test-connection-id", "test-hostname"); + } + } +} diff --git a/eventhubs/client/pom.xml b/eventhubs/client/pom.xml new file mode 100644 index 000000000000..2531082ec475 --- /dev/null +++ b/eventhubs/client/pom.xml @@ -0,0 +1,86 @@ + + + 4.0.0 + + com.azure + azure-client-sdk-parent + 1.0.0 + ../../pom.client.xml + + + com.azure + azure-messaging-eventhubs-parent + 1.0.0-SNAPSHOT + pom + + Microsoft Azure client library for Event Hubs (Parent) + This package contains the Microsoft Azure Event Hubs client library. + https://github.com/Azure/azure-sdk-for-java + + + + azure-java-build-docs + ${site.url}/site/${project.artifactId} + + + + + scm:git:https://github.com/Azure/azure-sdk-for-java + scm:git:git@github.com:Azure/azure-sdk-for-java.git + HEAD + + + + + com.azure + azure-core + + + com.azure + azure-core-amqp + + + com.microsoft.azure + qpid-proton-j-extensions + + + org.apache.qpid + proton-j + + + + com.azure + azure-core-test + test + + + junit + junit + test + + + org.slf4j + slf4j-simple + test + + + io.projectreactor + reactor-test + test + + + org.mockito + mockito-core + test + + + + + azure-eventhubs + + + diff --git a/parent/pom.xml b/parent/pom.xml index cb378ac733db..3564f5631784 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -111,6 +111,7 @@ 1.7.5 1.7.0 1.0.0-SNAPSHOT + 1.0.0-SNAPSHOT 1.0.0-SNAPSHOT 1.0.0-SNAPSHOT 0.8.3.RELEASE @@ -166,6 +167,7 @@ 1.8 3.0.0 8.18 + 2.28.2 0.8.4
@@ -196,6 +198,12 @@ ${azure-core.version} + + com.azure + azure-core-amqp + ${azure-core-amqp.version} + + com.azure azure-core-auth @@ -477,6 +485,13 @@ ${jetty-server.version} test + + + org.mockito + mockito-core + ${mockito-core.version} + test + diff --git a/pom.client.xml b/pom.client.xml index b27659806d5b..afa07c39bc25 100644 --- a/pom.client.xml +++ b/pom.client.xml @@ -349,6 +349,10 @@ Azure Core - Management com.azure.core.management* + + Azure Core - AMQP + com.azure.core.amqp* + Azure Core - Authentication com.azure.core.auth* @@ -357,6 +361,10 @@ Azure App Configuration com.azure.data.appconfiguration* + + Azure Event Hubs + com.azure.messaging.eventhubs* + Azure Key Vault com.azure.keyvault* @@ -683,6 +691,7 @@ ./appconfiguration/client ./core + ./eventhubs/client ./keyvault/client ./tracing ./identity/client