diff --git a/eventhubs/data-plane/.gitattributes b/eventhubs/data-plane/.gitattributes new file mode 100644 index 000000000000..cdec8a81a3c2 --- /dev/null +++ b/eventhubs/data-plane/.gitattributes @@ -0,0 +1,26 @@ +# Default behavior: if Git thinks a file is text (as opposed to binary), it +# will normalize line endings to LF in the repository, but convert to your +# platform's native line endings on checkout (e.g., CRLF for Windows). +* text=auto + +# Explicitly declare text files you want to always be normalized and converted +# to native line endings on checkout. E.g., +#*.c text + +# Declare files that will always have CRLF line endings on checkout. E.g., +#*.sln text eol=crlf + +# Declare files that will always have LF line endings on checkout. E.g., +*.sh text eol=lf + +# Denote all files that should not have line endings normalized, should not be +# merged, and should not show in a textual diff. +*.docm binary +*.docx binary +*.ico binary +*.lib binary +*.png binary +*.pptx binary +*.snk binary +*.vsdx binary +*.xps binary diff --git a/eventhubs/data-plane/.github/CONTRIBUTING.md b/eventhubs/data-plane/.github/CONTRIBUTING.md new file mode 100644 index 000000000000..a2d93ae0da71 --- /dev/null +++ b/eventhubs/data-plane/.github/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# Contribute or Provide Feedback for Azure Event Hubs Java library + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Filing Issues](#filing-issues) +- [Pull Requests](#pull-requests) + - [General guidelines](#general-guidelines) + - [Testing guidelines](#testing-guidelines) + +## Code of Conduct + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Filing Issues + +You can find all of the issues that have been filed in the [Issues](https://github.com/Azure/azure-event-hubs-java/issues) section of the repository. + +If you encounter any bugs, please file an issue [here](https://github.com/Azure/azure-event-hubs-java/issues/new) and make sure to fill out the provided template with the requested information. + +To suggest a new feature or changes that could be made, file an issue the same way you would for a bug, but remove the provided template and replace it with information about your suggestion. + +### Pull Requests + +If you are thinking about making a large change to this library, **break up the change into small, logical, testable chunks, and organize your pull requests accordingly**. + +You can find all of the pull requests that have been opened in the [Pull Request](https://github.com/Azure/azure-event-hubs-java/pulls) section of the repository. + +To open your own pull request, click [here](https://github.com/Azure/azure-event-hubs-java/compare). When creating a pull request, keep the following in mind: +- Make sure you are pointing to the fork and branch that your changes were made in +- The pull request template that is provided **should be filled out**; this is not something that should just be deleted or ignored when the pull request is created + - Deleting or ignoring this template will elongate the time it takes for your pull request to be reviewed + +#### General guidelines + +The following guidelines must be followed in **EVERY** pull request that is opened. + +- Title of the pull request is clear and informative +- There are a small number of commits that each have an informative message +- A description of the changes the pull request makes is included, and a reference to the bug/issue the pull request fixes is included, if applicable +- All files have the Microsoft copyright header + +#### Testing guidelines + +The following guidelines must be followed in **EVERY** pull request that is opened. + +- Pull request includes test coverage for the included changes +- Tests must use xunit +- Test code should not contain hard coded values for resource names or similar values +- Test should not use App.config files for settings diff --git a/eventhubs/data-plane/.github/ISSUE_TEMPLATE.md b/eventhubs/data-plane/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000000..c2d384f1a663 --- /dev/null +++ b/eventhubs/data-plane/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,11 @@ +## Actual Behavior +1. +2. + +## Expected Behavior +1. +2. + +## Versions +- OS platform and version: +- Maven package version or commit ID: \ No newline at end of file diff --git a/eventhubs/data-plane/.github/PULL_REQUEST_TEMPLATE.md b/eventhubs/data-plane/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000000..205cf43406a2 --- /dev/null +++ b/eventhubs/data-plane/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ +## Description + + +This checklist is used to make sure that common guidelines for a pull request are followed. + +- [ ] **I have read the [contribution guidelines](./CONTRIBUTING.md).** +- [ ] Title of the pull request is clear and informative. +- [ ] There are a small number of commits, each of which have an informative message. This means that previously merged commits do not appear in the history of the PR. +- [ ] The pull request does not introduce breaking changes (unless a major version change occurs in the assembly and module). +- [ ] If applicable, the public code is properly documented. +- [ ] Pull request includes test coverage for the included changes. +- [ ] The code builds without any errors. \ No newline at end of file diff --git a/eventhubs/data-plane/.gitignore b/eventhubs/data-plane/.gitignore new file mode 100755 index 000000000000..eb1e8bddfe2f --- /dev/null +++ b/eventhubs/data-plane/.gitignore @@ -0,0 +1,207 @@ +# Compiled object files +*.o + +# Compiled static libraries +*.a + +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# Cache/options +.vs/* + +# User-specific files +*.suo +*.user +*.sln.docstates + +# Build results +[Dd]ebug/ +[Rr]elease/ +x64/ +[Bb]in/ +[Oo]bj/ +!/build/release/ +target/ +/c/cmake + +# Jenkins build files +/jenkins/jenkins-cli.jar + +# Enable "build/" folder in the NuGet Packages folder since NuGet packages use it for MSBuild targets +!packages/*/build/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +*_i.c +*_p.c +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.log +*.scc + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# eclipse java +.classpath +.project +*.prefs +/.metadata/ + +# intellij java +.idea/ +*.iml + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +*.ncrunch* +.*crunch*.local.xml + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.Publish.xml + +# NuGet Packages Directory +packages/ + +# Windows Azure Build Output +csx +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +sql/ +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.[Pp]ublish.xml +*.pfx +*.publishsettings + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file to a newer +# Visual Studio version. Backup files are not needed, because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +App_Data/*.mdf +App_Data/*.ldf + + +#LightSwitch generated files +GeneratedArtifacts/ +_Pvt_Extensions/ +ModelManifest.xml + +# ========================= +# Windows detritus +# ========================= + +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Mac desktop service store files +.DS_Store + +# Visual studio build artifacts +*.tlog +*.lastbuildstate +*.idb +*.exp +*.lib +*.dll +*.lock.json + +#Windows CE build artifacts +Build.err +Build.wrn +Buildx86retail.dat +*.dat + +#Linux build arfifacts +*.opp + +#Tools EXE that doesn't end up in a typical build directory +**/common/tools/macro_utils_h_generator/macro_utils_h_generator.exe + +#version file +**/build/**/version.txt +**/node_modules/ +**/.vscode/ + +# Typescript installed definition files for modules +**/typings/ \ No newline at end of file diff --git a/eventhubs/data-plane/ConsumingEvents.md b/eventhubs/data-plane/ConsumingEvents.md new file mode 100644 index 000000000000..cff94be57c9e --- /dev/null +++ b/eventhubs/data-plane/ConsumingEvents.md @@ -0,0 +1,243 @@ +# Consuming Events with the Java client for Azure Event Hubs + +Consuming events from Event Hubs is different from typical messaging infrastuctures like queues or topic +subscriptions where a consumer simply fetches the "next" message. + +Event Hubs puts the consumer in control of the offset from which the log shall be read,and the consumer can repeatedly pick a different or the same offset and read the event stream from chosen offsets while the events are being retained. Each partition is therefore loosely analogous to a tape drive that you can wind back to a particular mark and then play back to the freshest data available. + +Azure Event Hubs consumers need to be aware of the partitioning model chosen for an Event Hub as receivers explicitly +interact with partitions. Any Event Hub's event store is split up into at least 4 partitions, each maintaining a separate event log. You can think of partitions like lanes on a highway. The more events the Event Hub needs to handle, the more lanes (partitions) you have +to add. Each partition can handle at most the equivalent of 1 "throughput unit", equivalent to at most 1000 events per +second and at most 1 Megabyte per second. + +The common consumption model for Event Hubs is that multiple consumers (threads, processes, compute nodes) process events +from a single Event Hub in parallel, and coordinate which consumer is responsible for pulling events from which partition. + +> An upcoming update for this client will also bring the popular and powerful "event processor host" from C# to Java. +> The event processor host dramatically simplifies writing high-scale, high-throughput event consumer applications +> that distribute the processing load over a dynamic cluster of machines. + +## Getting Started + +This library is available for use in Maven projects from the Maven Central Repository, and can be referenced using the +following dependency declaration inside of your Maven project file: + +```XML + + com.microsoft.azure + azure-eventhubs + 2.0.0 + +``` + +For different types of build environments, the latest released JAR files can also be [explicitly obtained from the +Maven Central Repository]() or from [the Release distribution point on GitHub](). + +For a simple event consumer, you'll need to import the *com.microsoft.azure.eventhubs* package for the Event Hub client classes. + +```Java + import com.microsoft.azure.eventhubs.*; +``` + +Event Hubs client library uses qpid proton reactor framework which exposes AMQP connection and message delivery related +state transitions as reactive events. In the process, +the library will need to run many asynchronous tasks while sending and receiving messages to Event Hubs. +So, `EventHubClient` requires an instance of `ScheduledExecutorService`, where all these tasks are run. + + +```Java + ScheduledExecutorService executor = Executors.newScheduledThreadPool(8) +``` + +The receiver code creates an *EventHubClient* from a given connecting string + +```Java + ConnectionStringBuilder connStr = new ConnectionStringBuilder() + .setNamespaceName("----ServiceBusNamespaceName-----") + .setEventHubName("----EventHubName-----") + .setSasKeyName("-----SharedAccessSignatureKeyName-----") + .setSasKey("---SharedAccessSignatureKey----"); + + EventHubClient ehClient = EventHubClient.createSync(connStr.toString(), executor); +``` + +The receiver code then creates (at least) one *PartitionReceiver* that will receive the data. The receiver is seeded with an offset, in the snippet below it's simply the start of the log. + +```Java + String partitionId = "0"; + PartitionReceiver receiver = ehClient.createReceiverSync( + EventHubClient.DEFAULT_CONSUMER_GROUP_NAME, + partitionId, + EventPosition.fromStartOfStream()); + + receiver.setReceiveTimeout(Duration.ofSeconds(20)); +``` + +Once the receiver is initialized, getting events is just a matter of calling the *receive()* method in a loop. Each call +to *receive()* will fetch an iterable batch of events to process. + +```Java + Iterable receivedEvents = receiver.receiveSync(maxEventCount); +``` + +## Consumer Groups + +Event Hub receivers always receive via Consumer Groups. A consumer group is a named entity on an Event Hub that is +conceptually similar to a Messaging Topic subscription, even though it provides no server-side filtering capabilities. + +Each Event Hub has a "default consumer group" that is created with the Event Hub, which is also the one used in +the samples. + +The primary function of consumers groups is to provide a shared coordination context for multiple concurrent consumers +processing the same event stream in parallel. There can be at most 5 concurrent readers on a partition per consumer group; +it is however *recommended* that there is only one active receiver on a partition per consumer group. The [Ownership, Failover, +and Epochs](#ownership-failover-and-epochs) section below explains how to ensure this. + +You can create up to 20 such consumer groups on an Event Hub via the Azure portal or the HTTP API. + +## Using Offsets + +Each Event Hub has a configurable event retention period, which defaults to one day and can be extended to seven days. +By contacting Microsoft product support you can ask for further extend the retention period to up to 30 days. + +There are several options for a consumer to pick at which point into the retained event stream it wants to +begin receiving events: + +1. **Start of stream** Receive from the start of the retained stream, as shown in the example above. This option will start + with the oldest available retained event in the partition and then continuously deliver events until all available events + have been read. + +2. **Time offset**. This option will start with the oldest event in the partition that has been received into the Event Hub + after the given instant. + + ``` Java + PartitionReceiver receiver = ehClient.createReceiverSync( + EventHubClient.DEFAULT_CONSUMER_GROUP_NAME, + partitionId, + EventPosition.fromEnqueuedTime(instant)); + ``` + +3. **Absolute offset** This option is commonly used to resume receiving events after a previous receiver on the partition + has been aborted or suspended for any reason. The offset is a system-supplied string that should not be interpreted by + the application. The next section will discuss scenarios for using this option. + + ``` Java + PartitionReceiver receiver = ehClient.createReceiverSync( + EventHubClient.DEFAULT_CONSUMER_GROUP_NAME, + partitionId, + EventPosition.fromOffset(savedOffset)); + ``` + +4. **End of stream** While this option is self explanatory, one point to remember here is that, this call is designed to be + more performant than using `EventPosition.fromEnqueuedTime(Instant.now())`. + +5. **Sequence number** This option is baked into the API to provide better integration with stream processing technologies + (ex: APACHE SPARK). + +## Ownership, Failover, and Epochs +As mentioned in the overview above, the common consumption model for Event Hubs is that multiple consumers process events +from a single Event Hub in parallel. Depending on the amount of processing work required and the data volume that has to be +worked through, and also dependent on how resilient the system needs to be against failures, these consumers may be spread +across multiple different compute nodes (VMs). + +A simple setup for this is to create a fixed assignment of Event Hub partitions to compute nodes. For instance, you +could have two compute nodes handling events from 8 Event Hub partitions, assigning the first 4 partitions to the +first node and assigning the second set of 4 to the second node. + +The downside of such a simple model with fixed assignments is that if one of the compute nodes becomes unavailable, no events +get processed for the partitions owned by that node. + +The alternative is to make ownership dynamic and have all processing nodes reach consensus about who owns which partition, +which is referred to as "[leader election](https://en.wikipedia.org/wiki/Leader_election)" or "consensus" in literature. +One infrastructure for negotiating leaders is [Apache Zookeeper] (https://zookeeper.apache.org/doc/trunk/recipes.html#sc_leaderElection), +another one one is [leader election over Azure Blobs](https://msdn.microsoft.com/de-de/library/dn568104.aspx). + +> The "event processor host" is a forthcoming extension to this Java client that provides an implementation of leader +> election over Azure blobs. The event processor host for Java is very similar to the respective implementation available +> for C# clients. + +As the number of event processor nodes grows or shrinks, a leader election model will yield a redistribution of partition +ownership. More nodes each own fewer partitions, fewer nodes each own more partitions. Since leader election occurs +external to the Event Hub clients, there's a mechanism needed to allow a new leader for a partition to force the old leader +to let go of the partition, meaning it must be forced to stop receiving and processing events from the partition. + +That mechanism is called **epochs**. An epoch is an integer value that acts as a label for the time period during which the +"current" leader for the partition retains its ownership. The epoch value is provided as an argument to the +*EventHubClient::createEpochReciver* method. + + ``` Java + epochValue = 1 + PartitionReceiver receiver1 = ehClient.createEpochReceiverSync( + EventHubClient.DefaultConsumerGroupName, + partitionId, + EventPosition.fromOffset(savedOffset), + epochValue); + ``` + + When a new partition owner takes over, it creates a receiver for the same partition, but with a greater epoch value. This will instantly + cause the previous receiver to be dropped (the service initiates a shutdown of the link) and the new receiver to take over. + + ``` Java + /* obtain checkpoint data */ + epochValue = 2 + PartitionReceiver receiver2 = ehClient.createEpochReceiverSync( + EventHubClient.DefaultConsumerGroupName, + partitionId, + EventPosition.fromOffset(savedOffset), + epochValue); + ``` + +The new reader obviously also needs to know at which offset processing shall continue. For this, the current owner of a partition should +periodically record its progress on the event stream to a shared location, tracking the offset of the last processed message. This is +called "checkpointing". In case of the aforementioned Azure Blob lease election model, the blob itself is a great place to keep this information. + +How often an event processor writes checkpoint information depends on the use-case. Frequent checkpointing may cause excessive writes to +the checkpoint state location. Too infrequent checkpointing may cause too many events to be re-processed as the new onwer picks up from +an outdated offset. + +## AMQP 1.0 +Azure Event Hubs requires using the AMQP 1.0 protocol for consuming events. + +AMQP 1.0 is a TCP based protocol. For Azure Event Hubs, all traffic *must* be protected using TLS (SSL) and is using +TCP port 5671. For the WebSocket binding of AMQP, traffic flows via port 443. + +## Connection Strings + +Azure Event Hubs and Azure Service Bus share a common format for connection strings. A connection string holds all required +information to set up a connection with an Event Hub. The format is a simple property/value list of the form +{property}={value} with pairs separated by ampersands (&). + +| Property | Description | +|-----------------------|------------------------------------------------------------| +| Endpoint | URI for the Event Hubs namespace. Typically has the form *sb://{namespace}.servicebus.windows.net/* | +| EntityPath | Relative path of the Event Hub in the namespace. Commonly this is just the Event Hub name | +| SharedAccessKeyName | Name of a Shared Access Signature rule configured for the Event Hub or the Event Hub name. For publishers, the rule must include "Send" permissions. | +| SharedAccessKey | Base64-encoded value of the Shared Access Key for the rule | +| SharedAccessSignature | A previously issued [Shared Access Signature token](https://azure.microsoft.com/en-us/documentation/articles/service-bus-sas-overview/) | + +A connection string will therefore have the following form: + +``` + Endpoint=sb://clemensveu.servicebus.windows.net&EntityPath=myeventhub&SharedAccessSignature=.... +``` + +### Azure IoT Hub event hub compatible endpoint connection string + +If you want to read Device to Cloud (D2C) messages sent to **Azure IoT Hub**, [IoT Hub Event Hub-compatible endpoint](https://docs.microsoft.com/en-us/azure/iot-hub/iot-hub-devguide-messages-read-builtin#read-from-the-built-in-endpoint) covers the connection string to be used in this case in detail. + +Consumers generally have a different relationship with the Event Hub than publishers. Usually there are relatively few consumers +and those consumers enjoy a high level of trust within the context of a system. The relationship between an event consumer +and the Event Hub is commonly also much longer-lived. + +It's therefore more common for a consumer to be directly configured with a SAS key rule name and key as part of the +connection string. In order to prevent the SAS key from leaking, it is still advisable to use a long-lived +token rather than the naked key. + +A generated token will be configured into the connection string with the *SharedAccessSignature* property. + +More information about Shared Access Signature in Service Bus and Event Hubs about about how to generate the required tokens +in a range of languages [can be found on the Azure site.](https://azure.microsoft.com/en-us/documentation/articles/service-bus-sas-overview/) + +The easiest way to obtain a token for development purposes is to copy the connection string from the Azure portal. These tokens +do include key name and key value outright. The portal does not issue tokens. + diff --git a/eventhubs/data-plane/LICENSE b/eventhubs/data-plane/LICENSE new file mode 100644 index 000000000000..f61ce8cad20b --- /dev/null +++ b/eventhubs/data-plane/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Microsoft Azure + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/eventhubs/data-plane/Overview.md b/eventhubs/data-plane/Overview.md new file mode 100644 index 000000000000..b6ac2d2da761 --- /dev/null +++ b/eventhubs/data-plane/Overview.md @@ -0,0 +1,113 @@ + +# General Overview of Microsoft Azure Event Hubs client for Java + +This Java client library for Azure Event Hubs allows for both sending events to and receiving events from an Azure Event Hub. + +An **event publisher** is a source of telemetry data, diagnostics information, usage logs, or other log data, as +part of an embedded device solution, a mobile device application, a game title running on a console or other device, +some client or server based business solution, or a web site. + +An **event consumer** picks up such information from the Event Hub and processes it. Processing may involve aggregation, complex +computation and filtering. Processing may also involve distribution or storage of the information in a raw or transformed fashion. +Event Hub consumers are often robust and high-scale platform infrastructure parts with built-in analytics capabilities, like Azure +Stream Analytics, Apache Spark, or Apache Storm. + +Most applications will act either as an event publisher or an event consumer, but rarely both. The exception are event +consumers that filter and/or transform event streams and then forward them on to another Event Hub; an example for such a consumer +and (re-)publisher is Azure Stream Analytics. + +We'll therefore only give a glimpse at publishing and receiving here in this overview and provide further detail in +the [Publishing Events](PublishingEvents.md) and [Consuming Events](ConsumingEvents.md) guides. + +### Publishing Events + +The vast majority of Event Hub applications using this and other client libraries are and will be event publishers. +And for most of these publishers, publishing events is extremely simple. + +With your Java application referencing this client library, +which is quite simple in a Maven build [as we explain in the guide](PublishingEvents.md), you'll need to import the +*com.microsoft.azure.eventhubs* package with the *EventData* and *EventHubClient* classes. + + +```Java + import com.microsoft.azure.eventhubs.*; +``` + +Event Hubs client library uses qpid proton reactor framework which exposes AMQP connection and message delivery related +state transitions as reactive events. In the process, +the library will need to run many asynchronous tasks while sending and receiving messages to Event Hubs. +So, `EventHubClient` requires an instance of `ScheduledExecutorService`, where all these tasks are run. + + +```Java + ScheduledExecutorService executor = Executors.newScheduledThreadPool(8) +``` + +Using an Event Hub connection string, which holds all required connection information, including an authorization key or token, +you then create an *EventHubClient* instance, which manages a secure AMQP 1.0 connection to the Event Hub. + +```Java + ConnectionStringBuilder connStr = new ConnectionStringBuilder() + .setNamespaceName("----ServiceBusNamespaceName-----") + .setEventHubName("----EventHubName-----") + .setSasKeyName("-----SharedAccessSignatureKeyName-----") + .setSasKey("---SharedAccessSignatureKey----"); + + EventHubClient ehClient = EventHubClient.createSync(connStr.toString(), executor); +``` + +Once you have the client in hands, you can package any arbitrary payload as a plain array of bytes and send it. + +```Java + EventData sendEvent = EventData.create(payloadBytes); + ehClient.sendSync(sendEvent); +``` + +The entire client API is built for Java 8's concurrent task model, generally returning +[*CompletableFuture*](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/CompletableFuture.html), so the library has these methods suffixed with *Sync* as their Synchronous counterparts/varaints. + +Learn more about publishing events, including advanced options, and when you should and shouldn't use those options, +[in the event publisher guide](PublishingEvents.md). + +### Consuming Events + +Consuming events from Azure Event Hubs is a bit more complex than sending events, because the receivers need to be +aware of Event Hub's partitioning model, while senders can most often ignore it. + +Any Event Hub's event store is split up into at least 4 partitions, each maintaining a separate event log. You can think +of partitions like lanes on a highway. The more events the Event Hub needs to handle, the more lanes (partitions) you have +to add. Each partition can handle at most the equivalent of 1 "throughput unit", equivalent to at most 1000 events per +second and at most 1 Megabyte per second. + +Consuming messages is also quite different compared to typical messaging infrastucture like queues or topic +subscriptions, where the consumer simply fetches the "next" message. Azure Event Hubs puts the consumer in control of +the offset from which the log shall be read, and the consumer can repeatedly pick a different or the same offset and read +the event stream from chosen offsets while the events are being retained. Each partition is therefore loosely analogous +to a tape drive that you can wind back to a particular mark and then play back to the freshest data available. + +Just like the sender, the receiver code imports the package and creates an *EventHubClient* from a given connecting string. +The receiver code then creates (at least) one *PartitionReceiver* that will receive the data. The receiver is seeded with +an offset, in the snippet below it's simply the start of the log. + +```Java + String partitionId = "0"; + PartitionReceiver receiver = ehClient.createReceiverSync( + EventHubClient.DEFAULT_CONSUMER_GROUP_NAME, + partitionId, + EventPosition.fromStartOfStream()); + + receiver.setReceiveTimeout(Duration.ofSeconds(20)); +``` + +Once the receiver is initialized, getting events is just a matter of calling the *receive()* method in a loop. Each call +to *receive()* will fetch an enumerable batch of events to process. +Simply put, create a receiver from a specific offset and from then on, the log can be read only in one direction (oldest to latest event). + +```Java + Iterable receivedEvents = receiver.receiveSync(maxEventsCount); +``` + +As you might imagine, there's quite a bit more to know about partitions, about distributing the workload of processing huge and +fast data streams across several receiver machines, and about managing offsets in such a multi-machine scenario such that +data is not repeatedly read or, worse, skipped. You can find this and other details discussed in +the [Consuming Events](ConsumingEvents.md) guide. diff --git a/eventhubs/data-plane/PublishingEvents.md b/eventhubs/data-plane/PublishingEvents.md new file mode 100644 index 000000000000..5a9eb428e531 --- /dev/null +++ b/eventhubs/data-plane/PublishingEvents.md @@ -0,0 +1,174 @@ +# Publishing Events with the Java client for Azure Event Hubs + +The vast majority of Event Hub applications using this and the other client libraries are and will be event publishers. +And for most of these publishers, publishing events is extremely simple and handled with just a few API gestures. + +## Getting Started + +This library is available for use in Maven projects from the Maven Central Repository, and can be referenced using the +following dependency declaration inside of your Maven project file: + +```XML + + com.microsoft.azure + azure-eventhubs + 2.0.0 + + ``` + + For different types of build environments, the latest released JAR files can also be [explicitly obtained from the + Maven Central Repository](https://search.maven.org/#search%7Cga%7C1%7Ca%3A%22azure-eventhubs%22) or from [the Release distribution point on GitHub](https://github.com/Azure/azure-event-hubs/releases). + + +For a simple event publisher, you'll need to import the *com.microsoft.azure.eventhubs* package for the Event Hub client classes. + + +```Java + import com.microsoft.azure.eventhubs.*; +``` + +Event Hubs client library uses qpid proton reactor framework which exposes AMQP connection and message delivery related +state transitions as reactive events. In the process, +the library will need to run many asynchronous tasks while sending and receiving messages to Event Hubs. +So, `EventHubClient` requires an instance of `Executor`, where all these tasks are run. + + +```Java + ScheduledExecutorService executor = Executors.newScheduledThreadPool(8) +``` + +Using an Event Hub connection string, which holds all required connection information including an authorization key or token +(see [Connection Strings](#connection-strings)), you then create an *EventHubClient* instance. + +```Java + ConnectionStringBuilder connStr = new ConnectionStringBuilder() + .setNamespaceName("----ServiceBusNamespaceName-----") + .setEventHubName("----EventHubName-----") + .setSasKeyName("-----SharedAccessSignatureKeyName-----") + .setSasKey("---SharedAccessSignatureKey----"); + + EventHubClient ehClient = EventHubClient.createSync(connStr.toString(), executor); +``` + +Once you have the client in hands, you can package any arbitrary payload as a plain array of bytes and send it. The samples +we use to illustrate the functionality send a UTF-8 encoded JSON data, but you can transfer any format you wish. + +```Java + EventData sendEvent = EventData.create(payloadBytes); + ehClient.sendSync(sendEvent); +``` + +The entire client API is built for Java 8's concurrent task model, generally returning +[*CompletableFuture*](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/CompletableFuture.html), so the library has these methods suffixed with *Sync* as their Synchronous counterparts/varaints. + +## AMQP 1.0 +Azure Event Hubs allows for publishing events using the HTTPS and AMQP 1.0 protocols. The Azure Event Hub endpoints +also support AMQP over the WebSocket protocol, allowing event traffic to leverage the same outbound TCP port as +HTTPS. + +This client library is built on top of the [Apache Qpid Proton-J]() libraries and supports AMQP, which is significantly +more efficient at publishing event streams than HTTPS. AMQP 1.0 is an international standard published as ISO/IEC 19464:2014. + +AMQP is session-oriented and sets up the required addressing information and authorization information just once for each +send link, while HTTPS requires doing so with each sent message. AMQP also has a compact binary format to express common +event properties, while HTTPS requires passing message metadata in a verbose text format. AMQP can also keep a significant +number of events "in flight" with asynchronous and robust acknowledgement flow, while HTTPS enforces a strict request-reply +pattern. + +AMQP 1.0 is a TCP based protocol. For Azure Event Hubs, all traffic *must* be protected using TLS (SSL) and is using +TCP port 5671. + +This library will provide HTTPS support via WebSockets when Proton-J supports HTTPS. + +## Connection Strings + +Azure Event Hubs and Azure Service Bus share a common format for connection strings. A connection string holds all required +information to set up a connection with an Event Hub. The format is a simple property/value list of the form +{property}={value} with pairs separated by ampersands (&). + +| Property | Description | +|-----------------------|------------------------------------------------------------| +| Endpoint | URI for the Event Hubs namespace. Typically has the form *sb://{namespace}.servicebus.windows.net/* | +| EntityPath | Relative path of the Event Hub in the namespace. Commonly this is just the Event Hub name | +| SharedAccessKeyName | Name of a Shared Access Signature rule configured for the Event Hub or the Event Hub name. For publishers, the rule must include "Send" permissions. | +| SharedAccessKey | Base64-encoded value of the Shared Access Key for the rule | +| SharedAccessSignature | A previously issued Shared Access Signature token (not yet supported; will be soon) | + +A connection string will therefore have the following form: + +``` + Endpoint=sb://clemensveu.servicebus.windows.net&EntityPath=myeventhub&SharedAccessSignature=.... +``` + +## Advanced Operations + +The publisher example shown in the overview above sends an event into the Event Hub without further qualification. This is +the preferred and most flexible and reliable option. For specific needs, Event Hubs offers two extra options to +qualify send operations: Publisher policies and partion addressing. + +### Partition Addressing + +Any Event Hub's event store is split up into at least 4 partitions, each maintaining a separate event log. You can think +of partitions like lanes on a highway. The more events the Event Hub needs to handle, the more lanes (partitions) you have +to add. Each partition can handle at most the equivalent of 1 "throughput unit", equivalent to at most 1000 events per +second and at most 1 Megabyte per second. + +In some cases, publisher applications need to address partitions directly in order to pre-categorize events for consumption. +A partition is directly addressed either by using the partition's identifier or by using some string (partition key) that gets +consistently hashed to a particular partition. + +This capability, paired with a large number of partitions, may appear attractive for implementing a fine grained, per publisher +subscription scheme similar to what Topics offer in Service Bus Messaging - but it's not at all how the capability should be used +and it's likely not going to yield satisfying results. + +Partition addressing is designed as a routing capability that consistently assigns events from the same sources to the same partition allowing +downstream consumer systems to be optimized, but under the assumption of very many of such sources (hundreds, thousands) share +the same partition. If you need fine-grained content-based routing, Service Bus Topics might be the better option. + +#### Using Partition Keys + +Of the two addressing options, the preferable one is to let the hash algorithm map the event to the appropriate partition. +The gesture is a straightforward extra override to the send operation supplying the partition key: + +```Java + EventData sendEvent = EventData.create(payloadBytes); +> ehClient.sendSync(sendEvent, partitionKey); +``` + +#### Using Partition Ids + +If you indeed need to target a specific partition, for instance because you must use a particular distribution strategy, +you can send directly to the partition, but doing so requires an extra gesture so that you don't accidentally choose this +option. To send to a partition you explicitly need to create a client object that is tied to the partition as shown below: + +```Java + EventHubClient ehClient = EventHubClient.createSync(connStr.toString(), executor); +> PartitionSender sender = ehClient.createPartitionSenderSync("0"); + EventData sendEvent = EventData.create(payloadBytes); + sender.sendSync(sendEvent); +``` + +#### Publisher Policies + +Event Hub Publisher Policies are not yet supported by this client and will be supported in a future release. + +#### Special considerations for partitions and publisher policies + +Using partitions or publisher policies (which are effectively a special kind of partition key) may impact throughput +and availability of your Event Hub solution. + +When you do a regular send operation that does not prescribe a particular partition, the Event Hub will choose a +partition at random, ensuring about equal distribution of events across partitions. Sticking with the above analogy, +all highway lanes get the same traffic. + +If you explicitly choose the partition key or partition-id, it's up to you to take care that traffic is evenly +distributed, otherwise you may end up with a traffic jam (in the form of throttling) on one partition while there's +little or no traffic on another partition. + +Also, like every other aspect of distributed systems, the log storage backing any partition may rarely and briefly slow +down or experience congestion. If you leave choosing the target partition for an event to Event Hubs, it can flexibly +react to such availability blips for publishers. + +Generally, you should *not* use partitioning as a traffic prioritization scheme, and you should *not* use it +for fine grained assignment of particular kinds of events to a particular partitions. *Partitions are a load +distribution mechanism, not a filtering model*. diff --git a/eventhubs/data-plane/appveyor.yml b/eventhubs/data-plane/appveyor.yml new file mode 100644 index 000000000000..f1f3a37f3993 --- /dev/null +++ b/eventhubs/data-plane/appveyor.yml @@ -0,0 +1,24 @@ +version: 1.0.{build} +os: Windows Server 2012 +branches: + only: + - master + - dev +install: + - ps: | + Add-Type -AssemblyName System.IO.Compression.FileSystem + if (!(Test-Path -Path "C:\maven" )) { + (new-object System.Net.WebClient).DownloadFile( + 'http://www.us.apache.org/dist/maven/maven-3/3.5.4/binaries/apache-maven-3.5.4-bin.zip', + 'C:\maven-bin.zip' + ) + [System.IO.Compression.ZipFile]::ExtractToDirectory("C:\maven-bin.zip", "C:\maven") + } + - cmd: SET PATH=C:\maven\apache-maven-3.2.5\bin;%JAVA_HOME%\bin;%PATH% + - cmd: SET MAVEN_OPTS=-XX:MaxPermSize=2g -Xmx4g + - cmd: SET JAVA_OPTS=-XX:MaxPermSize=2g -Xmx4g +build_script: + - mvn clean package javadoc:jar source:jar +cache: + - C:\maven\ + - C:\Users\appveyor\.m2 diff --git a/eventhubs/data-plane/azure-eventhubs-eph/.gitignore b/eventhubs/data-plane/azure-eventhubs-eph/.gitignore new file mode 100644 index 000000000000..b83d22266ac8 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/eventhubs/data-plane/azure-eventhubs-eph/Overview.md b/eventhubs/data-plane/azure-eventhubs-eph/Overview.md new file mode 100644 index 000000000000..e4332111693f --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/Overview.md @@ -0,0 +1,279 @@ +# General Overview of Microsoft Azure Event Processor Host for Java + +Event Processor Host is built on top of the Microsoft Azure Event Hubs Client for Java and provides a number of features +not present in that lower layer: + +1. Event Processor Host removes the need to write a receive loop. You simply create a Java class which + implements the IEventProcessor interface, and Event Processor Host will call an instance of that class when + events are available. +2. Event Processor Host removes the need to think about partitions. By default, it creates one instance of the event + processor class for each partition. Each instance will only ever handle + events from one partition, further simplifying the processing code. If you need a different pattern, you can + replace the event processor factory and generate and dispense event processor instances in any way you like. +3. Event Processor Host allows easy load balancing. Utilizing a shared persistent store for leases on partitions + (by default based on Azure Storage), instances of Event Processor Host receiving from the same consumer group + of the same Event Hub can be spread across multiple machines and partitions will be distributed across those + machines as evenly as possible. These instances can be started and stopped at any time, and partitions will be + redistributed as needed. It is even allowed to have more instances than partitions as a form of hot standby. (Note that + partition distribution is based solely on the number of partitions per instance, not event flow rate or any other metric.) +4. Event Processor Host allows the event processor to create a persistent "checkpoint" that describes a position in + the partition's event stream, and if restarted it automatically begins receiving at the next event after the checkpoint. + Because checkpointing is usually an expensive operation, it is up to your IEventProcessor implementation to create + them, at whatever interval is suitable for your application. For example, an application with relatively + infrequent messages might checkpoint after processing each one, whereas an application that requires high performance in + the processing code in order to keep up with event flow might checkpoint once every hundred messages, or once + per second. + +## Using Event Processor Host + +### Step 1: Implement IEventProcessor + +There are four methods which need to be implemented: onOpen, onClose, onError, and onEvents. +onOpen and onClose are called when an event processor instance is created and shut down, respectively, and are intended for setup +and cleanup. For example, an onOpen implementation might open a database connection, and then close it in onClose. onError is called when +an error tied to the partition, such as a receiver failure, has occurred. Recovering from the error, if possible, is up to +Event Processor Host; the call to onError is informational. If it is not possible to recover from the error and the event +processor instance must be shut down, onClose will be called to allow graceful cleanup. + +The onEvents method is where the real work of processing +events occurs: whenever additional events become available for the partition, this method will be called with a batch of events. +The maximum number of events in a batch can be controlled by an option when the event processor class is registered, described below, +and defaults to 10; the actual number of events in a particular batch will vary between 1 and the specified maximum. onEvents may also +be called with an empty iterable on receive timeout, if an option is set when the event processor class is registered, but by default will not. +Note that if onEvents throws an exception out to the calling code before processing all events in the iterable, it loses the opportunity to +process the remaining events. We strongly recommend having a try-catch inside the loop which iterates over the events. + +By default, any particular instance of the event processor is permanently associated with a partition. A PartitionContext +object is provided to every call, but the partition id will never change from call to call. If you are using a non-default event processor +factory to implement a different pattern, such as one where an event processor instance can handle events from multiple partitions, +then the PartitionContext becomes more meaningful. + +PartitionContext also provides the means to create a checkpoint for the partition. The code snippet below checkpoints after +processing every event, for the purpose of providing an example. Because checkpointing is usually an expensive operation, this +pattern is not appropriate for every application. + +```Java +class EventProcessor implements IEventProcessor +{ + @Override + public void onOpen(PartitionContext context) throws Exception + { + System.out.println("Partition " + context.getPartitionId() + " is opening"); + } + + @Override + public void onClose(PartitionContext context, CloseReason reason) throws Exception + { + System.out.println("Partition " + context.getPartitionId() + " is closing for reason " + reason.toString()); + } + + @Override + public void onError(PartitionContext context, Throwable error) + { + System.out.println("Partition " + context.getPartitionId() + " got error " + error.toString()); + } + + @Override + public void onEvents(PartitionContext context, Iterable events) throws Exception + { + System.out.println("SAMPLE: Partition " + context.getPartitionId() + " got message batch"); + for (EventData data : events) + { + try + { + // + // Do something useful with the event here. + // + + // Checkpointing is asynchronous. The only way to determine success or failure is to + // eventually wait for completion of the CompletableFuture. Doing an immediate get() is not + // the best for performance, but it makes a simple example. Because the get() can throw, + // it is inside the per-message try/catch. + context.checkpoint(data).get(); + } + catch (Exception e) // Replace with specific exceptions to catch. + { + // Handle the message-specific issue, or at least swallow the exception so the + // loop can go on to process the next event. Throwing out of onEvents results in + // skipping the entire rest of the batch. + } + } + } +} +``` + +### Step 2: Implement the General Error Notification Handler + +This is a class which implements Consumer. There is just one required method, accept, which will be +called with an argument of type ExceptionReceivedEventArgs if an error occurs which is not tied to any particular partition, or +sometimes if the error came from the event processor for that partition and therefore the state of the event processor is suspect. The +ExceptionReceivedEventArgs contains information specifying the instance of EventProcessorHost where the error occurred, the +exception, and the action being performed at the time of the error. To install this handler, an object of this class is passed +as an option when the event processor class is registered. Recovering from the error, if possible, is up to Event Processor Host; this +notification is informational. + +```Java +class ErrorNotificationHandler implements Consumer +{ + @Override + public void accept(ExceptionReceivedEventArgs t) + { + // Handle the notification here + } +} +``` + +### Step 3: Instantiate EventProcessorHost + +You will first need to build a connection string for the Event Hub. This may be conveniently done using +the ConnectionStringBuilder class provided by the Java client for Azure Event Hubs. Make sure the sasKey has listen permission. + +The EventProcessorHost class itself has multiple constructors. All of them require a name for the host instance, +the path to the Event Hub, the name of the consumer +group to receive from, and the connection string for the Event Hub. The most basic constructor also requires an Azure Storage +connection string for a storage account that the built-in partition lease and checkpoint managers will use to persist these +artifacts, and the name of a container to use or create in that storage account. Other constructors add more options. The +most advanced constructor allows the user to replace the Azure Storage-based lease and checkpoint managers with user implementations +of ILeaseManager and ICheckpointManager (for example, to use Zookeeper instead of Azure Storage). + +```Java +final String namespaceName = "---ServiceBusNamespaceName---"; +final String eventHubName = "---EventHubName---"; +final String sasKeyName = "---SharedAccessSignatureKeyName---"; +final String sasKey = "---SharedAccessSignatureKey---"; +ConnectionStringBuilder eventHubConnectionString = new ConnectionStringBuilder() + .setNamespaceName(namespaceName) + .setEventHubName(eventHubName) + .setSasKeyName(sasKeyName) + .setSasKey(sasKey); + +final String hostname = EventProcessorHost.createHostName("examplehost"); // createHostName adds a UUID to make a unique host name +final String consumerGroupName = EventHubClient.DEFAULT_CONSUMER_GROUP_NAME; // or any consumer group you have created +final String storageConnectionString = "---YouCanGetTheConnectionStringForAStorageAccountFromPortal---"; +final String storageContainerName = "---StorageContainerName---"; +EventProcessorHost host = new EventProcessorHost(hostname, eventHubName, consumerGroupName, eventHubConnectionString.toString(), storageConnectionString, storageContainerName); +``` + +### Step 4: Register the Event Processor Implementation to Start Processing Events + +Instantiate an object of class EventProcessorOptions and call the setExceptionNotification method with an object of the class +implemented in step 2. This is also the time to modify the maximum event batch size (setMaxBatchSize) if you wish, or set other options +such as the receive timeout duration or prefetch count. + +To start processing events, call registerEventProcessor with the options object and the .class of the IEventProcessor implementation +from step 1. This call returns a CompletableFuture which will complete when initialization is finished and event pumping is about to begin. +Waiting for the CompletableFuture to complete (by calling get) is important because initialization failures are detected by catching +exceptions from the get call. Many exceptions will be wrapped within a CompletionException. + +The code shown here uses the default event processor factory, which will generate and dispense a new instance of the event processor class +for every partition. To use a different pattern, you would need to implement IEventProcessorFactory and pass an instance of the +implementation to EventProcessorHost.registerEventProcessorFactory. + +``` Java +EventProcessorOptions options = EventProcessorOptions.getDefaultOptions(); +options.setExceptionNotification(new ErrorNotificationHandler()); +try +{ + host.registerEventProcessor(EventProcessor.class, options).get(); +} +catch (Exception e) +{ + System.out.print("Failure while registering: "); + if (e instanceof CompletionException) + { + Throwable inner = e.getCause(); + System.out.println(inner.toString()); + } + else + { + System.out.println(e.toString()); + } +} +``` + +### Step 5: Graceful Shutdown + +When the time comes to shut down the instance of EventProcessorHost, call the unregisterEventProcessor method. This also +returns a CompletableFuture, which will complete when the event processor host has finished shutting down. + +``` Java +CompletableFuture hostShutdown = host.unregisterEventProcessor(); + +// Do some other shutdown tasks here. + +try +{ + hostShutdown.get(); +} +catch (Exception e) +{ + System.out.print("Failure while shutting down: "); + if (e instanceof CompletionException) + { + Throwable inner = e.getCause(); + System.out.println(inner.toString()); + } + else + { + System.out.println(e.toString()); + } +} +``` + +## Threading Notes + +Calls to the IEventProcessor methods onOpen, onEvents, and onClose are serialized for a given partition. There is no guarantee that +calls to these methods will be on any particular thread, but there will only be one call to any of these methods at a time. The onError +method does not share this guarantee. In particular, if onEvents throws an exception up to the caller, then onError will be called with +that exception. Technically onError is not running at the same time as onEvents, since onEvents has terminated by throwing, but shared data +may be in an unexpected state. + +When using the default event processor factory, there is one IEventProcessor instance per partition, and each instance is permanently tied +to one partition. Under these conditions, an IEventProcessor instance is effectively single-threaded, except for onError. A user-supplied +event processor factory can implement any pattern, such as creating only one IEventProcessor instance and dispensing that instance for use +by every partition. In that example, onEvents will not receive multiple calls for a given partition at the same time, but it can be called +on multiple threads for different partitions. + +## Checkpointing, Partition Ownership, and Reprocessing Messages + +In a system using Event Processor Host, there are one or more hosts processing events from a particular event hub+consumer group combination, and +ownership of the partitions of the event hub are split up between the hosts. When a host takes ownership of a partition, it starts a receiver on +that partition, and when doing so it must specify the position in the stream of events at which the receiver will begin consuming. If there is a checkpoint +for that event hub+consumer group+partition combination available via the checkpoint manager (by default, in Azure Storage), the receiver will begin +consuming at the position indicated by the checkpoint. + +Any time a host takes ownership of a partition, reprocessing of events may occur. Exactly how many messages may be reprocessed depends on how +often checkpoints are written. Writing a checkpoint with the default checkpoint manager is expensive, since it makes at least one HTTPS call to Azure Storage. +The obvious strategy to minimize reprocessing of events is to checkpoint after processing each event, but we advise against this due to the performance hit. +In a low-throughput scenario it may be OK, but as the event rate goes up, checkpointing too often could prevent a processor from being able to keep up with +the flow. Also, event checkpointing after each event cannot completely prevent event reprocessing, since there will always be some time between finishing +processing and writing the checkpoint, during which the processor could fail. Customer applications must be able to detect and handle some amount of +reprocessing, and the customer needs to study their particular scenario and application to balance the cost of handling the reprocessing against the +performance hit of checkpointing more frequently. + +What can cause ownership of a partition to change: +1. Bringing a host online: it will steal ownership of partitions from already-running hosts until the distribution of partitions among hosts is as even as possible. +2. A host crashing/losing power/losing network connection/going offline for any reason: the leases on the partitions that the downed host owned will expire and the +remaining hosts will find the expired leases and take ownership. This may result in unbalanced distribution to start with which will cause additional ownership changes +until the distribution is balanced. +3. Azure Storage latency or failures which result in a partition lease expiring because it cannot be renewed in time: other hosts (or even the same host) will find the +expired lease and take ownership. Again, this can result in unbalanced distribution and additional ownership changes. This scenario can occur even if there is only one host. +4. Certain event hub client errors can cause the processor for a partition to shut down, with the same effects as case 3. This scenario can also occur even with only one host. + +## Running Tests + +Event Processor Host comes with a suite of JUnit-based tests. To run these tests, you will need an event hub and an Azure Storage account. +You can create both through the Azure Portal at [portal.azure.com](http://portal.azure.com/). Once you have done that, get the +connection strings for both and place them in environment variables: + +* `EVENT_HUB_CONNECTION_STRING` is the event hub connection string. The connection string needs to include a SAS rule which has send and listen permissions. +* `EPHTESTSTORAGE` is the storage account connection string. + +Under src/test/java, the general test cases are in files named *Test. If you have made modifications to the code, these are the +cases to run in order to detect major breakage. There are also some test cases in Repros.java, but those are not suitable for +general use. That file preserves repro code from times when we had to mount a major investigation to get to the +bottom of a problem. + +## Tracing + +Event Processor Host can trace its execution for debugging and problem diagnosis, using the well-known SLF4J library. diff --git a/eventhubs/data-plane/azure-eventhubs-eph/Readme.md b/eventhubs/data-plane/azure-eventhubs-eph/Readme.md new file mode 100644 index 000000000000..8926086c76c2 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/Readme.md @@ -0,0 +1,10 @@ +

+ Microsoft Azure Event Hubs +

+ +# Microsoft Azure Event Processor Host for Java + +Event Processor Host is built on top of the Azure Event Hubs Client and provides a number of features +not present in that lower layer. See the [readme for Azure Event Hubs Client for Java](../readme.md) for details of how to +include Event Processor Host in your project. See the [overview](Overview.md) for details of the functionality +that Event Processor Host offers and how to use it. diff --git a/eventhubs/data-plane/azure-eventhubs-eph/pom.xml b/eventhubs/data-plane/azure-eventhubs-eph/pom.xml new file mode 100644 index 000000000000..f0d357b38d90 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/pom.xml @@ -0,0 +1,40 @@ + + + + com.microsoft.azure + azure-eventhubs-clients + 2.0.0 + + + 2.2.0 + + 4.0.0 + + azure-eventhubs-eph + azure-eventhubs-eph + + + scm:git:https://github.com/Azure/azure-event-hubs + + + + + com.microsoft.azure + azure-eventhubs + ${project.parent.version} + + + com.microsoft.azure + azure-storage + 8.0.0 + + + com.google.code.gson + gson + 2.8.5 + + + + libraries and extensions built on Microsoft Azure Event Hubs + diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/AzureBlobLease.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/AzureBlobLease.java new file mode 100644 index 000000000000..d3feba5fbd98 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/AzureBlobLease.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import com.microsoft.azure.storage.StorageException; +import com.microsoft.azure.storage.blob.BlobProperties; +import com.microsoft.azure.storage.blob.BlobRequestOptions; +import com.microsoft.azure.storage.blob.CloudBlockBlob; + +final class AzureBlobLease extends CompleteLease { + private final transient CloudBlockBlob blob; // do not serialize + private final transient BlobRequestOptions options; // do not serialize + private String offset = null; // null means checkpoint is uninitialized + private long sequenceNumber = 0; + private String token = null; + + // not intended to be used; built for GSon + @SuppressWarnings("unused") + private AzureBlobLease() { + super(); + this.blob = null; // so that we can mark blob as final + this.options = null; // so that we can mark options as final + } + + AzureBlobLease(String partitionId, CloudBlockBlob blob, BlobRequestOptions options) { + super(partitionId); + this.blob = blob; + this.options = options; + } + + AzureBlobLease(AzureBlobLease source) { + super(source); + this.offset = source.offset; + this.sequenceNumber = source.sequenceNumber; + this.blob = source.blob; + this.options = source.options; + this.token = source.token; + } + + AzureBlobLease(AzureBlobLease source, CloudBlockBlob blob, BlobRequestOptions options) { + super(source); + this.offset = source.offset; + this.sequenceNumber = source.sequenceNumber; + this.blob = blob; + this.options = options; + this.token = source.token; + } + + AzureBlobLease(CompleteLease source, CloudBlockBlob blob, BlobRequestOptions options) { + super(source); + this.blob = blob; + this.options = options; + } + + CloudBlockBlob getBlob() { + return this.blob; + } + + String getOffset() { + return this.offset; + } + + void setOffset(String offset) { + this.offset = offset; + } + + long getSequenceNumber() { + return this.sequenceNumber; + } + + void setSequenceNumber(long sequenceNumber) { + this.sequenceNumber = sequenceNumber; + } + + String getToken() { + return this.token; + } + + void setToken(String token) { + this.token = token; + } + + Checkpoint getCheckpoint() { + return new Checkpoint(this.getPartitionId(), this.offset, this.sequenceNumber); + } + + @Override + String getStateDebug() { + String retval = "uninitialized"; + try { + this.blob.downloadAttributes(); + BlobProperties props = this.blob.getProperties(); + retval = props.getLeaseState().toString() + " " + props.getLeaseStatus().toString() + " " + props.getLeaseDuration().toString(); + } catch (StorageException e) { + retval = "downloadAttributes on the blob caught " + e.toString(); + } + return retval; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/AzureStorageCheckpointLeaseManager.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/AzureStorageCheckpointLeaseManager.java new file mode 100644 index 000000000000..65020d91a7f4 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/AzureStorageCheckpointLeaseManager.java @@ -0,0 +1,703 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import com.google.gson.Gson; +import com.microsoft.azure.storage.*; +import com.microsoft.azure.storage.blob.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.InvalidKeyException; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Hashtable; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + + +class AzureStorageCheckpointLeaseManager implements ICheckpointManager, ILeaseManager { + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(AzureStorageCheckpointLeaseManager.class); + private static final String METADATA_OWNER_NAME = "OWNINGHOST"; + + private final String storageConnectionString; + private final String storageBlobPrefix; + private final BlobRequestOptions leaseOperationOptions = new BlobRequestOptions(); + private final BlobRequestOptions checkpointOperationOptions = new BlobRequestOptions(); + private final BlobRequestOptions renewRequestOptions = new BlobRequestOptions(); + private HostContext hostContext; + private String storageContainerName; + private CloudBlobClient storageClient; + private CloudBlobContainer eventHubContainer; + private CloudBlobDirectory consumerGroupDirectory; + private Gson gson; + + private Hashtable latestCheckpoint = new Hashtable(); + + AzureStorageCheckpointLeaseManager(String storageConnectionString, String storageContainerName) { + this(storageConnectionString, storageContainerName, ""); + } + + AzureStorageCheckpointLeaseManager(String storageConnectionString, String storageContainerName, String storageBlobPrefix) { + if ((storageConnectionString == null) || storageConnectionString.trim().isEmpty()) { + throw new IllegalArgumentException("Provide valid Azure Storage connection string when using Azure Storage"); + } + this.storageConnectionString = storageConnectionString; + + if ((storageContainerName != null) && storageContainerName.trim().isEmpty()) { + throw new IllegalArgumentException("Azure Storage container name must be a valid container name or null to use the default"); + } + this.storageContainerName = storageContainerName; + + // Convert all-whitespace prefix to empty string. Convert null prefix to empty string. + // Then the rest of the code only has one case to worry about. + this.storageBlobPrefix = (storageBlobPrefix != null) ? storageBlobPrefix.trim() : ""; + } + + // The EventProcessorHost can't pass itself to the AzureStorageCheckpointLeaseManager constructor + // because it is still being constructed. Do other initialization here also because it might throw and + // hence we don't want it in the constructor. + void initialize(HostContext hostContext) throws InvalidKeyException, URISyntaxException, StorageException { + this.hostContext = hostContext; + + if (this.storageContainerName == null) { + this.storageContainerName = this.hostContext.getEventHubPath(); + } + + // Validate that the event hub name is also a legal storage container name. + // Regex pattern is copied from .NET version. The syntax for Java regexes seems to be the same. + // Error message is also copied from .NET version. + Pattern p = Pattern.compile("^(?-i)(?:[a-z0-9]|(?<=[0-9a-z])-(?=[0-9a-z])){3,63}$"); + Matcher m = p.matcher(this.storageContainerName); + if (!m.find()) { + throw new IllegalArgumentException("EventHub names must conform to the following rules to be able to use it with EventProcessorHost: " + + "Must start with a letter or number, and can contain only letters, numbers, and the dash (-) character. " + + "Every dash (-) character must be immediately preceded and followed by a letter or number; consecutive dashes are not permitted in container names. " + + "All letters in a container name must be lowercase. " + + "Must be from 3 to 63 characters long."); + } + + this.storageClient = CloudStorageAccount.parse(this.storageConnectionString).createCloudBlobClient(); + + this.eventHubContainer = this.storageClient.getContainerReference(this.storageContainerName); + + // storageBlobPrefix is either empty or a real user-supplied string. Either way we can just + // stick it on the front and get the desired result. + this.consumerGroupDirectory = this.eventHubContainer.getDirectoryReference(this.storageBlobPrefix + this.hostContext.getConsumerGroupName()); + + this.gson = new Gson(); + + this.leaseOperationOptions.setMaximumExecutionTimeInMs(this.hostContext.getPartitionManagerOptions().getLeaseDurationInSeconds() * 1000); + this.storageClient.setDefaultRequestOptions(this.leaseOperationOptions); + this.checkpointOperationOptions.setMaximumExecutionTimeInMs(this.hostContext.getPartitionManagerOptions().getCheckpointTimeoutInSeconds() * 1000); + // The only option that .NET sets on renewRequestOptions is ServerTimeout, which doesn't exist in Java equivalent. + // Keep it separate in case we need to change something later. + // Only used for leases, not checkpoints, so set max execution time to lease value + this.renewRequestOptions.setMaximumExecutionTimeInMs(this.hostContext.getPartitionManagerOptions().getLeaseDurationInSeconds() * 1000); + } + + @Override + public CompletableFuture checkpointStoreExists() { + return storeExistsInternal(this.checkpointOperationOptions, EventProcessorHostActionStrings.CHECKING_CHECKPOINT_STORE, + "Failure while checking checkpoint store existence"); + } + + + // + // In this implementation, checkpoints are data that's actually in the lease blob, so checkpoint operations + // turn into lease operations under the covers. + // + + @Override + public CompletableFuture createCheckpointStoreIfNotExists() { + // Because we control the caller, we know that this method will only be called after createLeaseStoreIfNotExists. + // In this implementation, it's the same store, so the store will always exist if execution reaches here. + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture deleteCheckpointStore() { + return deleteStoreInternal(this.checkpointOperationOptions); + } + + @Override + public CompletableFuture getCheckpoint(String partitionId) { + CompletableFuture future = null; + + try { + AzureBlobLease lease = getLeaseInternal(partitionId, this.checkpointOperationOptions); + Checkpoint checkpoint = null; + if (lease != null) { + if ((lease.getOffset() != null) && !lease.getOffset().isEmpty()) { + checkpoint = new Checkpoint(partitionId); + checkpoint.setOffset(lease.getOffset()); + checkpoint.setSequenceNumber(lease.getSequenceNumber()); + } + // else offset is null meaning no checkpoint stored for this partition so return null + } + future = CompletableFuture.completedFuture(checkpoint); + } catch (URISyntaxException | IOException | StorageException e) { + future = new CompletableFuture(); + future.completeExceptionally(LoggingUtils.wrapException(e, EventProcessorHostActionStrings.GETTING_CHECKPOINT)); + } + + return future; + } + + @Override + public CompletableFuture createAllCheckpointsIfNotExists(List partitionIds) { + // Because we control the caller, we know that this method will only be called after createAllLeasesIfNotExists. + // In this implementation checkpoints are in the same blobs as leases, so the blobs will already exist if execution reaches here. + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture updateCheckpoint(CompleteLease lease, Checkpoint checkpoint) { + AzureBlobLease updatedLease = new AzureBlobLease((AzureBlobLease) lease); + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(checkpoint.getPartitionId(), + "Checkpointing at " + checkpoint.getOffset() + " // " + checkpoint.getSequenceNumber())); + updatedLease.setOffset(checkpoint.getOffset()); + updatedLease.setSequenceNumber(checkpoint.getSequenceNumber()); + + CompletableFuture future = null; + + try { + if (updateLeaseInternal(updatedLease, this.checkpointOperationOptions)) { + future = CompletableFuture.completedFuture(null); + } else { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(lease, "Lease lost")); + future = new CompletableFuture(); + future.completeExceptionally(LoggingUtils.wrapException(new RuntimeException("Lease lost while updating checkpoint"), + EventProcessorHostActionStrings.UPDATING_CHECKPOINT)); + } + } catch (StorageException | IOException e) { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(lease, "Failure updating checkpoint"), e); + future = new CompletableFuture(); + future.completeExceptionally(LoggingUtils.wrapException(e, EventProcessorHostActionStrings.UPDATING_CHECKPOINT)); + } + + return future; + } + + @Override + public CompletableFuture deleteCheckpoint(String partitionId) { + // Not currently used by EventProcessorHost. + return CompletableFuture.completedFuture(null); + } + + + // + // Lease operations. + // + + @Override + public int getLeaseDurationInMilliseconds() { + return this.hostContext.getPartitionManagerOptions().getLeaseDurationInSeconds() * 1000; + } + + @Override + public CompletableFuture leaseStoreExists() { + return storeExistsInternal(this.leaseOperationOptions, EventProcessorHostActionStrings.CHECKING_LEASE_STORE, + "Failure while checking lease store existence"); + } + + private CompletableFuture storeExistsInternal(BlobRequestOptions options, String action, String trace) { + CompletableFuture future = null; + try { + future = CompletableFuture.completedFuture(this.eventHubContainer.exists(null, options, null)); + } catch (StorageException e) { + TRACE_LOGGER.error(this.hostContext.withHost(trace), e); + future = new CompletableFuture(); + future.completeExceptionally(LoggingUtils.wrapException(e, action)); + } + return future; + } + + @Override + public CompletableFuture createLeaseStoreIfNotExists() { + CompletableFuture future = null; + + try { + // returns true if the container was created, false if it already existed -- we don't care + this.eventHubContainer.createIfNotExists(this.leaseOperationOptions, null); + TRACE_LOGGER.info(this.hostContext.withHost("Created lease store OK or it already existed")); + future = CompletableFuture.completedFuture(null); + } catch (StorageException e) { + future = new CompletableFuture(); + future.completeExceptionally(LoggingUtils.wrapException(e, EventProcessorHostActionStrings.CREATING_LEASE_STORE)); + TRACE_LOGGER.error(this.hostContext.withHost("Failure while creating lease store"), e); + } + + return future; + } + + @Override + public CompletableFuture deleteLeaseStore() { + return deleteStoreInternal(this.leaseOperationOptions); + } + + private CompletableFuture deleteStoreInternal(BlobRequestOptions options) { + CompletableFuture future = null; + + try { + for (ListBlobItem blob : this.eventHubContainer.listBlobs(null, false, EnumSet.noneOf(BlobListingDetails.class), options, null)) { + if (blob instanceof CloudBlobDirectory) { + for (ListBlobItem subBlob : ((CloudBlobDirectory) blob).listBlobs(null, false, EnumSet.noneOf(BlobListingDetails.class), options, null)) { + ((CloudBlockBlob) subBlob).deleteIfExists(DeleteSnapshotsOption.NONE, null, options, null); + } + } else if (blob instanceof CloudBlockBlob) { + ((CloudBlockBlob) blob).deleteIfExists(DeleteSnapshotsOption.NONE, null, options, null); + } + } + + this.eventHubContainer.deleteIfExists(null, options, null); + + future = CompletableFuture.completedFuture(null); + } + catch (StorageException | URISyntaxException e) { + TRACE_LOGGER.error(this.hostContext.withHost("Failure while deleting lease store"), e); + future = new CompletableFuture(); + future.completeExceptionally(new CompletionException(e)); + } + + return future; + } + + @Override + public CompletableFuture getLease(String partitionId) { + CompletableFuture future = null; + + try { + future = CompletableFuture.completedFuture(getLeaseInternal(partitionId, this.leaseOperationOptions)); + } catch (URISyntaxException | IOException | StorageException e) { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(partitionId, "Failure while getting lease details"), e); + future = new CompletableFuture(); + future.completeExceptionally(LoggingUtils.wrapException(e, EventProcessorHostActionStrings.GETTING_LEASE)); + } + + return future; + } + + private AzureBlobLease getLeaseInternal(String partitionId, BlobRequestOptions options) throws URISyntaxException, IOException, StorageException { + AzureBlobLease retval = null; + + CloudBlockBlob leaseBlob = this.consumerGroupDirectory.getBlockBlobReference(partitionId); // getBlockBlobReference does not take options + if (leaseBlob.exists(null, options, null)) { + retval = downloadLease(leaseBlob, options); + } + + return retval; + } + + @Override + public CompletableFuture> getAllLeases() { + CompletableFuture> future = null; + + try { + ArrayList infos = new ArrayList(); + EnumSet details = EnumSet.of(BlobListingDetails.METADATA); + Iterable leaseBlobs = this.consumerGroupDirectory.listBlobs("", true, details, this.leaseOperationOptions, null); + leaseBlobs.forEach((lbi) -> { + CloudBlob blob = (CloudBlob)lbi; + BlobProperties bp = blob.getProperties(); + HashMap metadata = blob.getMetadata(); + Path p = Paths.get(lbi.getUri().getPath()); + infos.add(new BaseLease(p.getFileName().toString(), metadata.get(AzureStorageCheckpointLeaseManager.METADATA_OWNER_NAME), + (bp.getLeaseState() == LeaseState.LEASED))); + }); + future = CompletableFuture.completedFuture(infos); + } catch (URISyntaxException | StorageException e) { + TRACE_LOGGER.warn(this.hostContext.withHost("Failure while getting lease state details"), e); + future = new CompletableFuture>(); + future.completeExceptionally(LoggingUtils.wrapException(e, EventProcessorHostActionStrings.GETTING_LEASE)); + } + + return future; + } + + // NOTE NOTE NOTE: this is the one place where this lease manager implementation returns an uncompleted future. + // This is to support creating the blobs in parallel, which can be an important part of fast startup. + // Because it happens during startup, when no user code is running, it cannot deadlock with checkpointing. + @Override + public CompletableFuture createAllLeasesIfNotExists(List partitionIds) { + CompletableFuture future = null; + + // Optimization: list the blobs currently existing in the directory. If there are the + // expected number of blobs, then we can skip doing the creates. + int blobCount = 0; + try { + Iterable leaseBlobs = this.consumerGroupDirectory.listBlobs("", true, null, this.leaseOperationOptions, null); + Iterator blobIterator = leaseBlobs.iterator(); + while (blobIterator.hasNext()) { + blobCount++; + blobIterator.next(); + } + } catch (URISyntaxException | StorageException e) { + TRACE_LOGGER.error(this.hostContext.withHost("Exception checking lease existence - leaseContainerName: " + this.storageContainerName + " consumerGroupName: " + + this.hostContext.getConsumerGroupName() + " storageBlobPrefix: " + this.storageBlobPrefix), e); + future = new CompletableFuture(); + future.completeExceptionally(LoggingUtils.wrapException(e, EventProcessorHostActionStrings.CREATING_LEASES)); + } + + if (future == null) { + // No error checking the list, so keep going + if (blobCount == partitionIds.size()) { + // All expected blobs found, so short-circuit + future = CompletableFuture.completedFuture(null); + } else { + // Create the blobs in parallel + ArrayList> createFutures = new ArrayList>(); + + for (String id : partitionIds) { + CompletableFuture oneCreate = CompletableFuture.supplyAsync(() -> { + CompleteLease returnLease = null; + try { + returnLease = createLeaseIfNotExistsInternal(id, this.leaseOperationOptions); + } catch (URISyntaxException | IOException | StorageException e) { + TRACE_LOGGER.error(this.hostContext.withHostAndPartition(id, + "Exception creating lease - leaseContainerName: " + this.storageContainerName + " consumerGroupName: " + this.hostContext.getConsumerGroupName() + + " storageBlobPrefix: " + this.storageBlobPrefix), e); + throw LoggingUtils.wrapException(e, EventProcessorHostActionStrings.CREATING_LEASES); + } + return returnLease; + }, this.hostContext.getExecutor()); + createFutures.add(oneCreate); + } + + CompletableFuture dummy[] = new CompletableFuture[createFutures.size()]; + future = CompletableFuture.allOf(createFutures.toArray(dummy)); + } + } + + return future; + } + + private AzureBlobLease createLeaseIfNotExistsInternal(String partitionId, BlobRequestOptions options) throws URISyntaxException, IOException, StorageException { + AzureBlobLease returnLease = null; + try { + CloudBlockBlob leaseBlob = this.consumerGroupDirectory.getBlockBlobReference(partitionId); // getBlockBlobReference does not take options + returnLease = new AzureBlobLease(partitionId, leaseBlob, this.leaseOperationOptions); + uploadLease(returnLease, leaseBlob, AccessCondition.generateIfNoneMatchCondition("*"), UploadActivity.Create, options); + // Do not set metadata on creation. No metadata/no owner value indicates that the lease is unowned. + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(partitionId, + "CreateLeaseIfNotExist OK - leaseContainerName: " + this.storageContainerName + " consumerGroupName: " + this.hostContext.getConsumerGroupName() + + " storageBlobPrefix: " + this.storageBlobPrefix)); + } catch (StorageException se) { + StorageExtendedErrorInformation extendedErrorInfo = se.getExtendedErrorInformation(); + if ((extendedErrorInfo != null) && + ((extendedErrorInfo.getErrorCode().compareTo(StorageErrorCodeStrings.BLOB_ALREADY_EXISTS) == 0) || + (extendedErrorInfo.getErrorCode().compareTo(StorageErrorCodeStrings.LEASE_ID_MISSING) == 0))) // occurs when somebody else already has leased the blob + { + // The blob already exists. + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(partitionId, "Lease already exists")); + returnLease = getLeaseInternal(partitionId, options); + } else { + throw se; + } + } + + return returnLease; + } + + @Override + public CompletableFuture deleteLease(CompleteLease lease) { + CompletableFuture future = null; + + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(lease, "Deleting lease")); + try { + ((AzureBlobLease) lease).getBlob().deleteIfExists(); + future = CompletableFuture.completedFuture(null); + } catch (StorageException e) { + TRACE_LOGGER.error(this.hostContext.withHostAndPartition(lease, "Exception deleting lease"), e); + future = new CompletableFuture(); + future.completeExceptionally(LoggingUtils.wrapException(e, EventProcessorHostActionStrings.DELETING_LEASE)); + } + + return future; + } + + @Override + public CompletableFuture acquireLease(CompleteLease lease) { + CompletableFuture future = null; + + try { + future = CompletableFuture.completedFuture(acquireLeaseInternal((AzureBlobLease) lease)); + } catch (IOException | StorageException e) { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(lease, "Failure acquiring lease"), e); + future = new CompletableFuture(); + future.completeExceptionally(LoggingUtils.wrapException(e, EventProcessorHostActionStrings.ACQUIRING_LEASE)); + } + + return future; + } + + private boolean acquireLeaseInternal(AzureBlobLease lease) throws IOException, StorageException { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(lease, "Acquiring lease")); + + CloudBlockBlob leaseBlob = lease.getBlob(); + boolean succeeded = true; + String newLeaseId = EventProcessorHost.safeCreateUUID(); + if ((newLeaseId == null) || newLeaseId.isEmpty()) { + throw new IllegalArgumentException("acquireLeaseSync: newLeaseId really is " + ((newLeaseId == null) ? "null" : "empty")); + } + try { + String newToken = null; + leaseBlob.downloadAttributes(); + if (leaseBlob.getProperties().getLeaseState() == LeaseState.LEASED) { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(lease, "changeLease")); + if ((lease.getToken() == null) || lease.getToken().isEmpty()) { + // We reach here in a race condition: when this instance of EventProcessorHost scanned the + // lease blobs, this partition was unowned (token is empty) but between then and now, another + // instance of EPH has established a lease (getLeaseState() is LEASED). We normally enforce + // that we only steal the lease if it is still owned by the instance which owned it when we + // scanned, but we can't do that when we don't know who owns it. The safest thing to do is just + // fail the acquisition. If that means that one EPH instance gets more partitions than it should, + // rebalancing will take care of that quickly enough. + succeeded = false; + } else { + newToken = leaseBlob.changeLease(newLeaseId, AccessCondition.generateLeaseCondition(lease.getToken())); + } + } else { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(lease, "acquireLease")); + newToken = leaseBlob.acquireLease(this.hostContext.getPartitionManagerOptions().getLeaseDurationInSeconds(), newLeaseId); + } + if (succeeded) { + lease.setToken(newToken); + lease.setOwner(this.hostContext.getHostName()); + lease.incrementEpoch(); // Increment epoch each time lease is acquired or stolen by a new host + uploadLease(lease, leaseBlob, AccessCondition.generateLeaseCondition(lease.getToken()), UploadActivity.Acquire, this.leaseOperationOptions); + } + } catch (StorageException se) { + if (wasLeaseLost(se, lease.getPartitionId())) { + succeeded = false; + } else { + throw se; + } + } + + return succeeded; + } + + @Override + public CompletableFuture renewLease(CompleteLease lease) { + CompletableFuture future = null; + + try { + future = CompletableFuture.completedFuture(renewLeaseInternal(lease)); + } catch (StorageException se) { + future = new CompletableFuture(); + future.completeExceptionally(LoggingUtils.wrapException(se, EventProcessorHostActionStrings.RENEWING_LEASE)); + } + + return future; + } + + private boolean renewLeaseInternal(CompleteLease lease) throws StorageException { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(lease, "Renewing lease")); + + boolean result = false; + AzureBlobLease azLease = (AzureBlobLease)lease; + CloudBlockBlob leaseBlob = azLease.getBlob(); + + try { + leaseBlob.renewLease(AccessCondition.generateLeaseCondition(azLease.getToken()), this.renewRequestOptions, null); + result = true; + } catch (StorageException se) { + if (wasLeaseLost(se, azLease.getPartitionId())) { + // leave result as false + } else { + throw se; + } + } + + return result; + } + + @Override + public CompletableFuture releaseLease(CompleteLease lease) { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(lease, "Releasing lease")); + + CompletableFuture future = null; + + AzureBlobLease inLease = (AzureBlobLease) lease; + CloudBlockBlob leaseBlob = inLease.getBlob(); + + try { + String leaseId = inLease.getToken(); + AzureBlobLease releasedCopy = new AzureBlobLease(inLease); + releasedCopy.setToken(""); + releasedCopy.setOwner(""); + uploadLease(releasedCopy, leaseBlob, AccessCondition.generateLeaseCondition(leaseId), UploadActivity.Release, this.leaseOperationOptions); + leaseBlob.releaseLease(AccessCondition.generateLeaseCondition(leaseId)); + future = CompletableFuture.completedFuture(null); + } catch (StorageException se) { + if (wasLeaseLost(se, lease.getPartitionId())) { + // If the lease was already lost, then the intent of releasing it has been achieved. + future = CompletableFuture.completedFuture(null); + } else { + future = new CompletableFuture(); + future.completeExceptionally(LoggingUtils.wrapException(se, EventProcessorHostActionStrings.RELEASING_LEASE)); + } + } catch (IOException ie) { + future = new CompletableFuture(); + future.completeExceptionally(LoggingUtils.wrapException(ie, EventProcessorHostActionStrings.RELEASING_LEASE)); + } + + return future; + } + + @Override + public CompletableFuture updateLease(CompleteLease lease) { + CompletableFuture future = null; + + try { + boolean result = updateLeaseInternal((AzureBlobLease) lease, this.leaseOperationOptions); + future = CompletableFuture.completedFuture(result); + } catch (StorageException | IOException e) { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(lease, "Failure updating lease"), e); + future = new CompletableFuture(); + future.completeExceptionally(LoggingUtils.wrapException(e, EventProcessorHostActionStrings.UPDATING_LEASE)); + } + + return future; + } + + public boolean updateLeaseInternal(AzureBlobLease lease, BlobRequestOptions options) throws StorageException, IOException { + if (lease == null) { + return false; + } + + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(lease, "Updating lease")); + + String token = lease.getToken(); + if ((token == null) || (token.length() == 0)) { + return false; + } + + // Renew the lease to make sure the update will go through. + // Renewing the lease is always logically a lease operation, even if it is part of writing a checkpoint, so + // don't pass options. + boolean result = renewLeaseInternal(lease); + if (result) { + CloudBlockBlob leaseBlob = lease.getBlob(); + try { + uploadLease(lease, leaseBlob, AccessCondition.generateLeaseCondition(token), UploadActivity.Update, options); + // Success! Result is already true, so pass it up unchanged + } catch (StorageException se) { + if (wasLeaseLost(se, lease.getPartitionId())) { + result = false; + } else { + throw se; + } + } catch (IOException ie) { + throw ie; + } + } + // else could not renew lease due to lease loss. Result is already false, so pass it up unchanged + + return result; + } + + private AzureBlobLease downloadLease(CloudBlockBlob blob, BlobRequestOptions options) throws StorageException, IOException { + String jsonLease = blob.downloadText(null, null, options, null); + TRACE_LOGGER.debug(this.hostContext.withHost("Raw JSON downloaded: " + jsonLease)); + AzureBlobLease rehydrated = this.gson.fromJson(jsonLease, AzureBlobLease.class); + AzureBlobLease blobLease = new AzureBlobLease(rehydrated, blob, this.leaseOperationOptions); + + if (blobLease.getOffset() != null) { + this.latestCheckpoint.put(blobLease.getPartitionId(), blobLease.getCheckpoint()); + } + + return blobLease; + } + + private void uploadLease(AzureBlobLease lease, CloudBlockBlob blob, AccessCondition condition, UploadActivity activity, BlobRequestOptions options) + throws StorageException, IOException { + if (activity != UploadActivity.Create) { + // It is possible for AzureBlobLease objects in memory to have stale offset/sequence number fields if a + // checkpoint was written but PartitionManager hasn't done its ten-second sweep which downloads new copies + // of all the leases. This can happen because we're trying to maintain the fiction that checkpoints and leases + // are separate -- which they can be in other implementations -- even though they are completely intertwined + // in this implementation. To prevent writing stale checkpoint data to the store, merge the checkpoint data + // from the most recently written checkpoint into this write, if needed. + Checkpoint cached = this.latestCheckpoint.get(lease.getPartitionId()); // HASHTABLE + if ((cached != null) && ((cached.getSequenceNumber() > lease.getSequenceNumber()) || (lease.getOffset() == null))) { + lease.setOffset(cached.getOffset()); + lease.setSequenceNumber(cached.getSequenceNumber()); + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(lease, + "Replacing stale offset/seqno while uploading lease")); + } else if (lease.getOffset() != null) { + this.latestCheckpoint.put(lease.getPartitionId(), lease.getCheckpoint()); + } + } + + String jsonLease = this.gson.toJson(lease); + blob.uploadText(jsonLease, null, condition, options, null); + // During create, we blindly try upload and it may throw. Doing the logging after the upload + // avoids a spurious trace in that case. + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(lease, + "Raw JSON uploading for " + activity + ": " + jsonLease)); + + if ((activity == UploadActivity.Acquire) || (activity == UploadActivity.Release)) { + blob.downloadAttributes(); + HashMap metadata = blob.getMetadata(); + switch (activity) { + case Acquire: + // Add owner in metadata + metadata.put(AzureStorageCheckpointLeaseManager.METADATA_OWNER_NAME, lease.getOwner()); + break; + + case Release: + // Remove owner in metadata + metadata.remove(AzureStorageCheckpointLeaseManager.METADATA_OWNER_NAME); + break; + + default: + // Should never get here, but passing the metadata through unchanged is harmless. + break; + } + blob.setMetadata(metadata); + blob.uploadMetadata(condition, options, null); + } + // else don't touch metadata + } + + private boolean wasLeaseLost(StorageException se, String partitionId) { + boolean retval = false; + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(partitionId, "WAS LEASE LOST? Http " + se.getHttpStatusCode())); + if (se.getExtendedErrorInformation() != null) { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(partitionId, + "Http " + se.getExtendedErrorInformation().getErrorCode() + " :: " + se.getExtendedErrorInformation().getErrorMessage())); + } + if ((se.getHttpStatusCode() == 409) || // conflict + (se.getHttpStatusCode() == 412)) // precondition failed + { + StorageExtendedErrorInformation extendedErrorInfo = se.getExtendedErrorInformation(); + if (extendedErrorInfo != null) { + String errorCode = extendedErrorInfo.getErrorCode(); + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(partitionId, "Error code: " + errorCode)); + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(partitionId, "Error message: " + extendedErrorInfo.getErrorMessage())); + if ((errorCode.compareTo(StorageErrorCodeStrings.LEASE_LOST) == 0) || + (errorCode.compareTo(StorageErrorCodeStrings.LEASE_ID_MISMATCH_WITH_LEASE_OPERATION) == 0) || + (errorCode.compareTo(StorageErrorCodeStrings.LEASE_ID_MISMATCH_WITH_BLOB_OPERATION) == 0) || + (errorCode.compareTo(StorageErrorCodeStrings.LEASE_ALREADY_PRESENT) == 0)) { + retval = true; + } + } + } + return retval; + } + + private enum UploadActivity {Create, Acquire, Release, Update} +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/AzureStoragePartitionManagerOptions.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/AzureStoragePartitionManagerOptions.java new file mode 100644 index 000000000000..0b877ff7024f --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/AzureStoragePartitionManagerOptions.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +public final class AzureStoragePartitionManagerOptions extends PartitionManagerOptions { + public AzureStoragePartitionManagerOptions() { + } + + @Override + public void setLeaseDurationInSeconds(int duration) { + // Max Azure Storage blob lease is 60 seconds + if (duration > 60) { + throw new IllegalArgumentException("Lease duration cannot be more than 60 seconds"); + } + super.setLeaseDurationInSeconds(duration); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/BaseLease.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/BaseLease.java new file mode 100644 index 000000000000..3ca7cb1d9586 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/BaseLease.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +/** + * BaseLease class is public so that advanced users can implement an ILeaseManager. + * Unless you are implementing ILeaseManager you should not have to deal with objects + * of this class or derived classes directly. + *

+ * This lightweight base exists to allow ILeaseManager.getAllLeases to operate as quickly + * as possible -- for some lease manager implementations, loading the entire contents of a + * lease form the store may be expensive. BaseLease contains only the minimum amount of + * information required to allow PartitionScanner to operate. + *

+ * Note that a Lease object just carries information about a partition lease. The APIs + * to acquire/renew/release a lease are all on ILeaseManager. + */ +public class BaseLease implements Comparable { + private final String partitionId; + private String owner = ""; + private transient boolean isOwned = false; // do not serialize + + /** + * Do not use; added only for GSon deserializer + */ + protected BaseLease() { + partitionId = "-1"; + } + + /** + * Create a BaseLease for the given partition. + * + * @param partitionId Partition id for this lease. + */ + public BaseLease(String partitionId) { + this.partitionId = partitionId; + } + + /** + * Create and populate a BaseLease for the given partition. + * + * @param partitionId Partition id for this lease. + * @param owner Current owner of this lease, or empty. + * @param isOwned True if the lease is owned, false if not. + */ + public BaseLease(String partitionId, String owner, boolean isOwned) { + this.partitionId = partitionId; + this.owner = owner; + this.isOwned = isOwned; + } + + /** + * Create a BaseLease by duplicating the given Lease. + * + * @param source BaseLease to clone. + */ + public BaseLease(BaseLease source) { + this.partitionId = source.partitionId; + this.owner = source.owner; + this.isOwned = source.isOwned; + } + + /** + * The owner of a lease is the name of the EventProcessorHost instance which currently holds the lease. + * + * @return name of the owning instance + */ + public String getOwner() { + return this.owner; + } + + /** + * Set the owner string. Used when a host steals a lease. + * + * @param owner name of the new owning instance + */ + public void setOwner(String owner) { + this.owner = owner; + } + + /** + * Set the owned state of the lease. + * + * @param newState true if the lease is owned, or false if it is not + */ + public void setIsOwned(boolean newState) { + this.isOwned = newState; + } + + /** + * Get the owned state of the lease. + * + * @return true if the lease is owned, or false if it is not + */ + public boolean getIsOwned() { + return this.isOwned; + } + + /** + * Convenience function for comparing possibleOwner against this.owner + * + * @param possibleOwner name to check + * @return true if possibleOwner is the same as this.owner, false otherwise + */ + public boolean isOwnedBy(String possibleOwner) { + boolean retval = false; + if (this.owner != null) { + retval = (this.owner.compareTo(possibleOwner) == 0); + } + return retval; + } + + /** + * Returns the id of the partition that this Lease is for. Immutable so there is no corresponding setter. + * + * @return partition id + */ + public String getPartitionId() { + return this.partitionId; + } + + // Compares by partition id + @Override + public int compareTo(BaseLease other) { + return this.partitionId.compareTo(other.getPartitionId()); + } + + String getStateDebug() { + return "N/A"; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/Checkpoint.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/Checkpoint.java new file mode 100644 index 000000000000..1180420df3a4 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/Checkpoint.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import com.microsoft.azure.eventhubs.impl.ClientConstants; + +/** + * Checkpoint class is public so that advanced users can implement an ICheckpointManager. + * Unless you are implementing ICheckpointManager you should not have to deal with objects + * of this class directly. + *

+ * A Checkpoint is essentially just a tuple. It has a fixed partition id, set at creation time + * and immutable thereafter, and associates that with an offset/sequenceNumber pair which + * indicates a position within the events in that partition. + */ +public class Checkpoint { + private final String partitionId; + private String offset = ClientConstants.START_OF_STREAM; + private long sequenceNumber = 0; + + /** + * Create a checkpoint with offset/sequenceNumber set to the start of the stream. + * + * @param partitionId Associated partition. + */ + public Checkpoint(String partitionId) { + this.partitionId = partitionId; + } + + /** + * Create a checkpoint with the given offset and sequenceNumber. It is important that the + * offset and sequence number refer to the same event in the stream. The safest thing + * to do is get both values from the system properties of one EventData instance. + * + * @param partitionId Associated partition. + * @param offset Offset in the stream. + * @param sequenceNumber Sequence number in the stream. + */ + public Checkpoint(String partitionId, String offset, long sequenceNumber) { + this.partitionId = partitionId; + this.offset = offset; + this.sequenceNumber = sequenceNumber; + } + + /** + * Create a checkpoint which is a duplicate of the given checkpoint. + * + * @param source Existing checkpoint to clone. + */ + public Checkpoint(Checkpoint source) { + this.partitionId = source.partitionId; + this.offset = source.offset; + this.sequenceNumber = source.sequenceNumber; + } + + /** + * Return the offset. + * + * @return the current offset value. + */ + public String getOffset() { + return this.offset; + } + + /** + * Set the offset. Remember to also set the sequence number! + * + * @param newOffset the new value for offset in the stream. + */ + public void setOffset(String newOffset) { + this.offset = newOffset; + } + + /** + * Get the sequence number. + * + * @return the current sequence number. + */ + public long getSequenceNumber() { + return this.sequenceNumber; + } + + /** + * Set the sequence number. Remember to also set the offset! + * + * @param newSequenceNumber the new value for sequence number. + */ + public void setSequenceNumber(long newSequenceNumber) { + this.sequenceNumber = newSequenceNumber; + } + + /** + * Get the partition id. There is no corresponding setter because the partition id is immutable. + * + * @return the associated partition id. + */ + public String getPartitionId() { + return this.partitionId; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/Closable.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/Closable.java new file mode 100644 index 000000000000..2d9fa0bff6e3 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/Closable.java @@ -0,0 +1,58 @@ +package com.microsoft.azure.eventprocessorhost; + +class Closable { + private final Object syncClose; + private final Closable parent; // null for top-level + private boolean isClosing; + private boolean isClosed; + + // null parent means top-level + Closable(Closable parent) { + this.syncClose = new Object(); + this.parent = parent; + this.isClosing = false; + this.isClosed = false; + } + + protected final boolean getIsClosed() { + final boolean isParentClosed = this.parent != null && this.parent.getIsClosed(); + synchronized (this.syncClose) { + return isParentClosed || this.isClosed; + } + } + + // returns true even if the Parent is (being) Closed + protected final boolean getIsClosingOrClosed() { + final boolean isParentClosingOrClosed = this.parent != null && this.parent.getIsClosingOrClosed(); + synchronized (this.syncClose) { + return isParentClosingOrClosed || this.isClosing || this.isClosed; + } + } + + protected final void setClosing() { + synchronized (this.syncClose) { + this.isClosing = true; + } + } + + protected final void setClosed() { + synchronized (this.syncClose) { + this.isClosing = false; + this.isClosed = true; + } + } + + protected final void throwIfClosingOrClosed(String message) { + if (getIsClosingOrClosed()) { + throw new ClosingException(message); + } + } + + class ClosingException extends RuntimeException { + private static final long serialVersionUID = 1138985585921317036L; + + ClosingException(String message) { + super(message); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/CloseReason.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/CloseReason.java new file mode 100644 index 000000000000..4fb4387be2f5 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/CloseReason.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +/*** + * Used when implementing IEventProcessor. One argument to onClose is this enum. + */ +public enum CloseReason { + /*** + * The IEventProcessor is closing because the lease on the partition has been lost. + */ + LeaseLost, + + /*** + * The IEventProcessor is closing because the event processor host is being shut down, + * or because an error has occurred. + */ + Shutdown +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/CompleteLease.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/CompleteLease.java new file mode 100644 index 000000000000..f58cec14f987 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/CompleteLease.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +/** + * CompleteLease class is public so that advanced users can implement an ILeaseManager. + * Unless you are implementing ILeaseManager you should not have to deal with objects + * of this class or derived classes directly. + *

+ * CompleteLease carries around complete information about a lease. By itself, it has the + * epoch. Any lease manager implementation can derive from this class to add data which + * the lease manager needs to function -- see AzureBlobLease for an example. Having two + * distinct classes allows the code to clearly express which variety of lease any variable + * holds or a method requires, and avoids the problem of accidentally supplying a lightweight + * BaseLease to a method which needs the lease-manager-specific fields. + */ +public class CompleteLease extends BaseLease { + protected long epoch = -1; // start with illegal epoch + + /** + * Do not use; added only for GSon deserializer + */ + protected CompleteLease() { + super(); + } + + /** + * Create a CompleteLease for the given partition. + * + * @param partitionId Partition id for this lease. + */ + public CompleteLease(String partitionId) { + super(partitionId); + } + + /** + * Create a Lease by duplicating the given Lease. + * + * @param source Lease to clone. + */ + public CompleteLease(CompleteLease source) { + super(source); + this.epoch = source.epoch; + } + + /** + * Epoch is a concept used by Event Hub receivers. If a receiver is created on a partition + * with a higher epoch than the existing receiver, the previous receiver is forcibly disconnected. + * Attempting to create a receiver with a lower epoch than the existing receiver will fail. The Lease + * carries the epoch around so that when a host instance steals a lease, it can create a receiver with a higher epoch. + * + * @return the epoch of the current receiver + */ + public long getEpoch() { + return this.epoch; + } + + /** + * Set the epoch value. Used to update the lease after creating a new receiver with a higher epoch. + * + * @param epoch updated epoch value + */ + public void setEpoch(long epoch) { + this.epoch = epoch; + } + + /** + * The most common operation on the epoch value is incrementing it after stealing a lease. This + * convenience function replaces the get-increment-set that would otherwise be required. + * + * @return The new value of the epoch. + */ + public long incrementEpoch() { + this.epoch++; + return this.epoch; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/DefaultEventProcessorFactory.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/DefaultEventProcessorFactory.java new file mode 100644 index 000000000000..99e578be6da5 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/DefaultEventProcessorFactory.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + + +class DefaultEventProcessorFactory implements IEventProcessorFactory { + private Class eventProcessorClass = null; + + void setEventProcessorClass(Class eventProcessorClass) { + this.eventProcessorClass = eventProcessorClass; + } + + @Override + public T createEventProcessor(PartitionContext context) throws Exception { + return this.eventProcessorClass.newInstance(); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/EventProcessorHost.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/EventProcessorHost.java new file mode 100644 index 000000000000..86724933e0c9 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/EventProcessorHost.java @@ -0,0 +1,570 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import com.microsoft.azure.eventhubs.ConnectionStringBuilder; +import com.microsoft.azure.eventhubs.RetryPolicy; +import com.microsoft.azure.storage.StorageException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URISyntaxException; +import java.security.InvalidKeyException; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +/*** + * The main class of event processor host. + */ +public final class EventProcessorHost { + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(EventProcessorHost.class); + private static final Object uuidSynchronizer = new Object(); + // weOwnExecutor exists to support user-supplied thread pools. + private final boolean weOwnExecutor; + private final ScheduledExecutorService executorService; + private final int executorServicePoolSize = 16; + private final HostContext hostContext; + private boolean initializeLeaseManager = false; + private volatile CompletableFuture unregistered = null; + private PartitionManager partitionManager; + private PartitionManagerOptions partitionManagerOptions = null; + + /** + * Create a new host instance to process events from an Event Hub. + *

+ * Since Event Hubs are generally used for scale-out, high-traffic scenarios, in most scenarios there will + * be only one host instances per process, and the processes will be run on separate machines. Besides scale, this also + * provides isolation: one process or machine crashing will not take out multiple host instances. However, it is + * supported to run multiple host instances on one machine, or even within one process, for development and testing. + *

+ * The hostName parameter is a name for this event processor host, which must be unique among all event processor host instances + * receiving from this event hub+consumer group combination: the unique name is used to distinguish which event processor host + * instance owns the lease for a given partition. An easy way to generate a unique hostName which also includes + * other information is to call EventProcessorHost.createHostName("mystring"). + *

+ * This overload of the constructor uses the built-in lease and checkpoint managers. The + * Azure Storage account specified by the storageConnectionString parameter is used by the built-in + * managers to record leases and checkpoints, in the specified container. + *

+ * The Event Hub connection string may be conveniently constructed using the ConnectionStringBuilder class + * from the Java Event Hub client. + * + * @param hostName A name for this event processor host. See method notes. + * @param eventHubPath Specifies the Event Hub to receive events from. + * @param consumerGroupName The name of the consumer group to use when receiving from the Event Hub. + * @param eventHubConnectionString Connection string for the Event Hub to receive from. + * @param storageConnectionString Connection string for the Azure Storage account to use for persisting leases and checkpoints. + * @param storageContainerName Azure Storage container name for use by built-in lease and checkpoint manager. + */ + public EventProcessorHost( + final String hostName, + final String eventHubPath, + final String consumerGroupName, + final String eventHubConnectionString, + final String storageConnectionString, + final String storageContainerName) { + this(hostName, eventHubPath, consumerGroupName, eventHubConnectionString, storageConnectionString, storageContainerName, (ScheduledExecutorService) null); + } + + /** + * Create a new host to process events from an Event Hub. + *

+ * This overload adds an argument to specify a user-provided thread pool. The number of partitions in the + * target event hub and the number of host instances should be considered when choosing the size of the thread pool: + * how many partitions is one instance expected to own under normal circumstances? One thread per partition should + * provide good performance, while being able to support more partitions adequately if a host instance fails and its + * partitions must be redistributed. + * + * @param hostName A name for this event processor host. See method notes. + * @param eventHubPath Specifies the Event Hub to receive events from. + * @param consumerGroupName The name of the consumer group to use when receiving from the Event Hub. + * @param eventHubConnectionString Connection string for the Event Hub to receive from. + * @param storageConnectionString Connection string for the Azure Storage account to use for persisting leases and checkpoints. + * @param storageContainerName Azure Storage container name for use by built-in lease and checkpoint manager. + * @param executorService User-supplied thread executor, or null to use EventProcessorHost-internal executor. + */ + public EventProcessorHost( + final String hostName, + final String eventHubPath, + final String consumerGroupName, + final String eventHubConnectionString, + final String storageConnectionString, + final String storageContainerName, + final ScheduledExecutorService executorService) { + this(hostName, eventHubPath, consumerGroupName, eventHubConnectionString, storageConnectionString, storageContainerName, (String) null, executorService); + } + + /** + * Create a new host to process events from an Event Hub. + *

+ * This overload adds an argument to specify a prefix used by the built-in lease manager when naming blobs in Azure Storage. + * + * @param hostName A name for this event processor host. See method notes. + * @param eventHubPath Specifies the Event Hub to receive events from. + * @param consumerGroupName The name of the consumer group to use when receiving from the Event Hub. + * @param eventHubConnectionString Connection string for the Event Hub to receive from. + * @param storageConnectionString Connection string for the Azure Storage account to use for persisting leases and checkpoints. + * @param storageContainerName Azure Storage container name for use by built-in lease and checkpoint manager. + * @param storageBlobPrefix Prefix used when naming blobs within the storage container. + */ + public EventProcessorHost( + final String hostName, + final String eventHubPath, + final String consumerGroupName, + final String eventHubConnectionString, + final String storageConnectionString, + final String storageContainerName, + final String storageBlobPrefix) { + this(hostName, eventHubPath, consumerGroupName, eventHubConnectionString, storageConnectionString, storageContainerName, storageBlobPrefix, + (ScheduledExecutorService) null); + } + + /** + * Create a new host to process events from an Event Hub. + *

+ * This overload allows the caller to specify both a user-supplied thread pool and + * a prefix used by the built-in lease manager when naming blobs in Azure Storage. + * + * @param hostName A name for this event processor host. See method notes. + * @param eventHubPath Specifies the Event Hub to receive events from. + * @param consumerGroupName The name of the consumer group to use when receiving from the Event Hub. + * @param eventHubConnectionString Connection string for the Event Hub to receive from. + * @param storageConnectionString Connection string for the Azure Storage account to use for persisting leases and checkpoints. + * @param storageContainerName Azure Storage container name for use by built-in lease and checkpoint manager. + * @param storageBlobPrefix Prefix used when naming blobs within the storage container. + * @param executorService User-supplied thread executor, or null to use EventProcessorHost-internal executor. + */ + public EventProcessorHost( + final String hostName, + final String eventHubPath, + final String consumerGroupName, + final String eventHubConnectionString, + final String storageConnectionString, + final String storageContainerName, + final String storageBlobPrefix, + final ScheduledExecutorService executorService) { + // Would like to check storageConnectionString and storageContainerName here but can't, because Java doesn't allow statements before + // calling another constructor. storageBlobPrefix is allowed to be null or empty, doesn't need checking. + this(hostName, eventHubPath, consumerGroupName, eventHubConnectionString, + new AzureStorageCheckpointLeaseManager(storageConnectionString, storageContainerName, storageBlobPrefix), executorService); + this.initializeLeaseManager = true; + this.partitionManagerOptions = new AzureStoragePartitionManagerOptions(); + } + + // Because Java won't let you do ANYTHING before calling another constructor. In particular, you can't + // new up an object and pass it as TWO parameters of the other constructor. + private EventProcessorHost( + final String hostName, + final String eventHubPath, + final String consumerGroupName, + final String eventHubConnectionString, + final AzureStorageCheckpointLeaseManager combinedManager, + final ScheduledExecutorService executorService) { + this(hostName, eventHubPath, consumerGroupName, eventHubConnectionString, combinedManager, combinedManager, executorService, null); + } + + /** + * Create a new host to process events from an Event Hub. + *

+ * This overload allows the caller to provide their own lease and checkpoint managers to replace the built-in + * ones based on Azure Storage. + * + * @param hostName A name for this event processor host. See method notes. + * @param eventHubPath Specifies the Event Hub to receive events from. + * @param consumerGroupName The name of the consumer group to use when receiving from the Event Hub. + * @param eventHubConnectionString Connection string for the Event Hub to receive from. + * @param checkpointManager Implementation of ICheckpointManager, to be replacement checkpoint manager. + * @param leaseManager Implementation of ILeaseManager, to be replacement lease manager. + */ + public EventProcessorHost( + final String hostName, + final String eventHubPath, + final String consumerGroupName, + final String eventHubConnectionString, + ICheckpointManager checkpointManager, + ILeaseManager leaseManager) { + this(hostName, eventHubPath, consumerGroupName, eventHubConnectionString, checkpointManager, leaseManager, null, null); + } + + /** + * Create a new host to process events from an Event Hub. + *

+ * This overload allows the caller to provide their own lease and checkpoint managers to replace the built-in + * ones based on Azure Storage, and to provide an executor service and a retry policy for communications with the event hub. + * + * @param hostName A name for this event processor host. See method notes. + * @param eventHubPath Specifies the Event Hub to receive events from. + * @param consumerGroupName The name of the consumer group to use when receiving from the Event Hub. + * @param eventHubConnectionString Connection string for the Event Hub to receive from. + * @param checkpointManager Implementation of ICheckpointManager, to be replacement checkpoint manager. + * @param leaseManager Implementation of ILeaseManager, to be replacement lease manager. + * @param executorService User-supplied thread executor, or null to use EventProcessorHost-internal executor. + * @param retryPolicy Retry policy governing communications with the event hub. + */ + public EventProcessorHost( + final String hostName, + final String eventHubPath, + final String consumerGroupName, + final String eventHubConnectionString, + ICheckpointManager checkpointManager, + ILeaseManager leaseManager, + ScheduledExecutorService executorService, + RetryPolicy retryPolicy) { + if ((hostName == null) || hostName.isEmpty()) { + throw new IllegalArgumentException("hostName argument must not be null or empty string"); + } + + // eventHubPath is allowed to be null or empty if it is provided in the connection string. That will be checked later. + if ((consumerGroupName == null) || consumerGroupName.isEmpty()) { + throw new IllegalArgumentException("consumerGroupName argument must not be null or empty"); + } + + if ((eventHubConnectionString == null) || eventHubConnectionString.isEmpty()) { + throw new IllegalArgumentException("eventHubConnectionString argument must not be null or empty"); + } + + // The event hub path must appear in at least one of the eventHubPath argument or the connection string. + // If it appears in both, then it must be the same in both. If it appears in only one, populate the other. + ConnectionStringBuilder providedCSB = new ConnectionStringBuilder(eventHubConnectionString); + String extractedEntityPath = providedCSB.getEventHubName(); + String effectiveEventHubPath = eventHubPath; + String effectiveEventHubConnectionString = eventHubConnectionString; + if ((effectiveEventHubPath != null) && !effectiveEventHubPath.isEmpty()) { + if (extractedEntityPath != null) { + if (effectiveEventHubPath.compareTo(extractedEntityPath) != 0) { + throw new IllegalArgumentException("Provided EventHub path in eventHubPath parameter conflicts with the path in provided EventHub connection string"); + } + // else they are the same and that's fine + } else { + // There is no entity path in the connection string, so put it there. + ConnectionStringBuilder rebuildCSB = new ConnectionStringBuilder() + .setEndpoint(providedCSB.getEndpoint()) + .setEventHubName(effectiveEventHubPath) + .setSasKeyName(providedCSB.getSasKeyName()) + .setSasKey(providedCSB.getSasKey()); + rebuildCSB.setOperationTimeout(providedCSB.getOperationTimeout()); + effectiveEventHubConnectionString = rebuildCSB.toString(); + } + } else { + if ((extractedEntityPath != null) && !extractedEntityPath.isEmpty()) { + effectiveEventHubPath = extractedEntityPath; + } else { + throw new IllegalArgumentException("Provide EventHub entity path in either eventHubPath argument or in eventHubConnectionString"); + } + } + + if (checkpointManager == null) { + throw new IllegalArgumentException("Must provide an object which implements ICheckpointManager"); + } + if (leaseManager == null) { + throw new IllegalArgumentException("Must provide an object which implements ILeaseManager"); + } + // executorService argument is allowed to be null, that is the indication to use an internal threadpool. + + if (this.partitionManagerOptions == null) { + // Normally will not be null because we're using the AzureStorage implementation. + // If it is null, we're using user-supplied implementation. Establish generic defaults + // in case the user doesn't provide an options object. + this.partitionManagerOptions = new PartitionManagerOptions(); + } + + if (executorService != null) { + // User has supplied an ExecutorService, so use that. + this.weOwnExecutor = false; + this.executorService = executorService; + } else { + this.weOwnExecutor = true; + this.executorService = Executors.newScheduledThreadPool( + this.executorServicePoolSize, + new EventProcessorHostThreadPoolFactory(hostName, effectiveEventHubPath, consumerGroupName)); + } + + this.hostContext = new HostContext(this.executorService, + this, hostName, + effectiveEventHubPath, consumerGroupName, effectiveEventHubConnectionString, retryPolicy, + leaseManager, checkpointManager); + + this.partitionManager = new PartitionManager(hostContext); + + TRACE_LOGGER.info(this.hostContext.withHost("New EventProcessorHost created.")); + } + + /** + * Convenience method for generating unique host names, safe to pass to the EventProcessorHost constructors + * that take a hostName argument. + *

+ * If a prefix is supplied, the constructed name begins with that string. If the prefix argument is null or + * an empty string, the constructed name begins with "javahost". Then a dash '-' and a UUID are appended to + * create a unique name. + * + * @param prefix String to use as the beginning of the name. If null or empty, a default is used. + * @return A unique host name to pass to EventProcessorHost constructors. + */ + public static String createHostName(String prefix) { + String usePrefix = prefix; + if ((usePrefix == null) || usePrefix.isEmpty()) { + usePrefix = "javahost"; + } + return usePrefix + "-" + safeCreateUUID(); + } + + /** + * Synchronized string UUID generation convenience method. + *

+ * We saw null and empty strings returned from UUID.randomUUID().toString() when used from multiple + * threads and there is no clear answer on the net about whether it is really thread-safe or not. + *

+ * One of the major users of UUIDs is the built-in lease and checkpoint manager, which can be replaced by + * user implementations. This UUID generation method is public so user implementations can use it as well and + * avoid the problems. + * + * @return A string UUID with dashes but no curly brackets. + */ + public static String safeCreateUUID() { + synchronized (EventProcessorHost.uuidSynchronizer) { + final UUID newUuid = UUID.randomUUID(); + return newUuid.toString(); + } + } + + /** + * The processor host name is supplied by the user at constructor time, but being able to get + * it is useful because it means not having to carry both the host object and the name around. + * As long as you have the host object, you can get the name back, such as for logging. + * + * @return The processor host name + */ + public String getHostName() { + return this.hostContext.getHostName(); + } + + // TEST USE ONLY + void setPartitionManager(PartitionManager pm) { + this.partitionManager = pm; + } + + HostContext getHostContext() { + return this.hostContext; + } + + /** + * Returns the existing partition manager options object. Unless you are providing implementations of + * ILeaseManager and ICheckpointMananger, to change partition manager options, call this method to get + * the existing object and call setters on it to adjust the values. + * + * @return the internally-created PartitionManangerObjects object or any replacement object set with setPartitionManangerOptions + */ + public PartitionManagerOptions getPartitionManagerOptions() { + return this.partitionManagerOptions; + } + + /** + * Set the partition manager options all at once. Normally this method is used only when providing user + * implementations of ILeaseManager and ICheckpointManager, because it allows passing an object of a class + * derived from PartitionManagerOptions, which could contain options specific to the user-implemented ILeaseManager + * or ICheckpointMananger. When using the default, Azure Storage-based implementation, the recommendation is to + * call getPartitionManangerOptions to return the existing options object, then call setters on that object to + * adjust the values. + * + * @param options - a PartitionManangerOptions object (or derived object) representing the desired options + */ + public void setPartitionManagerOptions(PartitionManagerOptions options) { + this.partitionManagerOptions = options; + } + + /** + * Register class for event processor and start processing. + *

+ * This overload uses the default event processor factory, which simply creates new instances of + * the registered event processor class, and uses all the default options. + *

+ * The returned CompletableFuture completes when host initialization is finished. Initialization failures are + * reported by completing the future with an exception, so it is important to call get() on the future and handle + * any exceptions thrown. + *

+     * class MyEventProcessor implements IEventProcessor { ... }
+     * EventProcessorHost host = new EventProcessorHost(...);
+     * {@literal CompletableFuture} foo = host.registerEventProcessor(MyEventProcessor.class);
+     * foo.get();
+     * 
+ * + * @param Not actually a parameter. Represents the type of your class that implements IEventProcessor. + * @param eventProcessorType Class that implements IEventProcessor. + * @return Future that completes when initialization is finished. + */ + public CompletableFuture registerEventProcessor(Class eventProcessorType) { + DefaultEventProcessorFactory defaultFactory = new DefaultEventProcessorFactory(); + defaultFactory.setEventProcessorClass(eventProcessorType); + return registerEventProcessorFactory(defaultFactory, EventProcessorOptions.getDefaultOptions()); + } + + /** + * Register class for event processor and start processing. + *

+ * This overload uses the default event processor factory, which simply creates new instances of + * the registered event processor class, but takes user-specified options. + *

+ * The returned CompletableFuture completes when host initialization is finished. Initialization failures are + * reported by completing the future with an exception, so it is important to call get() on the future and handle + * any exceptions thrown. + * + * @param Not actually a parameter. Represents the type of your class that implements IEventProcessor. + * @param eventProcessorType Class that implements IEventProcessor. + * @param processorOptions Options for the processor host and event processor(s). + * @return Future that completes when initialization is finished. + */ + public CompletableFuture registerEventProcessor(Class eventProcessorType, EventProcessorOptions processorOptions) { + DefaultEventProcessorFactory defaultFactory = new DefaultEventProcessorFactory(); + defaultFactory.setEventProcessorClass(eventProcessorType); + return registerEventProcessorFactory(defaultFactory, processorOptions); + } + + /** + * Register a user-supplied event processor factory and start processing. + *

+ * If creating a new event processor requires more work than just new'ing an objects, the user must + * create an object that implements IEventProcessorFactory and pass it to this method, instead of calling + * registerEventProcessor. + *

+ * This overload uses default options for the processor host and event processor(s). + *

+ * The returned CompletableFuture completes when host initialization is finished. Initialization failures are + * reported by completing the future with an exception, so it is important to call get() on the future and handle + * any exceptions thrown. + * + * @param factory User-supplied event processor factory object. + * @return Future that completes when initialization is finished. + */ + public CompletableFuture registerEventProcessorFactory(IEventProcessorFactory factory) { + return registerEventProcessorFactory(factory, EventProcessorOptions.getDefaultOptions()); + } + + /** + * Register user-supplied event processor factory and start processing. + *

+ * This overload takes user-specified options. + *

+ * The returned CompletableFuture completes when host initialization is finished. Initialization failures are + * reported by completing the future with an exception, so it is important to call get() on the future and handle + * any exceptions thrown. + * + * @param factory User-supplied event processor factory object. + * @param processorOptions Options for the processor host and event processor(s). + * @return Future that completes when initialization is finished. + */ + public CompletableFuture registerEventProcessorFactory(IEventProcessorFactory factory, EventProcessorOptions processorOptions) { + if (this.unregistered != null) { + throw new IllegalStateException("Register cannot be called on an EventProcessorHost after unregister. Please create a new EventProcessorHost instance."); + } + if (this.hostContext.getEventProcessorFactory() != null) { + throw new IllegalStateException("Register has already been called on this EventProcessorHost"); + } + + this.hostContext.setEventProcessorFactory(factory); + this.hostContext.setEventProcessorOptions(processorOptions); + + if (this.executorService.isShutdown() || this.executorService.isTerminated()) { + TRACE_LOGGER.warn(this.hostContext.withHost("Calling registerEventProcessor/Factory after executor service has been shut down.")); + throw new RejectedExecutionException("EventProcessorHost executor service has been shut down"); + } + + if (this.initializeLeaseManager) { + try { + ((AzureStorageCheckpointLeaseManager) this.hostContext.getLeaseManager()).initialize(this.hostContext); + } catch (InvalidKeyException | URISyntaxException | StorageException e) { + TRACE_LOGGER.error(this.hostContext.withHost("Failure initializing default lease and checkpoint manager.")); + throw new RuntimeException("Failure initializing Storage lease manager", e); + } + } + + TRACE_LOGGER.info(this.hostContext.withHost("Starting event processing.")); + + return this.partitionManager.initialize(); + } + + /** + * Stop processing events and shut down this host instance. + * + * @return A CompletableFuture that completes when shutdown is finished. + */ + public CompletableFuture unregisterEventProcessor() { + TRACE_LOGGER.info(this.hostContext.withHost("Stopping event processing")); + + if (this.unregistered == null) { + // PartitionManager is created in constructor. If this object exists, then + // this.partitionManager is not null. + this.unregistered = this.partitionManager.stopPartitions(); + + // If we own the executor, stop it also. + // Owned executor is also created in constructor. + if (this.weOwnExecutor) { + this.unregistered = this.unregistered.thenRunAsync(() -> + { + // IMPORTANT: run this last stage in the default threadpool! + // If a task running in a threadpool waits for that threadpool to terminate, it's going to wait a long time... + + // It is OK to call shutdown() here even if threads are still running. + // Shutdown() causes the executor to stop accepting new tasks, but existing tasks will + // run to completion. The pool will terminate when all existing tasks finish. + // By this point all new tasks generated by the shutdown have been submitted. + this.executorService.shutdown(); + + try { + this.executorService.awaitTermination(10, TimeUnit.MINUTES); + } catch (InterruptedException e) { + throw new CompletionException(e); + } + }, ForkJoinPool.commonPool()); + } + } + + return this.unregistered; + } + + static class EventProcessorHostThreadPoolFactory implements ThreadFactory { + private static final AtomicInteger poolNumber = new AtomicInteger(1); + private final AtomicInteger threadNumber = new AtomicInteger(1); + private final ThreadGroup group; + private final String namePrefix; + private final String hostName; + private final String entityName; + private final String consumerGroupName; + + public EventProcessorHostThreadPoolFactory( + String hostName, + String entityName, + String consumerGroupName) { + this.hostName = hostName; + this.entityName = entityName; + this.consumerGroupName = consumerGroupName; + this.namePrefix = this.getNamePrefix(); + SecurityManager s = System.getSecurityManager(); + this.group = (s != null) ? s.getThreadGroup() : Thread.currentThread().getThreadGroup(); + } + + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(this.group, r, this.namePrefix + this.threadNumber.getAndIncrement(), 0); + t.setDaemon(false); + t.setPriority(Thread.NORM_PRIORITY); + t.setUncaughtExceptionHandler(new ThreadUncaughtExceptionHandler()); + return t; + } + + private String getNamePrefix() { + return String.format("[%s|%s|%s]-%s-", + this.entityName, this.consumerGroupName, this.hostName, poolNumber.getAndIncrement()); + } + + static class ThreadUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler { + @Override + public void uncaughtException(Thread t, Throwable e) { + TRACE_LOGGER.warn("Uncaught exception occurred. Thread " + t.getName(), e); + } + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/EventProcessorHostActionStrings.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/EventProcessorHostActionStrings.java new file mode 100644 index 000000000000..7d1335d5dfe4 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/EventProcessorHostActionStrings.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +/*** + * The action string of ExceptionReceivedEventArts will be one of this. + * They describe what activity was taking place when the exception occurred. + */ +public final class EventProcessorHostActionStrings { + public final static String ACQUIRING_LEASE = "Acquiring Lease"; + public final static String CHECKING_CHECKPOINT_STORE = "Checking Checpoint Store Existence"; + public final static String CHECKING_LEASES = "Checking Leases"; + public final static String CHECKING_LEASE_STORE = "Checking Lease Store Existence"; + public final static String CLOSING_EVENT_PROCESSOR = "Closing Event Processor"; + public final static String CREATING_CHECKPOINTS = "Creating Checkpoint Holders"; + public final static String CREATING_CHECKPOINT_STORE = "Creating Checkpoint Store"; + public final static String CREATING_EVENT_HUB_CLIENT = "Creating Event Hub Client"; + public final static String CREATING_EVENT_PROCESSOR = "Creating Event Processor"; + public final static String CREATING_LEASES = "Creating Leases"; + public final static String CREATING_LEASE_STORE = "Creating Lease Store"; + public final static String DELETING_LEASE = "Deleting Lease"; + public final static String GETTING_CHECKPOINT = "Getting Checkpoint Details"; + public final static String GETTING_LEASE = "Getting Lease Details"; + public final static String INITIALIZING_STORES = "Initializing Stores"; + public final static String OPENING_EVENT_PROCESSOR = "Opening Event Processor"; + public final static String PARTITION_MANAGER_CLEANUP = "Partition Manager Cleanup"; + public final static String PARTITION_MANAGER_MAIN_LOOP = "Partition Manager Main Loop"; + public final static String RELEASING_LEASE = "Releasing Lease"; + public final static String RENEWING_LEASE = "Renewing Lease"; + public final static String STEALING_LEASE = "Stealing Lease"; + public final static String UPDATING_CHECKPOINT = "Updating Checkpoint"; + public final static String UPDATING_LEASE = "Updating Lease"; +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/EventProcessorOptions.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/EventProcessorOptions.java new file mode 100644 index 000000000000..e5d5ac8cce5e --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/EventProcessorOptions.java @@ -0,0 +1,242 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import com.microsoft.azure.eventhubs.EventPosition; +import com.microsoft.azure.eventhubs.PartitionReceiver; + +import java.time.Duration; +import java.util.Locale; +import java.util.function.Consumer; +import java.util.function.Function; + +/*** + * Options affecting the behavior of the event processor host instance in general. + */ +public final class EventProcessorOptions { + private Consumer exceptionNotificationHandler = null; + private Boolean invokeProcessorAfterReceiveTimeout = false; + private boolean receiverRuntimeMetricEnabled = false; + private int maxBatchSize = 10; + private int prefetchCount = 300; + private Duration receiveTimeOut = Duration.ofMinutes(1); + private Function initialPositionProvider = (partitionId) -> { + return EventPosition.fromStartOfStream(); + }; + + public EventProcessorOptions() { + } + + /*** + * Returns an EventProcessorOptions instance with all options set to the default values. + * + * The default values are: + *

+     * MaxBatchSize: 10
+     * ReceiveTimeOut: 1 minute
+     * PrefetchCount: 300
+     * InitialPositionProvider: uses the last checkpoint, or START_OF_STREAM
+     * InvokeProcessorAfterReceiveTimeout: false
+     * ReceiverRuntimeMetricEnabled: false
+     * 
+ * + * @return an EventProcessorOptions instance with all options set to the default values + */ + public static EventProcessorOptions getDefaultOptions() { + return new EventProcessorOptions(); + } + + /** + * Sets a handler which receives notification of general exceptions. + *

+ * Exceptions which occur while processing events from a particular Event Hub partition are delivered + * to the onError method of the event processor for that partition. This handler is called on occasions + * when there is no event processor associated with the throwing activity, or the event processor could + * not be created. + *

+ * The handler is not expected to do anything about the exception. If it is possible to recover, the + * event processor host instance will recover automatically. + * + * @param notificationHandler Handler which is called when an exception occurs. Set to null to stop handling. + */ + public void setExceptionNotification(Consumer notificationHandler) { + this.exceptionNotificationHandler = notificationHandler; + } + + /** + * Returns the maximum number of events that will be passed to one call to IEventProcessor.onEvents + * + * @return the maximum maximum number of events that will be passed to one call to IEventProcessor.onEvents + */ + public int getMaxBatchSize() { + return this.maxBatchSize; + } + + /** + * Sets the maximum number of events that will be passed to one call to IEventProcessor.onEvents + * + * @param maxBatchSize the maximum number of events that will be passed to one call to IEventProcessor.onEvents + */ + public void setMaxBatchSize(int maxBatchSize) { + this.maxBatchSize = maxBatchSize; + } + + /** + * Returns the timeout for receive operations. + * + * @return the timeout for receive operations + */ + public Duration getReceiveTimeOut() { + return this.receiveTimeOut; + } + + /** + * Sets the timeout for receive operations. + * + * @param receiveTimeOut new timeout for receive operations + */ + public void setReceiveTimeOut(Duration receiveTimeOut) { + this.receiveTimeOut = receiveTimeOut; + } + + /*** + * Returns the current prefetch count for the underlying event hub client. + * + * @return the current prefetch count for the underlying client + */ + public int getPrefetchCount() { + return this.prefetchCount; + } + + /*** + * Sets the prefetch count for the underlying event hub client. + * + * The default is 500. This controls how many events are received in advance. + * + * @param prefetchCount The new prefetch count. + */ + public void setPrefetchCount(int prefetchCount) { + if (prefetchCount < PartitionReceiver.MINIMUM_PREFETCH_COUNT) { + throw new IllegalArgumentException(String.format(Locale.US, + "PrefetchCount has to be above %s", PartitionReceiver.MINIMUM_PREFETCH_COUNT)); + } + + if (prefetchCount > PartitionReceiver.MAXIMUM_PREFETCH_COUNT) { + throw new IllegalArgumentException(String.format(Locale.US, + "PrefetchCount has to be below %s", PartitionReceiver.MAXIMUM_PREFETCH_COUNT)); + } + + this.prefetchCount = prefetchCount; + } + + /*** + * If there is no checkpoint for a partition, the initialPositionProvider function is used to determine + * the position at which to start receiving events for that partition. + * + * @return the current initial position provider function + */ + public Function getInitialPositionProvider() { + return this.initialPositionProvider; + } + + /*** + * Sets the function used to determine the position at which to start receiving events for a + * partition if there is no checkpoint for that partition. + * + * The provider function takes one argument, the partition id (a String), and returns the desired position. + * + * @param initialPositionProvider The new provider function. + */ + public void setInitialPositionProvider(Function initialPositionProvider) { + this.initialPositionProvider = initialPositionProvider; + } + + /*** + * Returns whether the EventProcessorHost will call IEventProcessor.onEvents() with an empty iterable + * when a receive timeout occurs (true) or not (false). + * + * Defaults to false. + * + * @return true if EventProcessorHost will call IEventProcessor.OnEvents on receive timeout, false otherwise + */ + public Boolean getInvokeProcessorAfterReceiveTimeout() { + return this.invokeProcessorAfterReceiveTimeout; + } + + /** + * Changes whether the EventProcessorHost will call IEventProcessor.onEvents() with an empty iterable + * when a receive timeout occurs (true) or not (false). + *

+ * The default is false (no call). + * + * @param invokeProcessorAfterReceiveTimeout the new value for what to do + */ + public void setInvokeProcessorAfterReceiveTimeout(Boolean invokeProcessorAfterReceiveTimeout) { + this.invokeProcessorAfterReceiveTimeout = invokeProcessorAfterReceiveTimeout; + } + + /** + * Knob to enable/disable runtime metric of the receiver. If this is set to true, + * the first parameter {@link com.microsoft.azure.eventprocessorhost.PartitionContext#runtimeInformation} of + * {@link IEventProcessor#onEvents(com.microsoft.azure.eventprocessorhost.PartitionContext, java.lang.Iterable)} will be populated. + *

+ * Enabling this knob will add 3 additional properties to all raw AMQP events received. + * + * @return the {@link boolean} indicating, whether, the runtime metric of the receiver was enabled + */ + public boolean getReceiverRuntimeMetricEnabled() { + return this.receiverRuntimeMetricEnabled; + } + + /** + * Knob to enable/disable runtime metric of the receiver. If this is set to true, + * the first parameter {@link com.microsoft.azure.eventprocessorhost.PartitionContext#runtimeInformation} of + * {@link IEventProcessor#onEvents(com.microsoft.azure.eventprocessorhost.PartitionContext, java.lang.Iterable)} will be populated. + *

+ * Enabling this knob will add 3 additional properties to all raw AMQP events received. + * + * @param value the {@link boolean} to indicate, whether, the runtime metric of the receiver should be enabled + */ + public void setReceiverRuntimeMetricEnabled(boolean value) { + this.receiverRuntimeMetricEnabled = value; + } + + void notifyOfException(String hostname, Exception exception, String action) { + notifyOfException(hostname, exception, action, ExceptionReceivedEventArgs.NO_ASSOCIATED_PARTITION); + } + + void notifyOfException(String hostname, Exception exception, String action, String partitionId) { + // Capture handler so it doesn't get set to null between test and use + Consumer handler = this.exceptionNotificationHandler; + if (handler != null) { + handler.accept(new ExceptionReceivedEventArgs(hostname, exception, action, partitionId)); + } + } + + /*** + * A prefab initial position provider that starts from the first event available. + * + * How to use this initial position provider: setInitialPositionProvider(new EventProcessorOptions.StartOfStreamInitialPositionProvider()); + */ + public class StartOfStreamInitialPositionProvider implements Function { + @Override + public EventPosition apply(String t) { + return EventPosition.fromStartOfStream(); + } + } + + /*** + * A prefab initial position provider that starts from the next event that becomes available. + * + * How to use this initial position provider: setInitialPositionProvider(new EventProcessorOptions.EndOfStreamInitialPositionProvider()); + */ + public class EndOfStreamInitialPositionProvider implements Function { + @Override + public EventPosition apply(String t) { + return EventPosition.fromEndOfStream(); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/ExceptionReceivedEventArgs.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/ExceptionReceivedEventArgs.java new file mode 100644 index 000000000000..78eb7311864a --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/ExceptionReceivedEventArgs.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +/** + * Passed as an argument to the general exception handler that can be set via EventProcessorOptions. + */ +public final class ExceptionReceivedEventArgs { + public static final String NO_ASSOCIATED_PARTITION = "N/A"; + private final String hostname; + private final Exception exception; + private final String action; + private final String partitionId; + + ExceptionReceivedEventArgs(String hostname, Exception exception, String action) { + this(hostname, exception, action, ExceptionReceivedEventArgs.NO_ASSOCIATED_PARTITION); + } + + ExceptionReceivedEventArgs(String hostname, Exception exception, String action, String partitionId) { + this.hostname = hostname; + this.exception = exception; + this.action = action; + if ((partitionId == null) || partitionId.isEmpty()) { + throw new IllegalArgumentException("PartitionId must not be null or empty"); + } + this.partitionId = partitionId; + } + + /** + * Allows distinguishing the error source if multiple hosts in a single process. + * + * @return The name of the host that experienced the exception. + */ + public String getHostname() { + return this.hostname; + } + + /** + * Returns the exception that was thrown. + * + * @return The exception. + */ + public Exception getException() { + return this.exception; + } + + /** + * See EventProcessorHostActionString for a list of possible values. + * + * @return A short string that indicates what general activity threw the exception. + */ + public String getAction() { + return this.action; + } + + /** + * If the error is associated with a particular partition (for example, failed to open the event processor + * for the partition), the id of the partition. Otherwise, NO_ASSOCIATED_PARTITION. + * + * @return A partition id. + */ + public String getPartitionId() { + return this.partitionId; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/ExceptionWithAction.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/ExceptionWithAction.java new file mode 100644 index 000000000000..22b8e406f0ef --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/ExceptionWithAction.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +// This class is never thrown into user code, so it can be package private. +class ExceptionWithAction extends Exception { + private static final long serialVersionUID = 7480590197418857145L; + + private final String action; + + ExceptionWithAction(Throwable e, String action) { + super(e); + this.action = action; + } + + ExceptionWithAction(Throwable e, String message, String action) { + super(message, e); + this.action = action; + } + + String getAction() { + return this.action; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/HostContext.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/HostContext.java new file mode 100644 index 000000000000..d18a18317f77 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/HostContext.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import com.microsoft.azure.eventhubs.RetryPolicy; + +import java.util.concurrent.ScheduledExecutorService; + +final class HostContext { + final private ScheduledExecutorService executor; + + // Ideally we wouldn't need the host, but there are certain things which can be dynamically changed + // by the user via APIs on the host and which need to be exposed on the HostContext. Passing the + // call through is easier and safer than trying to keep two copies in sync. + final private EventProcessorHost host; + final private String hostName; + + final private String eventHubPath; + final private String consumerGroupName; + final private String eventHubConnectionString; + final private RetryPolicy retryPolicy; + + final private ILeaseManager leaseManager; + final private ICheckpointManager checkpointManager; + + // Cannot be final because it is not available at HostContext construction time. + private EventProcessorOptions eventProcessorOptions = null; + + // Cannot be final because it is not available at HostContext construction time. + private IEventProcessorFactory processorFactory = null; + + + HostContext(ScheduledExecutorService executor, + EventProcessorHost host, String hostName, + String eventHubPath, String consumerGroupName, String eventHubConnectionString, RetryPolicy retryPolicy, + ILeaseManager leaseManager, ICheckpointManager checkpointManager) { + this.executor = executor; + + this.host = host; + this.hostName = hostName; + + this.eventHubPath = eventHubPath; + this.consumerGroupName = consumerGroupName; + this.eventHubConnectionString = eventHubConnectionString; + this.retryPolicy = retryPolicy; + + this.leaseManager = leaseManager; + this.checkpointManager = checkpointManager; + } + + ScheduledExecutorService getExecutor() { + return this.executor; + } + + String getHostName() { + return this.hostName; + } + + String getEventHubPath() { + return this.eventHubPath; + } + + String getConsumerGroupName() { + return this.consumerGroupName; + } + + String getEventHubConnectionString() { + return this.eventHubConnectionString; + } + + RetryPolicy getRetryPolicy() { + return this.retryPolicy; + } + + ILeaseManager getLeaseManager() { + return this.leaseManager; + } + + ICheckpointManager getCheckpointManager() { + return this.checkpointManager; + } + + PartitionManagerOptions getPartitionManagerOptions() { + return this.host.getPartitionManagerOptions(); + } + + // May be null if called too early! Not set until register time. + // In particular, store initialization happens before this is set. + EventProcessorOptions getEventProcessorOptions() { + return this.eventProcessorOptions; + } + + void setEventProcessorOptions(EventProcessorOptions epo) { + this.eventProcessorOptions = epo; + } + + // May be null if called too early! Not set until register time. + // In particular, store initialization happens before this is set. + IEventProcessorFactory getEventProcessorFactory() { + return this.processorFactory; + } + + void setEventProcessorFactory(IEventProcessorFactory pf) { + this.processorFactory = pf; + } + + // + // Logging utility functions. They are here rather than on LoggingUtils because they + // make use of this.hostName. + // + + String withHost(String logMessage) { + return "host " + this.hostName + ": " + logMessage; + } + + String withHostAndPartition(String partitionId, String logMessage) { + return withHost(partitionId + ": " + logMessage); + } + + String withHostAndPartition(PartitionContext context, String logMessage) { + return withHostAndPartition(context.getPartitionId(), logMessage); + } + + String withHostAndPartition(BaseLease lease, String logMessage) { + return withHostAndPartition(lease.getPartitionId(), logMessage); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/ICheckpointManager.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/ICheckpointManager.java new file mode 100644 index 000000000000..687d54790108 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/ICheckpointManager.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/*** + * If you wish to have EventProcessorHost store checkpoints somewhere other than Azure Storage, + * you can write your own checkpoint manager using this interface. + * + * The Azure Storage managers use the same storage for both lease and checkpoints, so both + * interfaces are implemented by the same class. You are free to do the same thing if you have + * a unified store for both types of data. + * + * This interface does not specify initialization methods because we have no way of knowing what + * information your implementation will require. If your implementation needs initialization, you + * will have to initialize the instance before passing it to the EventProcessorHost constructor. + */ +public interface ICheckpointManager { + /*** + * Does the checkpoint store exist? + * + * The returned CompletableFuture completes with true if the checkpoint store exists or false if it + * does not. It completes exceptionally on error. + * + * @return CompletableFuture {@literal ->} true if it exists, false if not + */ + public CompletableFuture checkpointStoreExists(); + + /*** + * Create the checkpoint store if it doesn't exist. Do nothing if it does exist. + * + * @return CompletableFuture {@literal ->} null on success, completes exceptionally on error. + */ + public CompletableFuture createCheckpointStoreIfNotExists(); + + /** + * Deletes the checkpoint store. + * + * @return CompletableFuture {@literal ->} null on success, completes exceptionally on error. + */ + public CompletableFuture deleteCheckpointStore(); + + /*** + * Get the checkpoint data associated with the given partition. Could return null if no checkpoint has + * been created for that partition. + * + * @param partitionId Id of partition to get checkpoint info for. + * + * @return CompletableFuture {@literal ->} checkpoint info, or null. Completes exceptionally on error. + */ + public CompletableFuture getCheckpoint(String partitionId); + + /*** + * Creates the checkpoint HOLDERs for the given partitions. Does nothing for any checkpoint HOLDERs + * that already exist. + * + * The semantics of this are complicated because it is possible to use the same store for both + * leases and checkpoints (the Azure Storage implementation does so) and it is required to + * have a lease for every partition but it is not required to have a checkpoint for a partition. + * It is a valid scenario to never use checkpoints at all, so it is important for the store to + * distinguish between creating the structure(s) that will hold a checkpoint and actually creating + * a checkpoint (storing an offset/sequence number pair in the structure). + * + * @param partitionIds List of partitions to create checkpoint HOLDERs for. + * @return CompletableFuture {@literal ->} null on success, completes exceptionally on error. + */ + public CompletableFuture createAllCheckpointsIfNotExists(List partitionIds); + + /*** + * Update the checkpoint in the store with the offset/sequenceNumber in the provided checkpoint. + * + * The lease argument is necessary to make the Azure Storage implementation work correctly: the + * Azure Storage implementation stores the checkpoint as part of the lease and we cannot completely + * hide the connection between the two. If your implementation does not have this limitation, you are + * free to ignore the lease argument. + * + * @param lease lease for the partition to be checkpointed. + * @param checkpoint offset/sequenceNumber and partition id to update the store with. + * @return CompletableFuture {@literal ->} null on success. Completes exceptionally on error. + */ + public CompletableFuture updateCheckpoint(CompleteLease lease, Checkpoint checkpoint); + + /*** + * Delete the stored checkpoint data for the given partition. If there is no stored checkpoint for the + * given partition, that is treated as success. Deleting the checkpoint HOLDER is allowed but not required; + * your implementation is free to do whichever is more convenient. + * + * @param partitionId id of partition to delete checkpoint from store + * @return CompletableFuture {@literal ->} null on success. Completes exceptionally on error. + */ + public CompletableFuture deleteCheckpoint(String partitionId); +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/IEventProcessor.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/IEventProcessor.java new file mode 100644 index 000000000000..4d52f262c6d3 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/IEventProcessor.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import com.microsoft.azure.eventhubs.EventData; + + +/** + * Interface that must be implemented by event processor classes. + *

+ * Any given instance of an event processor class will only process events from one partition + * of one Event Hub. A PartitionContext is provided with each call to the event processor because + * some parameters could change, but it will always be the same partition. + *

+ * Although EventProcessorHost is multithreaded, calls to a given instance of an event processor + * class are serialized, except for onError(). onOpen() is called first, then onEvents() will be called zero or more + * times. When the event processor needs to be shut down, whether because there was a failure + * somewhere, or the lease for the partition has been lost, or because the entire processor host + * is being shut down, onClose() is called after the last onEvents() call returns. + *

+ * onError() could be called while onEvents() or onClose() is executing. No synchronization is attempted + * in order to avoid possibly deadlocking. + */ +public interface IEventProcessor { + /** + * Called by processor host to initialize the event processor. + *

+ * If onOpen fails, this event processor host instance will give up ownership of the partition. + * + * @param context Information about the partition that this event processor will process events from. + * @throws Exception to indicate failure. + */ + public void onOpen(PartitionContext context) throws Exception; + + /** + * Called by processor host to indicate that the event processor is being stopped. + *

+ * If onClose fails, the exception is reported to the general exception notification handler set via + * EventProcessorOptions, if any, but is otherwise ignored. + * + * @param context Information about the partition. + * @param reason Reason why the event processor is being stopped. + * @throws Exception to indicate failure. + */ + public void onClose(PartitionContext context, CloseReason reason) throws Exception; + + /** + * Called by the processor host when a batch of events has arrived. + *

+ * This is where the real work of the event processor is done. It is normally called when one + * or more events have arrived. If the EventProcessorHost instance was set up with an EventProcessorOptions + * on which setInvokeProcessorAfterReceiveTimeout(true) has been called, then when a receive times out, + * onEvents will be called with an empty iterable. By default this option is false and receive timeouts do not + * cause a call to this method. + * + * @param context Information about the partition. + * @param events The events to be processed. May be empty. + * @throws Exception to indicate failure. + */ + public void onEvents(PartitionContext context, Iterable events) throws Exception; + + /** + * Called when the underlying client experiences an error while receiving. EventProcessorHost will take + * care of recovering from the error and continuing to pump events, so no action is required from + * your code. This method is provided for informational purposes. + * + * @param context Information about the partition. + * @param error The error that occured. + */ + public void onError(PartitionContext context, Throwable error); +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/IEventProcessorFactory.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/IEventProcessorFactory.java new file mode 100644 index 000000000000..2a5204a7f6dd --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/IEventProcessorFactory.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + + +/** + * Interface that must be implemented by an event processor factory class. + *

+ * User-provided factories are needed if creating an event processor object requires more work than + * just a new with a parameterless constructor. + * + * @param The type of event processor objects produced by this factory, which must implement IEventProcessor + */ +public interface IEventProcessorFactory { + /** + * Called to create an event processor for the given partition. + *

+ * If it throws an exception, that causes this event processor host instance to give up ownership of the partition. + * + * @param context Information about the partition that the event processor will handle events from. + * @throws Exception to indicate failure. + * @return The event processor object. + */ + public T createEventProcessor(PartitionContext context) throws Exception; +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/ILeaseManager.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/ILeaseManager.java new file mode 100644 index 000000000000..ac6c8831033a --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/ILeaseManager.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/*** + * If you wish to have EventProcessorHost store leases somewhere other than Azure Storage, + * you can write your own lease manager using this interface. + * + * The Azure Storage managers use the same storage for both lease and checkpoints, so both + * interfaces are implemented by the same class. You are free to do the same thing if you have + * a unified store for both types of data. + * + * This interface does not specify initialization methods because we have no way of knowing what + * information your implementation will require. If your implementation needs initialization, you + * will have to initialize the instance before passing it to the EventProcessorHost constructor. + */ +public interface ILeaseManager { + /** + * The lease duration is mostly internal to the lease manager implementation but may be needed + * by other parts of the event processor host. + * + * @return Duration of a lease before it expires unless renewed, specified in milliseconds. + */ + public int getLeaseDurationInMilliseconds(); + + /** + * Does the lease store exist? + *

+ * The returned CompletableFuture completes with true if the checkpoint store exists or false if it + * does not. It completes exceptionally on error. + * + * @return CompletableFuture {@literal ->} true if it exists, false if not + */ + public CompletableFuture leaseStoreExists(); + + /** + * Create the lease store if it does not exist, do nothing if it does exist. + * + * @return CompletableFuture {@literal ->} null on success, completes exceptionally on error. + */ + public CompletableFuture createLeaseStoreIfNotExists(); + + /** + * Deletes the lease store. + * + * @return CompletableFuture {@literal ->} null on success, completes exceptionally on error. + */ + public CompletableFuture deleteLeaseStore(); + + /** + * Returns the lease info for the given partition.. + * + * @param partitionId Get the lease info for this partition. + * @return CompletableFuture {@literal ->} Lease, completes exceptionally on error. + */ + public CompletableFuture getLease(String partitionId); + + /** + * Returns lightweight BaseLease for all leases, which includes name of owning host and whether lease + * is expired. An implementation is free to return CompleteLease or its own class derived from CompleteLease, + * but it is important that getAllLeases run as fast as possible. If it is faster to obtain only the + * information required for a BaseLease, we heavily recommend doing that. + * + * @return CompletableFuture {@literal ->} list of BaseLease, completes exceptionally on error. + */ + public CompletableFuture> getAllLeases(); + + + /** + * Create in the store a lease for each of the given partitions, if it does not exist. Do nothing for any + * lease which exists in the store already. + * + * @param partitionIds ids of partitions to create lease info for + * @return CompletableFuture {@literal ->} null on success, completes exceptionally on error + */ + public CompletableFuture createAllLeasesIfNotExists(List partitionIds); + + /** + * Delete the lease info for a partition from the store. If there is no stored lease for the given partition, + * that is treated as success. + * + * @param lease the currently existing lease info for the partition + * @return CompletableFuture {@literal ->} null on success, completes exceptionally on error. + */ + public CompletableFuture deleteLease(CompleteLease lease); + + /** + * Acquire the lease on the desired partition for this EventProcessorHost. + *

+ * Note that it is legal to acquire a lease that is currently owned by another host, which is called "stealing". + * Lease-stealing is how partitions are redistributed when additional hosts are started. + *

+ * The existing Azure Storage implementation can experience races between two host instances attempting to acquire or steal + * the lease at the same time. To avoid situations where two host instances both believe that they own the lease, acquisition + * can fail non-exceptionally by returning false and should do so when there is any doubt -- the worst that can happen is that + * no host instance owns the lease for a short time. This is qualitatively different from, for example, the underlying store + * throwing an access exception, which is an error and should complete exceptionally. + * + * @param lease Lease info for the desired partition + * @return CompletableFuture {@literal ->} true if the lease was acquired, false if not, completes exceptionally on error. + */ + public CompletableFuture acquireLease(CompleteLease lease); + + /** + * Renew a lease currently held by this host instance. + *

+ * If the lease has been taken by another host instance (either stolen or after expiration) or explicitly released, + * renewLease must return false. With the Azure Storage-based implementation, it IS possible to renew an expired lease + * that has not been taken by another host, so your implementation can allow that or not, whichever is convenient. If + * it does not, renewLease should return false. + * + * @param lease Lease to be renewed + * @return true if the lease was renewed, false as described above, completes exceptionally on error. + */ + public CompletableFuture renewLease(CompleteLease lease); + + /** + * Give up a lease currently held by this host. + *

+ * If the lease has expired or been taken by another host, releasing it is unnecessary but will succeed since the intent + * has been fulfilled. + * + * @param lease Lease to be given up + * @return CompletableFuture {@literal ->} null on success, completes exceptionally on error. + */ + public CompletableFuture releaseLease(CompleteLease lease); + + /** + * Update the store with the information in the provided lease. + *

+ * It is necessary to currently hold a lease in order to update it. If the lease has been stolen, or expired, or + * released, it cannot be updated. Lease manager implementations should renew the lease before performing the update to avoid lease + * expiration during the process. + * + * @param lease New lease info to be stored + * @return true if the update was successful, false if lease was lost and could not be updated, completes exceptionally on error. + */ + public CompletableFuture updateLease(CompleteLease lease); +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/InMemoryCheckpointManager.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/InMemoryCheckpointManager.java new file mode 100644 index 000000000000..29ba81a38e5c --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/InMemoryCheckpointManager.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +/*** + * An ICheckpointManager implementation based on an in-memory store. + * + * THIS CLASS IS PROVIDED AS A CONVENIENCE FOR TESTING ONLY. All data stored via this class is in memory + * only and not persisted in any way. In addition, it is only visible within the same process: multiple + * instances of EventProcessorHost in the same process will share the same in-memory store and checkpoints + * created by one will be visible to the others, but that is not true across processes. + * + * With an ordinary store, there is a clear and distinct line between the values that are persisted + * and the values that are live in memory. With an in-memory store, that line gets blurry. If we + * accidentally hand out a reference to the in-store object, then the calling code is operating on + * the "persisted" values without going through the manager and behavior will be very different. + * Hence, the implementation takes pains to distinguish between references to "live" and "persisted" + * checkpoints. + * + * To use this class, create a new instance and pass it to the EventProcessorHost constructor that takes + * ICheckpointManager as an argument. After the EventProcessorHost instance is constructed, be sure to + * call initialize() on this object before starting processing with EventProcessorHost.registerEventProcessor() + * or EventProcessorHost.registerEventProcessorFactory(). + */ +public class InMemoryCheckpointManager implements ICheckpointManager { + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(InMemoryCheckpointManager.class); + private HostContext hostContext; + + public InMemoryCheckpointManager() { + } + + // This object is constructed before the EventProcessorHost and passed as an argument to + // EventProcessorHost's constructor. So it has to get context info later. + public void initialize(HostContext hostContext) { + this.hostContext = hostContext; + } + + @Override + public CompletableFuture checkpointStoreExists() { + boolean exists = InMemoryCheckpointStore.singleton.existsMap(); + TRACE_LOGGER.debug(this.hostContext.withHost("checkpointStoreExists() " + exists)); + return CompletableFuture.completedFuture(exists); + } + + @Override + public CompletableFuture createCheckpointStoreIfNotExists() { + TRACE_LOGGER.debug(this.hostContext.withHost("createCheckpointStoreIfNotExists()")); + InMemoryCheckpointStore.singleton.initializeMap(); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture deleteCheckpointStore() { + TRACE_LOGGER.debug(this.hostContext.withHost("deleteCheckpointStore()")); + InMemoryCheckpointStore.singleton.deleteMap(); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture getCheckpoint(String partitionId) { + Checkpoint returnCheckpoint = null; + Checkpoint checkpointInStore = InMemoryCheckpointStore.singleton.getCheckpoint(partitionId); + if (checkpointInStore == null) { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(partitionId, + "getCheckpoint() no existing Checkpoint")); + returnCheckpoint = null; + } else if (checkpointInStore.getSequenceNumber() == -1) { + // Uninitialized, so return null. + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(partitionId, "getCheckpoint() uninitalized")); + returnCheckpoint = null; + } else { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(partitionId, + "getCheckpoint() found " + checkpointInStore.getOffset() + "//" + checkpointInStore.getSequenceNumber())); + returnCheckpoint = new Checkpoint(checkpointInStore); + } + return CompletableFuture.completedFuture(returnCheckpoint); + } + + @Override + public CompletableFuture createAllCheckpointsIfNotExists(List partitionIds) { + for (String id : partitionIds) { + Checkpoint checkpointInStore = InMemoryCheckpointStore.singleton.getCheckpoint(id); + if (checkpointInStore != null) { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(id, + "createCheckpointIfNotExists() found existing checkpoint, OK")); + } else { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(id, + "createCheckpointIfNotExists() creating new checkpoint")); + Checkpoint newStoreCheckpoint = new Checkpoint(id); + // This API actually creates the holder, not the checkpoint itself. In this implementation, we do create a Checkpoint object + // and put it in the store, but the values are set to indicate that it is not initialized. + newStoreCheckpoint.setOffset(null); + newStoreCheckpoint.setSequenceNumber(-1); + InMemoryCheckpointStore.singleton.setOrReplaceCheckpoint(newStoreCheckpoint); + } + } + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture updateCheckpoint(CompleteLease lease, Checkpoint checkpoint) { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(checkpoint.getPartitionId(), + "updateCheckpoint() " + checkpoint.getOffset() + "//" + checkpoint.getSequenceNumber())); + Checkpoint checkpointInStore = InMemoryCheckpointStore.singleton.getCheckpoint(checkpoint.getPartitionId()); + if (checkpointInStore != null) { + checkpointInStore.setOffset(checkpoint.getOffset()); + checkpointInStore.setSequenceNumber(checkpoint.getSequenceNumber()); + } else { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(checkpoint.getPartitionId(), + "updateCheckpoint() can't find checkpoint")); + } + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture deleteCheckpoint(String partitionId) { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(partitionId, "deleteCheckpoint()")); + InMemoryCheckpointStore.singleton.removeCheckpoint(partitionId); + return CompletableFuture.completedFuture(null); + } + + + private static class InMemoryCheckpointStore { + final static InMemoryCheckpointStore singleton = new InMemoryCheckpointStore(); + + private ConcurrentHashMap inMemoryCheckpointsPrivate = null; + + synchronized boolean existsMap() { + return (this.inMemoryCheckpointsPrivate != null); + } + + synchronized void initializeMap() { + if (this.inMemoryCheckpointsPrivate == null) { + this.inMemoryCheckpointsPrivate = new ConcurrentHashMap(); + } + } + + synchronized void deleteMap() { + this.inMemoryCheckpointsPrivate = null; + } + + synchronized Checkpoint getCheckpoint(String partitionId) { + return this.inMemoryCheckpointsPrivate.get(partitionId); + } + + synchronized void setOrReplaceCheckpoint(Checkpoint newCheckpoint) { + this.inMemoryCheckpointsPrivate.put(newCheckpoint.getPartitionId(), newCheckpoint); + } + + synchronized void removeCheckpoint(String partitionId) { + this.inMemoryCheckpointsPrivate.remove(partitionId); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/InMemoryLeaseManager.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/InMemoryLeaseManager.java new file mode 100644 index 000000000000..3e2a0332f6d7 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/InMemoryLeaseManager.java @@ -0,0 +1,416 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.*; + +/*** + * An ILeaseManager implementation based on an in-memory store. + * + * THIS CLASS IS PROVIDED AS A CONVENIENCE FOR TESTING ONLY. All data stored via this class is in memory + * only and not persisted in any way. In addition, it is only visible within the same process: multiple + * instances of EventProcessorHost in the same process will share the same in-memory store and leases + * created by one will be visible to the others, but that is not true across processes. + * + * With an ordinary store, there is a clear and distinct line between the values that are persisted + * and the values that are live in memory. With an in-memory store, that line gets blurry. If we + * accidentally hand out a reference to the in-store object, then the calling code is operating on + * the "persisted" values without going through the manager and behavior will be very different. + * Hence, the implementation takes pains to distinguish between references to "live" and "persisted" + * checkpoints. + * + * To use this class, create a new instance and pass it to the EventProcessorHost constructor that takes + * ILeaseManager as an argument. After the EventProcessorHost instance is constructed, be sure to + * call initialize() on this object before starting processing with EventProcessorHost.registerEventProcessor() + * or EventProcessorHost.registerEventProcessorFactory(). + */ +public class InMemoryLeaseManager implements ILeaseManager { + private final static Logger TRACE_LOGGER = LoggerFactory.getLogger(InMemoryLeaseManager.class); + private HostContext hostContext; + private long millisecondsLatency = 0; + + public InMemoryLeaseManager() { + } + + // This object is constructed before the EventProcessorHost and passed as an argument to + // EventProcessorHost's constructor. So it has to get context info later. + public void initialize(HostContext hostContext) { + this.hostContext = hostContext; + } + + public void setLatency(long milliseconds) { + this.millisecondsLatency = milliseconds; + } + + private void latency(String caller) { + if (this.millisecondsLatency > 0) { + try { + //TRACE_LOGGER.info("sleep " + caller); + Thread.sleep(this.millisecondsLatency); + } catch (InterruptedException e) { + // Don't care + TRACE_LOGGER.info("sleepFAIL " + caller); + } + } + } + + @Override + public int getLeaseDurationInMilliseconds() { + return this.hostContext.getPartitionManagerOptions().getLeaseDurationInSeconds() * 1000; + } + + @Override + public CompletableFuture leaseStoreExists() { + boolean exists = InMemoryLeaseStore.singleton.existsMap(); + latency("leaseStoreExists"); + TRACE_LOGGER.debug(this.hostContext.withHost("leaseStoreExists() " + exists)); + return CompletableFuture.completedFuture(exists); + } + + @Override + public CompletableFuture createLeaseStoreIfNotExists() { + TRACE_LOGGER.debug(this.hostContext.withHost("createLeaseStoreIfNotExists()")); + InMemoryLeaseStore.singleton.initializeMap(getLeaseDurationInMilliseconds()); + latency("createLeaseStoreIfNotExists"); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture deleteLeaseStore() { + TRACE_LOGGER.debug(this.hostContext.withHost("deleteLeaseStore()")); + InMemoryLeaseStore.singleton.deleteMap(); + latency("deleteLeaseStore"); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture getLease(String partitionId) { + TRACE_LOGGER.debug(this.hostContext.withHost("getLease()")); + latency("getLease"); + InMemoryLease leaseInStore = InMemoryLeaseStore.singleton.getLease(partitionId); + return CompletableFuture.completedFuture(new InMemoryLease(leaseInStore)); + } + + @Override + public CompletableFuture> getAllLeases() { + ArrayList infos = new ArrayList(); + for (String id : InMemoryLeaseStore.singleton.getPartitionIds()) { + InMemoryLease leaseInStore = InMemoryLeaseStore.singleton.getLease(id); + infos.add(new BaseLease(id, leaseInStore.getOwner(), !leaseInStore.isExpiredSync())); + } + latency("getAllLeasesStateInfo"); + return CompletableFuture.completedFuture(infos); + } + + @Override + public CompletableFuture createAllLeasesIfNotExists(List partitionIds) { + ArrayList> createFutures = new ArrayList>(); + + // Implemented like this to provide an experience more similar to lease creation in the Storage-based manager. + for (String id : partitionIds) { + final String workingId = id; + CompletableFuture oneCreate = CompletableFuture.supplyAsync(() -> { + InMemoryLease leaseInStore = InMemoryLeaseStore.singleton.getLease(workingId); + InMemoryLease returnLease = null; + if (leaseInStore != null) { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(workingId, + "createLeaseIfNotExists() found existing lease, OK")); + returnLease = new InMemoryLease(leaseInStore); + } else { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(workingId, + "createLeaseIfNotExists() creating new lease")); + InMemoryLease newStoreLease = new InMemoryLease(workingId); + InMemoryLeaseStore.singleton.setOrReplaceLease(newStoreLease); + returnLease = new InMemoryLease(newStoreLease); + } + latency("createLeaseIfNotExists " + workingId); + return returnLease; + }, this.hostContext.getExecutor()); + createFutures.add(oneCreate); + } + + CompletableFuture dummy[] = new CompletableFuture[createFutures.size()]; + return CompletableFuture.allOf(createFutures.toArray(dummy)); + } + + @Override + public CompletableFuture deleteLease(CompleteLease lease) { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(lease, "deleteLease()")); + InMemoryLeaseStore.singleton.removeLease((InMemoryLease) lease); + latency("deleteLease " + lease.getPartitionId()); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture acquireLease(CompleteLease lease) { + InMemoryLease leaseToAcquire = (InMemoryLease) lease; + + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(leaseToAcquire, "acquireLease()")); + + boolean retval = true; + InMemoryLease leaseInStore = InMemoryLeaseStore.singleton.getLease(leaseToAcquire.getPartitionId()); + if (leaseInStore != null) { + InMemoryLease wasUnowned = InMemoryLeaseStore.singleton.atomicAquireUnowned(leaseToAcquire.getPartitionId(), this.hostContext.getHostName()); + if (wasUnowned != null) { + // atomicAcquireUnowned already set ownership of the persisted lease, just update the live lease. + leaseToAcquire.setOwner(this.hostContext.getHostName()); + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(leaseToAcquire, + "acquireLease() acquired lease")); + leaseInStore = wasUnowned; + leaseToAcquire.setExpirationTime(leaseInStore.getExpirationTime()); + } else { + if (leaseInStore.isOwnedBy(this.hostContext.getHostName())) { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(leaseToAcquire, + "acquireLease() already hold lease")); + } else { + String oldOwner = leaseInStore.getOwner(); + // Make change in both persisted lease and live lease! + InMemoryLeaseStore.singleton.stealLease(leaseInStore, this.hostContext.getHostName()); + leaseToAcquire.setOwner(this.hostContext.getHostName()); + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(leaseToAcquire, + "acquireLease() stole lease from " + oldOwner)); + } + long newExpiration = System.currentTimeMillis() + getLeaseDurationInMilliseconds(); + // Make change in both persisted lease and live lease! + leaseInStore.setExpirationTime(newExpiration); + leaseToAcquire.setExpirationTime(newExpiration); + } + } else { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(leaseToAcquire, + "acquireLease() can't find lease")); + retval = false; + } + + latency("acquireLease " + lease.getPartitionId()); + return CompletableFuture.completedFuture(retval); + } + + // Real partition pumps get "notified" when another host has stolen their lease because the receiver throws + // a ReceiverDisconnectedException. It doesn't matter how many hosts try to steal the lease at the same time, + // only one will end up with it and that one will kick the others off via the exclusivity of epoch receivers. + // This mechanism simulates that for dummy partition pumps used in testing. If expectedOwner does not currently + // own the lease for the given partition, then notifier is called immediately, otherwise it is called whenever + // ownership of the lease changes. + public void notifyOnSteal(String expectedOwner, String partitionId, Callable notifier) { + InMemoryLeaseStore.singleton.notifyOnSteal(expectedOwner, partitionId, notifier); + } + + @Override + public CompletableFuture renewLease(CompleteLease lease) { + InMemoryLease leaseToRenew = (InMemoryLease) lease; + + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(leaseToRenew, "renewLease()")); + + boolean retval = true; + InMemoryLease leaseInStore = InMemoryLeaseStore.singleton.getLease(leaseToRenew.getPartitionId()); + if (leaseInStore != null) { + // MATCH BEHAVIOR OF AzureStorageCheckpointLeaseManager: + // Renewing a lease that has expired succeeds unless some other host has grabbed it already. + // So don't check expiration, just ownership. + if (leaseInStore.isOwnedBy(this.hostContext.getHostName())) { + long newExpiration = System.currentTimeMillis() + getLeaseDurationInMilliseconds(); + // Make change in both persisted lease and live lease! + leaseInStore.setExpirationTime(newExpiration); + leaseToRenew.setExpirationTime(newExpiration); + } else { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(leaseToRenew, + "renewLease() not renewed because we don't own lease")); + retval = false; + } + } else { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(leaseToRenew, + "renewLease() can't find lease")); + retval = false; + } + + latency("renewLease " + lease.getPartitionId()); + return CompletableFuture.completedFuture(retval); + } + + @Override + public CompletableFuture releaseLease(CompleteLease lease) { + InMemoryLease leaseToRelease = (InMemoryLease) lease; + + CompletableFuture retval = CompletableFuture.completedFuture(null); + + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(leaseToRelease, "releaseLease()")); + + InMemoryLease leaseInStore = InMemoryLeaseStore.singleton.getLease(leaseToRelease.getPartitionId()); + if (leaseInStore != null) { + if (!leaseInStore.isExpiredSync() && leaseInStore.isOwnedBy(this.hostContext.getHostName())) { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(leaseToRelease, "releaseLease() released OK")); + // Make change in both persisted lease and live lease! + leaseInStore.setOwner(""); + leaseToRelease.setOwner(""); + leaseInStore.setExpirationTime(0); + leaseToRelease.setExpirationTime(0); + } else { + // Lease was lost, intent achieved. + } + } else { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(leaseToRelease, "releaseLease() can't find lease in store")); + retval = new CompletableFuture(); + retval.completeExceptionally(new CompletionException(new RuntimeException("releaseLease can't find lease in store for " + leaseToRelease.getPartitionId()))); + } + latency("releaseLease " + lease.getPartitionId()); + return retval; + } + + @Override + public CompletableFuture updateLease(CompleteLease lease) { + InMemoryLease leaseToUpdate = (InMemoryLease) lease; + + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(leaseToUpdate, "updateLease()")); + + // Renew lease first so it doesn't expire in the middle. + return renewLease(leaseToUpdate).thenApply((retval) -> + { + if (retval) { + InMemoryLease leaseInStore = InMemoryLeaseStore.singleton.getLease(leaseToUpdate.getPartitionId()); + if (leaseInStore != null) { + if (!leaseInStore.isExpiredSync() && leaseInStore.isOwnedBy(this.hostContext.getHostName())) { + // We are updating with values already in the live lease, so only need to set on the persisted lease. + leaseInStore.setEpoch(leaseToUpdate.getEpoch()); + // Don't copy expiration time, that is managed directly by Acquire/Renew/Release + } else { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(leaseToUpdate, + "updateLease() not updated because we don't own lease")); + retval = false; + } + } else { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(leaseToUpdate, + "updateLease() can't find lease")); + retval = false; + } + } + latency("updateLease " + lease.getPartitionId()); + return retval; + }); + } + + + private static class InMemoryLeaseStore { + final static InMemoryLeaseStore singleton = new InMemoryLeaseStore(); + private static int leaseDurationInMilliseconds; + + private ConcurrentHashMap inMemoryLeasesPrivate = null; + private ConcurrentHashMap> notifiers = new ConcurrentHashMap>(); + + synchronized boolean existsMap() { + return (this.inMemoryLeasesPrivate != null); + } + + synchronized void initializeMap(int leaseDurationInMilliseconds) { + if (this.inMemoryLeasesPrivate == null) { + this.inMemoryLeasesPrivate = new ConcurrentHashMap(); + } + InMemoryLeaseStore.leaseDurationInMilliseconds = leaseDurationInMilliseconds; + } + + synchronized void deleteMap() { + this.inMemoryLeasesPrivate = null; + } + + synchronized InMemoryLease getLease(String partitionId) { + return this.inMemoryLeasesPrivate.get(partitionId); + } + + synchronized List getPartitionIds() { + ArrayList ids = new ArrayList(); + this.inMemoryLeasesPrivate.keySet().forEach((key) -> + { + ids.add(key); + }); + return ids; + } + + synchronized InMemoryLease atomicAquireUnowned(String partitionId, String newOwner) { + InMemoryLease leaseInStore = getLease(partitionId); + if (leaseInStore.isExpiredSync() || (leaseInStore.getOwner() == null) || leaseInStore.getOwner().isEmpty()) { + leaseInStore.setOwner(newOwner); + leaseInStore.setExpirationTime(System.currentTimeMillis() + InMemoryLeaseStore.leaseDurationInMilliseconds); + } else { + // Return null if it was already owned + leaseInStore = null; + } + return leaseInStore; + } + + synchronized void notifyOnSteal(String expectedOwner, String partitionId, Callable notifier) { + InMemoryLease leaseInStore = getLease(partitionId); + if (!leaseInStore.isOwnedBy(expectedOwner)) { + // Already stolen. + try { + notifier.call(); + } catch (Exception e) { + } + } else { + this.notifiers.put(partitionId, notifier); + } + } + + synchronized void stealLease(InMemoryLease stealee, String newOwner) { + stealee.setOwner(newOwner); + Callable notifier = this.notifiers.get(stealee.getPartitionId()); + if (notifier != null) { + try { + notifier.call(); + } catch (Exception e) { + } + } + } + + synchronized void setOrReplaceLease(InMemoryLease newLease) { + this.inMemoryLeasesPrivate.put(newLease.getPartitionId(), newLease); + } + + synchronized void removeLease(InMemoryLease goneLease) { + this.inMemoryLeasesPrivate.remove(goneLease.getPartitionId()); + } + } + + + private static class InMemoryLease extends CompleteLease { + private final static Logger TRACE_LOGGER = LoggerFactory.getLogger(InMemoryLease.class); + private long expirationTimeMillis = 0; + + InMemoryLease(String partitionId) { + super(partitionId); + this.epoch = 0; + } + + InMemoryLease(InMemoryLease source) { + super(source); + this.expirationTimeMillis = source.expirationTimeMillis; + this.epoch = source.epoch; + } + + long getExpirationTime() { + return this.expirationTimeMillis; + } + + void setExpirationTime(long expireAtMillis) { + this.expirationTimeMillis = expireAtMillis; + } + + public boolean isExpiredSync() { + boolean hasExpired = (System.currentTimeMillis() >= this.expirationTimeMillis); + if (hasExpired) { + // CHANGE TO MATCH BEHAVIOR OF AzureStorageCheckpointLeaseManager + // An expired lease can be renewed by the previous owner. In order to implement that behavior for + // InMemory, the owner field has to remain unchanged. + //setOwner(""); + } + TRACE_LOGGER.debug("isExpired(" + this.getPartitionId() + (hasExpired ? ") expired " : ") leased ") + (this.expirationTimeMillis - System.currentTimeMillis())); + return hasExpired; + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/LeaseLostException.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/LeaseLostException.java new file mode 100644 index 000000000000..ed9482db7359 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/LeaseLostException.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +public class LeaseLostException extends Exception { + private static final long serialVersionUID = -4625001822439809869L; + + private final BaseLease lease; + + LeaseLostException(BaseLease lease, Throwable cause) { + super(null, cause); + this.lease = lease; + } + + LeaseLostException(BaseLease lease, String message) { + super(message, null); + this.lease = lease; + } + + // We don't want to expose Lease to the public. + public String getPartitionId() { + return this.lease.getPartitionId(); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/LoggingUtils.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/LoggingUtils.java new file mode 100644 index 000000000000..e26d9e8778bd --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/LoggingUtils.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * Centralize log message generation + */ +public final class LoggingUtils { + static CompletionException wrapException(Throwable e, String action) { + return new CompletionException(new ExceptionWithAction(e, action)); + } + + static CompletionException wrapExceptionWithMessage(Throwable e, String message, String action) { + return new CompletionException(new ExceptionWithAction(e, message, action)); + } + + // outAction can be null if you don't care about any action string + static Throwable unwrapException(Throwable wrapped, StringBuilder outAction) { + Throwable unwrapped = wrapped; + + while ((unwrapped instanceof ExecutionException) || (unwrapped instanceof CompletionException) || + (unwrapped instanceof ExceptionWithAction)) { + if ((unwrapped instanceof ExceptionWithAction) && (outAction != null)) { + // Save the action string from an ExceptionWithAction, if desired. + outAction.append(((ExceptionWithAction) unwrapped).getAction()); + } + + if ((unwrapped.getCause() != null) && (unwrapped.getCause() instanceof Exception)) { + unwrapped = (Exception) unwrapped.getCause(); + } else { + break; + } + } + + return unwrapped; + } + + static String threadPoolStatusReport(String hostName, ScheduledExecutorService threadPool) { + String report = ""; + + if (threadPool instanceof ThreadPoolExecutor) { + ThreadPoolExecutor pool = (ThreadPoolExecutor) threadPool; + + StringBuilder builder = new StringBuilder(); + builder.append("Thread pool settings: core: "); + builder.append(pool.getCorePoolSize()); + builder.append(" active: "); + builder.append(pool.getActiveCount()); + builder.append(" current: "); + builder.append(pool.getPoolSize()); + builder.append(" largest: "); + builder.append(pool.getLargestPoolSize()); + builder.append(" max: "); + builder.append(pool.getMaximumPoolSize()); + builder.append(" policy: "); + builder.append(pool.getRejectedExecutionHandler().getClass().toString()); + builder.append(" queue avail: "); + builder.append(pool.getQueue().remainingCapacity()); + + report = builder.toString(); + } else { + report = "Cannot report on thread pool of type " + threadPool.getClass().toString(); + } + + return report; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/PartitionContext.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/PartitionContext.java new file mode 100644 index 000000000000..601baab2d469 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/PartitionContext.java @@ -0,0 +1,202 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import com.microsoft.azure.eventhubs.EventData; +import com.microsoft.azure.eventhubs.EventPosition; +import com.microsoft.azure.eventhubs.ReceiverRuntimeInformation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +/*** + * PartitionContext is used to provide partition-related information to the methods of IEventProcessor, + * particularly onEvents where the user's event-processing logic lives. It also allows the user to + * persist checkpoints for the partition, which determine where event processing will begin if the + * event processor for that partition must be restarted, such as if ownership of the partition moves + * from one event processor host instance to another. + */ +public class PartitionContext { + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(PartitionContext.class); + private final HostContext hostContext; + private final String partitionId; + private CompleteLease lease; + private String offset = null; + private long sequenceNumber = 0; + private ReceiverRuntimeInformation runtimeInformation; + + PartitionContext(HostContext hostContext, String partitionId) { + this.hostContext = hostContext; + this.partitionId = partitionId; + + this.runtimeInformation = new ReceiverRuntimeInformation(partitionId); + } + + /*** + * Get the name of the consumer group that is being received from. + * + * @return consumer group name + */ + public String getConsumerGroupName() { + return this.hostContext.getConsumerGroupName(); + } + + /*** + * Get the path of the event hub that is being received from. + * + * @return event hub path + */ + public String getEventHubPath() { + return this.hostContext.getEventHubPath(); + } + + /*** + * Get the name of the event processor host instance. + * + * @return event processor host instance name + */ + public String getOwner() { + return this.lease.getOwner(); + } + + /*** + * If receiver runtime metrics have been enabled in EventProcessorHost, this method + * gets the metrics as they come in. + * + * @return See ReceiverRuntimeInformation. + */ + public ReceiverRuntimeInformation getRuntimeInformation() { + return this.runtimeInformation; + } + + void setRuntimeInformation(ReceiverRuntimeInformation value) { + this.runtimeInformation = value; + } + + CompleteLease getLease() { + return this.lease; + } + + // Unlike other properties which are immutable after creation, the lease is updated dynamically and needs a setter. + void setLease(CompleteLease lease) { + this.lease = lease; + } + + void setOffsetAndSequenceNumber(EventData event) { + if (event.getSystemProperties().getSequenceNumber() >= this.sequenceNumber) { + this.offset = event.getSystemProperties().getOffset(); + this.sequenceNumber = event.getSystemProperties().getSequenceNumber(); + } else { + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(this.partitionId, + "setOffsetAndSequenceNumber(" + event.getSystemProperties().getOffset() + "//" + + event.getSystemProperties().getSequenceNumber() + ") would move backwards, ignoring")); + } + } + + /*** + * Get the id of the partition being received from. + * + * @return partition id + */ + public String getPartitionId() { + return this.partitionId; + } + + // Returns a String (offset) or Instant (timestamp). + CompletableFuture getInitialOffset() { + return this.hostContext.getCheckpointManager().getCheckpoint(this.partitionId) + .thenApply((startingCheckpoint) -> + { + return checkpointToOffset(startingCheckpoint); + }); + } + + EventPosition checkpointToOffset(Checkpoint startingCheckpoint) { + EventPosition startAt = null; + if (startingCheckpoint == null) { + // No checkpoint was ever stored. Use the initialOffsetProvider instead. + Function initialPositionProvider = this.hostContext.getEventProcessorOptions().getInitialPositionProvider(); + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(this.partitionId, "Calling user-provided initial position provider")); + startAt = initialPositionProvider.apply(this.partitionId); + // Leave this.offset as null. The initialPositionProvider cannot provide enough information to write a valid checkpoint: + // at most if will give one of offset or sequence number, and if it is a starting time then it doesn't have either. + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(this.partitionId, "Initial position provided: " + startAt)); + } else { + // Checkpoint is valid, use it. + this.offset = startingCheckpoint.getOffset(); + startAt = EventPosition.fromOffset(this.offset); + this.sequenceNumber = startingCheckpoint.getSequenceNumber(); + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(this.partitionId, "Retrieved starting offset " + this.offset + "//" + this.sequenceNumber)); + } + + return startAt; + } + + /** + * Writes the position of the last event in the current batch to the checkpoint store via the checkpoint manager. + *

+ * It is important to check the result in order to detect failures. + *

+ * If receiving started from a user-provided EventPosition and no events have been received yet, + * then this will fail. (This scenario is possible when invoke-after-receive-timeout has been set + * in EventProcessorOptions.) + * + * @return CompletableFuture {@literal ->} null when the checkpoint has been persisted successfully, completes exceptionally on error. + */ + public CompletableFuture checkpoint() { + CompletableFuture result = null; + if (this.offset == null) { + result = new CompletableFuture(); + result.completeExceptionally(new RuntimeException("Cannot checkpoint until at least one event has been received on this partition")); + } else { + Checkpoint capturedCheckpoint = new Checkpoint(this.partitionId, this.offset, this.sequenceNumber); + result = checkpoint(capturedCheckpoint); + } + return result; + } + + /** + * Writes the position of the provided EventData instance to the checkpoint store via the checkpoint manager. + *

+ * It is important to check the result in order to detect failures. + * + * @param event A received EventData + * @return CompletableFuture {@literal ->} null when the checkpoint has been persisted successfully, completes exceptionally on error. + */ + public CompletableFuture checkpoint(EventData event) { + CompletableFuture result = null; + if (event == null) { + result = new CompletableFuture(); + result.completeExceptionally(new IllegalArgumentException("Cannot checkpoint with null EventData")); + } else { + result = checkpoint(new Checkpoint(this.partitionId, event.getSystemProperties().getOffset(), event.getSystemProperties().getSequenceNumber())); + } + return result; + } + + /** + * Writes the position of the provided Checkpoint instance to the checkpoint store via the checkpoint manager. + * + * It is important to check the result in order to detect failures. + * + * @param checkpoint a checkpoint + * @return CompletableFuture {@literal ->} null when the checkpoint has been persisted successfully, completes exceptionally on error. + */ + public CompletableFuture checkpoint(Checkpoint checkpoint) { + CompletableFuture result = null; + if (checkpoint == null) { + result = new CompletableFuture(); + result.completeExceptionally(new IllegalArgumentException("Cannot checkpoint with null Checkpoint")); + } else { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(checkpoint.getPartitionId(), + "Saving checkpoint: " + checkpoint.getOffset() + "//" + checkpoint.getSequenceNumber())); + result = this.hostContext.getCheckpointManager().updateCheckpoint(this.lease, checkpoint); + } + return result; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/PartitionManager.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/PartitionManager.java new file mode 100644 index 000000000000..ffad0a9de930 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/PartitionManager.java @@ -0,0 +1,334 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import com.microsoft.azure.eventhubs.EventHubClient; +import com.microsoft.azure.eventhubs.EventHubException; +import com.microsoft.azure.eventhubs.EventHubRuntimeInformation; +import com.microsoft.azure.eventhubs.IllegalEntityException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Arrays; +import java.util.concurrent.*; + +class PartitionManager extends Closable { + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(PartitionManager.class); + // Protected instead of private for testability + protected final HostContext hostContext; + final private Object scanFutureSynchronizer = new Object(); + private final int retryMax = 5; + protected PumpManager pumpManager = null; + protected volatile String partitionIds[] = null; + private ScheduledFuture scanFuture = null; + + PartitionManager(HostContext hostContext) { + super(null); + this.hostContext = hostContext; + } + + CompletableFuture cachePartitionIds() { + CompletableFuture retval = null; + + if (this.partitionIds != null) { + retval = CompletableFuture.completedFuture(null); + } else { + // This try-catch is necessary because EventHubClient.create can directly throw + // EventHubException or IOException, in addition to whatever failures may occur when the result of + // the CompletableFuture is evaluated. + try { + final CompletableFuture cleanupFuture = new CompletableFuture(); + + // Stage 0A: get EventHubClient for the event hub + retval = EventHubClient.create(this.hostContext.getEventHubConnectionString(), this.hostContext.getRetryPolicy(), this.hostContext.getExecutor()) + // Stage 0B: set up a way to close the EventHubClient when we're done + .thenApplyAsync((ehClient) -> + { + final EventHubClient saveForCleanupClient = ehClient; + cleanupFuture.thenComposeAsync((empty) -> saveForCleanupClient.close(), this.hostContext.getExecutor()); + return ehClient; + }, this.hostContext.getExecutor()) + // Stage 1: use the client to get runtime info for the event hub + .thenComposeAsync((ehClient) -> ehClient.getRuntimeInformation(), this.hostContext.getExecutor()) + // Stage 2: extract the partition ids from the runtime info or throw on null (timeout) + .thenAcceptAsync((EventHubRuntimeInformation ehInfo) -> + { + if (ehInfo != null) { + this.partitionIds = ehInfo.getPartitionIds(); + + TRACE_LOGGER.info(this.hostContext.withHost("Eventhub " + this.hostContext.getEventHubPath() + " count of partitions: " + ehInfo.getPartitionCount())); + for (String id : this.partitionIds) { + TRACE_LOGGER.info(this.hostContext.withHost("Found partition with id: " + id)); + } + } else { + throw new CompletionException(new TimeoutException("getRuntimeInformation returned null")); + } + }, this.hostContext.getExecutor()) + // Stage 3: RUN REGARDLESS OF EXCEPTIONS -- if there was an error, wrap it in IllegalEntityException and throw + .handleAsync((empty, e) -> + { + cleanupFuture.complete(null); // trigger client cleanup + if (e != null) { + Throwable notifyWith = e; + if (e instanceof CompletionException) { + notifyWith = e.getCause(); + } + throw new CompletionException(new IllegalEntityException("Failure getting partition ids for event hub", notifyWith)); + } + return null; + }, this.hostContext.getExecutor()); + } catch (EventHubException | IOException e) { + retval = new CompletableFuture(); + retval.completeExceptionally(new IllegalEntityException("Failure getting partition ids for event hub", e)); + } + } + + return retval; + } + + // Testability hook: allows a test subclass to insert dummy pump. + PumpManager createPumpTestHook() { + return new PumpManager(this.hostContext, this); + } + + // Testability hook: called after stores are initialized. + void onInitializeCompleteTestHook() { + } + + // Testability hook: called at the end of the main loop after all partition checks/stealing is complete. + void onPartitionCheckCompleteTestHook() { + } + + CompletableFuture stopPartitions() { + setClosing(); + + // If the lease scanner is between runs, cancel so it doesn't run again. + synchronized (this.scanFutureSynchronizer) { + if (this.scanFuture != null) { + this.scanFuture.cancel(true); + } + } + + // Stop any partition pumps that are running. + CompletableFuture stopping = CompletableFuture.completedFuture(null); + + if (this.pumpManager != null) { + TRACE_LOGGER.info(this.hostContext.withHost("Shutting down all pumps")); + stopping = this.pumpManager.removeAllPumps(CloseReason.Shutdown) + .whenCompleteAsync((empty, e) -> { + if (e != null) { + Throwable notifyWith = LoggingUtils.unwrapException(e, null); + TRACE_LOGGER.warn(this.hostContext.withHost("Failure during shutdown"), notifyWith); + if (notifyWith instanceof Exception) { + this.hostContext.getEventProcessorOptions().notifyOfException(this.hostContext.getHostName(), (Exception) notifyWith, + EventProcessorHostActionStrings.PARTITION_MANAGER_CLEANUP); + + } + } + }, this.hostContext.getExecutor()); + } + // else no pumps to shut down + + stopping = stopping.whenCompleteAsync((empty, e) -> { + TRACE_LOGGER.info(this.hostContext.withHost("Partition manager exiting")); + setClosed(); + }, this.hostContext.getExecutor()); + + return stopping; + } + + public CompletableFuture initialize() { + this.pumpManager = createPumpTestHook(); + + // Stage 0: get partition ids and cache + return cachePartitionIds() + // Stage 1: initialize stores, if stage 0 succeeded + .thenComposeAsync((unused) -> initializeStores(), this.hostContext.getExecutor()) + // Stage 2: RUN REGARDLESS OF EXCEPTIONS -- trace errors + .whenCompleteAsync((empty, e) -> + { + if (e != null) { + StringBuilder outAction = new StringBuilder(); + Throwable notifyWith = LoggingUtils.unwrapException(e, outAction); + if (outAction.length() > 0) { + TRACE_LOGGER.error(this.hostContext.withHost( + "Exception while initializing stores (" + outAction.toString() + "), not starting partition manager"), notifyWith); + } else { + TRACE_LOGGER.error(this.hostContext.withHost("Exception while initializing stores, not starting partition manager"), notifyWith); + } + } + }, this.hostContext.getExecutor()) + // Stage 3: schedule scan, which will find partitions and start pumps, if previous stages succeeded + .thenRunAsync(() -> + { + // Schedule the first scan immediately. + synchronized (this.scanFutureSynchronizer) { + TRACE_LOGGER.debug(this.hostContext.withHost("Scheduling lease scanner first pass")); + this.scanFuture = this.hostContext.getExecutor().schedule(() -> scan(true), 0, TimeUnit.SECONDS); + } + + onInitializeCompleteTestHook(); + }, this.hostContext.getExecutor()); + } + + private CompletableFuture initializeStores() { + ILeaseManager leaseManager = this.hostContext.getLeaseManager(); + ICheckpointManager checkpointManager = this.hostContext.getCheckpointManager(); + + // let R = this.retryMax + // Stages 0 to R: create lease store if it doesn't exist + CompletableFuture initializeStoresFuture = buildRetries(CompletableFuture.completedFuture(null), + () -> leaseManager.createLeaseStoreIfNotExists(), "Failure creating lease store for this Event Hub, retrying", + "Out of retries creating lease store for this Event Hub", EventProcessorHostActionStrings.CREATING_LEASE_STORE, this.retryMax); + + // Stages R+1 to 2R: create checkpoint store if it doesn't exist + initializeStoresFuture = buildRetries(initializeStoresFuture, () -> checkpointManager.createCheckpointStoreIfNotExists(), + "Failure creating checkpoint store for this Event Hub, retrying", "Out of retries creating checkpoint store for this Event Hub", + EventProcessorHostActionStrings.CREATING_CHECKPOINT_STORE, this.retryMax); + + // Stages 2R+1 to 3R: create leases if they don't exist + initializeStoresFuture = buildRetries(initializeStoresFuture, () -> leaseManager.createAllLeasesIfNotExists(Arrays.asList(this.partitionIds)), + "Failure creating leases, retrying", "Out of retries creating leases", EventProcessorHostActionStrings.CREATING_LEASES, this.retryMax); + + // Stages 3R+1 to 4R: create checkpoint holders if they don't exist + initializeStoresFuture = buildRetries(initializeStoresFuture, () -> checkpointManager.createAllCheckpointsIfNotExists(Arrays.asList(this.partitionIds)), + "Failure creating checkpoint holders, retrying", "Out of retries creating checkpoint holders", + EventProcessorHostActionStrings.CREATING_CHECKPOINTS, this.retryMax); + + initializeStoresFuture.whenCompleteAsync((r, e) -> + { + // If an exception has propagated this far, it should be a FinalException, which is guaranteed to contain a CompletionException. + // Unwrap it so we don't leak a private type. + if ((e != null) && (e instanceof FinalException)) { + throw ((FinalException) e).getInner(); + } + + // Otherwise, allow the existing result to pass to the caller. + }, this.hostContext.getExecutor()); + + return initializeStoresFuture; + } + + // CompletableFuture will be completed exceptionally if it runs out of retries. + // If the lambda succeeds, then it will not be invoked again by following stages. + private CompletableFuture buildRetries(CompletableFuture buildOnto, Callable> lambda, String retryMessage, + String finalFailureMessage, String action, int maxRetries) { + // Stage 0: first attempt + CompletableFuture retryChain = buildOnto.thenComposeAsync((unused) -> + { + CompletableFuture newresult = CompletableFuture.completedFuture(null); + try { + newresult = lambda.call(); + } catch (Exception e1) { + throw new CompletionException(e1); + } + return newresult; + }, this.hostContext.getExecutor()); + + for (int i = 1; i < maxRetries; i++) { + retryChain = retryChain + // Stages 1, 3, 5, etc: trace errors but stop normal exception propagation in order to keep going. + // Either return null if we don't have a valid result, or pass the result along to the next stage. + // FinalExceptions are passed along also so that fatal error earlier in the chain aren't lost. + .handleAsync((r, e) -> + { + Object effectiveResult = r; + if (e != null) { + if (e instanceof FinalException) { + // Propagate FinalException up to the end + throw (FinalException) e; + } else { + TRACE_LOGGER.warn(this.hostContext.withHost(retryMessage), LoggingUtils.unwrapException(e, null)); + } + } else { + // Some lambdas return null on success. Change to TRUE to skip retrying. + if (r == null) { + effectiveResult = true; + } + } + return (e == null) ? effectiveResult : null; // stop propagation of other exceptions so we can retry + }, this.hostContext.getExecutor()) + // Stages 2, 4, 6, etc: if we already have a valid result, pass it along. Otherwise, make another attempt. + // Once we have a valid result there will be no more attempts or exceptions. + .thenComposeAsync((oldresult) -> + { + CompletableFuture newresult = CompletableFuture.completedFuture(oldresult); + if (oldresult == null) { + try { + newresult = lambda.call(); + } catch (Exception e1) { + throw new CompletionException(e1); + } + } + return newresult; + }, this.hostContext.getExecutor()); + } + // Stage final: trace the exception with the final message, or pass along the valid result. + retryChain = retryChain.handleAsync((r, e) -> + { + if (e != null) { + if (e instanceof FinalException) { + throw (FinalException) e; + } else { + TRACE_LOGGER.warn(this.hostContext.withHost(finalFailureMessage)); + throw new FinalException(LoggingUtils.wrapExceptionWithMessage(LoggingUtils.unwrapException(e, null), finalFailureMessage, action)); + } + } + return (e == null) ? r : null; + }, this.hostContext.getExecutor()); + + return retryChain; + } + + // Return Void so it can be called from a lambda. + // throwOnFailure is true + private Void scan(boolean isFirst) { + TRACE_LOGGER.debug(this.hostContext.withHost("Starting lease scan")); + long start = System.currentTimeMillis(); + + (new PartitionScanner(this.hostContext, (lease) -> this.pumpManager.addPump(lease), this)).scan(isFirst) + .whenCompleteAsync((didSteal, e) -> + { + TRACE_LOGGER.debug(this.hostContext.withHost("Scanning took " + (System.currentTimeMillis() - start))); + + onPartitionCheckCompleteTestHook(); + + // Schedule the next scan unless we are shutting down. + if (!this.getIsClosingOrClosed()) { + int seconds = didSteal ? this.hostContext.getPartitionManagerOptions().getFastScanIntervalInSeconds() : + this.hostContext.getPartitionManagerOptions().getSlowScanIntervalInSeconds(); + if (isFirst) { + seconds = this.hostContext.getPartitionManagerOptions().getStartupScanDelayInSeconds(); + } + synchronized (this.scanFutureSynchronizer) { + this.scanFuture = this.hostContext.getExecutor().schedule(() -> scan(false), seconds, TimeUnit.SECONDS); + } + TRACE_LOGGER.debug(this.hostContext.withHost("Scheduling lease scanner in " + seconds)); + } else { + TRACE_LOGGER.debug(this.hostContext.withHost("Not scheduling lease scanner due to shutdown")); + } + }, this.hostContext.getExecutor()); + + return null; + } + + // Exception wrapper that buildRetries() uses to indicate that a fatal error has occurred. The chain + // built by buildRetries() normally swallows exceptions via odd-numbered stages so that the retries in + // even-numbered stages will execute. If multiple chains are concatenated, FinalException short-circuits + // the exceptional swallowing and allows fatal errors in earlier chains to be propagated all the way to the end. + class FinalException extends CompletionException { + private static final long serialVersionUID = -4600271981700687166L; + + FinalException(CompletionException e) { + super(e); + } + + CompletionException getInner() { + return (CompletionException) this.getCause(); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/PartitionManagerOptions.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/PartitionManagerOptions.java new file mode 100644 index 000000000000..ac20a6d61b39 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/PartitionManagerOptions.java @@ -0,0 +1,188 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +/*** + * Options affecting the operation of the partition manager within the event processor host. + * This class is broken out separately because many of these options also affect the operation + * of the ILeaseManager and ICheckpointManager implementations, and different implementations + * may need to subclass and provide different options or defaults. + */ +public class PartitionManagerOptions { + /** + * The default duration after which a partition lease will expire unless renewed. + */ + public final static int DefaultLeaseDurationInSeconds = 30; + + /** + * The default duration between lease renewals. + */ + public final static int DefaultLeaseRenewIntervalInSeconds = 10; + + /** + * The default timeout for checkpoint operations. + */ + public final static int DefaultCheckpointTimeoutInSeconds = 120; + + public final static int DefaultStartupScanDelayInSeconds = 30; + public final static int DefaultFastScanIntervalInSeconds = 3; + public final static int DefaultSlowScanIntervalInSeconds = 5; + + protected int leaseDurationInSeconds = PartitionManagerOptions.DefaultLeaseDurationInSeconds; + protected int leaseRenewIntervalInSeconds = PartitionManagerOptions.DefaultLeaseRenewIntervalInSeconds; + protected int checkpointTimeoutInSeconds = PartitionManagerOptions.DefaultCheckpointTimeoutInSeconds; + + protected int startupScanDelayInSeconds = PartitionManagerOptions.DefaultStartupScanDelayInSeconds; + protected int fastScanIntervalInSeconds = PartitionManagerOptions.DefaultFastScanIntervalInSeconds; + protected int slowScanIntervalInSeconds = PartitionManagerOptions.DefaultSlowScanIntervalInSeconds; + + /*** + * The base class automatically sets members to the static defaults. + */ + public PartitionManagerOptions() { + } + + /** + * Gets the duration after which a partition lease will expire unless renewed. + * Defaults to DefaultLeaseDurationInSeconds. + * + * @return lease duration + */ + public int getLeaseDurationInSeconds() { + return this.leaseDurationInSeconds; + } + + /** + * Sets the duration after which a partition lease will expire unless renewed. + * Must be greater than 0 and should not be less than the renew interval. When using the + * default, Azure Storage-based ILeaseManager, the duration cannot be greater than 60. + * + * @param duration new value for lease duration + */ + public void setLeaseDurationInSeconds(int duration) { + if (duration <= 0) { + throw new IllegalArgumentException("Lease duration must be greater than 0"); + } + this.leaseDurationInSeconds = duration; + } + + /** + * Gets the duration between lease renewals. Defaults to DefaultLeaseRenewIntervalInSeconds. + * + * @return how often leases are renewed + */ + public int getLeaseRenewIntervalInSeconds() { + return this.leaseRenewIntervalInSeconds; + } + + /** + * Sets the duration between lease renewals. Must be greater than 0 and less than the current lease duration. + * + * @param interval new value for how often leases are renewed + */ + public void setLeaseRenewIntervalInSeconds(int interval) { + if ((interval <= 0) || (interval > this.leaseDurationInSeconds)) { + throw new IllegalArgumentException("Lease renew interval must be greater than 0 and not more than lease duration"); + } + this.leaseRenewIntervalInSeconds = interval; + } + + /** + * Gets the timeout for checkpoint operations. Defaults to DefaultCheckpointTimeoutInSeconds. + * + * @return timeout for checkpoint operations + */ + public int getCheckpointTimeoutInSeconds() { + return this.checkpointTimeoutInSeconds; + } + + /** + * Sets the timeout for checkpoint operations. Must be greater than 0. + * + * @param timeout new value for checkpoint timeout + */ + public void setCheckpointTimeoutInSeconds(int timeout) { + if (timeout <= 0) { + throw new IllegalArgumentException("Checkpoint timeout must be greater than 0"); + } + this.checkpointTimeoutInSeconds = timeout; + } + + /** + * Gets the delay time between the first scan for available partitions and the second. This is + * part of a startup optimization which allows individual hosts to become visible to other + * hosts, and thereby get a more accurate count of the number of hosts in the system, before + * they try to estimate how many partitions they should own. + * + * Defaults to DefaultStartupScanDelayInSeconds. + * + * @return delay time in seconds + */ + public int getStartupScanDelayInSeconds() { + return this.startupScanDelayInSeconds; + } + + /** + * Sets the delay time in seconds between the first scan and the second. + * + * @param delay new delay time in seconds + */ + public void setStartupScanDelayInSeconds(int delay) { + if (delay <= 0) { + throw new IllegalArgumentException("Startup scan delay must be greater than 0"); + } + this.startupScanDelayInSeconds = delay; + } + + /** + * There are two possible interval times between scans for available partitions, fast and slow. + * The fast (short) interval is used after a scan in which lease stealing has occurred, to + * promote quicker rebalancing. + * + * Defaults to DefaultFastScanIntervalInSeconds. + * + * @return interval time in seconds + */ + public int getFastScanIntervalInSeconds() { + return this.fastScanIntervalInSeconds; + } + + /** + * Sets the time for fast interval. + * + * @param interval new fast interval in seconds + */ + public void setFastScanIntervalInSeconds(int interval) { + if (interval <= 0) { + throw new IllegalArgumentException("Fast scan interval must be greater than 0"); + } + this.fastScanIntervalInSeconds = interval; + } + + /** + * The slow (long) interval is used after a scan in which lease stealing did not occur, to + * reduce unnecessary scanning when the system is in steady state. + * + * Defaults to DefaultSlowScanIntervalInSeconds. + * + * @return interval time in seconds + */ + public int getSlowScanIntervalInSeconds() { + return this.slowScanIntervalInSeconds; + } + + /** + * Sets the time for slow interval. + * + * @param interval new slow interval in seconds + */ + public void setSlowScanIntervalInSeconds(int interval) { + if (interval <= 0) { + throw new IllegalArgumentException("Slow scan interval must be greater than 0"); + } + this.slowScanIntervalInSeconds = interval; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/PartitionPump.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/PartitionPump.java new file mode 100644 index 000000000000..fcdbe9abd853 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/PartitionPump.java @@ -0,0 +1,528 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import com.microsoft.azure.eventhubs.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +class PartitionPump extends Closable implements PartitionReceiveHandler { + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(PartitionPump.class); + protected final HostContext hostContext; + protected final CompleteLease lease; // protected for testability + final private CompletableFuture shutdownTriggerFuture; + final private CompletableFuture shutdownFinishedFuture; + private final Object processingSynchronizer; + private final Consumer pumpManagerCallback; + private EventHubClient eventHubClient = null; + private PartitionReceiver partitionReceiver = null; + private CloseReason shutdownReason; + private volatile CompletableFuture internalOperationFuture = null; + private IEventProcessor processor = null; + private PartitionContext partitionContext = null; + private ScheduledFuture leaseRenewerFuture = null; + + PartitionPump(HostContext hostContext, CompleteLease lease, Closable parent, Consumer pumpManagerCallback) { + super(parent); + + this.hostContext = hostContext; + this.lease = lease; + this.pumpManagerCallback = pumpManagerCallback; + this.processingSynchronizer = new Object(); + + this.partitionContext = new PartitionContext(this.hostContext, this.lease.getPartitionId()); + this.partitionContext.setLease(this.lease); + + // Set up the shutdown futures. The shutdown process can be triggered just by completing this.shutdownFuture. + this.shutdownTriggerFuture = new CompletableFuture(); + this.shutdownFinishedFuture = this.shutdownTriggerFuture + .handleAsync((r, e) -> { + this.pumpManagerCallback.accept(this.lease.getPartitionId()); + return cancelPendingOperations(); + }, this.hostContext.getExecutor()) + .thenComposeAsync((empty) -> cleanUpAll(this.shutdownReason), this.hostContext.getExecutor()) + .thenComposeAsync((empty) -> releaseLeaseOnShutdown(), this.hostContext.getExecutor()) + .whenCompleteAsync((empty, e) -> { + setClosed(); + }, this.hostContext.getExecutor()); + } + + // The CompletableFuture returned by startPump remains uncompleted as long as the pump is running. + // If startup fails, or an error occurs while running, it will complete exceptionally. + // If clean shutdown due to unregister call, it completes normally. + CompletableFuture startPump() { + // Do the slow startup stuff asynchronously. + // Use whenComplete to trigger cleanup on exception. + CompletableFuture.runAsync(() -> openProcessor(), this.hostContext.getExecutor()) + .thenComposeAsync((empty) -> openClientsRetryWrapper(), this.hostContext.getExecutor()) + .thenRunAsync(() -> scheduleLeaseRenewer(), this.hostContext.getExecutor()) + .whenCompleteAsync((r, e) -> + { + if (e != null) { + // If startup failed, trigger shutdown to clean up. + internalShutdown(CloseReason.Shutdown, e); + } + }, this.hostContext.getExecutor()); + + return shutdownFinishedFuture; + } + + private void openProcessor() { + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(this.partitionContext, "Creating and opening event processor instance")); + + String action = EventProcessorHostActionStrings.CREATING_EVENT_PROCESSOR; + try { + this.processor = this.hostContext.getEventProcessorFactory().createEventProcessor(this.partitionContext); + action = EventProcessorHostActionStrings.OPENING_EVENT_PROCESSOR; + this.processor.onOpen(this.partitionContext); + } catch (Exception e) { + // If the processor won't create or open, only thing we can do here is pass the buck. + // Null it out so we don't try to operate on it further. + this.processor = null; + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(this.partitionContext, "Failed " + action), e); + this.hostContext.getEventProcessorOptions().notifyOfException(this.hostContext.getHostName(), e, action, this.lease.getPartitionId()); + throw new CompletionException(e); + } + } + + private CompletableFuture openClientsRetryWrapper() { + // Stage 0: first attempt + CompletableFuture retryResult = openClients(); + + for (int i = 1; i < 5; i++) { + retryResult = retryResult + // Stages 1, 3, 5, etc: trace errors but stop exception propagation in order to keep going + // UNLESS it's ReceiverDisconnectedException. + .handleAsync((r, e) -> + { + if (e != null) { + Exception notifyWith = (Exception) LoggingUtils.unwrapException(e, null); + if (notifyWith instanceof ReceiverDisconnectedException) { + // TODO Assuming this is due to a receiver with a higher epoch. + // Is there a way to be sure without checking the exception text? + // DO NOT trace here because then we could get multiple traces for the same exception. + // If it's a bad epoch, then retrying isn't going to help. + // Rethrow to keep propagating error to the end and prevent any more attempts. + throw new CompletionException(notifyWith); + } else { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(this.partitionContext, + "Failure creating client or receiver, retrying"), e); + } + } + // If we have a valid result, pass it along to prevent further attempts. + return (e == null) ? r : false; + }, this.hostContext.getExecutor()) + // Stages 2, 4, 6, etc: make another attempt if needed. + .thenComposeAsync((done) -> + { + return done ? CompletableFuture.completedFuture(done) : openClients(); + }, this.hostContext.getExecutor()); + } + // Stage final: on success, hook up the user's event handler to start receiving events. On error, + // trace exceptions from the final attempt, or ReceiverDisconnectedException. + return retryResult.handleAsync((r, e) -> + { + if (e == null) { + // IEventProcessor.onOpen is called from the base PartitionPump and must have returned in order for execution to reach here, + // meaning it is safe to set the handler and start calling IEventProcessor.onEvents. + this.partitionReceiver.setReceiveHandler(this, this.hostContext.getEventProcessorOptions().getInvokeProcessorAfterReceiveTimeout()); + } else { + Exception notifyWith = (Exception) LoggingUtils.unwrapException(e, null); + if (notifyWith instanceof ReceiverDisconnectedException) { + // TODO Assuming this is due to a receiver with a higher epoch. + // Is there a way to be sure without checking the exception text? + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(this.partitionContext, + "Receiver disconnected on create, bad epoch?"), notifyWith); + } else { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(this.partitionContext, + "Failure creating client or receiver, out of retries"), e); + } + + // IEventProcessor.onOpen is called from the base PartitionPump and must have returned in order for execution to reach here, + // so we can report this error to it instead of the general error handler. + this.processor.onError(this.partitionContext, new ExceptionWithAction(notifyWith, EventProcessorHostActionStrings.CREATING_EVENT_HUB_CLIENT)); + + // Rethrow so caller will see failure + throw LoggingUtils.wrapException(notifyWith, EventProcessorHostActionStrings.CREATING_EVENT_HUB_CLIENT); + } + return null; + }, this.hostContext.getExecutor()); + } + + protected void scheduleLeaseRenewer() { + if (!getIsClosingOrClosed()) { + int seconds = this.hostContext.getPartitionManagerOptions().getLeaseRenewIntervalInSeconds(); + this.leaseRenewerFuture = this.hostContext.getExecutor().schedule(() -> leaseRenewer(), seconds, TimeUnit.SECONDS); + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(this.lease, "scheduling leaseRenewer in " + seconds)); + } + } + + private CompletableFuture openClients() { + // Create new client + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(this.partitionContext, "Opening EH client")); + + CompletableFuture startOpeningFuture = null; + try { + startOpeningFuture = EventHubClient.create(this.hostContext.getEventHubConnectionString(), + this.hostContext.getRetryPolicy(), this.hostContext.getExecutor()); + } catch (EventHubException | IOException e2) { + // Marking startOpeningFuture as completed exceptionally will cause all the + // following stages to fall through except stage 1 which will report the error. + startOpeningFuture = new CompletableFuture(); + startOpeningFuture.completeExceptionally(e2); + } + this.internalOperationFuture = startOpeningFuture; + + // Stage 0: get EventHubClient + return startOpeningFuture + // Stage 1: save EventHubClient on success, trace on error + .whenCompleteAsync((ehclient, e) -> + { + if ((ehclient != null) && (e == null)) { + this.eventHubClient = ehclient; + } else { + TRACE_LOGGER.error(this.hostContext.withHostAndPartition(this.partitionContext, "EventHubClient creation failed"), e); + } + // this.internalOperationFuture allows canceling startup if it gets stuck. Null out now that EventHubClient creation has completed. + this.internalOperationFuture = null; + }, this.hostContext.getExecutor()) + // Stage 2: get initial offset for receiver + .thenComposeAsync((empty) -> this.partitionContext.getInitialOffset(), this.hostContext.getExecutor()) + // Stage 3: set up other receiver options, create receiver if initial offset is valid + .thenComposeAsync((startAt) -> + { + long epoch = this.lease.getEpoch(); + + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(this.partitionContext, + "Opening EH receiver with epoch " + epoch + " at location " + startAt)); + + CompletableFuture receiverFuture = null; + + try { + ReceiverOptions options = new ReceiverOptions(); + options.setReceiverRuntimeMetricEnabled(this.hostContext.getEventProcessorOptions().getReceiverRuntimeMetricEnabled()); + options.setPrefetchCount(this.hostContext.getEventProcessorOptions().getPrefetchCount()); + + receiverFuture = this.eventHubClient.createEpochReceiver(this.partitionContext.getConsumerGroupName(), + this.partitionContext.getPartitionId(), startAt, epoch, options); + this.internalOperationFuture = receiverFuture; + } catch (EventHubException e) { + TRACE_LOGGER.error(this.hostContext.withHostAndPartition(this.partitionContext, "Opening EH receiver failed with an error "), e); + receiverFuture = new CompletableFuture(); + receiverFuture.completeExceptionally(e); + } + + return receiverFuture; + }, this.hostContext.getExecutor()) + // Stage 4: save PartitionReceiver on success, trace on error + .whenCompleteAsync((receiver, e) -> + { + if ((receiver != null) && (e == null)) { + this.partitionReceiver = receiver; + } else if (this.eventHubClient != null) { + if (e instanceof ReceiverDisconnectedException) { + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(this.partitionContext, "PartitionReceiver disconnected during startup")); + } else { + TRACE_LOGGER.error(this.hostContext.withHostAndPartition(this.partitionContext, "PartitionReceiver creation failed"), e); + } + } + // else if this.eventHubClient is null then we failed in stage 0 and already traced in stage 1 + + // this.internalOperationFuture allows canceling startup if it gets stuck. Null out now that PartitionReceiver creation has completed. + this.internalOperationFuture = null; + }, this.hostContext.getExecutor()) + // Stage 5: on success, set up the receiver + .thenApplyAsync((receiver) -> + { + this.partitionReceiver.setReceiveTimeout(this.hostContext.getEventProcessorOptions().getReceiveTimeOut()); + + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(this.partitionContext, + "EH client and receiver creation finished")); + + return true; + }, this.hostContext.getExecutor()); + } + + private CompletableFuture cleanUpAll(CloseReason reason) // swallows all exceptions + { + return cleanUpClients() + .thenRunAsync(() -> + { + if (this.processor != null) { + try { + synchronized (this.processingSynchronizer) { + // When we take the lock, any existing onEvents call has finished. + // Because the client has been closed, there will not be any more + // calls to onEvents in the future. Therefore we can safely call onClose. + this.processor.onClose(this.partitionContext, reason); + } + } catch (Exception e) { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(this.partitionContext, + "Failure closing processor"), e); + // If closing the processor has failed, the state of the processor is suspect. + // Report the failure to the general error handler instead. + this.hostContext.getEventProcessorOptions().notifyOfException(this.hostContext.getHostName(), e, EventProcessorHostActionStrings.CLOSING_EVENT_PROCESSOR, + this.lease.getPartitionId()); + } + } + }, this.hostContext.getExecutor()); + } + + private CompletableFuture cleanUpClients() // swallows all exceptions + { + CompletableFuture cleanupFuture = null; + if (this.partitionReceiver != null) { + // Disconnect the processor from the receiver we're about to close. + // Fortunately this is idempotent -- setting the handler to null when it's already been + // nulled by code elsewhere is harmless! + // Setting to null also waits for the in-progress calls to complete + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(this.partitionContext, "Setting receive handler to null")); + cleanupFuture = this.partitionReceiver.setReceiveHandler(null); + } else { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(this.partitionContext, "partitionReceiver is null in cleanup")); + cleanupFuture = CompletableFuture.completedFuture(null); + } + cleanupFuture = cleanupFuture.handleAsync((empty, e) -> + { + if (e != null) { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(this.partitionContext, + "Got exception when ReceiveHandler is set to null."), LoggingUtils.unwrapException(e, null)); + } + return null; // stop propagation of exceptions + }, this.hostContext.getExecutor()) + .thenApplyAsync((empty) -> + { + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(this.partitionContext, "Closing EH receiver")); + PartitionReceiver partitionReceiverTemp = this.partitionReceiver; + this.partitionReceiver = null; + return partitionReceiverTemp; + }, this.hostContext.getExecutor()) + .thenComposeAsync((partitionReceiverTemp) -> + { + return (partitionReceiverTemp != null) ? partitionReceiverTemp.close() : CompletableFuture.completedFuture(null); + }, this.hostContext.getExecutor()) + .handleAsync((empty, e) -> + { + if (e != null) { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(this.partitionContext, + "Closing EH receiver failed."), LoggingUtils.unwrapException(e, null)); + } + return null; // stop propagation of exceptions + }, this.hostContext.getExecutor()) + .thenApplyAsync((empty) -> + { + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(this.partitionContext, "Closing EH client")); + final EventHubClient eventHubClientTemp = this.eventHubClient; + this.eventHubClient = null; + if (eventHubClientTemp == null) { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(this.partitionContext, + "eventHubClient is null in cleanup")); + } + return eventHubClientTemp; + }, this.hostContext.getExecutor()) + .thenComposeAsync((eventHubClientTemp) -> + { + return (eventHubClientTemp != null) ? eventHubClientTemp.close() : CompletableFuture.completedFuture(null); + }, this.hostContext.getExecutor()) + .handleAsync((empty, e) -> + { + if (e != null) { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(this.partitionContext, "Closing EH client failed."), + LoggingUtils.unwrapException(e, null)); + } + return null; // stop propagation of exceptions + }, this.hostContext.getExecutor()); + + return cleanupFuture; + } + + protected Void cancelPendingOperations() { + // If an open operation is stuck, this lets us shut down anyway. + CompletableFuture captured = this.internalOperationFuture; + if (captured != null) { + captured.cancel(true); + } + + ScheduledFuture capturedLeaseRenewer = this.leaseRenewerFuture; + if (capturedLeaseRenewer != null) { + capturedLeaseRenewer.cancel(true); + } + return null; + } + + private CompletableFuture releaseLeaseOnShutdown() // swallows all exceptions + { + CompletableFuture result = CompletableFuture.completedFuture(null); + + if (this.shutdownReason != CloseReason.LeaseLost) { + // Since this pump is dead, release the lease. Don't care about any errors that may occur. Worst case is + // that the lease eventually expires, since the lease renewer has been cancelled. + result = PartitionPump.this.hostContext.getLeaseManager().releaseLease(this.lease) + .handleAsync((empty, e) -> + { + if (e != null) { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(this.partitionContext, + "Failure releasing lease on pump shutdown"), LoggingUtils.unwrapException(e, null)); + } + return null; // stop propagation of exceptions + }, this.hostContext.getExecutor()); + } + // else we already lost the lease, releasing is unnecessary and would fail if we try + + return result; + } + + protected void internalShutdown(CloseReason reason, Throwable e) { + setClosing(); + + this.shutdownReason = reason; + if (e == null) { + this.shutdownTriggerFuture.complete(null); + } else { + this.shutdownTriggerFuture.completeExceptionally(e); + } + } + + CompletableFuture shutdown(CloseReason reason) { + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(this.partitionContext, + "pump shutdown for reason " + reason.toString())); + internalShutdown(reason, null); + return this.shutdownFinishedFuture; + } + + private void leaseRenewer() { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(this.lease, "leaseRenewer()")); + + // Theoretically, if the future is cancelled then this method should never fire, but + // there's no harm in being sure. + if (this.leaseRenewerFuture.isCancelled()) { + return; + } + if (getIsClosingOrClosed()) { + return; + } + + // Stage 0: renew the lease + this.hostContext.getLeaseManager().renewLease(this.lease) + // Stage 1: check result of renewing + .thenApplyAsync((renewed) -> + { + Boolean scheduleNext = true; + if (!renewed) { + // False return from renewLease means that lease was lost. + // Start pump shutdown process and do not schedule another call to leaseRenewer. + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(this.lease, "Lease lost, shutting down pump")); + internalShutdown(CloseReason.LeaseLost, null); + scheduleNext = false; + } + return scheduleNext; + }, this.hostContext.getExecutor()) + // Stage 2: RUN REGARDLESS OF EXCEPTIONS -- trace exceptions, schedule next iteration + .whenCompleteAsync((scheduleNext, e) -> + { + if (e != null) { + // Failure renewing lease due to storage exception or whatever. + // Trace error and leave scheduleNext as true to schedule another try. + Exception notifyWith = (Exception) LoggingUtils.unwrapException(e, null); + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(this.lease, "Transient failure renewing lease"), notifyWith); + // Notify the general error handler rather than calling this.processor.onError so we can provide context (RENEWING_LEASE) + this.hostContext.getEventProcessorOptions().notifyOfException(this.hostContext.getHostName(), notifyWith, EventProcessorHostActionStrings.RENEWING_LEASE, + this.lease.getPartitionId()); + } + + if ((scheduleNext != null) && scheduleNext.booleanValue() && !this.leaseRenewerFuture.isCancelled() && !getIsClosingOrClosed()) { + scheduleLeaseRenewer(); + } + }, this.hostContext.getExecutor()); + } + + @Override + public int getMaxEventCount() { + return this.hostContext.getEventProcessorOptions().getMaxBatchSize(); + } + + @Override + public void onReceive(Iterable events) { + if (this.hostContext.getEventProcessorOptions().getReceiverRuntimeMetricEnabled()) { + this.partitionContext.setRuntimeInformation(this.partitionReceiver.getRuntimeInformation()); + } + + // This method is called on the thread that the Java EH client uses to run the pump. + // There is one pump per EventHubClient. Since each PartitionPump creates a new EventHubClient, + // using that thread to call onEvents does no harm. Even if onEvents is slow, the pump will + // get control back each time onEvents returns, and be able to receive a new batch of events + // with which to make the next onEvents call. The pump gains nothing by running faster than onEvents. + + // The underlying client returns null if there are no events, but the contract for IEventProcessor + // is different and is expecting an empty iterable if there are no events (and invoke processor after + // receive timeout is turned on). + + Iterable effectiveEvents = events; + if (effectiveEvents == null) { + effectiveEvents = new ArrayList(); + } + + // Update offset and sequence number in the PartitionContext to support argument-less overload of PartitionContext.checkpoint() + Iterator iter = effectiveEvents.iterator(); + EventData last = null; + while (iter.hasNext()) { + last = iter.next(); + } + if (last != null) { + this.partitionContext.setOffsetAndSequenceNumber(last); + } + + try { + // Synchronize to serialize calls to the processor. + // The handler is not installed until after onOpen returns, so onEvents cannot overlap with onOpen. + // onEvents and onClose are synchronized via this.processingSynchronizer to prevent calls to onClose + // while an onEvents call is still in progress. + synchronized (this.processingSynchronizer) { + this.processor.onEvents(this.partitionContext, effectiveEvents); + } + } catch (Exception e) { + // TODO -- do we pass errors from IEventProcessor.onEvents to IEventProcessor.onError? + // Depending on how you look at it, that's either pointless (if the user's code throws, the user's code should already know about it) or + // a convenient way of centralizing error handling. + // In the meantime, just trace it. + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(this.partitionContext, + "Got exception from onEvents"), e); + } + } + + @Override + public void onError(Throwable error) { + if (error == null) { + error = new Throwable("No error info supplied by EventHub client"); + } + if (error instanceof ReceiverDisconnectedException) { + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(this.partitionContext, + "EventHub client disconnected, probably another host took the partition")); + } else { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(this.partitionContext, "EventHub client error: " + error.toString())); + if (error instanceof Exception) { + TRACE_LOGGER.warn(this.hostContext.withHostAndPartition(this.partitionContext, "EventHub client error continued"), (Exception) error); + } + } + + // It is vital to perform the rest of cleanup in a separate thread and not block this one. This thread is the client's + // receive pump thread, and blocking it means that the receive pump never completes its CompletableFuture, which in turn + // blocks other client calls that we would like to make during cleanup. Specifically, this issue was found when + // PartitionReceiver.setReceiveHandler(null).get() was called and never returned. + final Throwable capturedError = error; + CompletableFuture.runAsync(() -> PartitionPump.this.processor.onError(PartitionPump.this.partitionContext, capturedError), this.hostContext.getExecutor()) + .thenRunAsync(() -> internalShutdown(CloseReason.Shutdown, capturedError), this.hostContext.getExecutor()); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/PartitionScanner.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/PartitionScanner.java new file mode 100644 index 000000000000..4bd0a3386909 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/PartitionScanner.java @@ -0,0 +1,323 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +class PartitionScanner extends Closable { + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(PartitionScanner.class); + private static final Random randomizer = new Random(); + private final HostContext hostContext; + private final Consumer addPump; + + // Populated by getAllLeaseStates() + private List allLeaseStates = null; + + // Values populated by sortLeasesAndCalculateDesiredCount + private int desiredCount; + private int unownedCount; // updated by acquireExpiredInChunksParallel + final private ConcurrentHashMap leasesOwnedByOthers; // updated by acquireExpiredInChunksParallel + + PartitionScanner(HostContext hostContext, Consumer addPump, Closable parent) { + super(parent); + + this.hostContext = hostContext; + this.addPump = addPump; + + this.desiredCount = 0; + this.unownedCount = 0; + this.leasesOwnedByOthers = new ConcurrentHashMap(); + } + + public CompletableFuture scan(boolean isFirst) { + return getAllLeaseStates() + .thenComposeAsync((unused) -> { + throwIfClosingOrClosed("PartitionScanner is shutting down"); + int ourLeasesCount = sortLeasesAndCalculateDesiredCount(isFirst); + return acquireExpiredInChunksParallel(0, this.desiredCount - ourLeasesCount); + }, this.hostContext.getExecutor()) + .thenApplyAsync((remainingNeeded) -> { + throwIfClosingOrClosed("PartitionScanner is shutting down"); + ArrayList stealThese = new ArrayList(); + if (remainingNeeded > 0) { + TRACE_LOGGER.debug(this.hostContext.withHost("Looking to steal: " + remainingNeeded)); + stealThese = findLeasesToSteal(remainingNeeded); + } + return stealThese; + }, this.hostContext.getExecutor()) + .thenComposeAsync((stealThese) -> { + throwIfClosingOrClosed("PartitionScanner is shutting down"); + return stealLeases(stealThese); + }, this.hostContext.getExecutor()) + .handleAsync((didSteal, e) -> { + if ((e != null) && !(e instanceof ClosingException)) { + StringBuilder outAction = new StringBuilder(); + Exception notifyWith = (Exception) LoggingUtils.unwrapException(e, outAction); + TRACE_LOGGER.warn(this.hostContext.withHost("Exception scanning leases"), notifyWith); + this.hostContext.getEventProcessorOptions().notifyOfException(this.hostContext.getHostName(), notifyWith, outAction.toString(), + ExceptionReceivedEventArgs.NO_ASSOCIATED_PARTITION); + didSteal = false; + } + return didSteal; + }, this.hostContext.getExecutor()); + } + + private CompletableFuture getAllLeaseStates() { + throwIfClosingOrClosed("PartitionScanner is shutting down"); + return this.hostContext.getLeaseManager().getAllLeases() + .thenAcceptAsync((states) -> { + throwIfClosingOrClosed("PartitionScanner is shutting down"); + this.allLeaseStates = states; + Collections.sort(this.allLeaseStates); + }, this.hostContext.getExecutor()); + } + + // NONBLOCKING + private int sortLeasesAndCalculateDesiredCount(boolean isFirst) { + TRACE_LOGGER.debug(this.hostContext.withHost("Accounting input: allLeaseStates size is " + this.allLeaseStates.size())); + + HashSet uniqueOwners = new HashSet(); + uniqueOwners.add(this.hostContext.getHostName()); + int ourLeasesCount = 0; + this.unownedCount = 0; + for (BaseLease info : this.allLeaseStates) { + boolean ownedByUs = info.getIsOwned() && info.getOwner() != null && (info.getOwner().compareTo(this.hostContext.getHostName()) == 0); + if (info.getIsOwned() && info.getOwner() != null) { + uniqueOwners.add(info.getOwner()); + } else { + this.unownedCount++; + } + if (ownedByUs) { + ourLeasesCount++; + } else if (info.getIsOwned()) { + this.leasesOwnedByOthers.put(info.getPartitionId(), info); + } + } + int hostCount = uniqueOwners.size(); + int countPerHost = this.allLeaseStates.size() / hostCount; + this.desiredCount = isFirst ? 1 : countPerHost; + if (!isFirst && (this.unownedCount > 0) && (this.unownedCount < hostCount) && ((this.allLeaseStates.size() % hostCount) != 0)) { + // Distribute leftovers. + this.desiredCount++; + } + + ArrayList sortedHosts = new ArrayList(uniqueOwners); + Collections.sort(sortedHosts); + int hostOrdinal = -1; + int startingPoint = 0; + if (isFirst) { + // If the entire system is starting up, the list of hosts is probably not complete and we can't really + // compute a meaningful hostOrdinal. But we only want hostOrdinal to calculate startingPoint. Instead, + // just randomly select a startingPoint. + startingPoint = PartitionScanner.randomizer.nextInt(this.allLeaseStates.size()); + } else { + for (hostOrdinal = 0; hostOrdinal < sortedHosts.size(); hostOrdinal++) { + if (sortedHosts.get(hostOrdinal).compareTo(this.hostContext.getHostName()) == 0) { + break; + } + } + startingPoint = countPerHost * hostOrdinal; + } + // Rotate allLeaseStates + TRACE_LOGGER.debug(this.hostContext.withHost("Host ordinal: " + hostOrdinal + " Rotating leases to start at " + startingPoint)); + if (startingPoint != 0) { + ArrayList rotatedList = new ArrayList(this.allLeaseStates.size()); + for (int j = 0; j < this.allLeaseStates.size(); j++) { + rotatedList.add(this.allLeaseStates.get((j + startingPoint) % this.allLeaseStates.size())); + } + this.allLeaseStates = rotatedList; + } + + TRACE_LOGGER.debug(this.hostContext.withHost("Host count is " + hostCount + " Desired owned count is " + this.desiredCount)); + TRACE_LOGGER.debug(this.hostContext.withHost("ourLeasesCount " + ourLeasesCount + " leasesOwnedByOthers " + this.leasesOwnedByOthers.size() + + " unowned " + unownedCount)); + + return ourLeasesCount; + } + + // NONBLOCKING + // Returns a CompletableFuture as a convenience for the caller + private CompletableFuture> findExpiredLeases(int startAt, int endAt) { + final ArrayList expiredLeases = new ArrayList(); + TRACE_LOGGER.debug(this.hostContext.withHost("Finding expired leases from '" + this.allLeaseStates.get(startAt).getPartitionId() + "'[" + startAt + "] up to '" + + ((endAt < this.allLeaseStates.size()) ? this.allLeaseStates.get(endAt).getPartitionId() : "end") + "'[" + endAt + "]")); + + for (BaseLease info : this.allLeaseStates.subList(startAt, endAt)) { + if (!info.getIsOwned()) { + expiredLeases.add(info); + } + } + + TRACE_LOGGER.debug(this.hostContext.withHost("Found in range: " + expiredLeases.size())); + return CompletableFuture.completedFuture(expiredLeases); + } + + private CompletableFuture acquireExpiredInChunksParallel(int startAt, int needed) { + throwIfClosingOrClosed("PartitionScanner is shutting down"); + + CompletableFuture resultFuture = CompletableFuture.completedFuture(needed); + if (startAt < this.allLeaseStates.size()) { + TRACE_LOGGER.debug(this.hostContext.withHost("Examining chunk at '" + this.allLeaseStates.get(startAt).getPartitionId() + "'[" + startAt + "] need " + needed)); + } else { + TRACE_LOGGER.debug(this.hostContext.withHost("Examining chunk skipping, startAt is off end: " + startAt)); + } + + if ((needed > 0) && (this.unownedCount > 0) && (startAt < this.allLeaseStates.size())) { + final AtomicInteger runningNeeded = new AtomicInteger(needed); + final int endAt = Math.min(startAt + needed, this.allLeaseStates.size()); + + resultFuture = findExpiredLeases(startAt, endAt) + .thenComposeAsync((getThese) -> { + throwIfClosingOrClosed("PartitionScanner is shutting down"); + CompletableFuture acquireFuture = CompletableFuture.completedFuture(null); + if (getThese.size() > 0) { + ArrayList> getFutures = new ArrayList>(); + for (BaseLease info : getThese) { + throwIfClosingOrClosed("PartitionScanner is shutting down"); + final AcquisitionHolder holder = new AcquisitionHolder(); + CompletableFuture getOneFuture = this.hostContext.getLeaseManager().getLease(info.getPartitionId()) + .thenComposeAsync((lease) -> { + throwIfClosingOrClosed("PartitionScanner is shutting down"); + holder.setAcquiredLease(lease); + return this.hostContext.getLeaseManager().acquireLease(lease); + }, this.hostContext.getExecutor()) + .thenAcceptAsync((acquired) -> { + throwIfClosingOrClosed("PartitionScanner is shutting down"); + if (acquired) { + runningNeeded.decrementAndGet(); + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(holder.getAcquiredLease().getPartitionId(), "Acquired unowned/expired")); + if (this.leasesOwnedByOthers.containsKey(holder.getAcquiredLease().getPartitionId())) { + this.leasesOwnedByOthers.remove(holder.getAcquiredLease().getPartitionId()); + this.unownedCount--; + } + this.addPump.accept(holder.getAcquiredLease()); + } else { + this.leasesOwnedByOthers.put(holder.getAcquiredLease().getPartitionId(), holder.getAcquiredLease()); + } + }, this.hostContext.getExecutor()); + getFutures.add(getOneFuture); + } + CompletableFuture[] dummy = new CompletableFuture[getFutures.size()]; + acquireFuture = CompletableFuture.allOf(getFutures.toArray(dummy)); + } + return acquireFuture; + }, this.hostContext.getExecutor()) + .handleAsync((empty, e) -> { + // log/notify if exception occurred, then swallow exception and continue with next chunk + if ((e != null) && !(e instanceof ClosingException)) { + Exception notifyWith = (Exception) LoggingUtils.unwrapException(e, null); + TRACE_LOGGER.warn(this.hostContext.withHost("Failure getting/acquiring lease, continuing"), notifyWith); + this.hostContext.getEventProcessorOptions().notifyOfException(this.hostContext.getHostName(), notifyWith, + EventProcessorHostActionStrings.CHECKING_LEASES, ExceptionReceivedEventArgs.NO_ASSOCIATED_PARTITION); + } + return null; + }, this.hostContext.getExecutor()) + .thenComposeAsync((unused) -> acquireExpiredInChunksParallel(endAt, runningNeeded.get()), this.hostContext.getExecutor()); + } else { + TRACE_LOGGER.debug(this.hostContext.withHost("Short circuit: needed is 0, unowned is 0, or off end")); + } + + return resultFuture; + } + + // NONBLOCKING + private ArrayList findLeasesToSteal(int stealAsk) { + // Generate a map of hostnames and owned counts. + HashMap hostOwns = new HashMap(); + for (BaseLease info : this.leasesOwnedByOthers.values()) { + if (hostOwns.containsKey(info.getOwner())) { + int newCount = hostOwns.get(info.getOwner()) + 1; + hostOwns.put(info.getOwner(), newCount); + } else { + hostOwns.put(info.getOwner(), 1); + } + } + + // Extract hosts which own more than the desired count + ArrayList bigOwners = new ArrayList(); + for (Map.Entry pair : hostOwns.entrySet()) { + if (pair.getValue() > this.desiredCount) { + bigOwners.add(pair.getKey()); + TRACE_LOGGER.debug(this.hostContext.withHost("Big owner " + pair.getKey() + " has " + pair.getValue())); + } + } + + ArrayList stealInfos = new ArrayList(); + + if (bigOwners.size() > 0) { + // Randomly pick one of the big owners + String bigVictim = bigOwners.get(PartitionScanner.randomizer.nextInt(bigOwners.size())); + int victimExtra = hostOwns.get(bigVictim) - this.desiredCount - 1; + int stealCount = Math.min(victimExtra, stealAsk); + TRACE_LOGGER.debug(this.hostContext.withHost("Stealing " + stealCount + " from " + bigVictim)); + + // Grab stealCount partitions owned by bigVictim and return the infos. + for (BaseLease candidate : this.allLeaseStates) { + if (candidate.getOwner() != null && candidate.getOwner().compareTo(bigVictim) == 0) { + stealInfos.add(candidate); + if (stealInfos.size() >= stealCount) { + break; + } + } + } + } else { + TRACE_LOGGER.debug(this.hostContext.withHost("No big owners found, skipping steal")); + } + + return stealInfos; + } + + private CompletableFuture stealLeases(List stealThese) { + CompletableFuture allSteals = CompletableFuture.completedFuture(false); + + if (stealThese.size() > 0) { + ArrayList> steals = new ArrayList>(); + for (BaseLease info : stealThese) { + throwIfClosingOrClosed("PartitionScanner is shutting down"); + + final AcquisitionHolder holder = new AcquisitionHolder(); + CompletableFuture oneSteal = this.hostContext.getLeaseManager().getLease(info.getPartitionId()) + .thenComposeAsync((lease) -> { + throwIfClosingOrClosed("PartitionScanner is shutting down"); + holder.setAcquiredLease(lease); + return this.hostContext.getLeaseManager().acquireLease(lease); + }, this.hostContext.getExecutor()) + .thenAcceptAsync((acquired) -> { + throwIfClosingOrClosed("PartitionScanner is shutting down"); + if (acquired) { + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(holder.getAcquiredLease().getPartitionId(), "Stole lease")); + this.addPump.accept(holder.getAcquiredLease()); + } + }, this.hostContext.getExecutor()); + steals.add(oneSteal); + } + + CompletableFuture dummy[] = new CompletableFuture[steals.size()]; + allSteals = CompletableFuture.allOf(steals.toArray(dummy)).thenApplyAsync((empty) -> true, this.hostContext.getExecutor()); + } + + return allSteals; + } + + private class AcquisitionHolder { + private CompleteLease acquiredLease; + + void setAcquiredLease(CompleteLease l) { + this.acquiredLease = l; + } + + CompleteLease getAcquiredLease() { + return this.acquiredLease; + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/PumpManager.java b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/PumpManager.java new file mode 100644 index 000000000000..fbd4c7279d58 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/main/java/com/microsoft/azure/eventprocessorhost/PumpManager.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + + +class PumpManager extends Closable implements Consumer { + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(PumpManager.class); + protected final HostContext hostContext; + protected ConcurrentHashMap pumpStates; // protected for testability + + public PumpManager(HostContext hostContext, Closable parent) { + super(parent); + + this.hostContext = hostContext; + + this.pumpStates = new ConcurrentHashMap(); + } + + public void addPump(CompleteLease lease) { + if (getIsClosingOrClosed()) { + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(lease, "Shutting down, not creating new pump")); + return; + } + + PartitionPump capturedPump = this.pumpStates.get(lease.getPartitionId()); // CONCURRENTHASHTABLE + if (capturedPump != null) { + // There already is a pump. This should never happen and it's not harmless if it does. If we get here, + // it implies that the existing pump is a zombie which is not renewing its lease. + TRACE_LOGGER.error(this.hostContext.withHostAndPartition(lease, "throwing away zombie pump")); + // Shutdown should remove the pump from the hashmap, but we don't know what state this pump is in so + // remove it manually. ConcurrentHashMap specifies that removing an item that doesn't exist is a safe no-op. + this.pumpStates.remove(lease.getPartitionId()); + // Call shutdown to try to clean up, but do not wait. + capturedPump.shutdown(CloseReason.Shutdown); + } + + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(lease, "creating new pump")); + PartitionPump newPartitionPump = createNewPump(lease); + this.pumpStates.put(lease.getPartitionId(), newPartitionPump); + newPartitionPump.startPump(); + } + + // Callback used by pumps during pump shutdown. + @Override + public void accept(String partitionId) { + // These are fast, non-blocking actions. + this.pumpStates.remove(partitionId); + removingPumpTestHook(partitionId); + } + + // Separated out so that tests can override and substitute their own pump class. + protected PartitionPump createNewPump(CompleteLease lease) { + return new PartitionPump(this.hostContext, lease, this, this); + } + + public CompletableFuture removePump(String partitionId, final CloseReason reason) { + CompletableFuture retval = CompletableFuture.completedFuture(null); + PartitionPump capturedPump = this.pumpStates.get(partitionId); // CONCURRENTHASHTABLE + if (capturedPump != null) { + TRACE_LOGGER.info(this.hostContext.withHostAndPartition(partitionId, + "closing pump for reason " + reason.toString())); + retval = capturedPump.shutdown(reason); + } else { + // Shouldn't get here but not really harmful, so just trace. + TRACE_LOGGER.debug(this.hostContext.withHostAndPartition(partitionId, + "no pump found to remove for partition " + partitionId)); + } + return retval; + } + + public CompletableFuture removeAllPumps(CloseReason reason) { + setClosing(); + + CompletableFuture[] futures = new CompletableFuture[this.pumpStates.size()]; + int i = 0; + for (String partitionId : this.pumpStates.keySet()) { + futures[i++] = removePump(partitionId, reason); + } + + return CompletableFuture.allOf(futures).whenCompleteAsync((empty, e) -> { setClosed(); }, this.hostContext.getExecutor()); + } + + protected void removingPumpTestHook(String partitionId) { + // For test use. MUST BE FAST, NON-BLOCKING. + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/CheckpointManagerTest.java b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/CheckpointManagerTest.java new file mode 100644 index 000000000000..d4d7455314e6 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/CheckpointManagerTest.java @@ -0,0 +1,259 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import com.microsoft.azure.eventhubs.EventHubClient; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.UUID; + +import static org.junit.Assert.*; + +public class CheckpointManagerTest extends TestBase { + private ILeaseManager[] leaseManagers; + private ICheckpointManager[] checkpointManagers; + private EventProcessorHost[] hosts; + + @Test + public void singleManangerInMemoryCheckpointSmokeTest() throws Exception { + singleManagerCheckpointSmokeTest(false, 8); + } + + @Test + public void twoManagerInMemoryCheckpointSmokeTest() throws Exception { + twoManagerCheckpointSmokeTest(false, 8); + } + + @Test + public void singleManagerAzureCheckpointSmokeTest() throws Exception { + singleManagerCheckpointSmokeTest(true, 8); + } + + @Test + public void twoManagerAzureCheckpointSmokeTest() throws Exception { + twoManagerCheckpointSmokeTest(true, 8); + } + + public void singleManagerCheckpointSmokeTest(boolean useAzureStorage, int partitionCount) throws Exception { + this.leaseManagers = new ILeaseManager[1]; + this.checkpointManagers = new ICheckpointManager[1]; + this.hosts = new EventProcessorHost[1]; + setupOneManager(useAzureStorage, 0, "0", generateContainerName("0")); + + TestBase.logInfo("Check whether checkpoint store exists before create"); + boolean boolret = this.checkpointManagers[0].checkpointStoreExists().get(); + assertFalse("checkpoint store should not exist yet", boolret); + + TestBase.logInfo("Create checkpoint store"); + if (useAzureStorage) { + // Storage implementation optimizes checkpoint store creation to a no-op. Have to call lease manager + // to actually create. + this.leaseManagers[0].createLeaseStoreIfNotExists().get(); + } else { + this.checkpointManagers[0].createCheckpointStoreIfNotExists().get(); + } + + TestBase.logInfo("Check whether checkpoint store exists after create"); + boolret = this.checkpointManagers[0].checkpointStoreExists().get(); + assertTrue("checkpoint store should exist but does not", boolret); + + ArrayList partitionIds = new ArrayList(); + for (int i = 0; i < partitionCount; i++) { + partitionIds.add(String.valueOf(i)); + } + TestBase.logInfo("Create checkpoint holders for all partitions"); + if (useAzureStorage) { + // Storage implementation optimizes checkpoint creation to a no-op. Have to create the leases instead. + this.leaseManagers[0].createAllLeasesIfNotExists(partitionIds); + } else { + this.checkpointManagers[0].createAllCheckpointsIfNotExists(partitionIds); + } + + TestBase.logInfo("Trying to get checkpoints for all partitions"); + for (int i = 0; i < partitionCount; i++) { + Checkpoint blah = this.checkpointManagers[0].getCheckpoint(String.valueOf(i)).get(); + assertNull("unexpectedly successful retrieve checkpoint for " + i, blah); + } + + // AzureStorageCheckpointLeaseManager tries to pretend that checkpoints and leases are separate, but they really aren't. + // Because the checkpoint data is stored in the lease, updating the checkpoint means updating the lease, and it is + // necessary to hold the lease in order to update it. + HashMap leases = new HashMap(); + if (useAzureStorage) { + for (int i = 0; i < partitionCount; i++) { + CompleteLease l = this.leaseManagers[0].getLease(partitionIds.get(i)).get(); + assertTrue("null lease for " + partitionIds.get(i), l != null); + leases.put(l.getPartitionId(), l); + boolret = this.leaseManagers[0].acquireLease(l).get(); + assertTrue("failed to acquire lease for " + l.getPartitionId(), boolret); + } + } + + Checkpoint[] checkpoints = new Checkpoint[partitionCount]; + TestBase.logInfo("Creating checkpoints for all partitions"); + for (int i = 0; i < partitionCount; i++) { + // Arbitrary values, just checking that they are persisted + checkpoints[i] = new Checkpoint(String.valueOf(i)); + checkpoints[i].setOffset(String.valueOf(i * 234)); + checkpoints[i].setSequenceNumber(i + 77); + this.checkpointManagers[0].updateCheckpoint(leases.get(String.valueOf(i)), checkpoints[i]).get(); + } + + TestBase.logInfo("Getting checkpoints for all partitions and verifying"); + for (int i = 0; i < partitionCount; i++) { + Checkpoint blah = this.checkpointManagers[0].getCheckpoint(String.valueOf(i)).get(); + assertNotNull("failed to retrieve checkpoint for " + i, blah); + assertEquals("retrieved offset does not match written offset", blah.getOffset(), checkpoints[i].getOffset()); + assertEquals("retrieved seqno does not match written seqno", blah.getSequenceNumber(), checkpoints[i].getSequenceNumber()); + } + + // Have to release the leases before we can delete the store. + if (useAzureStorage) { + for (CompleteLease l : leases.values()) { + this.leaseManagers[0].releaseLease(l).get(); + } + } + + TestBase.logInfo("Cleaning up checkpoint store"); + this.checkpointManagers[0].deleteCheckpointStore().get(); + } + + public void twoManagerCheckpointSmokeTest(boolean useAzureStorage, int partitionCount) throws Exception { + this.leaseManagers = new ILeaseManager[2]; + this.checkpointManagers = new ICheckpointManager[2]; + this.hosts = new EventProcessorHost[2]; + String containerName = generateContainerName(null); + setupOneManager(useAzureStorage, 0, "twoCheckpoint", containerName); + setupOneManager(useAzureStorage, 1, "twoCheckpoint", containerName); + + TestBase.logInfo("Check whether checkpoint store exists before create"); + boolean boolret = this.checkpointManagers[0].checkpointStoreExists().get(); + assertFalse("checkpoint store should not exist yet", boolret); + + TestBase.logInfo("Second manager create checkpoint store"); + if (useAzureStorage) { + // Storage implementation optimizes checkpoint store creation to a no-op. Have to call lease manager + // to actually create. + this.leaseManagers[1].createLeaseStoreIfNotExists().get(); + } else { + this.checkpointManagers[1].createCheckpointStoreIfNotExists().get(); + } + + TestBase.logInfo("First mananger check whether checkpoint store exists after create"); + boolret = this.checkpointManagers[0].checkpointStoreExists().get(); + assertTrue("checkpoint store should exist but does not", boolret); + + ArrayList partitionIds = new ArrayList(); + for (int i = 0; i < partitionCount; i++) { + partitionIds.add(String.valueOf(i)); + } + TestBase.logInfo("Create checkpoint holders for all partitions"); + if (useAzureStorage) { + // Storage implementation optimizes checkpoint creation to a no-op. Have to create the leases instead. + this.leaseManagers[0].createAllLeasesIfNotExists(partitionIds); + } else { + this.checkpointManagers[0].createAllCheckpointsIfNotExists(partitionIds); + } + + TestBase.logInfo("Try to get each others checkpoints for all partitions"); + for (int i = 0; i < partitionCount; i++) { + Checkpoint blah = this.checkpointManagers[(i + 1) % 2].getCheckpoint(String.valueOf(i)).get(); + assertNull("unexpected successful retrieve checkpoint for " + i, blah); + } + + // AzureStorageCheckpointLeaseManager tries to pretend that checkpoints and leases are separate, but they really aren't. + // Because the checkpoint data is stored in the lease, updating the checkpoint means updating the lease, and it is + // necessary to hold the lease in order to update it. + HashMap leases = new HashMap(); + if (useAzureStorage) { + for (int i = 0; i < partitionCount; i++) { + CompleteLease l = this.leaseManagers[1].getLease(partitionIds.get(i)).get(); + leases.put(l.getPartitionId(), l); + boolret = this.leaseManagers[1].acquireLease(l).get(); + assertTrue("failed to acquire lease for " + l.getPartitionId(), boolret); + } + } + + Checkpoint[] checkpoints = new Checkpoint[partitionCount]; + TestBase.logInfo("Second manager update checkpoints for all partitions"); + for (int i = 0; i < partitionCount; i++) { + // Arbitrary values, just checking that they are persisted + checkpoints[i] = new Checkpoint(String.valueOf(i)); + checkpoints[i].setOffset(String.valueOf(i * 234)); + checkpoints[i].setSequenceNumber(i + 77); + this.checkpointManagers[1].updateCheckpoint(leases.get(String.valueOf(i)), checkpoints[i]).get(); + } + + TestBase.logInfo("First manager get and verify checkpoints for all partitions"); + for (int i = 0; i < partitionCount; i++) { + Checkpoint blah = this.checkpointManagers[0].getCheckpoint(String.valueOf(i)).get(); + assertNotNull("failed to retrieve checkpoint for " + i, blah); + assertEquals("retrieved offset does not match written offset", blah.getOffset(), checkpoints[i].getOffset()); + assertEquals("retrieved seqno does not match written seqno", blah.getSequenceNumber(), checkpoints[i].getSequenceNumber()); + } + + // Have to release the leases before we can delete the store. + if (useAzureStorage) { + for (CompleteLease l : leases.values()) { + assertNotNull("failed to retrieve lease", l); + this.leaseManagers[1].releaseLease(l).get(); + } + } + + TestBase.logInfo("Clean up checkpoint store"); + this.checkpointManagers[0].deleteCheckpointStore().get(); + } + + private String generateContainerName(String infix) { + StringBuilder containerName = new StringBuilder(64); + containerName.append("ckptmgrtest-"); + if (infix != null) { + containerName.append(infix); + containerName.append('-'); + } + containerName.append(UUID.randomUUID().toString()); + return containerName.toString(); + } + + private void setupOneManager(boolean useAzureStorage, int index, String suffix, String containerName) throws Exception { + ILeaseManager leaseMgr = null; + ICheckpointManager checkpointMgr = null; + + if (!useAzureStorage) { + leaseMgr = new InMemoryLeaseManager(); + checkpointMgr = new InMemoryCheckpointManager(); + } else { + TestBase.logInfo("Container name: " + containerName); + String azureStorageConnectionString = TestUtilities.getStorageConnectionString(); + AzureStorageCheckpointLeaseManager azMgr = new AzureStorageCheckpointLeaseManager(azureStorageConnectionString, containerName); + leaseMgr = azMgr; + checkpointMgr = azMgr; + } + + // Host name needs to be unique per host so use index. Event hub should be the same for all hosts in a test, so use the supplied suffix. + EventProcessorHost host = new EventProcessorHost("dummyHost" + String.valueOf(index), RealEventHubUtilities.syntacticallyCorrectDummyEventHubPath + suffix, + EventHubClient.DEFAULT_CONSUMER_GROUP_NAME, RealEventHubUtilities.syntacticallyCorrectDummyConnectionString + suffix, checkpointMgr, leaseMgr); + + + try { + if (!useAzureStorage) { + ((InMemoryLeaseManager) leaseMgr).initialize(host.getHostContext()); + ((InMemoryCheckpointManager) checkpointMgr).initialize(host.getHostContext()); + } else { + ((AzureStorageCheckpointLeaseManager) checkpointMgr).initialize(host.getHostContext()); + } + } catch (Exception e) { + TestBase.logError("Manager initializion failed"); + throw e; + } + + this.leaseManagers[index] = leaseMgr; + this.checkpointManagers[index] = checkpointMgr; + this.hosts[index] = host; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/DummyPump.java b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/DummyPump.java new file mode 100644 index 000000000000..543dda08cf01 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/DummyPump.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import com.microsoft.azure.eventhubs.EventData; + +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + + +class DummyPump extends PumpManager { + public DummyPump(HostContext hostContext, Closable parent) { + super(hostContext, parent); + } + + Iterable getPumpsList() { + return this.pumpStates.keySet(); + } + + @Override + protected PartitionPump createNewPump(CompleteLease lease) { + return new DummyPartitionPump(this.hostContext, lease, this, this); + } + + @Override + protected void removingPumpTestHook(String partitionId) { + TestBase.logInfo("Steal detected, host " + this.hostContext.getHostName() + " removing " + partitionId); + } + + + private class DummyPartitionPump extends PartitionPump implements Callable { + CompletableFuture blah = null; + + DummyPartitionPump(HostContext hostContext, CompleteLease lease, Closable parent, Consumer pumpManagerCallback) { + super(hostContext, lease, parent, pumpManagerCallback); + } + + @Override + CompletableFuture startPump() { + this.blah = new CompletableFuture(); + ((InMemoryLeaseManager) this.hostContext.getLeaseManager()).notifyOnSteal(this.hostContext.getHostName(), this.lease.getPartitionId(), this); + super.scheduleLeaseRenewer(); + return this.blah; + } + + @Override + protected void internalShutdown(CloseReason reason, Throwable e) { + super.cancelPendingOperations(); + if (e != null) { + this.blah.completeExceptionally(e); + } else { + this.blah.complete(null); + } + } + + @Override + CompletableFuture shutdown(CloseReason reason) { + internalShutdown(reason, null); + return this.blah; + } + + @Override + public void onReceive(Iterable events) { + } + + @Override + public void onError(Throwable error) { + } + + @Override + public Void call() { + if (this.blah != null) { + this.blah.completeExceptionally(new LeaseLostException(this.lease, "lease stolen")); + } + return null; + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/EPHConstructorTests.java b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/EPHConstructorTests.java new file mode 100644 index 000000000000..6d3bfd952189 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/EPHConstructorTests.java @@ -0,0 +1,282 @@ +package com.microsoft.azure.eventprocessorhost; + +import org.junit.Test; + +import static org.junit.Assert.fail; + +public class EPHConstructorTests extends TestBase { + @Test + public void conflictingEventHubPathsTest() throws Exception { + PerTestSettings settings = new PerTestSettings("ConflictingEventHubPaths"); + settings.inHasSenders = false; + settings.inEventHubDoesNotExist = true; + settings.inoutEPHConstructorArgs.dummyStorageConnection(); + + settings.inoutEPHConstructorArgs.setEHPath("thisisdifferentfromtheconnectionstring", PerTestSettings.EPHConstructorArgs.EH_PATH_OVERRIDE); + + try { + settings = testSetup(settings); + fail("No exception occurred"); + } catch (IllegalArgumentException e) { + if ((e.getMessage() != null) && (e.getMessage().compareTo("Provided EventHub path in eventHubPath parameter conflicts with the path in provided EventHub connection string") == 0)) { + TestBase.logInfo("Got expected exception"); + } else { + throw e; + } + } finally { + testFinish(settings, NO_CHECKS); + } + } + + @Test + public void missingEventHubPathTest() throws Exception { + PerTestSettings settings = new PerTestSettings("MissingEventHubPath"); + settings.inHasSenders = false; + settings.inEventHubDoesNotExist = true; + settings.inoutEPHConstructorArgs.dummyStorageConnection(); + + settings.inoutEPHConstructorArgs.setEHPath("", PerTestSettings.EPHConstructorArgs.EH_PATH_OVERRIDE_AND_REPLACE); + + try { + settings = testSetup(settings); + fail("No exception occurred"); + } catch (IllegalArgumentException e) { + if ((e.getMessage() != null) && (e.getMessage().compareTo("Provide EventHub entity path in either eventHubPath argument or in eventHubConnectionString") == 0)) { + TestBase.logInfo("Got expected exception"); + } else { + throw e; + } + } finally { + testFinish(settings, NO_CHECKS); + } + } + + @Test + public void nullHostNameTest() throws Exception { + PerTestSettings settings = new PerTestSettings("NullHostName"); + settings.inHasSenders = false; + settings.inEventHubDoesNotExist = true; + settings.inoutEPHConstructorArgs.dummyStorageConnection(); + + settings.inoutEPHConstructorArgs.setHostName(null); + + try { + settings = testSetup(settings); + fail("No exception occurred"); + } catch (IllegalArgumentException e) { + TestBase.logInfo("Got expected exception"); + } finally { + testFinish(settings, NO_CHECKS); + } + } + + @Test + public void emptyHostNameTest() throws Exception { + PerTestSettings settings = new PerTestSettings("EmptyHostName"); + settings.inHasSenders = false; + settings.inEventHubDoesNotExist = true; + settings.inoutEPHConstructorArgs.dummyStorageConnection(); + + settings.inoutEPHConstructorArgs.setHostName(""); + + try { + settings = testSetup(settings); + fail("No exception occurred"); + } catch (IllegalArgumentException e) { + TestBase.logInfo("Got expected exception"); + } finally { + testFinish(settings, NO_CHECKS); + } + } + + @Test + public void nullConsumerGroupNameTest() throws Exception { + PerTestSettings settings = new PerTestSettings("NullConsumerGroupName"); + settings.inHasSenders = false; + settings.inEventHubDoesNotExist = true; + settings.inoutEPHConstructorArgs.dummyStorageConnection(); + + settings.inoutEPHConstructorArgs.setConsumerGroupName(null); + + try { + settings = testSetup(settings); + fail("No exception occurred"); + } catch (IllegalArgumentException e) { + TestBase.logInfo("Got expected exception"); + } finally { + testFinish(settings, NO_CHECKS); + } + } + + @Test + public void emptyConsumerGroupNameTest() throws Exception { + PerTestSettings settings = new PerTestSettings("EmptyConsumerGroupName"); + settings.inHasSenders = false; + settings.inEventHubDoesNotExist = true; + settings.inoutEPHConstructorArgs.dummyStorageConnection(); + + settings.inoutEPHConstructorArgs.setConsumerGroupName(""); + + try { + settings = testSetup(settings); + fail("No exception occurred"); + } catch (IllegalArgumentException e) { + TestBase.logInfo("Got expected exception"); + } finally { + testFinish(settings, NO_CHECKS); + } + } + + @Test + public void nullEHConnectionStringTest() throws Exception { + PerTestSettings settings = new PerTestSettings("NullEHConnectionString"); + settings.inHasSenders = false; + settings.inEventHubDoesNotExist = true; + settings.inoutEPHConstructorArgs.dummyStorageConnection(); + + settings.inoutEPHConstructorArgs.setEHConnection(null); + + try { + settings = testSetup(settings); + fail("No exception occurred"); + } catch (IllegalArgumentException e) { + TestBase.logInfo("Got expected exception"); + } finally { + testFinish(settings, NO_CHECKS); + } + } + + @Test + public void emptyEHConnectionStringTest() throws Exception { + PerTestSettings settings = new PerTestSettings("EmptyEHConnectionString"); + settings.inHasSenders = false; + settings.inEventHubDoesNotExist = true; + settings.inoutEPHConstructorArgs.dummyStorageConnection(); + + settings.inoutEPHConstructorArgs.setEHConnection(""); + + try { + settings = testSetup(settings); + fail("No exception occurred"); + } catch (IllegalArgumentException e) { + TestBase.logInfo("Got expected exception"); + } finally { + testFinish(settings, NO_CHECKS); + } + } + + @Test + public void ehPathOnlySeparateTest() throws Exception { + PerTestSettings settings = new PerTestSettings("EHPathOnlySeparate"); + settings.inHasSenders = false; + settings.inEventHubDoesNotExist = true; + settings.inoutEPHConstructorArgs.dummyStorageConnection(); + + settings.inoutEPHConstructorArgs.removePathFromEHConnection(); + + try { + settings = testSetup(settings); + } finally { + testFinish(settings, NO_CHECKS); + } + } + + @Test + public void ehPathOnlyInConnStringTest() throws Exception { + PerTestSettings settings = new PerTestSettings("EHPathOnlyInConnString"); + settings.inHasSenders = false; + settings.inEventHubDoesNotExist = true; + settings.inoutEPHConstructorArgs.dummyStorageConnection(); + + settings.inoutEPHConstructorArgs.setEHPath("", PerTestSettings.EPHConstructorArgs.EH_PATH_OVERRIDE); + + try { + settings = testSetup(settings); + } finally { + testFinish(settings, NO_CHECKS); + } + } + + @Test + public void nullCheckpointManagerTest() throws Exception { + PerTestSettings settings = new PerTestSettings("NullCheckpointManager"); + settings.inHasSenders = false; + settings.inEventHubDoesNotExist = true; + settings.inoutEPHConstructorArgs.dummyStorageConnection(); + + settings.inoutEPHConstructorArgs.setCheckpointManager(null); + + try { + settings = testSetup(settings); + fail("No exception occurred"); + } catch (IllegalArgumentException e) { + TestBase.logInfo("Got expected exception"); + } finally { + testFinish(settings, NO_CHECKS); + } + } + + @Test + public void nullLeaseManagerTest() throws Exception { + PerTestSettings settings = new PerTestSettings("NullLeaseManager"); + settings.inHasSenders = false; + settings.inEventHubDoesNotExist = true; + settings.inoutEPHConstructorArgs.dummyStorageConnection(); + + settings.inoutEPHConstructorArgs.setLeaseManager(null); + + try { + settings = testSetup(settings); + fail("No exception occurred"); + } catch (IllegalArgumentException e) { + TestBase.logInfo("Got expected exception"); + } finally { + testFinish(settings, NO_CHECKS); + } + } + + @Test + public void nullStorageConnectionStringTest() throws Exception { + PerTestSettings settings = new PerTestSettings("NullStorageConnectionString"); + settings.inHasSenders = false; + settings.inEventHubDoesNotExist = true; + + settings.inoutEPHConstructorArgs.setStorageConnection(null); + try { + settings = testSetup(settings); + fail("No exception occurred"); + } catch (IllegalArgumentException e) { + TestBase.logInfo("Got expected exception"); + } finally { + testFinish(settings, NO_CHECKS); + } + } + + @Test + public void emptyStorageConnectionStringTest() throws Exception { + PerTestSettings settings = new PerTestSettings("EmptyStorageConnectionString"); + settings.inHasSenders = false; + settings.inEventHubDoesNotExist = true; + + settings.inoutEPHConstructorArgs.setStorageConnection(""); + + try { + settings = testSetup(settings); + fail("No exception occurred"); + } catch (IllegalArgumentException e) { + TestBase.logInfo("Got expected exception"); + } finally { + testFinish(settings, NO_CHECKS); + } + } + + // TODO + // @Test + // public void verifyStorageContainerNameTest() throws Exception + // Uses Storage APIs to check that the expected container has been created. + + // TODO + // @Test + // public void verifyStorageBlobPrefixTest() throws Exception + // Uses Storage APIs to check that the blobs have the expected prefix in their names +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/LeaseManagerTest.java b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/LeaseManagerTest.java new file mode 100644 index 000000000000..00f4d06c3e35 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/LeaseManagerTest.java @@ -0,0 +1,307 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import com.microsoft.azure.eventhubs.EventHubClient; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ExecutionException; + +import static org.junit.Assert.*; + +public class LeaseManagerTest extends TestBase { + private ILeaseManager[] leaseManagers; + private EventProcessorHost[] hosts; + + @Test + public void singleManangerInMemoryLeaseSmokeTest() throws Exception { + singleManagerLeaseSmokeTest(false, 8); + } + + @Test + public void singleManagerAzureLeaseSmokeTest() throws Exception { + singleManagerLeaseSmokeTest(true, 8); + } + + @Test + public void twoManagerInMemoryLeaseStealingTest() throws Exception { + twoManagerLeaseStealingTest(false); + } + + @Test + public void twoManangerAzureLeaseStealingTest() throws Exception { + twoManagerLeaseStealingTest(true); + } + + void singleManagerLeaseSmokeTest(boolean useAzureStorage, int partitionCount) throws Exception { + this.leaseManagers = new ILeaseManager[1]; + this.hosts = new EventProcessorHost[1]; + setupOneManager(useAzureStorage, 0, "0", generateContainerName("0")); + + TestBase.logInfo("Check whether lease store exists before create"); + Boolean boolret = this.leaseManagers[0].leaseStoreExists().get(); + assertFalse("lease store should not exist yet", boolret); + + TestBase.logInfo("Creating lease store"); + this.leaseManagers[0].createLeaseStoreIfNotExists().get(); + + TestBase.logInfo("Checking whether lease store exists after create"); + boolret = this.leaseManagers[0].leaseStoreExists().get(); + assertTrue("lease store should exist but does not", boolret); + + ArrayList partitionIds = new ArrayList(); + for (int i = 0; i < partitionCount; i++) { + partitionIds.add(String.valueOf(i)); + } + TestBase.logInfo("Creating leases for all partitions"); + this.leaseManagers[0].createAllLeasesIfNotExists(partitionIds).get(); // throws on failure + + CompleteLease[] leases = new CompleteLease[partitionCount]; + TestBase.logInfo("Getting leases for all partitions"); + for (int i = 0; i < partitionIds.size(); i++) { + leases[i] = this.leaseManagers[0].getLease(partitionIds.get(i)).get(); + assertNotNull("getLease returned null", leases[i]); + } + + TestBase.logInfo("Acquiring leases for all partitions"); + for (int i = 0; i < partitionCount; i++) { + if (useAzureStorage) { + TestBase.logInfo("Partition " + i + " state before: " + leases[i].getStateDebug()); + } + boolret = this.leaseManagers[0].acquireLease(leases[i]).get(); + assertTrue("failed to acquire lease for " + i, boolret); + if (useAzureStorage) { + TestBase.logInfo("Partition " + i + " state after: " + leases[i].getStateDebug()); + } + } + + Thread.sleep(5000); + + TestBase.logInfo("Getting state for all leases"); + List states = this.leaseManagers[0].getAllLeases().get(); // throws on failure + for (BaseLease s : states) { + TestBase.logInfo("Partition " + s.getPartitionId() + " owned by " + s.getOwner() + " isowned: " + s.getIsOwned()); + } + + TestBase.logInfo("Renewing leases for all partitions"); + for (int i = 0; i < partitionCount; i++) { + if (useAzureStorage) { + TestBase.logInfo("Partition " + i + " state before: " + leases[i].getStateDebug()); + } + boolret = this.leaseManagers[0].renewLease(leases[i]).get(); + assertTrue("failed to renew lease for " + i, boolret); + if (useAzureStorage) { + TestBase.logInfo("Partition " + i + " state after: " + leases[i].getStateDebug()); + } + } + + int x = 1; + while (getOneState(leases[0].getPartitionId(), this.leaseManagers[0]).getIsOwned()) { + Thread.sleep(5000); + TestBase.logInfo("Still waiting for lease on 0 to expire: " + (5 * x)); + assertFalse("lease 0 expiration is overdue", (5000 * x) > (this.leaseManagers[0].getLeaseDurationInMilliseconds() + 10000)); + for (int i = 1; i < partitionCount; i++) { + boolret = this.leaseManagers[0].renewLease(leases[i]).get(); + assertTrue("failed to renew lease for " + i, boolret); + } + x++; + } + + TestBase.logInfo("Updating lease 1"); + leases[1].setEpoch(5); + boolret = this.leaseManagers[0].updateLease(leases[1]).get(); + assertTrue("failed to update lease for 1", boolret); + CompleteLease retrievedLease = this.leaseManagers[0].getLease("1").get(); + assertNotNull("failed to get lease for 1", retrievedLease); + assertEquals("epoch was not persisted, expected " + leases[1].getEpoch() + " got " + retrievedLease.getEpoch(), leases[1].getEpoch(), retrievedLease.getEpoch()); + + // Release for 0 should not throw even though lease has expired -- it just won't do anything + TestBase.logInfo("Trying to release expired lease 0"); + this.leaseManagers[0].releaseLease(leases[0]).get(); + + // Renew for 0 succeeds even though it has expired. + // This is the behavior of AzureStorageCheckpointLeaseManager, which is dictated by the behavior of Azure Storage leases. + TestBase.logInfo("Renewing expired lease 0"); + boolret = this.leaseManagers[0].renewLease(leases[0]).get(); + assertTrue("renew lease on 0 failed unexpectedly", boolret); + + TestBase.logInfo("Releasing leases for all partitions"); + for (int i = 0; i < partitionCount; i++) { + if (useAzureStorage) { + TestBase.logInfo("Partition " + i + " state before: " + leases[i].getStateDebug()); + } + this.leaseManagers[0].releaseLease(leases[i]).get(); + if (useAzureStorage) { + TestBase.logInfo("Partition " + i + " state after: " + leases[i].getStateDebug()); + } + } + + TestBase.logInfo("Trying to acquire released lease 0"); + boolret = this.leaseManagers[0].acquireLease(leases[0]).get(); + assertTrue("failed to acquire previously released 0", boolret); + + TestBase.logInfo("Trying to release lease 0"); + this.leaseManagers[0].releaseLease(leases[0]).get(); + + TestBase.logInfo("Cleaning up lease store"); + this.leaseManagers[0].deleteLeaseStore().get(); + } + + + void twoManagerLeaseStealingTest(boolean useAzureStorage) throws Exception { + this.leaseManagers = new ILeaseManager[2]; + this.hosts = new EventProcessorHost[2]; + String containerName = generateContainerName(null); + setupOneManager(useAzureStorage, 0, "StealTest", containerName); + setupOneManager(useAzureStorage, 1, "StealTest", containerName); + + TestBase.logInfo("Check whether lease store exists before create"); + Boolean boolret = this.leaseManagers[0].leaseStoreExists().get(); + assertFalse("lease store should not exist yet", boolret); + + TestBase.logInfo("Creating lease store"); + this.leaseManagers[0].createLeaseStoreIfNotExists().get(); + + TestBase.logInfo("Check whether lease store exists after create"); + boolret = this.leaseManagers[0].leaseStoreExists().get(); + assertTrue("lease store should exist but does not", boolret); + + TestBase.logInfo("Check whether second manager can see lease store"); + boolret = this.leaseManagers[1].leaseStoreExists().get(); + assertTrue("second manager cannot see lease store", boolret); + + TestBase.logInfo("First manager creating lease for partition 0"); + ArrayList partitionIds = new ArrayList(); + partitionIds.add("0"); + this.leaseManagers[0].createAllLeasesIfNotExists(partitionIds).get(); + + TestBase.logInfo("Checking whether second manager can see lease 0"); + CompleteLease mgr2Lease = this.leaseManagers[1].getLease("0").get(); + assertNotNull("second manager cannot see lease for 0", mgr2Lease); + + TestBase.logInfo("Checking whether first manager can see lease 0"); + CompleteLease mgr1Lease = this.leaseManagers[0].getLease("0").get(); + assertNotNull("second manager cannot see lease for 0", mgr1Lease); + + TestBase.logInfo("First manager acquiring lease 0"); + boolret = this.leaseManagers[0].acquireLease(mgr1Lease).get(); + assertTrue("first manager failed acquiring lease for 0", boolret); + if (useAzureStorage) { + TestBase.logInfo("Lease token is " + ((AzureBlobLease)mgr1Lease).getToken()); + } + + int x = 0; + while (getOneState("0", this.leaseManagers[0]).getIsOwned()) { + assertFalse("lease 0 expiration is overdue", (5000 * x) > (this.leaseManagers[0].getLeaseDurationInMilliseconds() + 10000)); + Thread.sleep(5000); + TestBase.logInfo("Still waiting for lease on 0 to expire: " + (5 * ++x)); + } + + TestBase.logInfo("Second manager acquiring lease 0"); + boolret = this.leaseManagers[1].acquireLease(mgr2Lease).get(); + assertTrue("second manager failed acquiring expired lease for 0", boolret); + if (useAzureStorage) { + TestBase.logInfo("Lease token is " + ((AzureBlobLease)mgr2Lease).getToken()); + } + + TestBase.logInfo("First manager trying to renew lease 0"); + boolret = this.leaseManagers[0].renewLease(mgr1Lease).get(); + assertFalse("first manager unexpected success renewing lease for 0", boolret); + + TestBase.logInfo("First manager getting lease 0"); + mgr1Lease = this.leaseManagers[0].getLease("0").get(); + assertNotNull("first manager cannot see lease for 0", mgr1Lease); + + TestBase.logInfo("First manager stealing lease 0"); + boolret = this.leaseManagers[0].acquireLease(mgr1Lease).get(); + assertTrue("first manager failed stealing lease 0", boolret); + if (useAzureStorage) { + TestBase.logInfo("Lease token is " + ((AzureBlobLease)mgr1Lease).getToken()); + } + + TestBase.logInfo("Second mananger getting lease 0"); + mgr2Lease = this.leaseManagers[1].getLease("0").get(); + assertNotNull("second manager cannot see lease for 0", mgr2Lease); + + TestBase.logInfo("Second mananger stealing lease 0"); + boolret = this.leaseManagers[1].acquireLease(mgr2Lease).get(); + assertTrue("second manager failed stealing lease 0", boolret); + if (useAzureStorage) { + TestBase.logInfo("Lease token is " + ((AzureBlobLease)mgr2Lease).getToken()); + } + + TestBase.logInfo("Second mananger releasing lease 0"); + this.leaseManagers[1].releaseLease(mgr2Lease).get(); + + // Won't do anything because first manager didn't own lease 0, but shouldn't throw either + TestBase.logInfo("First mananger tyring to release lease 0"); + this.leaseManagers[0].releaseLease(mgr1Lease).get(); + + TestBase.logInfo("Cleaning up lease store"); + this.leaseManagers[1].deleteLeaseStore().get(); + } + + private String generateContainerName(String infix) { + StringBuilder containerName = new StringBuilder(64); + containerName.append("leasemgrtest-"); + if (infix != null) { + containerName.append(infix); + containerName.append('-'); + } + containerName.append(UUID.randomUUID().toString()); + return containerName.toString(); + } + + private BaseLease getOneState(String partitionId, ILeaseManager leaseMgr) throws InterruptedException, ExecutionException { + List states = leaseMgr.getAllLeases().get(); + BaseLease returnState = null; + for (BaseLease s : states) { + if (s.getPartitionId().compareTo(partitionId) == 0) { + returnState = s; + break; + } + } + return returnState; + } + + private void setupOneManager(boolean useAzureStorage, int index, String suffix, String containerName) throws Exception { + ILeaseManager leaseMgr = null; + ICheckpointManager checkpointMgr = null; + + if (!useAzureStorage) { + leaseMgr = new InMemoryLeaseManager(); + checkpointMgr = new InMemoryCheckpointManager(); + } else { + TestBase.logInfo("Container name: " + containerName); + String azureStorageConnectionString = TestUtilities.getStorageConnectionString(); + AzureStorageCheckpointLeaseManager azMgr = new AzureStorageCheckpointLeaseManager(azureStorageConnectionString, containerName); + leaseMgr = azMgr; + checkpointMgr = azMgr; + } + + // Host name needs to be unique per host so use index. Event hub should be the same for all hosts in a test, so use the supplied suffix. + EventProcessorHost host = new EventProcessorHost("dummyHost" + String.valueOf(index), RealEventHubUtilities.syntacticallyCorrectDummyEventHubPath + suffix, + EventHubClient.DEFAULT_CONSUMER_GROUP_NAME, RealEventHubUtilities.syntacticallyCorrectDummyConnectionString + suffix, checkpointMgr, leaseMgr); + + try { + if (!useAzureStorage) { + ((InMemoryLeaseManager) leaseMgr).initialize(host.getHostContext()); + ((InMemoryCheckpointManager) checkpointMgr).initialize(host.getHostContext()); + } else { + ((AzureStorageCheckpointLeaseManager) leaseMgr).initialize(host.getHostContext()); + } + } catch (Exception e) { + TestBase.logError("Manager initializion failed"); + throw e; + } + + this.leaseManagers[index] = leaseMgr; + this.hosts[index] = host; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/PartitionManagerTest.java b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/PartitionManagerTest.java new file mode 100644 index 000000000000..d604715e91e8 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/PartitionManagerTest.java @@ -0,0 +1,440 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import com.microsoft.azure.eventhubs.EventHubClient; + +import java.util.ArrayList; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +import org.junit.Test; +import static org.junit.Assert.assertTrue; + +public class PartitionManagerTest extends TestBase { + private ILeaseManager[] leaseManagers; + private ICheckpointManager[] checkpointManagers; + private EventProcessorHost[] hosts; + private TestPartitionManager[] partitionManagers; + private int partitionCount; + private boolean[] running; + + private int countOfChecks; + private int desiredDistributionDetected; + + private boolean keepGoing; + private boolean expectEqualDistribution; + private int overrideHostCount = -1; + private int maxChecks; + private boolean shuttingDown; + + @Test + public void partitionBalancingExactMultipleTest() throws Exception { + setup(2, 4, 0, 0); // two hosts, four partitions, no latency, default threadpool + this.countOfChecks = 0; + this.desiredDistributionDetected = 0; + this.keepGoing = true; + this.expectEqualDistribution = true; + this.maxChecks = 20; + startManagers(); + + // Poll until checkPartitionDistribution() declares that it's time to stop. + while (this.keepGoing) { + try { + Thread.sleep(15000); + } catch (InterruptedException e) { + TestBase.logError("Sleep interrupted, emergency bail"); + Thread.currentThread().interrupt(); + throw e; + } + } + + stopManagers(); + + assertTrue("Desired distribution never reached or was not stable", this.desiredDistributionDetected >= this.partitionManagers.length); + + this.leaseManagers[0].deleteLeaseStore().get(); + this.checkpointManagers[0].deleteCheckpointStore().get(); + } + + @Test + public void partitionBalancingUnevenTest() throws Exception { + setup(5, 16, 250, 0); // five hosts, sixteen partitions, 250ms latency, default threadpool + this.countOfChecks = 0; + this.desiredDistributionDetected = 0; + this.keepGoing = true; + this.expectEqualDistribution = false; + this.maxChecks = 35; + startManagers(); + + // Poll until checkPartitionDistribution() declares that it's time to stop. + while (this.keepGoing) { + try { + Thread.sleep(15000); + } catch (InterruptedException e) { + TestBase.logError("Sleep interrupted, emergency bail"); + Thread.currentThread().interrupt(); + throw e; + } + } + + stopManagers(); + + assertTrue("Desired distribution never reached or was not stable", this.desiredDistributionDetected >= this.partitionManagers.length); + + this.leaseManagers[0].deleteLeaseStore().get(); + this.checkpointManagers[0].deleteCheckpointStore().get(); + } + + + @Test + public void partitionBalancingHugeTest() throws Exception { + setup(10, 201, 250, 20); // ten hosts, 201 partitions, 250ms latency, threadpool with 20 threads + this.countOfChecks = 0; + this.desiredDistributionDetected = 0; + this.keepGoing = true; + this.expectEqualDistribution = false; + this.maxChecks = 99; + startManagers(); + + // Poll until checkPartitionDistribution() declares that it's time to stop. + while (this.keepGoing) { + try { + Thread.sleep(15000); + } catch (InterruptedException e) { + TestBase.logError("Sleep interrupted, emergency bail"); + Thread.currentThread().interrupt(); + throw e; + } + } + + stopManagers(); + + assertTrue("Desired distribution never reached or was not stable", this.desiredDistributionDetected >= this.partitionManagers.length); + + this.leaseManagers[0].deleteLeaseStore().get(); + this.checkpointManagers[0].deleteCheckpointStore().get(); + } + + @Test + public void partitionRebalancingTest() throws Exception { + setup(3, 8, 0, 8); // three hosts, eight partitions, 250ms latency, default threadpool + + // + // Start two hosts of three, expect 4/4/0. + // + this.countOfChecks = 0; + this.desiredDistributionDetected = 0; + this.keepGoing = true; + this.expectEqualDistribution = true; // only going to start two of the three hosts + this.maxChecks = 20; + this.overrideHostCount = 2; + startManagers(2); + while (this.keepGoing) { + try { + Thread.sleep(15000); + } catch (InterruptedException e) { + TestBase.logError("Sleep interrupted, emergency bail"); + Thread.currentThread().interrupt(); + throw e; + } + } + assertTrue("Desired distribution 4/4/0 never reached or was not stable", this.desiredDistributionDetected >= this.partitionManagers.length); + + // + // Start up the third host and wait for rebalance + // + this.countOfChecks = 0; + this.desiredDistributionDetected = 0; + this.keepGoing = true; + this.expectEqualDistribution = false; + this.maxChecks = 30; + this.overrideHostCount = 3; + startSingleManager(2); + while (this.keepGoing) { + try { + Thread.sleep(15000); + } catch (InterruptedException e) { + TestBase.logError("Sleep interrupted, emergency bail"); + Thread.currentThread().interrupt(); + throw e; + } + } + assertTrue("Desired distribution never reached or was not stable", this.desiredDistributionDetected >= this.partitionManagers.length); + + // + // Now stop host 0 and wait for 0/4/4 + // + this.countOfChecks = 0; + this.desiredDistributionDetected = 0; + this.keepGoing = true; + this.expectEqualDistribution = true; // only two of the three hosts running + this.maxChecks = 20; + this.overrideHostCount = 2; + stopSingleManager(0); + while (this.keepGoing) { + try { + Thread.sleep(15000); + } catch (InterruptedException e) { + TestBase.logError("Sleep interrupted, emergency bail"); + Thread.currentThread().interrupt(); + throw e; + } + } + assertTrue("Desired distribution 4/4/0 never reached or was not stable", this.desiredDistributionDetected >= this.partitionManagers.length); + + stopManagers(); + + this.leaseManagers[1].deleteLeaseStore().get(); + this.checkpointManagers[1].deleteCheckpointStore().get(); + } + + @Test + public void partitionBalancingTooManyHostsTest() throws Exception { + setup(10, 4, 0, 8); // ten hosts, four partitions + this.countOfChecks = 0; + this.desiredDistributionDetected = 0; + this.keepGoing = true; + this.expectEqualDistribution = false; + this.maxChecks = 20; + startManagers(); + + // Poll until checkPartitionDistribution() declares that it's time to stop. + while (this.keepGoing) { + try { + Thread.sleep(15000); + } catch (InterruptedException e) { + TestBase.logError("Sleep interrupted, emergency bail"); + Thread.currentThread().interrupt(); + throw e; + } + } + + stopManagers(); + + assertTrue("Desired distribution never reached or was not stable", this.desiredDistributionDetected >= this.partitionManagers.length); + + this.leaseManagers[0].deleteLeaseStore().get(); + this.checkpointManagers[0].deleteCheckpointStore().get(); + } + + synchronized void checkPartitionDistribution() { + if (this.shuttingDown) { + return; + } + + TestBase.logInfo("Checking partition distribution"); + int[] countsPerHost = new int[this.partitionManagers.length]; + int totalCounts = 0; + int runningCount = 0; + for (int i = 0; i < this.partitionManagers.length; i++) { + StringBuilder blah = new StringBuilder(); + blah.append("\tHost "); + blah.append(this.hosts[i].getHostContext().getHostName()); + blah.append(" has "); + countsPerHost[i] = 0; + for (String id : this.partitionManagers[i].getOwnedPartitions()) { + blah.append(id); + blah.append(", "); + countsPerHost[i]++; + totalCounts++; + } + TestBase.logInfo(blah.toString()); + if (this.running[i]) { + runningCount++; + } + } + + if (totalCounts != this.partitionCount) { + TestBase.logInfo("Unowned leases, " + totalCounts + " owned versus " + this.partitionCount + " partitions, skipping checks"); + return; + } + if (this.overrideHostCount > 0) { + if (runningCount != this.overrideHostCount) { + TestBase.logInfo("Hosts not running, " + this.overrideHostCount + " expected versus " + runningCount + " found, skipping checks"); + return; + } + } else if (runningCount != this.partitionManagers.length) { + TestBase.logInfo("Hosts not running, " + this.partitionManagers.length + " expected versus " + runningCount + " found, skipping checks"); + return; + } + + boolean desired = true; + int highest = Integer.MIN_VALUE; + int lowest = Integer.MAX_VALUE; + for (int i = 0; i < countsPerHost.length; i++) { + if (!this.running[i]) { + // Skip + } else { + highest = Integer.max(highest, countsPerHost[i]); + lowest = Integer.min(lowest, countsPerHost[i]); + } + } + TestBase.logInfo("Check " + this.countOfChecks + " Highest " + highest + " Lowest " + lowest + " Descnt " + this.desiredDistributionDetected); + if (this.expectEqualDistribution) { + // All hosts should have exactly equal counts, so highest == lowest + desired = (highest == lowest); + } else { + // An equal distribution isn't possible, but the maximum difference between counts should be 1. + // Max(counts[]) - Min(counts[]) == 1 + desired = ((highest - lowest) == 1); + } + if (desired) { + TestBase.logInfo("Evenest distribution detected"); + this.desiredDistributionDetected++; + if (this.desiredDistributionDetected > this.partitionManagers.length) { + // Every partition manager has looked at the current distribution and + // it has not changed. The algorithm is stable once it reaches the desired state. + // No need to keep iterating. + TestBase.logInfo("Desired distribution is stable"); + this.keepGoing = false; + } + } else { + if ((this.desiredDistributionDetected > 0) && !this.shuttingDown) { + // If we have detected the desired distribution on previous iterations + // but not on this one, then the algorithm is unstable. Bail and fail. + TestBase.logInfo("Desired distribution was not stable"); + this.keepGoing = false; + } + } + + this.countOfChecks++; + if (this.countOfChecks > this.maxChecks) { + // Ran out of iterations without reaching the desired distribution. Bail and fail. + this.keepGoing = false; + } + } + + private void setup(int hostCount, int partitionCount, long latency, int threads) { + // PartitionManager tests are all long. Skip if running automated (maven, appveyor, etc.) + skipIfAutomated(); + + this.leaseManagers = new ILeaseManager[hostCount]; + this.checkpointManagers = new ICheckpointManager[hostCount]; + this.hosts = new EventProcessorHost[hostCount]; + this.partitionManagers = new TestPartitionManager[hostCount]; + this.partitionCount = partitionCount; + this.running = new boolean[hostCount]; + + for (int i = 0; i < hostCount; i++) { + InMemoryLeaseManager lm = new InMemoryLeaseManager(); + InMemoryCheckpointManager cm = new InMemoryCheckpointManager(); + + // In order to test hosts competing for partitions, each host must have a unique name, but they must share the + // target eventhub/consumer group. + ScheduledExecutorService threadpool = null; + if (threads > 0) { + threadpool = Executors.newScheduledThreadPool(threads); + } + this.hosts[i] = new EventProcessorHost("dummyHost" + String.valueOf(i), "NOTREAL", EventHubClient.DEFAULT_CONSUMER_GROUP_NAME, + RealEventHubUtilities.syntacticallyCorrectDummyConnectionString, cm, lm, threadpool, null); + + lm.initialize(this.hosts[i].getHostContext()); + lm.setLatency(latency); + this.leaseManagers[i] = lm; + cm.initialize(this.hosts[i].getHostContext()); + this.checkpointManagers[i] = cm; + this.running[i] = false; + + this.partitionManagers[i] = new TestPartitionManager(this.hosts[i].getHostContext(), partitionCount); + this.hosts[i].setPartitionManager(this.partitionManagers[i]); + this.hosts[i].getHostContext().setEventProcessorOptions(EventProcessorOptions.getDefaultOptions()); + // Quick lease expiration helps with some tests. Because we're using InMemoryLeaseManager, don't + // have to worry about storage latency, all lease operations are guaranteed to be fast. + PartitionManagerOptions opts = new PartitionManagerOptions(); + opts.setLeaseDurationInSeconds(15); + //opts.setStartupScanDelayInSeconds(17); + //opts.setSlowScanIntervalInSeconds(15); + this.hosts[i].setPartitionManagerOptions(opts); + } + } + + private void startManagers() throws Exception { + startManagers(this.partitionManagers.length); + } + + private void startManagers(int maxIndex) throws Exception { + this.shuttingDown = false; + for (int i = 0; i < maxIndex; i++) { + startSingleManager(i); + } + } + + private void startSingleManager(int index) throws Exception { + try { + this.partitionManagers[index].initialize().get(); + this.running[index] = true; + } catch (Exception e) { + TestBase.logError("TASK START FAILED " + e.toString() + " " + e.getMessage()); + throw e; + } + } + + private void stopManagers() throws InterruptedException, ExecutionException { + TestBase.logInfo("SHUTTING DOWN"); + this.shuttingDown = true; + for (int i = 0; i < this.partitionManagers.length; i++) { + if (this.running[i]) { + this.partitionManagers[i].stopPartitions().get(); + TestBase.logInfo("Host " + i + " stopped"); + } + } + } + + private void stopSingleManager(int index) throws InterruptedException, ExecutionException { + if (this.running[index]) { + this.partitionManagers[index].stopPartitions().get(); + TestBase.logInfo("Host " + index + " stopped"); + this.running[index] = false; + } + } + + private class TestPartitionManager extends PartitionManager { + private int partitionCount; + + TestPartitionManager(HostContext hostContext, int partitionCount) { + super(hostContext); + this.partitionCount = partitionCount; + } + + Iterable getOwnedPartitions() { + Iterable retval = null; + if (this.pumpManager != null) { + retval = ((DummyPump) this.pumpManager).getPumpsList(); + } else { + // If the manager isn't started, return an empty list. + retval = new ArrayList(); + } + return retval; + } + + @Override + CompletableFuture cachePartitionIds() { + this.partitionIds = new String[this.partitionCount]; + for (int i = 0; i < this.partitionCount; i++) { + this.partitionIds[i] = String.valueOf(i); + } + return CompletableFuture.completedFuture(null); + } + + @Override + PumpManager createPumpTestHook() { + return new DummyPump(this.hostContext, this); + } + + @Override + void onInitializeCompleteTestHook() { + TestBase.logInfo("PartitionManager for host " + this.hostContext.getHostName() + " initialized stores OK"); + } + + @Override + void onPartitionCheckCompleteTestHook() { + PartitionManagerTest.this.checkPartitionDistribution(); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/PerTestSettings.java b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/PerTestSettings.java new file mode 100644 index 000000000000..3f49b3bbabca --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/PerTestSettings.java @@ -0,0 +1,203 @@ +package com.microsoft.azure.eventprocessorhost; + +import java.util.ArrayList; +import java.util.concurrent.ScheduledExecutorService; + +public class PerTestSettings { + // In-out properties: may be set before test setup and then changed by setup. + final EPHConstructorArgs inoutEPHConstructorArgs; + + // Output properties: any value set before test setup is ignored. The real value is + // established during test setup. + RealEventHubUtilities outUtils; + String outTelltale; + ArrayList outPartitionIds; + PrefabGeneralErrorHandler outGeneralErrorHandler; + PrefabProcessorFactory outProcessorFactory; + EventProcessorHost outHost; + + // Properties which are inputs to test setup. Constructor sets up defaults, except for hostName. + private String inDefaultHostName; + EventProcessorOptions inOptions; // can be null + PrefabEventProcessor.CheckpointChoices inDoCheckpoint; + boolean inEventHubDoesNotExist; // Prevents test code from doing certain checks that would fail on nonexistence before reaching product code. + boolean inSkipIfNoEventHubConnectionString; // Requires valid connection string even though event hub may not exist. + boolean inTelltaleOnTimeout; // Generates an empty telltale string, which causes PrefabEventProcessor to trigger telltale on timeout. + boolean inHasSenders; + + PerTestSettings(String defaultHostName) { + this.inDefaultHostName = defaultHostName; + this.inOptions = EventProcessorOptions.getDefaultOptions(); + this.inDoCheckpoint = PrefabEventProcessor.CheckpointChoices.CKP_NONE; + this.inEventHubDoesNotExist = false; + this.inSkipIfNoEventHubConnectionString = false; + this.inTelltaleOnTimeout = false; + this.inHasSenders = true; + + this.inoutEPHConstructorArgs = new EPHConstructorArgs(); + } + + String getDefaultHostName() { + return this.inDefaultHostName; + } + + class EPHConstructorArgs { + static final int HOST_OVERRIDE = 0x0001; + static final int EH_PATH_OVERRIDE = 0x0002; + static final int EH_PATH_REPLACE_IN_CONNECTION = 0x0004; + static final int EH_PATH_OVERRIDE_AND_REPLACE = EH_PATH_OVERRIDE | EH_PATH_REPLACE_IN_CONNECTION; + static final int CONSUMER_GROUP_OVERRIDE = 0x0008; + static final int EH_CONNECTION_OVERRIDE = 0x0010; + static final int EH_CONNECTION_REMOVE_PATH = 0x0020; + static final int STORAGE_CONNECTION_OVERRIDE = 0x0040; + static final int STORAGE_CONTAINER_OVERRIDE = 0x0080; + static final int STORAGE_BLOB_PREFIX_OVERRIDE = 0x0100; + static final int EXECUTOR_OVERRIDE = 0x0200; + static final int CHECKPOINT_MANAGER_OVERRIDE = 0x0400; + static final int LEASE_MANAGER_OVERRIDE = 0x0800; + static final int EXPLICIT_MANAGER = CHECKPOINT_MANAGER_OVERRIDE | LEASE_MANAGER_OVERRIDE; + static final int TELLTALE_ON_TIMEOUT = 0x1000; + + private int flags; + + private String hostName; + private String ehPath; + private String consumerGroupName; + private String ehConnection; + private String storageConnection; + private String storageContainerName; + private String storageBlobPrefix; + private ScheduledExecutorService executor; + private ICheckpointManager checkpointManager; + private ILeaseManager leaseManager; + + EPHConstructorArgs() { + this.flags = 0; + + this.hostName = null; + this.ehPath = null; + this.consumerGroupName = null; + this.ehConnection = null; + this.storageConnection = null; + this.storageContainerName = null; + this.storageBlobPrefix = null; + this.executor = null; + this.checkpointManager = null; + this.leaseManager = null; + } + + int getFlags() { + return this.flags; + } + + boolean isFlagSet(int testFlag) { + return ((this.flags & testFlag) != 0); + } + + String getHostName() { + return this.hostName; + } + + void setHostName(String hostName) { + this.hostName = hostName; + this.flags |= HOST_OVERRIDE; + } + + void setEHPath(String ehPath, int flags) { + this.ehPath = ehPath; + this.flags |= (flags & EH_PATH_OVERRIDE_AND_REPLACE); + } + + String getEHPath() { + return this.ehPath; + } + + String getConsumerGroupName() { + return this.consumerGroupName; + } + + void setConsumerGroupName(String consumerGroupName) { + this.consumerGroupName = consumerGroupName; + this.flags |= CONSUMER_GROUP_OVERRIDE; + } + + void removePathFromEHConnection() { + this.flags |= EH_CONNECTION_REMOVE_PATH; + } + + String getEHConnection() { + return this.ehConnection; + } + + void setEHConnection(String ehConnection) { + this.ehConnection = ehConnection; + this.flags |= EH_CONNECTION_OVERRIDE; + } + + String getStorageConnection() { + return this.storageConnection; + } + + void setStorageConnection(String storageConnection) { + this.storageConnection = storageConnection; + this.flags |= STORAGE_CONNECTION_OVERRIDE; + } + + void dummyStorageConnection() { + setStorageConnection("DefaultEndpointsProtocol=https;AccountName=doesnotexist;AccountKey=dGhpcyBpcyBub3QgYSB2YWxpZCBrZXkgYnV0IGl0IGRvZXMgaGF2ZSA2MCBjaGFyYWN0ZXJzLjEyMzQ1Njc4OTAK;EndpointSuffix=core.windows.net"); + } + + void setDefaultStorageContainerName(String defaultStorageContainerName) { + this.storageContainerName = defaultStorageContainerName; + } + + String getStorageContainerName() { + return this.storageContainerName; + } + + void setStorageContainerName(String storageContainerName) { + this.storageContainerName = storageContainerName; + this.flags |= STORAGE_CONTAINER_OVERRIDE; + } + + String getStorageBlobPrefix() { + return this.storageBlobPrefix; + } + + void setStorageBlobPrefix(String storageBlobPrefix) { + this.storageBlobPrefix = storageBlobPrefix; + this.flags |= STORAGE_BLOB_PREFIX_OVERRIDE; + } + + ScheduledExecutorService getExecutor() { + return this.executor; + } + + void setExecutor(ScheduledExecutorService executor) { + this.executor = executor; + this.flags |= EXECUTOR_OVERRIDE; + } + + boolean useExplicitManagers() { + return ((this.flags & EXPLICIT_MANAGER) != 0); + } + + void setCheckpointManager(ICheckpointManager checkpointManager) { + this.checkpointManager = checkpointManager; + this.flags |= CHECKPOINT_MANAGER_OVERRIDE; + } + + ICheckpointManager getCheckpointMananger() { + return this.checkpointManager; + } + + ILeaseManager getLeaseManager() { + return this.leaseManager; + } + + void setLeaseManager(ILeaseManager leaseManager) { + this.leaseManager = leaseManager; + this.flags |= LEASE_MANAGER_OVERRIDE; + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/PrefabEventProcessor.java b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/PrefabEventProcessor.java new file mode 100644 index 000000000000..c662e5e35100 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/PrefabEventProcessor.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import com.microsoft.azure.eventhubs.EventData; + +import java.util.Arrays; + +public class PrefabEventProcessor implements IEventProcessor { + private PrefabProcessorFactory factory; + + ; + private byte[] telltaleBytes; + private CheckpointChoices doCheckpoint; + private boolean doMarker; + private boolean logEveryEvent; + private boolean telltaleOnTimeout; + private int eventCount = 0; + + PrefabEventProcessor(PrefabProcessorFactory factory, String telltale, CheckpointChoices doCheckpoint, boolean doMarker, boolean logEveryEvent) { + this.factory = factory; + this.telltaleBytes = telltale.getBytes(); + this.doCheckpoint = doCheckpoint; + this.doMarker = doMarker; + this.logEveryEvent = logEveryEvent; + this.telltaleOnTimeout = telltale.isEmpty(); + } + + @Override + public void onOpen(PartitionContext context) throws Exception { + TestBase.logInfo(context.getOwner() + " opening " + context.getPartitionId()); + } + + @Override + public void onClose(PartitionContext context, CloseReason reason) throws Exception { + TestBase.logInfo(context.getOwner() + " closing " + context.getPartitionId()); + } + + @Override + public void onEvents(PartitionContext context, Iterable events) throws Exception { + int batchSize = 0; + EventData lastEvent = null; + int baseline = this.eventCount; + if (events != null && events.iterator().hasNext()) { + this.factory.setOnEventsContext(context); + + for (EventData event : events) { + this.eventCount++; + batchSize++; + /* + if (((this.eventCount % 10) == 0) && this.doMarker) { + TestBase.logInfo("P" + context.getPartitionId() + ": " + this.eventCount); + } + */ + if (this.logEveryEvent) { + TestBase.logInfo("(" + context.getOwner() + ") P" + context.getPartitionId() + " " + new String(event.getBytes()) + " @ " + event.getSystemProperties().getOffset()); + } + if (Arrays.equals(event.getBytes(), this.telltaleBytes)) { + this.factory.setTelltaleFound(context.getPartitionId()); + } + lastEvent = event; + } + } + if (batchSize == 0) { + if (this.telltaleOnTimeout) { + TestBase.logInfo("P" + context.getPartitionId() + " got expected timeout"); + this.factory.setTelltaleFound(context.getPartitionId()); + } else { + TestBase.logError("P" + context.getPartitionId() + " got UNEXPECTED timeout"); + this.factory.putError("P" + context.getPartitionId() + " got UNEXPECTED timeout"); + } + } + this.factory.addBatch(batchSize); + if (this.doMarker) { + TestBase.logInfo("(" + context.getOwner() + ") P" + context.getPartitionId() + " total " + this.eventCount + "(" + (this.eventCount - baseline) + ")"); + } + switch (doCheckpoint) { + case CKP_NONE: + break; + + case CKP_EXPLICIT: + context.checkpoint(lastEvent).get(); + TestBase.logInfo("P" + context.getPartitionId() + " checkpointed at " + lastEvent.getSystemProperties().getOffset()); + break; + + case CKP_NOARGS: + context.checkpoint().get(); + TestBase.logInfo("P" + context.getPartitionId() + " checkpointed without arguments"); + break; + } + } + + @Override + public void onError(PartitionContext context, Throwable error) { + TestBase.logInfo("P" + context.getPartitionId() + "onError: " + error.toString() + " " + error.getMessage()); + this.factory.putError(context.getPartitionId() + " onError: " + error.toString() + " " + error.getMessage()); + } + + public enum CheckpointChoices {CKP_NONE, CKP_EXPLICIT, CKP_NOARGS} +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/PrefabGeneralErrorHandler.java b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/PrefabGeneralErrorHandler.java new file mode 100644 index 000000000000..b67f45157060 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/PrefabGeneralErrorHandler.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import java.util.ArrayList; +import java.util.function.Consumer; + +public class PrefabGeneralErrorHandler implements Consumer { + private ArrayList errors = new ArrayList(); + + ArrayList getErrors() { + return this.errors; + } + + int getErrorCount() { + return this.errors.size(); + } + + @Override + public void accept(ExceptionReceivedEventArgs e) { + this.errors.add("GENERAL: " + e.getHostname() + " " + e.getAction() + " " + e.getException().toString() + " " + e.getException().getMessage()); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/PrefabProcessorFactory.java b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/PrefabProcessorFactory.java new file mode 100644 index 000000000000..1c877c69b7fb --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/PrefabProcessorFactory.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import java.util.ArrayList; +import java.util.HashMap; + +public class PrefabProcessorFactory implements IEventProcessorFactory { + private String telltale; + private PrefabEventProcessor.CheckpointChoices doCheckpoint; + private boolean doMarker; + private boolean logEveryEvent; + + private ArrayList errors = new ArrayList(); + private HashMap foundTelltale = new HashMap(); + private int eventsReceivedCount = 0; + private PartitionContext partitionContextOnEvents; + + PrefabProcessorFactory(String telltale, PrefabEventProcessor.CheckpointChoices doCheckpoint, boolean doMarker) { + this(telltale, doCheckpoint, doMarker, false); + } + + PrefabProcessorFactory(String telltale, PrefabEventProcessor.CheckpointChoices doCheckpoint, boolean doMarker, boolean logEveryEvent) { + this.telltale = telltale; + this.doCheckpoint = doCheckpoint; + this.doMarker = doMarker; + this.logEveryEvent = logEveryEvent; + } + + void putError(String error) { + this.errors.add(error); + } + + ArrayList getErrors() { + return this.errors; + } + + int getErrorCount() { + return this.errors.size(); + } + + boolean getTelltaleFound(String partitionId) { + Boolean retval = this.foundTelltale.get(partitionId); + return ((retval != null) ? retval : false); + } + + boolean getAnyTelltaleFound() { + return (this.foundTelltale.size() > 0); + } + + void setTelltaleFound(String partitionId) { + this.foundTelltale.put(partitionId, true); + } + + synchronized void addBatch(int batchSize) { + this.eventsReceivedCount += batchSize; + } + + int getEventsReceivedCount() { + return this.eventsReceivedCount; + } + + PartitionContext getOnEventsContext() { + return this.partitionContextOnEvents; + } + + void setOnEventsContext(PartitionContext value) { + this.partitionContextOnEvents = value; + } + + @Override + public IEventProcessor createEventProcessor(PartitionContext context) throws Exception { + return new PrefabEventProcessor(this, this.telltale, this.doCheckpoint, this.doMarker, this.logEveryEvent); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/RealEventHubUtilities.java b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/RealEventHubUtilities.java new file mode 100644 index 000000000000..80f8c8ee23a7 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/RealEventHubUtilities.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import com.microsoft.azure.eventhubs.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.concurrent.ExecutionException; + +import org.junit.Assume; + +final class RealEventHubUtilities { + static final int QUERY_ENTITY_FOR_PARTITIONS = -1; + static final String syntacticallyCorrectDummyEventHubPath = "doesnotexist"; + static final String syntacticallyCorrectDummyConnectionString = + "Endpoint=sb://doesnotexist.servicebus.windows.net/;SharedAccessKeyName=doesnotexist;SharedAccessKey=dGhpcyBpcyBub3QgYSB2YWxpZCBrZXkgLi4uLi4uLi4=;EntityPath=" + + RealEventHubUtilities.syntacticallyCorrectDummyEventHubPath; + + private ConnectionStringBuilder hubConnectionString = null; + private String hubName = null; + private String consumerGroup = EventHubClient.DEFAULT_CONSUMER_GROUP_NAME; + private EventHubClient client = null; + private ArrayList cachedPartitionIds = null; + private HashMap partitionSenders = new HashMap(); + + RealEventHubUtilities() { + } + + ArrayList setup(boolean skipIfFakeEH, int fakePartitions) throws EventHubException, IOException { + ArrayList partitionIds = setupWithoutSenders(skipIfFakeEH, fakePartitions); + + // EventHubClient is source of all senders + this.client = EventHubClient.createSync(this.hubConnectionString.toString(), TestUtilities.EXECUTOR_SERVICE); + + return partitionIds; + } + + ArrayList setupWithoutSenders(boolean skipIfFakeEH, int fakePartitions) throws EventHubException, IOException { + // Get the connection string from the environment + ehCacheCheck(skipIfFakeEH); + + // Get the consumer group from the environment, if present. + String tempConsumerGroup = System.getenv("EVENT_HUB_CONSUMER_GROUP"); + if (tempConsumerGroup != null) { + this.consumerGroup = tempConsumerGroup; + } + + ArrayList partitionIds = null; + + if (fakePartitions == RealEventHubUtilities.QUERY_ENTITY_FOR_PARTITIONS) { + partitionIds = getPartitionIdsForTest(); + } else { + partitionIds = new ArrayList(); + for (int i = 0; i < fakePartitions; i++) { + partitionIds.add(Integer.toString(i)); + } + } + + return partitionIds; + } + + void shutdown() throws EventHubException { + for (PartitionSender sender : this.partitionSenders.values()) { + sender.closeSync(); + } + if (this.client != null) { + this.client.closeSync(); + } + } + + ConnectionStringBuilder getConnectionString(boolean skipIfFakeEH) { + ehCacheCheck(skipIfFakeEH); + return this.hubConnectionString; + } + + private void ehCacheCheck(boolean skipIfFakeEH) { + if (this.hubName == null) { + if (skipIfFakeEH) { + TestUtilities.skipIfAppveyor(); + } + String rawConnectionString = System.getenv("EVENT_HUB_CONNECTION_STRING"); + if (rawConnectionString == null) { + if (skipIfFakeEH) { + TestBase.logInfo("SKIPPING - REQUIRES REAL EVENT HUB"); + Assume.assumeTrue(rawConnectionString != null); + } + TestBase.logInfo("Using dummy event hub connection string"); + rawConnectionString = RealEventHubUtilities.syntacticallyCorrectDummyConnectionString; + } + + this.hubConnectionString = new ConnectionStringBuilder(rawConnectionString); + this.hubName = this.hubConnectionString.getEventHubName(); + } + } + + String getConsumerGroup() { + return this.consumerGroup; + } + + void sendToAny(String body, int count) throws EventHubException { + for (int i = 0; i < count; i++) { + sendToAny(body); + } + } + + void sendToAny(String body) throws EventHubException { + EventData event = EventData.create(body.getBytes()); + this.client.sendSync(event); + } + + void sendToPartition(String partitionId, String body) throws IllegalArgumentException, EventHubException { + EventData event = EventData.create(body.getBytes()); + PartitionSender sender = null; + if (this.partitionSenders.containsKey(partitionId)) { + sender = this.partitionSenders.get(partitionId); + } else { + sender = this.client.createPartitionSenderSync(partitionId); + this.partitionSenders.put(partitionId, sender); + } + sender.sendSync(event); + } + + ArrayList getPartitionIdsForTest() throws EventHubException, IOException { + if (this.cachedPartitionIds == null) { + this.cachedPartitionIds = new ArrayList(); + ehCacheCheck(true); + + EventHubClient idClient = EventHubClient.createSync(this.hubConnectionString.toString(), TestUtilities.EXECUTOR_SERVICE); + try { + EventHubRuntimeInformation info = idClient.getRuntimeInformation().get(); + String ids[] = info.getPartitionIds(); + for (String id : ids) { + this.cachedPartitionIds.add(id); + } + } catch (ExecutionException | InterruptedException e) { + throw new IllegalArgumentException("Error getting partition ids in test framework", e.getCause()); + } + } + + return this.cachedPartitionIds; + } + +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/Repros.java b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/Repros.java new file mode 100644 index 000000000000..c7467ddef7ae --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/Repros.java @@ -0,0 +1,320 @@ +package com.microsoft.azure.eventprocessorhost; + +import com.microsoft.azure.eventhubs.*; +import org.junit.Test; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadPoolExecutor; + +public class Repros extends TestBase { + /* + * This class exists to preserve repro code for specific problems we have had to debug/fix. The + * cases here are not really useful/usable as general-purpose tests (most of them run infinitely, + * for example), so they are not marked as JUnit cases by default. + */ + + /* + * Two instances of EventProcessorHost with the same host name is not a valid configuration. + * Since lease ownership is determined by host name, they will both believe that they own all + * the partitions and constantly be recreating receivers and knocking the other one off. Don't + * do this. This repro exists because another test case did this scenario by accident and saw + * massive memory leaks, so I recreated it deliberately to find out what was going on. + */ + //@Test + public void conflictingHosts() throws Exception { + RealEventHubUtilities utils = new RealEventHubUtilities(); + utils.setup(true, RealEventHubUtilities.QUERY_ENTITY_FOR_PARTITIONS); + + String telltale = "conflictingHosts-telltale-" + EventProcessorHost.safeCreateUUID(); + String conflictingName = "conflictingHosts-NOTSAFE"; + String storageName = conflictingName.toLowerCase() + EventProcessorHost.safeCreateUUID(); + PrefabEventProcessor.CheckpointChoices doCheckpointing = PrefabEventProcessor.CheckpointChoices.CKP_NONE; + boolean doMarker = false; + + PrefabGeneralErrorHandler general1 = new PrefabGeneralErrorHandler(); + PrefabProcessorFactory factory1 = new PrefabProcessorFactory(telltale, doCheckpointing, doMarker); + EventProcessorHost host1 = new EventProcessorHost(conflictingName, utils.getConnectionString(true).getEventHubName(), + utils.getConsumerGroup(), utils.getConnectionString(true).toString(), + TestUtilities.getStorageConnectionString(), storageName); + EventProcessorOptions options1 = EventProcessorOptions.getDefaultOptions(); + options1.setExceptionNotification(general1); + + PrefabGeneralErrorHandler general2 = new PrefabGeneralErrorHandler(); + PrefabProcessorFactory factory2 = new PrefabProcessorFactory(telltale, doCheckpointing, doMarker); + EventProcessorHost host2 = new EventProcessorHost(conflictingName, utils.getConnectionString(true).getEventHubName(), + utils.getConsumerGroup(), utils.getConnectionString(true).toString(), + TestUtilities.getStorageConnectionString(), storageName); + EventProcessorOptions options2 = EventProcessorOptions.getDefaultOptions(); + options2.setExceptionNotification(general2); + + host1.registerEventProcessorFactory(factory1, options1); + host2.registerEventProcessorFactory(factory2, options2); + + int i = 0; + while (true) { + utils.sendToAny("conflict-" + i++, 10); + System.out.println("\n." + factory1.getEventsReceivedCount() + "." + factory2.getEventsReceivedCount() + ":" + + ((ThreadPoolExecutor) host1.getHostContext().getExecutor()).getPoolSize() + "." + + ((ThreadPoolExecutor) host2.getHostContext().getExecutor()).getPoolSize() + ":" + + Thread.activeCount()); + Thread.sleep(100); + } + } + + @Test + public void infiniteReceive() throws Exception { + RealEventHubUtilities utils = new RealEventHubUtilities(); + utils.setupWithoutSenders(true, RealEventHubUtilities.QUERY_ENTITY_FOR_PARTITIONS); + + PrefabGeneralErrorHandler genErr = new PrefabGeneralErrorHandler(); + PrefabProcessorFactory factory = new PrefabProcessorFactory("never match", PrefabEventProcessor.CheckpointChoices.CKP_NONE, true, false); + InMemoryCheckpointManager checkpointer = new InMemoryCheckpointManager(); + InMemoryLeaseManager leaser = new InMemoryLeaseManager(); + EventProcessorHost host = new EventProcessorHost("infiniteReceive-1", utils.getConnectionString(true).getEventHubName(), + utils.getConsumerGroup(), utils.getConnectionString(true).toString(), + checkpointer, leaser, Executors.newScheduledThreadPool(16), null); + checkpointer.initialize(host.getHostContext()); + leaser.initialize(host.getHostContext()); + + EventProcessorOptions opts = EventProcessorOptions.getDefaultOptions(); + opts.setExceptionNotification(genErr); + host.registerEventProcessorFactory(factory, opts).get(); + + while (System.in.available() == 0) { + System.out.println("STANDING BY AT " + Thread.activeCount()); + Thread.sleep(10000); + } + while (System.in.available() > 0) { + System.in.read(); + } + while (System.in.available() == 0) { + System.out.println("STANDING BY AT " + Thread.activeCount()); + Thread.sleep(1000); + } + + host.unregisterEventProcessor(); + } + + @Test + public void infiniteReceive2Hosts() throws Exception { + RealEventHubUtilities utils = new RealEventHubUtilities(); + utils.setup(true, RealEventHubUtilities.QUERY_ENTITY_FOR_PARTITIONS); + + String storageName = "ir2hosts" + EventProcessorHost.safeCreateUUID(); + + PrefabGeneralErrorHandler general1 = new PrefabGeneralErrorHandler(); + PrefabProcessorFactory factory1 = new PrefabProcessorFactory("never match", PrefabEventProcessor.CheckpointChoices.CKP_NONE, true, false); + EventProcessorHost host1 = new EventProcessorHost("infiniteReceive2Hosts-1", utils.getConnectionString(true).getEventHubName(), + utils.getConsumerGroup(), utils.getConnectionString(true).toString(), + TestUtilities.getStorageConnectionString(), storageName); + EventProcessorOptions options1 = EventProcessorOptions.getDefaultOptions(); + options1.setExceptionNotification(general1); + + PrefabGeneralErrorHandler general2 = new PrefabGeneralErrorHandler(); + PrefabProcessorFactory factory2 = new PrefabProcessorFactory("never match", PrefabEventProcessor.CheckpointChoices.CKP_NONE, true, false); + EventProcessorHost host2 = new EventProcessorHost("infiniteReceive2Hosts-2", utils.getConnectionString(true).getEventHubName(), + utils.getConsumerGroup(), utils.getConnectionString(true).toString(), + TestUtilities.getStorageConnectionString(), storageName); + EventProcessorOptions options2 = EventProcessorOptions.getDefaultOptions(); + options2.setExceptionNotification(general2); + + host1.registerEventProcessorFactory(factory1, options1).get(); + host2.registerEventProcessorFactory(factory2, options2).get(); + + int i = 0; + boolean upAndDown = true; + int upAndDownInterval = 25; + CompletableFuture upAndDownFuture = null; + while (true) { + if (upAndDownFuture != null) + { + if (upAndDownFuture.isDone()) + { + upAndDownFuture.get(); + System.out.println("Reg/unreg completed"); + upAndDownFuture = null; + } + } + + utils.sendToAny("blah-" + i++, 10); + + StringBuilder blah = new StringBuilder(); + blah.append("\n."); + blah.append(factory1.getEventsReceivedCount()); + blah.append('.'); + if (host2 != null) + { + blah.append(factory2.getEventsReceivedCount()); + } + blah.append(':'); + blah.append(((ThreadPoolExecutor) host1.getHostContext().getExecutor()).getPoolSize()); + blah.append('.'); + if (host2 != null) + { + blah.append(((ThreadPoolExecutor) host2.getHostContext().getExecutor()).getPoolSize()); + } + blah.append(':'); + blah.append(Thread.activeCount()); + blah.append(" i="); + blah.append(i); + System.out.println(blah.toString()); + + Thread.sleep(100); + + if (upAndDown && ((i % upAndDownInterval) == 0)) + { + if (host2 != null) + { + upAndDownFuture = host2.unregisterEventProcessor(); + System.out.println("Unregister started"); + host2 = null; + } + else + { + factory2 = new PrefabProcessorFactory("never match", PrefabEventProcessor.CheckpointChoices.CKP_NONE, true, false); + host2 = new EventProcessorHost("infiniteReceive2Hosts-2", utils.getConnectionString(true).getEventHubName(), + utils.getConsumerGroup(), utils.getConnectionString(true).toString(), + TestUtilities.getStorageConnectionString(), storageName); + options2 = EventProcessorOptions.getDefaultOptions(); + options2.setExceptionNotification(general2); + + System.out.println("Reregister started"); + upAndDownFuture = host2.registerEventProcessorFactory(factory2, options2); + } + } + } + } + + /* + * The memory leak mentioned in the previous case turned out to be a thread leak. This case was created to see if + * the thread leak was related to EPH or was in the underlying client. At first we believed that the leak was due + * to creating a new epoch receiver that was kicking the old receiver off. Then we believed that it was about epoch + * receivers. Then we finally determined that a thread was leaked every time a receiver was closed, with no special + * sauce required. + */ + //@Test + public void rawEpochStealing() throws Exception { + RealEventHubUtilities utils = new RealEventHubUtilities(); + utils.setup(true, RealEventHubUtilities.QUERY_ENTITY_FOR_PARTITIONS); + + int clientSerialNumber = 0; + while (true) { + Thread[] blah = new Thread[Thread.activeCount() + 10]; + int actual = Thread.enumerate(blah); + if (actual >= blah.length) { + System.out.println("Lost some threads"); + } + int parkedCount = 0; + String selectingList = ""; + boolean display = true; + for (int i = 0; i < actual; i++) { + display = true; + StackTraceElement[] bloo = blah[i].getStackTrace(); + String show = "nostack"; + if (bloo.length > 0) { + show = bloo[0].getClassName() + "." + bloo[0].getMethodName(); + if (show.compareTo("sun.misc.Unsafe.park") == 0) { + parkedCount++; + display = false; + } else if (show.compareTo("sun.nio.ch.WindowsSelectorImpl$SubSelector.poll0") == 0) { + selectingList += (" " + blah[i].getId()); + display = false; + } + } + if (display) { + System.out.print(" " + blah[i].getId() + ":" + show); + } + } + System.out.println("\nParked: " + parkedCount + " SELECTING: " + selectingList); + + System.out.println("Client " + clientSerialNumber + " starting"); + EventHubClient client = EventHubClient.createSync(utils.getConnectionString(true).toString(), TestUtilities.EXECUTOR_SERVICE); + PartitionReceiver receiver = client.createReceiver(utils.getConsumerGroup(), "0", EventPosition.fromStartOfStream()).get(); + //client.createEpochReceiver(utils.getConsumerGroup(), "0", PartitionReceiver.START_OF_STREAM, 1).get(); + + boolean useReceiveHandler = false; + + if (useReceiveHandler) { + Blah b = new Blah(clientSerialNumber++, receiver, client); + receiver.setReceiveHandler(b).get(); + // wait for events to start flowing + b.waitForReceivedEvents().get(); + } else { + receiver.receiveSync(1); + System.out.println("Received an event"); + } + + // Enable these lines to avoid overlap + try { + System.out.println("Non-overlap close of PartitionReceiver"); + if (useReceiveHandler) { + receiver.setReceiveHandler(null).get(); + } + receiver.close().get(); + } catch (InterruptedException | ExecutionException e) { + System.out.println("Client " + clientSerialNumber + " failed while closing PartitionReceiver: " + e.toString()); + } + try { + System.out.println("Non-overlap close of EventHubClient"); + client.close().get(); + } catch (InterruptedException | ExecutionException e) { + System.out.println("Client " + clientSerialNumber + " failed while closing EventHubClient: " + e.toString()); + } + System.out.println("Client " + clientSerialNumber + " closed"); + // Enable these lines to avoid overlap + + System.out.println("Threads: " + Thread.activeCount()); + } + } + + private class Blah implements PartitionReceiveHandler { + private int clientSerialNumber; + private PartitionReceiver receiver; + private EventHubClient client; + private CompletableFuture receivedEvents = null; + private boolean firstEvents = true; + + protected Blah(int clientSerialNumber, PartitionReceiver receiver, EventHubClient client) { + this.clientSerialNumber = clientSerialNumber; + this.receiver = receiver; + this.client = client; + } + + CompletableFuture waitForReceivedEvents() { + this.receivedEvents = new CompletableFuture(); + return this.receivedEvents; + } + + @Override + public int getMaxEventCount() { + return 300; + } + + @Override + public void onReceive(Iterable events) { + if (this.firstEvents) { + System.out.println("Client " + this.clientSerialNumber + " got events"); + this.receivedEvents.complete(null); + this.firstEvents = false; + } + } + + @Override + public void onError(Throwable error) { + System.out.println("Client " + this.clientSerialNumber + " got " + error.toString()); + try { + this.receiver.close().get(); + } catch (InterruptedException | ExecutionException e) { + System.out.println("Client " + this.clientSerialNumber + " failed while closing PartitionReceiver: " + e.toString()); + } + try { + this.client.close().get(); + } catch (InterruptedException | ExecutionException e) { + System.out.println("Client " + this.clientSerialNumber + " failed while closing EventHubClient: " + e.toString()); + } + System.out.println("Client " + this.clientSerialNumber + " closed"); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/SadPathTests.java b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/SadPathTests.java new file mode 100644 index 000000000000..089cfabec5d2 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/SadPathTests.java @@ -0,0 +1,140 @@ +package com.microsoft.azure.eventprocessorhost; + +import com.microsoft.azure.eventhubs.EventHubException; +import com.microsoft.azure.eventhubs.IllegalEntityException; + +import org.junit.Assume; +import org.junit.Test; + +import java.util.concurrent.ExecutionException; + +import static org.junit.Assert.fail; + +public class SadPathTests extends TestBase { + @Test + public void noSuchEventHubNamespaceTest() throws Exception { + PerTestSettings settings = new PerTestSettings("NoSuchEventHubNamespace"); + settings.inHasSenders = false; + settings.inEventHubDoesNotExist = true; + Assume.assumeFalse(TestUtilities.isRunningOnAzure()); + try { + settings = testSetup(settings); + fail("No exception occurred"); + } catch (EventHubException e) { + TestBase.logInfo("Got expected EventHubException"); + } finally { + testFinish(settings, NO_CHECKS); + } + } + + @Test + public void noSuchEventHubTest() throws Exception { + PerTestSettings settings = new PerTestSettings("NoSuchEventHub"); + settings.inHasSenders = false; + settings.inSkipIfNoEventHubConnectionString = true; + settings.inoutEPHConstructorArgs.setEHPath("thereisnoeventhubwiththisname", PerTestSettings.EPHConstructorArgs.EH_PATH_OVERRIDE_AND_REPLACE); + try { + settings = testSetup(settings); + fail("No exception occurred"); + } catch (ExecutionException e) { + Throwable inner = e.getCause(); + if ((inner != null) && (inner instanceof IllegalEntityException)) { + TestBase.logInfo("Got expected IllegalEntityException"); + } else { + throw e; + } + } finally { + testFinish(settings, NO_CHECKS); + } + } + + // Turned off -- we cannot detect no such consumer group at register time + //@Test + public void noSuchConsumerGroupTest() throws Exception { + PerTestSettings settings = new PerTestSettings("NoSuchConsumerGroup"); + settings.inHasSenders = false; + settings.inoutEPHConstructorArgs.setConsumerGroupName("thereisnoconsumergroupwiththisname"); + try { + settings = testSetup(settings); + fail("No exception occurred"); + } catch (ExecutionException e) { + Throwable inner = e.getCause(); + if ((inner != null) && (inner instanceof IllegalEntityException)) { + TestBase.logInfo("Got expected IllegalEntityException"); + } else { + throw e; + } + } finally { + testFinish(settings, NO_CHECKS); + } + } + + @Test + public void secondRegisterFailsTest() throws Exception { + PerTestSettings settings = new PerTestSettings("SecondRegisterFails"); + settings.inHasSenders = false; + settings = testSetup(settings); + + try { + settings.outHost.registerEventProcessorFactory(settings.outProcessorFactory, settings.inOptions).get(); + Thread.sleep(10000); + fail("No exception occurred"); + } catch (IllegalStateException e) { + if ((e.getMessage() != null) && (e.getMessage().compareTo("Register has already been called on this EventProcessorHost") == 0)) { + TestBase.logInfo("Got expected exception"); + } else { + fail("Got IllegalStateException but text is wrong"); + } + } finally { + testFinish(settings, NO_CHECKS); + } + } + + @Test + public void reregisterFailsTest() throws Exception { + PerTestSettings settings = new PerTestSettings("ReregisterFails"); + settings.inHasSenders = false; + settings = testSetup(settings); + + try { + Thread.sleep(15000); + settings.outHost.unregisterEventProcessor().get(); + + settings.outHost.registerEventProcessorFactory(settings.outProcessorFactory, settings.inOptions).get(); + Thread.sleep(10000); + fail("No exception occurred"); + } catch (IllegalStateException e) { + if ((e.getMessage() != null) && (e.getMessage().compareTo("Register cannot be called on an EventProcessorHost after unregister. Please create a new EventProcessorHost instance.") == 0)) { + TestBase.logInfo("Got expected exception"); + } else { + fail("Got IllegalStateException but text is wrong"); + } + } finally { + testFinish(settings, NO_CHECKS); + } + } + + // Turned off -- requires special setup beyond just having a real event hub + // @Test + public void badEventHubNameTest() throws Exception { + // This case requires an eventhub with a bad name (not legal as storage container name). + // Within EPH the validation of the name occurs after other operations that fail if the eventhub + // doesn't exist, so this case can't use arbitrary bad names. + PerTestSettings settings = new PerTestSettings("BadEventHubName"); + settings.inHasSenders = false; + try { + settings.inoutEPHConstructorArgs.setStorageContainerName(null); // otherwise test framework creates unique storage container name + settings = testSetup(settings); + fail("No exception occurred"); + } catch (IllegalArgumentException e) { + String message = e.getMessage(); + if ((message != null) && message.startsWith("EventHub names must conform to the following rules")) { + TestBase.logInfo("Got expected IllegalArgumentException"); + } else { + throw e; + } + } finally { + testFinish(settings, NO_CHECKS); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/SmokeTest.java b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/SmokeTest.java new file mode 100644 index 000000000000..5fb1c7347f33 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/SmokeTest.java @@ -0,0 +1,234 @@ + /* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. */ + + package com.microsoft.azure.eventprocessorhost; + + import com.microsoft.azure.eventhubs.EventPosition; + import org.junit.Assert; + import org.junit.Test; + + import java.time.Instant; + import java.util.concurrent.Executors; + + + public class SmokeTest extends TestBase { + @Test + public void SendRecv1MsgTest() throws Exception { + PerTestSettings settings = new PerTestSettings("SendRecv1Msg"); + settings = testSetup(settings); + + settings.outUtils.sendToAny(settings.outTelltale); + waitForTelltale(settings); + + testFinish(settings, SmokeTest.ANY_NONZERO_COUNT); + } + + @Test + public void ReceiverRuntimeMetricsTest() throws Exception { + PerTestSettings settings = new PerTestSettings("ReceiverRuntimeMetrics"); + settings.inOptions.setReceiverRuntimeMetricEnabled(true); + settings = testSetup(settings); + + settings.outUtils.sendToAny(settings.outTelltale); + waitForTelltale(settings); + + // correctness of runtimeInfo is already tested in javaclient - this is only testing for presence of non-default value + Assert.assertTrue(settings.outProcessorFactory.getOnEventsContext().getRuntimeInformation() != null); + Assert.assertTrue(settings.outProcessorFactory.getOnEventsContext().getRuntimeInformation().getLastEnqueuedSequenceNumber() > 0); + + testFinish(settings, SmokeTest.ANY_NONZERO_COUNT); + } + + @Test + public void receiveFromNowTest() throws Exception { + // Doing two iterations with the same "now" requires storing the "now" value instead of + // using the current time when the initial offset provider is executed. It also requires + // that the "now" be before the first send. + final Instant storedNow = Instant.now(); + + // Do the first iteration. + PerTestSettings firstSettings = receiveFromNowIteration(storedNow, 1, 1, null); + + // Do a second iteration with the same "now". Because the first iteration does not checkpoint, + // it should receive the telltale from the first iteration AND the telltale from this iteration. + // The purpose of running a second iteration is to look for bugs that occur when leases have been + // created and persisted but checkpoints have not, so it is vital that the second iteration uses the + // same storage container. + receiveFromNowIteration(storedNow, 2, 2, firstSettings.inoutEPHConstructorArgs.getStorageContainerName()); + } + + private PerTestSettings receiveFromNowIteration(final Instant storedNow, int iteration, int expectedEvents, String containerName) throws Exception { + PerTestSettings settings = new PerTestSettings("receiveFromNow-iter-" + iteration); + if (containerName != null) { + settings.inoutEPHConstructorArgs.setStorageContainerName(containerName); + } + settings.inOptions.setInitialPositionProvider((partitionId) -> { + return EventPosition.fromEnqueuedTime(storedNow); + }); + settings = testSetup(settings); + + settings.outUtils.sendToAny(settings.outTelltale); + waitForTelltale(settings); + + testFinish(settings, expectedEvents); + + return settings; + } + + @Test + public void receiveFromCheckpoint() throws Exception { + PerTestSettings firstSettings = receiveFromCheckpointIteration(1, SmokeTest.ANY_NONZERO_COUNT, null, PrefabEventProcessor.CheckpointChoices.CKP_EXPLICIT); + + receiveFromCheckpointIteration(2, firstSettings.outPartitionIds.size(), firstSettings.inoutEPHConstructorArgs.getStorageContainerName(), + firstSettings.inDoCheckpoint); + } + + @Test + public void receiveFromCheckpointNoArgs() throws Exception { + PerTestSettings firstSettings = receiveFromCheckpointIteration(1, SmokeTest.ANY_NONZERO_COUNT, null, PrefabEventProcessor.CheckpointChoices.CKP_NOARGS); + + receiveFromCheckpointIteration(2, firstSettings.outPartitionIds.size(), firstSettings.inoutEPHConstructorArgs.getStorageContainerName(), + firstSettings.inDoCheckpoint); + } + + private PerTestSettings receiveFromCheckpointIteration(int iteration, int expectedEvents, String containerName, + PrefabEventProcessor.CheckpointChoices checkpointCallType) throws Exception { + String distinguisher = "e"; + if (checkpointCallType == PrefabEventProcessor.CheckpointChoices.CKP_NOARGS) { + distinguisher = "n"; + } + PerTestSettings settings = new PerTestSettings("recvFromCkpt-" + iteration + "-" + distinguisher); + if (containerName != null) { + settings.inoutEPHConstructorArgs.setStorageContainerName(containerName); + } + settings.inDoCheckpoint = checkpointCallType; + settings = testSetup(settings); + + for (String id : settings.outPartitionIds) { + settings.outUtils.sendToPartition(id, settings.outTelltale); + waitForTelltale(settings, id); + } + + testFinish(settings, expectedEvents); + + return settings; + } + + @Test + public void receiveInvokeOnTimeout() throws Exception { + PerTestSettings settings = new PerTestSettings("receiveInvokeOnTimeout"); + settings.inOptions.setInvokeProcessorAfterReceiveTimeout(true); + settings.inTelltaleOnTimeout = true; + settings.inHasSenders = false; + settings = testSetup(settings); + + waitForTelltale(settings, "0"); + + testFinish(settings, SmokeTest.SKIP_COUNT_CHECK); + } + + @Test + public void receiveNotInvokeOnTimeout() throws Exception { + PerTestSettings settings = new PerTestSettings("receiveNotInvokeOnTimeout"); + settings = testSetup(settings); + + // Receive timeout is one minute. If event processor is invoked on timeout, it will + // record an error that will fail the case on shutdown. + Thread.sleep(120 * 1000); + + settings.outUtils.sendToAny(settings.outTelltale); + waitForTelltale(settings); + + testFinish(settings, SmokeTest.ANY_NONZERO_COUNT); + } + + @Test + public void receiveAllPartitionsTest() throws Exception { + // Save "now" to avoid race with sender startup. + final Instant savedNow = Instant.now(); + + PerTestSettings settings = new PerTestSettings("receiveAllPartitions"); + settings.inOptions.setInitialPositionProvider((partitionId) -> { + return EventPosition.fromEnqueuedTime(savedNow); + }); + settings = testSetup(settings); + + final int maxGeneration = 10; + for (int generation = 0; generation < maxGeneration; generation++) { + for (String id : settings.outPartitionIds) { + settings.outUtils.sendToPartition(id, "receiveAllPartitions-" + id + "-" + generation); + } + TestBase.logInfo("Generation " + generation + " sent\n"); + } + for (String id : settings.outPartitionIds) { + settings.outUtils.sendToPartition(id, settings.outTelltale); + TestBase.logInfo("Telltale " + id + " sent\n"); + } + for (String id : settings.outPartitionIds) { + waitForTelltale(settings, id); + } + + testFinish(settings, (settings.outPartitionIds.size() * (maxGeneration + 1))); // +1 for the telltales + } + + @Test + public void receiveAllPartitionsWithUserExecutorTest() throws Exception { + // Save "now" to avoid race with sender startup. + final Instant savedNow = Instant.now(); + + PerTestSettings settings = new PerTestSettings("rcvAllPartsUserExec"); + settings.inOptions.setInitialPositionProvider((partitionId) -> { + return EventPosition.fromEnqueuedTime(savedNow); + }); + settings.inoutEPHConstructorArgs.setExecutor(Executors.newScheduledThreadPool(4)); + settings = testSetup(settings); + + final int maxGeneration = 10; + for (int generation = 0; generation < maxGeneration; generation++) { + for (String id : settings.outPartitionIds) { + settings.outUtils.sendToPartition(id, "receiveAllPartitionsWithUserExecutor-" + id + "-" + generation); + } + TestBase.logInfo("Generation " + generation + " sent\n"); + } + for (String id : settings.outPartitionIds) { + settings.outUtils.sendToPartition(id, settings.outTelltale); + TestBase.logInfo("Telltale " + id + " sent\n"); + } + for (String id : settings.outPartitionIds) { + waitForTelltale(settings, id); + } + + testFinish(settings, (settings.outPartitionIds.size() * (maxGeneration + 1))); // +1 for the telltales + } + + @Test + public void receiveAllPartitionsWithSingleThreadExecutorTest() throws Exception { + // Save "now" to avoid race with sender startup. + final Instant savedNow = Instant.now(); + + PerTestSettings settings = new PerTestSettings("rcvAllParts1ThrdExec"); + settings.inOptions.setInitialPositionProvider((partitionId) -> { + return EventPosition.fromEnqueuedTime(savedNow); + }); + settings.inoutEPHConstructorArgs.setExecutor(Executors.newSingleThreadScheduledExecutor()); + settings = testSetup(settings); + + final int maxGeneration = 10; + for (int generation = 0; generation < maxGeneration; generation++) { + for (String id : settings.outPartitionIds) { + settings.outUtils.sendToPartition(id, "receiveAllPartitionsWithSingleThreadExecutor-" + id + "-" + generation); + } + TestBase.logInfo("Generation " + generation + " sent\n"); + } + for (String id : settings.outPartitionIds) { + settings.outUtils.sendToPartition(id, settings.outTelltale); + TestBase.logInfo("Telltale " + id + " sent\n"); + } + for (String id : settings.outPartitionIds) { + waitForTelltale(settings, id); + } + + testFinish(settings, (settings.outPartitionIds.size() * (maxGeneration + 1))); // +1 for the telltales + } + } diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/TestBase.java b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/TestBase.java new file mode 100644 index 000000000000..969b02aa9c79 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/TestBase.java @@ -0,0 +1,328 @@ +package com.microsoft.azure.eventprocessorhost; + +import com.microsoft.azure.eventhubs.ConnectionStringBuilder; +import com.microsoft.azure.eventhubs.EventHubClient; +import com.microsoft.azure.eventhubs.EventHubException; + +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Assume; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.rules.TestName; +import org.slf4j.LoggerFactory; +import org.slf4j.Logger; + + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledExecutorService; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class TestBase { + final static int SKIP_COUNT_CHECK = -3; // expectedEvents could be anything, don't check it at all + final static int NO_CHECKS = -2; // do no checks at all, used for tests which are expected fail in startup + final static int ANY_NONZERO_COUNT = -1; // if expectedEvents is -1, just check for > 0 + + static boolean logInfo = false; + static boolean logConsole = false; + static final Logger TRACE_LOGGER = LoggerFactory.getLogger("servicebus.test-eph.trace"); + + @Rule + public final TestName name = new TestName(); + + @BeforeClass + public static void allTestStart() { + String env = System.getenv("VERBOSELOG"); + if (env != null) { + TestBase.logInfo = true; + if (env.compareTo("CONSOLE") == 0) { + TestBase.logConsole = true; + } + } + } + + @AfterClass + public static void allTestFinish() { + } + + static void logError(String message) { + if (TestBase.logConsole) { + System.err.println("TEST ERROR: " + message); + } else { + TestBase.TRACE_LOGGER.error(message); + } + } + + static void logInfo(String message) { + if (TestBase.logInfo) { + if (TestBase.logConsole) { + System.err.println("TEST INFO: " + message); + } else { + TestBase.TRACE_LOGGER.info(message); + } + } + } + + void skipIfAutomated() { + Assume.assumeTrue(System.getenv("VERBOSELOG") != null); + } + + @Before + public void logCaseStart() { + String usemsg = "CASE START: " + this.name.getMethodName(); + if (TestBase.logConsole) { + System.err.println(usemsg); + } else { + TestBase.TRACE_LOGGER.info(usemsg); + } + } + + @After + public void logCaseEnd() { + String usemsg = "CASE END: " + this.name.getMethodName(); + if (TestBase.logConsole) { + System.err.println(usemsg); + } else { + TestBase.TRACE_LOGGER.info(usemsg); + } + } + + PerTestSettings testSetup(PerTestSettings settings) throws Exception { + String effectiveHostName = settings.inoutEPHConstructorArgs.isFlagSet(PerTestSettings.EPHConstructorArgs.HOST_OVERRIDE) ? + settings.inoutEPHConstructorArgs.getHostName() : settings.getDefaultHostName() + "-1"; + + settings.outUtils = new RealEventHubUtilities(); + boolean skipIfNoEventHubConnectionString = !settings.inEventHubDoesNotExist || settings.inSkipIfNoEventHubConnectionString; + if (settings.inHasSenders) { + settings.outPartitionIds = settings.outUtils.setup(skipIfNoEventHubConnectionString, settings.inEventHubDoesNotExist ? 8 : RealEventHubUtilities.QUERY_ENTITY_FOR_PARTITIONS); + } else { + settings.outPartitionIds = settings.outUtils.setupWithoutSenders(skipIfNoEventHubConnectionString, settings.inEventHubDoesNotExist ? 8 : RealEventHubUtilities.QUERY_ENTITY_FOR_PARTITIONS); + } + ConnectionStringBuilder environmentCSB = settings.outUtils.getConnectionString(skipIfNoEventHubConnectionString); + + String effectiveEntityPath = settings.inoutEPHConstructorArgs.isFlagSet(PerTestSettings.EPHConstructorArgs.EH_PATH_OVERRIDE) ? + settings.inoutEPHConstructorArgs.getEHPath() : environmentCSB.getEventHubName(); + + String effectiveConsumerGroup = settings.inoutEPHConstructorArgs.isFlagSet(PerTestSettings.EPHConstructorArgs.CONSUMER_GROUP_OVERRIDE) ? + settings.inoutEPHConstructorArgs.getConsumerGroupName() : EventHubClient.DEFAULT_CONSUMER_GROUP_NAME; + + String effectiveConnectionString = environmentCSB.toString(); + if (settings.inoutEPHConstructorArgs.isFlagSet(PerTestSettings.EPHConstructorArgs.EH_PATH_REPLACE_IN_CONNECTION) || + settings.inoutEPHConstructorArgs.isFlagSet(PerTestSettings.EPHConstructorArgs.EH_CONNECTION_REMOVE_PATH)) { + ConnectionStringBuilder replacedCSB = new ConnectionStringBuilder() + .setEndpoint(environmentCSB.getEndpoint()) + .setEventHubName( + settings.inoutEPHConstructorArgs.isFlagSet(PerTestSettings.EPHConstructorArgs.EH_CONNECTION_REMOVE_PATH) ? + "" : + settings.inoutEPHConstructorArgs.getEHPath() + ) + .setSasKeyName(environmentCSB.getSasKeyName()) + .setSasKey(environmentCSB.getSasKey()); + replacedCSB.setOperationTimeout(environmentCSB.getOperationTimeout()); + effectiveConnectionString = replacedCSB.toString(); + } + if (settings.inoutEPHConstructorArgs.isFlagSet(PerTestSettings.EPHConstructorArgs.EH_CONNECTION_OVERRIDE)) { + effectiveConnectionString = settings.inoutEPHConstructorArgs.getEHConnection(); + } + + ScheduledExecutorService effectiveExecutor = settings.inoutEPHConstructorArgs.isFlagSet(PerTestSettings.EPHConstructorArgs.EXECUTOR_OVERRIDE) ? + settings.inoutEPHConstructorArgs.getExecutor() : null; + + if (settings.inTelltaleOnTimeout) { + settings.outTelltale = ""; + } else { + settings.outTelltale = settings.getDefaultHostName() + "-telltale-" + EventProcessorHost.safeCreateUUID(); + } + settings.outGeneralErrorHandler = new PrefabGeneralErrorHandler(); + settings.outProcessorFactory = new PrefabProcessorFactory(settings.outTelltale, settings.inDoCheckpoint, false, false); + + settings.inOptions.setExceptionNotification(settings.outGeneralErrorHandler); + + if (settings.inoutEPHConstructorArgs.useExplicitManagers()) { + ICheckpointManager effectiveCheckpointMananger = settings.inoutEPHConstructorArgs.isFlagSet(PerTestSettings.EPHConstructorArgs.CHECKPOINT_MANAGER_OVERRIDE) ? + settings.inoutEPHConstructorArgs.getCheckpointMananger() : new BogusCheckpointMananger(); + ILeaseManager effectiveLeaseManager = settings.inoutEPHConstructorArgs.isFlagSet(PerTestSettings.EPHConstructorArgs.LEASE_MANAGER_OVERRIDE) ? + settings.inoutEPHConstructorArgs.getLeaseManager() : new BogusLeaseManager(); + + settings.outHost = new EventProcessorHost(effectiveHostName, effectiveEntityPath, effectiveConsumerGroup, effectiveConnectionString, + effectiveCheckpointMananger, effectiveLeaseManager, effectiveExecutor, null); + } else { + String effectiveStorageConnectionString = settings.inoutEPHConstructorArgs.isFlagSet(PerTestSettings.EPHConstructorArgs.STORAGE_CONNECTION_OVERRIDE) ? + settings.inoutEPHConstructorArgs.getStorageConnection() : TestUtilities.getStorageConnectionString(); + + String effectiveStorageContainerName = settings.getDefaultHostName().toLowerCase() + "-" + EventProcessorHost.safeCreateUUID(); + if (settings.inoutEPHConstructorArgs.isFlagSet(PerTestSettings.EPHConstructorArgs.STORAGE_CONTAINER_OVERRIDE)) { + effectiveStorageContainerName = settings.inoutEPHConstructorArgs.getStorageContainerName(); + if (effectiveStorageContainerName != null) { + effectiveStorageContainerName = effectiveStorageContainerName.toLowerCase(); + } + } else { + settings.inoutEPHConstructorArgs.setDefaultStorageContainerName(effectiveStorageContainerName); + } + + String effectiveBlobPrefix = settings.inoutEPHConstructorArgs.isFlagSet(PerTestSettings.EPHConstructorArgs.STORAGE_BLOB_PREFIX_OVERRIDE) ? + settings.inoutEPHConstructorArgs.getStorageBlobPrefix() : null; + + settings.outHost = new EventProcessorHost(effectiveHostName, effectiveEntityPath, effectiveConsumerGroup, effectiveConnectionString, + effectiveStorageConnectionString, effectiveStorageContainerName, effectiveBlobPrefix, effectiveExecutor); + } + + if (!settings.inEventHubDoesNotExist) { + settings.outHost.registerEventProcessorFactory(settings.outProcessorFactory, settings.inOptions).get(); + } + + return settings; + } + + void waitForTelltale(PerTestSettings settings) throws InterruptedException { + for (int i = 0; i < 100; i++) { + if (settings.outProcessorFactory.getAnyTelltaleFound()) { + TestBase.logInfo("Telltale found\n"); + break; + } + Thread.sleep(5000); + } + } + + void waitForTelltale(PerTestSettings settings, String partitionId) throws InterruptedException { + for (int i = 0; i < 100; i++) { + if (settings.outProcessorFactory.getTelltaleFound(partitionId)) { + TestBase.logInfo("Telltale " + partitionId + " found\n"); + break; + } + Thread.sleep(5000); + } + } + + void testFinish(PerTestSettings settings, int expectedEvents) throws InterruptedException, ExecutionException, EventHubException { + if ((settings.outHost != null) && !settings.inEventHubDoesNotExist) { + settings.outHost.unregisterEventProcessor().get(); + TestBase.logInfo("Host unregistered"); + } + + if (expectedEvents != NO_CHECKS) { + TestBase.logInfo("Events received: " + settings.outProcessorFactory.getEventsReceivedCount() + "\n"); + if (expectedEvents == ANY_NONZERO_COUNT) { + assertTrue("no events received", settings.outProcessorFactory.getEventsReceivedCount() > 0); + } else if (expectedEvents != SKIP_COUNT_CHECK) { + assertEquals("wrong number of events received", expectedEvents, settings.outProcessorFactory.getEventsReceivedCount()); + } + + assertTrue("telltale event was not found", settings.outProcessorFactory.getAnyTelltaleFound()); + assertEquals("partition errors seen", 0, settings.outProcessorFactory.getErrors().size()); + assertEquals("general errors seen", 0, settings.outGeneralErrorHandler.getErrors().size()); + for (String err : settings.outProcessorFactory.getErrors()) { + logError(err); + } + for (String err : settings.outGeneralErrorHandler.getErrors()) { + logError(err); + } + } + + settings.outUtils.shutdown(); + } + + class BogusCheckpointMananger implements ICheckpointManager { + @Override + public CompletableFuture checkpointStoreExists() { + return CompletableFuture.completedFuture(true); + } + + @Override + public CompletableFuture createCheckpointStoreIfNotExists() { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture deleteCheckpointStore() { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture getCheckpoint(String partitionId) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture createAllCheckpointsIfNotExists(List partitionIds) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture updateCheckpoint(CompleteLease lease, Checkpoint checkpoint) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture deleteCheckpoint(String partitionId) { + return CompletableFuture.completedFuture(null); + } + } + + class BogusLeaseManager implements ILeaseManager { + @Override + public int getLeaseDurationInMilliseconds() { + return 0; + } + + @Override + public CompletableFuture leaseStoreExists() { + return CompletableFuture.completedFuture(true); + } + + @Override + public CompletableFuture createLeaseStoreIfNotExists() { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture deleteLeaseStore() { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture getLease(String partitionId) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture> getAllLeases() { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture createAllLeasesIfNotExists(List partitionIds) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture deleteLease(CompleteLease lease) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture acquireLease(CompleteLease lease) { + return CompletableFuture.completedFuture(true); + } + + @Override + public CompletableFuture renewLease(CompleteLease lease) { + return CompletableFuture.completedFuture(true); + } + + @Override + public CompletableFuture releaseLease(CompleteLease lease) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture updateLease(CompleteLease lease) { + return CompletableFuture.completedFuture(true); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/TestUtilities.java b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/TestUtilities.java new file mode 100644 index 000000000000..d0466a6b4865 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-eph/src/test/java/com/microsoft/azure/eventprocessorhost/TestUtilities.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventprocessorhost; + +import org.junit.Assume; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +final class TestUtilities { + static final ScheduledExecutorService EXECUTOR_SERVICE = Executors.newScheduledThreadPool(1); + + static void skipIfAppveyor() { + String appveyor = System.getenv("APPVEYOR"); // Set to "true" by Appveyor + if (appveyor != null) { + TestBase.logInfo("SKIPPING - APPVEYOR DETECTED"); + } + Assume.assumeTrue(appveyor == null); + } + + static String getStorageConnectionString() { + TestUtilities.skipIfAppveyor(); + + String retval = System.getenv("EPHTESTSTORAGE"); + + // if EPHTESTSTORAGE is not set - we cannot run integration tests + if (retval == null) { + TestBase.logInfo("SKIPPING - NO STORAGE CONNECTION STRING"); + } + Assume.assumeTrue(retval != null); + + return ((retval != null) ? retval : ""); + } + + static Boolean isRunningOnAzure() { + return (System.getenv("EVENT_HUB_CONNECTION_STRING") != null); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs-extensions/pom.xml b/eventhubs/data-plane/azure-eventhubs-extensions/pom.xml new file mode 100644 index 000000000000..5dc33296bbba --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-extensions/pom.xml @@ -0,0 +1,38 @@ + + + libraries and extensions built on Microsoft Azure Event Hubs + + + com.microsoft.azure + azure-eventhubs-clients + 2.0.0 + + + 4.0.0 + + azure-eventhubs-extensions + azure-eventhubs-extensions + + + + com.microsoft.azure + azure-eventhubs + ${project.parent.version} + compile + + + org.apache.logging.log4j + log4j-api + 2.5 + + + org.apache.logging.log4j + log4j-core + 2.5 + + + + + diff --git a/eventhubs/data-plane/azure-eventhubs-extensions/src/main/java/com/microsoft/azure/eventhubs/extensions/appender/EventHubsAppender.java b/eventhubs/data-plane/azure-eventhubs-extensions/src/main/java/com/microsoft/azure/eventhubs/extensions/appender/EventHubsAppender.java new file mode 100644 index 000000000000..6fd45684198d --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-extensions/src/main/java/com/microsoft/azure/eventhubs/extensions/appender/EventHubsAppender.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.extensions.appender; + +import org.apache.logging.log4j.core.Filter; +import org.apache.logging.log4j.core.Layout; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.appender.AppenderLoggingException; +import org.apache.logging.log4j.core.config.plugins.Plugin; +import org.apache.logging.log4j.core.config.plugins.PluginAttribute; +import org.apache.logging.log4j.core.config.plugins.PluginElement; +import org.apache.logging.log4j.core.config.plugins.PluginFactory; +import org.apache.logging.log4j.core.config.plugins.validation.constraints.Required; +import org.apache.logging.log4j.core.util.StringEncoder; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Sends {@link LogEvent}'s to Microsoft Azure EventHubs. + * By default, tuned for high performance and hence, pushes a batch of Events. + */ +@Plugin(name = "EventHub", category = "Core", elementType = "appender", printObject = true) +public final class EventHubsAppender extends AbstractAppender { + private static final int MAX_BATCH_SIZE_BYTES = 200 * 1024; + + // this constant is tuned to use the MaximumAllowedMessageSize(256K) including AMQP-Headers for a LogEvent of 1Char + private static final int MAX_BATCH_SIZE = 21312; + private static final long serialVersionUID = 1L; + + private final EventHubsManager eventHubsManager; + private final boolean immediateFlush; + private final AtomicInteger currentBufferedSizeBytes; + private final ConcurrentLinkedQueue logEvents; + + private EventHubsAppender( + final String name, + final Filter filter, + final Layout layout, + final boolean ignoreExceptions, + final EventHubsManager eventHubsManager, + final boolean immediateFlush) { + super(name, filter, layout, ignoreExceptions); + + this.eventHubsManager = eventHubsManager; + this.immediateFlush = immediateFlush; + this.logEvents = new ConcurrentLinkedQueue(); + this.currentBufferedSizeBytes = new AtomicInteger(); + } + + @PluginFactory + public static EventHubsAppender createAppender( + @Required(message = "Provide a Name for EventHubs Log4j Appender") @PluginAttribute("name") final String name, + @PluginElement("Filter") final Filter filter, + @PluginElement("Layout") final Layout layout, + @PluginAttribute(value = "ignoreExceptions", defaultBoolean = true) final boolean ignoreExceptions, + @Required(message = "Provide EventHub connection string to append the events to") @PluginAttribute("eventHubConnectionString") final String connectionString, + @PluginAttribute(value = "immediateFlush", defaultBoolean = false) final boolean immediateFlush) { + final EventHubsManager eventHubsManager = new EventHubsManager(name, connectionString); + return new EventHubsAppender(name, filter, layout, ignoreExceptions, eventHubsManager, immediateFlush); + } + + @Override + public void append(LogEvent logEvent) { + byte[] serializedLogEvent = null; + + try { + Layout layout = getLayout(); + + if (layout != null) { + serializedLogEvent = layout.toByteArray(logEvent); + } else { + serializedLogEvent = StringEncoder.toBytes(logEvent.getMessage().getFormattedMessage(), StandardCharsets.UTF_8); + } + + if (serializedLogEvent != null) { + if (this.immediateFlush) { + this.eventHubsManager.send(serializedLogEvent); + return; + } else { + int currentSize = this.currentBufferedSizeBytes.addAndGet(serializedLogEvent.length); + this.logEvents.offer(serializedLogEvent); + + if (currentSize < EventHubsAppender.MAX_BATCH_SIZE_BYTES + && this.logEvents.size() < EventHubsAppender.MAX_BATCH_SIZE + && !logEvent.isEndOfBatch()) { + return; + } + + logEvent.setEndOfBatch(true); + + this.eventHubsManager.send(this.logEvents); + + this.logEvents.clear(); + this.currentBufferedSizeBytes.set(0); + } + } + } catch (final Throwable exception) { + AppenderLoggingException appenderLoggingException = exception instanceof AppenderLoggingException + ? (AppenderLoggingException) exception + : new AppenderLoggingException("Appending logEvent to EventHubs failed: " + exception.getMessage(), exception); + + LOGGER.error(String.format(Locale.US, "[%s] Appender failed to logEvent to EventHub.", this.getName())); + + // remove the current LogEvent from the inMem Q - to avoid replay + if (serializedLogEvent != null && this.logEvents.remove(serializedLogEvent)) { + this.currentBufferedSizeBytes.addAndGet(-1 * serializedLogEvent.length); + } + + throw appenderLoggingException; + } + } + + ; + + @Override + public void start() { + super.start(); + + try { + this.eventHubsManager.startup(); + } catch (Throwable exception) { + final String errMsg = String.format(Locale.US, "[%s] Appender initialization failed with error: [%s]", this.getName(), exception.getMessage()); + + LOGGER.error(errMsg); + throw new AppenderLoggingException(errMsg, exception); + } + } + + @Override + public void stop() { + super.stop(); + this.eventHubsManager.release(); + } +} \ No newline at end of file diff --git a/eventhubs/data-plane/azure-eventhubs-extensions/src/main/java/com/microsoft/azure/eventhubs/extensions/appender/EventHubsManager.java b/eventhubs/data-plane/azure-eventhubs-extensions/src/main/java/com/microsoft/azure/eventhubs/extensions/appender/EventHubsManager.java new file mode 100644 index 000000000000..aadaac89272e --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs-extensions/src/main/java/com/microsoft/azure/eventhubs/extensions/appender/EventHubsManager.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.extensions.appender; + +import com.microsoft.azure.eventhubs.EventData; +import com.microsoft.azure.eventhubs.EventHubClient; +import com.microsoft.azure.eventhubs.EventHubException; +import org.apache.logging.log4j.core.appender.AbstractManager; + +import java.io.IOException; +import java.util.LinkedList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +public final class EventHubsManager extends AbstractManager { + private static final ScheduledExecutorService EXECUTOR_SERVICE = Executors.newScheduledThreadPool(1); + private final String eventHubConnectionString; + private EventHubClient eventHubSender; + + protected EventHubsManager(final String name, final String eventHubConnectionString) { + super(name); + this.eventHubConnectionString = eventHubConnectionString; + } + + public void send(final byte[] msg) throws EventHubException { + if (msg != null) { + EventData data = EventData.create(msg); + this.eventHubSender.sendSync(data); + } + } + + public void send(final Iterable messages) throws EventHubException { + if (messages != null) { + LinkedList events = new LinkedList(); + for (byte[] message : messages) { + events.add(EventData.create(message)); + } + + this.eventHubSender.sendSync(events); + } + } + + public void startup() throws EventHubException, IOException { + this.eventHubSender = EventHubClient.createSync(this.eventHubConnectionString, EXECUTOR_SERVICE); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/pom.xml b/eventhubs/data-plane/azure-eventhubs/pom.xml new file mode 100644 index 000000000000..786b4ea51688 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/pom.xml @@ -0,0 +1,20 @@ + + + + com.microsoft.azure + azure-eventhubs-clients + 2.0.0 + + + 4.0.0 + + azure-eventhubs + azure-eventhubs + + + scm:git:https://github.com/Azure/azure-event-hubs-java + + + libraries and extensions built on Microsoft Azure Event Hubs + diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/AuthorizationFailedException.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/AuthorizationFailedException.java new file mode 100644 index 000000000000..fde67c966ac4 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/AuthorizationFailedException.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +import java.util.concurrent.ScheduledExecutorService; + +/** + * Authorization failed exception is thrown when error is encountered during authorizing user's permission to run the intended operations. + * When encountered this exception user should check whether the token/key provided in the connection string (e.g. one passed to + * {@link EventHubClient#create(String, ScheduledExecutorService)}) is valid, and has correct execution right for the intended operations (e.g. + * Receive call will need Listen claim associated with the key/token). + * + * @see http://go.microsoft.com/fwlink/?LinkId=761101 + */ +public class AuthorizationFailedException extends EventHubException { + private static final long serialVersionUID = 5384872132102860710L; + + AuthorizationFailedException() { + super(false); + } + + /** + * Constructor for the exception class + * + * @param message the actual error message detailing the reason for the failure + */ + public AuthorizationFailedException(final String message) { + super(false, message); + } + + AuthorizationFailedException(final Throwable cause) { + super(false, cause); + } + + AuthorizationFailedException(final String message, final Throwable cause) { + super(false, message, cause); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/BatchOptions.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/BatchOptions.java new file mode 100644 index 000000000000..916660ef8d43 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/BatchOptions.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ + +package com.microsoft.azure.eventhubs; + +import java.util.function.Consumer; + +/** + * BatchOptions is used to create {@link EventDataBatch}es. + *

If you're creating {@link EventDataBatch}es with {@link EventHubClient}, then you can set a partitionKey and maxMessageSize + * using the .with() method. Alternatively, if you'd like the default settings, simply construct BatchOptions with the void constructor. + * Default settings: + * - partitionKey is null + * - maxMessageSize is the maximum allowed size + *

If you're creating {@link EventDataBatch}es with {@link PartitionSender}, then you can only set a maxMessageSize + * using the .with() method. Alternatively, if you'd like the default settings, simply construct BatchOptions with the void constructor. + * Default settings: + * - maxMessageSize is the maximum allowed size + * - Note: if you set a partition key, an {@link IllegalArgumentException} will be thrown. + *

To construct either type of batch, create a {@link BatchOptions} object and pass it into the appropriate + * createBatch method. If using {@link PartitionSender}, then use ({@link PartitionSender#createBatch(BatchOptions)}. + * If using {@link EventHubClient}, then use {@link EventHubClient#createBatch(BatchOptions)}. + *

+ *     {@code
+ *     // Note: For all examples, 'client' is an instance of EventHubClient. The usage is the same for PartitionSender,
+ *     however, you can NOT set a partition key when using PartitionSender
+ *
+ *     // Create EventDataBatch with defaults
+ *     EventDataBatch edb1 = client.createBatch();
+ *
+ *     // Create EventDataBatch with custom partitionKey
+ *     BatchOptions options = new BatchOptions().with( options -> options.partitionKey = "foo");
+ *     EventDataBatch edb2 = client.createBatch(options);
+ *
+ *     // Create EventDataBatch with custom partitionKey and maxMessageSize
+ *     BatchOptions options = new BatchOptions().with ( options -> {
+ *         options.partitionKey = "foo";
+ *         options.maxMessageSize = 100 * 1024;
+ *     };
+ *     EventDataBatch edb3 = client.createBatch(options);
+ *     }
+ * 
+ */ +public final class BatchOptions { + + /** + * The partitionKey to use for all {@link EventData}s sent in the current {@link EventDataBatch}. + * Setting a PartitionKey will deliver the {@link EventData} to a specific Event Hubs partition. + */ + public String partitionKey = null; + + /** + * The maximum size in bytes of {@link EventDataBatch} being constructed. + * This value cannot exceed the maximum size supported by Event Hubs service. + * {@link EventDataBatch#tryAdd(EventData)} API will use this value as the upper limit. + */ + public Integer maxMessageSize = null; + + public final BatchOptions with(Consumer builderFunction) { + builderFunction.accept(this); + return this; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/CommunicationException.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/CommunicationException.java new file mode 100644 index 000000000000..73c26f38ccac --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/CommunicationException.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +/** + * This exception is thrown when there is a client side connectivity issue. When receiving this exception user should + * check client connectivity settings to the service: + *
    + *
  • Check for correct hostname and port number used in endpoint. + *
  • Check for any possible proxy settings that can block amqp ports + *
  • Check for any firewall settings that can block amqp ports + *
  • Check for any general network connectivity issues, as well as network latency. + *
+ * + * @see http://go.microsoft.com/fwlink/?LinkId=761101 + */ +public class CommunicationException extends EventHubException { + private static final long serialVersionUID = 7968596830506494332L; + + CommunicationException() { + super(true); + } + + CommunicationException(final String message) { + super(true, message); + } + + CommunicationException(final Throwable cause) { + super(true, cause); + } + + public CommunicationException(final String message, final Throwable cause) { + super(true, message, cause); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/ConnectionStringBuilder.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/ConnectionStringBuilder.java new file mode 100644 index 000000000000..cb05f5584daa --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/ConnectionStringBuilder.java @@ -0,0 +1,419 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +import com.microsoft.azure.eventhubs.impl.MessagingFactory; +import com.microsoft.azure.eventhubs.impl.StringUtil; + +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Duration; +import java.time.format.DateTimeParseException; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * {@link ConnectionStringBuilder} can be used to construct a connection string which can establish communication with Event Hub instances. + * In addition to constructing a connection string, the {@link ConnectionStringBuilder} can be used to modify an existing connection string. + *

Sample Code: + *

{@code
+ *  // Construct a new connection string
+ * 	ConnectionStringBuilder connectionStringBuilder = new ConnectionStringBuilder()
+ * 	    .setNamespaceName("EventHubsNamespaceName")
+ * 	    .setEventHubName("EventHubsEntityName")
+ * 	    .setSasKeyName("SharedAccessSignatureKeyName")
+ * 	    .setSasKey("SharedAccessSignatureKey")
+ *
+ *  string connString = connectionStringBuilder.build();
+ *
+ *  // Modify an existing connection string
+ *  ConnectionStringBuilder connectionStringBuilder = new ConnectionStringBuilder(existingConnectionString)
+ *      .setEventHubName("SomeOtherEventHubsName")
+ *      .setOperationTimeout(Duration.ofSeconds(30)
+ *
+ *  string connString = connectionStringBuilder.build();
+ * }
+ *

+ * A connection string is basically a string consisting of key-value pairs separated by ";". + * The basic format is {{@literal <}key{@literal >}={@literal <}value{@literal >}[;{@literal <}key{@literal >}={@literal <}value{@literal >}]} where supported key name are as follow: + *

    + *
  • Endpoint - the URL that contains the EventHubs namespace + *
  • EntityPath - the EventHub name which you are connecting to + *
  • SharedAccessKeyName - the key name to the corresponding shared access policy rule for the namespace, or entity. + *
  • SharedAccessKey - the key for the corresponding shared access policy rule of the namespace or entity. + *
+ */ +public final class ConnectionStringBuilder { + + final static String endpointFormat = "sb://%s.%s"; + final static String hostnameFormat = "sb://%s"; + final static String defaultDomainName = "servicebus.windows.net"; + + final static String HostnameConfigName = "Hostname"; // Hostname is a key that is used in IoTHub. + final static String EndpointConfigName = "Endpoint"; // Endpoint key is used in EventHubs. It's identical to Hostname in IoTHub. + final static String EntityPathConfigName = "EntityPath"; + final static String OperationTimeoutConfigName = "OperationTimeout"; + final static String KeyValueSeparator = "="; + final static String KeyValuePairDelimiter = ";"; + final static String SharedAccessKeyNameConfigName = "SharedAccessKeyName"; // We use a (KeyName, Key) pair OR the SAS token - never both. + final static String SharedAccessKeyConfigName = "SharedAccessKey"; + final static String SharedAccessSignatureConfigName = "SharedAccessSignature"; + final static String TransportTypeConfigName = "TransportType"; + + private static final String AllKeyEnumerateRegex = "(" + HostnameConfigName + "|" + EndpointConfigName + "|" + SharedAccessKeyNameConfigName + + "|" + SharedAccessKeyConfigName + "|" + SharedAccessSignatureConfigName + "|" + EntityPathConfigName + "|" + OperationTimeoutConfigName + + "|" + TransportTypeConfigName + ")"; + + private static final String KeysWithDelimitersRegex = KeyValuePairDelimiter + AllKeyEnumerateRegex + + KeyValueSeparator; + + private URI endpoint; + private String eventHubName; + private String sharedAccessKeyName; + private String sharedAccessKey; + private String sharedAccessSignature; + private Duration operationTimeout; + private TransportType transportType; + + /** + * Creates an empty {@link ConnectionStringBuilder}. At minimum, a namespace name, an entity path, SAS key name, and SAS key + * need to be set before a valid connection string can be built. + *

+ * For advanced users, the following replacements can be done: + *

    + *
  • An endpoint can be provided instead of a namespace name.
  • + *
  • A SAS token can be provided instead of a SAS key name and SAS key.
  • + *
  • Optionally, users can set an operation timeout instead of using the default value.
  • + *
+ */ + public ConnectionStringBuilder() { + } + + /** + * ConnectionString format: + * Endpoint=sb://namespace_DNS_Name;EntityPath=EVENT_HUB_NAME;SharedAccessKeyName=SHARED_ACCESS_KEY_NAME;SharedAccessKey=SHARED_ACCESS_KEY + * + * @param connectionString EventHubs ConnectionString + * @throws IllegalConnectionStringFormatException when the format of the ConnectionString is not valid + */ + public ConnectionStringBuilder(String connectionString) { + parseConnectionString(connectionString); + } + + /** + * Get the endpoint which can be used to connect to the EventHub instance. + * + * @return The currently set endpoint + */ + public URI getEndpoint() { + return this.endpoint; + } + + /** + * Set an endpoint which can be used to connect to the EventHub instance. + * + * @param endpoint is a combination of the namespace name and domain name. Together, these pieces make a valid + * endpoint. For example, the default domain name is "servicebus.windows.net", so a sample endpoint + * would look like this: "sb://namespace_name.servicebus.windows.net". + * @return the {@link ConnectionStringBuilder} being set. + */ + public ConnectionStringBuilder setEndpoint(URI endpoint) { + this.endpoint = endpoint; + return this; + } + + /** + * Set an endpoint which can be used to connect to the EventHub instance. + * + * @param namespaceName the name of the namespace to connect to. + * @param domainName identifies the domain the namespace is located in. For non-public and national clouds, + * the domain will not be "servicebus.windows.net". Available options include: + * - "servicebus.usgovcloudapi.net" + * - "servicebus.cloudapi.de" + * - "servicebus.chinacloudapi.cn" + * @return the {@link ConnectionStringBuilder} being set. + */ + public ConnectionStringBuilder setEndpoint(String namespaceName, String domainName) { + try { + this.endpoint = new URI(String.format(Locale.US, endpointFormat, namespaceName, domainName)); + } catch (URISyntaxException exception) { + throw new IllegalConnectionStringFormatException( + String.format(Locale.US, "Invalid namespace name: %s", namespaceName), + exception); + } + return this; + } + + /** + * Set a namespace name which will be used to connect to an EventHubs instance. This method adds + * "servicebus.windows.net" as the default domain name. + * + * @param namespaceName the name of the namespace to connect to. + * @return the {@link ConnectionStringBuilder} being set. + */ + public ConnectionStringBuilder setNamespaceName(String namespaceName) { + return this.setEndpoint(namespaceName, defaultDomainName); + } + + /** + * Get the entity path value from the connection string. + * + * @return Entity Path + */ + public String getEventHubName() { + return this.eventHubName; + } + + /** + * Set the entity path value from the connection string. + * + * @param eventHubName the name of the Event Hub to connect to. + * @return the {@link ConnectionStringBuilder} being set. + */ + public ConnectionStringBuilder setEventHubName(String eventHubName) { + this.eventHubName = eventHubName; + return this; + } + + /** + * Get the shared access policy key value from the connection string + * + * @return Shared Access Signature key + */ + public String getSasKey() { + return this.sharedAccessKey; + } + + /** + * Set the shared access policy key value from the connection string + * + * @param sasKey the SAS key + * @return the {@link ConnectionStringBuilder} being set. + */ + public ConnectionStringBuilder setSasKey(String sasKey) { + this.sharedAccessKey = sasKey; + return this; + } + + /** + * Get the shared access policy owner name from the connection string + * + * @return Shared Access Signature key name. + */ + public String getSasKeyName() { + return this.sharedAccessKeyName; + } + + /** + * Set the shared access policy owner name from the connection string + * + * @param sasKeyName the SAS key name + * @return the {@link ConnectionStringBuilder} being set. + */ + public ConnectionStringBuilder setSasKeyName(String sasKeyName) { + this.sharedAccessKeyName = sasKeyName; + return this; + } + + /** + * Get the shared access signature (also referred as SAS Token) from the connection string + * + * @return Shared Access Signature + */ + public String getSharedAccessSignature() { + return this.sharedAccessSignature; + } + + /** + * Set the shared access signature (also referred as SAS Token) from the connection string + * + * @param sharedAccessSignature the shared access key signature + * @return the {@link ConnectionStringBuilder} being set. + */ + public ConnectionStringBuilder setSharedAccessSignature(String sharedAccessSignature) { + this.sharedAccessSignature = sharedAccessSignature; + return this; + } + + /** + * OperationTimeout is applied in erroneous situations to notify the caller about the relevant {@link EventHubException} + * + * @return operationTimeout + */ + public Duration getOperationTimeout() { + return (this.operationTimeout == null ? MessagingFactory.DefaultOperationTimeout : this.operationTimeout); + } + + /** + * Set the OperationTimeout value in the Connection String. This value will be used by all operations which uses this {@link ConnectionStringBuilder}, unless explicitly over-ridden. + *

ConnectionString with operationTimeout is not inter-operable between java and clients in other platforms. + * + * @param operationTimeout Operation Timeout + * @return the {@link ConnectionStringBuilder} being set. + */ + public ConnectionStringBuilder setOperationTimeout(final Duration operationTimeout) { + this.operationTimeout = operationTimeout; + return this; + } + + /** + * TransportType on which all the communication for the EventHub objects created using this ConnectionString. + * Default value is {@link TransportType#AMQP}. + * + * @return transportType + */ + public TransportType getTransportType() { + return (this.transportType == null ? TransportType.AMQP : transportType); + } + + /** + * Set the TransportType value in the Connection String. If no TransportType is set, this defaults to {@link TransportType#AMQP}. + * + * @param transportType Transport Type + * @return the {@link ConnectionStringBuilder} instance being set. + */ + public ConnectionStringBuilder setTransportType(final TransportType transportType) { + this.transportType = transportType; + return this; + } + + /** + * Returns an inter-operable connection string that can be used to connect to EventHubs instances. + * + * @return connection string + */ + @Override + public String toString() { + final StringBuilder connectionStringBuilder = new StringBuilder(); + if (this.endpoint != null) { + connectionStringBuilder.append(String.format(Locale.US, "%s%s%s%s", EndpointConfigName, KeyValueSeparator, + this.endpoint.toString(), KeyValuePairDelimiter)); + } + + if (!StringUtil.isNullOrWhiteSpace(this.eventHubName)) { + connectionStringBuilder.append(String.format(Locale.US, "%s%s%s%s", EntityPathConfigName, + KeyValueSeparator, this.eventHubName, KeyValuePairDelimiter)); + } + + if (!StringUtil.isNullOrWhiteSpace(this.sharedAccessKeyName)) { + connectionStringBuilder.append(String.format(Locale.US, "%s%s%s%s", SharedAccessKeyNameConfigName, + KeyValueSeparator, this.sharedAccessKeyName, KeyValuePairDelimiter)); + } + + if (!StringUtil.isNullOrWhiteSpace(this.sharedAccessKey)) { + connectionStringBuilder.append(String.format(Locale.US, "%s%s%s%s", SharedAccessKeyConfigName, + KeyValueSeparator, this.sharedAccessKey, KeyValuePairDelimiter)); + } + + if (!StringUtil.isNullOrWhiteSpace(this.sharedAccessSignature)) { + connectionStringBuilder.append(String.format(Locale.US, "%s%s%s%s", SharedAccessSignatureConfigName, + KeyValueSeparator, this.sharedAccessSignature, KeyValuePairDelimiter)); + } + + if (this.operationTimeout != null) { + connectionStringBuilder.append(String.format(Locale.US, "%s%s%s%s", OperationTimeoutConfigName, + KeyValueSeparator, this.operationTimeout.toString(), KeyValuePairDelimiter)); + } + + if (this.transportType != null) { + connectionStringBuilder.append(String.format(Locale.US, "%s%s%s%s", TransportTypeConfigName, + KeyValueSeparator, this.transportType.toString(), KeyValuePairDelimiter)); + } + + connectionStringBuilder.deleteCharAt(connectionStringBuilder.length() - 1); + return connectionStringBuilder.toString(); + } + + + private void parseConnectionString(final String connectionString) { + if (StringUtil.isNullOrWhiteSpace(connectionString)) { + throw new IllegalConnectionStringFormatException("connectionString cannot be empty"); + } + + final String connection = KeyValuePairDelimiter + connectionString; + + final Pattern keyValuePattern = Pattern.compile(KeysWithDelimitersRegex, Pattern.CASE_INSENSITIVE); + final String[] values = keyValuePattern.split(connection); + final Matcher keys = keyValuePattern.matcher(connection); + + if (values == null || values.length <= 1 || keys.groupCount() == 0) { + throw new IllegalConnectionStringFormatException("Connection String cannot be parsed."); + } + + if (!StringUtil.isNullOrWhiteSpace((values[0]))) { + throw new IllegalConnectionStringFormatException( + String.format(Locale.US, "Cannot parse part of ConnectionString: %s", values[0])); + } + + int valueIndex = 0; + while (keys.find()) { + valueIndex++; + + String key = keys.group(); + key = key.substring(1, key.length() - 1); + + if (values.length < valueIndex + 1) { + throw new IllegalConnectionStringFormatException( + String.format(Locale.US, "Value for the connection string parameter name: %s, not found", key)); + } + + if (key.equalsIgnoreCase(EndpointConfigName)) { + if (this.endpoint != null) { + // we have parsed the endpoint once, which means we have multiple config which is not allowed + throw new IllegalConnectionStringFormatException( + String.format(Locale.US, "Multiple %s and/or %s detected. Make sure only one is defined", EndpointConfigName, HostnameConfigName)); + } + + try { + this.endpoint = new URI(values[valueIndex]); + } catch (URISyntaxException exception) { + throw new IllegalConnectionStringFormatException( + String.format(Locale.US, "%s should be in format scheme://fullyQualifiedServiceBusNamespaceEndpointName", EndpointConfigName), + exception); + } + } else if (key.equalsIgnoreCase(HostnameConfigName)) { + if (this.endpoint != null) { + // we have parsed the endpoint once, which means we have multiple config which is not allowed + throw new IllegalConnectionStringFormatException( + String.format(Locale.US, "Multiple %s and/or %s detected. Make sure only one is defined", EndpointConfigName, HostnameConfigName)); + } + + try { + this.endpoint = new URI(String.format(Locale.US, hostnameFormat, values[valueIndex])); + } catch (URISyntaxException exception) { + throw new IllegalConnectionStringFormatException( + String.format(Locale.US, "%s should be a fully quantified host name address", HostnameConfigName), + exception); + } + } else if (key.equalsIgnoreCase(SharedAccessKeyNameConfigName)) { + this.sharedAccessKeyName = values[valueIndex]; + } else if (key.equalsIgnoreCase(SharedAccessKeyConfigName)) { + this.sharedAccessKey = values[valueIndex]; + } else if (key.equalsIgnoreCase(SharedAccessSignatureConfigName)) { + this.sharedAccessSignature = values[valueIndex]; + } else if (key.equalsIgnoreCase(EntityPathConfigName)) { + this.eventHubName = values[valueIndex]; + } else if (key.equalsIgnoreCase(OperationTimeoutConfigName)) { + try { + this.operationTimeout = Duration.parse(values[valueIndex]); + } catch (DateTimeParseException exception) { + throw new IllegalConnectionStringFormatException("Invalid value specified for property 'Duration' in the ConnectionString.", exception); + } + } else if (key.equalsIgnoreCase(TransportTypeConfigName)) { + try { + this.transportType = TransportType.fromString(values[valueIndex]); + } catch (IllegalArgumentException exception) { + throw new IllegalConnectionStringFormatException( + String.format("Invalid value specified for property '%s' in the ConnectionString.", TransportTypeConfigName), + exception); + } + } else { + throw new IllegalConnectionStringFormatException( + String.format(Locale.US, "Illegal connection string parameter name: %s", key)); + } + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/ErrorContext.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/ErrorContext.java new file mode 100644 index 000000000000..2f56282b271a --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/ErrorContext.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +import com.microsoft.azure.eventhubs.impl.StringUtil; + +import java.io.Serializable; +import java.util.Locale; + +public abstract class ErrorContext implements Serializable { + private final String namespaceName; + + protected ErrorContext(final String namespaceName) { + this.namespaceName = namespaceName; + } + + protected String getNamespaceName() { + return this.namespaceName; + } + + @Override + public String toString() { + return StringUtil.isNullOrEmpty(this.namespaceName) + ? null + : String.format(Locale.US, "NS: %s", this.namespaceName); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/EventData.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/EventData.java new file mode 100755 index 000000000000..d9884bf248c5 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/EventData.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +import com.microsoft.azure.eventhubs.impl.AmqpConstants; +import com.microsoft.azure.eventhubs.impl.EventDataImpl; +import org.apache.qpid.proton.amqp.Binary; + +import java.io.Serializable; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ScheduledExecutorService; + +/** + * The data structure encapsulating the Event being sent-to and received-from EventHubs. + * Each EventHubs partition can be visualized as a Stream of {@link EventData}. + *

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

+ * Here's how AMQP message sections map to {@link EventData}. Here's the reference used for AMQP 1.0 specification: http://docs.oasis-open.org/amqp/core/v1.0/os/amqp-core-complete-v1.0-os.pdf + *

+ * i.   {@link #getProperties()} - AMQPMessage.ApplicationProperties section
+ * ii.  {@link #getBytes()} - if AMQPMessage.Body has Data section
+ * iii. {@link #getObject()} - if AMQPMessage.Body has AMQPValue or AMQPSequence sections
+ * 
+ * While using client libraries released by Microsoft Azure EventHubs, sections (i) and (ii) alone are sufficient. + * Section (iii) is used for advanced scenarios, where the sending application uses third-party AMQP library to send the message to EventHubs and the receiving application + * uses this client library to receive {@link EventData}. + */ +public interface EventData extends Serializable, Comparable { + + /** + * Construct EventData to Send to EventHubs. + * Typical pattern to create a Sending EventData is: + *
+     * i.	Serialize the sending ApplicationEvent to be sent to EventHubs into bytes.
+     * ii.	If complex serialization logic is involved (for example: multiple types of data) - add a Hint using the {@link #getProperties()} for the Consumer.
+     * 
+ *

Sample Code: + *

+     * EventData eventData = EventData.create(telemetryEventBytes);
+     * eventData.getProperties().put("eventType", "com.microsoft.azure.monitoring.EtlEvent");
+     * partitionSender.Send(eventData);
+     * 
+ * + * @param data the actual payload of data in bytes to be Sent to EventHubs. + * @return EventData the created {@link EventData} to send to EventHubs. + * @see EventHubClient#create(String, ScheduledExecutorService) + */ + static EventData create(final byte[] data) { + return new EventDataImpl(data); + } + + /** + * Construct EventData to Send to EventHubs. + * Typical pattern to create a Sending EventData is: + *
+     * i.	Serialize the sending ApplicationEvent to be sent to EventHubs into bytes.
+     * ii.	If complex serialization logic is involved (for example: multiple types of data) - add a Hint using the {@link #getProperties()} for the Consumer.
+     *  
+ *

Illustration: + *

 {@code
+     *  EventData eventData = EventData.create(telemetryEventBytes, offset, length);
+     *  eventData.getProperties().put("eventType", "com.microsoft.azure.monitoring.EtlEvent");
+     *  partitionSender.Send(eventData);
+     *  }
+ * + * @param data the byte[] where the payload of the Event to be sent to EventHubs is present + * @param offset Offset in the byte[] to read from ; inclusive index + * @param length length of the byte[] to be read, starting from offset + * @return EventData the created {@link EventData} to send to EventHubs. + * @see EventHubClient#create(String, ScheduledExecutorService) + */ + static EventData create(final byte[] data, final int offset, final int length) { + return new EventDataImpl(data, offset, length); + } + + /** + * Construct EventData to Send to EventHubs. + * Typical pattern to create a Sending EventData is: + *
+     * i.	Serialize the sending ApplicationEvent to be sent to EventHubs into bytes.
+     * ii.	If complex serialization logic is involved (for example: multiple types of data) - add a Hint using the {@link #getProperties()} for the Consumer.
+     *  
+ *

Illustration: + *

 {@code
+     *  EventData eventData = EventData.create(telemetryEventByteBuffer);
+     *  eventData.getProperties().put("eventType", "com.microsoft.azure.monitoring.EtlEvent");
+     * 	partitionSender.Send(eventData);
+     *  }
+ * + * @param buffer ByteBuffer which references the payload of the Event to be sent to EventHubs + * @return EventData the created {@link EventData} to send to EventHubs. + * @see EventHubClient#create(String, ScheduledExecutorService) + */ + static EventData create(final ByteBuffer buffer) { + return new EventDataImpl(buffer); + } + + /** + * Use this method only if, the sender could be sending messages using third-party AMQP libraries. + *

If all the senders of EventHub use client libraries released and maintained by Microsoft Azure EventHubs, use {@link #getBytes()} method. + *

Get the value of AMQP messages' Body section on the received {@link EventData}. + *

If the AMQP message Body is always guaranteed to have Data section, use {@link #getBytes()} method. + * + * @return returns the Object which could represent either Data or AmqpValue or AmqpSequence. + *

{@link Binary} if the Body is Data section + *

{@link List} if the Body is AmqpSequence + *

package org.apache.qpid.proton.amqp contains various AMQP types that could be returned. + */ + Object getObject(); + + /** + * Get Actual Payload/Data wrapped by EventData. + * + * @return byte[] of the actual data + *

null if the body of the message has other inter-operable AMQP messages, whose body does not represent byte[]. + * In that case use {@link #getObject()}. + */ + byte[] getBytes(); + + /** + * Application property bag + * + * @return returns Application properties + */ + Map getProperties(); + + /** + * SystemProperties that are populated by EventHubService. + *

As these are populated by Service, they are only present on a Received EventData. + *

Usage:

+ * + * final String offset = eventData.getSystemProperties().getOffset(); + * + * + * @return an encapsulation of all SystemProperties appended by EventHubs service into EventData. + * null if the {@link EventData} is not received and is created by the public constructors. + * @see SystemProperties#getOffset + * @see SystemProperties#getSequenceNumber + * @see SystemProperties#getPartitionKey + * @see SystemProperties#getEnqueuedTime + */ + SystemProperties getSystemProperties(); + + class SystemProperties extends HashMap { + private static final long serialVersionUID = -2827050124966993723L; + + public SystemProperties(final HashMap map) { + super(Collections.unmodifiableMap(map)); + } + + public String getOffset() { + return this.getSystemProperty(AmqpConstants.OFFSET_ANNOTATION_NAME); + } + + public String getPartitionKey() { + return this.getSystemProperty(AmqpConstants.PARTITION_KEY_ANNOTATION_NAME); + } + + public Instant getEnqueuedTime() { + final Date enqueuedTimeValue = this.getSystemProperty(AmqpConstants.ENQUEUED_TIME_UTC_ANNOTATION_NAME); + return enqueuedTimeValue != null ? enqueuedTimeValue.toInstant() : null; + } + + public long getSequenceNumber() { + return this.getSystemProperty(AmqpConstants.SEQUENCE_NUMBER_ANNOTATION_NAME); + } + + public String getPublisher() { + return this.getSystemProperty(AmqpConstants.PUBLISHER_ANNOTATION_NAME); + } + + @SuppressWarnings("unchecked") + private T getSystemProperty(final String key) { + if (this.containsKey(key)) { + return (T) (this.get(key)); + } + + return null; + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/EventDataBatch.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/EventDataBatch.java new file mode 100644 index 000000000000..97364bb43733 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/EventDataBatch.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +/** + * Helper for creating a batch/collection of EventData objects to be used while Sending to EventHubs + */ +public interface EventDataBatch { + + /** + * Get the number of events present in this {@link EventDataBatch} + * + * @return the EventDataBatch size + */ + int getSize(); + + /** + * Add's {@link EventData} to {@link EventDataBatch}, if permitted by the batch's size limit. + * This method is not thread-safe. + * + * @param eventData The {@link EventData} to add. + * @return A boolean value indicating if the {@link EventData} addition to this batch/collection was successful or not. + * @throws PayloadSizeExceededException when a single {@link EventData} instance exceeds maximum allowed size of the batch + */ + boolean tryAdd(final EventData eventData) throws PayloadSizeExceededException; +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/EventHubClient.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/EventHubClient.java new file mode 100644 index 000000000000..8fa03e6d3679 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/EventHubClient.java @@ -0,0 +1,477 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +import com.microsoft.azure.eventhubs.impl.EventHubClientImpl; +import com.microsoft.azure.eventhubs.impl.ExceptionUtil; + +import java.io.IOException; +import java.nio.channels.UnresolvedAddressException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.ScheduledExecutorService; + +/** + * Anchor class - all EventHub client operations STARTS here. + * + * @see EventHubClient#create(String, ScheduledExecutorService) + */ +public interface EventHubClient { + + String DEFAULT_CONSUMER_GROUP_NAME = "$Default"; + + /** + * Synchronous version of {@link #create(String, ScheduledExecutorService)}. + * + * @param connectionString The connection string to be used. See {@link ConnectionStringBuilder} to construct a connectionString. + * @param executor An {@link ScheduledExecutorService} to run all tasks performed by {@link EventHubClient}. + * @return EventHubClient which can be used to create Senders and Receivers to EventHub + * @throws EventHubException If Service Bus service encountered problems during connection creation. + * @throws IOException If the underlying Proton-J layer encounter network errors. + */ + static EventHubClient createSync(final String connectionString, final ScheduledExecutorService executor) + throws EventHubException, IOException { + return EventHubClient.createSync(connectionString, null, executor); + } + + /** + * Synchronous version of {@link #create(String, ScheduledExecutorService)}. + * + * @param connectionString The connection string to be used. See {@link ConnectionStringBuilder} to construct a connectionString. + * @param retryPolicy A custom {@link RetryPolicy} to be used when communicating with EventHub. + * @param executor An {@link ScheduledExecutorService} to run all tasks performed by {@link EventHubClient}. + * @return EventHubClient which can be used to create Senders and Receivers to EventHub + * @throws EventHubException If Service Bus service encountered problems during connection creation. + * @throws IOException If the underlying Proton-J layer encounter network errors. + */ + static EventHubClient createSync(final String connectionString, final RetryPolicy retryPolicy, final ScheduledExecutorService executor) + throws EventHubException, IOException { + return ExceptionUtil.syncWithIOException(() -> create(connectionString, retryPolicy, executor).get()); + } + + /** + * Factory method to create an instance of {@link EventHubClient} using the supplied connectionString. + * In a normal scenario (when re-direct is not enabled) - one EventHubClient instance maps to one Connection to the Azure ServiceBus EventHubs service. + *

The {@link EventHubClient} created from this method creates a Sender instance internally, which is used by the {@link #send(EventData)} methods. + * + * @param connectionString The connection string to be used. See {@link ConnectionStringBuilder} to construct a connectionString. + * @param executor An {@link ScheduledExecutorService} to run all tasks performed by {@link EventHubClient}. + * @return CompletableFuture{@literal } which can be used to create Senders and Receivers to EventHub + * @throws EventHubException If Service Bus service encountered problems during connection creation. + * @throws IOException If the underlying Proton-J layer encounter network errors. + */ + static CompletableFuture create(final String connectionString, final ScheduledExecutorService executor) + throws EventHubException, IOException { + return EventHubClient.create(connectionString, null, executor); + } + + /** + * Factory method to create an instance of {@link EventHubClient} using the supplied connectionString. + * In a normal scenario (when re-direct is not enabled) - one EventHubClient instance maps to one Connection to the Azure ServiceBus EventHubs service. + *

The {@link EventHubClient} created from this method creates a Sender instance internally, which is used by the {@link #send(EventData)} methods. + * + * @param connectionString The connection string to be used. See {@link ConnectionStringBuilder} to construct a connectionString. + * @param retryPolicy A custom {@link RetryPolicy} to be used when communicating with EventHub. + * @param executor An {@link ScheduledExecutorService} to run all tasks performed by {@link EventHubClient}. + * @return CompletableFuture{@literal } which can be used to create Senders and Receivers to EventHub + * @throws EventHubException If Service Bus service encountered problems during connection creation. + * @throws IOException If the underlying Proton-J layer encounter network errors. + */ + static CompletableFuture create( + final String connectionString, final RetryPolicy retryPolicy, final ScheduledExecutorService executor) + throws EventHubException, IOException { + return EventHubClientImpl.create(connectionString, retryPolicy, executor); + } + + /** + * @return the name of the Event Hub this client is connected to. + */ + String getEventHubName(); + + /** + * Creates an Empty Collection of {@link EventData}. + * The same partitionKey must be used while sending these events using {@link EventHubClient#send(EventDataBatch)}. + * + * @param options see {@link BatchOptions} for more details + * @return the empty {@link EventDataBatch}, after negotiating maximum message size with EventHubs service + * @throws EventHubException if the Microsoft Azure Event Hubs service encountered problems during the operation. + */ + EventDataBatch createBatch(BatchOptions options) throws EventHubException; + + /** + * Creates an Empty Collection of {@link EventData}. + * The same partitionKey must be used while sending these events using {@link EventHubClient#send(EventDataBatch)}. + * + * @return the empty {@link EventDataBatch}, after negotiating maximum message size with EventHubs service + * @throws EventHubException if the Microsoft Azure Event Hubs service encountered problems during the operation. + */ + default EventDataBatch createBatch() throws EventHubException { + return this.createBatch(new BatchOptions()); + } + + /** + * Synchronous version of {@link #send(EventData)}. + * + * @param data the {@link EventData} to be sent. + * @throws PayloadSizeExceededException if the total size of the {@link EventData} exceeds a predefined limit set by the service. Default is 256k bytes. + * @throws EventHubException if Service Bus service encountered problems during the operation. + * @throws UnresolvedAddressException if there are Client to Service network connectivity issues, if the Azure DNS resolution of the ServiceBus Namespace fails (ex: namespace deleted etc.) + */ + default void sendSync(final EventData data) throws EventHubException { + ExceptionUtil.syncVoid(() -> this.send(data).get()); + } + + /** + * Send {@link EventData} to EventHub. The sent {@link EventData} will land on any arbitrarily chosen EventHubs partition. + *

There are 3 ways to send to EventHubs, each exposed as a method (along with its sendBatch overload): + *

    + *
  • {@link #send(EventData)}, {@link #send(Iterable)}, or {@link #send(EventDataBatch)} + *
  • {@link #send(EventData, String)} or {@link #send(Iterable, String)} + *
  • {@link PartitionSender#send(EventData)}, {@link PartitionSender#send(Iterable)}, or {@link PartitionSender#send(EventDataBatch)} + *
+ *

Use this method to Send, if: + *

+     * a)  the send({@link EventData}) operation should be highly available and
+     * b)  the data needs to be evenly distributed among all partitions; exception being, when a subset of partitions are unavailable
+     * 
+ *

+ * {@link #send(EventData)} send's the {@link EventData} to a Service Gateway, which in-turn will forward the {@link EventData} to one of the EventHubs' partitions. Here's the message forwarding algorithm: + *

+     * i.  Forward the {@link EventData}'s to EventHub partitions, by equally distributing the data among all partitions (ex: Round-robin the {@link EventData}'s to all EventHubs' partitions)
+     * ii. If one of the EventHub partitions is unavailable for a moment, the Service Gateway will automatically detect it and forward the message to another available partition - making the Send operation highly-available.
+     * 
+ * + * @param data the {@link EventData} to be sent. + * @return a CompletableFuture that can be completed when the send operations is done.. + * @see #send(EventData, String) + * @see PartitionSender#send(EventData) + */ + CompletableFuture send(final EventData data); + + /** + * Synchronous version of {@link #send(Iterable)}. + * + * @param eventDatas batch of events to send to EventHub + * @throws PayloadSizeExceededException if the total size of the {@link EventData} exceeds a pre-defined limit set by the service. Default is 256k bytes. + * @throws EventHubException if Service Bus service encountered problems during the operation. + * @throws UnresolvedAddressException if there are Client to Service network connectivity issues, if the Azure DNS resolution of the ServiceBus Namespace fails (ex: namespace deleted etc.) + */ + default void sendSync(final Iterable eventDatas) throws EventHubException { + ExceptionUtil.syncVoid(() -> this.send(eventDatas).get()); + } + + /** + * Send a batch of {@link EventData} to EventHub. The sent {@link EventData} will land on any arbitrarily chosen EventHubs partition. + * This is the most recommended way to Send to EventHubs. + *

There are 3 ways to send to EventHubs, to understand this particular type of Send refer to the overload {@link #send(EventData)}, which is used to send single {@link EventData}. + * Use this overload versus {@link #send(EventData)}, if you need to send a batch of {@link EventData}. + *

Sending a batch of {@link EventData}'s is useful in the following cases: + *

+     * i.	Efficient send - sending a batch of {@link EventData} maximizes the overall throughput by optimally using the number of sessions created to EventHubs' service.
+     * ii.	Send multiple {@link EventData}'s in a Transaction. To achieve ACID properties, the Gateway Service will forward all {@link EventData}'s in the batch to a single EventHubs' partition.
+     * 
+ *

+ * Sample code (sample uses sync version of the api but concept are identical): + *

+     * Gson gson = new GsonBuilder().create();
+     * EventHubClient client = EventHubClient.createSync("__connection__");
+     *
+     * while (true)
+     * {
+     *     LinkedList{@literal<}EventData{@literal>} events = new LinkedList{@literal<}EventData{@literal>}();}
+     *     for (int count = 1; count {@literal<} 11; count++)
+     *     {
+     *         PayloadEvent payload = new PayloadEvent(count);
+     *         byte[] payloadBytes = gson.toJson(payload).getBytes(Charset.defaultCharset());
+     *         EventData sendEvent = new EventData(payloadBytes);
+     *         sendEvent.getProperties().put("from", "javaClient");
+     *         events.add(sendEvent);
+     *     }
+     *
+     *     client.sendSync(events);
+     *     System.out.println(String.format("Sent Batch... Size: %s", events.size()));
+     * }
+     * 
+ *

for Exceptions refer to {@link #sendSync(Iterable)} + * + * @param eventDatas batch of events to send to EventHub + * @return a CompletableFuture that can be completed when the send operations is done.. + * @see #send(EventData, String) + * @see PartitionSender#send(EventData) + */ + CompletableFuture send(final Iterable eventDatas); + + /** + * Synchronous version of {@link #send(EventDataBatch)}. + * + * @param eventDatas EventDataBatch to send to EventHub + * @throws EventHubException if Service Bus service encountered problems during the operation. + */ + default void sendSync(final EventDataBatch eventDatas) throws EventHubException { + ExceptionUtil.syncVoid(() -> this.send(eventDatas).get()); + } + + /** + * Send {@link EventDataBatch} to EventHub. The sent {@link EventDataBatch} will land according the partition key + * set in the {@link EventDataBatch}. If a partition key is not set, then we will Round-robin the {@link EventData}'s + * to all EventHubs' partitions. + * + * @param eventDatas EventDataBatch to send to EventHub + * @return a CompleteableFuture that can be completed when the send operations are done + * @see #send(Iterable) + * @see EventDataBatch + */ + CompletableFuture send(final EventDataBatch eventDatas); + + /** + * Synchronous version of {@link #send(EventData, String)}. + * + * @param eventData the {@link EventData} to be sent. + * @param partitionKey the partitionKey will be hash'ed to determine the partitionId to send the eventData to. On the Received message this can be accessed at {@link EventData.SystemProperties#getPartitionKey()} + * @throws PayloadSizeExceededException if the total size of the {@link EventData} exceeds a pre-defined limit set by the service. Default is 256k bytes. + * @throws EventHubException if Service Bus service encountered problems during the operation. + */ + default void sendSync(final EventData eventData, final String partitionKey) throws EventHubException { + ExceptionUtil.syncVoid(() -> this.send(eventData, partitionKey).get()); + } + + /** + * Send an '{@link EventData} with a partitionKey' to EventHub. All {@link EventData}'s with a partitionKey are guaranteed to land on the same partition. + * This send pattern emphasize data correlation over general availability and latency. + *

+ * There are 3 ways to send to EventHubs, each exposed as a method (along with its sendBatch overload): + *

+     * i.   {@link #send(EventData)} or {@link #send(Iterable)}
+     * ii.  {@link #send(EventData, String)} or {@link #send(Iterable, String)}
+     * iii. {@link PartitionSender#send(EventData)} or {@link PartitionSender#send(Iterable)}
+     * 
+ *

+ * Use this type of Send, if: + *

+     * i.  There is a need for correlation of events based on Sender instance; The sender can generate a UniqueId and set it as partitionKey - which on the received Message can be used for correlation
+     * ii. The client wants to take control of distribution of data across partitions.
+     * 
+ *

+ * Multiple PartitionKey's could be mapped to one Partition. EventHubs service uses a proprietary Hash algorithm to map the PartitionKey to a PartitionId. + * Using this type of Send (Sending using a specific partitionKey), could sometimes result in partitions which are not evenly distributed. + * + * @param eventData the {@link EventData} to be sent. + * @param partitionKey the partitionKey will be hash'ed to determine the partitionId to send the eventData to. On the Received message this can be accessed at {@link EventData.SystemProperties#getPartitionKey()} + * @return a CompletableFuture that can be completed when the send operations is done.. + * @see #send(EventData) + * @see PartitionSender#send(EventData) + */ + CompletableFuture send(final EventData eventData, final String partitionKey); + + /** + * Synchronous version of {@link #send(Iterable, String)}. + * + * @param eventDatas the batch of events to send to EventHub + * @param partitionKey the partitionKey will be hash'ed to determine the partitionId to send the eventData to. On the Received message this can be accessed at {@link EventData.SystemProperties#getPartitionKey()} + * @throws PayloadSizeExceededException if the total size of the {@link EventData} exceeds a pre-defined limit set by the service. Default is 256k bytes. + * @throws EventHubException if Service Bus service encountered problems during the operation. + * @throws UnresolvedAddressException if there are Client to Service network connectivity issues, if the Azure DNS resolution of the ServiceBus Namespace fails (ex: namespace deleted etc.) + */ + default void sendSync(final Iterable eventDatas, final String partitionKey) throws EventHubException { + ExceptionUtil.syncVoid(() -> this.send(eventDatas, partitionKey).get()); + } + + /** + * Send a 'batch of {@link EventData} with the same partitionKey' to EventHub. All {@link EventData}'s with a partitionKey are guaranteed to land on the same partition. + * Multiple PartitionKey's will be mapped to one Partition. + *

There are 3 ways to send to EventHubs, to understand this particular type of Send refer to the overload {@link #send(EventData, String)}, which is the same type of Send and is used to send single {@link EventData}. + *

Sending a batch of {@link EventData}'s is useful in the following cases: + *

+     * i.	Efficient send - sending a batch of {@link EventData} maximizes the overall throughput by optimally using the number of sessions created to EventHubs service.
+     * ii.	Send multiple events in One Transaction. This is the reason why all events sent in a batch needs to have same partitionKey (so that they are sent to one partition only).
+     * 
+ * + * @param eventDatas the batch of events to send to EventHub + * @param partitionKey the partitionKey will be hash'ed to determine the partitionId to send the eventData to. On the Received message this can be accessed at {@link EventData.SystemProperties#getPartitionKey()} + * @return a CompletableFuture that can be completed when the send operations is done.. + * @see #send(EventData) + * @see PartitionSender#send(EventData) + */ + CompletableFuture send(final Iterable eventDatas, final String partitionKey); + + /** + * Synchronous version of {@link #createPartitionSender(String)}. + * + * @param partitionId partitionId of EventHub to send the {@link EventData}'s to + * @return PartitionSenderImpl which can be used to send events to a specific partition. + * @throws EventHubException if Service Bus service encountered problems during connection creation. + */ + default PartitionSender createPartitionSenderSync(final String partitionId) throws EventHubException, IllegalArgumentException { + return ExceptionUtil.syncWithIllegalArgException(() -> this.createPartitionSender(partitionId).get()); + } + + /** + * Create a {@link PartitionSender} which can publish {@link EventData}'s directly to a specific EventHub partition (sender type iii. in the below list). + *

+ * There are 3 patterns/ways to send to EventHubs: + *

+     * i.   {@link #send(EventData)} or {@link #send(Iterable)}
+     * ii.  {@link #send(EventData, String)} or {@link #send(Iterable, String)}
+     * iii. {@link PartitionSender#send(EventData)} or {@link PartitionSender#send(Iterable)}
+     * 
+ * + * @param partitionId partitionId of EventHub to send the {@link EventData}'s to + * @return a CompletableFuture that would result in a PartitionSenderImpl when it is completed. + * @throws EventHubException if Service Bus service encountered problems during connection creation. + * @see PartitionSender + */ + CompletableFuture createPartitionSender(final String partitionId) throws EventHubException; + + /** + * Synchronous version of {@link #createReceiver(String, String, EventPosition)}. + * + * @param consumerGroupName the consumer group name that this receiver should be grouped under. + * @param partitionId the partition Id that the receiver belongs to. All data received will be from this partition only. + * @param eventPosition the position to start receiving the events from. See {@link EventPosition} + * @return PartitionReceiver instance which can be used for receiving {@link EventData}. + * @throws EventHubException if Service Bus service encountered problems during the operation. + */ + default PartitionReceiver createReceiverSync(final String consumerGroupName, final String partitionId, final EventPosition eventPosition) throws EventHubException { + return ExceptionUtil.sync(() -> this.createReceiver(consumerGroupName, partitionId, eventPosition).get()); + } + + /** + * Create the EventHub receiver with given partition id and start receiving from the specified starting offset. + * The receiver is created for a specific EventHub Partition from the specific consumer group. + * + * @param consumerGroupName the consumer group name that this receiver should be grouped under. + * @param partitionId the partition Id that the receiver belongs to. All data received will be from this partition only. + * @param eventPosition the position to start receiving the events from. See {@link EventPosition} + * @return a CompletableFuture that would result in a PartitionReceiver instance when it is completed. + * @throws EventHubException if Service Bus service encountered problems during the operation. + * @see PartitionReceiver + */ + CompletableFuture createReceiver(final String consumerGroupName, final String partitionId, final EventPosition eventPosition) throws EventHubException; + + /** + * Synchronous version of {@link #createReceiver(String, String, EventPosition)}. + * + * @param consumerGroupName the consumer group name that this receiver should be grouped under. + * @param partitionId the partition Id that the receiver belongs to. All data received will be from this partition only. + * @param eventPosition the position to start receiving the events from. See {@link EventPosition} + * @param receiverOptions the set of options to enable on the event hubs receiver + * @return PartitionReceiver instance which can be used for receiving {@link EventData}. + * @throws EventHubException if Service Bus service encountered problems during the operation. + */ + default PartitionReceiver createReceiverSync(final String consumerGroupName, final String partitionId, final EventPosition eventPosition, final ReceiverOptions receiverOptions) throws EventHubException { + return ExceptionUtil.sync(() -> this.createReceiver(consumerGroupName, partitionId, eventPosition, receiverOptions).get()); + } + + /** + * Create the EventHub receiver with given partition id and start receiving from the specified starting offset. + * The receiver is created for a specific EventHub Partition from the specific consumer group. + * + * @param consumerGroupName the consumer group name that this receiver should be grouped under. + * @param partitionId the partition Id that the receiver belongs to. All data received will be from this partition only. + * @param eventPosition the position to start receiving the events from. See {@link EventPosition} + * @param receiverOptions the set of options to enable on the event hubs receiver + * @return a CompletableFuture that would result in a PartitionReceiver instance when it is completed. + * @throws EventHubException if Service Bus service encountered problems during the operation. + * @see PartitionReceiver + */ + CompletableFuture createReceiver(final String consumerGroupName, final String partitionId, final EventPosition eventPosition, final ReceiverOptions receiverOptions) throws EventHubException; + + /** + * Synchronous version of {@link #createEpochReceiver(String, String, EventPosition, long)}. + * + * @param consumerGroupName the consumer group name that this receiver should be grouped under. + * @param partitionId the partition Id that the receiver belongs to. All data received will be from this partition only. + * @param eventPosition the position to start receiving the events from. See {@link EventPosition} + * @param epoch an unique identifier (epoch value) that the service uses, to enforce partition/lease ownership. + * @return PartitionReceiver instance which can be used for receiving {@link EventData}. + * @throws EventHubException if Service Bus service encountered problems during the operation. + */ + default PartitionReceiver createEpochReceiverSync(final String consumerGroupName, final String partitionId, final EventPosition eventPosition, final long epoch) throws EventHubException { + return ExceptionUtil.sync(() -> this.createEpochReceiver(consumerGroupName, partitionId, eventPosition, epoch).get()); + } + + /** + * Create a Epoch based EventHub receiver with given partition id and start receiving from the beginning of the partition stream. + * The receiver is created for a specific EventHub Partition from the specific consumer group. + *

+ * It is important to pay attention to the following when creating epoch based receiver: + *

    + *
  • Ownership enforcement - Once you created an epoch based receiver, you cannot create a non-epoch receiver to the same consumerGroup-Partition combo until all receivers to the combo are closed. + *
  • Ownership stealing - If a receiver with higher epoch value is created for a consumerGroup-Partition combo, any older epoch receiver to that combo will be force closed. + *
  • Any receiver closed due to lost of ownership to a consumerGroup-Partition combo will get ReceiverDisconnectedException for all operations from that receiver. + *
+ * + * @param consumerGroupName the consumer group name that this receiver should be grouped under. + * @param partitionId the partition Id that the receiver belongs to. All data received will be from this partition only. + * @param eventPosition the position to start receiving the events from. See {@link EventPosition} + * @param epoch an unique identifier (epoch value) that the service uses, to enforce partition/lease ownership. + * @return a CompletableFuture that would result in a PartitionReceiver when it is completed. + * @throws EventHubException if Service Bus service encountered problems during the operation. + * @see PartitionReceiver + * @see ReceiverDisconnectedException + */ + CompletableFuture createEpochReceiver(final String consumerGroupName, final String partitionId, final EventPosition eventPosition, final long epoch) throws EventHubException; + + /** + * Synchronous version of {@link #createEpochReceiver(String, String, EventPosition, long)}. + * + * @param consumerGroupName the consumer group name that this receiver should be grouped under. + * @param partitionId the partition Id that the receiver belongs to. All data received will be from this partition only. + * @param eventPosition the position to start receiving the events from. See {@link EventPosition} + * @param epoch an unique identifier (epoch value) that the service uses, to enforce partition/lease ownership. + * @param receiverOptions the set of options to enable on the event hubs receiver + * @return PartitionReceiver instance which can be used for receiving {@link EventData}. + * @throws EventHubException if Service Bus service encountered problems during the operation. + */ + default PartitionReceiver createEpochReceiverSync(final String consumerGroupName, final String partitionId, final EventPosition eventPosition, final long epoch, final ReceiverOptions receiverOptions) throws EventHubException { + return ExceptionUtil.sync(() -> this.createEpochReceiver(consumerGroupName, partitionId, eventPosition, epoch, receiverOptions).get()); + } + + /** + * Create a Epoch based EventHub receiver with given partition id and start receiving from the beginning of the partition stream. + * The receiver is created for a specific EventHub Partition from the specific consumer group. + *

+ * It is important to pay attention to the following when creating epoch based receiver: + *

    + *
  • Ownership enforcement - Once you created an epoch based receiver, you cannot create a non-epoch receiver to the same consumerGroup-Partition combo until all receivers to the combo are closed. + *
  • Ownership stealing - If a receiver with higher epoch value is created for a consumerGroup-Partition combo, any older epoch receiver to that combo will be force closed. + *
  • Any receiver closed due to lost of ownership to a consumerGroup-Partition combo will get ReceiverDisconnectedException for all operations from that receiver. + *
+ * + * @param consumerGroupName the consumer group name that this receiver should be grouped under. + * @param partitionId the partition Id that the receiver belongs to. All data received will be from this partition only. + * @param eventPosition the position to start receiving the events from. See {@link EventPosition} + * @param epoch an unique identifier (epoch value) that the service uses, to enforce partition/lease ownership. + * @param receiverOptions the set of options to enable on the event hubs receiver + * @return a CompletableFuture that would result in a PartitionReceiver when it is completed. + * @throws EventHubException if Service Bus service encountered problems during the operation. + * @see PartitionReceiver + * @see ReceiverDisconnectedException + */ + CompletableFuture createEpochReceiver(final String consumerGroupName, final String partitionId, final EventPosition eventPosition, final long epoch, final ReceiverOptions receiverOptions) throws EventHubException; + + /** + * Retrieves general information about an event hub (see {@link EventHubRuntimeInformation} for details). + * Retries until it reaches the operation timeout, then either rethrows the last error if available or + * returns null to indicate timeout. + * + * @return CompletableFuture which returns an EventHubRuntimeInformation on success, or null on timeout. + */ + CompletableFuture getRuntimeInformation(); + + /** + * Retrieves dynamic information about a partition of an event hub (see {@link PartitionRuntimeInformation} for + * details. Retries until it reaches the operation timeout, then either rethrows the last error if available or + * returns null to indicate timeout. + * + * @param partitionId Partition to get information about. Must be one of the partition ids returned by {@link #getRuntimeInformation}. + * @return CompletableFuture which returns an PartitionRuntimeInformation on success, or null on timeout. + */ + CompletableFuture getPartitionRuntimeInformation(String partitionId); + + CompletableFuture close(); + + void closeSync() throws EventHubException; +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/EventHubException.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/EventHubException.java new file mode 100644 index 000000000000..f7ae9a2db72b --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/EventHubException.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +import com.microsoft.azure.eventhubs.impl.StringUtil; + +import java.util.Locale; + +/** + * This is the base exception that service bus will produce for all error cases. + */ +public class EventHubException extends Exception { + private static final long serialVersionUID = -3654294093967132325L; + + private final boolean isTransient; + private ErrorContext errorContext; + + EventHubException(final boolean isTransient) { + super(); + this.isTransient = isTransient; + } + + public EventHubException(final boolean isTransient, final String message) { + super(message); + this.isTransient = isTransient; + } + + public EventHubException(final boolean isTransient, final Throwable cause) { + super(cause); + this.isTransient = isTransient; + } + + public EventHubException(final boolean isTransient, final String message, final Throwable cause) { + super(message, cause); + this.isTransient = isTransient; + } + + @Override + public String getMessage() { + final String baseMessage = super.getMessage(); + return this.errorContext == null || StringUtil.isNullOrEmpty(this.errorContext.toString()) + ? baseMessage + : (!StringUtil.isNullOrEmpty(baseMessage) + ? String.format(Locale.US, "%s, %s[%s]", baseMessage, "errorContext", this.errorContext.toString()) + : String.format(Locale.US, "%s[%s]", "errorContext", this.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 getIsTransient() { + return this.isTransient; + } + + public ErrorContext getContext() { + return this.errorContext; + } + + public void setContext(ErrorContext errorContext) { + this.errorContext = errorContext; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/EventHubRuntimeInformation.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/EventHubRuntimeInformation.java new file mode 100644 index 000000000000..405756d7c005 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/EventHubRuntimeInformation.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +import java.time.Instant; + +/** + * Holds information about Event Hubs which can come handy while performing data-plane operations + * like {@link EventHubClient#createPartitionSender(String)} and {@link EventHubClient#createReceiver(String, String, EventPosition)} + */ +public final class EventHubRuntimeInformation { + + final String path; + final Instant createdAt; + final int partitionCount; + final String[] partitionIds; + + public EventHubRuntimeInformation( + final String path, + final Instant createdAt, + final int partitionCount, + final String[] partitionIds) { + this.path = path; + this.createdAt = createdAt; + this.partitionCount = partitionCount; + this.partitionIds = partitionIds; + } + + /** + * Event Hub name + * + * @return name + */ + public String getPath() { + return this.path; + } + + /** + * Time at which Event Hub was created at. + * + * @return created time + */ + public Instant getCreatedAt() { + return this.createdAt; + } + + /** + * Number of partitions in the Event Hub. + * + * @return partition count + */ + public int getPartitionCount() { + return this.partitionCount; + } + + /** + * List of Partition identifiers of the Event Hub. + * + * @return partition identifiers + */ + public String[] getPartitionIds() { + return this.partitionIds; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/EventPosition.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/EventPosition.java new file mode 100644 index 000000000000..797fbb2d0446 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/EventPosition.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +import com.microsoft.azure.eventhubs.impl.EventPositionImpl; + +import java.io.Serializable; +import java.time.Instant; + +/** + * Defines a position of an {@link EventData} in the event hub partition. + * The position can be an Offset, Sequence Number, or EnqueuedTime. + */ +public interface EventPosition extends Serializable { + + /** + * Creates a position at the given offset. The specified event will not be included. + * Instead, the next event is returned. + * + * @param offset is the byte offset of the event. + * @return An {@link EventPosition} object. + */ + static EventPosition fromOffset(String offset) { + return EventPositionImpl.fromOffset(offset); + } + + /** + * Creates a position at the given offset. + * + * @param offset is the byte offset of the event. + * @param inclusiveFlag will include the specified event when set to true; otherwise, the next event is returned. + * @return An {@link EventPosition} object. + */ + static EventPosition fromOffset(String offset, boolean inclusiveFlag) { + return EventPositionImpl.fromOffset(offset, inclusiveFlag); + } + + /** + * 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. + */ + static EventPosition fromSequenceNumber(Long sequenceNumber) { + return EventPositionImpl.fromSequenceNumber(sequenceNumber); + } + + /** + * 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. + * @param inclusiveFlag will include the specified event when set to true; otherwise, the next event is returned. + * @return An {@link EventPosition} object. + */ + static EventPosition fromSequenceNumber(Long sequenceNumber, boolean inclusiveFlag) { + return EventPositionImpl.fromSequenceNumber(sequenceNumber, inclusiveFlag); + } + + /** + * Creates a position at the given {@link Instant}. + * + * @param dateTime is the enqueued time of the event. + * @return An {@link EventPosition} object. + */ + static EventPosition fromEnqueuedTime(Instant dateTime) { + return EventPositionImpl.fromEnqueuedTime(dateTime); + } + + /** + * Returns the position for the start of a stream. Provide this position in receiver creation + * to start receiving from the first available event in the partition. + * + * @return An {@link EventPosition} set to the start of an Event Hubs stream. + */ + static EventPosition fromStartOfStream() { + return EventPositionImpl.fromStartOfStream(); + } + + /** + * Returns the position for the end of a stream. Provide this position in receiver creation + * to start receiving from the next available event in the partition after the receiver is created. + * + * @return An {@link EventPosition} set to the end of an Event Hubs stream. + */ + static EventPosition fromEndOfStream() { + return EventPositionImpl.fromEndOfStream(); + } + + /** + * Gets the sequence number. + *

+ * @return the sequence number. + */ + Long getSequenceNumber(); + + /** + * Gets the enqueued time. + *

+ * @return the enqueued time. + */ + Instant getEnqueuedTime(); + + /** + * Gets the offset. + *

+ * @return the offset. + */ + String getOffset(); + + /** + * Gets the inclusive value. + *

+ * @return the inclusive value. + */ + boolean getInclusiveFlag(); +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/IllegalConnectionStringFormatException.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/IllegalConnectionStringFormatException.java new file mode 100644 index 000000000000..f52fb4c53356 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/IllegalConnectionStringFormatException.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +/** + * This exception is thrown when the connection string provided does not meet the requirement for connection. + */ +public class IllegalConnectionStringFormatException extends IllegalArgumentException { + private static final long serialVersionUID = 2514898858133972030L; + + IllegalConnectionStringFormatException() { + } + + IllegalConnectionStringFormatException(String detail) { + super(detail); + } + + IllegalConnectionStringFormatException(Throwable cause) { + super(cause); + } + + IllegalConnectionStringFormatException(String detail, Throwable cause) { + super(detail, cause); + } + +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/IllegalEntityException.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/IllegalEntityException.java new file mode 100644 index 000000000000..94aa9abe242c --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/IllegalEntityException.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +/** + * This exception is thrown for the following reasons: + *

    + *
  • When the entity user attempted to connect does not exist + *
  • The entity user wants to connect is disabled + *
+ * + * @see http://go.microsoft.com/fwlink/?LinkId=761101 + */ +public class IllegalEntityException extends EventHubException { + private static final long serialVersionUID = 1842057379278310290L; + + // TEST HOOK - to be used by unit tests to inject non-transient failures + private static volatile boolean isTransient = false; + + IllegalEntityException() { + super(isTransient); + } + + public IllegalEntityException(final String message) { + super(isTransient, message); + } + + public IllegalEntityException(final Throwable cause) { + super(isTransient, cause); + } + + public IllegalEntityException(final String message, final Throwable cause) { + super(isTransient, message, cause); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/OperationCancelledException.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/OperationCancelledException.java new file mode 100644 index 000000000000..a25a5656b5c3 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/OperationCancelledException.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +/** + * This exception is thrown when the underlying AMQP layer encounter an abnormal link abort or disconnect of connection in an unexpected fashion. + */ +public class OperationCancelledException extends EventHubException { + private static final long serialVersionUID = 1L; + + OperationCancelledException() { + super(false); + } + + public OperationCancelledException(final String message) { + super(false, message); + } + + OperationCancelledException(final Throwable cause) { + super(false, cause); + } + + public OperationCancelledException(final String message, final Throwable cause) { + super(false, message, cause); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/PartitionReceiveHandler.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/PartitionReceiveHandler.java new file mode 100644 index 000000000000..e88776a8a7a9 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/PartitionReceiveHandler.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +/** + * The handler to invoke after receiving {@link EventData}s from Microsoft Azure EventHubs. Use any implementation of this abstract class to specify + * user action when using PartitionReceiver's setReceiveHandler(). + * + * @see PartitionReceiver#setReceiveHandler + */ +public interface PartitionReceiveHandler { + + /** + * Maximum number of {@link EventData} to supply while invoking {@link #onReceive(Iterable)} + *

Ensure that the value should be less than or equal to the value of {@link ReceiverOptions#getPrefetchCount()} + * + * @return value indicating the maximum number of {@link EventData} to supply while invoking {@link #onReceive(Iterable)} + */ + int getMaxEventCount(); + + /** + * user should implement this method to specify the action to be performed on the received events. + * + * @param events the list of fetched events from the corresponding PartitionReceiver. + * @see PartitionReceiver#receive + */ + void onReceive(final Iterable events); + + /** + * Implement this method to Listen to errors which lead to Closure of the {@link PartitionReceiveHandler} pump. + * + * @param error fatal error encountered while running the {@link PartitionReceiveHandler} pump + */ + void onError(final Throwable error); +} \ No newline at end of file diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/PartitionReceiver.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/PartitionReceiver.java new file mode 100644 index 000000000000..83912309702a --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/PartitionReceiver.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +import com.microsoft.azure.eventhubs.impl.ExceptionUtil; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; + +/** + * This is a logical representation of receiving from a EventHub partition. + *

+ * A {@link PartitionReceiver} is tied to a ConsumerGroup + EventHub Partition combination. + *

    + *
  • If an epoch based {@link PartitionReceiver} (i.e., PartitionReceiver.getEpoch != 0) is created, EventHubs service will guarantee only 1 active receiver exists per ConsumerGroup + Partition combo. + * This is the recommended approach to create a {@link PartitionReceiver}. + *
  • Multiple receivers per ConsumerGroup + Partition combo can be created using non-epoch receivers. + *
+ * + * @see EventHubClient#createReceiver + * @see EventHubClient#createEpochReceiver + */ +public interface PartitionReceiver { + + int MINIMUM_PREFETCH_COUNT = 1; + int DEFAULT_PREFETCH_COUNT = 500; + int MAXIMUM_PREFETCH_COUNT = 2000; + + long NULL_EPOCH = 0; + + /** + * Get EventHubs partition identifier. + * + * @return The identifier representing the partition from which this receiver is fetching data + */ + String getPartitionId(); + + Duration getReceiveTimeout(); + + void setReceiveTimeout(Duration value); + + /** + * Get the epoch value that this receiver is currently using for partition ownership. + *

+ * A value of 0 means this receiver is not an epoch-based receiver. + * + * @return the epoch value that this receiver is currently using for partition ownership. + */ + long getEpoch(); + + /** + * Gets the temporal {@link ReceiverRuntimeInformation} for this EventHub partition. + * In general, this information is a representation of, where this {@link PartitionReceiver}'s end of stream is, + * at the time {@link ReceiverRuntimeInformation#getRetrievalTime()}. + *

This value will not be populated, unless the knob {@link ReceiverOptions#setReceiverRuntimeMetricEnabled(boolean)} is set. + * This value will be refreshed every time an {@link EventData} is consumed from {@link PartitionReceiver}. + * For ex: if no events have been consumed, then this value is not populated. + * + * @return receiver runtime information + */ + ReceiverRuntimeInformation getRuntimeInformation(); + + /** + * Get the {@link EventPosition} that corresponds to an {@link EventData} which was returned last by the receiver. + *

This value will not be populated, unless the knob {@link ReceiverOptions#setReceiverRuntimeMetricEnabled(boolean)} is set. + * Note that EventPosition object is initialized using SequenceNumber and other parameters are not set and get will return null. + * + * @return the EventPosition object. + */ + EventPosition getEventPosition(); + + /** + * Synchronous version of {@link #receive}. + * + * @param maxEventCount maximum number of {@link EventData}'s that this call should return + * @return Batch of {@link EventData}'s from the partition on which this receiver is created. Returns 'null' if no {@link EventData} is present. + * @throws EventHubException if ServiceBus client encountered any unrecoverable/non-transient problems during {@link #receive} + */ + default Iterable receiveSync(final int maxEventCount) throws EventHubException { + return ExceptionUtil.sync(() -> this.receive(maxEventCount).get()); + } + + /** + * Receive a batch of {@link EventData}'s from an EventHub partition + *

+ * Sample code (sample uses sync version of the api but concept are identical): + *

+     * EventHubClient client = EventHubClient.createSync("__connection__");
+     * PartitionReceiver receiver = client.createPartitionReceiverSync("ConsumerGroup1", "1");
+     * Iterable{@literal<}EventData{@literal>} receivedEvents = receiver.receiveSync();
+     *
+     * while (true)
+     * {
+     *     int batchSize = 0;
+     *     if (receivedEvents != null)
+     *     {
+     *         for(EventData receivedEvent: receivedEvents)
+     *         {
+     *             System.out.println(String.format("Message Payload: %s", new String(receivedEvent.getBytes(), Charset.defaultCharset())));
+     *             System.out.println(String.format("Offset: %s, SeqNo: %s, EnqueueTime: %s",
+     *                 receivedEvent.getSystemProperties().getOffset(),
+     *                 receivedEvent.getSystemProperties().getSequenceNumber(),
+     *                 receivedEvent.getSystemProperties().getEnqueuedTime()));
+     *             batchSize++;
+     *         }
+     *     }
+     *
+     *     System.out.println(String.format("ReceivedBatch Size: %s", batchSize));
+     *     receivedEvents = receiver.receiveSync();
+     * }
+     * 
+ * + * @param maxEventCount maximum number of {@link EventData}'s that this call should return + * @return A completableFuture that will yield a batch of {@link EventData}'s from the partition on which this receiver is created. Returns 'null' if no {@link EventData} is present. + */ + CompletableFuture> receive(final int maxEventCount); + + /** + * Register a receive handler that will be called when an event is available. A + * {@link PartitionReceiveHandler} is a handler that allows user to specify a callback + * for event processing and error handling in a receive pump model. + * + * @param receiveHandler An implementation of {@link PartitionReceiveHandler}. Setting this handler to null will stop the receive pump. + * @return A completableFuture which sets receiveHandler + */ + CompletableFuture setReceiveHandler(final PartitionReceiveHandler receiveHandler); + + /** + * Register a receive handler that will be called when an event is available. A + * {@link PartitionReceiveHandler} is a handler that allows user to specify a callback + * for event processing and error handling in a receive pump model. + * + * @param receiveHandler An implementation of {@link PartitionReceiveHandler} + * @param invokeWhenNoEvents flag to indicate whether the {@link PartitionReceiveHandler#onReceive(Iterable)} should be invoked when the receive call times out + * @return A completableFuture which sets receiveHandler + */ + CompletableFuture setReceiveHandler(final PartitionReceiveHandler receiveHandler, final boolean invokeWhenNoEvents); + + CompletableFuture close(); + + void closeSync() throws EventHubException; +} \ No newline at end of file diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/PartitionRuntimeInformation.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/PartitionRuntimeInformation.java new file mode 100644 index 000000000000..c086f63ca818 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/PartitionRuntimeInformation.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +import java.time.Instant; + +public final class PartitionRuntimeInformation { + + private final String eventHubPath; + private final String partitionId; + private final long beginSequenceNumber; + private final long lastEnqueuedSequenceNumber; + private final String lastEnqueuedOffset; + private final Instant lastEnqueuedTimeUtc; + private final boolean isEmpty; + + public PartitionRuntimeInformation( + final String eventHubPath, + final String partitionId, + final long beginSequenceNumber, + final long lastEnqueuedSequenceNumber, + final String lastEnqueuedOffset, + final Instant lastEnqueuedTimeUtc, + final boolean isEmpty) { + + this.eventHubPath = eventHubPath; + this.partitionId = partitionId; + this.beginSequenceNumber = beginSequenceNumber; + this.lastEnqueuedSequenceNumber = lastEnqueuedSequenceNumber; + this.lastEnqueuedOffset = lastEnqueuedOffset; + this.lastEnqueuedTimeUtc = lastEnqueuedTimeUtc; + this.isEmpty = isEmpty; + } + + public String getEventHubPath() { + return this.eventHubPath; + } + + public String getPartitionId() { + return this.partitionId; + } + + public long getBeginSequenceNumber() { + return this.beginSequenceNumber; + } + + public long getLastEnqueuedSequenceNumber() { + return this.lastEnqueuedSequenceNumber; + } + + public String getLastEnqueuedOffset() { + return this.lastEnqueuedOffset; + } + + public Instant getLastEnqueuedTimeUtc() { + return this.lastEnqueuedTimeUtc; + } + + public boolean getIsEmpty() { + return this.isEmpty; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/PartitionSender.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/PartitionSender.java new file mode 100644 index 000000000000..b394714de8d4 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/PartitionSender.java @@ -0,0 +1,161 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +import com.microsoft.azure.eventhubs.impl.ExceptionUtil; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ScheduledExecutorService; + +/** + * This sender class is a logical representation of sending events to a specific EventHub partition. Do not use this class + * if you do not care about sending events to specific partitions. Instead, use {@link EventHubClient#send} method. + * + * @see EventHubClient#createPartitionSender(String) + * @see EventHubClient#create(String, ScheduledExecutorService) + */ +public interface PartitionSender { + + /** + * The partition id that will receive events from this sender. + * + * @return the partition id the PartitionSender is connected to. + */ + String getPartitionId(); + + /** + * Creates an Empty Collection of {@link EventData}. + * The same partitionKey must be used while sending these events using {@link PartitionSender#send(EventDataBatch)}. + * + * @param options see {@link BatchOptions} for more usage details + * @return the empty {@link EventDataBatch}, after negotiating maximum message size with EventHubs service + */ + EventDataBatch createBatch(BatchOptions options); + + /** + * Creates an Empty Collection of {@link EventData}. + * The same partitionKey must be used while sending these events using {@link PartitionSender#send(EventDataBatch)}. + * + * @return the empty {@link EventDataBatch}, after negotiating maximum message size with EventHubs service + */ + default EventDataBatch createBatch() { + return this.createBatch(new BatchOptions()); + } + + /** + * Synchronous version of {@link #send(EventData)} Api. + * + * @param data the {@link EventData} to be sent. + * @throws PayloadSizeExceededException if the total size of the {@link EventData} exceeds a pre-defined limit set by the service. Default is 256k bytes. + * @throws EventHubException if Service Bus service encountered problems during the operation. + */ + default void sendSync(final EventData data) throws EventHubException { + ExceptionUtil.syncVoid(() -> this.send(data).get()); + } + + /** + * Send {@link EventData} to a specific EventHub partition. The target partition is pre-determined when this PartitionSender was created. + * This send pattern emphasize data correlation over general availability and latency. + *

+ * There are 3 ways to send to EventHubs, each exposed as a method (along with its sendBatch overload): + *

+     * i.   {@link EventHubClient#send(EventData)}, {@link EventHubClient#send(Iterable)}, {@link EventHubClient#send(EventDataBatch)}
+     * ii.  {@link EventHubClient#send(EventData, String)} or {@link EventHubClient#send(Iterable, String)}
+     * iii. {@link PartitionSender#send(EventData)}, {@link PartitionSender#send(Iterable)}, or {@link PartitionSender#send(EventDataBatch)}
+     * 
+ *

+ * Use this type of Send, if: + *

+     * i. The client wants to take direct control of distribution of data across partitions. In this case client is responsible for making sure there is at least one sender per event hub partition.
+     * ii. User cannot use partition key as a mean to direct events to specific partition, yet there is a need for data correlation with partitioning scheme.
+     * 
+ * + * @param data the {@link EventData} to be sent. + * @return a CompletableFuture that can be completed when the send operations is done.. + */ + CompletableFuture send(EventData data); + + /** + * Synchronous version of {@link #send(Iterable)} . + * + * @param eventDatas batch of events to send to EventHub + * @throws EventHubException if Service Bus service encountered problems during the operation. + */ + default void sendSync(final Iterable eventDatas) throws EventHubException { + ExceptionUtil.syncVoid(() -> this.send(eventDatas).get()); + } + + /** + * Send {@link EventData} to a specific EventHub partition. The targeted partition is pre-determined when this PartitionSender was created. + *

+ * There are 3 ways to send to EventHubs, to understand this particular type of Send refer to the overload {@link #send(EventData)}, which is the same type of Send and is used to send single {@link EventData}. + *

+ * Sending a batch of {@link EventData}'s is useful in the following cases: + *

+     * i.	Efficient send - sending a batch of {@link EventData} maximizes the overall throughput by optimally using the number of sessions created to EventHubs' service.
+     * ii.	Send multiple {@link EventData}'s in a Transaction. To achieve ACID properties, the Gateway Service will forward all {@link EventData}'s in the batch to a single EventHubs' partition.
+     * 
+ *

+ * Sample code (sample uses sync version of the api but concept are identical): + *

+     * Gson gson = new GsonBuilder().create();
+     * EventHubClient client = EventHubClient.createSync("__connection__");
+     * PartitionSender senderToPartitionOne = client.createPartitionSenderSync("1");
+     *
+     * while (true)
+     * {
+     *     LinkedList{@literal<}EventData{@literal>} events = new LinkedList{@literal<}EventData{@literal>}();
+     *     for (int count = 1; count {@literal<} 11; count++)
+     *     {
+     *         PayloadEvent payload = new PayloadEvent(count);
+     *         byte[] payloadBytes = gson.toJson(payload).getBytes(Charset.defaultCharset());
+     *         EventData sendEvent = EventData.create(payloadBytes);
+     *         sendEvent.getProperties().put("from", "javaClient");
+     *         events.add(sendEvent);
+     *     }
+     *
+     *     senderToPartitionOne.sendSync(events);
+     *     System.out.println(String.format("Sent Batch... Size: %s", events.size()));
+     * }
+     * 
+ * + * @param eventDatas batch of events to send to EventHub + * @return a CompletableFuture that can be completed when the send operations is done.. + */ + CompletableFuture send(Iterable eventDatas); + + /** + * Synchronous version of {@link #send(EventDataBatch)} + * + * @param eventDatas EventDataBatch to send to EventHub + * @throws EventHubException if Service Bus service encountered problems during the operation. + */ + default void sendSync(final EventDataBatch eventDatas) throws EventHubException { + ExceptionUtil.syncVoid(() -> this.send(eventDatas).get()); + } + + /** + * Send {@link EventDataBatch} to a specific EventHub partition. The targeted partition is pre-determined when this PartitionSender was created. + * A partitionKey cannot be set when using EventDataBatch with a PartitionSender. + *

+ * There are 3 ways to send to EventHubs, to understand this particular type of Send refer to the overload {@link #send(EventData)}, which is the same type of Send and is used to send single {@link EventData}. + *

+ * Sending a batch of {@link EventData}'s is useful in the following cases: + *

+     * i.	Efficient send - sending a batch of {@link EventData} maximizes the overall throughput by optimally using the number of sessions created to EventHubs' service.
+     * ii.	Send multiple {@link EventData}'s in a Transaction. To achieve ACID properties, the Gateway Service will forward all {@link EventData}'s in the batch to a single EventHubs' partition.
+     * 
+ * + * @param eventDatas EventDataBatch to send to EventHub + * @return a CompletableFuture that can be completed when the send operation is done.. + * @see #send(Iterable) + * @see EventDataBatch + */ + CompletableFuture send(EventDataBatch eventDatas); + + CompletableFuture close(); + + void closeSync() throws EventHubException; +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/PayloadSizeExceededException.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/PayloadSizeExceededException.java new file mode 100644 index 000000000000..4df415f5b9a7 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/PayloadSizeExceededException.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +/** + * this exception is thrown when user attempts to send a event data or brokered message that has exceeded the + * allowed payload size as defined by the service. Note that in a batch send scenario the limit can include possible + * batch overhead. + * + * @see http://go.microsoft.com/fwlink/?LinkId=761101 + */ +public class PayloadSizeExceededException extends EventHubException { + + private static final long serialVersionUID = 3627182744252750014L; + + PayloadSizeExceededException() { + super(false); + } + + public PayloadSizeExceededException(final String message) { + super(false, message); + } + + PayloadSizeExceededException(final Throwable cause) { + super(false, cause); + } + + public PayloadSizeExceededException(final String message, final Throwable cause) { + super(false, message, cause); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/QuotaExceededException.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/QuotaExceededException.java new file mode 100644 index 000000000000..c622fd46835c --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/QuotaExceededException.java @@ -0,0 +1,17 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +public class QuotaExceededException extends EventHubException { + + public QuotaExceededException(String message) { + super(false, message); + } + + public QuotaExceededException(Throwable cause) { + super(false, cause); + } + +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/ReceiverDisconnectedException.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/ReceiverDisconnectedException.java new file mode 100644 index 000000000000..51df584645fa --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/ReceiverDisconnectedException.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +/** + * This exception is thrown when a EventHubReceiver is being disconnected because of one of the + * following reason: + *
    + *
  • user attempts to connect a non-epoch receiver to a event hub partition, when there is an epoch receiver connected to the partition. + *
  • you are using an epoch receiver for a given partition but another epoch receiver with a higher epoch value connects to the same partition. + *
+ * User should make sure either all code are using non-epoch receivers, or ensure that there is only one epoch receiver processing a given partition + * at any given point in time. + * + * @see http://go.microsoft.com/fwlink/?LinkId=761101 + */ +public class ReceiverDisconnectedException extends EventHubException { + + private static final long serialVersionUID = 3385140843418138213L; + + ReceiverDisconnectedException() { + super(false); + } + + public ReceiverDisconnectedException(final String message) { + super(false, message); + } + + ReceiverDisconnectedException(final Throwable cause) { + super(false, cause); + } + + ReceiverDisconnectedException(final String message, final Throwable cause) { + super(false, message, cause); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/ReceiverOptions.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/ReceiverOptions.java new file mode 100644 index 000000000000..d5cdf6307137 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/ReceiverOptions.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +import com.microsoft.azure.eventhubs.impl.ClientConstants; + +import java.util.Locale; + +/** + * Represents various optional behaviors which can be turned on or off during the creation of a {@link PartitionReceiver}. + */ +public final class ReceiverOptions { + + private boolean receiverRuntimeMetricEnabled; + private String identifier; + private int prefetchCount; + + public ReceiverOptions() { + this.prefetchCount = PartitionReceiver.DEFAULT_PREFETCH_COUNT; + } + + private static void validateReceiverIdentifier(final String receiverName) { + + if (receiverName != null && + receiverName.length() > ClientConstants.MAX_RECEIVER_NAME_LENGTH) { + throw new IllegalArgumentException("receiverIdentifier length cannot exceed 64"); + } + } + + /** + * Knob to enable/disable runtime metric of the receiver. If this is set to true and is passed to {@link EventHubClient#createReceiver}, + * after the first {@link PartitionReceiver#receive(int)} call, {@link PartitionReceiver#getRuntimeInformation()} is populated. + *

+ * Enabling this knob will add 3 additional properties to all {@link EventData}'s received on the {@link EventHubClient#createReceiver}. + * + * @return the {@link boolean} indicating, whether, the runtime metric of the receiver was enabled + */ + public boolean getReceiverRuntimeMetricEnabled() { + + return this.receiverRuntimeMetricEnabled; + } + + /** + * Knob to enable/disable runtime metric of the receiver. If this is set to true and is passed to {@link EventHubClient#createReceiver}, + * after the first {@link PartitionReceiver#receive(int)} call, {@link PartitionReceiver#getRuntimeInformation()} and + * {@link PartitionReceiver#getEventPosition()} will be populated. + *

+ * This knob facilitates for an optimization where the Consumer of Event Hub has the end of stream details at the disposal, + * without making any additional {@link EventHubClient#getPartitionRuntimeInformation(String)} call to Event Hubs service. + * To achieve this, behind the scenes, along with the actual {@link EventData}, that the Event Hubs {@link PartitionReceiver} + * delivers, it includes extra information about the Event Hubs partitions end of stream details on every event. + * In summary, enabling this knob will + * help users to save an extra call to Event Hubs service to fetch Event Hubs partition information and as a result, will add that information as + * header to each {@link EventData} received by the client. + * + * @param value the {@link boolean} to indicate, whether, the runtime metric of the receiver should be enabled + */ + public void setReceiverRuntimeMetricEnabled(boolean value) { + + this.receiverRuntimeMetricEnabled = value; + } + + /** + * Gets the identifier of the {@link PartitionReceiver} + * + * @return identifier of the {@link PartitionReceiver}; null if nothing was set + */ + public String getIdentifier() { + + return this.identifier; + } + + /** + * Set an identifier to {@link PartitionReceiver}. + *

+ * This identifier will be used by EventHubs service when reporting any errors across receivers, and is caused by this receiver. + * For example, when receiver quota limit is hit, while a user is trying to create New receiver, + * EventHubs service will throw {@link QuotaExceededException} and will include this identifier. + * So, its very critical to choose a value, which can uniquely identify the whereabouts of {@link PartitionReceiver}. + *

+ *

+ * + * @param value string to identify {@link PartitionReceiver} + */ + public void setIdentifier(final String value) { + + ReceiverOptions.validateReceiverIdentifier(value); + this.identifier = value; + } + + /** + * Get Prefetch Count. + * + * @return the upper limit of events this receiver will actively receive regardless of whether a receive operation is pending. + * @see #setPrefetchCount + */ + public int getPrefetchCount() { + return this.prefetchCount; + } + + /** + * Set the number of events that can be pre-fetched and cached at the {@link PartitionReceiver}. + *

By default the value is 500 + * + * @param prefetchCount the number of events to pre-fetch. value must be between 1 and 2000. + * @throws EventHubException if setting prefetchCount encounters error + */ + public void setPrefetchCount(final int prefetchCount) throws EventHubException { + if (prefetchCount < PartitionReceiver.MINIMUM_PREFETCH_COUNT) { + throw new IllegalArgumentException(String.format(Locale.US, + "PrefetchCount has to be above %s", PartitionReceiver.MINIMUM_PREFETCH_COUNT)); + } + + if (prefetchCount > PartitionReceiver.MAXIMUM_PREFETCH_COUNT) { + throw new IllegalArgumentException(String.format(Locale.US, + "PrefetchCount has to be below %s", PartitionReceiver.MAXIMUM_PREFETCH_COUNT)); + } + + this.prefetchCount = prefetchCount; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/ReceiverRuntimeInformation.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/ReceiverRuntimeInformation.java new file mode 100644 index 000000000000..b2d7d056eafc --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/ReceiverRuntimeInformation.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +import java.time.Instant; + +/** + * Represents the temporal end of stream information of an EventHubs Partition. + */ +public final class ReceiverRuntimeInformation { + + private final String partitionId; + + private long lastSequenceNumber; + private Instant lastEnqueuedTime; + private String lastEnqueuedOffset; + private Instant retrievalTime; + + public ReceiverRuntimeInformation(final String partitionId) { + + this.partitionId = partitionId; + } + + /** + * The Event Hubs partition id to which this information belongs to + * + * @return the partition identifier + */ + public String getPartitionId() { + + return this.partitionId; + } + + /** + * The last enqueued {@link EventData}'s sequence number on this EventHubs Partition + * + * @return last enqueued sequence number + */ + public long getLastEnqueuedSequenceNumber() { + + return this.lastSequenceNumber; + } + + /** + * The last enqueued {@link EventData}'s enqueue time stamp on this EventHubs Partition + * + * @return last enqueued time + */ + public Instant getLastEnqueuedTime() { + + return this.lastEnqueuedTime; + } + + /** + * The last enqueued {@link EventData}'s offset on this EventHubs Partition + * + * @return offset + */ + public String getLastEnqueuedOffset() { + + return this.lastEnqueuedOffset; + } + + /** + * The value indicating when this information was retrieved from the Event Hubs service + * + * @return retrieval time + */ + public Instant getRetrievalTime() { + + return this.retrievalTime; + } + + public void setRuntimeInformation(final long sequenceNumber, final Instant enqueuedTime, final String offset) { + + this.lastSequenceNumber = sequenceNumber; + this.lastEnqueuedTime = enqueuedTime; + this.lastEnqueuedOffset = offset; + + this.retrievalTime = Instant.now(); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/RetryPolicy.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/RetryPolicy.java new file mode 100644 index 000000000000..abbd775ce010 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/RetryPolicy.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +import com.microsoft.azure.eventhubs.impl.ClientConstants; +import com.microsoft.azure.eventhubs.impl.RetryExponential; + +import java.time.Duration; +import java.util.concurrent.ConcurrentHashMap; + +// TODO: SIMPLIFY retryPolicy - ConcurrentHashMap is not needed +public abstract class RetryPolicy { + + private static final RetryPolicy NO_RETRY = new RetryExponential(Duration.ofSeconds(0), Duration.ofSeconds(0), 0, ClientConstants.NO_RETRY); + + private final String name; + private ConcurrentHashMap retryCounts; + private Object serverBusySync; + + protected RetryPolicy(final String name) { + this.name = name; + this.retryCounts = new ConcurrentHashMap(); + this.serverBusySync = new Object(); + } + + public static boolean isRetryableException(Exception exception) { + if (exception == null) { + throw new IllegalArgumentException("exception cannot be null"); + } + + if (exception instanceof EventHubException) { + return ((EventHubException) exception).getIsTransient(); + } + + return false; + } + + public static RetryPolicy getDefault() { + return new RetryExponential( + ClientConstants.DEFAULT_RETRY_MIN_BACKOFF, + ClientConstants.DEFAULT_RETRY_MAX_BACKOFF, + ClientConstants.DEFAULT_MAX_RETRY_COUNT, + ClientConstants.DEFAULT_RETRY); + } + + public static RetryPolicy getNoRetry() { + return RetryPolicy.NO_RETRY; + } + + public void incrementRetryCount(String clientId) { + Integer retryCount = this.retryCounts.get(clientId); + this.retryCounts.put(clientId, retryCount == null ? 1 : retryCount + 1); + } + + public void resetRetryCount(String clientId) { + Integer currentRetryCount = this.retryCounts.get(clientId); + if (currentRetryCount != null && currentRetryCount.intValue() != 0) { + this.retryCounts.put(clientId, 0); + } + } + + protected int getRetryCount(String clientId) { + Integer retryCount = this.retryCounts.get(clientId); + return retryCount == null ? 0 : retryCount; + } + + /** + * Gets the Interval after which nextRetry should be done. + * + * @param clientId clientId + * @param lastException lastException + * @param remainingTime remainingTime to retry + * @return returns 'null' Duration when not Allowed + */ + public Duration getNextRetryInterval(String clientId, Exception lastException, Duration remainingTime) { + int baseWaitTime = 0; + synchronized (this.serverBusySync) { + if (lastException != null && + (lastException instanceof ServerBusyException || (lastException.getCause() != null && lastException.getCause() instanceof ServerBusyException))) { + baseWaitTime += ClientConstants.SERVER_BUSY_BASE_SLEEP_TIME_IN_SECS; + } + } + + return this.onGetNextRetryInterval(clientId, lastException, remainingTime, baseWaitTime); + } + + protected abstract Duration onGetNextRetryInterval(String clientId, Exception lastException, Duration remainingTime, int baseWaitTime); + + @Override + public String toString() { + return this.name; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/ServerBusyException.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/ServerBusyException.java new file mode 100644 index 000000000000..f83d9d596dd6 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/ServerBusyException.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +/** + * Server busy exception is thrown when the current entity's activity has put excessive load onto the service. + * When encountered this exception user should wait at least 4 seconds before any retry/runtime operations for the said entity again. + * + * @see http://go.microsoft.com/fwlink/?LinkId=761101 + */ +public class ServerBusyException extends EventHubException { + + private static final long serialVersionUID = 1L; + + /** + * Default constructor for the exception + */ + public ServerBusyException() { + super(true); + } + + public ServerBusyException(final String message) { + super(true, message); + } + + ServerBusyException(final Throwable cause) { + super(true, cause); + } + + ServerBusyException(final String message, final Throwable cause) { + super(true, message, cause); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/TimeoutException.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/TimeoutException.java new file mode 100644 index 000000000000..6e18878c07e2 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/TimeoutException.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +/** + * This exception is thrown when the operation has exceeded the predetermined time limit. + * User should check connectivity is healthy between client process and service. + * + * @see http://go.microsoft.com/fwlink/?LinkId=761101 + */ +public class TimeoutException extends EventHubException { + + private static final long serialVersionUID = -3505469991851121512L; + + /** + * Default constructor for exception type. + */ + public TimeoutException() { + super(true); + } + + public TimeoutException(final String message) { + super(true, message); + } + + public TimeoutException(final Throwable cause) { + super(true, cause); + } + + public TimeoutException(final String message, final Throwable cause) { + super(true, message, cause); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/TransportType.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/TransportType.java new file mode 100644 index 000000000000..c7231280b56f --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/TransportType.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs; + +/** + * All TransportType switches available for communicating to EventHubs service. + */ +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; + } + + @Override + public String toString() { + return this.value; + } + + static TransportType fromString(final String value) { + for (TransportType transportType : values()) { + if (transportType.value.equalsIgnoreCase(value)) { + return transportType; + } + } + + throw new IllegalArgumentException(); + } +} \ No newline at end of file diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ActiveClientTokenManager.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ActiveClientTokenManager.java new file mode 100644 index 000000000000..c2c339e369f7 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ActiveClientTokenManager.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; + +final class ActiveClientTokenManager { + + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(ActiveClientTokenManager.class); + private final Object timerLock; + private final Runnable sendTokenTask; + private final ClientEntity clientEntity; + private final Duration tokenRefreshInterval; + private final SchedulerProvider schedulerProvider; + private final Timer timerScheduler; + private CompletableFuture timer; + + public ActiveClientTokenManager( + final ClientEntity clientEntity, + final Runnable sendTokenAsync, + final Duration tokenRefreshInterval, + final SchedulerProvider schedulerProvider) { + + this.sendTokenTask = sendTokenAsync; + this.clientEntity = clientEntity; + this.tokenRefreshInterval = tokenRefreshInterval; + this.timerLock = new Object(); + this.schedulerProvider = schedulerProvider; + this.timerScheduler = new Timer(schedulerProvider); + + synchronized (this.timerLock) { + this.timer = this.timerScheduler.schedule(new TimerCallback(), tokenRefreshInterval); + } + } + + public void cancel() { + + synchronized (this.timerLock) { + this.timer.cancel(false); + } + } + + private class TimerCallback implements Runnable { + + @Override + public void run() { + + if (!clientEntity.getIsClosingOrClosed()) { + + sendTokenTask.run(); + + synchronized (ActiveClientTokenManager.this.timerLock) { + ActiveClientTokenManager.this.timer = ActiveClientTokenManager.this.timerScheduler.schedule(new TimerCallback(), tokenRefreshInterval); + } + } else { + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info( + String.format(Locale.US, + "clientEntity[%s] - closing ActiveClientLinkManager", clientEntity.getClientId())); + } + } + } + + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpConnection.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpConnection.java new file mode 100644 index 000000000000..8713accb358d --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpConnection.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.amqp.transport.ErrorCondition; +import org.apache.qpid.proton.engine.Link; + +public interface AmqpConnection { + + /** + * Host name intended to be used on Amqp Connection Open frame + * @return host name + */ + String getHostName(); + + void onOpenComplete(Exception exception); + + void onConnectionError(ErrorCondition error); + + void registerForConnectionError(Link link); + + void deregisterForConnectionError(Link link); +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpConstants.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpConstants.java new file mode 100644 index 000000000000..8ca75a0d2692 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpConstants.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.amqp.Symbol; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +public final class AmqpConstants { + + public static final String APACHE = "apache.org"; + public static final String VENDOR = "com.microsoft"; + public static final String AMQP_ANNOTATION_FORMAT = "amqp.annotation.%s >%s '%s'"; + public static final String OFFSET_ANNOTATION_NAME = "x-opt-offset"; + public static final String ENQUEUED_TIME_UTC_ANNOTATION_NAME = "x-opt-enqueued-time"; + public static final String PARTITION_KEY_ANNOTATION_NAME = "x-opt-partition-key"; + public static final String SEQUENCE_NUMBER_ANNOTATION_NAME = "x-opt-sequence-number"; + public static final String PUBLISHER_ANNOTATION_NAME = "x-opt-publisher"; + public static final Symbol PARTITION_KEY = Symbol.getSymbol(PARTITION_KEY_ANNOTATION_NAME); + public static final Symbol OFFSET = Symbol.getSymbol(OFFSET_ANNOTATION_NAME); + public static final Symbol SEQUENCE_NUMBER = Symbol.getSymbol(SEQUENCE_NUMBER_ANNOTATION_NAME); + public static final Symbol ENQUEUED_TIME_UTC = Symbol.getSymbol(ENQUEUED_TIME_UTC_ANNOTATION_NAME); + public static final Symbol STRING_FILTER = Symbol.valueOf(APACHE + ":selector-filter:string"); + public static final Symbol EPOCH = Symbol.valueOf(VENDOR + ":epoch"); + public static final Symbol PRODUCT = Symbol.valueOf("product"); + public static final Symbol VERSION = Symbol.valueOf("version"); + public static final Symbol PLATFORM = Symbol.valueOf("platform"); + public static final Symbol FRAMEWORK = Symbol.valueOf("framework"); + public static final Symbol USER_AGENT = Symbol.valueOf("user-agent"); + public static final int MAX_USER_AGENT_LENGTH = 128; + public static final int AMQP_BATCH_MESSAGE_FORMAT = 0x80013700; // 2147563264L; + public static final int MAX_FRAME_SIZE = 65536; + public static final String AMQP_PROPERTY_MESSAGE_ID = "message-id"; + public static final String AMQP_PROPERTY_USER_ID = "user-id"; + public static final String AMQP_PROPERTY_TO = "to"; + public static final String AMQP_PROPERTY_SUBJECT = "subject"; + public static final String AMQP_PROPERTY_REPLY_TO = "reply-to"; + public static final String AMQP_PROPERTY_CORRELATION_ID = "correlation-id"; + public static final String AMQP_PROPERTY_CONTENT_TYPE = "content-type"; + public static final String AMQP_PROPERTY_CONTENT_ENCODING = "content-encoding"; + public static final String AMQP_PROPERTY_ABSOLUTE_EXPRITY_TIME = "absolute-expiry-time"; + public static final String AMQP_PROPERTY_CREATION_TIME = "creation-time"; + public static final String AMQP_PROPERTY_GROUP_ID = "group-id"; + public static final String AMQP_PROPERTY_GROUP_SEQUENCE = "group-sequence"; + public static final String AMQP_PROPERTY_REPLY_TO_GROUP_ID = "reply-to-group-id"; + @SuppressWarnings("serial") + public static final Set RESERVED_PROPERTY_NAMES = Collections.unmodifiableSet(new HashSet() {{ + add(AMQP_PROPERTY_MESSAGE_ID); + add(AMQP_PROPERTY_USER_ID); + add(AMQP_PROPERTY_TO); + add(AMQP_PROPERTY_SUBJECT); + add(AMQP_PROPERTY_REPLY_TO); + add(AMQP_PROPERTY_CORRELATION_ID); + add(AMQP_PROPERTY_CONTENT_TYPE); + add(AMQP_PROPERTY_CONTENT_ENCODING); + add(AMQP_PROPERTY_ABSOLUTE_EXPRITY_TIME); + add(AMQP_PROPERTY_CREATION_TIME); + add(AMQP_PROPERTY_GROUP_ID); + add(AMQP_PROPERTY_GROUP_SEQUENCE); + add(AMQP_PROPERTY_REPLY_TO_GROUP_ID); + }}); + public static final Symbol ENABLE_RECEIVER_RUNTIME_METRIC_NAME = Symbol.valueOf(VENDOR + ":enable-receiver-runtime-metric"); + public static final Symbol RECEIVER_IDENTIFIER_NAME = Symbol.valueOf(AmqpConstants.VENDOR + ":receiver-name"); + private AmqpConstants() { + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpErrorCode.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpErrorCode.java new file mode 100644 index 000000000000..79cd3af28b50 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpErrorCode.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.amqp.Symbol; + +public final class AmqpErrorCode { + + public static final Symbol NotFound = Symbol.getSymbol("amqp:not-found"); + public static final Symbol UnauthorizedAccess = Symbol.getSymbol("amqp:unauthorized-access"); + public static final Symbol ResourceLimitExceeded = Symbol.getSymbol("amqp:resource-limit-exceeded"); + public static final Symbol NotAllowed = Symbol.getSymbol("amqp:not-allowed"); + public static final Symbol InternalError = Symbol.getSymbol("amqp:internal-error"); + public static final Symbol IllegalState = Symbol.getSymbol("amqp:illegal-state"); + public static final Symbol NotImplemented = Symbol.getSymbol("amqp:not-implemented"); + + // link errors + public static final Symbol Stolen = Symbol.getSymbol("amqp:link:stolen"); + public static final Symbol PayloadSizeExceeded = Symbol.getSymbol("amqp:link:message-size-exceeded"); + public static final Symbol AmqpLinkDetachForced = Symbol.getSymbol("amqp:link:detach-forced"); + + // connection errors + public static final Symbol ConnectionForced = Symbol.getSymbol("amqp:connection:forced"); + + // proton library introduced this amqpsymbol 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"); +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpException.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpException.java new file mode 100644 index 000000000000..fb9da08eab0c --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpException.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.amqp.transport.ErrorCondition; + +/** + * All AmqpExceptions - which EventHub client handles internally. + */ +public class AmqpException extends Exception { + private static final long serialVersionUID = -750417419234273714L; + private ErrorCondition errorCondition; + + public AmqpException(ErrorCondition errorCondition) { + super(errorCondition.getDescription()); + this.errorCondition = errorCondition; + } + + public ErrorCondition getError() { + return this.errorCondition; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpLink.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpLink.java new file mode 100644 index 000000000000..70e77ee07d78 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpLink.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.amqp.transport.ErrorCondition; + +public interface AmqpLink { + /** + * @param completionException completionException=null if open is successful + */ + void onOpenComplete(Exception completionException); + + void onError(Exception exception); + + void onClose(ErrorCondition condition); +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpReceiver.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpReceiver.java new file mode 100644 index 000000000000..2273f2ab3864 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpReceiver.java @@ -0,0 +1,11 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.engine.Delivery; + +public interface AmqpReceiver extends AmqpLink { + void onReceiveComplete(Delivery delivery); +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpResponseCode.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpResponseCode.java new file mode 100644 index 000000000000..4915cf3188c0 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpResponseCode.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import java.util.HashMap; +import java.util.Map; + +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; + + private AmqpResponseCode(final int value) { + this.value = value; + } + + public static AmqpResponseCode valueOf(final int value) { + return valueMap.get(value); + } + + public int getValue() { + return this.value; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpSender.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpSender.java new file mode 100644 index 000000000000..cc5708736385 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpSender.java @@ -0,0 +1,13 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.engine.Delivery; + +public interface AmqpSender extends AmqpLink { + void onFlow(final int creditIssued); + + void onSendComplete(final Delivery delivery); +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpUtil.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpUtil.java new file mode 100644 index 000000000000..ab9861ab960a --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/AmqpUtil.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +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.Locale; + +public final class AmqpUtil { + + private AmqpUtil() { + } + + 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; + } + + public 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) { + for (Symbol value : messageAnnotations.getValue().keySet()) { + annotationsSize += sizeof(value); + } + + for (Object value : messageAnnotations.getValue().values()) { + annotationsSize += sizeof(value); + } + } + + if (applicationProperties != null) { + for (Object value : applicationProperties.getValue().keySet()) { + applicationPropertiesSize += sizeof(value); + } + + for (Object value : applicationProperties.getValue().values()) { + applicationPropertiesSize += sizeof(value); + } + } + + return annotationsSize + applicationPropertiesSize + payloadSize; + } + + 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())); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/BaseLinkHandler.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/BaseLinkHandler.java new file mode 100644 index 000000000000..4386bd3ad831 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/BaseLinkHandler.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.amqp.transport.ErrorCondition; +import org.apache.qpid.proton.engine.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class BaseLinkHandler extends BaseHandler { + protected static final Logger TRACE_LOGGER = LoggerFactory.getLogger(BaseLinkHandler.class); + + private final AmqpLink underlyingEntity; + + public BaseLinkHandler(final AmqpLink amqpLink) { + this.underlyingEntity = amqpLink; + } + + @Override + public void onLinkLocalClose(Event event) { + final Link link = event.getLink(); + final ErrorCondition condition = link.getCondition(); + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format("onLinkLocalClose linkName[%s], errorCondition[%s], errorDescription[%s]", + link.getName(), + condition != null ? condition.getCondition() : "n/a", + condition != null ? condition.getDescription() : "n/a")); + } + + closeSession(link, link.getCondition()); + } + + @Override + public void onLinkRemoteClose(Event event) { + final Link link = event.getLink(); + final ErrorCondition condition = link.getCondition(); + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format("onLinkRemoteClose linkName[%s], errorCondition[%s], errorDescription[%s]", + link.getName(), + condition != null ? condition.getCondition() : "n/a", + condition != null ? condition.getDescription() : "n/a")); + } + + handleRemoteLinkClosed(event); + } + + @Override + public void onLinkRemoteDetach(Event event) { + final Link link = event.getLink(); + final ErrorCondition condition = link.getCondition(); + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format("onLinkRemoteDetach linkName[%s], errorCondition[%s], errorDescription[%s]", + link.getName(), + condition != null ? condition.getCondition() : "n/a", + condition != null ? condition.getDescription() : "n/a")); + } + + handleRemoteLinkClosed(event); + } + + public void processOnClose(Link link, ErrorCondition condition) { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format("processOnClose linkName[%s], errorCondition[%s], errorDescription[%s]", + link.getName(), + condition != null ? condition.getCondition() : "n/a", + condition != null ? condition.getDescription() : "n/a")); + } + + this.underlyingEntity.onClose(condition); + } + + public void processOnClose(Link link, Exception exception) { + this.underlyingEntity.onError(exception); + } + + private void closeSession(Link link, ErrorCondition condition) { + final Session session = link.getSession(); + + if (session != null && session.getLocalState() != EndpointState.CLOSED) { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format("closeSession for linkName[%s], errorCondition[%s], errorDescription[%s]", + link.getName(), + condition != null ? condition.getCondition() : "n/a", + condition != null ? condition.getDescription() : "n/a")); + } + + 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(); + } + + this.processOnClose(link, condition); + + this.closeSession(link, condition); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/CBSChannel.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/CBSChannel.java new file mode 100644 index 000000000000..cbc0aa56378f --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/CBSChannel.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +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 java.util.HashMap; +import java.util.Map; + +final class CBSChannel { + + final FaultTolerantObject innerChannel; + final SessionProvider sessionProvider; + final AmqpConnection connectionEventDispatcher; + + public CBSChannel( + final SessionProvider sessionProvider, + final AmqpConnection connection) { + + this.sessionProvider = sessionProvider; + this.connectionEventDispatcher = connection; + + RequestResponseCloser closer = new RequestResponseCloser(); + this.innerChannel = new FaultTolerantObject<>( + new RequestResponseOpener(sessionProvider, "cbs-session", "cbs", ClientConstants.CBS_ADDRESS, connection), + closer); + closer.setInnerChannel(this.innerChannel); + } + + public void sendToken( + final ReactorDispatcher dispatcher, + final String token, + final String tokenAudience, + final OperationResult sendTokenCallback) { + + final Message request = Proton.message(); + final Map properties = new HashMap<>(); + properties.put(ClientConstants.PUT_TOKEN_OPERATION, ClientConstants.PUT_TOKEN_OPERATION_VALUE); + properties.put(ClientConstants.PUT_TOKEN_TYPE, ClientConstants.SAS_TOKEN_TYPE); + properties.put(ClientConstants.PUT_TOKEN_AUDIENCE, tokenAudience); + final ApplicationProperties applicationProperties = new ApplicationProperties(properties); + request.setApplicationProperties(applicationProperties); + request.setBody(new AmqpValue(token)); + + this.innerChannel.runOnOpenedObject(dispatcher, + new OperationResult() { + @Override + public void onComplete(final RequestResponseChannel result) { + result.request(request, + new OperationResult() { + @Override + public void onComplete(final Message response) { + + final int statusCode = (int) response.getApplicationProperties().getValue().get(ClientConstants.PUT_TOKEN_STATUS_CODE); + final String statusDescription = (String) response.getApplicationProperties().getValue().get(ClientConstants.PUT_TOKEN_STATUS_DESCRIPTION); + + if (statusCode == AmqpResponseCode.ACCEPTED.getValue() || statusCode == AmqpResponseCode.OK.getValue()) { + sendTokenCallback.onComplete(null); + } else { + this.onError(ExceptionUtil.amqpResponseCodeToException(statusCode, statusDescription)); + } + } + + @Override + public void onError(final Exception error) { + sendTokenCallback.onError(error); + } + }); + } + + @Override + public void onError(Exception error) { + sendTokenCallback.onError(error); + } + }); + } + + public void close( + final ReactorDispatcher reactorDispatcher, + final OperationResult closeCallback) { + + this.innerChannel.close(reactorDispatcher, closeCallback); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ClientConstants.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ClientConstants.java new file mode 100644 index 000000000000..80ab06af36d0 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ClientConstants.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.amqp.Symbol; + +import java.time.Duration; + +public final class ClientConstants { + public final static int AMQPS_PORT = 5671; + public final static int HTTPS_PORT = 443; + public final static int MAX_PARTITION_KEY_LENGTH = 128; + public final static Symbol SERVER_BUSY_ERROR = Symbol.getSymbol(AmqpConstants.VENDOR + ":server-busy"); + public final static Symbol ARGUMENT_ERROR = Symbol.getSymbol(AmqpConstants.VENDOR + ":argument-error"); + public final static Symbol ARGUMENT_OUT_OF_RANGE_ERROR = Symbol.getSymbol(AmqpConstants.VENDOR + ":argument-out-of-range"); + public final static Symbol ENTITY_DISABLED_ERROR = Symbol.getSymbol(AmqpConstants.VENDOR + ":entity-disabled"); + public final static Symbol PARTITION_NOT_OWNED_ERROR = Symbol.getSymbol(AmqpConstants.VENDOR + ":partition-not-owned"); + public final static Symbol STORE_LOCK_LOST_ERROR = Symbol.getSymbol(AmqpConstants.VENDOR + ":store-lock-lost"); + public final static Symbol PUBLISHER_REVOKED_ERROR = Symbol.getSymbol(AmqpConstants.VENDOR + ":publisher-revoked"); + public final static Symbol TIMEOUT_ERROR = Symbol.getSymbol(AmqpConstants.VENDOR + ":timeout"); + public final static Symbol TRACKING_ID_PROPERTY = Symbol.getSymbol(AmqpConstants.VENDOR + ":tracking-id"); + public static final int MAX_MESSAGE_LENGTH_BYTES = 256 * 1024; + public static final int MAX_FRAME_SIZE_BYTES = 64 * 1024; + public static final int MAX_EVENTHUB_AMQP_HEADER_SIZE_BYTES = 512; + public final static Duration TIMER_TOLERANCE = Duration.ofSeconds(1); + public final static Duration DEFAULT_RETRY_MIN_BACKOFF = Duration.ofSeconds(0); + public final static Duration DEFAULT_RETRY_MAX_BACKOFF = Duration.ofSeconds(30); + public final static Duration TOKEN_REFRESH_INTERVAL = Duration.ofMinutes(10); // renew every 10 mins, which expires 20 mins + public final static Duration TOKEN_VALIDITY = Duration.ofMinutes(20); + public final static int DEFAULT_MAX_RETRY_COUNT = 10; + public final static boolean DEFAULT_IS_TRANSIENT = true; + public final static int REACTOR_IO_POLL_TIMEOUT = 20; + public final static int SERVER_BUSY_BASE_SLEEP_TIME_IN_SECS = 4; + public final static int MGMT_CHANNEL_MIN_RETRY_IN_MILLIS = 5; + public final static String NO_RETRY = "NoRetry"; + public final static String DEFAULT_RETRY = "Default"; + public final static String PRODUCT_NAME = "MSJavaClient"; + public final static String CURRENT_JAVACLIENT_VERSION = "2.0.0"; + public static final String PLATFORM_INFO = getPlatformInfo(); + public static final String FRAMEWORK_INFO = getFrameworkInfo(); + public static final String CBS_ADDRESS = "$cbs"; + public static final String PUT_TOKEN_OPERATION = "operation"; + public static final String PUT_TOKEN_OPERATION_VALUE = "put-token"; + public static final String PUT_TOKEN_TYPE = "type"; + public static final String SAS_TOKEN_TYPE = "servicebus.windows.net:sastoken"; + public static final String PUT_TOKEN_AUDIENCE = "name"; + public static final String PUT_TOKEN_EXPIRY = "expiration"; + public static final String PUT_TOKEN_STATUS_CODE = "status-code"; + public static final String PUT_TOKEN_STATUS_DESCRIPTION = "status-description"; + public static final String MANAGEMENT_ADDRESS = "$management"; + public static final String MANAGEMENT_EVENTHUB_ENTITY_TYPE = AmqpConstants.VENDOR + ":eventhub"; + public static final String MANAGEMENT_PARTITION_ENTITY_TYPE = AmqpConstants.VENDOR + ":partition"; + public static final String MANAGEMENT_OPERATION_KEY = "operation"; + public static final String READ_OPERATION_VALUE = "READ"; + public static final String MANAGEMENT_ENTITY_TYPE_KEY = "type"; + public static final String MANAGEMENT_ENTITY_NAME_KEY = "name"; + public static final String MANAGEMENT_PARTITION_NAME_KEY = "partition"; + public static final String MANAGEMENT_SECURITY_TOKEN_KEY = "security_token"; + public static final String MANAGEMENT_RESULT_PARTITION_IDS = "partition_ids"; + public static final String MANAGEMENT_RESULT_PARTITION_COUNT = "partition_count"; + 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"; + public static final String MANAGEMENT_STATUS_CODE_KEY = "status-code"; + public static final String MANAGEMENT_STATUS_DESCRIPTION_KEY = "status-description"; + public static final String MANAGEMENT_RESPONSE_ERROR_CONDITION = "error-condition"; + public static final Symbol LAST_ENQUEUED_SEQUENCE_NUMBER = Symbol.valueOf(MANAGEMENT_RESULT_LAST_ENQUEUED_SEQUENCE_NUMBER); + public static final Symbol LAST_ENQUEUED_OFFSET = Symbol.valueOf(MANAGEMENT_RESULT_LAST_ENQUEUED_OFFSET); + public static final Symbol LAST_ENQUEUED_TIME_UTC = Symbol.valueOf(MANAGEMENT_RESULT_LAST_ENQUEUED_TIME_UTC); + public static final String AMQP_REQUEST_FAILED_ERROR = "status-code: %s, status-description: %s"; + public static final String TOKEN_AUDIENCE_FORMAT = "amqp://%s/%s"; + public static final String HTTPS_URI_FORMAT = "https://%s:%s"; + public static final int MAX_RECEIVER_NAME_LENGTH = 64; + + /** + * This is a constant defined to represent the start of a partition stream in EventHub. + */ + public 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. + */ + static final String END_OF_STREAM = "@latest"; + + private ClientConstants() { + } + + private static String getPlatformInfo() { + final StringBuilder platformInfo = new StringBuilder(); + platformInfo.append("arch:"); + platformInfo.append(System.getProperty("os.arch")); + platformInfo.append(";os:"); + platformInfo.append(System.getProperty("os.name")); + platformInfo.append(";os version:"); + platformInfo.append(System.getProperty("os.version")); + + return platformInfo.toString(); + } + + private static String getFrameworkInfo() { + final Package javaRuntimeClassPkg = Runtime.class.getPackage(); + final StringBuilder frameworkInfo = new StringBuilder(); + frameworkInfo.append("jre:"); + frameworkInfo.append(javaRuntimeClassPkg.getImplementationVersion()); + frameworkInfo.append(";vendor:"); + frameworkInfo.append(javaRuntimeClassPkg.getImplementationVendor()); + frameworkInfo.append(";jvm"); + frameworkInfo.append(System.getProperty("java.vm.version")); + + return frameworkInfo.toString(); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ClientEntity.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ClientEntity.java new file mode 100644 index 000000000000..1f760fa2fc75 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ClientEntity.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.EventHubException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Locale; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledExecutorService; + +/** + * Contract for all client entities with Open-Close/Abort state m/c + * main-purpose: closeAll related entities + * Internal-class + */ +abstract class ClientEntity { + + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(ClientEntity.class); + protected final ScheduledExecutorService executor; + private final String clientId; + private final Object syncClose; + private final ClientEntity parent; + private CompletableFuture closeTask; + private boolean isClosing; + private boolean isClosed; + + protected ClientEntity(final String clientId, final ClientEntity parent, final ScheduledExecutorService executor) { + this.clientId = clientId; + this.parent = parent; + this.executor = executor; + + this.syncClose = new Object(); + } + + protected abstract CompletableFuture onClose(); + + public String getClientId() { + return this.clientId; + } + + boolean getIsClosed() { + final boolean isParentClosed = this.parent != null && this.parent.getIsClosed(); + synchronized (this.syncClose) { + return isParentClosed || this.isClosed; + } + } + + // returns true even if the Parent is (being) Closed + boolean getIsClosingOrClosed() { + final boolean isParentClosingOrClosed = this.parent != null && this.parent.getIsClosingOrClosed(); + synchronized (this.syncClose) { + return isParentClosingOrClosed || this.isClosing || this.isClosed; + } + } + + // used to force close when entity is faulted + protected final void setClosed() { + synchronized (this.syncClose) { + this.isClosed = true; + } + } + + public final CompletableFuture close() { + synchronized (this.syncClose) { + if (this.isClosed || this.isClosing) + return this.closeTask == null ? CompletableFuture.completedFuture(null) : this.closeTask; + + this.isClosing = true; + } + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info("close: clientId[" + this.clientId + "]"); + } + + this.closeTask = this.onClose().thenRunAsync(new Runnable() { + @Override + public void run() { + synchronized (ClientEntity.this.syncClose) { + ClientEntity.this.isClosing = false; + ClientEntity.this.isClosed = true; + } + } + }, this.executor); + + return this.closeTask; + } + + public final void closeSync() throws EventHubException { + try { + this.close().get(); + } catch (InterruptedException | ExecutionException exception) { + if (exception instanceof InterruptedException) { + // Re-assert the thread's interrupted status + Thread.currentThread().interrupt(); + } + + final Throwable throwable = exception.getCause(); + if (throwable instanceof RuntimeException) { + throw (RuntimeException) throwable; + } else if (throwable instanceof EventHubException) { + throw (EventHubException) throwable; + } else { + throw new RuntimeException(throwable != null ? throwable : exception); + } + } + } + + protected final void throwIfClosed() { + if (this.getIsClosingOrClosed()) { + throw new IllegalStateException(String.format(Locale.US, "Operation not allowed after the %s instance is Closed.", this.getClass().getName()), this.getLastKnownError()); + } + } + + protected Exception getLastKnownError() { + return null; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ConnectionHandler.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ConnectionHandler.java new file mode 100644 index 000000000000..1e13d52c6a65 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ConnectionHandler.java @@ -0,0 +1,280 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.TransportType; +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.*; +import org.apache.qpid.proton.engine.impl.TransportInternal; +import org.apache.qpid.proton.reactor.Handshaker; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +// ServiceBus <-> ProtonReactor interaction handles all +// amqp_connection/transport related events from reactor +public class ConnectionHandler extends BaseHandler { + + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(ConnectionHandler.class); + + private final AmqpConnection amqpConnection; + + protected ConnectionHandler(final AmqpConnection amqpConnection) { + + add(new Handshaker()); + this.amqpConnection = amqpConnection; + } + + static ConnectionHandler create(TransportType transportType, AmqpConnection amqpConnection) { + switch (transportType) { + case AMQP_WEB_SOCKETS: + if (WebSocketProxyConnectionHandler.shouldUseProxy(amqpConnection.getHostName())) { + return new WebSocketProxyConnectionHandler(amqpConnection); + } else { + return new WebSocketConnectionHandler(amqpConnection); + } + case AMQP: + default: + return new ConnectionHandler(amqpConnection); + } + } + + private static SslDomain makeDomain(SslDomain.Mode mode) { + + final SslDomain domain = Proton.sslDomain(); + domain.init(mode); + + // TODO: VERIFY_PEER_NAME support + domain.setPeerAuthentication(SslDomain.VerifyMode.ANONYMOUS_PEER); + return domain; + } + + protected AmqpConnection getAmqpConnection() { + return this.amqpConnection; + } + + @Override + public void onConnectionInit(Event event) { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, "onConnectionInit hostname[%s]", this.amqpConnection.getHostName())); + } + + final Connection connection = event.getConnection(); + final String hostName = new StringBuilder(this.amqpConnection.getHostName()) + .append(":") + .append(String.valueOf(this.getProtocolPort())) + .toString(); + + connection.setHostname(hostName); + connection.setContainer(StringUtil.getRandomString()); + + final Map connectionProperties = new HashMap<>(); + connectionProperties.put(AmqpConstants.PRODUCT, ClientConstants.PRODUCT_NAME); + connectionProperties.put(AmqpConstants.VERSION, ClientConstants.CURRENT_JAVACLIENT_VERSION); + connectionProperties.put(AmqpConstants.PLATFORM, ClientConstants.PLATFORM_INFO); + connectionProperties.put(AmqpConstants.FRAMEWORK, ClientConstants.FRAMEWORK_INFO); + + final String userAgent = EventHubClientImpl.USER_AGENT; + if (userAgent != null) { + connectionProperties.put(AmqpConstants.USER_AGENT, userAgent.length() < AmqpConstants.MAX_USER_AGENT_LENGTH ? + userAgent : + userAgent.substring(0, AmqpConstants.MAX_USER_AGENT_LENGTH)); + } + + connection.setProperties(connectionProperties); + connection.open(); + } + + protected void addTransportLayers(final Event event, final TransportInternal transport) { + final SslDomain domain = makeDomain(SslDomain.Mode.CLIENT); + transport.ssl(domain); + } + + protected void notifyTransportErrors(final Event event) { + // no-op + } + + /** + * HostName to be used for socket creation. + * for ex: in case of proxy server - this could be proxy ip address + * + * @return host name + */ + public String getRemoteHostName() { + return amqpConnection.getHostName(); + } + + /** + * port used to create socket. + * for ex: in case of talking to event hubs service via proxy - use proxy port + * + * @return port + */ + protected int getRemotePort() { + return this.getProtocolPort(); + } + + /** + * Port used on connection open frame + * + * @return port + */ + protected int getProtocolPort() { + return ClientConstants.AMQPS_PORT; + } + + protected int getMaxFrameSize() { + return AmqpConstants.MAX_FRAME_SIZE; + } + + @Override + public void onConnectionBound(Event event) { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, "onConnectionBound hostname[%s]", this.amqpConnection.getHostName())); + } + + final Transport transport = event.getTransport(); + + this.addTransportLayers(event, (TransportInternal) transport); + } + + @Override + public void onConnectionUnbound(Event event) { + + final Connection connection = event.getConnection(); + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, "onConnectionUnbound: hostname[%s], state[%s], remoteState[%s]", + connection.getHostname(), connection.getLocalState(), connection.getRemoteState())); + } + + // if failure happened while establishing transport - nothing to free up. + if (connection.getRemoteState() != EndpointState.UNINITIALIZED) + connection.free(); + } + + @Override + public void onTransportError(Event event) { + + final Connection connection = event.getConnection(); + final Transport transport = event.getTransport(); + final ErrorCondition condition = transport.getCondition(); + + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn(String.format(Locale.US, "onTransportError: hostname[%s], error[%s]", + connection != null ? connection.getHostname() : "n/a", + condition != null ? condition.getDescription() : "n/a")); + } + + if (connection != null && connection.getRemoteState() != EndpointState.CLOSED) { + // if the remote-peer abruptly closes the connection without issuing close frame + // issue one + this.amqpConnection.onConnectionError(condition); + } + + // onTransportError event is not handled by the global IO Handler for cleanup + transport.unbind(); + + this.notifyTransportErrors(event); + } + + @Override + public void onTransportClosed(Event event) { + + final Connection connection = event.getConnection(); + final Transport transport = event.getTransport(); + final ErrorCondition condition = transport.getCondition(); + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, "onTransportClosed: hostname[%s], error[%s]", + connection != null ? connection.getHostname() : "n/a", (condition != null ? condition.getDescription() : "n/a"))); + } + + if (connection != null && connection.getRemoteState() != EndpointState.CLOSED) { + // if the remote-peer abruptly closes the connection without issuing close frame + // issue one + this.amqpConnection.onConnectionError(condition); + } + } + + @Override + public void onConnectionLocalOpen(Event event) { + final Connection connection = event.getConnection(); + final ErrorCondition error = connection.getCondition(); + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, "onConnectionLocalOpen: hostname[%s], errorCondition[%s], errorDescription[%s]", + connection.getHostname(), + error != null ? error.getCondition() : "n/a", + error != null ? error.getDescription() : "n/a")); + } + } + + @Override + public void onConnectionRemoteOpen(Event event) { + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, "onConnectionRemoteOpen: hostname[%s], remoteContainer[%s]", + event.getConnection().getHostname(), event.getConnection().getRemoteContainer())); + } + + this.amqpConnection.onOpenComplete(null); + } + + @Override + public void onConnectionLocalClose(Event event) { + + final Connection connection = event.getConnection(); + final ErrorCondition error = connection.getCondition(); + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, "onConnectionLocalClose: hostname[%s], errorCondition[%s], errorDescription[%s]", + connection.getHostname(), + error != null ? error.getCondition() : "n/a", + error != null ? error.getDescription() : "n/a")); + } + + 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 + } + } + } + + @Override + public void onConnectionRemoteClose(Event event) { + + final Connection connection = event.getConnection(); + final ErrorCondition error = connection.getRemoteCondition(); + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, "onConnectionRemoteClose: hostname[%s], errorCondition[%s], errorDescription[%s]", + connection.getHostname(), + error != null ? error.getCondition() : "n/a", + error != null ? error.getDescription() : "n/a")); + } + + this.amqpConnection.onConnectionError(error); + } + + @Override + public void onConnectionFinal(Event event) { + final Connection connection = event.getConnection(); + final ErrorCondition error = connection.getCondition(); + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, "onConnectionFinal: hostname[%s], errorCondition[%s], errorDescription[%s]", + connection.getHostname(), + error != null ? error.getCondition() : "n/a", + error != null ? error.getDescription() : "n/a")); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/CustomIOHandler.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/CustomIOHandler.java new file mode 100644 index 000000000000..c091c00be1fc --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/CustomIOHandler.java @@ -0,0 +1,30 @@ +package com.microsoft.azure.eventhubs.impl; + +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; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Locale; + +public class CustomIOHandler extends IOHandler { + + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(CustomIOHandler.class); + + @Override + public void onTransportClosed(Event event) { + final Transport transport = event.getTransport(); + final Connection connection = event.getConnection(); + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, "onTransportClosed hostname[%s]", + (connection != null ? connection.getHostname() : "n/a"))); + } + + if (transport != null && connection != null && connection.getTransport() != null) { + transport.unbind(); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/DispatchHandler.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/DispatchHandler.java new file mode 100644 index 000000000000..67c84a0aee7f --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/DispatchHandler.java @@ -0,0 +1,13 @@ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.engine.BaseHandler; +import org.apache.qpid.proton.engine.Event; + +public abstract class DispatchHandler extends BaseHandler { + @Override + public void onTimerTask(Event e) { + this.onEvent(); + } + + public abstract void onEvent(); +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ErrorContextProvider.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ErrorContextProvider.java new file mode 100644 index 000000000000..95468a02a6f4 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ErrorContextProvider.java @@ -0,0 +1,11 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.ErrorContext; + +interface ErrorContextProvider { + ErrorContext getContext(); +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/EventDataBatchImpl.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/EventDataBatchImpl.java new file mode 100644 index 000000000000..1f138df8d414 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/EventDataBatchImpl.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.EventData; +import com.microsoft.azure.eventhubs.EventDataBatch; +import com.microsoft.azure.eventhubs.PayloadSizeExceededException; +import org.apache.qpid.proton.message.Message; + +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; + +final class EventDataBatchImpl implements EventDataBatch { + + private final int maxMessageSize; + private final String partitionKey; + private final List events; + private final byte[] eventBytes; + private int currentSize = 0; + + EventDataBatchImpl(final int maxMessageSize, final String partitionKey) { + + this.maxMessageSize = maxMessageSize; + this.partitionKey = partitionKey; + this.events = new LinkedList<>(); + this.currentSize = (maxMessageSize / 65536) * 1024; // reserve 1KB for every 64KB + this.eventBytes = new byte[maxMessageSize]; + } + + public final int getSize() { + return events.size(); + } + + public final boolean tryAdd(final EventData eventData) throws PayloadSizeExceededException { + + if (eventData == null) { + throw new IllegalArgumentException("eventData cannot be null"); + } + + final EventDataImpl eventDataImpl = (EventDataImpl) eventData; + + final int size; + try { + size = getSize(eventDataImpl, events.isEmpty()); + } catch (java.nio.BufferOverflowException exception) { + throw new PayloadSizeExceededException(String.format("Size of the payload exceeded Maximum message size: %s kb", this.maxMessageSize / 1024)); + } + + if (this.currentSize + size > this.maxMessageSize) + return false; + + this.events.add(eventDataImpl); + this.currentSize += size; + return true; + } + + public Iterator iterator() { + + return this.events.iterator(); + } + + Iterable getInternalIterable() { + + return this.events; + } + + String getPartitionKey() { + + return this.partitionKey; + } + + private int getSize(final EventDataImpl eventData, final boolean isFirst) { + + final Message amqpMessage = this.partitionKey != null ? eventData.toAmqpMessage(this.partitionKey) : eventData.toAmqpMessage(); + 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; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/EventDataImpl.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/EventDataImpl.java new file mode 100755 index 000000000000..69986268623c --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/EventDataImpl.java @@ -0,0 +1,269 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.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.*; +import org.apache.qpid.proton.message.Message; + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class EventDataImpl implements EventData { + private static final long serialVersionUID = -5631628195600014255L; + private static final int BODY_DATA_NULL = -1; + + transient private Binary bodyData; + transient private Object amqpBody; + + private Map properties; + private SystemProperties systemProperties; + + private EventDataImpl() { + } + + @SuppressWarnings("unchecked") + EventDataImpl(Message amqpMessage) { + if (amqpMessage == null) { + throw new IllegalArgumentException("amqpMessage cannot be null"); + } + + final Map messageAnnotations = amqpMessage.getMessageAnnotations().getValue(); + final HashMap receiveProperties = new HashMap<>(); + + for (Map.Entry annotation : messageAnnotations.entrySet()) { + receiveProperties.put(annotation.getKey().toString(), annotation.getValue() != null ? annotation.getValue() : null); + } + + if (amqpMessage.getProperties() != null) { + if (amqpMessage.getMessageId() != null) + receiveProperties.put(AmqpConstants.AMQP_PROPERTY_MESSAGE_ID, amqpMessage.getMessageId()); + if (amqpMessage.getUserId() != null) + receiveProperties.put(AmqpConstants.AMQP_PROPERTY_USER_ID, amqpMessage.getUserId()); + if (amqpMessage.getAddress() != null) + receiveProperties.put(AmqpConstants.AMQP_PROPERTY_TO, amqpMessage.getAddress()); + if (amqpMessage.getSubject() != null) + receiveProperties.put(AmqpConstants.AMQP_PROPERTY_SUBJECT, amqpMessage.getSubject()); + if (amqpMessage.getReplyTo() != null) + receiveProperties.put(AmqpConstants.AMQP_PROPERTY_REPLY_TO, amqpMessage.getReplyTo()); + if (amqpMessage.getCorrelationId() != null) + receiveProperties.put(AmqpConstants.AMQP_PROPERTY_CORRELATION_ID, amqpMessage.getCorrelationId()); + if (amqpMessage.getContentType() != null) + receiveProperties.put(AmqpConstants.AMQP_PROPERTY_CONTENT_TYPE, amqpMessage.getContentType()); + if (amqpMessage.getContentEncoding() != null) + receiveProperties.put(AmqpConstants.AMQP_PROPERTY_CONTENT_ENCODING, amqpMessage.getContentEncoding()); + if (amqpMessage.getProperties().getAbsoluteExpiryTime() != null) + receiveProperties.put(AmqpConstants.AMQP_PROPERTY_ABSOLUTE_EXPRITY_TIME, amqpMessage.getExpiryTime()); + if (amqpMessage.getProperties().getCreationTime() != null) + receiveProperties.put(AmqpConstants.AMQP_PROPERTY_CREATION_TIME, amqpMessage.getCreationTime()); + if (amqpMessage.getGroupId() != null) + receiveProperties.put(AmqpConstants.AMQP_PROPERTY_GROUP_ID, amqpMessage.getGroupId()); + if (amqpMessage.getProperties().getGroupSequence() != null) + receiveProperties.put(AmqpConstants.AMQP_PROPERTY_GROUP_SEQUENCE, amqpMessage.getGroupSequence()); + if (amqpMessage.getReplyToGroupId() != null) + receiveProperties.put(AmqpConstants.AMQP_PROPERTY_REPLY_TO_GROUP_ID, amqpMessage.getReplyToGroupId()); + } + + this.systemProperties = new SystemProperties(receiveProperties); + this.properties = amqpMessage.getApplicationProperties() == null ? null : amqpMessage.getApplicationProperties().getValue(); + + final Section bodySection = amqpMessage.getBody(); + if (bodySection != null) { + if (bodySection instanceof Data) { + this.bodyData = ((Data) bodySection).getValue(); + this.amqpBody = this.bodyData; + } else if (bodySection instanceof AmqpValue) { + this.amqpBody = ((AmqpValue) bodySection).getValue(); + } else if (bodySection instanceof AmqpSequence) { + this.amqpBody = ((AmqpSequence) bodySection).getValue(); + } + } + + amqpMessage.clear(); + } + + public EventDataImpl(byte[] data) { + this(); + + if (data == null) { + throw new IllegalArgumentException("data cannot be null"); + } + + this.bodyData = new Binary(data); + } + + public EventDataImpl(byte[] data, final int offset, final int length) { + this(); + + if (data == null) { + throw new IllegalArgumentException("data cannot be null"); + } + + this.bodyData = new Binary(data, offset, length); + } + + public EventDataImpl(ByteBuffer buffer) { + this(); + + if (buffer == null) { + throw new IllegalArgumentException("data cannot be null"); + } + + this.bodyData = Binary.create(buffer); + } + + public Object getObject() { + return this.amqpBody; + } + + public byte[] getBytes() { + + if (this.bodyData == null) + return null; + + return this.bodyData.getArray(); + } + + public Map getProperties() { + if (this.properties == null) { + this.properties = new HashMap<>(); + } + + return this.properties; + } + + public SystemProperties getSystemProperties() { + return this.systemProperties; + } + + // This is intended to be used while sending EventData - so EventData.SystemProperties will not be copied over to the AmqpMessage + Message toAmqpMessage() { + final Message amqpMessage = Proton.message(); + + if (this.properties != null && !this.properties.isEmpty()) { + final ApplicationProperties applicationProperties = new ApplicationProperties(this.properties); + amqpMessage.setApplicationProperties(applicationProperties); + } + + if (this.systemProperties != null && !this.systemProperties.isEmpty()) { + for (Map.Entry systemProperty : this.systemProperties.entrySet()) { + final String propertyName = systemProperty.getKey(); + if (!EventDataUtil.RESERVED_SYSTEM_PROPERTIES.contains(propertyName)) { + if (AmqpConstants.RESERVED_PROPERTY_NAMES.contains(propertyName)) + switch (propertyName) { + case AmqpConstants.AMQP_PROPERTY_MESSAGE_ID: + amqpMessage.setMessageId(systemProperty.getValue()); + break; + case AmqpConstants.AMQP_PROPERTY_USER_ID: + amqpMessage.setUserId((byte[]) systemProperty.getValue()); + break; + case AmqpConstants.AMQP_PROPERTY_TO: + amqpMessage.setAddress((String) systemProperty.getValue()); + break; + case AmqpConstants.AMQP_PROPERTY_SUBJECT: + amqpMessage.setSubject((String) systemProperty.getValue()); + break; + case AmqpConstants.AMQP_PROPERTY_REPLY_TO: + amqpMessage.setReplyTo((String) systemProperty.getValue()); + break; + case AmqpConstants.AMQP_PROPERTY_CORRELATION_ID: + amqpMessage.setCorrelationId(systemProperty.getValue()); + break; + case AmqpConstants.AMQP_PROPERTY_CONTENT_TYPE: + amqpMessage.setContentType((String) systemProperty.getValue()); + break; + case AmqpConstants.AMQP_PROPERTY_CONTENT_ENCODING: + amqpMessage.setContentEncoding((String) systemProperty.getValue()); + break; + case AmqpConstants.AMQP_PROPERTY_ABSOLUTE_EXPRITY_TIME: + amqpMessage.setExpiryTime((long) systemProperty.getValue()); + break; + case AmqpConstants.AMQP_PROPERTY_CREATION_TIME: + amqpMessage.setCreationTime((long) systemProperty.getValue()); + break; + case AmqpConstants.AMQP_PROPERTY_GROUP_ID: + amqpMessage.setGroupId((String) systemProperty.getValue()); + break; + case AmqpConstants.AMQP_PROPERTY_GROUP_SEQUENCE: + amqpMessage.setGroupSequence((long) systemProperty.getValue()); + break; + case AmqpConstants.AMQP_PROPERTY_REPLY_TO_GROUP_ID: + amqpMessage.setReplyToGroupId((String) systemProperty.getValue()); + break; + default: + throw new RuntimeException("unreachable"); + } + else { + final MessageAnnotations messageAnnotations = (amqpMessage.getMessageAnnotations() == null) + ? new MessageAnnotations(new HashMap<>()) + : amqpMessage.getMessageAnnotations(); + messageAnnotations.getValue().put(Symbol.getSymbol(systemProperty.getKey()), systemProperty.getValue()); + amqpMessage.setMessageAnnotations(messageAnnotations); + } + } + } + } + + if (this.bodyData != null) { + amqpMessage.setBody(new Data(this.bodyData)); + } else if (this.amqpBody != null) { + if (this.amqpBody instanceof List) { + amqpMessage.setBody(new AmqpSequence((List) this.amqpBody)); + } else { + amqpMessage.setBody(new AmqpValue(this.amqpBody)); + } + } + + return amqpMessage; + } + + Message toAmqpMessage(final String partitionKey) { + final Message amqpMessage = this.toAmqpMessage(); + + final MessageAnnotations messageAnnotations = (amqpMessage.getMessageAnnotations() == null) + ? new MessageAnnotations(new HashMap<>()) + : amqpMessage.getMessageAnnotations(); + messageAnnotations.getValue().put(AmqpConstants.PARTITION_KEY, partitionKey); + amqpMessage.setMessageAnnotations(messageAnnotations); + + return amqpMessage; + } + + private void writeObject(ObjectOutputStream out) throws IOException { + out.defaultWriteObject(); + + out.writeInt(this.bodyData == null ? BODY_DATA_NULL : this.bodyData.getLength()); + if (this.bodyData != null) + out.write(this.bodyData.getArray(), this.bodyData.getArrayOffset(), this.bodyData.getLength()); + } + + private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { + in.defaultReadObject(); + + final int length = in.readInt(); + if (length != BODY_DATA_NULL) { + + final byte[] data = new byte[length]; + in.readFully(data, 0, length); + this.bodyData = new Binary(data, 0, length); + } + } + + @Override + public int compareTo(EventData other) { + return Long.compare( + this.getSystemProperties().getSequenceNumber(), + other.getSystemProperties().getSequenceNumber() + ); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/EventDataUtil.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/EventDataUtil.java new file mode 100644 index 000000000000..491ee6b6e60e --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/EventDataUtil.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.EventData; +import com.microsoft.azure.eventhubs.EventPosition; +import org.apache.qpid.proton.message.Message; + +import java.util.*; +import java.util.function.Consumer; + +/* + * Internal utility class for EventData + */ +final class EventDataUtil { + + @SuppressWarnings("serial") + static final Set RESERVED_SYSTEM_PROPERTIES = Collections.unmodifiableSet(new HashSet() {{ + add(AmqpConstants.OFFSET_ANNOTATION_NAME); + add(AmqpConstants.PARTITION_KEY_ANNOTATION_NAME); + add(AmqpConstants.SEQUENCE_NUMBER_ANNOTATION_NAME); + add(AmqpConstants.ENQUEUED_TIME_UTC_ANNOTATION_NAME); + add(AmqpConstants.PUBLISHER_ANNOTATION_NAME); + }}); + + private EventDataUtil() { + } + + static LinkedList toEventDataCollection(final Collection messages, final PassByRef lastMessageRef) { + + if (messages == null) { + return null; + } + + LinkedList events = new LinkedList<>(); + for (Message message : messages) { + EventData eventData = new EventDataImpl(message); + events.add(eventData); + + if (lastMessageRef != null) { + lastMessageRef.set(new MessageWrapper(message, + EventPosition.fromSequenceNumber(eventData.getSystemProperties().getSequenceNumber(), true))); + } + } + + return events; + } + + static Iterable toAmqpMessages(final Iterable eventDatas, final String partitionKey) { + + final LinkedList messages = new LinkedList<>(); + eventDatas.forEach(new Consumer() { + @Override + public void accept(EventData eventData) { + EventDataImpl eventDataImpl = (EventDataImpl) eventData; + Message amqpMessage = partitionKey == null ? eventDataImpl.toAmqpMessage() : eventDataImpl.toAmqpMessage(partitionKey); + messages.add(amqpMessage); + } + }); + + return messages; + } + + static Iterable toAmqpMessages(final Iterable eventDatas) { + + return EventDataUtil.toAmqpMessages(eventDatas, null); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/EventHubClientImpl.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/EventHubClientImpl.java new file mode 100644 index 000000000000..4dc5e927bb7d --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/EventHubClientImpl.java @@ -0,0 +1,412 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.*; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.Date; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.Consumer; +import java.util.function.Function; + +public final class EventHubClientImpl extends ClientEntity implements EventHubClient { + + /** + * It will be truncated to 128 characters + */ + public static String USER_AGENT = null; + + private final String eventHubName; + private final Object senderCreateSync; + private volatile boolean isSenderCreateStarted; + private volatile MessagingFactory underlyingFactory; + private volatile MessageSender sender; + private volatile Timer timer; + + private CompletableFuture createSender; + + private EventHubClientImpl(final ConnectionStringBuilder connectionString, final ScheduledExecutorService executor) { + super("EventHubClientImpl".concat(StringUtil.getRandomString()), null, executor); + + this.eventHubName = connectionString.getEventHubName(); + this.senderCreateSync = new Object(); + } + + public static CompletableFuture create( + final String connectionString, final RetryPolicy retryPolicy, final ScheduledExecutorService executor) + throws EventHubException, IOException { + final ConnectionStringBuilder connStr = new ConnectionStringBuilder(connectionString); + final EventHubClientImpl eventHubClient = new EventHubClientImpl(connStr, executor); + + return MessagingFactory.createFromConnectionString(connectionString.toString(), retryPolicy, executor) + .thenApplyAsync(new Function() { + @Override + public EventHubClient apply(MessagingFactory factory) { + eventHubClient.underlyingFactory = factory; + eventHubClient.timer = new Timer(factory); + return eventHubClient; + } + }, executor); + } + + public String getEventHubName() { + return eventHubName; + } + + public final EventDataBatch createBatch(BatchOptions options) throws EventHubException { + + return ExceptionUtil.sync(() -> { + int maxSize = this.createInternalSender().thenApplyAsync( + (aVoid) -> this.sender.getMaxMessageSize(), + this.executor).get(); + if (options.maxMessageSize == null) { + return new EventDataBatchImpl(maxSize, options.partitionKey); + } + + if (options.maxMessageSize > maxSize) { + throw new IllegalArgumentException("The maxMessageSize set in BatchOptions is too large. You set a maxMessageSize of " + + options.maxMessageSize + ". The maximum allowed size is " + maxSize + "."); + } + + return new EventDataBatchImpl(options.maxMessageSize, options.partitionKey); + } + ); + } + + @Override + public final CompletableFuture send(final EventData data) { + if (data == null) { + throw new IllegalArgumentException("EventData cannot be empty."); + } + + return this.createInternalSender().thenComposeAsync(new Function>() { + @Override + public CompletableFuture apply(Void voidArg) { + return EventHubClientImpl.this.sender.send(((EventDataImpl) data).toAmqpMessage()); + } + }, this.executor); + } + + @Override + public final CompletableFuture send(final Iterable eventDatas) { + if (eventDatas == null || IteratorUtil.sizeEquals(eventDatas, 0)) { + throw new IllegalArgumentException("Empty batch of EventData cannot be sent."); + } + + return this.createInternalSender().thenComposeAsync(new Function>() { + @Override + public CompletableFuture apply(Void voidArg) { + return EventHubClientImpl.this.sender.send(EventDataUtil.toAmqpMessages(eventDatas)); + } + }, this.executor); + } + + @Override + public final CompletableFuture send(final EventDataBatch eventDatas) { + if (eventDatas == null || Integer.compare(eventDatas.getSize(), 0) == 0) { + throw new IllegalArgumentException("Empty batch of EventData cannot be sent."); + } + + final EventDataBatchImpl eventDataBatch = (EventDataBatchImpl) eventDatas; + return eventDataBatch.getPartitionKey() != null ? + this.send(eventDataBatch.getInternalIterable(), eventDataBatch.getPartitionKey()) : + this.send(eventDataBatch.getInternalIterable()); + } + + @Override + public final CompletableFuture send(final EventData eventData, final String partitionKey) { + if (eventData == null) { + throw new IllegalArgumentException("EventData cannot be null."); + } + + if (partitionKey == null) { + throw new IllegalArgumentException("partitionKey cannot be null"); + } + + return this.createInternalSender().thenComposeAsync(new Function>() { + @Override + public CompletableFuture apply(Void voidArg) { + return EventHubClientImpl.this.sender.send(((EventDataImpl) eventData).toAmqpMessage(partitionKey)); + } + }, this.executor); + } + + @Override + public final CompletableFuture send(final Iterable eventDatas, final String partitionKey) { + if (eventDatas == null || IteratorUtil.sizeEquals(eventDatas, 0)) { + throw new IllegalArgumentException("Empty batch of EventData cannot be sent."); + } + + if (partitionKey == null) { + throw new IllegalArgumentException("partitionKey cannot be null"); + } + + if (partitionKey.length() > ClientConstants.MAX_PARTITION_KEY_LENGTH) { + throw new IllegalArgumentException( + String.format(Locale.US, "PartitionKey exceeds the maximum allowed length of partitionKey: %s", ClientConstants.MAX_PARTITION_KEY_LENGTH)); + } + + return this.createInternalSender().thenComposeAsync(new Function>() { + @Override + public CompletableFuture apply(Void voidArg) { + return EventHubClientImpl.this.sender.send(EventDataUtil.toAmqpMessages(eventDatas, partitionKey)); + } + }, this.executor); + } + + @Override + public final CompletableFuture createPartitionSender(final String partitionId) + throws EventHubException { + return PartitionSenderImpl.Create(this.underlyingFactory, this.eventHubName, partitionId, this.executor); + } + + @Override + public final CompletableFuture createReceiver(final String consumerGroupName, final String partitionId, final EventPosition eventPosition) + throws EventHubException { + return this.createReceiver(consumerGroupName, partitionId, eventPosition, null); + } + + @Override + public final CompletableFuture createReceiver(final String consumerGroupName, final String partitionId, final EventPosition eventPosition, final ReceiverOptions receiverOptions) + throws EventHubException { + return PartitionReceiverImpl.create(this.underlyingFactory, this.eventHubName, consumerGroupName, partitionId, eventPosition, PartitionReceiverImpl.NULL_EPOCH, false, receiverOptions, this.executor); + } + + @Override + public final CompletableFuture createEpochReceiver(final String consumerGroupName, final String partitionId, final EventPosition eventPosition, final long epoch) + throws EventHubException { + return this.createEpochReceiver(consumerGroupName, partitionId, eventPosition, epoch, null); + } + + @Override + public final CompletableFuture createEpochReceiver(final String consumerGroupName, final String partitionId, final EventPosition eventPosition, final long epoch, final ReceiverOptions receiverOptions) + throws EventHubException { + return PartitionReceiverImpl.create(this.underlyingFactory, this.eventHubName, consumerGroupName, partitionId, eventPosition, epoch, true, receiverOptions, this.executor); + } + + @Override + public CompletableFuture onClose() { + if (this.underlyingFactory != null) { + synchronized (this.senderCreateSync) { + final CompletableFuture internalSenderClose = this.sender != null + ? this.sender.close().thenComposeAsync(new Function>() { + @Override + public CompletableFuture apply(Void voidArg) { + return EventHubClientImpl.this.underlyingFactory.close(); + } + }, this.executor) + : this.underlyingFactory.close(); + + return internalSenderClose; + } + } + + return CompletableFuture.completedFuture(null); + } + + private CompletableFuture createInternalSender() { + if (!this.isSenderCreateStarted) { + synchronized (this.senderCreateSync) { + if (!this.isSenderCreateStarted) { + this.createSender = MessageSender.create(this.underlyingFactory, this.getClientId().concat("-InternalSender"), this.eventHubName) + .thenAcceptAsync(new Consumer() { + public void accept(MessageSender a) { + EventHubClientImpl.this.sender = a; + } + }, this.executor); + + this.isSenderCreateStarted = true; + } + } + } + + return this.createSender; + } + + @Override + public CompletableFuture getRuntimeInformation() { + CompletableFuture future1 = null; + + throwIfClosed(); + + Map request = new HashMap(); + request.put(ClientConstants.MANAGEMENT_ENTITY_TYPE_KEY, ClientConstants.MANAGEMENT_EVENTHUB_ENTITY_TYPE); + request.put(ClientConstants.MANAGEMENT_ENTITY_NAME_KEY, this.eventHubName); + request.put(ClientConstants.MANAGEMENT_OPERATION_KEY, ClientConstants.READ_OPERATION_VALUE); + future1 = this.addManagementToken(request); + + if (future1 == null) { + future1 = managementWithRetry(request).thenComposeAsync(new Function, CompletableFuture>() { + @Override + public CompletableFuture apply(Map rawdata) { + CompletableFuture future2 = new CompletableFuture(); + future2.complete(new EventHubRuntimeInformation( + (String) rawdata.get(ClientConstants.MANAGEMENT_ENTITY_NAME_KEY), + ((Date) rawdata.get(ClientConstants.MANAGEMENT_RESULT_CREATED_AT)).toInstant(), + (int) rawdata.get(ClientConstants.MANAGEMENT_RESULT_PARTITION_COUNT), + (String[]) rawdata.get(ClientConstants.MANAGEMENT_RESULT_PARTITION_IDS))); + return future2; + } + }, this.executor); + } + + return future1; + } + + @Override + public CompletableFuture getPartitionRuntimeInformation(String partitionId) { + CompletableFuture future1 = null; + + throwIfClosed(); + + Map request = new HashMap(); + request.put(ClientConstants.MANAGEMENT_ENTITY_TYPE_KEY, ClientConstants.MANAGEMENT_PARTITION_ENTITY_TYPE); + request.put(ClientConstants.MANAGEMENT_ENTITY_NAME_KEY, this.eventHubName); + request.put(ClientConstants.MANAGEMENT_PARTITION_NAME_KEY, partitionId); + request.put(ClientConstants.MANAGEMENT_OPERATION_KEY, ClientConstants.READ_OPERATION_VALUE); + future1 = this.addManagementToken(request); + + if (future1 == null) { + future1 = managementWithRetry(request).thenComposeAsync(new Function, CompletableFuture>() { + @Override + public CompletableFuture apply(Map rawData) { + CompletableFuture future2 = new CompletableFuture(); + future2.complete(new PartitionRuntimeInformation( + (String) rawData.get(ClientConstants.MANAGEMENT_ENTITY_NAME_KEY), + (String) rawData.get(ClientConstants.MANAGEMENT_PARTITION_NAME_KEY), + (long) rawData.get(ClientConstants.MANAGEMENT_RESULT_BEGIN_SEQUENCE_NUMBER), + (long) rawData.get(ClientConstants.MANAGEMENT_RESULT_LAST_ENQUEUED_SEQUENCE_NUMBER), + (String) rawData.get(ClientConstants.MANAGEMENT_RESULT_LAST_ENQUEUED_OFFSET), + ((Date) rawData.get(ClientConstants.MANAGEMENT_RESULT_LAST_ENQUEUED_TIME_UTC)).toInstant(), + (boolean) rawData.get(ClientConstants.MANAGEMENT_RESULT_PARTITION_IS_EMPTY))); + return future2; + } + }, this.executor); + } + + return future1; + } + + private CompletableFuture addManagementToken(Map request) { + CompletableFuture retval = null; + try { + String audience = String.format("amqp://%s/%s", this.underlyingFactory.getHostName(), this.eventHubName); + String token = this.underlyingFactory.getTokenProvider().getToken(audience, ClientConstants.TOKEN_REFRESH_INTERVAL); + request.put(ClientConstants.MANAGEMENT_SECURITY_TOKEN_KEY, token); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + retval = new CompletableFuture(); + retval.completeExceptionally(e); + } + return retval; + } + + private CompletableFuture> managementWithRetry(Map request) { + final CompletableFuture> rawdataFuture = new CompletableFuture>(); + + final ManagementRetry retrier = new ManagementRetry( + rawdataFuture, + new TimeoutTracker(this.underlyingFactory.getOperationTimeout(), true), + this.underlyingFactory, + request); + + final CompletableFuture scheduledTask = this.timer.schedule(retrier, Duration.ZERO); + if (scheduledTask.isCompletedExceptionally()) { + rawdataFuture.completeExceptionally(ExceptionUtil.getExceptionFromCompletedFuture(scheduledTask)); + } + + return rawdataFuture; + } + + private class ManagementRetry implements Runnable { + private final CompletableFuture> finalFuture; + private final TimeoutTracker timeoutTracker; + private final MessagingFactory mf; + private final Map request; + + ManagementRetry(final CompletableFuture> future, + final TimeoutTracker timeoutTracker, + final MessagingFactory mf, + final Map request) { + this.finalFuture = future; + this.timeoutTracker = timeoutTracker; + this.mf = mf; + this.request = request; + } + + @Override + public void run() { + final long timeLeft = this.timeoutTracker.remaining().toMillis(); + final CompletableFuture> intermediateFuture = this.mf.getManagementChannel() + .request(this.mf.getReactorDispatcher(), + this.request, + timeLeft > 0 ? timeLeft : 0); + + intermediateFuture.whenComplete((final Map result, final Throwable error) -> { + if ((result != null) && (error == null)) { + // Success! + ManagementRetry.this.finalFuture.complete(result); + } else { + final Exception lastException; + final Throwable completeWith; + if (error == null) { + // Timeout, so fake up an exception to keep getNextRetryInternal happy. + // It has to be a EventHubException that is set to retryable or getNextRetryInterval will halt the retries. + lastException = new EventHubException(true, "timed out"); + completeWith = null; + } else if (error instanceof Exception) { + if (error instanceof EventHubException) { + lastException = (EventHubException) error; + } else if (error instanceof AmqpException) { + lastException = ExceptionUtil.toException(((AmqpException) error).getError()); + } else if (error instanceof CompletionException || error instanceof ExecutionException) { + lastException = ExceptionUtil.stripOuterException((Exception) error); + } else { + lastException = (Exception) error; + } + completeWith = lastException; + } else { + lastException = new Exception("got a throwable: " + error.toString()); + completeWith = error; + } + + if (ManagementRetry.this.mf.getIsClosingOrClosed()) { + ManagementRetry.this.finalFuture.completeExceptionally( + new OperationCancelledException( + "OperationCancelled as the underlying client instance was closed.", + lastException)); + } else { + final Duration waitTime = ManagementRetry.this.mf.getRetryPolicy().getNextRetryInterval( + ManagementRetry.this.mf.getClientId(), lastException, this.timeoutTracker.remaining()); + if (waitTime == null) { + // Do not retry again, give up and report error. + if (completeWith == null) { + ManagementRetry.this.finalFuture.complete(null); + } else { + ManagementRetry.this.finalFuture.completeExceptionally(completeWith); + } + } else { + // The only thing needed here is to schedule a new attempt. Even if the RequestResponseChannel has croaked, + // ManagementChannel uses FaultTolerantObject, so the underlying RequestResponseChannel will be recreated + // the next time it is needed. + final ManagementRetry retrier = new ManagementRetry(ManagementRetry.this.finalFuture, ManagementRetry.this.timeoutTracker, + ManagementRetry.this.mf, ManagementRetry.this.request); + EventHubClientImpl.this.timer.schedule(retrier, waitTime); + } + } + } + }); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/EventPositionImpl.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/EventPositionImpl.java new file mode 100644 index 000000000000..898975d5b0e2 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/EventPositionImpl.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.EventPosition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; + +public final class EventPositionImpl implements EventPosition { + + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(EventPositionImpl.class); + + private final String offset; + private final Long sequenceNumber; + private final Instant dateTime; + private final Boolean inclusiveFlag; + + private EventPositionImpl(String o, Long s, Instant d, Boolean i) { + this.offset = o; + this.sequenceNumber = s; + this.dateTime = d; + this.inclusiveFlag = i; + } + + public static EventPositionImpl fromOffset(String offset) { + return EventPositionImpl.fromOffset(offset, false); + } + + public static EventPositionImpl fromOffset(String offset, boolean inclusiveFlag) { + return new EventPositionImpl(offset, null, null, inclusiveFlag); + } + + public static EventPositionImpl fromSequenceNumber(Long sequenceNumber) { + return EventPositionImpl.fromSequenceNumber(sequenceNumber, false); + } + + public static EventPositionImpl fromSequenceNumber(Long sequenceNumber, boolean inclusiveFlag) { + return new EventPositionImpl(null, sequenceNumber, null, inclusiveFlag); + } + + public static EventPositionImpl fromEnqueuedTime(Instant dateTime) { + return new EventPositionImpl(null, null, dateTime, null); + } + + public static EventPositionImpl fromStartOfStream() { + return new EventPositionImpl(ClientConstants.START_OF_STREAM, null, null, true); + } + + public static EventPositionImpl fromEndOfStream() { + return new EventPositionImpl(ClientConstants.END_OF_STREAM, null, null, false); + } + + public Long getSequenceNumber() { + return this.sequenceNumber; + } + + public Instant getEnqueuedTime() { + return this.dateTime; + } + + public String getOffset() { + return this.offset; + } + + public boolean getInclusiveFlag() { + return this.inclusiveFlag; + } + + String getExpression() { + // order of preference + if (this.offset != null) { + return this.inclusiveFlag ? + String.format(AmqpConstants.AMQP_ANNOTATION_FORMAT, AmqpConstants.OFFSET_ANNOTATION_NAME, "=", this.offset) : + String.format(AmqpConstants.AMQP_ANNOTATION_FORMAT, AmqpConstants.OFFSET_ANNOTATION_NAME, StringUtil.EMPTY, this.offset); + } + + if (this.sequenceNumber != null) { + return this.inclusiveFlag ? + String.format(AmqpConstants.AMQP_ANNOTATION_FORMAT, AmqpConstants.SEQUENCE_NUMBER_ANNOTATION_NAME, "=", this.sequenceNumber) : + String.format(AmqpConstants.AMQP_ANNOTATION_FORMAT, AmqpConstants.SEQUENCE_NUMBER_ANNOTATION_NAME, StringUtil.EMPTY, this.sequenceNumber); + } + + if (this.dateTime != null) { + String ms; + try { + ms = Long.toString(this.dateTime.toEpochMilli()); + } catch (ArithmeticException ex) { + ms = Long.toString(Long.MAX_VALUE); + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn( + "receiver not yet created, action[createReceiveLink], warning[starting receiver from epoch+Long.Max]"); + } + } + return String.format(AmqpConstants.AMQP_ANNOTATION_FORMAT, AmqpConstants.ENQUEUED_TIME_UTC_ANNOTATION_NAME, StringUtil.EMPTY, ms); + } + + throw new IllegalArgumentException("No starting position was set."); + } + + @Override + public String toString() { + return String.format("offset[%s], sequenceNumber[%s], enqueuedTime[%s], inclusiveFlag[%s]", + this.offset, this.sequenceNumber, + (this.dateTime != null) ? this.dateTime.toEpochMilli() : "null", + this.inclusiveFlag); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ExceptionUtil.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ExceptionUtil.java new file mode 100644 index 000000000000..0814f2ceeeb9 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ExceptionUtil.java @@ -0,0 +1,243 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.*; +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.transport.ErrorCondition; + +import java.io.IOException; +import java.time.ZonedDateTime; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class ExceptionUtil { + static Exception toException(ErrorCondition errorCondition) { + if (errorCondition == null) { + throw new IllegalArgumentException("'null' errorCondition cannot be translated to EventHubException"); + } + + if (errorCondition.getCondition() == ClientConstants.TIMEOUT_ERROR) { + return new EventHubException(ClientConstants.DEFAULT_IS_TRANSIENT, new TimeoutException(errorCondition.getDescription())); + } else if (errorCondition.getCondition() == ClientConstants.SERVER_BUSY_ERROR) { + return new ServerBusyException(errorCondition.getDescription()); + } else if (errorCondition.getCondition() == AmqpErrorCode.NotFound) { + return ExceptionUtil.distinguishNotFound(errorCondition.getDescription()); + } else if (errorCondition.getCondition() == ClientConstants.ENTITY_DISABLED_ERROR) { + return new IllegalEntityException(errorCondition.getDescription()); + } else if (errorCondition.getCondition() == AmqpErrorCode.Stolen) { + return new ReceiverDisconnectedException(errorCondition.getDescription()); + } else if (errorCondition.getCondition() == AmqpErrorCode.UnauthorizedAccess) { + return new AuthorizationFailedException(errorCondition.getDescription()); + } else if (errorCondition.getCondition() == AmqpErrorCode.PayloadSizeExceeded) { + return new PayloadSizeExceededException(errorCondition.getDescription()); + } else if (errorCondition.getCondition() == AmqpErrorCode.InternalError) { + return new EventHubException(true, new AmqpException(errorCondition)); + } else if (errorCondition.getCondition() == ClientConstants.ARGUMENT_ERROR) { + return new EventHubException(false, errorCondition.getDescription(), new AmqpException(errorCondition)); + } else if (errorCondition.getCondition() == ClientConstants.ARGUMENT_OUT_OF_RANGE_ERROR) { + return new EventHubException(false, errorCondition.getDescription(), new AmqpException(errorCondition)); + } else if (errorCondition.getCondition() == AmqpErrorCode.NotImplemented) { + return new UnsupportedOperationException(errorCondition.getDescription()); + } else if (errorCondition.getCondition() == AmqpErrorCode.NotAllowed) { + return new UnsupportedOperationException(errorCondition.getDescription()); + } else if (errorCondition.getCondition() == ClientConstants.PARTITION_NOT_OWNED_ERROR) { + return new EventHubException(false, errorCondition.getDescription()); + } else if (errorCondition.getCondition() == ClientConstants.STORE_LOCK_LOST_ERROR) { + return new EventHubException(false, errorCondition.getDescription()); + } else if (errorCondition.getCondition() == AmqpErrorCode.AmqpLinkDetachForced) { + return new EventHubException(true, new AmqpException(errorCondition)); + } else if (errorCondition.getCondition() == AmqpErrorCode.ResourceLimitExceeded) { + return new QuotaExceededException(new AmqpException(errorCondition)); + } + + return new EventHubException(ClientConstants.DEFAULT_IS_TRANSIENT, errorCondition.getDescription()); + } + + static Exception amqpResponseCodeToException(final int statusCode, final String statusDescription) { + final AmqpResponseCode amqpResponseCode = AmqpResponseCode.valueOf(statusCode); + if (amqpResponseCode == null) + return new EventHubException(true, String.format(ClientConstants.AMQP_REQUEST_FAILED_ERROR, statusCode, statusDescription)); + + switch (amqpResponseCode) { + case BAD_REQUEST: + return new IllegalArgumentException(String.format(ClientConstants.AMQP_REQUEST_FAILED_ERROR, statusCode, statusDescription)); + case NOT_FOUND: + return ExceptionUtil.distinguishNotFound(statusDescription); + case FORBIDDEN: + return new QuotaExceededException(String.format(ClientConstants.AMQP_REQUEST_FAILED_ERROR, statusCode, statusDescription)); + case UNAUTHORIZED: + return new AuthorizationFailedException(String.format(ClientConstants.AMQP_REQUEST_FAILED_ERROR, statusCode, statusDescription)); + default: + return new EventHubException(true, String.format(ClientConstants.AMQP_REQUEST_FAILED_ERROR, statusCode, statusDescription)); + } + } + + static Exception distinguishNotFound(final String message) { + Pattern p = Pattern.compile("The messaging entity .* could not be found"); + Matcher m = p.matcher(message); + if (m.find()) { + return new IllegalEntityException(message); + } else { + return new EventHubException(true, String.format(ClientConstants.AMQP_REQUEST_FAILED_ERROR, AmqpResponseCode.NOT_FOUND, message)); + } + } + + static void completeExceptionally(CompletableFuture future, Exception exception, ErrorContextProvider contextProvider) { + if (exception != null && exception instanceof EventHubException) { + final ErrorContext errorContext = contextProvider.getContext(); + ((EventHubException) exception).setContext(errorContext); + } + + future.completeExceptionally(exception); + } + + // not a specific message related error + static boolean isGeneralSendError(Symbol amqpError) { + return (amqpError == ClientConstants.SERVER_BUSY_ERROR + || amqpError == ClientConstants.TIMEOUT_ERROR + || amqpError == AmqpErrorCode.ResourceLimitExceeded); + } + + static String getTrackingIDAndTimeToLog() { + return String.format(Locale.US, "TrackingId: %s, at: %s", UUID.randomUUID().toString(), ZonedDateTime.now()); + } + + public static String toStackTraceString(final Throwable exception, final String customErrorMessage) { + final StringBuilder builder = new StringBuilder(); + + if (!StringUtil.isNullOrEmpty(customErrorMessage)) { + builder.append(customErrorMessage); + builder.append(System.lineSeparator()); + } + + builder.append(exception.getMessage()); + final StackTraceElement[] stackTraceElements = exception.getStackTrace(); + if (stackTraceElements != null) { + for (final StackTraceElement ste : stackTraceElements) { + builder.append(System.lineSeparator()); + builder.append(ste.toString()); + } + } + + final Throwable innerException = exception.getCause(); + if (innerException != null) { + builder.append("Cause: " + innerException.getMessage()); + final StackTraceElement[] innerStackTraceElements = innerException.getStackTrace(); + if (innerStackTraceElements != null) { + for (final StackTraceElement ste : innerStackTraceElements) { + builder.append(System.lineSeparator()); + builder.append(ste.toString()); + } + } + } + + return builder.toString(); + } + + public static Throwable getExceptionFromCompletedFuture( + final CompletableFuture exceptionallyCompletedFuture) { + try { + exceptionallyCompletedFuture.get(); + } catch (ExecutionException | InterruptedException exception) { + final Throwable cause = exception.getCause(); + return (cause == null ? exception : cause); + } catch (Exception exception) { + return exception; + } + + return null; + } + + static Exception stripOuterException(final Exception exception) { + Throwable throwable = exception.getCause(); + if (throwable instanceof EventHubException) { + return (EventHubException) throwable; + } else if (throwable instanceof RuntimeException) { + return (RuntimeException) throwable; + } else if (throwable != null) { + return new RuntimeException(throwable); + } else { + return new RuntimeException(exception); + } + } + + private static void handle(final Exception exception) throws EventHubException { + if (exception instanceof InterruptedException) { + // Re-assert the thread's interrupted status + Thread.currentThread().interrupt(); + } + + Throwable throwable = exception.getCause(); + if (throwable instanceof EventHubException) { + throw (EventHubException) throwable; + } else if (throwable instanceof RuntimeException) { + throw (RuntimeException) throwable; + } else if (throwable != null) { + throw new RuntimeException(throwable); + } else { + throw new RuntimeException(exception); + } + } + + public static T sync(final SyncFactory factory) throws EventHubException { + try { + return factory.execute(); + } catch (InterruptedException | ExecutionException exception) { + handle(exception); + return null; + } + } + + public static T syncWithIOException(final SyncFactoryWithIOException factory) throws IOException, EventHubException { + try { + return factory.execute(); + } catch (InterruptedException | ExecutionException exception) { + handle(exception); + return null; + } + } + + public static void syncVoid(final SyncFactoryVoid factory) throws EventHubException { + try { + factory.execute(); + } catch (InterruptedException | ExecutionException exception) { + handle(exception); + } + } + + public static T syncWithIllegalArgException(final SyncFactoryWithIllegalArgException factory) throws EventHubException { + try { + return factory.execute(); + } catch (InterruptedException | ExecutionException exception) { + handle(exception); + return null; + } + } + + @FunctionalInterface + public interface SyncFactory { + T execute() throws EventHubException, ExecutionException, InterruptedException; + } + + @FunctionalInterface + public interface SyncFactoryWithIOException { + T execute() throws IOException, EventHubException, ExecutionException, InterruptedException; + } + + @FunctionalInterface + public interface SyncFactoryVoid { + void execute() throws EventHubException, ExecutionException, InterruptedException; + } + + @FunctionalInterface + public interface SyncFactoryWithIllegalArgException { + T execute() throws IllegalArgumentException, EventHubException, ExecutionException, InterruptedException; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/FaultTolerantObject.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/FaultTolerantObject.java new file mode 100644 index 000000000000..9aefd3889b46 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/FaultTolerantObject.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import java.io.IOException; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +public class FaultTolerantObject { + + private final Operation openTask; + private final Operation closeTask; + private final Queue> openCallbacks; + private final Queue> closeCallbacks; + + private T innerObject; + private boolean creatingNewInnerObject; + private boolean closingInnerObject; + + public FaultTolerantObject( + final Operation openAsync, + final Operation closeAsync) { + + this.openTask = openAsync; + this.closeTask = closeAsync; + this.openCallbacks = new ConcurrentLinkedQueue<>(); + this.closeCallbacks = new ConcurrentLinkedQueue<>(); + } + + // should be invoked from reactor thread + T unsafeGetIfOpened() { + + if (innerObject != null && innerObject.getState() == IOObject.IOObjectState.OPENED) + return innerObject; + + return null; + } + + public void runOnOpenedObject( + final ReactorDispatcher dispatcher, + final OperationResult openCallback) { + + try { + dispatcher.invoke(new DispatchHandler() { + @Override + public void onEvent() { + if (!creatingNewInnerObject + && (innerObject == null || innerObject.getState() == IOObject.IOObjectState.CLOSED || + innerObject.getState() == IOObject.IOObjectState.CLOSING)) { + creatingNewInnerObject = true; + + try { + openCallbacks.offer(openCallback); + openTask.run(new OperationResult() { + @Override + public void onComplete(T result) { + innerObject = result; + for (OperationResult callback : openCallbacks) + callback.onComplete(result); + + openCallbacks.clear(); + } + + @Override + public void onError(Exception error) { + for (OperationResult callback : openCallbacks) + callback.onError(error); + + openCallbacks.clear(); + } + }); + } finally { + creatingNewInnerObject = false; + } + } else if (innerObject != null && innerObject.getState() == IOObject.IOObjectState.OPENED) { + openCallback.onComplete(innerObject); + } else { + openCallbacks.offer(openCallback); + } + } + }); + } catch (IOException ioException) { + openCallback.onError(ioException); + } + } + + public void close( + final ReactorDispatcher dispatcher, + final OperationResult closeCallback) { + + try { + dispatcher.invoke(new DispatchHandler() { + @Override + public void onEvent() { + if (innerObject == null || innerObject.getState() == IOObject.IOObjectState.CLOSED) { + closeCallback.onComplete(null); + } else if (!closingInnerObject && (innerObject.getState() == IOObject.IOObjectState.OPENED || innerObject.getState() == IOObject.IOObjectState.OPENING)) { + closingInnerObject = true; + closeCallbacks.offer(closeCallback); + closeTask.run(new OperationResult() { + @Override + public void onComplete(Void result) { + closingInnerObject = false; + for (OperationResult callback : closeCallbacks) + callback.onComplete(result); + + closeCallbacks.clear(); + } + + @Override + public void onError(Exception error) { + closingInnerObject = false; + for (OperationResult callback : closeCallbacks) + callback.onError(error); + + closeCallbacks.clear(); + } + }); + } else { + closeCallbacks.offer(closeCallback); + } + } + }); + } catch (IOException ioException) { + closeCallback.onError(ioException); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/IOObject.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/IOObject.java new file mode 100644 index 000000000000..5c9b2e0d07ea --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/IOObject.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +public interface IOObject { + + // should be run on reactor thread + public IOObjectState getState(); + + public static enum IOObjectState { + OPENING, + OPENED, + CLOSED, + CLOSING + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/IteratorUtil.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/IteratorUtil.java new file mode 100644 index 000000000000..46e74b4ef70c --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/IteratorUtil.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import java.util.Iterator; + +public final class IteratorUtil { + private IteratorUtil() { + } + + public static boolean sizeEquals(Iterable iterable, int expectedSize) { + Iterator iterator = iterable.iterator(); + + int currentSize = 0; + while (iterator.hasNext()) { + if (expectedSize > currentSize) { + currentSize++; + iterator.next(); + continue; + } else { + return false; + } + } + + return true; + } + + public static T getLast(Iterator iterator) { + T last = null; + while (iterator.hasNext()) { + last = iterator.next(); + } + + return last; + } + + public static T getFirst(final Iterable iterable) { + if (iterable == null) { + return null; + } + + final Iterator iterator = iterable.iterator(); + if (iterator == null) { + return null; + } + + return iterator.hasNext() ? iterator.next() : null; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ManagementChannel.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ManagementChannel.java new file mode 100644 index 000000000000..8941ddb01c54 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ManagementChannel.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +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 java.io.IOException; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.azure.eventhubs.OperationCancelledException; +import com.microsoft.azure.eventhubs.TimeoutException; + +final class ManagementChannel { + + final FaultTolerantObject innerChannel; + final SessionProvider sessionProvider; + final AmqpConnection connectionEventDispatcher; + + public ManagementChannel(final SessionProvider sessionProvider, final AmqpConnection connection) { + this.sessionProvider = sessionProvider; + this.connectionEventDispatcher = connection; + + final RequestResponseCloser closer = new RequestResponseCloser(); + this.innerChannel = new FaultTolerantObject<>( + new RequestResponseOpener( + sessionProvider, + "mgmt-session", + "mgmt", + ClientConstants.MANAGEMENT_ADDRESS, + connection), + closer); + closer.setInnerChannel(this.innerChannel); + } + + public CompletableFuture> request( + final ReactorDispatcher dispatcher, + final Map request, + final long timeoutInMillis) { + // no body required + final Message requestMessage = Proton.message(); + final ApplicationProperties applicationProperties = new ApplicationProperties(request); + requestMessage.setApplicationProperties(applicationProperties); + final CompletableFuture> resultFuture = new CompletableFuture>(); + try { + // schedule client-timeout on the request + dispatcher.invoke((int) timeoutInMillis, + new DispatchHandler() { + @Override + public void onEvent() { + final RequestResponseChannel channel = innerChannel.unsafeGetIfOpened(); + final String errorMessage; + if (channel != null && channel.getState() == IOObject.IOObjectState.OPENED) { + final String remoteContainerId = channel.getSendLink().getSession().getConnection().getRemoteContainer(); + errorMessage = String.format("Management request timed out (%sms), after not receiving response from service. TrackingId: %s", + timeoutInMillis, StringUtil.isNullOrEmpty(remoteContainerId) ? "n/a" : remoteContainerId); + } else { + errorMessage = "Management request timed out on the client - enable info level tracing to diagnose."; + } + + resultFuture.completeExceptionally(new TimeoutException(errorMessage)); + } + }); + } catch (final IOException ioException) { + resultFuture.completeExceptionally( + new OperationCancelledException( + "Sending request failed while dispatching to Reactor, see cause for more details.", + ioException)); + + return resultFuture; + } + + // if there isn't even 5 millis left - request will not make the round-trip + // to the event hubs service. so don't schedule the request - let it timeout + if (timeoutInMillis > ClientConstants.MGMT_CHANNEL_MIN_RETRY_IN_MILLIS) { + this.innerChannel.runOnOpenedObject(dispatcher, + new OperationResult() { + @Override + public void onComplete(final RequestResponseChannel result) { + result.request(requestMessage, + new OperationResult() { + @Override + public void onComplete(final Message response) { + final int statusCode = (int) response.getApplicationProperties().getValue() + .get(ClientConstants.PUT_TOKEN_STATUS_CODE); + final String statusDescription = (String) response.getApplicationProperties().getValue() + .get(ClientConstants.PUT_TOKEN_STATUS_DESCRIPTION); + + if (statusCode == AmqpResponseCode.ACCEPTED.getValue() + || statusCode == AmqpResponseCode.OK.getValue()) { + if (response.getBody() != null) { + resultFuture.complete((Map) ((AmqpValue) response.getBody()).getValue()); + } + } else { + this.onError(ExceptionUtil.amqpResponseCodeToException(statusCode, statusDescription)); + } + } + + @Override + public void onError(final Exception error) { + resultFuture.completeExceptionally(error); + } + }); + } + + @Override + public void onError(Exception error) { + resultFuture.completeExceptionally(error); + } + }); + } + + return resultFuture; + } + + public void close(final ReactorDispatcher reactorDispatcher, final OperationResult closeCallback) { + this.innerChannel.close(reactorDispatcher, closeCallback); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/MessageReceiver.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/MessageReceiver.java new file mode 100644 index 000000000000..7b43796e6256 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/MessageReceiver.java @@ -0,0 +1,821 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.ErrorContext; +import com.microsoft.azure.eventhubs.EventHubException; +import com.microsoft.azure.eventhubs.TimeoutException; +import org.apache.qpid.proton.Proton; +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.ErrorCondition; +import org.apache.qpid.proton.amqp.transport.ReceiverSettleMode; +import org.apache.qpid.proton.amqp.transport.SenderSettleMode; +import org.apache.qpid.proton.engine.*; +import org.apache.qpid.proton.message.Message; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.*; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * Common Receiver that abstracts all amqp related details + * translates event-driven reactor model into async receive Api + */ +public final class MessageReceiver extends ClientEntity implements AmqpReceiver, ErrorContextProvider { + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(MessageReceiver.class); + private static final int MIN_TIMEOUT_DURATION_MILLIS = 20; + private static final int MAX_OPERATION_TIMEOUT_SCHEDULED = 2; + // TestHooks for code injection + private static volatile Consumer onOpenRetry = null; + private final AtomicInteger operationTimeoutScheduled = new AtomicInteger(0); + private final ConcurrentLinkedQueue pendingReceives; + private final MessagingFactory underlyingFactory; + private final String receivePath; + private final Runnable onOperationTimedout; + private final Duration operationTimeout; + private final CompletableFuture linkClose; + private final ReceiverSettingsProvider settingsProvider; + private final String tokenAudience; + private final ActiveClientTokenManager activeClientTokenManager; + private final WorkItem linkOpen; + private final ConcurrentLinkedQueue prefetchedMessages; + private final ReceiveWork receiveWork; + private final CreateAndReceive createAndReceive; + private final Object errorConditionLock; + private final Timer timer; + private volatile int nextCreditToFlow; + private volatile Receiver receiveLink; + private volatile Duration receiveTimeout; + private volatile Message lastReceivedMessage; + private volatile boolean creatingLink; + private volatile CompletableFuture openTimer; + private volatile CompletableFuture closeTimer; + private int prefetchCount; + private Exception lastKnownLinkError; + private String linkCreationTime; + + private MessageReceiver(final MessagingFactory factory, + final String name, + final String recvPath, + final int prefetchCount, + final ReceiverSettingsProvider settingsProvider) { + super(name, factory, factory.executor); + + this.underlyingFactory = factory; + this.operationTimeout = factory.getOperationTimeout(); + this.receivePath = recvPath; + this.prefetchCount = prefetchCount; + this.prefetchedMessages = new ConcurrentLinkedQueue<>(); + this.linkClose = new CompletableFuture<>(); + this.lastKnownLinkError = null; + this.receiveTimeout = factory.getOperationTimeout(); + this.settingsProvider = settingsProvider; + this.linkOpen = new WorkItem<>(new CompletableFuture<>(), factory.getOperationTimeout()); + this.timer = new Timer(factory); + + this.pendingReceives = new ConcurrentLinkedQueue<>(); + this.errorConditionLock = new Object(); + + // onOperationTimeout delegate - per receive call + this.onOperationTimedout = new Runnable() { + public void run() { + MessageReceiver.this.operationTimeoutTimerFired(); + + WorkItem> topWorkItem = null; + while ((topWorkItem = MessageReceiver.this.pendingReceives.peek()) != null) { + if (topWorkItem.getTimeoutTracker().remaining().toMillis() <= MessageReceiver.MIN_TIMEOUT_DURATION_MILLIS) { + WorkItem> dequedWorkItem = MessageReceiver.this.pendingReceives.poll(); + if (dequedWorkItem != null && dequedWorkItem.getWork() != null && !dequedWorkItem.getWork().isDone()) { + dequedWorkItem.getWork().complete(null); + } else { + break; + } + } else { + if (MessageReceiver.this.shouldScheduleOperationTimeoutTimer()) { + TimeoutTracker timeoutTracker = topWorkItem.getTimeoutTracker(); + + if (TRACE_LOGGER.isDebugEnabled()) { + TRACE_LOGGER.debug( + String.format(Locale.US, + "clientId[%s], path[%s], linkName[%s] - Reschedule operation timer, current: [%s], remaining: [%s] secs", + getClientId(), + receivePath, + receiveLink.getName(), + Instant.now(), + timeoutTracker.remaining().getSeconds())); + } + + MessageReceiver.this.scheduleOperationTimer(timeoutTracker); + } + + break; + } + } + } + }; + + this.receiveWork = new ReceiveWork(); + this.createAndReceive = new CreateAndReceive(); + + this.tokenAudience = String.format(ClientConstants.TOKEN_AUDIENCE_FORMAT, underlyingFactory.getHostName(), receivePath); + + this.activeClientTokenManager = new ActiveClientTokenManager( + this, + new Runnable() { + @Override + public void run() { + try { + underlyingFactory.getCBSChannel().sendToken( + underlyingFactory.getReactorDispatcher(), + underlyingFactory.getTokenProvider().getToken(tokenAudience, ClientConstants.TOKEN_VALIDITY), + tokenAudience, + new OperationResult() { + @Override + public void onComplete(Void result) { + if (TRACE_LOGGER.isDebugEnabled()) { + TRACE_LOGGER.debug( + String.format(Locale.US, + "clientId[%s], path[%s], linkName[%s] - token renewed", + getClientId(), receivePath, receiveLink.getName())); + } + } + + @Override + public void onError(Exception error) { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info( + String.format(Locale.US, + "clientId[%s], path[%s], linkName[%s], tokenRenewalFailure[%s]", + getClientId(), receivePath, receiveLink.getName(), error.getMessage())); + } + } + }); + } catch (IOException | NoSuchAlgorithmException | InvalidKeyException | RuntimeException exception) { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info( + String.format(Locale.US, + "clientId[%s], path[%s], linkName[%s], tokenRenewalScheduleFailure[%s]", + getClientId(), receivePath, receiveLink.getName(), exception.getMessage())); + } + } + } + }, + ClientConstants.TOKEN_REFRESH_INTERVAL, + this.underlyingFactory); + } + + // @param connection Connection on which the MessageReceiver's receive AMQP link need to be created on. + // Connection has to be associated with Reactor before Creating a receiver on it. + public static CompletableFuture create( + final MessagingFactory factory, + final String name, + final String recvPath, + final int prefetchCount, + final ReceiverSettingsProvider settingsProvider) { + MessageReceiver msgReceiver = new MessageReceiver( + factory, + name, + recvPath, + prefetchCount, + settingsProvider); + return msgReceiver.createLink(); + } + + public String getReceivePath() { + return this.receivePath; + } + + private CompletableFuture createLink() { + try { + this.underlyingFactory.scheduleOnReactorThread(new DispatchHandler() { + @Override + public void onEvent() { + MessageReceiver.this.createReceiveLink(); + } + }); + } catch (IOException | RejectedExecutionException schedulerException) { + this.linkOpen.getWork().completeExceptionally(schedulerException); + } + + return this.linkOpen.getWork(); + } + + private List receiveCore(final int messageCount) { + List returnMessages = null; + Message currentMessage; + + while ((currentMessage = this.pollPrefetchQueue()) != null) { + if (returnMessages == null) { + returnMessages = new LinkedList<>(); + } + + returnMessages.add(currentMessage); + if (returnMessages.size() >= messageCount) { + break; + } + } + + return returnMessages; + } + + public Duration getReceiveTimeout() { + return this.receiveTimeout; + } + + public void setReceiveTimeout(final Duration value) { + this.receiveTimeout = value; + } + + public CompletableFuture> receive(final int maxMessageCount) { + this.throwIfClosed(); + + final CompletableFuture> onReceive = new CompletableFuture<>(); + if (maxMessageCount <= 0 || maxMessageCount > this.prefetchCount) { + onReceive.completeExceptionally(new IllegalArgumentException(String.format( + Locale.US, + "Entity(%s): maxEventCount(%s) should be a positive number and should be less than prefetchCount(%s)", + this.receivePath, maxMessageCount, this.prefetchCount))); + return onReceive; + } + + if (this.shouldScheduleOperationTimeoutTimer()) { + if (TRACE_LOGGER.isDebugEnabled()) { + TRACE_LOGGER.debug( + String.format(Locale.US, + "clientId[%s], path[%s], linkName[%s] - schedule operation timer, current: [%s], remaining: [%s] secs", + this.getClientId(), + this.receivePath, + this.receiveLink.getName(), + Instant.now(), + this.receiveTimeout.getSeconds())); + } + + timer.schedule(this.onOperationTimedout, this.receiveTimeout); + } + + pendingReceives.offer(new ReceiveWorkItem(onReceive, receiveTimeout, maxMessageCount)); + + try { + this.underlyingFactory.scheduleOnReactorThread(this.createAndReceive); + } catch (IOException | RejectedExecutionException schedulerException) { + onReceive.completeExceptionally(schedulerException); + } + + return onReceive; + } + + @Override + public void onOpenComplete(Exception exception) { + this.creatingLink = false; + + if (exception == null) { + if (this.linkOpen != null && !this.linkOpen.getWork().isDone()) { + this.linkOpen.getWork().complete(this); + } + + this.cancelOpenTimer(); + + if (this.getIsClosingOrClosed()) { + return; + } + + synchronized (this.errorConditionLock) { + this.lastKnownLinkError = null; + } + + this.underlyingFactory.getRetryPolicy().resetRetryCount(this.underlyingFactory.getClientId()); + + this.nextCreditToFlow = 0; + this.sendFlow(this.prefetchCount - this.prefetchedMessages.size()); + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format("onOpenComplete - clientId[%s], receiverPath[%s], linkName[%s], updated-link-credit[%s], sentCredits[%s]", + this.getClientId(), this.receivePath, this.receiveLink.getName(), this.receiveLink.getCredit(), this.prefetchCount)); + } + } else { + synchronized (this.errorConditionLock) { + this.lastKnownLinkError = exception; + } + + if (this.linkOpen != null && !this.linkOpen.getWork().isDone()) { + final Duration nextRetryInterval = this.underlyingFactory.getRetryPolicy().getNextRetryInterval( + this.getClientId(), exception, this.linkOpen.getTimeoutTracker().remaining()); + if (nextRetryInterval != null) { + if (onOpenRetry != null) { + onOpenRetry.accept(this); + } + + try { + this.underlyingFactory.scheduleOnReactorThread((int) nextRetryInterval.toMillis(), new DispatchHandler() { + @Override + public void onEvent() { + if (!MessageReceiver.this.getIsClosingOrClosed() + && (receiveLink == null || receiveLink.getLocalState() == EndpointState.CLOSED || receiveLink.getRemoteState() == EndpointState.CLOSED)) { + createReceiveLink(); + underlyingFactory.getRetryPolicy().incrementRetryCount(getClientId()); + } + } + }); + } catch (IOException | RejectedExecutionException schedulerException) { + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn( + String.format(Locale.US, "clientId[%s], receiverPath[%s], scheduling createLink encountered error: %s", + this.getClientId(), this.receivePath, schedulerException.getLocalizedMessage())); + } + + this.cancelOpen(schedulerException); + } + } else if (exception instanceof EventHubException && !((EventHubException) exception).getIsTransient()) { + this.cancelOpen(exception); + } + } else { + this.cancelOpenTimer(); + } + } + } + + private void cancelOpen(final Exception completionException) { + this.setClosed(); + ExceptionUtil.completeExceptionally(this.linkOpen.getWork(), completionException, this); + this.cancelOpenTimer(); + } + + private void cancelOpenTimer() { + if (this.openTimer != null && !this.openTimer.isCancelled()) { + this.openTimer.cancel(false); + } + } + + @Override + public void onReceiveComplete(Delivery delivery) { + int msgSize = delivery.pending(); + byte[] buffer = new byte[msgSize]; + + int read = receiveLink.recv(buffer, 0, msgSize); + + Message message = Proton.message(); + message.decode(buffer, 0, read); + + delivery.settle(); + + this.prefetchedMessages.add(message); + this.underlyingFactory.getRetryPolicy().resetRetryCount(this.getClientId()); + + this.receiveWork.onEvent(); + } + + @Override + public void onError(final Exception exception) { + this.prefetchedMessages.clear(); + + if (this.getIsClosingOrClosed()) { + if (this.closeTimer != null) + this.closeTimer.cancel(false); + + this.drainPendingReceives(exception); + this.linkClose.complete(null); + } else { + synchronized (this.errorConditionLock) { + this.lastKnownLinkError = exception == null ? this.lastKnownLinkError : exception; + } + + final Exception completionException = exception == null + ? new EventHubException(true, String.format(Locale.US, + "Entity(%s): client encountered transient error for unknown reasons, please retry the operation.", this.receivePath)) + : exception; + + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn( + String.format(Locale.US, "clientId[%s], receiverPath[%s], linkName[%s], onError: %s", + this.getClientId(), + this.receivePath, + this.receiveLink.getName(), + completionException)); + } + + this.onOpenComplete(completionException); + + final WorkItem> workItem = this.pendingReceives.peek(); + final Duration nextRetryInterval = workItem != null && workItem.getTimeoutTracker() != null + ? this.underlyingFactory.getRetryPolicy().getNextRetryInterval(this.getClientId(), completionException, workItem.getTimeoutTracker().remaining()) + : null; + + boolean recreateScheduled = true; + if (nextRetryInterval != null) { + try { + this.underlyingFactory.scheduleOnReactorThread((int) nextRetryInterval.toMillis(), new DispatchHandler() { + @Override + public void onEvent() { + if (!MessageReceiver.this.getIsClosingOrClosed() + && (receiveLink.getLocalState() == EndpointState.CLOSED || receiveLink.getRemoteState() == EndpointState.CLOSED)) { + createReceiveLink(); + underlyingFactory.getRetryPolicy().incrementRetryCount(getClientId()); + } + } + }); + } catch (IOException | RejectedExecutionException ignore) { + recreateScheduled = false; + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn( + String.format(Locale.US, "clientId[%s], receiverPath[%s], linkName[%s], scheduling createLink encountered error: %s", + this.getClientId(), + this.receivePath, + this.receiveLink.getName(), ignore.getLocalizedMessage())); + } + } + } + + if (nextRetryInterval == null || !recreateScheduled) { + this.drainPendingReceives(completionException); + } + } + } + + private void drainPendingReceives(final Exception exception) { + WorkItem> workItem; + final boolean shouldReturnNull = (exception == null + || (exception instanceof EventHubException && ((EventHubException) exception).getIsTransient())); + + while ((workItem = this.pendingReceives.poll()) != null) { + final CompletableFuture> future = workItem.getWork(); + if (shouldReturnNull) { + future.complete(null); + } else { + ExceptionUtil.completeExceptionally(future, exception, this); + } + } + } + + private void scheduleOperationTimer(final TimeoutTracker tracker) { + if (tracker != null) { + timer.schedule(this.onOperationTimedout, tracker.remaining()); + } + } + + private void createReceiveLink() { + synchronized (this.errorConditionLock) { + if (this.creatingLink) { + return; + } + + this.creatingLink = true; + } + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info( + String.format(Locale.US, + "clientId[%s], path[%s], operationTimeout[%s], creating a receive link", + this.getClientId(), this.receivePath, this.operationTimeout)); + } + + this.linkCreationTime = Instant.now().toString(); + + this.scheduleLinkOpenTimeout(TimeoutTracker.create(this.operationTimeout)); + + final Consumer onSessionOpen = new Consumer() { + @Override + public void accept(Session session) { + // if the MessageReceiver is closed - we no-longer need to create the link + if (MessageReceiver.this.getIsClosingOrClosed()) { + + session.close(); + return; + } + + final Source source = new Source(); + source.setAddress(receivePath); + + final Map filterMap = MessageReceiver.this.settingsProvider.getFilter(MessageReceiver.this.lastReceivedMessage); + if (filterMap != null) + source.setFilter(filterMap); + + final Receiver receiver = session.receiver(TrackingUtil.getLinkName(session)); + 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); + + final Map linkProperties = MessageReceiver.this.settingsProvider.getProperties(); + if (linkProperties != null) + receiver.setProperties(linkProperties); + + final Symbol[] desiredCapabilities = MessageReceiver.this.settingsProvider.getDesiredCapabilities(); + if (desiredCapabilities != null) + receiver.setDesiredCapabilities(desiredCapabilities); + + final ReceiveLinkHandler handler = new ReceiveLinkHandler(MessageReceiver.this); + BaseHandler.setHandler(receiver, handler); + + if (MessageReceiver.this.receiveLink != null) { + MessageReceiver.this.underlyingFactory.deregisterForConnectionError(MessageReceiver.this.receiveLink); + } + + MessageReceiver.this.underlyingFactory.registerForConnectionError(receiver); + + receiver.open(); + + synchronized (MessageReceiver.this.errorConditionLock) { + MessageReceiver.this.receiveLink = receiver; + } + } + }; + + final BiConsumer onSessionOpenFailed = new BiConsumer() { + @Override + public void accept(ErrorCondition t, Exception u) { + if (t != null) { + onError((t.getCondition() != null) ? ExceptionUtil.toException(t) : null); + } else if (u != null) { + onError(u); + } + } + }; + + try { + this.underlyingFactory.getCBSChannel().sendToken( + this.underlyingFactory.getReactorDispatcher(), + this.underlyingFactory.getTokenProvider().getToken(tokenAudience, ClientConstants.TOKEN_VALIDITY), + tokenAudience, + new OperationResult() { + @Override + public void onComplete(Void result) { + if (MessageReceiver.this.getIsClosingOrClosed()) + return; + + underlyingFactory.getSession( + receivePath, + onSessionOpen, + onSessionOpenFailed); + } + + @Override + public void onError(Exception error) { + final Exception completionException; + if (error != null && error instanceof AmqpException) { + completionException = ExceptionUtil.toException(((AmqpException) error).getError()); + if (completionException != error && completionException.getCause() == null) { + completionException.initCause(error); + } + } else { + completionException = error; + } + + MessageReceiver.this.onError(completionException); + } + }); + } catch (IOException | NoSuchAlgorithmException | InvalidKeyException | RuntimeException exception) { + MessageReceiver.this.onError(exception); + } + } + + // CONTRACT: message should be delivered to the caller of MessageReceiver.receive() only via Poll on prefetchqueue + private Message pollPrefetchQueue() { + final Message message = this.prefetchedMessages.poll(); + if (message != null) { + // message lastReceivedOffset should be up-to-date upon each poll - as recreateLink will depend on this + this.lastReceivedMessage = message; + this.sendFlow(1); + } + + return message; + } + + private void sendFlow(final int credits) { + // slow down sending the flow - to make the protocol less-chat'y + this.nextCreditToFlow += credits; + if (this.nextCreditToFlow >= this.prefetchCount || this.nextCreditToFlow >= 100) { + final int tempFlow = this.nextCreditToFlow; + this.receiveLink.flow(tempFlow); + this.nextCreditToFlow = 0; + + if (TRACE_LOGGER.isDebugEnabled()) { + TRACE_LOGGER.debug(String.format("clientId[%s], receiverPath[%s], linkName[%s], updated-link-credit[%s], sentCredits[%s], ThreadId[%s]", + this.getClientId(), this.receivePath, this.receiveLink.getName(), this.receiveLink.getCredit(), tempFlow, Thread.currentThread().getId())); + } + } + } + + private void scheduleLinkOpenTimeout(final TimeoutTracker timeout) { + // timer to signal a timeout if exceeds the operationTimeout on MessagingFactory + this.openTimer = timer.schedule( + new Runnable() { + public void run() { + creatingLink = false; + + if (!linkOpen.getWork().isDone()) { + final Receiver link; + final Exception lastReportedLinkError; + synchronized (errorConditionLock) { + link = MessageReceiver.this.receiveLink; + lastReportedLinkError = MessageReceiver.this.lastKnownLinkError; + } + + final Exception operationTimedout = new TimeoutException( + String.format(Locale.US, "Open operation on entity(%s) timed out at %s.", + MessageReceiver.this.receivePath, ZonedDateTime.now()), + lastReportedLinkError); + + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn( + String.format(Locale.US, "clientId[%s], receiverPath[%s], Open call timed out", + MessageReceiver.this.getClientId(), MessageReceiver.this.receivePath), operationTimedout); + } + + ExceptionUtil.completeExceptionally(linkOpen.getWork(), operationTimedout, MessageReceiver.this); + setClosed(); + } + } + } + , timeout.remaining()); + + this.openTimer.handleAsync( + (unUsed, exception) -> { + if (exception != null + && exception instanceof Exception + && !(exception instanceof CancellationException)) { + ExceptionUtil.completeExceptionally(linkOpen.getWork(), (Exception) exception, MessageReceiver.this); + } + + return null; + }, this.executor); + } + + private void scheduleLinkCloseTimeout(final TimeoutTracker timeout) { + // timer to signal a timeout if exceeds the operationTimeout on MessagingFactory + this.closeTimer = timer.schedule( + new Runnable() { + public void run() { + if (!linkClose.isDone()) { + final Receiver link; + synchronized (errorConditionLock) { + link = MessageReceiver.this.receiveLink; + } + + final Exception operationTimedout = new TimeoutException(String.format(Locale.US, "Close operation on Receive Link(%s) timed out at %s", + link.getName(), ZonedDateTime.now())); + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info( + String.format(Locale.US, "clientId[%s], receiverPath[%s], linkName[%s], Close call timed out", + MessageReceiver.this.getClientId(), MessageReceiver.this.receivePath, link.getName()), + operationTimedout); + } + + ExceptionUtil.completeExceptionally(linkClose, operationTimedout, MessageReceiver.this); + MessageReceiver.this.onError((Exception) null); + } + } + } + , timeout.remaining()); + + this.closeTimer.handleAsync( + (unUsed, exception) -> { + if (exception != null && exception instanceof Exception && !(exception instanceof CancellationException)) { + ExceptionUtil.completeExceptionally(linkClose, (Exception) exception, MessageReceiver.this); + } + + return null; + }, this.executor); + } + + private boolean shouldScheduleOperationTimeoutTimer() { + boolean scheduleTimer = this.operationTimeoutScheduled.getAndIncrement() < MAX_OPERATION_TIMEOUT_SCHEDULED; + if (!scheduleTimer) { + this.operationTimeoutScheduled.decrementAndGet(); + } + + return scheduleTimer; + } + + private void operationTimeoutTimerFired() { + MessageReceiver.this.operationTimeoutScheduled.decrementAndGet(); + } + + @Override + public void onClose(ErrorCondition condition) { + if (this.receiveLink != null) { + this.underlyingFactory.deregisterForConnectionError(MessageReceiver.this.receiveLink); + } + + final Exception completionException = (condition != null && condition.getCondition() != null) ? ExceptionUtil.toException(condition) : null; + this.onError(completionException); + } + + @Override + public ErrorContext getContext() { + final Receiver link; + synchronized (this.errorConditionLock) { + link = this.receiveLink; + } + + final boolean isLinkOpened = this.linkOpen != null && this.linkOpen.getWork().isDone(); + final String referenceId = link != null && link.getRemoteProperties() != null && link.getRemoteProperties().containsKey(ClientConstants.TRACKING_ID_PROPERTY) + ? link.getRemoteProperties().get(ClientConstants.TRACKING_ID_PROPERTY).toString() + : ((link != null) ? link.getName() : null); + + final ReceiverContext errorContext = new ReceiverContext(this.underlyingFactory != null ? this.underlyingFactory.getHostName() : null, + this.receivePath, + referenceId, + isLinkOpened ? this.prefetchCount : null, + isLinkOpened && link != null ? link.getCredit() : null, + isLinkOpened && this.prefetchedMessages != null ? this.prefetchedMessages.size() : null); + + return errorContext; + } + + @Override + protected CompletableFuture onClose() { + if (!this.getIsClosed()) { + try { + this.activeClientTokenManager.cancel(); + scheduleLinkCloseTimeout(TimeoutTracker.create(operationTimeout)); + + this.underlyingFactory.scheduleOnReactorThread(new DispatchHandler() { + @Override + public void onEvent() { + if (receiveLink != null && receiveLink.getLocalState() != EndpointState.CLOSED) { + receiveLink.close(); + } else if (receiveLink == null || receiveLink.getRemoteState() == EndpointState.CLOSED) { + if (closeTimer != null && !closeTimer.isCancelled()) { + closeTimer.cancel(false); + } + + linkClose.complete(null); + } + } + }); + } catch (IOException | RejectedExecutionException schedulerException) { + this.linkClose.completeExceptionally(schedulerException); + } + } + + return this.linkClose; + } + + @Override + protected Exception getLastKnownError() { + synchronized (this.errorConditionLock) { + return this.lastKnownLinkError; + } + } + + private static class ReceiveWorkItem extends WorkItem> { + private final int maxMessageCount; + + public ReceiveWorkItem(CompletableFuture> completableFuture, Duration timeout, final int maxMessageCount) { + super(completableFuture, timeout); + this.maxMessageCount = maxMessageCount; + } + } + + private final class ReceiveWork extends DispatchHandler { + + @Override + public void onEvent() { + + ReceiveWorkItem pendingReceive; + while (!prefetchedMessages.isEmpty() && (pendingReceive = pendingReceives.poll()) != null) { + if (pendingReceive.getWork() != null && !pendingReceive.getWork().isDone()) { + Collection receivedMessages = receiveCore(pendingReceive.maxMessageCount); + pendingReceive.getWork().complete(receivedMessages); + } + } + } + } + + private final class CreateAndReceive extends DispatchHandler { + + @Override + public void onEvent() { + receiveWork.onEvent(); + + if (!MessageReceiver.this.getIsClosingOrClosed() + && (receiveLink.getLocalState() == EndpointState.CLOSED || receiveLink.getRemoteState() == EndpointState.CLOSED)) { + createReceiveLink(); + } + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/MessageSender.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/MessageSender.java new file mode 100644 index 000000000000..6f67323ce997 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/MessageSender.java @@ -0,0 +1,1009 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.*; +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.amqp.Binary; +import org.apache.qpid.proton.amqp.UnsignedLong; +import org.apache.qpid.proton.amqp.messaging.*; +import org.apache.qpid.proton.amqp.transport.DeliveryState; +import org.apache.qpid.proton.amqp.transport.ErrorCondition; +import org.apache.qpid.proton.amqp.transport.SenderSettleMode; +import org.apache.qpid.proton.engine.*; +import org.apache.qpid.proton.engine.impl.DeliveryImpl; +import org.apache.qpid.proton.message.Message; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.BufferOverflowException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.*; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.RejectedExecutionException; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * Abstracts all amqp related details + * translates event-driven reactor model into async send Api + */ +public final class MessageSender extends ClientEntity implements AmqpSender, ErrorContextProvider { + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(MessageSender.class); + private static final String SEND_TIMED_OUT = "Send operation timed out"; + // TestHooks for code injection + private static volatile Consumer onOpenRetry = null; + private final MessagingFactory underlyingFactory; + private final String sendPath; + private final Duration operationTimeout; + private final RetryPolicy retryPolicy; + private final CompletableFuture linkClose; + private final Object pendingSendLock; + private final ConcurrentHashMap> pendingSendsData; + private final PriorityQueue pendingSends; + private final DispatchHandler sendWork; + private final ActiveClientTokenManager activeClientTokenManager; + private final String tokenAudience; + private final Object errorConditionLock; + private final Timer timer; + private volatile int maxMessageSize; + private volatile Sender sendLink; + private volatile CompletableFuture linkFirstOpen; + private volatile TimeoutTracker openLinkTracker; + private volatile boolean creatingLink; + private volatile CompletableFuture closeTimer; + private volatile CompletableFuture openTimer; + private Exception lastKnownLinkError; + private Instant lastKnownErrorReportedAt; + private String linkCreationTime; + + private MessageSender(final MessagingFactory factory, final String sendLinkName, final String senderPath) { + super(sendLinkName, factory, factory.executor); + + this.sendPath = senderPath; + this.underlyingFactory = factory; + this.operationTimeout = factory.getOperationTimeout(); + this.timer = new Timer(factory); + this.lastKnownLinkError = null; + this.lastKnownErrorReportedAt = Instant.EPOCH; + this.retryPolicy = factory.getRetryPolicy(); + this.maxMessageSize = ClientConstants.MAX_MESSAGE_LENGTH_BYTES; + this.errorConditionLock = new Object(); + this.pendingSendLock = new Object(); + this.pendingSendsData = new ConcurrentHashMap<>(); + this.pendingSends = new PriorityQueue<>(1000, new DeliveryTagComparator()); + this.linkClose = new CompletableFuture<>(); + this.linkFirstOpen = new CompletableFuture<>(); + this.openLinkTracker = TimeoutTracker.create(factory.getOperationTimeout()); + this.sendWork = new DispatchHandler() { + @Override + public void onEvent() { + MessageSender.this.processSendWork(); + } + }; + this.tokenAudience = String.format(ClientConstants.TOKEN_AUDIENCE_FORMAT, underlyingFactory.getHostName(), sendPath); + this.activeClientTokenManager = new ActiveClientTokenManager( + this, + new Runnable() { + @Override + public void run() { + try { + underlyingFactory.getCBSChannel().sendToken( + underlyingFactory.getReactorDispatcher(), + underlyingFactory.getTokenProvider().getToken(tokenAudience, ClientConstants.TOKEN_VALIDITY), + tokenAudience, + new OperationResult() { + @Override + public void onComplete(Void result) { + if (TRACE_LOGGER.isDebugEnabled()) { + TRACE_LOGGER.debug(String.format(Locale.US, + "clientId[%s], path[%s], linkName[%s] - token renewed", + getClientId(), sendPath, sendLink.getName())); + } + } + + @Override + public void onError(Exception error) { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, + "clientId[%s], path[%s], linkName[%s] - tokenRenewalFailure[%s]", + getClientId(), sendPath, sendLink.getName(), error.getMessage())); + } + } + }); + } catch (IOException | NoSuchAlgorithmException | InvalidKeyException | RuntimeException exception) { + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn(String.format(Locale.US, + "clientId[%s], path[%s], linkName[%s] - tokenRenewalScheduleFailure[%s]", + getClientId(), sendPath, sendLink.getName(), exception.getMessage())); + } + } + } + }, + ClientConstants.TOKEN_REFRESH_INTERVAL, + this.underlyingFactory); + } + + public static CompletableFuture create( + final MessagingFactory factory, + final String sendLinkName, + final String senderPath) { + final MessageSender msgSender = new MessageSender(factory, sendLinkName, senderPath); + + try { + msgSender.underlyingFactory.scheduleOnReactorThread(new DispatchHandler() { + @Override + public void onEvent() { + msgSender.createSendLink(); + } + }); + } catch (IOException | RejectedExecutionException schedulerException) { + msgSender.linkFirstOpen.completeExceptionally(schedulerException); + } + + return msgSender.linkFirstOpen; + } + + public String getSendPath() { + return this.sendPath; + } + + public int getMaxMessageSize() { + return this.maxMessageSize; + } + + private CompletableFuture send(byte[] bytes, int arrayOffset, int messageFormat) { + return this.send(bytes, arrayOffset, messageFormat, null, null); + } + + private CompletableFuture sendCore( + final byte[] bytes, + final int arrayOffset, + final int messageFormat, + final CompletableFuture onSend, + final TimeoutTracker tracker, + final Exception lastKnownError, + final CompletableFuture timeoutTask) { + this.throwIfClosed(); + + final boolean isRetrySend = (onSend != null); + + final CompletableFuture onSendFuture = (onSend == null) ? new CompletableFuture<>() : onSend; + + final ReplayableWorkItem sendWaiterData = (tracker == null) ? + new ReplayableWorkItem<>(bytes, arrayOffset, messageFormat, onSendFuture, this.operationTimeout) : + new ReplayableWorkItem<>(bytes, arrayOffset, messageFormat, onSendFuture, tracker); + + final TimeoutTracker currentSendTracker = sendWaiterData.getTimeoutTracker(); + final String deliveryTag = UUID.randomUUID().toString().replace("-", StringUtil.EMPTY) + "_" + currentSendTracker.elapsed().getSeconds(); + + if (lastKnownError != null) { + sendWaiterData.setLastKnownException(lastKnownError); + } + + if (timeoutTask != null) + timeoutTask.cancel(false); + + final CompletableFuture timeoutTimerTask = this.timer.schedule( + new SendTimeout(deliveryTag, sendWaiterData), + currentSendTracker.remaining()); + + // if the timeoutTask completed with scheduling error - notify sender + if (timeoutTimerTask.isCompletedExceptionally()) { + timeoutTimerTask.handleAsync( + (unUsed, exception) -> { + if (exception != null && !(exception instanceof CancellationException)) + onSendFuture.completeExceptionally( + new OperationCancelledException(String.format(Locale.US, + "Entity(%s): send failed while dispatching to Reactor, see cause for more details.", + this.sendPath), exception)); + + return null; + }, this.executor); + + return onSendFuture; + } + + sendWaiterData.setTimeoutTask(timeoutTimerTask); + + synchronized (this.pendingSendLock) { + this.pendingSendsData.put(deliveryTag, sendWaiterData); + this.pendingSends.offer(new WeightedDeliveryTag(deliveryTag, isRetrySend ? 1 : 0)); + } + + try { + this.underlyingFactory.scheduleOnReactorThread(this.sendWork); + } catch (IOException | RejectedExecutionException schedulerException) { + onSendFuture.completeExceptionally( + new OperationCancelledException(String.format(Locale.US, + "Entity(%s): send failed while dispatching to Reactor, see cause for more details.", + this.sendPath), schedulerException)); + } + + return onSendFuture; + } + + private CompletableFuture send( + final byte[] bytes, + final int arrayOffset, + final int messageFormat, + final CompletableFuture onSend, + final TimeoutTracker tracker) { + return this.sendCore(bytes, arrayOffset, messageFormat, onSend, tracker, null, null); + } + + public CompletableFuture send(final Iterable messages) { + if (messages == null || IteratorUtil.sizeEquals(messages, 0)) { + throw new IllegalArgumentException(String.format(Locale.US, + "Entity[%s}: sending Empty batch of messages is not allowed.", this.sendPath)); + } + + final Message firstMessage = messages.iterator().next(); + if (IteratorUtil.sizeEquals(messages, 1)) { + return this.send(firstMessage); + } + + // 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 : messages) { + final Message messageWrappedByData = Proton.message(); + + int payloadSize = AmqpUtil.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 CompletableFuture sendTask = new CompletableFuture<>(); + sendTask.completeExceptionally(new PayloadSizeExceededException(String.format(Locale.US, + "Entity(%s): size of the payload exceeded Maximum message size: %s kb", + this.sendPath, maxMessageSizeTemp / 1024), exception)); + return sendTask; + } + + byteArrayOffset = byteArrayOffset + encodedSize; + } + + return this.send(bytes, byteArrayOffset, AmqpConstants.AMQP_BATCH_MESSAGE_FORMAT); + } + + public CompletableFuture send(Message msg) { + int payloadSize = AmqpUtil.getDataSerializedSize(msg); + + final int maxMessageSizeTemp = this.maxMessageSize; + int allocationSize = Math.min(payloadSize + ClientConstants.MAX_EVENTHUB_AMQP_HEADER_SIZE_BYTES, maxMessageSizeTemp); + + final byte[] bytes = new byte[allocationSize]; + int encodedSize = 0; + try { + encodedSize = msg.encode(bytes, 0, allocationSize); + } catch (BufferOverflowException exception) { + final CompletableFuture sendTask = new CompletableFuture(); + sendTask.completeExceptionally(new PayloadSizeExceededException(String.format(Locale.US, + "Entity(%s): size of the payload exceeded Maximum message size: %s kb", + this.sendPath, maxMessageSizeTemp / 1024), exception)); + return sendTask; + } + + return this.send(bytes, encodedSize, DeliveryImpl.DEFAULT_MESSAGE_FORMAT); + } + + @Override + public void onOpenComplete(Exception completionException) { + this.creatingLink = false; + + if (completionException == null) { + if (this.getIsClosingOrClosed()) { + this.sendLink.close(); + return; + } + + synchronized (this.errorConditionLock) { + this.lastKnownLinkError = null; + } + + this.retryPolicy.resetRetryCount(this.getClientId()); + + final UnsignedLong remoteMaxMessageSize = this.sendLink.getRemoteMaxMessageSize(); + if (remoteMaxMessageSize != null) { + this.maxMessageSize = remoteMaxMessageSize.intValue(); + } + + this.cancelOpenTimer(); + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format("onOpenComplete - clientId[%s], sendPath[%s], linkName[%s]", + this.getClientId(), this.sendPath, this.sendLink.getName())); + } + + if (!this.linkFirstOpen.isDone()) { + this.linkFirstOpen.complete(this); + } else { + synchronized (this.pendingSendLock) { + if (!this.pendingSendsData.isEmpty()) { + final List unacknowledgedSends = new LinkedList<>(); + unacknowledgedSends.addAll(this.pendingSendsData.keySet()); + + if (unacknowledgedSends.size() > 0) { + final Iterator reverseReader = unacknowledgedSends.iterator(); + while (reverseReader.hasNext()) { + final String unacknowledgedSend = reverseReader.next(); + if (this.pendingSendsData.get(unacknowledgedSend).isWaitingForAck()) { + this.pendingSends.offer(new WeightedDeliveryTag(unacknowledgedSend, 1)); + } + } + } + + unacknowledgedSends.clear(); + } + } + } + } else { + if (!this.linkFirstOpen.isDone()) { + final Duration nextRetryInterval = this.retryPolicy.getNextRetryInterval( + this.getClientId(), completionException, this.openLinkTracker.remaining()); + + if (nextRetryInterval != null) { + if (onOpenRetry != null) { + onOpenRetry.accept(this); + } + + try { + this.underlyingFactory.scheduleOnReactorThread((int) nextRetryInterval.toMillis(), new DispatchHandler() { + @Override + public void onEvent() { + if (!MessageSender.this.getIsClosingOrClosed() + && (sendLink == null || sendLink.getLocalState() == EndpointState.CLOSED || sendLink.getRemoteState() == EndpointState.CLOSED)) { + recreateSendLink(); + } + } + }); + } catch (IOException | RejectedExecutionException schedulerException) { + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn( + String.format(Locale.US, "clientId[%s], senderPath[%s], scheduling createLink encountered error: %s", + this.getClientId(), this.sendPath, schedulerException.getLocalizedMessage())); + } + + this.cancelOpen(schedulerException); + } + } else if (completionException instanceof EventHubException + && !((EventHubException) completionException).getIsTransient()) { + this.cancelOpen(completionException); + } + } else { + this.cancelOpenTimer(); + } + } + } + + private void cancelOpen(final Exception completionException) { + this.setClosed(); + ExceptionUtil.completeExceptionally(this.linkFirstOpen, completionException, this); + this.cancelOpenTimer(); + } + + private void cancelOpenTimer() { + if (this.openTimer != null && !this.openTimer.isCancelled()) { + this.openTimer.cancel(false); + } + } + + @Override + public void onClose(final ErrorCondition condition) { + if (this.sendLink != null) { + this.underlyingFactory.deregisterForConnectionError(this.sendLink); + } + + final Exception completionException = (condition != null && condition.getCondition() != null) ? ExceptionUtil.toException(condition) : null; + this.onError(completionException); + } + + @Override + public void onError(final Exception completionException) { + if (this.getIsClosingOrClosed()) { + if (this.closeTimer != null && !this.closeTimer.isDone()) + this.closeTimer.cancel(false); + + synchronized (this.pendingSendLock) { + for (Map.Entry> pendingSend : this.pendingSendsData.entrySet()) { + ExceptionUtil.completeExceptionally(pendingSend.getValue().getWork(), + completionException == null + ? new OperationCancelledException(String.format(Locale.US, + "Entity(%s): send cancelled as the Sender instance is Closed before the sendOperation completed.", + this.sendPath)) + : completionException, + this); + } + + this.pendingSendsData.clear(); + this.pendingSends.clear(); + } + + this.linkClose.complete(null); + + return; + } else { + synchronized (this.errorConditionLock) { + this.lastKnownLinkError = completionException == null ? this.lastKnownLinkError : completionException; + this.lastKnownErrorReportedAt = Instant.now(); + } + + final Exception finalCompletionException = completionException == null + ? new EventHubException(true, String.format(Locale.US, + "Entity(%s): client encountered transient error for unknown reasons, please retry the operation.", + this.sendPath)) : completionException; + + this.onOpenComplete(finalCompletionException); + + final Map.Entry> pendingSendEntry = IteratorUtil.getFirst(this.pendingSendsData.entrySet()); + if (pendingSendEntry != null && pendingSendEntry.getValue() != null) { + final TimeoutTracker tracker = pendingSendEntry.getValue().getTimeoutTracker(); + if (tracker != null) { + final Duration nextRetryInterval = this.retryPolicy.getNextRetryInterval(this.getClientId(), finalCompletionException, tracker.remaining()); + boolean scheduledRecreate = true; + + if (nextRetryInterval != null) { + try { + this.underlyingFactory.scheduleOnReactorThread((int) nextRetryInterval.toMillis(), new DispatchHandler() { + @Override + public void onEvent() { + if (!MessageSender.this.getIsClosingOrClosed() + && (sendLink.getLocalState() == EndpointState.CLOSED || sendLink.getRemoteState() == EndpointState.CLOSED)) { + recreateSendLink(); + } + } + }); + } catch (IOException | RejectedExecutionException ignore) { + scheduledRecreate = false; + } + } + + if (nextRetryInterval == null || !scheduledRecreate) { + synchronized (this.pendingSendLock) { + for (Map.Entry> pendingSend : this.pendingSendsData.entrySet()) { + this.cleanupFailedSend(pendingSend.getValue(), finalCompletionException); + } + + this.pendingSendsData.clear(); + this.pendingSends.clear(); + } + } + } + } + } + } + + @Override + public void onSendComplete(final Delivery delivery) { + final DeliveryState outcome = delivery.getRemoteState(); + final String deliveryTag = new String(delivery.getTag()); + + if (TRACE_LOGGER.isTraceEnabled()) + TRACE_LOGGER.trace( + String.format( + Locale.US, + "clientId[%s], path[%s], linkName[%s], deliveryTag[%s]", + this.getClientId(), this.sendPath, this.sendLink.getName(), deliveryTag)); + + final ReplayableWorkItem pendingSendWorkItem = this.pendingSendsData.remove(deliveryTag); + + if (pendingSendWorkItem != null) { + if (outcome instanceof Accepted) { + synchronized (this.errorConditionLock) { + this.lastKnownLinkError = null; + } + + this.retryPolicy.resetRetryCount(this.getClientId()); + + pendingSendWorkItem.getTimeoutTask().cancel(false); + pendingSendWorkItem.clearMessage(); + pendingSendWorkItem.getWork().complete(null); + } else if (outcome instanceof Rejected) { + final Rejected rejected = (Rejected) outcome; + final ErrorCondition error = rejected.getError(); + + final Exception exception = ExceptionUtil.toException(error); + + if (ExceptionUtil.isGeneralSendError(error.getCondition())) { + synchronized (this.errorConditionLock) { + this.lastKnownLinkError = exception; + this.lastKnownErrorReportedAt = Instant.now(); + } + } + + final Duration retryInterval = this.retryPolicy.getNextRetryInterval( + this.getClientId(), exception, pendingSendWorkItem.getTimeoutTracker().remaining()); + if (retryInterval == null) { + this.cleanupFailedSend(pendingSendWorkItem, exception); + } else { + pendingSendWorkItem.setLastKnownException(exception); + try { + this.underlyingFactory.scheduleOnReactorThread((int) retryInterval.toMillis(), + new DispatchHandler() { + @Override + public void onEvent() { + MessageSender.this.sendCore( + pendingSendWorkItem.getMessage(), + pendingSendWorkItem.getEncodedMessageSize(), + pendingSendWorkItem.getMessageFormat(), + pendingSendWorkItem.getWork(), + pendingSendWorkItem.getTimeoutTracker(), + pendingSendWorkItem.getLastKnownException(), + pendingSendWorkItem.getTimeoutTask()); + } + }); + } catch (IOException | RejectedExecutionException schedulerException) { + exception.initCause(schedulerException); + this.cleanupFailedSend( + pendingSendWorkItem, + new EventHubException(false, String.format(Locale.US, + "Entity(%s): send operation failed while scheduling a retry on Reactor, see cause for more details.", + this.sendPath), + schedulerException)); + } + } + } else if (outcome instanceof Released) { + this.cleanupFailedSend(pendingSendWorkItem, new OperationCancelledException(outcome.toString())); + } else { + this.cleanupFailedSend(pendingSendWorkItem, new EventHubException(false, outcome.toString())); + } + } else { + if (TRACE_LOGGER.isDebugEnabled()) + TRACE_LOGGER.debug( + String.format(Locale.US, "clientId[%s]. path[%s], linkName[%s], delivery[%s] - mismatch (or send timed out)", + this.getClientId(), this.sendPath, this.sendLink.getName(), deliveryTag)); + } + } + + private void cleanupFailedSend(final ReplayableWorkItem failedSend, final Exception exception) { + if (failedSend.getTimeoutTask() != null) + failedSend.getTimeoutTask().cancel(false); + + ExceptionUtil.completeExceptionally(failedSend.getWork(), exception, this); + } + + private void createSendLink() { + synchronized (this.errorConditionLock) { + if (this.creatingLink) { + return; + } + + this.creatingLink = true; + } + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info( + String.format(Locale.US, + "clientId[%s], path[%s], operationTimeout[%s], creating a send link", + this.getClientId(), this.sendPath, this.operationTimeout)); + } + + this.linkCreationTime = Instant.now().toString(); + + this.scheduleLinkOpenTimeout(TimeoutTracker.create(this.operationTimeout)); + + final Consumer onSessionOpen = new Consumer() { + @Override + public void accept(Session session) { + if (MessageSender.this.getIsClosingOrClosed()) { + + session.close(); + return; + } + + final Sender sender = session.sender(TrackingUtil.getLinkName(session)); + final Target target = new Target(); + target.setAddress(sendPath); + sender.setTarget(target); + + final Source source = new Source(); + sender.setSource(source); + + sender.setSenderSettleMode(SenderSettleMode.UNSETTLED); + + final SendLinkHandler handler = new SendLinkHandler(MessageSender.this); + BaseHandler.setHandler(sender, handler); + + if (MessageSender.this.sendLink != null) { + MessageSender.this.underlyingFactory.deregisterForConnectionError(MessageSender.this.sendLink); + } + + MessageSender.this.underlyingFactory.registerForConnectionError(sender); + sender.open(); + + synchronized (MessageSender.this.errorConditionLock) { + MessageSender.this.sendLink = sender; + } + } + }; + + final BiConsumer onSessionOpenError = new BiConsumer() { + @Override + public void accept(ErrorCondition t, Exception u) { + if (t != null) + MessageSender.this.onError((t != null && t.getCondition() != null) ? ExceptionUtil.toException(t) : null); + else if (u != null) + MessageSender.this.onError(u); + } + }; + + try { + this.underlyingFactory.getCBSChannel().sendToken( + this.underlyingFactory.getReactorDispatcher(), + this.underlyingFactory.getTokenProvider().getToken(tokenAudience, ClientConstants.TOKEN_VALIDITY), + tokenAudience, + new OperationResult() { + @Override + public void onComplete(Void result) { + if (MessageSender.this.getIsClosingOrClosed()) + return; + + underlyingFactory.getSession( + sendPath, + onSessionOpen, + onSessionOpenError); + } + + @Override + public void onError(Exception error) { + final Exception completionException; + if (error != null && error instanceof AmqpException) { + completionException = ExceptionUtil.toException(((AmqpException) error).getError()); + if (completionException != error && completionException.getCause() == null) { + completionException.initCause(error); + } + } else { + completionException = error; + } + + MessageSender.this.onError(completionException); + } + }); + } catch (IOException | NoSuchAlgorithmException | InvalidKeyException | RuntimeException exception) { + MessageSender.this.onError(exception); + } + } + + private void scheduleLinkOpenTimeout(TimeoutTracker timeout) { + // timer to signal a timeout if exceeds the operationTimeout on MessagingFactory + this.openTimer = this.timer.schedule( + new Runnable() { + public void run() { + creatingLink = false; + + if (!MessageSender.this.linkFirstOpen.isDone()) { + final Exception lastReportedError; + final Sender link; + synchronized (MessageSender.this.errorConditionLock) { + lastReportedError = MessageSender.this.lastKnownLinkError; + link = MessageSender.this.sendLink; + } + + final Exception operationTimedout = new TimeoutException( + String.format(Locale.US, "Open operation on entity(%s) timed out at %s.", + MessageSender.this.getSendPath(), ZonedDateTime.now().toString()), + lastReportedError); + + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn( + String.format(Locale.US, "clientId[%s], path[%s], open call timed out", + MessageSender.this.getClientId(), MessageSender.this.sendPath), + operationTimedout); + } + + ExceptionUtil.completeExceptionally(MessageSender.this.linkFirstOpen, operationTimedout, MessageSender.this); + setClosed(); + } + } + } + , timeout.remaining()); + + this.openTimer.handleAsync( + (unUsed, exception) -> { + if (exception != null + && exception instanceof Exception + && !(exception instanceof CancellationException)) { + ExceptionUtil.completeExceptionally(linkFirstOpen, (Exception) exception, this); + } + + return null; + }, this.executor); + } + + @Override + public ErrorContext getContext() { + final Sender link; + synchronized (this.errorConditionLock) { + link = this.sendLink; + } + + final boolean isLinkOpened = this.linkFirstOpen != null && this.linkFirstOpen.isDone(); + final String referenceId = link != null && link.getRemoteProperties() != null && link.getRemoteProperties().containsKey(ClientConstants.TRACKING_ID_PROPERTY) + ? link.getRemoteProperties().get(ClientConstants.TRACKING_ID_PROPERTY).toString() + : ((link != null) ? link.getName() : null); + + final SenderContext errorContext = new SenderContext( + this.underlyingFactory != null ? this.underlyingFactory.getHostName() : null, + this.sendPath, + referenceId, + isLinkOpened && link != null ? link.getCredit() : null); + return errorContext; + } + + @Override + public void onFlow(final int creditIssued) { + synchronized (this.errorConditionLock) { + this.lastKnownLinkError = null; + } + + if (creditIssued <= 0) + return; + + if (TRACE_LOGGER.isDebugEnabled()) { + int numberOfSendsWaitingforCredit = this.pendingSends.size(); + TRACE_LOGGER.debug(String.format(Locale.US, + "clientId[%s], path[%s], linkName[%s], remoteLinkCredit[%s], pendingSendsWaitingForCredit[%s], pendingSendsWaitingDelivery[%s]", + this.getClientId(), this.sendPath, this.sendLink.getName(), creditIssued, numberOfSendsWaitingforCredit, this.pendingSendsData.size() - numberOfSendsWaitingforCredit)); + } + + this.sendWork.onEvent(); + } + + private void recreateSendLink() { + this.createSendLink(); + this.retryPolicy.incrementRetryCount(this.getClientId()); + } + + // actual send on the SenderLink should happen only in this method & should run on Reactor Thread + private void processSendWork() { + if (this.sendLink.getLocalState() == EndpointState.CLOSED || this.sendLink.getRemoteState() == EndpointState.CLOSED) { + if (!this.getIsClosingOrClosed()) + this.recreateSendLink(); + + return; + } + + while (this.sendLink.getLocalState() == EndpointState.ACTIVE && this.sendLink.getRemoteState() == EndpointState.ACTIVE + && this.sendLink.getCredit() > 0) { + final WeightedDeliveryTag weightedDelivery; + final ReplayableWorkItem sendData; + final String deliveryTag; + synchronized (this.pendingSendLock) { + weightedDelivery = this.pendingSends.poll(); + if (weightedDelivery != null) { + deliveryTag = weightedDelivery.getDeliveryTag(); + sendData = this.pendingSendsData.get(deliveryTag); + } else { + sendData = null; + deliveryTag = null; + } + } + + if (sendData != null) { + if (sendData.getWork() != null && sendData.getWork().isDone()) { + // CoreSend could enque Sends into PendingSends Queue and can fail the SendCompletableFuture + // (when It fails to schedule the ProcessSendWork on reactor Thread) + this.pendingSendsData.remove(deliveryTag); + continue; + } + + Delivery delivery = null; + boolean linkAdvance = false; + int sentMsgSize = 0; + Exception sendException = null; + + try { + delivery = this.sendLink.delivery(deliveryTag.getBytes()); + delivery.setMessageFormat(sendData.getMessageFormat()); + + sentMsgSize = this.sendLink.send(sendData.getMessage(), 0, sendData.getEncodedMessageSize()); + assert sentMsgSize == sendData.getEncodedMessageSize() : "Contract of the ProtonJ library for Sender.Send API changed"; + + linkAdvance = this.sendLink.advance(); + } catch (Exception exception) { + sendException = exception; + } + + if (linkAdvance) { + sendData.setWaitingForAck(); + } else { + if (TRACE_LOGGER.isDebugEnabled()) { + TRACE_LOGGER.debug( + String.format(Locale.US, "clientId[%s], path[%s], linkName[%s], deliveryTag[%s], sentMessageSize[%s], payloadActualSize[%s] - sendlink advance failed", + this.getClientId(), this.sendPath, this.sendLink.getName(), deliveryTag, sentMsgSize, sendData.getEncodedMessageSize())); + } + + if (delivery != null) { + delivery.free(); + } + + sendData.getWork().completeExceptionally(sendException != null + ? new OperationCancelledException(String.format(Locale.US, + "Entity(%s): send operation failed. Please see cause for more details", this.sendPath), sendException) + : new OperationCancelledException( + String.format(Locale.US, "Entity(%s): send operation failed while advancing delivery(tag: %s).", this.sendPath, deliveryTag))); + } + } else { + if (deliveryTag != null) { + if (TRACE_LOGGER.isDebugEnabled()) { + TRACE_LOGGER.debug( + String.format(Locale.US, "clientId[%s], path[%s], linkName[%s], deliveryTag[%s] - sendData not found for this delivery.", + this.getClientId(), this.sendPath, this.sendLink.getName(), deliveryTag)); + } + } + + break; + } + } + } + + private void throwSenderTimeout(final CompletableFuture pendingSendWork, final Exception lastKnownException) { + + Exception cause = lastKnownException; + if (lastKnownException == null) { + final Exception lastReportedLinkLevelError; + final Instant lastLinkErrorReportedAt; + synchronized (this.errorConditionLock) { + lastReportedLinkLevelError = this.lastKnownLinkError; + lastLinkErrorReportedAt = this.lastKnownErrorReportedAt; + } + + if (lastReportedLinkLevelError != null) { + boolean isServerBusy = ((lastReportedLinkLevelError instanceof ServerBusyException) + && (lastLinkErrorReportedAt.isAfter(Instant.now().minusSeconds(ClientConstants.SERVER_BUSY_BASE_SLEEP_TIME_IN_SECS)))); + cause = isServerBusy || (lastLinkErrorReportedAt.isAfter(Instant.now().minusMillis(this.operationTimeout.toMillis()))) + ? lastReportedLinkLevelError + : null; + } + } + + final boolean isClientSideTimeout = (cause == null || !(cause instanceof EventHubException)); + final EventHubException exception = isClientSideTimeout + ? new TimeoutException(String.format(Locale.US, "Entity(%s): %s at %s.", + this.sendPath, MessageSender.SEND_TIMED_OUT, ZonedDateTime.now()), cause) + : (EventHubException) cause; + + ExceptionUtil.completeExceptionally(pendingSendWork, exception, this); + } + + private void scheduleLinkCloseTimeout(final TimeoutTracker timeout) { + // timer to signal a timeout if exceeds the operationTimeout on MessagingFactory + this.closeTimer = this.timer.schedule( + new Runnable() { + public void run() { + if (!linkClose.isDone()) { + final Sender link; + synchronized (MessageSender.this.errorConditionLock) { + link = MessageSender.this.sendLink; + } + + final Exception operationTimedout = new TimeoutException(String.format(Locale.US, + "Entity(%s): close operation timed out at %s", MessageSender.this.sendPath, ZonedDateTime.now())); + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info( + String.format(Locale.US, "clientId[%s], message sender(linkName: %s, path: %s) close call timed out", + MessageSender.this.getClientId(), link.getName(), MessageSender.this.sendPath), + operationTimedout); + } + + ExceptionUtil.completeExceptionally(linkClose, operationTimedout, MessageSender.this); + MessageSender.this.onError((Exception) null); + } + } + } + , timeout.remaining()); + + this.closeTimer.handleAsync( + (unUsed, exception) -> { + if (exception != null && exception instanceof Exception && !(exception instanceof CancellationException)) { + ExceptionUtil.completeExceptionally(linkClose, (Exception) exception, MessageSender.this); + } + + return null; + }, this.executor); + } + + @Override + protected CompletableFuture onClose() { + if (!this.getIsClosed()) { + try { + this.activeClientTokenManager.cancel(); + scheduleLinkCloseTimeout(TimeoutTracker.create(operationTimeout)); + this.underlyingFactory.scheduleOnReactorThread(new DispatchHandler() { + @Override + public void onEvent() { + if (sendLink != null && sendLink.getLocalState() != EndpointState.CLOSED) { + sendLink.close(); + } else if (sendLink == null || sendLink.getRemoteState() == EndpointState.CLOSED) { + if (closeTimer != null && !closeTimer.isCancelled()) { + closeTimer.cancel(false); + } + + linkClose.complete(null); + } + } + }); + + } catch (IOException | RejectedExecutionException schedulerException) { + this.linkClose.completeExceptionally(schedulerException); + } + } + + return this.linkClose; + } + + @Override + protected Exception getLastKnownError() { + synchronized (this.errorConditionLock) { + return this.lastKnownLinkError; + } + } + + 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; + } + + public String getDeliveryTag() { + return this.deliveryTag; + } + + public int getPriority() { + return this.priority; + } + } + + private static class DeliveryTagComparator implements Comparator { + @Override + public int compare(WeightedDeliveryTag deliveryTag0, WeightedDeliveryTag deliveryTag1) { + return deliveryTag1.getPriority() - deliveryTag0.getPriority(); + } + } + + private class SendTimeout implements Runnable { + private final String deliveryTag; + private final ReplayableWorkItem sendWaiterData; + + public SendTimeout( + final String deliveryTag, + final ReplayableWorkItem sendWaiterData) { + this.sendWaiterData = sendWaiterData; + this.deliveryTag = deliveryTag; + } + + @Override + public void run() { + if (!sendWaiterData.getWork().isDone()) { + MessageSender.this.pendingSendsData.remove(deliveryTag); + MessageSender.this.throwSenderTimeout(sendWaiterData.getWork(), sendWaiterData.getLastKnownException()); + } + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/MessageWrapper.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/MessageWrapper.java new file mode 100644 index 000000000000..f2388659bb5c --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/MessageWrapper.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.EventPosition; +import org.apache.qpid.proton.message.Message; + +final class MessageWrapper { + private final Message message; + private final EventPosition eventPosition; + + MessageWrapper(Message message, EventPosition eventPosition) { + this.message = message; + this.eventPosition = eventPosition; + } + + Message getMessage() { + return this.message; + } + + EventPosition getEventPosition() { + return this.eventPosition; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/MessagingFactory.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/MessagingFactory.java new file mode 100644 index 000000000000..24031a7c74ca --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/MessagingFactory.java @@ -0,0 +1,610 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.TimeoutException; +import org.apache.qpid.proton.amqp.transport.ErrorCondition; +import org.apache.qpid.proton.engine.*; +import org.apache.qpid.proton.reactor.Reactor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.channels.UnresolvedAddressException; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.*; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * Abstracts all amqp related details and exposes AmqpConnection object + * Manages connection life-cycle + */ +public final class MessagingFactory extends ClientEntity implements AmqpConnection, SessionProvider, SchedulerProvider { + public static final Duration DefaultOperationTimeout = Duration.ofSeconds(60); + + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(MessagingFactory.class); + private final String hostName; + private final CompletableFuture closeTask; + private final ConnectionHandler connectionHandler; + private final LinkedList registeredLinks; + private final Object reactorLock; + private final Object cbsChannelCreateLock; + private final Object mgmtChannelCreateLock; + private final SharedAccessSignatureTokenProvider tokenProvider; + private final ReactorFactory reactorFactory; + + private Reactor reactor; + private ReactorDispatcher reactorDispatcher; + private Connection connection; + private CBSChannel cbsChannel; + private ManagementChannel mgmtChannel; + private Duration operationTimeout; + private RetryPolicy retryPolicy; + private CompletableFuture open; + private CompletableFuture openTimer; + private CompletableFuture closeTimer; + private String reactorCreationTime; + + MessagingFactory(final ConnectionStringBuilder builder, + final RetryPolicy retryPolicy, + final ScheduledExecutorService executor, + final ReactorFactory reactorFactory) { + super("MessagingFactory".concat(StringUtil.getRandomString()), null, executor); + + this.hostName = builder.getEndpoint().getHost(); + this.reactorFactory = reactorFactory; + this.operationTimeout = builder.getOperationTimeout(); + this.retryPolicy = retryPolicy; + this.registeredLinks = new LinkedList<>(); + this.reactorLock = new Object(); + this.connectionHandler = ConnectionHandler.create(builder.getTransportType(), this); + this.cbsChannelCreateLock = new Object(); + this.mgmtChannelCreateLock = new Object(); + this.tokenProvider = builder.getSharedAccessSignature() == null + ? new SharedAccessSignatureTokenProvider(builder.getSasKeyName(), builder.getSasKey()) + : new SharedAccessSignatureTokenProvider(builder.getSharedAccessSignature()); + + this.closeTask = new CompletableFuture<>(); + } + + public static CompletableFuture createFromConnectionString(final String connectionString, final ScheduledExecutorService executor) throws IOException { + return createFromConnectionString(connectionString, RetryPolicy.getDefault(), executor); + } + + public static CompletableFuture createFromConnectionString( + final String connectionString, + final RetryPolicy retryPolicy, + final ScheduledExecutorService executor) throws IOException { + return createFromConnectionString(connectionString, retryPolicy, executor, new ReactorFactory()); + } + + public static CompletableFuture createFromConnectionString( + final String connectionString, + final RetryPolicy retryPolicy, + final ScheduledExecutorService executor, + final ReactorFactory reactorFactory) throws IOException { + final ConnectionStringBuilder builder = new ConnectionStringBuilder(connectionString); + final MessagingFactory messagingFactory = new MessagingFactory(builder, + (retryPolicy != null) ? retryPolicy : RetryPolicy.getDefault(), + executor, + reactorFactory); + + messagingFactory.createConnection(); + + final Timer timer = new Timer(messagingFactory); + messagingFactory.openTimer = timer.schedule( + new Runnable() { + @Override + public void run() { + if (!messagingFactory.open.isDone()) { + messagingFactory.open.completeExceptionally(new TimeoutException("Opening MessagingFactory timed out.")); + messagingFactory.getReactor().stop(); + } + } + }, + messagingFactory.getOperationTimeout()); + + // if scheduling messagingfactory openTimer fails - notify user and stop + messagingFactory.openTimer.handleAsync( + (unUsed, exception) -> { + if (exception != null && !(exception instanceof CancellationException)) { + messagingFactory.open.completeExceptionally(exception); + messagingFactory.getReactor().stop(); + } + + return null; + }, messagingFactory.executor); + + return messagingFactory.open; + } + + @Override + public String getHostName() { + return this.hostName; + } + + private Reactor getReactor() { + synchronized (this.reactorLock) { + return this.reactor; + } + } + + public ReactorDispatcher getReactorDispatcher() { + synchronized (this.reactorLock) { + return this.reactorDispatcher; + } + } + + public SharedAccessSignatureTokenProvider getTokenProvider() { + return this.tokenProvider; + } + + private void createConnection() throws IOException { + this.open = new CompletableFuture<>(); + this.startReactor(new ReactorHandlerWithConnection()); + } + + private void startReactor(final ReactorHandler reactorHandler) throws IOException { + final Reactor newReactor = this.reactorFactory.create(reactorHandler, this.connectionHandler.getMaxFrameSize()); + synchronized (this.reactorLock) { + this.reactor = newReactor; + this.reactorDispatcher = new ReactorDispatcher(newReactor); + reactorHandler.unsafeSetReactorDispatcher(this.reactorDispatcher); + } + + this.reactorCreationTime = Instant.now().toString(); + + executor.execute(new RunReactor(newReactor, executor)); + } + + public CBSChannel getCBSChannel() { + synchronized (this.cbsChannelCreateLock) { + if (this.cbsChannel == null) { + this.cbsChannel = new CBSChannel(this, this); + } + } + + return this.cbsChannel; + } + + public ManagementChannel getManagementChannel() { + synchronized (this.mgmtChannelCreateLock) { + if (this.mgmtChannel == null) { + this.mgmtChannel = new ManagementChannel(this, this); + } + } + + return this.mgmtChannel; + } + + @Override + public Session getSession(final String path, final Consumer onRemoteSessionOpen, final BiConsumer onRemoteSessionOpenError) { + if (this.getIsClosingOrClosed()) { + + onRemoteSessionOpenError.accept(null, new OperationCancelledException("underlying messagingFactory instance is closed")); + return null; + } + + if (this.connection == null || this.connection.getLocalState() == EndpointState.CLOSED || this.connection.getRemoteState() == EndpointState.CLOSED) { + this.connection = this.getReactor().connectionToHost( + this.connectionHandler.getRemoteHostName(), + this.connectionHandler.getRemotePort(), + this.connectionHandler); + } + + final Session session = this.connection.session(); + BaseHandler.setHandler(session, new SessionHandler(path, onRemoteSessionOpen, onRemoteSessionOpenError, this.operationTimeout)); + session.open(); + + return session; + } + + public Duration getOperationTimeout() { + return this.operationTimeout; + } + + public RetryPolicy getRetryPolicy() { + return this.retryPolicy; + } + + @Override + public void onOpenComplete(Exception exception) { + if (exception == null) { + this.open.complete(this); + + // if connection creation is in progress and then msgFactory.close call came thru + if (this.getIsClosingOrClosed()) + this.connection.close(); + } else { + this.open.completeExceptionally(exception); + } + + if (this.openTimer != null) + this.openTimer.cancel(false); + } + + @Override + public void onConnectionError(ErrorCondition error) { + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn(String.format(Locale.US, "onConnectionError: messagingFactory[%s], hostname[%s], error[%s]", + this.getClientId(), + this.hostName, + error != null ? error.getDescription() : "n/a")); + } + + if (!this.open.isDone()) { + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn(String.format(Locale.US, "onConnectionError: messagingFactory[%s], hostname[%s], open hasn't complete, stopping the reactor", + this.getClientId(), + this.hostName)); + } + + this.getReactor().stop(); + this.onOpenComplete(ExceptionUtil.toException(error)); + } else { + final Connection oldConnection = this.connection; + final List oldRegisteredLinksCopy = new LinkedList<>(this.registeredLinks); + final List closedLinks = new LinkedList<>(); + + for (Link link : oldRegisteredLinksCopy) { + if (link.getLocalState() != EndpointState.CLOSED && link.getRemoteState() != EndpointState.CLOSED) { + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn(String.format(Locale.US, "onConnectionError: messagingFactory[%s], hostname[%s], closing link [%s]", + this.getClientId(), + this.hostName, link.getName())); + } + + link.setCondition(error); + link.close(); + closedLinks.add(link); + } + } + + // if proton-j detects transport error - onConnectionError is invoked, but, the connection state is not set to closed + // in connection recreation we depend on currentConnection state to evaluate need for recreation + if (oldConnection.getLocalState() != EndpointState.CLOSED) { + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn(String.format(Locale.US, "onConnectionError: messagingFactory[%s], hostname[%s], closing current connection", + this.getClientId(), + this.hostName)); + } + + // this should ideally be done in Connectionhandler + // - but, since proton doesn't automatically emit close events + // for all child objects (links & sessions) we are doing it here + oldConnection.setCondition(error); + oldConnection.close(); + } + + for (Link link : closedLinks) { + final Handler handler = BaseHandler.getHandler(link); + if (handler != null && handler instanceof BaseLinkHandler) { + final BaseLinkHandler linkHandler = (BaseLinkHandler) handler; + linkHandler.processOnClose(link, error); + } + } + } + + if (this.getIsClosingOrClosed() && !this.closeTask.isDone()) { + this.getReactor().stop(); + } + } + + private void onReactorError(Exception cause) { + if (!this.open.isDone()) { + this.onOpenComplete(cause); + } else { + if (this.getIsClosingOrClosed()) { + return; + } + + TRACE_LOGGER.warn(String.format(Locale.US, "onReactorError messagingFactory[%s], hostName[%s], error[%s]", + this.getClientId(), this.getHostName(), + cause.getMessage())); + + final Connection oldConnection = this.connection; + final List oldRegisteredLinksCopy = new LinkedList<>(this.registeredLinks); + + try { + TRACE_LOGGER.info(String.format(Locale.US, "onReactorError messagingFactory[%s], hostName[%s], message[%s]", + this.getClientId(), this.getHostName(), + "starting new reactor")); + + this.startReactor(new ReactorHandlerWithConnection()); + } catch (IOException e) { + TRACE_LOGGER.error(String.format(Locale.US, "messagingFactory[%s], hostName[%s], error[%s]", + this.getClientId(), this.getHostName(), + ExceptionUtil.toStackTraceString(e, "Re-starting reactor failed with error"))); + + // TODO - stop retrying on the error after multiple attempts. + this.onReactorError(cause); + } + + // when the instance of the reactor itself faults - Connection and Links will not be cleaned up even after the + // below .close() calls (local closes). + // But, we still need to change the states of these to Closed - so that subsequent retries - will + // treat the links and connection as closed and re-establish them and continue running on new Reactor instance. + if (oldConnection.getLocalState() != EndpointState.CLOSED && oldConnection.getRemoteState() != EndpointState.CLOSED) { + oldConnection.close(); + } + + for (final Link link : oldRegisteredLinksCopy) { + if (link.getLocalState() != EndpointState.CLOSED && link.getRemoteState() != EndpointState.CLOSED) { + link.close(); + } + + final Handler handler = BaseHandler.getHandler(link); + if (handler != null && handler instanceof BaseLinkHandler) { + final BaseLinkHandler linkHandler = (BaseLinkHandler) handler; + linkHandler.processOnClose(link, cause); + } + } + } + } + + @Override + protected CompletableFuture onClose() { + if (!this.getIsClosed()) { + final Timer timer = new Timer(this); + this.closeTimer = timer.schedule(new Runnable() { + @Override + public void run() { + if (!closeTask.isDone()) { + closeTask.completeExceptionally(new TimeoutException("Closing MessagingFactory timed out.")); + getReactor().stop(); + } + } + }, + operationTimeout); + + if (this.closeTimer.isCompletedExceptionally()) { + this.closeTask.completeExceptionally(ExceptionUtil.getExceptionFromCompletedFuture(this.closeTimer)); + } else { + try { + this.scheduleOnReactorThread(new CloseWork()); + } catch (IOException | RejectedExecutionException schedulerException) { + this.closeTask.completeExceptionally(schedulerException); + } + } + } + + return this.closeTask; + } + + @Override + public void registerForConnectionError(Link link) { + this.registeredLinks.add(link); + } + + @Override + public void deregisterForConnectionError(Link link) { + this.registeredLinks.remove(link); + } + + public void scheduleOnReactorThread(final DispatchHandler handler) throws IOException, RejectedExecutionException { + this.getReactorDispatcher().invoke(handler); + } + + public void scheduleOnReactorThread(final int delay, final DispatchHandler handler) throws IOException, RejectedExecutionException { + this.getReactorDispatcher().invoke(delay, handler); + } + + public static class ReactorFactory { + + public Reactor create(final ReactorHandler reactorHandler, final int maxFrameSize) throws IOException { + return ProtonUtil.reactor(reactorHandler, maxFrameSize); + } + } + + private class CloseWork extends DispatchHandler { + @Override + public void onEvent() { + final ReactorDispatcher dispatcher = getReactorDispatcher(); + synchronized (cbsChannelCreateLock) { + if (cbsChannel != null) { + cbsChannel.close( + dispatcher, + new OperationResult() { + @Override + public void onComplete(Void result) { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info( + String.format(Locale.US, "messagingFactory[%s], hostName[%s], info[%s]", + getClientId(), getHostName(), "cbsChannel closed")); + } + } + + @Override + public void onError(Exception error) { + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn(String.format(Locale.US, + "messagingFactory[%s], hostName[%s], cbsChannelCloseError[%s]", + getClientId(), getHostName(), error.getMessage())); + } + } + }); + } + } + + synchronized (mgmtChannelCreateLock) { + if (mgmtChannel != null) { + mgmtChannel.close( + dispatcher, + new OperationResult() { + @Override + public void onComplete(Void result) { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info( + String.format(Locale.US, "messagingFactory[%s], hostName[%s], info[%s]", + getClientId(), getHostName(), "mgmtChannel closed")); + } + } + + @Override + public void onError(Exception error) { + + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn(String.format(Locale.US, + "messagingFactory[%s], hostName[%s], mgmtChannelCloseError[%s]", + getClientId(), getHostName(), error.getMessage())); + } + } + }); + } + } + + if (connection != null && connection.getRemoteState() != EndpointState.CLOSED && connection.getLocalState() != EndpointState.CLOSED) { + connection.close(); + } + } + } + + private class RunReactor implements Runnable { + final private Reactor rctr; + final private ScheduledExecutorService executor; + + volatile boolean hasStarted; + + public RunReactor(final Reactor reactor, final ScheduledExecutorService executor) { + this.rctr = reactor; + this.executor = executor; + this.hasStarted = false; + } + + public void run() { + boolean reScheduledReactor = false; + + try { + if (!this.hasStarted) { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, "messagingFactory[%s], hostName[%s], info[%s]", + getClientId(), getHostName(), "starting reactor instance.")); + } + + this.rctr.start(); + this.hasStarted = true; + } + + if (!Thread.interrupted() && this.rctr.process()) { + try { + this.executor.execute(this); + reScheduledReactor = true; + } catch (RejectedExecutionException exception) { + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn(String.format(Locale.US, "messagingFactory[%s], hostName[%s], error[%s]", + getClientId(), getHostName(), + ExceptionUtil.toStackTraceString(exception, "scheduling reactor failed because the executor has been shut down"))); + } + + this.rctr.attachments().set(RejectedExecutionException.class, RejectedExecutionException.class, exception); + } + + return; + } + + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn(String.format(Locale.US, "messagingFactory[%s], hostName[%s], message[%s]", + getClientId(), getHostName(), + "stopping the reactor because thread was interrupted or the reactor has no more events to process.")); + } + + this.rctr.stop(); + } catch (HandlerException handlerException) { + Throwable cause = handlerException.getCause(); + if (cause == null) { + cause = handlerException; + } + + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn(String.format(Locale.US, "messagingFactory[%s], hostName[%s], error[%s]", + getClientId(), getHostName(), ExceptionUtil.toStackTraceString(handlerException, + "Unhandled exception while processing events in reactor, report this error."))); + } + + final String message = !StringUtil.isNullOrEmpty(cause.getMessage()) ? + cause.getMessage() : + !StringUtil.isNullOrEmpty(handlerException.getMessage()) ? + handlerException.getMessage() : + "Reactor encountered unrecoverable error"; + + final EventHubException sbException; + + if (cause instanceof UnresolvedAddressException) { + sbException = new CommunicationException( + String.format(Locale.US, "%s. This is usually caused by incorrect hostname or network configuration. Check correctness of namespace information. %s", + message, ExceptionUtil.getTrackingIDAndTimeToLog()), + cause); + } else { + sbException = new EventHubException( + true, + String.format(Locale.US, "%s, %s", message, ExceptionUtil.getTrackingIDAndTimeToLog()), + cause); + } + + MessagingFactory.this.onReactorError(sbException); + } finally { + if (reScheduledReactor) { + return; + } + + if (getIsClosingOrClosed() && !closeTask.isDone()) { + this.rctr.free(); + closeTask.complete(null); + if (closeTimer != null) { + closeTimer.cancel(false); + } + } else { + scheduleCompletePendingTasks(); + } + } + } + + private void scheduleCompletePendingTasks() { + this.executor.schedule(new Runnable() { + @Override + public void run() { + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn(String.format(Locale.US, "messagingFactory[%s], hostName[%s], message[%s]", + getClientId(), getHostName(), + "Processing all pending tasks and closing old reactor.")); + } + + try { + rctr.stop(); + rctr.process(); + } catch (HandlerException e) { + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn(String.format(Locale.US, "messagingFactory[%s], hostName[%s], error[%s]", + getClientId(), getHostName(), ExceptionUtil.toStackTraceString(e, + "scheduleCompletePendingTasks - exception occurred while processing events."))); + } + } finally { + rctr.free(); + } + } + }, MessagingFactory.this.getOperationTimeout().getSeconds(), TimeUnit.SECONDS); + } + } + + private class ReactorHandlerWithConnection extends ReactorHandler { + @Override + public void onReactorInit(Event e) { + super.onReactorInit(e); + + final Reactor r = e.getReactor(); + connection = r.connectionToHost( + connectionHandler.getRemoteHostName(), + connectionHandler.getRemotePort(), + connectionHandler); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/Operation.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/Operation.java new file mode 100644 index 000000000000..2eee91a38426 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/Operation.java @@ -0,0 +1,10 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +public interface Operation { + + void run(OperationResult operationCallback); +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/OperationResult.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/OperationResult.java new file mode 100644 index 000000000000..100760a2791f --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/OperationResult.java @@ -0,0 +1,13 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + + +public interface OperationResult { + + void onComplete(T result); + + void onError(E error); +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/PartitionReceiverImpl.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/PartitionReceiverImpl.java new file mode 100644 index 000000000000..3e06503835f8 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/PartitionReceiverImpl.java @@ -0,0 +1,276 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.*; +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.UnknownDescribedType; +import org.apache.qpid.proton.amqp.messaging.DeliveryAnnotations; +import org.apache.qpid.proton.message.Message; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.Consumer; +import java.util.function.Function; + +final class PartitionReceiverImpl extends ClientEntity implements ReceiverSettingsProvider, PartitionReceiver { + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(PartitionReceiverImpl.class); + + private final String partitionId; + private final MessagingFactory underlyingFactory; + private final String eventHubName; + private final String consumerGroupName; + private final Object receiveHandlerLock; + private final EventPositionImpl eventPosition; + private final Long epoch; + private final boolean isEpochReceiver; + private final ReceiverOptions receiverOptions; + private final ReceiverRuntimeInformation runtimeInformation; + + private volatile MessageReceiver internalReceiver; + + private ReceivePump receivePump; + private EventPosition currentEventPosition; + + private PartitionReceiverImpl(MessagingFactory factory, + final String eventHubName, + final String consumerGroupName, + final String partitionId, + final EventPositionImpl eventPosition, + final Long epoch, + final boolean isEpochReceiver, + final ReceiverOptions receiverOptions, + final ScheduledExecutorService executor) { + super("PartitionReceiverImpl".concat(StringUtil.getRandomString()), null, executor); + + this.underlyingFactory = factory; + this.eventHubName = eventHubName; + this.consumerGroupName = consumerGroupName; + this.partitionId = partitionId; + this.eventPosition = eventPosition; + this.epoch = epoch; + this.isEpochReceiver = isEpochReceiver; + this.receiveHandlerLock = new Object(); + this.receiverOptions = receiverOptions; + this.runtimeInformation = (this.receiverOptions != null && this.receiverOptions.getReceiverRuntimeMetricEnabled()) + ? new ReceiverRuntimeInformation(partitionId) + : null; + this.currentEventPosition = EventPosition.fromStartOfStream(); + } + + static CompletableFuture create(MessagingFactory factory, + final String eventHubName, + final String consumerGroupName, + final String partitionId, + final EventPosition eventPosition, + final long epoch, + final boolean isEpochReceiver, + ReceiverOptions receiverOptions, + final ScheduledExecutorService executor) { + if (epoch < NULL_EPOCH) { + throw new IllegalArgumentException("epoch cannot be a negative value. Please specify a zero or positive long value."); + } + + if (StringUtil.isNullOrWhiteSpace(consumerGroupName)) { + throw new IllegalArgumentException("specify valid string for argument - 'consumerGroupName'"); + } + + if (receiverOptions == null) { + receiverOptions = new ReceiverOptions(); + } + + final PartitionReceiverImpl receiver = new PartitionReceiverImpl(factory, eventHubName, consumerGroupName, partitionId, (EventPositionImpl) eventPosition, epoch, isEpochReceiver, receiverOptions, executor); + return receiver.createInternalReceiver().thenApplyAsync(new Function() { + public PartitionReceiver apply(Void a) { + return receiver; + } + }, executor); + } + + private CompletableFuture createInternalReceiver() { + return MessageReceiver.create(this.underlyingFactory, + this.getClientId().concat("-InternalReceiver"), + String.format("%s/ConsumerGroups/%s/Partitions/%s", this.eventHubName, this.consumerGroupName, this.partitionId), + this.receiverOptions.getPrefetchCount(), this) + .thenAcceptAsync(new Consumer() { + public void accept(MessageReceiver r) { + PartitionReceiverImpl.this.internalReceiver = r; + } + }, this.executor); + } + + final EventPosition getStartingPosition() { + return this.eventPosition; + } + + public final String getPartitionId() { + return this.partitionId; + } + + public final Duration getReceiveTimeout() { + return this.internalReceiver.getReceiveTimeout(); + } + + public void setReceiveTimeout(Duration value) { + this.internalReceiver.setReceiveTimeout(value); + } + + public final long getEpoch() { + return this.epoch; + } + + public final ReceiverRuntimeInformation getRuntimeInformation() { + return this.runtimeInformation; + } + + public final EventPosition getEventPosition() { + return this.currentEventPosition; + } + + public CompletableFuture> receive(final int maxEventCount) { + return this.internalReceiver.receive(maxEventCount).thenApplyAsync(new Function, Iterable>() { + @Override + public Iterable apply(Collection amqpMessages) { + PassByRef lastMessageRef = null; + if (PartitionReceiverImpl.this.receiverOptions != null && PartitionReceiverImpl.this.receiverOptions.getReceiverRuntimeMetricEnabled()) + lastMessageRef = new PassByRef<>(); + + final Iterable events = EventDataUtil.toEventDataCollection(amqpMessages, lastMessageRef); + + if (lastMessageRef != null && lastMessageRef.get() != null) { + final DeliveryAnnotations deliveryAnnotations = lastMessageRef.get().getMessage().getDeliveryAnnotations(); + if (deliveryAnnotations != null && deliveryAnnotations.getValue() != null) { + final Map deliveryAnnotationsMap = deliveryAnnotations.getValue(); + PartitionReceiverImpl.this.runtimeInformation.setRuntimeInformation( + (long) deliveryAnnotationsMap.get(ClientConstants.LAST_ENQUEUED_SEQUENCE_NUMBER), + ((Date) deliveryAnnotationsMap.get(ClientConstants.LAST_ENQUEUED_TIME_UTC)).toInstant(), + (String) deliveryAnnotationsMap.get(ClientConstants.LAST_ENQUEUED_OFFSET)); + } + + PartitionReceiverImpl.this.currentEventPosition = lastMessageRef.get().getEventPosition(); + } + + return events; + } + }, this.executor); + } + + public CompletableFuture setReceiveHandler(final PartitionReceiveHandler receiveHandler) { + return this.setReceiveHandler(receiveHandler, false); + } + + public CompletableFuture setReceiveHandler(final PartitionReceiveHandler receiveHandler, final boolean invokeWhenNoEvents) { + synchronized (this.receiveHandlerLock) { + // user setting receiveHandler==null should stop the pump if its running + if (receiveHandler == null) { + if (this.receivePump != null && this.receivePump.isRunning()) { + return this.receivePump.stop(); + } + } else { + if (this.receivePump != null && this.receivePump.isRunning()) + throw new IllegalArgumentException( + "Unexpected value for parameter 'receiveHandler'. PartitionReceiver was already registered with a PartitionReceiveHandler instance. Only 1 instance can be registered."); + + this.receivePump = new ReceivePump( + this.eventHubName, + this.consumerGroupName, + new ReceivePump.IPartitionReceiver() { + @Override + public CompletableFuture> receive(int maxBatchSize) { + return PartitionReceiverImpl.this.receive(maxBatchSize); + } + + @Override + public String getPartitionId() { + return PartitionReceiverImpl.this.getPartitionId(); + } + }, + receiveHandler, + invokeWhenNoEvents, + this.executor); + + this.executor.execute(this.receivePump); + } + + return CompletableFuture.completedFuture(null); + } + } + + @Override + public CompletableFuture onClose() { + synchronized (this.receiveHandlerLock) { + if (this.receivePump != null && this.receivePump.isRunning()) { + // set the state of receivePump to StopEventRaised + // - but don't actually wait until the current user-code completes + // if user intends to stop everything - setReceiveHandler(null) should be invoked before close + this.receivePump.stop(); + } + } + + if (this.internalReceiver != null) { + return this.internalReceiver.close(); + } else { + return CompletableFuture.completedFuture(null); + } + } + + @Override + public Map getFilter(final Message lastReceivedMessage) { + String expression; + if (lastReceivedMessage != null) { + String lastReceivedOffset = lastReceivedMessage.getMessageAnnotations().getValue().get(AmqpConstants.OFFSET).toString(); + expression = String.format(AmqpConstants.AMQP_ANNOTATION_FORMAT, AmqpConstants.OFFSET_ANNOTATION_NAME, StringUtil.EMPTY, lastReceivedOffset); + } else { + expression = this.eventPosition.getExpression(); + } + + if (TRACE_LOGGER.isInfoEnabled()) { + String logReceivePath = ""; + if (this.internalReceiver == null) { + // During startup, internalReceiver is still null. Need to handle this special case when logging during startup + // or the reactor thread crashes with NPE when calling internalReceiver.getReceivePath() and no receiving occurs. + logReceivePath = "receiverPath[RECEIVER IS NULL]"; + } else { + logReceivePath = "receiverPath[" + this.internalReceiver.getReceivePath() + "]"; + } + TRACE_LOGGER.info(String.format("%s, action[createReceiveLink], %s", logReceivePath, this.eventPosition)); + } + + return Collections.singletonMap(AmqpConstants.STRING_FILTER, new UnknownDescribedType(AmqpConstants.STRING_FILTER, expression)); + } + + @Override + public Map getProperties() { + + if (!this.isEpochReceiver && + (this.receiverOptions == null || this.receiverOptions.getIdentifier() == null)) { + return null; + } + + final Map properties = new HashMap<>(); + + if (this.isEpochReceiver) { + properties.put(AmqpConstants.EPOCH, (Object) this.epoch); + } + + if (this.receiverOptions != null && this.receiverOptions.getIdentifier() != null) { + properties.put(AmqpConstants.RECEIVER_IDENTIFIER_NAME, (Object) this.receiverOptions.getIdentifier()); + } + + return properties; + } + + @Override + public Symbol[] getDesiredCapabilities() { + + return this.receiverOptions != null && this.receiverOptions.getReceiverRuntimeMetricEnabled() + ? new Symbol[]{AmqpConstants.ENABLE_RECEIVER_RUNTIME_METRIC_NAME} + : null; + } +} \ No newline at end of file diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/PartitionSenderImpl.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/PartitionSenderImpl.java new file mode 100644 index 000000000000..8acdc2a27d05 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/PartitionSenderImpl.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.*; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.Consumer; +import java.util.function.Function; + +final class PartitionSenderImpl extends ClientEntity implements PartitionSender { + private final String partitionId; + private final String eventHubName; + private final MessagingFactory factory; + + private volatile MessageSender internalSender; + + private PartitionSenderImpl(final MessagingFactory factory, final String eventHubName, final String partitionId, final ScheduledExecutorService executor) { + super("PartitionSenderImpl".concat(StringUtil.getRandomString()), null, executor); + + this.partitionId = partitionId; + this.eventHubName = eventHubName; + this.factory = factory; + } + + static CompletableFuture Create(final MessagingFactory factory, + final String eventHubName, + final String partitionId, + final ScheduledExecutorService executor) throws EventHubException { + final PartitionSenderImpl sender = new PartitionSenderImpl(factory, eventHubName, partitionId, executor); + return sender.createInternalSender() + .thenApplyAsync(new Function() { + public PartitionSender apply(Void a) { + return sender; + } + }, executor); + } + + private CompletableFuture createInternalSender() throws EventHubException { + return MessageSender.create(this.factory, this.getClientId().concat("-InternalSender"), + String.format("%s/Partitions/%s", this.eventHubName, this.partitionId)) + .thenAcceptAsync(new Consumer() { + public void accept(MessageSender a) { + PartitionSenderImpl.this.internalSender = a; + } + }, this.executor); + } + + public String getPartitionId() { + return this.partitionId; + } + + public EventDataBatch createBatch(BatchOptions options) { + if (!StringUtil.isNullOrEmpty(options.partitionKey)) { + throw new IllegalArgumentException("A partition key cannot be set when using PartitionSenderImpl. If you'd like to " + + "continue using PartitionSenderImpl with EventDataBatches, then please do not set a partition key in your BatchOptions."); + } + + int maxSize = this.internalSender.getMaxMessageSize(); + + if (options.maxMessageSize == null) { + return new EventDataBatchImpl(maxSize, null); + } + + if (options.maxMessageSize > maxSize) { + throw new IllegalArgumentException("The maxMessageSize set in BatchOptions is too large. You set a maxMessageSize of " + + options.maxMessageSize + ". The maximum allowed size is " + maxSize + "."); + } + + return new EventDataBatchImpl(options.maxMessageSize, null); + } + + public final CompletableFuture send(EventData data) { + return this.internalSender.send(((EventDataImpl) data).toAmqpMessage()); + } + + public final CompletableFuture send(Iterable eventDatas) { + if (eventDatas == null || IteratorUtil.sizeEquals(eventDatas, 0)) { + throw new IllegalArgumentException("EventData batch cannot be empty."); + } + + return this.internalSender.send(EventDataUtil.toAmqpMessages(eventDatas)); + } + + public final CompletableFuture send(EventDataBatch eventDatas) { + if (eventDatas == null || Integer.compare(eventDatas.getSize(), 0) == 0) { + throw new IllegalArgumentException("EventDataBatch cannot be empty."); + } + + if (!StringUtil.isNullOrEmpty(((EventDataBatchImpl) eventDatas).getPartitionKey())) { + throw new IllegalArgumentException("A partition key cannot be set when using PartitionSenderImpl. If you'd like to " + + "continue using PartitionSenderImpl with EventDataBatches, then please do not set a partition key in your BatchOptions"); + } + + return this.internalSender.send(EventDataUtil.toAmqpMessages(((EventDataBatchImpl) eventDatas).getInternalIterable())); + } + + @Override + public CompletableFuture onClose() { + if (this.internalSender == null) { + return CompletableFuture.completedFuture(null); + } else { + return this.internalSender.close(); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/PassByRef.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/PassByRef.java new file mode 100644 index 000000000000..818aeeea2153 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/PassByRef.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +public final class PassByRef { + + T t; + + public T get() { + return this.t; + } + + public void set(final T t) { + this.t = t; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ProtonUtil.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ProtonUtil.java new file mode 100644 index 000000000000..7b719fcf4eee --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ProtonUtil.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.reactor.Reactor; +import org.apache.qpid.proton.reactor.ReactorOptions; + +import java.io.IOException; + +public final class ProtonUtil { + + private ProtonUtil() { + } + + public static Reactor reactor(final ReactorHandler reactorHandler, final int maxFrameSize) throws IOException { + + final ReactorOptions reactorOptions = new ReactorOptions(); + reactorOptions.setMaxFrameSize(maxFrameSize); + reactorOptions.setEnableSaslByDefault(true); + + final Reactor reactor = Proton.reactor(reactorOptions, reactorHandler); + reactor.setGlobalHandler(new CustomIOHandler()); + + return reactor; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReactorDispatcher.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReactorDispatcher.java new file mode 100644 index 000000000000..6aad0e296670 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReactorDispatcher.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.engine.BaseHandler; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.reactor.Reactor; +import org.apache.qpid.proton.reactor.Selectable; +import org.apache.qpid.proton.reactor.Selectable.Callback; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; +import java.nio.channels.Pipe; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.RejectedExecutionException; + +/** + * {@link Reactor} is not thread-safe - all calls to {@link Proton} API's 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} didn't support API's like Send() out-of-box - which could potentially run on different thread to that of Reactor. + * So, 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 static final Logger TRACE_LOGGER = LoggerFactory.getLogger(ReactorDispatcher.class); + private final Reactor reactor; + private final Pipe ioSignal; + private final ConcurrentLinkedQueue workQueue; + private final ScheduleHandler workScheduler; + + public ReactorDispatcher(final Reactor reactor) throws IOException { + this.reactor = reactor; + this.ioSignal = Pipe.open(); + this.workQueue = new ConcurrentLinkedQueue<>(); + this.workScheduler = new ScheduleHandler(); + + initializeSelectable(); + } + + private void initializeSelectable() { + Selectable schedulerSelectable = this.reactor.selectable(); + + schedulerSelectable.setChannel(this.ioSignal.source()); + schedulerSelectable.onReadable(this.workScheduler); + schedulerSelectable.onFree(new CloseHandler()); + + schedulerSelectable.setReading(true); + this.reactor.update(schedulerSelectable); + } + + public void invoke(final DispatchHandler timerCallback) throws IOException, RejectedExecutionException { + this.throwIfSchedulerError(); + + this.workQueue.offer(timerCallback); + this.signalWorkQueue(); + } + + public void invoke(final int delay, final DispatchHandler timerCallback) throws IOException, RejectedExecutionException { + this.throwIfSchedulerError(); + + this.workQueue.offer(new DelayHandler(this.reactor, delay, timerCallback)); + 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 { + while (this.ioSignal.sink().write(ByteBuffer.allocate(1)) == 0) { + } + } catch (ClosedChannelException ignorePipeClosedDuringReactorShutdown) { + TRACE_LOGGER.info("signalWorkQueue failed with an error", ignorePipeClosedDuringReactorShutdown); + } + } + + private final class DelayHandler extends BaseHandler { + final int delay; + final BaseHandler timerCallback; + final Reactor reactor; + + public DelayHandler(final Reactor reactor, final int delay, final DispatchHandler timerCallback) { + this.delay = delay; + this.timerCallback = timerCallback; + this.reactor = reactor; + } + + @Override + public void onTimerTask(Event e) { + this.reactor.schedule(this.delay, this.timerCallback); + } + } + + private final class ScheduleHandler implements Callback { + @Override + public void run(Selectable selectable) { + try { + while (ioSignal.source().read(ByteBuffer.allocate(1024)) > 0) { + // read until the end of the stream + } + } catch (ClosedChannelException ignorePipeClosedDuringReactorShutdown) { + TRACE_LOGGER.info("ScheduleHandler.run() failed with an error", ignorePipeClosedDuringReactorShutdown); + } catch (IOException ioException) { + TRACE_LOGGER.warn("ScheduleHandler.run() failed with an error", ioException); + throw new RuntimeException(ioException); + } + + BaseHandler topWork; + while ((topWork = workQueue.poll()) != null) { + topWork.onTimerTask(null); + } + } + } + + private final class CloseHandler implements Callback { + @Override + public void run(Selectable selectable) { + try { + if (ioSignal.sink().isOpen()) + ioSignal.sink().close(); + } catch (IOException ioException) { + TRACE_LOGGER.info("CloseHandler.run() sink().close() failed with an error", ioException); + } + + workScheduler.run(null); + + try { + if (ioSignal.source().isOpen()) + ioSignal.source().close(); + } catch (IOException ioException) { + TRACE_LOGGER.info("CloseHandler.run() source().close() failed with an error", ioException); + } + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReactorHandler.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReactorHandler.java new file mode 100644 index 000000000000..9b1bea3c0d13 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReactorHandler.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.engine.BaseHandler; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.reactor.Reactor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ReactorHandler extends BaseHandler { + + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(ReactorHandler.class); + + private ReactorDispatcher reactorDispatcher; + + public ReactorDispatcher getReactorDispatcher() { + return this.reactorDispatcher; + } + + // set needs to happen before starting reactorThread + public void unsafeSetReactorDispatcher(final ReactorDispatcher reactorDispatcher) { + this.reactorDispatcher = reactorDispatcher; + } + + @Override + public void onReactorInit(Event e) { + + TRACE_LOGGER.info("reactor.onReactorInit"); + + final Reactor reactor = e.getReactor(); + reactor.setTimeout(ClientConstants.REACTOR_IO_POLL_TIMEOUT); + } + + @Override + public void onReactorFinal(Event e) { + + TRACE_LOGGER.info("reactor.onReactorFinal"); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReceiveLinkHandler.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReceiveLinkHandler.java new file mode 100644 index 000000000000..db32fe36d881 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReceiveLinkHandler.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.engine.Delivery; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.engine.Link; +import org.apache.qpid.proton.engine.Receiver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Locale; + +// ServiceBus <-> ProtonReactor interaction +// handles all recvLink - reactor events +public final class ReceiveLinkHandler extends BaseLinkHandler { + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(ReceiveLinkHandler.class); + private final AmqpReceiver amqpReceiver; + private final Object firstResponse; + private boolean isFirstResponse; + + public ReceiveLinkHandler(final AmqpReceiver receiver) { + super(receiver); + + this.amqpReceiver = receiver; + this.firstResponse = new Object(); + this.isFirstResponse = true; + } + + @Override + public void onLinkLocalOpen(Event evt) { + Link link = evt.getLink(); + if (link instanceof Receiver) { + Receiver receiver = (Receiver) link; + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info( + String.format("onLinkLocalOpen linkName[%s], localSource[%s]", receiver.getName(), receiver.getSource())); + } + } + } + + @Override + public void onLinkRemoteOpen(Event event) { + Link link = event.getLink(); + if (link instanceof Receiver) { + Receiver receiver = (Receiver) link; + if (link.getRemoteSource() != null) { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, "onLinkRemoteOpen linkName[%s], remoteSource[%s]", + receiver.getName(), link.getRemoteSource())); + } + + synchronized (this.firstResponse) { + this.isFirstResponse = false; + this.amqpReceiver.onOpenComplete(null); + } + } else { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info( + String.format(Locale.US, "onLinkRemoteOpen linkName[%s], remoteTarget[null], " + + "remoteSource[null], action[waitingForError]", receiver.getName())); + } + } + } + } + + @Override + public void onDelivery(Event event) { + synchronized (this.firstResponse) { + if (this.isFirstResponse) { + this.isFirstResponse = false; + this.amqpReceiver.onOpenComplete(null); + } + } + + Delivery delivery = event.getDelivery(); + Receiver receiveLink = (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 (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn( + receiveLink != null + ? String.format(Locale.US, "onDelivery linkName[%s], updatedLinkCredit[%s], remoteCredit[%s], " + + "remoteCondition[%s], delivery.isSettled[%s]", + receiveLink.getName(), receiveLink.getCredit(), receiveLink.getRemoteCredit(), receiveLink.getRemoteCondition(), delivery.isSettled()) + : String.format(Locale.US, "delivery.isSettled[%s]", delivery.isSettled())); + } + } else { + this.amqpReceiver.onReceiveComplete(delivery); + } + } + + if (TRACE_LOGGER.isTraceEnabled() && receiveLink != null) { + TRACE_LOGGER.trace( + String.format(Locale.US, "onDelivery linkName[%s], updatedLinkCredit[%s], remoteCredit[%s], " + + "remoteCondition[%s], delivery.isPartial[%s]", + receiveLink.getName(), receiveLink.getCredit(), receiveLink.getRemoteCredit(), receiveLink.getRemoteCondition(), delivery.isPartial())); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReceivePump.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReceivePump.java new file mode 100644 index 000000000000..b60e67db0462 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReceivePump.java @@ -0,0 +1,178 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.EventData; +import com.microsoft.azure.eventhubs.PartitionReceiveHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiFunction; + +public class ReceivePump implements Runnable { + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(ReceivePump.class); + + private final IPartitionReceiver receiver; + private final PartitionReceiveHandler onReceiveHandler; + private final boolean invokeOnTimeout; + private final CompletableFuture stopPump; + private final Executor executor; + private final ProcessAndReschedule processAndReschedule; + private final String eventHubName; + private final String consumerGroupName; + + private AtomicBoolean stopPumpRaised; + private volatile boolean isPumpHealthy = true; + + public ReceivePump( + final String eventHubName, + final String consumerGroupName, + final IPartitionReceiver receiver, + final PartitionReceiveHandler receiveHandler, + final boolean invokeOnReceiveWithNoEvents, + final Executor executor) { + this.eventHubName = eventHubName; + this.consumerGroupName = consumerGroupName; + this.receiver = receiver; + this.onReceiveHandler = receiveHandler; + this.invokeOnTimeout = invokeOnReceiveWithNoEvents; + this.stopPump = new CompletableFuture<>(); + this.executor = executor; + this.processAndReschedule = new ProcessAndReschedule(); + + this.stopPumpRaised = new AtomicBoolean(false); + } + + // entry-point - for runnable + public void run() { + try { + ReceivePump.this.receiveAndProcess(); + } catch (final Exception exception) { + if (TRACE_LOGGER.isErrorEnabled()) { + TRACE_LOGGER.error( + String.format("Receive pump for eventHub (%s), consumerGroup (%s), partition (%s) " + + "encountered unrecoverable error and exited with exception %s.", + this.eventHubName, this.consumerGroupName, this.receiver.getPartitionId(), exception.toString())); + } + + throw exception; + } + } + + // receives and invokes user-callback if success or stops pump if fails + public void receiveAndProcess() { + if (this.shouldContinue()) { + this.receiver.receive(this.onReceiveHandler.getMaxEventCount()) + .handleAsync(this.processAndReschedule, this.executor); + } else { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format("Stopping receive pump for eventHub (%s), consumerGroup (%s), partition (%s) as %s", + this.eventHubName, this.consumerGroupName, this.receiver.getPartitionId(), + this.stopPumpRaised.get() ? "per the request." : "pump ran into errors.")); + } + + this.stopPump.complete(null); + } + } + + public CompletableFuture stop() { + this.stopPumpRaised.set(true); + return this.stopPump; + } + + public boolean isRunning() { + return !this.stopPump.isDone(); + } + + private boolean shouldContinue() { + return this.isPumpHealthy && !this.stopPumpRaised.get(); + } + + private void handleClientExceptions(final Throwable clientException) { + if (clientException != null) { + this.isPumpHealthy = false; + + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn(String.format( + "Receive pump for eventHub (%s), consumerGroup (%s), partition (%s) exiting after receive exception %s", + this.eventHubName, this.consumerGroupName, this.receiver.getPartitionId(), clientException.toString())); + } + + this.onReceiveHandler.onError(clientException); + } + } + + private void handleUserCodeExceptions(final Throwable userCodeException) { + this.isPumpHealthy = false; + if (TRACE_LOGGER.isErrorEnabled()) { + TRACE_LOGGER.error( + String.format("Receive pump for eventHub (%s), consumerGroup (%s), partition (%s) " + + "exiting after user-code exception %s", + this.eventHubName, this.consumerGroupName, this.receiver.getPartitionId(), userCodeException.toString())); + } + + this.onReceiveHandler.onError(userCodeException); + + if (userCodeException instanceof InterruptedException) { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format("Interrupting receive pump for eventHub (%s), consumerGroup (%s), partition (%s)", + this.eventHubName, this.consumerGroupName, this.receiver.getPartitionId())); + } + + Thread.currentThread().interrupt(); + } + } + + private void schedulePump() { + try { + this.executor.execute(this); + } catch (final RejectedExecutionException rejectedException) { + this.isPumpHealthy = false; + + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn(String.format( + "Receive pump for eventHub (%s), consumerGroup (%s), partition (%s) exiting with error: %s", + this.eventHubName, this.consumerGroupName, ReceivePump.this.receiver.getPartitionId(), rejectedException.toString())); + } + + this.onReceiveHandler.onError(rejectedException); + } + } + + // partition receiver contract against which this pump works + public interface IPartitionReceiver { + String getPartitionId(); + + CompletableFuture> receive(final int maxBatchSize); + } + + private final class ProcessAndReschedule implements BiFunction, Throwable, Void> { + + @Override + public Void apply(final Iterable receivedEvents, final Throwable clientException) { + + ReceivePump.this.handleClientExceptions(clientException); + + try { + // don't invoke user call back - if stop is already raised / pump is unhealthy + if (ReceivePump.this.shouldContinue() && + (receivedEvents != null + || (receivedEvents == null && ReceivePump.this.invokeOnTimeout))) { + ReceivePump.this.onReceiveHandler.onReceive(receivedEvents); + } + } catch (final Throwable userCodeError) { + ReceivePump.this.handleUserCodeExceptions(userCodeError); + } + + ReceivePump.this.schedulePump(); + + return null; + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReceiverContext.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReceiverContext.java new file mode 100644 index 000000000000..7c0597780eed --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReceiverContext.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.ErrorContext; + +import java.util.Locale; + +public class ReceiverContext extends ErrorContext { + final static boolean EPOCH_RECEIVER_TYPE = true; + final static boolean NON_EPOCH_RECEIVER_TYPE = !ReceiverContext.EPOCH_RECEIVER_TYPE; + + final String receivePath; + final String referenceId; + final Integer prefetchCount; + final Integer currentLinkCredit; + final Integer prefetchQueueLength; + + ReceiverContext( + final String namespaceName, + final String receivePath, + final String referenceId, + final Integer prefetchCount, + final Integer currentLinkCredit, + final Integer prefetchQueueLength) { + super(namespaceName); + this.receivePath = receivePath; + this.referenceId = referenceId; + this.prefetchCount = prefetchCount; + this.currentLinkCredit = currentLinkCredit; + this.prefetchQueueLength = prefetchQueueLength; + } + + @Override + public String toString() { + final String superString = super.toString(); + StringBuilder toString = new StringBuilder(); + + if (!StringUtil.isNullOrEmpty(superString)) { + toString.append(superString); + toString.append(", "); + } + + if (this.receivePath != null) { + toString.append(String.format(Locale.US, "PATH: %s", this.receivePath)); + toString.append(", "); + } + + if (this.referenceId != null) { + toString.append(String.format(Locale.US, "REFERENCE_ID: %s", this.referenceId)); + toString.append(", "); + } + + if (this.prefetchCount != null) { + toString.append(String.format(Locale.US, "PREFETCH_COUNT: %s", this.prefetchCount)); + toString.append(", "); + } + + if (this.currentLinkCredit != null) { + toString.append(String.format(Locale.US, "LINK_CREDIT: %s", this.currentLinkCredit)); + toString.append(", "); + } + + if (this.prefetchQueueLength != null) { + toString.append(String.format(Locale.US, "PREFETCH_Q_LEN: %s", this.prefetchQueueLength)); + toString.append(", "); + } + + if (toString.length() > 2) { + toString.setLength(toString.length() - 2); + } + + return toString.toString(); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReceiverSettingsProvider.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReceiverSettingsProvider.java new file mode 100644 index 000000000000..e5b559520126 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReceiverSettingsProvider.java @@ -0,0 +1,19 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.UnknownDescribedType; +import org.apache.qpid.proton.message.Message; + +import java.util.Map; + +public interface ReceiverSettingsProvider { + Map getFilter(final Message lastReceivedMessage); + + Map getProperties(); + + Symbol[] getDesiredCapabilities(); +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReplayableWorkItem.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReplayableWorkItem.java new file mode 100644 index 000000000000..dae7f31e2fce --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/ReplayableWorkItem.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; + +public class ReplayableWorkItem extends WorkItem { + private byte[] amqpMessage; + private int messageFormat; + private int encodedMessageSize; + private boolean waitingForAck; + + private Exception lastKnownException; + private CompletableFuture timeoutTask; + + public ReplayableWorkItem(final byte[] amqpMessage, final int encodedMessageSize, final int messageFormat, final CompletableFuture completableFuture, final Duration timeout) { + super(completableFuture, timeout); + this.initialize(amqpMessage, encodedMessageSize, messageFormat); + } + + public ReplayableWorkItem(final byte[] amqpMessage, final int encodedMessageSize, final int messageFormat, final CompletableFuture completableFuture, final TimeoutTracker timeout) { + super(completableFuture, timeout); + this.initialize(amqpMessage, encodedMessageSize, messageFormat); + } + + private void initialize(final byte[] amqpMessage, final int encodedMessageSize, final int messageFormat) { + this.amqpMessage = amqpMessage; + this.messageFormat = messageFormat; + this.encodedMessageSize = encodedMessageSize; + } + + public byte[] getMessage() { + return this.amqpMessage; + } + + public void clearMessage() { + this.amqpMessage = null; + } + + public int getEncodedMessageSize() { + return this.encodedMessageSize; + } + + public int getMessageFormat() { + return this.messageFormat; + } + + public Exception getLastKnownException() { + return this.lastKnownException; + } + + public void setLastKnownException(Exception exception) { + this.lastKnownException = exception; + } + + public CompletableFuture getTimeoutTask() { + return this.timeoutTask; + } + + public void setTimeoutTask(final CompletableFuture timeoutTask) { + this.timeoutTask = timeoutTask; + } + + public void setWaitingForAck() { + this.waitingForAck = true; + } + + public boolean isWaitingForAck() { + return this.waitingForAck; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/RequestResponseChannel.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/RequestResponseChannel.java new file mode 100644 index 000000000000..68a9719b4ab5 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/RequestResponseChannel.java @@ -0,0 +1,263 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.EventHubException; +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.ErrorCondition; +import org.apache.qpid.proton.amqp.transport.ReceiverSettleMode; +import org.apache.qpid.proton.amqp.transport.SenderSettleMode; +import org.apache.qpid.proton.engine.*; +import org.apache.qpid.proton.message.Message; + +import java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +public class RequestResponseChannel implements IOObject { + + private final Sender sendLink; + private final Receiver receiveLink; + private final String replyTo; + private final HashMap> inflightRequests; + private final AtomicLong requestId; + private final AtomicInteger openRefCount; + private final AtomicInteger closeRefCount; + + private OperationResult onOpen; + private OperationResult onClose; // handles closeLink due to failures + private OperationResult onGraceFullClose; // handles intentional close + + public RequestResponseChannel( + final String linkName, + final String path, + final Session session) { + + this.replyTo = path.replace("$", "") + "-client-reply-to"; + this.openRefCount = new AtomicInteger(2); + this.closeRefCount = new AtomicInteger(2); + this.inflightRequests = new HashMap<>(); + this.requestId = new AtomicLong(0); + + 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); + BaseHandler.setHandler(this.sendLink, new SendLinkHandler(new RequestHandler())); + + 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(this.replyTo); + this.receiveLink.setTarget(receiverTarget); + this.receiveLink.setSenderSettleMode(SenderSettleMode.SETTLED); + this.receiveLink.setReceiverSettleMode(ReceiverSettleMode.SECOND); + BaseHandler.setHandler(this.receiveLink, new ReceiveLinkHandler(new ResponseHandler())); + } + + // open should be called only once - we use FaultTolerantObject for that + public void open(final OperationResult onOpen, final OperationResult onClose) { + + this.onOpen = onOpen; + this.onClose = onClose; + this.sendLink.open(); + this.receiveLink.open(); + } + + // close should be called exactly once - we use FaultTolerantObject for that + public void close(final OperationResult onGraceFullClose) { + + this.onGraceFullClose = onGraceFullClose; + this.sendLink.close(); + this.receiveLink.close(); + } + + public Sender getSendLink() { + return this.sendLink; + } + + public Receiver getReceiveLink() { + return this.receiveLink; + } + + // not thread-safe + // this must be invoked from reactor/dispatcher thread + // & assumes that this is run on Opened Object + public void request( + final Message message, + final OperationResult onResponse) { + + 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"); + + message.setMessageId("request" + UnsignedLong.valueOf(this.requestId.incrementAndGet()).toString()); + message.setReplyTo(this.replyTo); + + this.inflightRequests.put(message.getMessageId(), onResponse); + + sendLink.delivery(UUID.randomUUID().toString().replace("-", StringUtil.EMPTY).getBytes()); + final int payloadSize = AmqpUtil.getDataSerializedSize(message) + 512; // need buffer for headers + + 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 void onLinkOpenComplete(final Exception exception) { + + if (openRefCount.decrementAndGet() <= 0 && onOpen != null) + if (exception == null && this.sendLink.getRemoteState() == EndpointState.ACTIVE && this.receiveLink.getRemoteState() == EndpointState.ACTIVE) + onOpen.onComplete(null); + else { + if (exception != null) + onOpen.onError(exception); + else { + final ErrorCondition error = (this.sendLink.getRemoteCondition() != null && this.sendLink.getRemoteCondition().getCondition() != null) + ? this.sendLink.getRemoteCondition() + : this.receiveLink.getRemoteCondition(); + onOpen.onError(ExceptionUtil.toException(error)); + } + } + } + + private void onLinkCloseComplete(final Exception exception) { + + if (closeRefCount.decrementAndGet() <= 0) + if (exception == null) { + onClose.onComplete(null); + if (onGraceFullClose != null) + onGraceFullClose.onComplete(null); + } else { + onClose.onError(exception); + if (onGraceFullClose != null) + onGraceFullClose.onError(exception); + } + } + + @Override + public IOObjectState getState() { + + if (sendLink.getLocalState() == EndpointState.UNINITIALIZED || receiveLink.getLocalState() == EndpointState.UNINITIALIZED + || sendLink.getRemoteState() == EndpointState.UNINITIALIZED || receiveLink.getRemoteState() == EndpointState.UNINITIALIZED) + return IOObjectState.OPENING; + + if (sendLink.getRemoteState() == EndpointState.ACTIVE && receiveLink.getRemoteState() == EndpointState.ACTIVE + && sendLink.getLocalState() == EndpointState.ACTIVE && receiveLink.getRemoteState() == EndpointState.ACTIVE) + return IOObjectState.OPENED; + + if (sendLink.getRemoteState() == EndpointState.CLOSED && receiveLink.getRemoteState() == EndpointState.CLOSED) + return IOObjectState.CLOSED; + + return IOObjectState.CLOSING; // only left cases are if some are active and some are closed + } + + private class RequestHandler implements AmqpSender { + + @Override + public void onFlow(int creditIssued) { + } + + @Override + public void onSendComplete(Delivery delivery) { + } + + @Override + public void onOpenComplete(Exception completionException) { + + onLinkOpenComplete(completionException); + } + + @Override + public void onError(Exception exception) { + + onLinkCloseComplete(exception); + } + + @Override + public void onClose(ErrorCondition condition) { + + if (condition == null || condition.getCondition() == null) + onLinkCloseComplete(null); + else + onError(ExceptionUtil.toException(condition)); + } + + } + + private class ResponseHandler implements AmqpReceiver { + + @Override + public void onReceiveComplete(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(); + + final OperationResult responseCallback = inflightRequests.remove(response.getCorrelationId()); + if (responseCallback != null) + responseCallback.onComplete(response); + } + + @Override + public void onOpenComplete(Exception completionException) { + + onLinkOpenComplete(completionException); + } + + @Override + public void onError(Exception exception) { + + this.cancelPendingRequests(exception); + + if (onClose != null) + onLinkCloseComplete(exception); + } + + @Override + public void onClose(ErrorCondition condition) { + + if (condition == null || condition.getCondition() == null) { + this.cancelPendingRequests( + new EventHubException( + ClientConstants.DEFAULT_IS_TRANSIENT, + "The underlying request-response channel closed, recreate the channel and retry the request.")); + + if (onClose != null) + onLinkCloseComplete(null); + } else { + this.onError(ExceptionUtil.toException(condition)); + } + } + + private void cancelPendingRequests(final Exception exception) { + for (OperationResult responseCallback : inflightRequests.values()) + responseCallback.onError(exception); + + inflightRequests.clear(); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/RequestResponseCloser.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/RequestResponseCloser.java new file mode 100644 index 000000000000..cfd57ac043e4 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/RequestResponseCloser.java @@ -0,0 +1,33 @@ +package com.microsoft.azure.eventhubs.impl; + +public class RequestResponseCloser implements Operation { + private FaultTolerantObject innerChannel = null; + + public RequestResponseCloser() { + } + + // innerChannel is not available when this object is constructed, have to set later + public void setInnerChannel(final FaultTolerantObject innerChannel) { + this.innerChannel = innerChannel; + } + + @Override + public void run(OperationResult closeOperationCallback) { + final RequestResponseChannel channelToBeClosed = this.innerChannel.unsafeGetIfOpened(); + if (channelToBeClosed == null) { + closeOperationCallback.onComplete(null); + } else { + channelToBeClosed.close(new OperationResult() { + @Override + public void onComplete(Void result) { + closeOperationCallback.onComplete(result); + } + + @Override + public void onError(Exception error) { + closeOperationCallback.onError(error); + } + }); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/RequestResponseOpener.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/RequestResponseOpener.java new file mode 100644 index 000000000000..bd4d55617084 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/RequestResponseOpener.java @@ -0,0 +1,77 @@ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.amqp.transport.ErrorCondition; +import org.apache.qpid.proton.engine.Session; + +import java.util.function.BiConsumer; + +public class RequestResponseOpener implements Operation { + private final SessionProvider sessionProvider; + private final String sessionName; + private final String linkName; + private final String endpointAddress; + private final AmqpConnection eventDispatcher; + + public RequestResponseOpener(final SessionProvider sessionProvider, final String sessionName, final String linkName, + final String endpointAddress, final AmqpConnection eventDispatcher) { + this.sessionProvider = sessionProvider; + this.sessionName = sessionName; + this.linkName = linkName; + this.endpointAddress = endpointAddress; + this.eventDispatcher = eventDispatcher; + } + + @Override + public void run(OperationResult operationCallback) { + + final Session session = this.sessionProvider.getSession( + this.sessionName, + null, + new BiConsumer() { + @Override + public void accept(ErrorCondition error, Exception exception) { + if (error != null) + operationCallback.onError(ExceptionUtil.toException(error)); + else if (exception != null) + operationCallback.onError(exception); + } + }); + + if (session == null) + return; + + final RequestResponseChannel requestResponseChannel = new RequestResponseChannel( + this.linkName, + this.endpointAddress, + session); + + requestResponseChannel.open( + new OperationResult() { + @Override + public void onComplete(Void result) { + eventDispatcher.registerForConnectionError(requestResponseChannel.getSendLink()); + eventDispatcher.registerForConnectionError(requestResponseChannel.getReceiveLink()); + + operationCallback.onComplete(requestResponseChannel); + } + + @Override + public void onError(Exception error) { + operationCallback.onError(error); + } + }, + new OperationResult() { + @Override + public void onComplete(Void result) { + eventDispatcher.deregisterForConnectionError(requestResponseChannel.getSendLink()); + eventDispatcher.deregisterForConnectionError(requestResponseChannel.getReceiveLink()); + } + + @Override + public void onError(Exception error) { + eventDispatcher.deregisterForConnectionError(requestResponseChannel.getSendLink()); + eventDispatcher.deregisterForConnectionError(requestResponseChannel.getReceiveLink()); + } + }); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/RetryExponential.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/RetryExponential.java new file mode 100644 index 000000000000..c356c7d92912 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/RetryExponential.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.ConnectionStringBuilder; +import com.microsoft.azure.eventhubs.RetryPolicy; + +import java.time.Duration; + +/** + * RetryPolicy implementation where the delay between retries will grow in an exponential manner. + * RetryPolicy can be set on the client operations using {@link ConnectionStringBuilder}. + * RetryIntervals will be computed using a retryFactor which is a function of deltaBackOff (MaximumBackoff - MinimumBackoff) and MaximumRetryCount + */ +public final class RetryExponential extends RetryPolicy { + private final Duration minimumBackoff; + private final Duration maximumBackoff; + private final int maximumRetryCount; + private final double retryFactor; + + public RetryExponential(final Duration minimumBackoff, final Duration maximumBackoff, final int maximumRetryCount, final String name) { + super(name); + + this.minimumBackoff = minimumBackoff; + this.maximumBackoff = maximumBackoff; + this.maximumRetryCount = maximumRetryCount; + this.retryFactor = this.computeRetryFactor(); + } + + @Override + protected Duration onGetNextRetryInterval(final String clientId, final Exception lastException, final Duration remainingTime, final int baseWaitTimeSecs) { + int currentRetryCount = this.getRetryCount(clientId); + + if (currentRetryCount >= this.maximumRetryCount) { + return null; + } + + if (!RetryPolicy.isRetryableException(lastException)) { + return null; + } + + double nextRetryInterval = Math.pow(this.retryFactor, (double) currentRetryCount); + long nextRetryIntervalSeconds = (long) nextRetryInterval; + long nextRetryIntervalNano = (long) ((nextRetryInterval - (double) nextRetryIntervalSeconds) * 1000000000); + if (remainingTime.getSeconds() < Math.max(nextRetryInterval, ClientConstants.TIMER_TOLERANCE.getSeconds())) { + return null; + } + + Duration retryAfter = this.minimumBackoff.plus(Duration.ofSeconds(nextRetryIntervalSeconds, nextRetryIntervalNano)); + retryAfter = retryAfter.plus(Duration.ofSeconds(baseWaitTimeSecs)); + + return retryAfter; + } + + private double computeRetryFactor() { + long deltaBackoff = this.maximumBackoff.minus(this.minimumBackoff).getSeconds(); + if (deltaBackoff <= 0 || this.maximumRetryCount <= 0) { + return 0; + } + + return (Math.log(deltaBackoff) / Math.log(this.maximumRetryCount)); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/SchedulerProvider.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/SchedulerProvider.java new file mode 100644 index 000000000000..03e6d91c9d8c --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/SchedulerProvider.java @@ -0,0 +1,6 @@ +package com.microsoft.azure.eventhubs.impl; + +interface SchedulerProvider { + + ReactorDispatcher getReactorDispatcher(); +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/SendLinkHandler.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/SendLinkHandler.java new file mode 100644 index 000000000000..8a9df26a626c --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/SendLinkHandler.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.engine.Delivery; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.engine.Link; +import org.apache.qpid.proton.engine.Sender; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Locale; + +public class SendLinkHandler extends BaseLinkHandler { + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(SendLinkHandler.class); + private final AmqpSender msgSender; + private final Object firstFlow; + private boolean isFirstFlow; + + public SendLinkHandler(final AmqpSender sender) { + super(sender); + + this.msgSender = sender; + this.firstFlow = new Object(); + this.isFirstFlow = true; + } + + @Override + public void onLinkLocalOpen(Event event) { + Link link = event.getLink(); + if (link instanceof Sender) { + Sender sender = (Sender) link; + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format("onLinkLocalOpen linkName[%s], localTarget[%s]", sender.getName(), sender.getTarget())); + } + } + } + + @Override + public void onLinkRemoteOpen(Event event) { + Link link = event.getLink(); + if (link instanceof Sender) { + Sender sender = (Sender) link; + if (link.getRemoteTarget() != null) { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, "onLinkRemoteOpen linkName[%s], remoteTarget[%s]", sender.getName(), link.getRemoteTarget())); + } + + synchronized (this.firstFlow) { + this.isFirstFlow = false; + this.msgSender.onOpenComplete(null); + } + } else { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info( + String.format(Locale.US, "onLinkRemoteOpen linkName[%s], remoteTarget[null], remoteSource[null], action[waitingForError]", sender.getName())); + } + } + } + } + + @Override + public void onDelivery(Event event) { + Delivery delivery = event.getDelivery(); + + while (delivery != null) { + Sender sender = (Sender) delivery.getLink(); + + if (TRACE_LOGGER.isTraceEnabled()) { + TRACE_LOGGER.trace( + "onDelivery linkName[" + sender.getName() + + "], unsettled[" + sender.getUnsettled() + "], credit[" + sender.getRemoteCredit() + "], deliveryState[" + delivery.getRemoteState() + + "], delivery.isBuffered[" + delivery.isBuffered() + "], delivery.id[" + new String(delivery.getTag()) + "]"); + } + + msgSender.onSendComplete(delivery); + delivery.settle(); + + delivery = sender.current(); + } + } + + @Override + public void onLinkFlow(Event event) { + if (this.isFirstFlow) { + synchronized (this.firstFlow) { + if (this.isFirstFlow) { + this.msgSender.onOpenComplete(null); + this.isFirstFlow = false; + } + } + } + + Sender sender = event.getSender(); + this.msgSender.onFlow(sender.getRemoteCredit()); + + if (TRACE_LOGGER.isDebugEnabled()) { + TRACE_LOGGER.debug("onLinkFlow linkName[" + sender.getName() + "], unsettled[" + sender.getUnsettled() + "], credit[" + sender.getCredit() + "]"); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/SenderContext.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/SenderContext.java new file mode 100644 index 000000000000..6fd4952ce6cd --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/SenderContext.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.ErrorContext; + +import java.util.Locale; + +public class SenderContext extends ErrorContext { + final String sendPath; + final String referenceId; + final Integer currentLinkCredit; + + SenderContext( + final String namespaceName, + final String sendPath, + final String referenceId, + final Integer currentLinkCredit) { + super(namespaceName); + + this.sendPath = sendPath; + this.referenceId = referenceId; + this.currentLinkCredit = currentLinkCredit; + } + + @Override + public String toString() { + final String superString = super.toString(); + StringBuilder toString = new StringBuilder(); + + if (!StringUtil.isNullOrEmpty(superString)) { + toString.append(superString); + toString.append(", "); + } + + if (this.sendPath != null) { + toString.append(String.format(Locale.US, "PATH: %s", this.sendPath)); + toString.append(", "); + } + + if (this.referenceId != null) { + toString.append(String.format(Locale.US, "REFERENCE_ID: %s", this.referenceId)); + toString.append(", "); + } + + if (this.currentLinkCredit != null) { + toString.append(String.format(Locale.US, "LINK_CREDIT: %s", this.currentLinkCredit)); + toString.append(", "); + } + + if (toString.length() > 2) { + toString.setLength(toString.length() - 2); + } + + return toString.toString(); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/SessionHandler.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/SessionHandler.java new file mode 100644 index 000000000000..65714d2e57c5 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/SessionHandler.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.eventhubs.EventHubException; +import org.apache.qpid.proton.amqp.transport.ErrorCondition; +import org.apache.qpid.proton.engine.*; +import org.apache.qpid.proton.reactor.Reactor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.time.Duration; +import java.util.Iterator; +import java.util.Locale; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +public class SessionHandler extends BaseHandler { + protected static final Logger TRACE_LOGGER = LoggerFactory.getLogger(SessionHandler.class); + + private final String entityName; + private final Consumer onRemoteSessionOpen; + private final BiConsumer onRemoteSessionOpenError; + private final Duration openTimeout; + + private boolean sessionCreated = false; + private boolean sessionOpenErrorDispatched = false; + + public SessionHandler(final String entityName, + final Consumer onRemoteSessionOpen, + final BiConsumer onRemoteSessionOpenError, + final Duration openTimeout) { + this.entityName = entityName; + this.onRemoteSessionOpenError = onRemoteSessionOpenError; + this.onRemoteSessionOpen = onRemoteSessionOpen; + this.openTimeout = openTimeout; + } + + @Override + public void onSessionLocalOpen(Event e) { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, "onSessionLocalOpen entityName[%s], condition[%s]", this.entityName, + e.getSession().getCondition() == null ? "none" : e.getSession().getCondition().toString())); + } + + if (this.onRemoteSessionOpenError != null) { + ReactorHandler reactorHandler = null; + final Reactor reactor = e.getReactor(); + final Iterator reactorEventHandlers = reactor.getHandler().children(); + while (reactorEventHandlers.hasNext()) { + final Handler currentHandler = reactorEventHandlers.next(); + if (currentHandler instanceof ReactorHandler) { + reactorHandler = (ReactorHandler) currentHandler; + break; + } + } + + final ReactorDispatcher reactorDispatcher = reactorHandler.getReactorDispatcher(); + final Session session = e.getSession(); + + try { + reactorDispatcher.invoke((int) this.openTimeout.toMillis(), new SessionTimeoutHandler(session, entityName)); + } catch (IOException ioException) { + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn(String.format(Locale.US, "onSessionLocalOpen entityName[%s], reactorDispatcherError[%s]", + this.entityName, ioException.getMessage())); + } + + session.close(); + this.onRemoteSessionOpenError.accept( + null, + new EventHubException( + false, + String.format("onSessionLocalOpen entityName[%s], underlying IO of reactorDispatcher faulted with error: %s", + this.entityName, ioException.getMessage()), ioException)); + } + } + } + + @Override + public void onSessionRemoteOpen(Event e) { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, "onSessionRemoteOpen entityName[%s], sessionIncCapacity[%s], sessionOutgoingWindow[%s]", + this.entityName, e.getSession().getIncomingCapacity(), e.getSession().getOutgoingWindow())); + } + + final Session session = e.getSession(); + if (session != null && session.getLocalState() == EndpointState.UNINITIALIZED) { + session.open(); + } + + sessionCreated = true; + if (this.onRemoteSessionOpen != null) + this.onRemoteSessionOpen.accept(session); + } + + @Override + public void onSessionLocalClose(Event e) { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, "onSessionLocalClose entityName[%s], condition[%s]", this.entityName, + e.getSession().getCondition() == null ? "none" : e.getSession().getCondition().toString())); + } + } + + @Override + public void onSessionRemoteClose(Event e) { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, "onSessionRemoteClose entityName[%s], condition[%s]", this.entityName, + e.getSession().getRemoteCondition() == null ? "none" : e.getSession().getRemoteCondition().toString())); + } + + final Session session = e.getSession(); + ErrorCondition condition = session != null ? session.getRemoteCondition() : null; + + if (session != null && session.getLocalState() != EndpointState.CLOSED) { + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info(String.format(Locale.US, "onSessionRemoteClose closing a local session for entityName[%s], condition[%s], description[%s]", + this.entityName, + condition != null ? condition.getCondition() : "n/a", + condition != null ? condition.getDescription() : "n/a")); + } + + session.setCondition(session.getRemoteCondition()); + session.close(); + } + + this.sessionOpenErrorDispatched = true; + if (!sessionCreated && this.onRemoteSessionOpenError != null) + this.onRemoteSessionOpenError.accept(condition, null); + } + + @Override + public void onSessionFinal(Event e) { + if (TRACE_LOGGER.isInfoEnabled()) { + final Session session = e.getSession(); + ErrorCondition condition = session != null ? session.getCondition() : null; + + TRACE_LOGGER.info(String.format(Locale.US, "onSessionFinal entityName[%s], condition[%s], description[%s]", + this.entityName, + condition != null ? condition.getCondition() : "n/a", + condition != null ? condition.getDescription() : "n/a")); + } + } + + private class SessionTimeoutHandler extends DispatchHandler { + + private final Session session; + private final String entityName; + + SessionTimeoutHandler(final Session session, final String entityName) { + this.session = session; + this.entityName = entityName; + } + + @Override + public void onEvent() { + // 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) { + if (TRACE_LOGGER.isWarnEnabled()) { + TRACE_LOGGER.warn(String.format(Locale.US, "SessionTimeoutHandler.onEvent - entityName[%s], session open timed out.", + this.entityName)); + } + } + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/SessionProvider.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/SessionProvider.java new file mode 100644 index 000000000000..a1a97eb8eec5 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/SessionProvider.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.amqp.transport.ErrorCondition; +import org.apache.qpid.proton.engine.Session; + +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +public interface SessionProvider { + Session getSession( + final String path, + final Consumer onSessionOpen, + final BiConsumer onSessionOpenError); +} \ No newline at end of file diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/SharedAccessSignatureTokenProvider.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/SharedAccessSignatureTokenProvider.java new file mode 100644 index 000000000000..431c0c513f53 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/SharedAccessSignatureTokenProvider.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +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.util.Base64; +import java.util.Locale; + +public class SharedAccessSignatureTokenProvider { + final String keyName; + final String sharedAccessKey; + final String sharedAccessSignature; + + SharedAccessSignatureTokenProvider( + final String keyName, + final String sharedAccessKey) { + this.keyName = keyName; + this.sharedAccessKey = sharedAccessKey; + this.sharedAccessSignature = null; + } + + public SharedAccessSignatureTokenProvider(final String sharedAccessSignature) { + this.keyName = null; + this.sharedAccessKey = null; + this.sharedAccessSignature = sharedAccessSignature; + } + + public static String generateSharedAccessSignature( + final String keyName, + final String sharedAccessKey, + final String resource, + final Duration tokenTimeToLive) + throws IOException, NoSuchAlgorithmException, InvalidKeyException { + if (StringUtil.isNullOrWhiteSpace(keyName)) { + throw new IllegalArgumentException("keyName cannot be empty"); + } + + if (StringUtil.isNullOrWhiteSpace(sharedAccessKey)) { + throw new IllegalArgumentException("sharedAccessKey cannot be empty"); + } + + if (StringUtil.isNullOrWhiteSpace(resource)) { + throw new IllegalArgumentException("resource cannot be empty"); + } + + if (tokenTimeToLive.isZero() || tokenTimeToLive.isNegative()) { + throw new IllegalArgumentException("tokenTimeToLive has to positive and in the order-of seconds"); + } + + final String utf8Encoding = StandardCharsets.UTF_8.name(); + String expiresOn = Long.toString(Instant.now().getEpochSecond() + tokenTimeToLive.getSeconds()); + String audienceUri = URLEncoder.encode(resource, utf8Encoding); + String secretToSign = audienceUri + "\n" + expiresOn; + + final String hashAlgorithm = "HMACSHA256"; + Mac hmac = Mac.getInstance(hashAlgorithm); + byte[] sasKeyBytes = sharedAccessKey.getBytes(utf8Encoding); + SecretKeySpec finalKey = new SecretKeySpec(sasKeyBytes, hashAlgorithm); + hmac.init(finalKey); + byte[] signatureBytes = hmac.doFinal(secretToSign.getBytes(utf8Encoding)); + String signature = Base64.getEncoder().encodeToString(signatureBytes); + + return String.format(Locale.US, "SharedAccessSignature sr=%s&sig=%s&se=%s&skn=%s", + audienceUri, + URLEncoder.encode(signature, utf8Encoding), + URLEncoder.encode(expiresOn, utf8Encoding), + URLEncoder.encode(keyName, utf8Encoding)); + } + + public String getToken(final String resource, final Duration tokenTimeToLive) throws IOException, InvalidKeyException, NoSuchAlgorithmException { + return this.sharedAccessSignature == null + ? generateSharedAccessSignature(this.keyName, this.sharedAccessKey, resource, tokenTimeToLive) + : this.sharedAccessSignature; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/StringUtil.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/StringUtil.java new file mode 100644 index 000000000000..e7da5dec9249 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/StringUtil.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import java.util.UUID; + +public final class StringUtil { + public final static String EMPTY = ""; + + public static boolean isNullOrEmpty(String string) { + return (string == null || string.isEmpty()); + } + + public static boolean isNullOrWhiteSpace(String string) { + if (string == null) + return true; + + for (int index = 0; index < string.length(); index++) { + if (!Character.isWhitespace(string.charAt(index))) { + return false; + } + } + + return true; + } + + public static String getRandomString() { + return UUID.randomUUID().toString().substring(0, 6); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/TimeoutTracker.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/TimeoutTracker.java new file mode 100644 index 000000000000..255fef69c915 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/TimeoutTracker.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import java.time.*; + +public class TimeoutTracker { + private final Duration originalTimeout; + private boolean isTimerStarted; + private Instant startTime; + + /** + * @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()} + */ + public 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; + } + + public static TimeoutTracker create(Duration timeout) { + return new TimeoutTracker(timeout, true); + } + + public Duration remaining() { + return this.originalTimeout.minus(this.elapsed()); + } + + public Duration elapsed() { + if (!this.isTimerStarted) { + this.startTime = Instant.now(); + this.isTimerStarted = true; + } + + return Duration.between(this.startTime, Instant.now()); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/Timer.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/Timer.java new file mode 100644 index 000000000000..a1cf86553732 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/Timer.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; + +final class Timer { + + final SchedulerProvider schedulerProvider; + + public Timer(final SchedulerProvider schedulerProvider) { + this.schedulerProvider = schedulerProvider; + } + + public CompletableFuture schedule( + final Runnable runnable, + final Duration runAfter) { + + final ScheduledTask scheduledTask = new ScheduledTask(runnable); + final CompletableFuture taskHandle = scheduledTask.getScheduledFuture(); + try { + this.schedulerProvider.getReactorDispatcher().invoke((int) runAfter.toMillis(), scheduledTask); + } catch (IOException | RejectedExecutionException e) { + taskHandle.completeExceptionally(e); + } + + return taskHandle; + } + + final static class ScheduledTask extends DispatchHandler { + + final CompletableFuture scheduledFuture; + final Runnable runnable; + + public ScheduledTask(final Runnable runnable) { + this.runnable = runnable; + this.scheduledFuture = new CompletableFuture<>(); + } + + @Override + public void onEvent() { + if (!scheduledFuture.isCancelled()) { + try { + runnable.run(); + scheduledFuture.complete(null); + } catch (Exception exception) { + scheduledFuture.completeExceptionally(exception); + } + } + } + + public CompletableFuture getScheduledFuture() { + return this.scheduledFuture; + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/TrackingUtil.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/TrackingUtil.java new file mode 100644 index 000000000000..cc20776589d3 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/TrackingUtil.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import org.apache.qpid.proton.engine.Session; + +import java.time.Instant; + +public final class TrackingUtil { + public static final String TRACKING_ID_TOKEN_SEPARATOR = "_"; + + private TrackingUtil() { + } + + /** + * parses ServiceBus role identifiers from trackingId + * + * @return null if no roleIdentifier found + */ + static String parseRoleIdentifier(final String trackingId) { + if (StringUtil.isNullOrWhiteSpace(trackingId) || !trackingId.contains(TRACKING_ID_TOKEN_SEPARATOR)) { + return null; + } + + return trackingId.substring(trackingId.indexOf(TRACKING_ID_TOKEN_SEPARATOR)); + } + + public static String getLinkName(final Session session) { + // returned linkName lookslike: ea9cac_8b_G27_1479943074829 + final String linkNamePrefix = StringUtil.getRandomString(); + final String linkNameWithServiceRoleTracker = session.getConnection() != null && !StringUtil.isNullOrEmpty(session.getConnection().getRemoteContainer()) ? + linkNamePrefix.concat(TrackingUtil.TRACKING_ID_TOKEN_SEPARATOR).concat(session.getConnection().getRemoteContainer() + .substring(Math.max(session.getConnection().getRemoteContainer().length() - 7, 0), session.getConnection().getRemoteContainer().length())) : + linkNamePrefix; + return linkNameWithServiceRoleTracker.concat(TrackingUtil.TRACKING_ID_TOKEN_SEPARATOR).concat(String.valueOf(Instant.now().toEpochMilli())); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/WebSocketConnectionHandler.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/WebSocketConnectionHandler.java new file mode 100644 index 000000000000..b8ab9484b012 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/WebSocketConnectionHandler.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.proton.transport.ws.impl.WebSocketImpl; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.engine.impl.TransportInternal; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class WebSocketConnectionHandler extends ConnectionHandler { + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(WebSocketConnectionHandler.class); + + public WebSocketConnectionHandler(AmqpConnection amqpConnection) { + super(amqpConnection); + } + + @Override + protected void addTransportLayers(final Event event, final TransportInternal transport) { + final String hostName = event.getConnection().getHostname(); + + final WebSocketImpl webSocket = new WebSocketImpl(); + webSocket.configure( + hostName, + "/$servicebus/websocket", + "", + 0, + "AMQPWSB10", + null, + null); + + transport.addTransportLayer(webSocket); + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info("addWebsocketHandshake: hostname[" + hostName +"]"); + } + + super.addTransportLayers(event, transport); + } + + @Override + protected int getProtocolPort() { + return ClientConstants.HTTPS_PORT; + } + + @Override + protected int getMaxFrameSize() { + // This is the current limitation of https://github.com/Azure/qpid-proton-j-extensions + // once, this library enables larger frames - this property can be removed. + return 4 * 1024; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/WebSocketProxyConnectionHandler.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/WebSocketProxyConnectionHandler.java new file mode 100644 index 000000000000..41dc0baa3d04 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/WebSocketProxyConnectionHandler.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import com.microsoft.azure.proton.transport.proxy.ProxyHandler; +import com.microsoft.azure.proton.transport.proxy.impl.ProxyHandlerImpl; +import com.microsoft.azure.proton.transport.proxy.impl.ProxyImpl; + +import org.apache.qpid.proton.amqp.transport.ConnectionError; +import org.apache.qpid.proton.amqp.transport.ErrorCondition; +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.engine.impl.TransportInternal; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.Authenticator; +import java.net.InetSocketAddress; +import java.net.PasswordAuthentication; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.URI; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class WebSocketProxyConnectionHandler extends WebSocketConnectionHandler { + private static final Logger TRACE_LOGGER = LoggerFactory.getLogger(WebSocketProxyConnectionHandler.class); + private final String proxySelectorModifiedError = "ProxySelector has been modified."; + + public static Boolean shouldUseProxy(final String hostName) { + final URI uri = createURIFromHostNamePort(hostName, ClientConstants.HTTPS_PORT); + final ProxySelector proxySelector = ProxySelector.getDefault(); + if (proxySelector == null) { + return false; + } + + final List proxies = proxySelector.select(uri); + return isProxyAddressLegal(proxies); + } + + public WebSocketProxyConnectionHandler(AmqpConnection amqpConnection) { + super(amqpConnection); + } + + @Override + protected void addTransportLayers(final Event event, final TransportInternal transport) { + super.addTransportLayers(event, transport); + + final ProxyImpl proxy = new ProxyImpl(); + + // host name used to create proxy connect request + // after creating the socket to proxy + final String hostName = event.getConnection().getHostname(); + final ProxyHandler proxyHandler = new ProxyHandlerImpl(); + final Map proxyHeader = getAuthorizationHeader(); + proxy.configure(hostName, proxyHeader, proxyHandler, transport); + + transport.addTransportLayer(proxy); + + if (TRACE_LOGGER.isInfoEnabled()) { + TRACE_LOGGER.info("addProxyHandshake: hostname[" + hostName +"]"); + } + } + + @Override + protected void notifyTransportErrors(final Event event) { + final Transport transport = event.getTransport(); + final Connection connection = event.getConnection(); + if (connection == null || transport == null) { + return; + } + + final ErrorCondition errorCondition = transport.getCondition(); + final String hostName = event.getReactor().getConnectionAddress(connection); + final ProxySelector proxySelector = ProxySelector.getDefault(); + + if (errorCondition == null + || !(errorCondition.getCondition().equals(ConnectionError.FRAMING_ERROR) + || errorCondition.getCondition().equals(AmqpErrorCode.PROTON_IO_ERROR)) + || proxySelector == null + || StringUtil.isNullOrEmpty(hostName)) { + return; + } + + final String[] hostNameParts = hostName.split(":"); + if (hostNameParts.length != 2) { + return; + } + + int port; + try { + port = Integer.parseInt(hostNameParts[1]); + } catch (NumberFormatException ignore){ + return; + } + + final IOException ioException = reconstructIOException(errorCondition); + proxySelector.connectFailed( + createURIFromHostNamePort(this.getAmqpConnection().getHostName(), this.getProtocolPort()), + new InetSocketAddress(hostNameParts[0], port), + ioException); + } + + @Override + public String getRemoteHostName() { + final InetSocketAddress socketAddress = getProxyAddress(); + return socketAddress.getHostString(); + } + + @Override + public int getRemotePort() { + final InetSocketAddress socketAddress = getProxyAddress(); + return socketAddress.getPort(); + } + + private Map getAuthorizationHeader() { + final PasswordAuthentication authentication = Authenticator.requestPasswordAuthentication( + getRemoteHostName(), + null, + getRemotePort(), + null, + null, + "http", + null, + Authenticator.RequestorType.PROXY); + if (authentication == null) { + return null; + } + + final String proxyUserName = authentication.getUserName(); + final String proxyPassword = authentication.getPassword() != null + ? new String(authentication.getPassword()) + : null; + if (StringUtil.isNullOrEmpty(proxyUserName) + || StringUtil.isNullOrEmpty(proxyPassword)) { + return null; + } + + final HashMap proxyAuthorizationHeader = new HashMap<>(); + // https://tools.ietf.org/html/rfc7617 + final String usernamePasswordPair = proxyUserName + ":" + proxyPassword; + proxyAuthorizationHeader.put( + "Proxy-Authorization", + "Basic " + Base64.getEncoder().encodeToString(usernamePasswordPair.getBytes())); + return proxyAuthorizationHeader; + } + + private InetSocketAddress getProxyAddress() { + final URI serviceUri = createURIFromHostNamePort( + this.getAmqpConnection().getHostName(), + this.getProtocolPort()); + final ProxySelector proxySelector = ProxySelector.getDefault(); + if (proxySelector == null) { + throw new IllegalStateException(proxySelectorModifiedError); + } + + final List proxies = proxySelector.select(serviceUri); + if (!isProxyAddressLegal(proxies)) { + throw new IllegalStateException(proxySelectorModifiedError); + } + + final Proxy proxy = proxies.get(0); + return (InetSocketAddress) proxy.address(); + } + + private static URI createURIFromHostNamePort(final String hostName, final int port) { + return URI.create(String.format(ClientConstants.HTTPS_URI_FORMAT, hostName, port)); + } + + private static boolean isProxyAddressLegal(final List proxies) { + // we look only at the first proxy in the list + // if the proxy can be translated to InetSocketAddress + // only then - can we parse it to hostName and Port + // which is required by qpid-proton-j library reactor.connectToHost() API + return proxies != null + && !proxies.isEmpty() + && proxies.get(0).type() == Proxy.Type.HTTP + && proxies.get(0).address() != null + && proxies.get(0).address() instanceof InetSocketAddress; + } + + private static IOException reconstructIOException(ErrorCondition errorCondition) { + // since proton library communicates all errors based on amqp-error-condition + // it swallows the IOException and translates it to proton-io errorCode + // we reconstruct the IOException in this case - but, callstack is lost + return new IOException(errorCondition.getDescription()); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/WorkItem.java b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/WorkItem.java new file mode 100644 index 000000000000..dc6cd3950f8a --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/main/java/com/microsoft/azure/eventhubs/impl/WorkItem.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import java.time.*; +import java.util.concurrent.*; + +public class WorkItem { + private final TimeoutTracker tracker; + private final CompletableFuture work; + + public WorkItem(final CompletableFuture completableFuture, final Duration timeout) { + this(completableFuture, TimeoutTracker.create(timeout)); + } + + public WorkItem(final CompletableFuture completableFuture, final TimeoutTracker tracker) { + this.work = completableFuture; + this.tracker = tracker; + } + + public TimeoutTracker getTimeoutTracker() { + return this.tracker; + } + + public CompletableFuture getWork() { + return this.work; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/concurrency/ConcurrentReceiversTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/concurrency/ConcurrentReceiversTest.java new file mode 100644 index 000000000000..4e54c0b4cd6d --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/concurrency/ConcurrentReceiversTest.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.concurrency; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +public class ConcurrentReceiversTest extends ApiTestBase { + static EventHubClient sender; + static PartitionReceiver[] receivers; + static EventHubClient ehClient; + static String consumerGroupName; + static ConnectionStringBuilder connStr; + static int partitionCount; + + int eventSentPerPartition = 1; + + @BeforeClass + public static void initialize() throws InterruptedException, ExecutionException, EventHubException, IOException { + connStr = TestContext.getConnectionString(); + + sender = EventHubClient.create(connStr.toString(), TestContext.EXECUTOR_SERVICE).get(); + partitionCount = sender.getRuntimeInformation().get().getPartitionCount(); + receivers = new PartitionReceiver[partitionCount]; + consumerGroupName = TestContext.getConsumerGroupName(); + } + + @AfterClass() + public static void cleanup() { + if (sender != null) { + sender.close(); + } + } + + @Test() + public void testParallelCreationOfReceivers() throws EventHubException, IOException, InterruptedException, ExecutionException, TimeoutException { + ehClient = EventHubClient.createSync(connStr.toString(), TestContext.EXECUTOR_SERVICE); + ReceiveAtleastOneEventValidator[] counter = new ReceiveAtleastOneEventValidator[partitionCount]; + + @SuppressWarnings("unchecked") CompletableFuture[] validationSignals = new CompletableFuture[partitionCount]; + @SuppressWarnings("unchecked") CompletableFuture[] receiverFutures = new CompletableFuture[partitionCount]; + for (int i = 0; i < partitionCount; i++) { + final int index = i; + receiverFutures[i] = ehClient.createReceiver(consumerGroupName, Integer.toString(i), EventPosition.fromEnqueuedTime(Instant.now())).thenAcceptAsync( + new Consumer() { + @Override + public void accept(final PartitionReceiver t) { + receivers[index] = t; + receivers[index].setReceiveTimeout(Duration.ofMillis(400)); + validationSignals[index] = new CompletableFuture(); + counter[index] = new ReceiveAtleastOneEventValidator(validationSignals[index], receivers[index]); + receivers[index].setReceiveHandler(counter[index]); + } + }); + } + + CompletableFuture.allOf(receiverFutures).get(partitionCount * 10, TimeUnit.SECONDS); + + @SuppressWarnings("unchecked") + CompletableFuture[] sendFutures = new CompletableFuture[partitionCount]; + for (int i = 0; i < partitionCount; i++) { + sendFutures[i] = TestBase.pushEventsToPartition(sender, Integer.toString(i), eventSentPerPartition); + } + + CompletableFuture.allOf(sendFutures).get(); + + CompletableFuture.allOf(validationSignals).get(partitionCount * 10, TimeUnit.SECONDS); + } + + @After + public void cleanupTest() { + for (int i = 0; i < partitionCount; i++) { + if (receivers[i] != null) { + receivers[i].close(); + } + } + + if (ehClient != null) { + ehClient.close(); + } + } + + public static final class ReceiveAtleastOneEventValidator implements PartitionReceiveHandler { + final CompletableFuture signalReceived; + final PartitionReceiver currentReceiver; + + public ReceiveAtleastOneEventValidator(final CompletableFuture signalReceived, final PartitionReceiver currentReceiver) { + this.signalReceived = signalReceived; + this.currentReceiver = currentReceiver; + } + + @Override + public int getMaxEventCount() { + return 50; + } + + @Override + public void onReceive(Iterable events) { + if (events != null && events.iterator().hasNext()) { + this.signalReceived.complete(null); + this.currentReceiver.setReceiveHandler(null); + } + } + + @Override + public void onError(Throwable error) { + this.signalReceived.completeExceptionally(error); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/concurrency/EventHubClientTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/concurrency/EventHubClientTest.java new file mode 100644 index 000000000000..c292d6a51ee9 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/concurrency/EventHubClientTest.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.concurrency; + +import com.microsoft.azure.eventhubs.ConnectionStringBuilder; +import com.microsoft.azure.eventhubs.EventHubClient; +import com.microsoft.azure.eventhubs.EventPosition; +import com.microsoft.azure.eventhubs.PartitionReceiver; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.Assert; +import org.junit.Test; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +public class EventHubClientTest extends ApiTestBase { + + @Test() + public void testParallelEventHubClients() throws Exception { + + final String consumerGroupName = TestContext.getConsumerGroupName(); + final String partitionId = "0"; + final int noOfClients = 4; + final ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1); + + @SuppressWarnings("unchecked") + CompletableFuture[] createFutures = new CompletableFuture[noOfClients]; + try { + ConnectionStringBuilder connectionString = TestContext.getConnectionString(); + for (int i = 0; i < noOfClients; i++) { + createFutures[i] = EventHubClient.create(connectionString.toString(), executorService); + } + + CompletableFuture.allOf(createFutures).get(); + boolean firstOne = true; + for (CompletableFuture createFuture : createFutures) { + final EventHubClient ehClient = createFuture.join(); + if (firstOne) { + TestBase.pushEventsToPartition(ehClient, partitionId, 10).get(); + firstOne = false; + } + + PartitionReceiver receiver = ehClient.createReceiverSync(consumerGroupName, partitionId, EventPosition.fromStartOfStream()); + try { + Assert.assertTrue(receiver.receiveSync(100).iterator().hasNext()); + } finally { + receiver.closeSync(); + } + } + } finally { + if (createFutures != null) { + for (CompletableFuture createFuture : createFutures) { + if (!createFuture.isCancelled() || !createFuture.isCompletedExceptionally()) { + EventHubClient ehClient = createFuture.join(); + ehClient.closeSync(); + } + } + } + + executorService.shutdown(); + } + } + +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/connstrbuilder/ConnStrBuilderTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/connstrbuilder/ConnStrBuilderTest.java new file mode 100644 index 000000000000..4649b88628c2 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/connstrbuilder/ConnStrBuilderTest.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.connstrbuilder; + +import com.microsoft.azure.eventhubs.ConnectionStringBuilder; +import com.microsoft.azure.eventhubs.IllegalConnectionStringFormatException; +import com.microsoft.azure.eventhubs.TransportType; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import org.junit.Assert; +import org.junit.Test; + +import java.time.Duration; +import java.util.function.Consumer; + +public class ConnStrBuilderTest extends ApiTestBase { + static final String correctConnectionString = "Endpoint=sb://endpoint1;EntityPath=eventhub1;SharedAccessKeyName=somevalue;SharedAccessKey=something;OperationTimeout=PT5S;TransportType=AMQP"; + static final Consumer validateConnStrBuilder = new Consumer() { + @Override + public void accept(ConnectionStringBuilder connStrBuilder) { + Assert.assertTrue(connStrBuilder.getEventHubName().equals("eventhub1")); + Assert.assertTrue(connStrBuilder.getEndpoint().getHost().equals("endpoint1")); + Assert.assertTrue(connStrBuilder.getSasKey().equals("something")); + Assert.assertTrue(connStrBuilder.getSasKeyName().equals("somevalue")); + Assert.assertTrue(connStrBuilder.getTransportType() == TransportType.AMQP); + Assert.assertTrue(connStrBuilder.getOperationTimeout().equals(Duration.ofSeconds(5))); + } + }; + + @Test(expected = IllegalConnectionStringFormatException.class) + public void parseInvalidConnectionString() { + new ConnectionStringBuilder("something"); + } + + @Test(expected = IllegalConnectionStringFormatException.class) + public void throwOnUnrecognizedParts() { + new ConnectionStringBuilder(correctConnectionString + ";" + "something"); + } + + @Test(expected = IllegalConnectionStringFormatException.class) + public void throwOnInvalidTransportType() { + ConnectionStringBuilder connectionStringBuilder = new ConnectionStringBuilder(correctConnectionString); + String connectionStringWithTransportType = connectionStringBuilder.setTransportType(TransportType.AMQP_WEB_SOCKETS).toString(); + String connectionStringWithInvalidTransportType = connectionStringWithTransportType.replace(TransportType.AMQP_WEB_SOCKETS.toString(), "invalid"); + new ConnectionStringBuilder(connectionStringWithInvalidTransportType); + } + + @Test + public void parseValidConnectionString() { + final ConnectionStringBuilder connStrBuilder = new ConnectionStringBuilder(correctConnectionString); + validateConnStrBuilder.accept(connStrBuilder); + } + + @Test + public void exchangeConnectionStringAcrossConstructors() { + final ConnectionStringBuilder connStrBuilder = new ConnectionStringBuilder(correctConnectionString); + final ConnectionStringBuilder secondConnStr = new ConnectionStringBuilder() + .setEndpoint(connStrBuilder.getEndpoint()) + .setEventHubName(connStrBuilder.getEventHubName()) + .setSasKeyName(connStrBuilder.getSasKeyName()) + .setSasKey(connStrBuilder.getSasKey()) + .setTransportType(connStrBuilder.getTransportType()) + .setOperationTimeout(connStrBuilder.getOperationTimeout()); + + validateConnStrBuilder.accept(new ConnectionStringBuilder(secondConnStr.toString())); + } + + @Test + public void testPropertySetters() { + final ConnectionStringBuilder connStrBuilder = new ConnectionStringBuilder(correctConnectionString); + final ConnectionStringBuilder testConnStrBuilder = new ConnectionStringBuilder(connStrBuilder.toString()); + validateConnStrBuilder.accept(testConnStrBuilder); + + connStrBuilder.setOperationTimeout(Duration.ofSeconds(8)); + connStrBuilder.setTransportType(TransportType.AMQP_WEB_SOCKETS); + + ConnectionStringBuilder testConnStrBuilder1 = new ConnectionStringBuilder(connStrBuilder.toString()); + Assert.assertTrue(testConnStrBuilder1.getOperationTimeout().getSeconds() == 8); + Assert.assertTrue(testConnStrBuilder1.getTransportType() == TransportType.AMQP_WEB_SOCKETS); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/connstrbuilder/TransportTypeTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/connstrbuilder/TransportTypeTest.java new file mode 100644 index 000000000000..68eb7fb8d9c8 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/connstrbuilder/TransportTypeTest.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.connstrbuilder; + +import com.microsoft.azure.eventhubs.ConnectionStringBuilder; +import com.microsoft.azure.eventhubs.EventHubClient; +import com.microsoft.azure.eventhubs.TransportType; +import com.microsoft.azure.eventhubs.impl.ConnectionHandler; +import com.microsoft.azure.eventhubs.impl.EventHubClientImpl; +import com.microsoft.azure.eventhubs.impl.MessagingFactory; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; + +import org.jutils.jproxy.ProxyServer; +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.*; +import java.util.LinkedList; +import java.util.List; + +public class TransportTypeTest extends ApiTestBase { + + public volatile boolean isProxySelectorInvoked = false; + + @Test + public void transportTypeAmqpCreatesConnectionWithPort5671() throws Exception { + ConnectionStringBuilder builder = new ConnectionStringBuilder(TestContext.getConnectionString().toString()); + builder.setTransportType(TransportType.AMQP); + + EventHubClient ehClient = EventHubClient.createSync(builder.toString(), TestContext.EXECUTOR_SERVICE); + try { + EventHubClientImpl eventHubClientImpl = (EventHubClientImpl) ehClient; + final Field factoryField = EventHubClientImpl.class.getDeclaredField("underlyingFactory"); + factoryField.setAccessible(true); + final MessagingFactory underlyingFactory = (MessagingFactory) factoryField.get(eventHubClientImpl); + + final Field connectionHandlerField = MessagingFactory.class.getDeclaredField("connectionHandler"); + connectionHandlerField.setAccessible(true); + final ConnectionHandler connectionHandler = (ConnectionHandler) connectionHandlerField.get(underlyingFactory); + + final Method outboundSocketPort = ConnectionHandler.class.getDeclaredMethod("getRemotePort"); + outboundSocketPort.setAccessible(true); + + final Method protocolPort = ConnectionHandler.class.getDeclaredMethod("getProtocolPort"); + protocolPort.setAccessible(true); + + Assert.assertEquals(5671, outboundSocketPort.invoke(connectionHandler)); + Assert.assertEquals(5671, protocolPort.invoke(connectionHandler)); + } finally { + ehClient.closeSync(); + } + } + + @Test + public void transportTypeAmqpWebSocketsCreatesConnectionWithPort443() throws Exception { + ConnectionStringBuilder builder = new ConnectionStringBuilder(TestContext.getConnectionString().toString()); + builder.setTransportType(TransportType.AMQP_WEB_SOCKETS); + + EventHubClient ehClient = EventHubClient.createSync(builder.toString(), TestContext.EXECUTOR_SERVICE); + try { + EventHubClientImpl eventHubClientImpl = (EventHubClientImpl) ehClient; + final Field factoryField = EventHubClientImpl.class.getDeclaredField("underlyingFactory"); + factoryField.setAccessible(true); + final MessagingFactory underlyingFactory = (MessagingFactory) factoryField.get(eventHubClientImpl); + + final Field connectionHandlerField = MessagingFactory.class.getDeclaredField("connectionHandler"); + connectionHandlerField.setAccessible(true); + final ConnectionHandler connectionHandler = (ConnectionHandler) connectionHandlerField.get(underlyingFactory); + + final Method outboundSocketPort = ConnectionHandler.class.getDeclaredMethod("getRemotePort"); + outboundSocketPort.setAccessible(true); + + final Method protocolPort = ConnectionHandler.class.getDeclaredMethod("getProtocolPort"); + protocolPort.setAccessible(true); + + Assert.assertEquals(443, outboundSocketPort.invoke(connectionHandler)); + Assert.assertEquals(443, protocolPort.invoke(connectionHandler)); + } finally { + ehClient.closeSync(); + } + } + + @Test + public void transportTypeAmqpWebSocketsWithProxyCreatesConnectionWithCorrectPorts() throws Exception { + int proxyPort = 8899; + ProxyServer proxyServer = ProxyServer.create("localhost", proxyPort); + proxyServer.start(throwable -> {}); + + ProxySelector defaultProxySelector = ProxySelector.getDefault(); + this.isProxySelectorInvoked = false; + try { + ProxySelector.setDefault(new ProxySelector() { + @Override + public List select(URI uri) { + LinkedList proxies = new LinkedList<>(); + proxies.add(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost", proxyPort))); + isProxySelectorInvoked = true; + return proxies; + } + + @Override + public void connectFailed(URI uri, SocketAddress sa, IOException ioe) { + // no-op + } + }); + + ConnectionStringBuilder builder = new ConnectionStringBuilder(TestContext.getConnectionString().toString()); + builder.setTransportType(TransportType.AMQP_WEB_SOCKETS); + + EventHubClient ehClient = EventHubClient.createSync(builder.toString(), TestContext.EXECUTOR_SERVICE); + try { + EventHubClientImpl eventHubClientImpl = (EventHubClientImpl) ehClient; + final Field factoryField = EventHubClientImpl.class.getDeclaredField("underlyingFactory"); + factoryField.setAccessible(true); + final MessagingFactory underlyingFactory = (MessagingFactory) factoryField.get(eventHubClientImpl); + + final Field connectionHandlerField = MessagingFactory.class.getDeclaredField("connectionHandler"); + connectionHandlerField.setAccessible(true); + final ConnectionHandler connectionHandler = (ConnectionHandler) connectionHandlerField.get(underlyingFactory); + + final Method outboundSocketPort = ConnectionHandler.class.getDeclaredMethod("getRemotePort"); + outboundSocketPort.setAccessible(true); + + final Method protocolPort = ConnectionHandler.class.getDeclaredMethod("getProtocolPort"); + protocolPort.setAccessible(true); + + Assert.assertEquals(proxyPort, outboundSocketPort.invoke(connectionHandler)); + Assert.assertEquals(443, protocolPort.invoke(connectionHandler)); + + Assert.assertTrue(isProxySelectorInvoked); + } finally { + ehClient.closeSync(); + ProxySelector.setDefault(defaultProxySelector); + } + } finally { + proxyServer.stop(); + } + } +} \ No newline at end of file diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/eventdata/BackCompatTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/eventdata/BackCompatTest.java new file mode 100644 index 000000000000..c4f8fb496d08 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/eventdata/BackCompatTest.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.eventdata; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.impl.MessageSender; +import com.microsoft.azure.eventhubs.impl.MessagingFactory; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.amqp.Binary; +import org.apache.qpid.proton.amqp.messaging.ApplicationProperties; +import org.apache.qpid.proton.amqp.messaging.Data; +import org.apache.qpid.proton.message.Message; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.HashMap; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; + +public class BackCompatTest extends ApiTestBase { + static final String partitionId = "0"; + static final Message originalMessage = Proton.message(); + static final String applicationProperty = "firstProp"; + static final String intApplicationProperty = "intProp"; + static final String msgAnnotation = "message-annotation-1"; + static final String payload = "testmsg"; + static EventHubClient ehClient; + static MessagingFactory msgFactory; + static PartitionReceiver receiver; + static MessageSender partitionMsgSender; + static EventData receivedEvent; + + final Consumer validateAmqpPropertiesInEventData = new Consumer() { + @Override + public void accept(EventData eData) { + Assert.assertTrue(eData.getProperties().containsKey(applicationProperty) + && eData.getProperties().get(applicationProperty).equals(originalMessage.getApplicationProperties().getValue().get(applicationProperty))); + + Assert.assertTrue(eData.getProperties().containsKey(intApplicationProperty) + && eData.getProperties().get(intApplicationProperty).equals(originalMessage.getApplicationProperties().getValue().get(intApplicationProperty))); + + Assert.assertTrue(eData.getProperties().size() == 2); + + Assert.assertTrue(new String(eData.getBytes()).equals(payload)); + } + }; + + @BeforeClass + public static void initialize() throws EventHubException, IOException, InterruptedException, ExecutionException { + final ConnectionStringBuilder connStrBuilder = TestContext.getConnectionString(); + final String connectionString = connStrBuilder.toString(); + + ehClient = EventHubClient.createSync(connectionString, TestContext.EXECUTOR_SERVICE); + msgFactory = MessagingFactory.createFromConnectionString(connectionString, TestContext.EXECUTOR_SERVICE).get(); + receiver = ehClient.createReceiverSync(TestContext.getConsumerGroupName(), partitionId, EventPosition.fromEnqueuedTime(Instant.now())); + partitionMsgSender = MessageSender.create(msgFactory, "link1", connStrBuilder.getEventHubName() + "/partitions/" + partitionId).get(); + + // until version 0.10.0 - we used to have Properties as HashMap + // This specific combination is intended to test the back compat - with the new Properties type as HashMap + final HashMap appProperties = new HashMap<>(); + appProperties.put(applicationProperty, "value1"); + appProperties.put(intApplicationProperty, "3"); + // back compat end + + final ApplicationProperties applicationProperties = new ApplicationProperties(appProperties); + originalMessage.setApplicationProperties(applicationProperties); + + originalMessage.setBody(new Data(Binary.create(ByteBuffer.wrap(payload.getBytes())))); + + partitionMsgSender.send(originalMessage).get(); + receivedEvent = receiver.receiveSync(10).iterator().next(); + } + + @AfterClass + public static void cleanup() throws EventHubException { + if (partitionMsgSender != null) + partitionMsgSender.closeSync(); + + if (receiver != null) + receiver.closeSync(); + + if (ehClient != null) + ehClient.closeSync(); + + if (msgFactory != null) + msgFactory.closeSync(); + } + + @Test + public void backCompatWithJavaSDKOlderThan_0_11_0() { + validateAmqpPropertiesInEventData.accept(receivedEvent); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/eventdata/EventDataBatchTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/eventdata/EventDataBatchTest.java new file mode 100644 index 000000000000..744ed427359e --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/eventdata/EventDataBatchTest.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.eventdata; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.util.concurrent.Executors; + +public class EventDataBatchTest extends ApiTestBase { + + private static EventHubClient ehClient; + + @Test(expected = PayloadSizeExceededException.class) + public void payloadExceededException() throws EventHubException, IOException { + final ConnectionStringBuilder connStrBuilder = TestContext.getConnectionString(); + ehClient = EventHubClient.createSync(connStrBuilder.toString(), Executors.newScheduledThreadPool(1)); + + final EventDataBatch batch = ehClient.createBatch(); + + final EventData within = EventData.create(new byte[1024]); + final EventData tooBig = EventData.create(new byte[1024 * 1024 * 2]); + + Assert.assertTrue(batch.tryAdd(within)); + batch.tryAdd(tooBig); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/eventdata/EventDataTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/eventdata/EventDataTest.java new file mode 100644 index 000000000000..90d6998df41a --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/eventdata/EventDataTest.java @@ -0,0 +1,248 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.eventdata; + +import com.microsoft.azure.eventhubs.EventData; +import org.junit.Assert; +import org.junit.Test; + +import java.io.*; +import java.nio.ByteBuffer; + +public class EventDataTest { + final String payload = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; // even number of chars + + @Test(expected = IllegalArgumentException.class) + public void eventDataByteArrayNotNull() { + byte[] byteArray = null; + EventData.create(byteArray); + } + + @Test(expected = IllegalArgumentException.class) + public void eventDataByteArrayNotNullBuffer() { + final ByteBuffer buffer = null; + EventData.create(buffer); + } + + @Test(expected = IllegalArgumentException.class) + public void eventDataByteArrayNotNullConstructor2() { + EventData.create(null, 0, 0); + } + + @Test + public void eventDataEmptyByteArray() throws IOException, ClassNotFoundException { + byte[] byteArray = new byte[0]; + EventData deSerializedEvent = serializeAndDeserialize(EventData.create(byteArray)); + Assert.assertEquals(deSerializedEvent.getBytes().length, 0); + Assert.assertTrue(deSerializedEvent.getBytes() != null); + } + + @Test + public void eventDataSerializationTest() throws IOException, ClassNotFoundException { + final EventData withSimpleByteArray = EventData.create(payload.getBytes()); + EventData deSerializedEvent = serializeAndDeserialize(withSimpleByteArray); + Assert.assertTrue(payload.equals(new String(deSerializedEvent.getBytes()))); + } + + @Test + public void eventDataSerializationTestConstWithOffsetAndLength() throws IOException, ClassNotFoundException { + final ByteArrayOutputStream payloadStream = new ByteArrayOutputStream(); + payloadStream.write(payload.getBytes()); + payloadStream.write(payload.getBytes()); + payloadStream.close(); + + final EventData withByteArrayAndOffset = EventData.create(payloadStream.toByteArray(), payloadStream.size() / 2, payloadStream.size() / 2); + final EventData deSerializedEvent = serializeAndDeserialize(withByteArrayAndOffset); + Assert.assertTrue(payload.equals(new String(deSerializedEvent.getBytes()))); + } + + @Test + public void eventDataSerializationTestConstWithByteBuffer() throws IOException, ClassNotFoundException { + final ByteArrayOutputStream payloadStream = new ByteArrayOutputStream(); + payloadStream.write(payload.getBytes()); + payloadStream.write(payload.getBytes()); + payloadStream.close(); + + final EventData withByteBuffer = EventData.create(ByteBuffer.wrap(payloadStream.toByteArray(), payloadStream.size() / 2, payloadStream.size() / 2)); + final EventData deSerializedEvent = serializeAndDeserialize(withByteBuffer); + Assert.assertTrue(payload.equals(new String(deSerializedEvent.getBytes()))); + } + + @Test + public void sendingEventsSysPropsShouldBeNull() { + Assert.assertTrue(EventData.create("Test".getBytes()).getSystemProperties() == null); + } + + private EventData serializeAndDeserialize(final EventData input) throws IOException, ClassNotFoundException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos); + oos.writeObject(input); + oos.close(); + + ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bais); + final EventData deSerializedEvent = (EventData) ois.readObject(); + ois.close(); + + return deSerializedEvent; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/eventdata/InteropAmqpPropertiesTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/eventdata/InteropAmqpPropertiesTest.java new file mode 100755 index 000000000000..7a2aa90bfb12 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/eventdata/InteropAmqpPropertiesTest.java @@ -0,0 +1,195 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.eventdata; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.impl.*; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +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 org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.HashMap; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; + +public class InteropAmqpPropertiesTest extends ApiTestBase { + static final String partitionId = "0"; + static final Message originalMessage = Proton.message(); + static final String applicationProperty = "firstProp"; + static final String msgAnnotation = "message-annotation-1"; + static final String payload = "testmsg"; + static EventHubClient ehClient; + static MessagingFactory msgFactory; + static PartitionReceiver receiver; + static MessageReceiver msgReceiver; + static MessageSender partitionMsgSender; + static PartitionSender partitionEventSender; + static EventData receivedEvent; + static EventData reSentAndReceivedEvent; + static Message reSendAndReceivedMessage; + + final Consumer validateAmqpPropertiesInEventData = new Consumer() { + @Override + public void accept(EventData eData) { + Assert.assertTrue(eData.getSystemProperties().containsKey(AmqpConstants.AMQP_PROPERTY_MESSAGE_ID) + && eData.getSystemProperties().get(AmqpConstants.AMQP_PROPERTY_MESSAGE_ID).equals(originalMessage.getMessageId())); + Assert.assertTrue(eData.getSystemProperties().containsKey(AmqpConstants.AMQP_PROPERTY_USER_ID) + && new String((byte[]) eData.getSystemProperties().get(AmqpConstants.AMQP_PROPERTY_USER_ID)).equals(new String(originalMessage.getUserId()))); + Assert.assertTrue(eData.getSystemProperties().containsKey(AmqpConstants.AMQP_PROPERTY_TO) + && eData.getSystemProperties().get(AmqpConstants.AMQP_PROPERTY_TO).equals(originalMessage.getAddress())); + Assert.assertTrue(eData.getSystemProperties().containsKey(AmqpConstants.AMQP_PROPERTY_CONTENT_TYPE) + && eData.getSystemProperties().get(AmqpConstants.AMQP_PROPERTY_CONTENT_TYPE).equals(originalMessage.getContentType())); + Assert.assertTrue(eData.getSystemProperties().containsKey(AmqpConstants.AMQP_PROPERTY_CONTENT_ENCODING) + && eData.getSystemProperties().get(AmqpConstants.AMQP_PROPERTY_CONTENT_ENCODING).equals(originalMessage.getContentEncoding())); + Assert.assertTrue(eData.getSystemProperties().containsKey(AmqpConstants.AMQP_PROPERTY_CORRELATION_ID) + && eData.getSystemProperties().get(AmqpConstants.AMQP_PROPERTY_CORRELATION_ID).equals(originalMessage.getCorrelationId())); + Assert.assertTrue(eData.getSystemProperties().containsKey(AmqpConstants.AMQP_PROPERTY_CREATION_TIME) + && eData.getSystemProperties().get(AmqpConstants.AMQP_PROPERTY_CREATION_TIME).equals(originalMessage.getCreationTime())); + Assert.assertTrue(eData.getSystemProperties().containsKey(AmqpConstants.AMQP_PROPERTY_SUBJECT) + && eData.getSystemProperties().get(AmqpConstants.AMQP_PROPERTY_SUBJECT).equals(originalMessage.getSubject())); + Assert.assertTrue(eData.getSystemProperties().containsKey(AmqpConstants.AMQP_PROPERTY_GROUP_ID) + && eData.getSystemProperties().get(AmqpConstants.AMQP_PROPERTY_GROUP_ID).equals(originalMessage.getGroupId())); + Assert.assertTrue(eData.getSystemProperties().containsKey(AmqpConstants.AMQP_PROPERTY_REPLY_TO_GROUP_ID) + && eData.getSystemProperties().get(AmqpConstants.AMQP_PROPERTY_REPLY_TO_GROUP_ID).equals(originalMessage.getReplyToGroupId())); + Assert.assertTrue(eData.getSystemProperties().containsKey(AmqpConstants.AMQP_PROPERTY_REPLY_TO) + && eData.getSystemProperties().get(AmqpConstants.AMQP_PROPERTY_REPLY_TO).equals(originalMessage.getReplyTo())); + Assert.assertTrue(eData.getSystemProperties().containsKey(AmqpConstants.AMQP_PROPERTY_ABSOLUTE_EXPRITY_TIME) + && eData.getSystemProperties().get(AmqpConstants.AMQP_PROPERTY_ABSOLUTE_EXPRITY_TIME).equals(originalMessage.getExpiryTime())); + + Assert.assertTrue(eData.getSystemProperties().containsKey(msgAnnotation) + && eData.getSystemProperties().get(msgAnnotation).equals(originalMessage.getMessageAnnotations().getValue().get(Symbol.getSymbol(msgAnnotation)))); + + Assert.assertTrue(eData.getProperties().containsKey(applicationProperty) + && eData.getProperties().get(applicationProperty).equals(originalMessage.getApplicationProperties().getValue().get(applicationProperty))); + + Assert.assertTrue(eData.getProperties().size() == 1); + + Assert.assertTrue(new String(eData.getBytes()).equals(payload)); + } + }; + + @BeforeClass + public static void initialize() throws EventHubException, IOException, InterruptedException, ExecutionException { + final ConnectionStringBuilder connStrBuilder = TestContext.getConnectionString(); + final String connectionString = connStrBuilder.toString(); + + ehClient = EventHubClient.createSync(connectionString, TestContext.EXECUTOR_SERVICE); + msgFactory = MessagingFactory.createFromConnectionString(connectionString, TestContext.EXECUTOR_SERVICE).get(); + receiver = ehClient.createReceiverSync(TestContext.getConsumerGroupName(), partitionId, EventPosition.fromEnqueuedTime(Instant.now())); + partitionMsgSender = MessageSender.create(msgFactory, "link1", connStrBuilder.getEventHubName() + "/partitions/" + partitionId).get(); + partitionEventSender = ehClient.createPartitionSenderSync(partitionId); + + final HashMap appProperties = new HashMap<>(); + appProperties.put(applicationProperty, "value1"); + final ApplicationProperties applicationProperties = new ApplicationProperties(appProperties); + originalMessage.setApplicationProperties(applicationProperties); + + originalMessage.setMessageId("id1"); + originalMessage.setUserId("user1".getBytes()); + originalMessage.setAddress("eventhub1"); + originalMessage.setSubject("sub"); + originalMessage.setReplyTo("replyingTo"); + originalMessage.setExpiryTime(456L); + originalMessage.setGroupSequence(5555L); + originalMessage.setContentType("events"); + originalMessage.setContentEncoding("UTF-8"); + originalMessage.setCorrelationId("corid1"); + originalMessage.setCreationTime(345L); + originalMessage.setGroupId("gid"); + originalMessage.setReplyToGroupId("replyToGroupId"); + + originalMessage.setMessageAnnotations(new MessageAnnotations(new HashMap<>())); + originalMessage.getMessageAnnotations().getValue().put(Symbol.getSymbol(msgAnnotation), "messageAnnotationValue"); + + originalMessage.setBody(new Data(Binary.create(ByteBuffer.wrap(payload.getBytes())))); + + partitionMsgSender.send(originalMessage).get(); + receivedEvent = receiver.receiveSync(10).iterator().next(); + + partitionEventSender.sendSync(receivedEvent); + reSentAndReceivedEvent = receiver.receiveSync(10).iterator().next(); + + partitionEventSender.sendSync(reSentAndReceivedEvent); + msgReceiver = MessageReceiver.create( + msgFactory, + "receiver1", + connStrBuilder.getEventHubName() + "/ConsumerGroups/" + TestContext.getConsumerGroupName() + "/Partitions/" + partitionId, + 100, + (ReceiverSettingsProvider) ehClient.createReceiver(TestContext.getConsumerGroupName(), partitionId, EventPosition.fromOffset(reSentAndReceivedEvent.getSystemProperties().getOffset(), false)).get()).get(); + + reSendAndReceivedMessage = msgReceiver.receive(10).get().iterator().next(); + } + + @AfterClass + public static void cleanup() throws EventHubException { + if (msgReceiver != null) + msgReceiver.closeSync(); + + if (partitionEventSender != null) + partitionEventSender.closeSync(); + + if (partitionMsgSender != null) + partitionMsgSender.closeSync(); + + if (receiver != null) + receiver.closeSync(); + + if (ehClient != null) + ehClient.closeSync(); + + if (msgFactory != null) + msgFactory.closeSync(); + } + + @Test + public void interopWithDirectProtonAmqpMessage() { + validateAmqpPropertiesInEventData.accept(receivedEvent); + } + + @Test + public void interopWithDirectProtonEventDataReSend() { + validateAmqpPropertiesInEventData.accept(reSentAndReceivedEvent); + } + + @Test + public void resentAmqpMessageShouldRetainAllOriginalProps() { + Assert.assertTrue(reSendAndReceivedMessage.getMessageId().equals(originalMessage.getMessageId())); + Assert.assertTrue(reSendAndReceivedMessage.getAddress().equals(originalMessage.getAddress())); + Assert.assertTrue(reSendAndReceivedMessage.getContentEncoding().equals(originalMessage.getContentEncoding())); + Assert.assertTrue(reSendAndReceivedMessage.getContentType().equals(originalMessage.getContentType())); + Assert.assertTrue(new String(reSendAndReceivedMessage.getUserId()).equals(new String(originalMessage.getUserId()))); + Assert.assertTrue(reSendAndReceivedMessage.getCorrelationId().equals(originalMessage.getCorrelationId())); + Assert.assertTrue(reSendAndReceivedMessage.getGroupId().equals(originalMessage.getGroupId())); + Assert.assertTrue(reSendAndReceivedMessage.getReplyTo().equals(originalMessage.getReplyTo())); + Assert.assertTrue(reSendAndReceivedMessage.getReplyToGroupId().equals(originalMessage.getReplyToGroupId())); + Assert.assertTrue(reSendAndReceivedMessage.getSubject().equals(originalMessage.getSubject())); + Assert.assertTrue(reSendAndReceivedMessage.getExpiryTime() == originalMessage.getExpiryTime()); + Assert.assertTrue(reSendAndReceivedMessage.getCreationTime() == originalMessage.getCreationTime()); + Assert.assertTrue(reSendAndReceivedMessage.getExpiryTime() == originalMessage.getExpiryTime()); + Assert.assertTrue(reSendAndReceivedMessage.getGroupSequence() == originalMessage.getGroupSequence()); + + Assert.assertTrue(reSendAndReceivedMessage.getApplicationProperties().getValue().get(applicationProperty) + .equals(originalMessage.getApplicationProperties().getValue().get(applicationProperty))); + + Assert.assertTrue(reSendAndReceivedMessage.getMessageAnnotations().getValue().get(Symbol.getSymbol(msgAnnotation)) + .equals(originalMessage.getMessageAnnotations().getValue().get(Symbol.getSymbol(msgAnnotation)))); + + Binary payloadBytes = ((Data) reSendAndReceivedMessage.getBody()).getValue(); + Assert.assertTrue(new String(payloadBytes.getArray(), payloadBytes.getArrayOffset(), payloadBytes.getLength()).equals(payload)); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/eventdata/InteropEventBodyTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/eventdata/InteropEventBodyTest.java new file mode 100644 index 000000000000..42c012cbf6af --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/eventdata/InteropEventBodyTest.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.eventdata; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.impl.MessageSender; +import com.microsoft.azure.eventhubs.impl.MessagingFactory; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.amqp.Binary; +import org.apache.qpid.proton.amqp.messaging.AmqpSequence; +import org.apache.qpid.proton.amqp.messaging.AmqpValue; +import org.apache.qpid.proton.amqp.messaging.Data; +import org.apache.qpid.proton.message.Message; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.ExecutionException; + +public class InteropEventBodyTest extends ApiTestBase { + + static final String partitionId = "0"; + static EventHubClient ehClient; + static MessagingFactory msgFactory; + static PartitionReceiver receiver; + static MessageSender partitionMsgSender; + static PartitionSender partitionSender; + static EventData receivedEvent; + static EventData reSentAndReceivedEvent; + + @BeforeClass + public static void initialize() throws EventHubException, IOException, InterruptedException, ExecutionException { + final ConnectionStringBuilder connStrBuilder = TestContext.getConnectionString(); + final String connectionString = connStrBuilder.toString(); + + ehClient = EventHubClient.createSync(connectionString, TestContext.EXECUTOR_SERVICE); + msgFactory = MessagingFactory.createFromConnectionString(connectionString, TestContext.EXECUTOR_SERVICE).get(); + receiver = ehClient.createReceiverSync(TestContext.getConsumerGroupName(), partitionId, EventPosition.fromEnqueuedTime(Instant.now())); + partitionSender = ehClient.createPartitionSenderSync(partitionId); + partitionMsgSender = MessageSender.create(msgFactory, "link1", connStrBuilder.getEventHubName() + "/partitions/" + partitionId).get(); + + // run out of messages in that specific partition - to account for clock-skew with Instant.now() on test machine vs eventhubs service + receiver.setReceiveTimeout(Duration.ofSeconds(5)); + Iterable clockSkewEvents; + do { + clockSkewEvents = receiver.receiveSync(100); + } while (clockSkewEvents != null && clockSkewEvents.iterator().hasNext()); + } + + @AfterClass + public static void cleanup() throws EventHubException { + if (partitionMsgSender != null) + partitionMsgSender.closeSync(); + + if (receiver != null) + receiver.closeSync(); + + if (ehClient != null) + ehClient.closeSync(); + + if (msgFactory != null) + msgFactory.closeSync(); + } + + @Test + public void interopWithProtonAmqpMessageBodyAsAmqpValue() throws EventHubException, InterruptedException, ExecutionException { + Message originalMessage = Proton.message(); + String payload = "testmsg"; + originalMessage.setBody(new AmqpValue(payload)); + partitionMsgSender.send(originalMessage).get(); + receivedEvent = receiver.receiveSync(10).iterator().next(); + + Assert.assertEquals(payload, receivedEvent.getObject()); + Assert.assertEquals(receivedEvent.getBytes(), null); + + partitionSender.sendSync(receivedEvent); + reSentAndReceivedEvent = receiver.receiveSync(10).iterator().next(); + Assert.assertEquals(payload, reSentAndReceivedEvent.getObject()); + Assert.assertEquals(reSentAndReceivedEvent.getBytes(), null); + } + + @Test + public void interopWithProtonAmqpMessageBodyAsAmqpSequence() throws EventHubException, InterruptedException, ExecutionException { + Message originalMessage = Proton.message(); + String payload = "testmsg"; + LinkedList datas = new LinkedList<>(); + datas.add(new Data(new Binary(payload.getBytes()))); + originalMessage.setBody(new AmqpSequence(datas)); + + partitionMsgSender.send(originalMessage).get(); + receivedEvent = receiver.receiveSync(10).iterator().next(); + + Assert.assertEquals(payload, new String(((List) receivedEvent.getObject()).get(0).getValue().getArray())); + Assert.assertEquals(receivedEvent.getBytes(), null); + + partitionSender.sendSync(receivedEvent); + reSentAndReceivedEvent = receiver.receiveSync(10).iterator().next(); + Assert.assertEquals(payload, new String(((List) reSentAndReceivedEvent.getObject()).get(0).getValue().getArray())); + Assert.assertArrayEquals(reSentAndReceivedEvent.getBytes(), null); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/ClientEntityCreateTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/ClientEntityCreateTest.java new file mode 100644 index 000000000000..396d24d9b816 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/ClientEntityCreateTest.java @@ -0,0 +1,216 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.exceptioncontracts; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.impl.MessageReceiver; +import com.microsoft.azure.eventhubs.impl.MessageSender; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.time.Duration; +import java.util.UUID; +import java.util.function.Consumer; + +public class ClientEntityCreateTest extends ApiTestBase { + static final String PARTITION_ID = "0"; + static ConnectionStringBuilder connStr; + static final int SHORT_TIMEOUT = 8; + + @BeforeClass + public static void initialize() { + connStr = TestContext.getConnectionString(); + } + + @Test() + public void createReceiverShouldRetryAndThrowTimeoutExceptionUponRepeatedTransientErrors() throws Exception { + setIsTransientOnIllegalEntityException(true); + + try { + final ConnectionStringBuilder localConnStr = new ConnectionStringBuilder(connStr.toString()); + localConnStr.setOperationTimeout(Duration.ofSeconds(SHORT_TIMEOUT)); // to retry atleast once + + final EventHubClient eventHubClient = EventHubClient.createSync(localConnStr.toString(), TestContext.EXECUTOR_SERVICE); + + try { + eventHubClient.createReceiverSync("nonexistantcg", PARTITION_ID, EventPosition.fromStartOfStream()); + Assert.assertTrue(false); // this should be unreachable + } catch (TimeoutException exception) { + Assert.assertTrue(exception.getCause() instanceof IllegalEntityException); + } + + eventHubClient.closeSync(); + } finally { + setIsTransientOnIllegalEntityException(false); + } + } + + @Test() + public void createSenderShouldRetryAndThrowTimeoutExceptionUponRepeatedTransientErrors() throws Exception { + setIsTransientOnIllegalEntityException(true); + + try { + final ConnectionStringBuilder localConnStr = new ConnectionStringBuilder(connStr.toString()); + localConnStr.setOperationTimeout(Duration.ofSeconds(SHORT_TIMEOUT)); // to retry atleast once + localConnStr.setEventHubName("nonexistanteventhub"); + final EventHubClient eventHubClient = EventHubClient.createSync(localConnStr.toString(), TestContext.EXECUTOR_SERVICE); + + try { + eventHubClient.createPartitionSenderSync(PARTITION_ID); + Assert.assertTrue(false); // this should be unreachable + } catch (TimeoutException exception) { + Assert.assertTrue(exception.getCause() instanceof IllegalEntityException); + } + + eventHubClient.closeSync(); + } finally { + setIsTransientOnIllegalEntityException(false); + } + } + + @Test() + public void createInternalSenderShouldRetryAndThrowTimeoutExceptionUponRepeatedTransientErrors() throws Exception { + setIsTransientOnIllegalEntityException(true); + + try { + final ConnectionStringBuilder localConnStr = new ConnectionStringBuilder(connStr.toString()); + localConnStr.setOperationTimeout(Duration.ofSeconds(SHORT_TIMEOUT)); // to retry atleast once + localConnStr.setEventHubName("nonexistanteventhub"); + final EventHubClient eventHubClient = EventHubClient.createSync(localConnStr.toString(), TestContext.EXECUTOR_SERVICE); + + try { + eventHubClient.sendSync(EventData.create("Testmessage".getBytes())); + Assert.assertTrue(false); // this should be unreachable + } catch (TimeoutException exception) { + Assert.assertTrue(exception.getCause() instanceof IllegalEntityException); + } + + eventHubClient.closeSync(); + } finally { + setIsTransientOnIllegalEntityException(false); + } + } + + @Test() + public void createReceiverFailsOnTransientErrorAndThenSucceedsOnRetry() throws Exception { + final TestObject testObject = new TestObject(); + testObject.isRetried = false; + final String nonExistentEventHubName = "nonexistanteh" + UUID.randomUUID(); + + Consumer onOpenRetry = new Consumer() { + @Override + public void accept(MessageReceiver messageReceiver) { + try { + final Field receivePathField = MessageReceiver.class.getDeclaredField("receivePath"); + receivePathField.setAccessible(true); + String receivePath = (String) receivePathField.get(messageReceiver); + receivePathField.set(messageReceiver, receivePath.replace(nonExistentEventHubName, connStr.getEventHubName())); + + final Field tokenAudienceField = MessageReceiver.class.getDeclaredField("tokenAudience"); + tokenAudienceField.setAccessible(true); + String tokenAudience = (String) tokenAudienceField.get(messageReceiver); + tokenAudienceField.set(messageReceiver, tokenAudience.replace(nonExistentEventHubName, connStr.getEventHubName())); + + testObject.isRetried = true; + } catch(Exception ignore){ + System.out.println("this testcase depends on receivepath & tokenAudience in MessageReceiver class for faultinjection..."); + } + } + }; + + final Field openRetryField = MessageReceiver.class.getDeclaredField("onOpenRetry"); + openRetryField.setAccessible(true); + openRetryField.set(null, onOpenRetry); + + setIsTransientOnIllegalEntityException(true); + + try { + ConnectionStringBuilder localConnectionStringBuilder = new ConnectionStringBuilder(connStr.toString()); + localConnectionStringBuilder.setEventHubName(nonExistentEventHubName); + final EventHubClient eventHubClient = EventHubClient.createSync(localConnectionStringBuilder.toString(), TestContext.EXECUTOR_SERVICE); + eventHubClient.createReceiverSync(EventHubClient.DEFAULT_CONSUMER_GROUP_NAME, PARTITION_ID, EventPosition.fromStartOfStream()); + eventHubClient.closeSync(); + } finally { + setIsTransientOnIllegalEntityException(false); + } + + Assert.assertTrue(testObject.isRetried); + } + + @Test() + public void createSenderFailsOnTransientErrorAndThenSucceedsOnRetry() throws Exception { + final TestObject testObject = new TestObject(); + testObject.isRetried = false; + final String nonExistentEventHubName = "nonexistanteh" + UUID.randomUUID(); + + Consumer onOpenRetry = new Consumer() { + @Override + public void accept(MessageSender messageSender) { + try { + final Field receivePathField = MessageSender.class.getDeclaredField("sendPath"); + receivePathField.setAccessible(true); + String receivePath = (String) receivePathField.get(messageSender); + receivePathField.set(messageSender, receivePath.replace(nonExistentEventHubName, connStr.getEventHubName())); + + final Field tokenAudienceField = MessageSender.class.getDeclaredField("tokenAudience"); + tokenAudienceField.setAccessible(true); + String tokenAudience = (String) tokenAudienceField.get(messageSender); + tokenAudienceField.set(messageSender, tokenAudience.replace(nonExistentEventHubName, connStr.getEventHubName())); + + testObject.isRetried = true; + } catch(Exception ignore){ + System.out.println("this testcase depends on sendPath & tokenAudience in MessageReceiver class for faultinjection..."); + } + } + }; + + final Field openRetryField = MessageSender.class.getDeclaredField("onOpenRetry"); + openRetryField.setAccessible(true); + openRetryField.set(null, onOpenRetry); + + setIsTransientOnIllegalEntityException(true); + + try { + ConnectionStringBuilder localConnectionStringBuilder = new ConnectionStringBuilder(connStr.toString()); + localConnectionStringBuilder.setEventHubName(nonExistentEventHubName); + final EventHubClient eventHubClient = EventHubClient.createSync(localConnectionStringBuilder.toString(), TestContext.EXECUTOR_SERVICE); + eventHubClient.createPartitionSenderSync(PARTITION_ID); + eventHubClient.closeSync(); + } finally { + setIsTransientOnIllegalEntityException(false); + } + + Assert.assertTrue(testObject.isRetried); + } + + @Test(expected = IllegalEntityException.class) + public void createReceiverShouldThrowRespectiveExceptionUponNonTransientErrors() throws Exception { + setIsTransientOnIllegalEntityException(false); + final ConnectionStringBuilder localConnStr = new ConnectionStringBuilder(connStr.toString()); + localConnStr.setOperationTimeout(Duration.ofSeconds(SHORT_TIMEOUT)); // to retry atleast once + + final EventHubClient eventHubClient = EventHubClient.createSync(localConnStr.toString(), TestContext.EXECUTOR_SERVICE); + + try { + eventHubClient.createReceiverSync("nonexistantcg", PARTITION_ID, EventPosition.fromStartOfStream()); + } finally { + eventHubClient.closeSync(); + } + } + + static void setIsTransientOnIllegalEntityException(final boolean value) throws Exception { + final Field isTransientField = IllegalEntityException.class.getDeclaredField("isTransient"); + isTransientField.setAccessible(true); + isTransientField.setBoolean(null, value); + } + + private class TestObject { + public boolean isRetried; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/MsgFactoryOpenCloseTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/MsgFactoryOpenCloseTest.java new file mode 100644 index 000000000000..4ae37ffe71a2 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/MsgFactoryOpenCloseTest.java @@ -0,0 +1,292 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.exceptioncontracts; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.impl.MessagingFactory; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.FaultInjectingReactorFactory; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.time.Instant; +import java.util.concurrent.*; + +public class MsgFactoryOpenCloseTest extends ApiTestBase { + + static final String PARTITION_ID = "0"; + static ConnectionStringBuilder connStr; + + @BeforeClass + public static void initialize() { + connStr = TestContext.getConnectionString(); + } + + @Test() + public void VerifyTaskQueueEmptyOnMsgFactoryGracefulClose() throws Exception { + + final ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + try { + final EventHubClient ehClient = EventHubClient.createSync( + TestContext.getConnectionString().toString(), + executor); + + final PartitionReceiver receiver = ehClient.createReceiverSync( + TestContext.getConsumerGroupName(), PARTITION_ID, EventPosition.fromEnqueuedTime(Instant.now())); + final PartitionSender sender = ehClient.createPartitionSenderSync(PARTITION_ID); + sender.sendSync(EventData.create("test data - string".getBytes())); + Iterable events = receiver.receiveSync(10); + + Assert.assertTrue(events.iterator().hasNext()); + sender.closeSync(); + receiver.closeSync(); + + ehClient.closeSync(); + + Assert.assertEquals(((ScheduledThreadPoolExecutor) executor).getQueue().size(), 0); + } finally { + executor.shutdown(); + } + } + + @Test() + public void VerifyTaskQueueEmptyOnMsgFactoryWithPumpGracefulClose() throws Exception { + + final ScheduledExecutorService executor = new ScheduledThreadPoolExecutor(1); + + try { + final EventHubClient ehClient = EventHubClient.createSync( + TestContext.getConnectionString().toString(), + executor); + + final PartitionReceiver receiver = ehClient.createReceiverSync( + TestContext.getConsumerGroupName(), PARTITION_ID, EventPosition.fromEnqueuedTime(Instant.now())); + + final CompletableFuture> signalReceive = new CompletableFuture<>(); + receiver.setReceiveHandler(new PartitionReceiveHandler() { + @Override + public int getMaxEventCount() { + return 10; + } + + @Override + public void onReceive(Iterable events) { + signalReceive.complete(events); + } + + @Override + public void onError(Throwable error) { + } + }, false); + + final PartitionSender sender = ehClient.createPartitionSenderSync(PARTITION_ID); + sender.sendSync(EventData.create("test data - string".getBytes())); + + final Iterable events = signalReceive.get(); + Assert.assertTrue(events.iterator().hasNext()); + + receiver.setReceiveHandler(null).get(); + + sender.closeSync(); + receiver.closeSync(); + + ehClient.closeSync(); + + Assert.assertEquals(((ScheduledThreadPoolExecutor) executor).getQueue().size(), 0); + } finally { + executor.shutdown(); + } + } + + @Test() + public void VerifyThreadReleaseOnMsgFactoryOpenError() throws Exception { + + final FaultInjectingReactorFactory networkOutageSimulator = new FaultInjectingReactorFactory(); + networkOutageSimulator.setFaultType(FaultInjectingReactorFactory.FaultType.NetworkOutage); + + final ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + + try { + final CompletableFuture openFuture = MessagingFactory.createFromConnectionString( + connStr.toString(), null, + executor, + networkOutageSimulator); + try { + openFuture.get(); + Assert.assertFalse(true); + } catch (ExecutionException error) { + Assert.assertEquals(EventHubException.class, error.getCause().getClass()); + } + + Thread.sleep(1000); // for reactor to transition from cleanup to complete-stop + + Assert.assertEquals(((ScheduledThreadPoolExecutor) executor).getQueue().size(), 0); + } finally { + executor.shutdown(); + } + } + + @Test(expected = RejectedExecutionException.class) + public void SupplyClosedExecutorServiceToEventHubClient() throws Exception { + final ScheduledExecutorService testClosed = new ScheduledThreadPoolExecutor(1); + testClosed.shutdown(); + + EventHubClient.createSync( + TestContext.getConnectionString().toString(), + testClosed); + } + + @Test(expected = RejectedExecutionException.class) + public void SupplyClosedExecutorServiceToSendOperation() throws Exception { + final ScheduledExecutorService testClosed = Executors.newScheduledThreadPool(1); + + final EventHubClient temp = EventHubClient.createSync( + TestContext.getConnectionString().toString(), + testClosed); + temp.sendSync(EventData.create("test data - string".getBytes())); + + testClosed.shutdown(); + + temp.sendSync(EventData.create("test data - string".getBytes())); + testClosed.awaitTermination(60, TimeUnit.SECONDS); + } + + @Test(expected = RejectedExecutionException.class) + public void SupplyClosedExecutorServiceToReceiveOperation() throws Exception { + final ScheduledExecutorService testClosed = new ScheduledThreadPoolExecutor(1); + + final PartitionReceiver temp = EventHubClient.createSync( + TestContext.getConnectionString().toString(), + testClosed) + .createReceiverSync(TestContext.getConsumerGroupName(), PARTITION_ID, EventPosition.fromEndOfStream()); + + testClosed.shutdown(); + testClosed.awaitTermination(60, TimeUnit.SECONDS); + + temp.receiveSync(20); + } + + @Test(expected = RejectedExecutionException.class) + public void SupplyClosedExecutorServiceToCreateLinkOperation() throws Exception { + final ScheduledExecutorService testClosed = Executors.newScheduledThreadPool(1); + + final EventHubClient temp = EventHubClient.createSync( + TestContext.getConnectionString().toString(), + testClosed); + + testClosed.shutdown(); + testClosed.awaitTermination(60, TimeUnit.SECONDS); + + // first send creates send link + temp.sendSync(EventData.create("test data - string".getBytes())); + } + + @Test(expected = RejectedExecutionException.class) + public void SupplyClosedExecutorServiceToCreateSenderOperation() throws Exception { + final ScheduledExecutorService testClosed = new ScheduledThreadPoolExecutor(1); + + final EventHubClient temp = EventHubClient.createSync( + TestContext.getConnectionString().toString(), + testClosed); + + testClosed.shutdown(); + testClosed.awaitTermination(60, TimeUnit.SECONDS); + + temp.createPartitionSenderSync(PARTITION_ID); + } + + @Test(expected = RejectedExecutionException.class) + public void SupplyClosedExecutorServiceToCreateReceiverOperation() throws Exception { + final ScheduledExecutorService testClosed = Executors.newScheduledThreadPool(1); + + final EventHubClient temp = EventHubClient.createSync( + TestContext.getConnectionString().toString(), + testClosed); + + testClosed.shutdown(); + testClosed.awaitTermination(60, TimeUnit.SECONDS); + + temp.createReceiverSync(TestContext.getConsumerGroupName(), PARTITION_ID, EventPosition.fromEndOfStream()); + } + + @Test(expected = RejectedExecutionException.class) + public void SupplyClosedExecutorServiceThenMgmtOperation() throws Throwable { + final ScheduledThreadPoolExecutor testClosed = new ScheduledThreadPoolExecutor(1); + + final EventHubClient temp = EventHubClient.createSync( + TestContext.getConnectionString().toString(), + testClosed); + + testClosed.shutdown(); + testClosed.awaitTermination(60, TimeUnit.SECONDS); + + try { + temp.getPartitionRuntimeInformation(PARTITION_ID).get(); + } catch (ExecutionException ex) { + throw ex.getCause(); + } + } + + @Test(expected = RejectedExecutionException.class) + public void SupplyClosedExecutorServiceThenFactoryCloseOperation() throws Exception { + final ScheduledExecutorService testClosed = Executors.newScheduledThreadPool(1); + + final EventHubClient temp = EventHubClient.createSync( + TestContext.getConnectionString().toString(), + testClosed); + + testClosed.shutdown(); + testClosed.awaitTermination(60, TimeUnit.SECONDS); + + temp.closeSync(); + } + + @Test(expected = RejectedExecutionException.class) + public void SupplyClosedExecutorServiceThenSenderCloseOperation() throws Exception { + final ScheduledThreadPoolExecutor testClosed = new ScheduledThreadPoolExecutor(1); + + final PartitionSender temp = EventHubClient.createSync( + TestContext.getConnectionString().toString(), + testClosed).createPartitionSenderSync(PARTITION_ID); + + testClosed.shutdown(); + testClosed.awaitTermination(60, TimeUnit.SECONDS); + + temp.closeSync(); + } + + @Test(expected = RejectedExecutionException.class) + public void SupplyClosedExecutorServiceThenReceiverCloseOperation() throws Exception { + final ScheduledExecutorService testClosed = Executors.newScheduledThreadPool(1); + + final PartitionReceiver temp = EventHubClient.createSync( + TestContext.getConnectionString().toString(), + testClosed).createReceiverSync(TestContext.getConsumerGroupName(), PARTITION_ID, EventPosition.fromEndOfStream()); + + testClosed.shutdown(); + testClosed.awaitTermination(60, TimeUnit.SECONDS); + + temp.closeSync(); + } + + @Test(expected = RejectedExecutionException.class) + public void testEventHubClientSendAfterClose() throws Exception { + final ConnectionStringBuilder connectionString = TestContext.getConnectionString(); + final EventHubClient eventHubClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + eventHubClient.closeSync(); + eventHubClient.sendSync(EventData.create("test message".getBytes())); + } + + @Test(expected = IllegalStateException.class) + public void testEventHubClientSendCloseAfterSomeSends() throws Exception { + final ConnectionStringBuilder connectionString = TestContext.getConnectionString(); + final EventHubClient eventHubClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + eventHubClient.sendSync(EventData.create("test message".getBytes())); + eventHubClient.closeSync(); + eventHubClient.sendSync(EventData.create("test message".getBytes())); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/ReactorFaultTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/ReactorFaultTest.java new file mode 100644 index 000000000000..56786c4019a9 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/ReactorFaultTest.java @@ -0,0 +1,132 @@ +package com.microsoft.azure.eventhubs.exceptioncontracts; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.impl.EventHubClientImpl; +import com.microsoft.azure.eventhubs.impl.MessagingFactory; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.apache.qpid.proton.engine.BaseHandler; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.engine.impl.CollectorImpl; +import org.apache.qpid.proton.engine.impl.ConnectionImpl; +import org.apache.qpid.proton.reactor.Reactor; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.Iterator; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +public class ReactorFaultTest extends ApiTestBase { + static final String PARTITION_ID = "0"; + static ConnectionStringBuilder connStr; + + @BeforeClass + public static void initialize() throws Exception { + connStr = TestContext.getConnectionString(); + } + + @Test() + public void VerifyReactorRestartsOnProtonBugs() throws Exception { + final EventHubClient eventHubClient = EventHubClient.createSync(connStr.toString(), TestContext.EXECUTOR_SERVICE); + try { + final PartitionReceiver partitionReceiver = eventHubClient.createEpochReceiverSync( + "$default", "0", EventPosition.fromStartOfStream(), System.currentTimeMillis()); + partitionReceiver.receiveSync(100); + + Executors.newScheduledThreadPool(1).schedule(new Runnable() { + @Override + public void run() { + try { + final Field factoryField = EventHubClientImpl.class.getDeclaredField("underlyingFactory"); + factoryField.setAccessible(true); + final MessagingFactory underlyingFactory = (MessagingFactory) factoryField.get(eventHubClient); + + final Field reactorField = MessagingFactory.class.getDeclaredField("reactor"); + reactorField.setAccessible(true); + final Reactor reactor = (Reactor) reactorField.get(underlyingFactory); + + org.apache.qpid.proton.engine.Handler handler = reactor.getHandler(); + handler.add(new BaseHandler() { + @Override + public void handle(org.apache.qpid.proton.engine.Event e) { + throw new NullPointerException(); + } + }); + } catch (Exception e) { + Assert.fail(e.getMessage()); + } + } + }, 2, TimeUnit.SECONDS); + + try { + Thread.sleep(4000); + + final Iterable events = partitionReceiver.receiveSync(100); + Assert.assertTrue(events != null && events.iterator().hasNext()); + } finally { + partitionReceiver.closeSync(); + } + } finally { + eventHubClient.closeSync(); + } + } + + @Test() + public void VerifyTransportAbort() throws Exception { + final EventHubClient eventHubClient = EventHubClient.createSync(connStr.toString(), TestContext.EXECUTOR_SERVICE); + try { + final PartitionReceiver partitionReceiver = eventHubClient.createEpochReceiverSync( + "$default", "0", EventPosition.fromStartOfStream(), System.currentTimeMillis()); + final Iterable firstBatch = partitionReceiver.receiveSync(100); + Assert.assertTrue(firstBatch != null); + + long sequenceNumber = -1; + final Iterator iterator = firstBatch.iterator(); + while (iterator.hasNext()) { + sequenceNumber = iterator.next().getSystemProperties().getSequenceNumber(); + } + + Assert.assertTrue(sequenceNumber > -1); + + Executors.newScheduledThreadPool(1).schedule(new Runnable() { + @Override + public void run() { + try { + final Field factoryField = EventHubClientImpl.class.getDeclaredField("underlyingFactory"); + factoryField.setAccessible(true); + final MessagingFactory underlyingFactory = (MessagingFactory) factoryField.get(eventHubClient); + + final Field reactorField = MessagingFactory.class.getDeclaredField("reactor"); + reactorField.setAccessible(true); + final Reactor reactor = (Reactor) reactorField.get(underlyingFactory); + + final Field connectionField = MessagingFactory.class.getDeclaredField("connection"); + connectionField.setAccessible(true); + final ConnectionImpl connection = (ConnectionImpl) connectionField.get(underlyingFactory); + + ((CollectorImpl) reactor.collector()).put(Event.Type.TRANSPORT_ERROR, connection.getTransport()); + } catch (Exception e) { + Assert.fail(e.getMessage()); + } + } + }, 5, TimeUnit.SECONDS); + + try { + Thread.sleep(10000); + + final Iterable events = partitionReceiver.receiveSync(100); + Assert.assertTrue(events != null && events.iterator().hasNext()); + Assert.assertEquals(sequenceNumber + 1, events.iterator().next().getSystemProperties().getSequenceNumber()); + } catch (Exception e) { + Assert.fail(e.getMessage()); + } finally { + partitionReceiver.closeSync(); + } + } finally { + eventHubClient.closeSync(); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/ReceiverEpochTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/ReceiverEpochTest.java new file mode 100644 index 000000000000..140023b0cc81 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/ReceiverEpochTest.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.exceptioncontracts; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.*; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.Random; +import java.util.concurrent.ExecutionException; + +public class ReceiverEpochTest extends ApiTestBase { + static final String cgName = TestContext.getConsumerGroupName(); + static final String partitionId = "0"; + + static EventHubClient ehClient; + + PartitionReceiver receiver; + + @BeforeClass + public static void initializeEventHub() throws EventHubException, IOException { + final ConnectionStringBuilder connectionString = TestContext.getConnectionString(); + ehClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + } + + @AfterClass + public static void cleanup() throws EventHubException { + if (ehClient != null) + ehClient.closeSync(); + } + + @Test(expected = ReceiverDisconnectedException.class) + public void testEpochReceiverWins() throws EventHubException, InterruptedException, ExecutionException { + int sendEventCount = 5; + + PartitionReceiver receiverLowEpoch = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromEnqueuedTime(Instant.now())); + receiverLowEpoch.setReceiveTimeout(Duration.ofSeconds(2)); + TestBase.pushEventsToPartition(ehClient, partitionId, sendEventCount).get(); + receiverLowEpoch.receiveSync(20); + + receiver = ehClient.createEpochReceiverSync(cgName, partitionId, EventPosition.fromEnqueuedTime(Instant.now()), Long.MAX_VALUE); + + for (int retryCount = 0; retryCount < sendEventCount; retryCount++) // retry to flush all msgs in cache + receiverLowEpoch.receiveSync(10); + } + + @Test(expected = ReceiverDisconnectedException.class) + public void testOldHighestEpochWins() throws EventHubException, InterruptedException, ExecutionException { + Instant testStartTime = Instant.now(); + long epoch = Math.abs(new Random().nextLong()); + + if (epoch < 11L) + epoch += 11L; + + receiver = ehClient.createEpochReceiverSync(cgName, partitionId, EventPosition.fromEnqueuedTime(testStartTime), epoch); + receiver.setReceiveTimeout(Duration.ofSeconds(10)); + ehClient.createEpochReceiverSync(cgName, partitionId, EventPosition.fromStartOfStream(), epoch - 10); + + TestBase.pushEventsToPartition(ehClient, partitionId, 5).get(); + Assert.assertTrue(receiver.receiveSync(10).iterator().hasNext()); + } + + @Test(expected = ReceiverDisconnectedException.class) + public void testNewHighestEpochWins() throws EventHubException, InterruptedException, ExecutionException { + int sendEventCount = 5; + long epoch = new Random().nextInt(Integer.MAX_VALUE); + + PartitionReceiver receiverLowEpoch = ehClient.createEpochReceiverSync(cgName, partitionId, EventPosition.fromEnqueuedTime(Instant.now()), epoch); + receiverLowEpoch.setReceiveTimeout(Duration.ofSeconds(2)); + TestBase.pushEventsToPartition(ehClient, partitionId, sendEventCount).get(); + receiverLowEpoch.receiveSync(20); + + receiver = ehClient.createEpochReceiverSync(cgName, partitionId, EventPosition.fromEnqueuedTime(Instant.now()), Long.MAX_VALUE); + + for (int retryCount = 0; retryCount < sendEventCount; retryCount++) // retry to flush all msgs in cache + receiverLowEpoch.receiveSync(10); + } + + @After + public void testCleanup() throws EventHubException { + if (receiver != null) { + receiver.closeSync(); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/RetryPolicyTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/RetryPolicyTest.java new file mode 100644 index 000000000000..0cae3390a989 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/RetryPolicyTest.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.exceptioncontracts; + +import com.microsoft.azure.eventhubs.AuthorizationFailedException; +import com.microsoft.azure.eventhubs.RetryPolicy; +import com.microsoft.azure.eventhubs.ServerBusyException; +import com.microsoft.azure.eventhubs.lib.TestBase; +import org.junit.Assert; +import org.junit.Test; + +import java.time.Duration; +import java.util.logging.Level; + +public class RetryPolicyTest extends TestBase { + @Test + public void testRetryPolicy() throws Exception { + String clientId = "someClientEntity"; + RetryPolicy retry = RetryPolicy.getDefault(); + + retry.incrementRetryCount(clientId); + Duration firstRetryInterval = retry.getNextRetryInterval(clientId, new ServerBusyException(), Duration.ofSeconds(60)); + TestBase.TEST_LOGGER.log(Level.FINE, "firstRetryInterval: " + firstRetryInterval.toString()); + Assert.assertTrue(firstRetryInterval != null); + + retry.incrementRetryCount(clientId); + Duration secondRetryInterval = retry.getNextRetryInterval(clientId, new ServerBusyException(), Duration.ofSeconds(60)); + TestBase.TEST_LOGGER.log(Level.FINE, "secondRetryInterval: " + secondRetryInterval.toString()); + + Assert.assertTrue(secondRetryInterval != null); + Assert.assertTrue(secondRetryInterval.getSeconds() > firstRetryInterval.getSeconds() || + (secondRetryInterval.getSeconds() == firstRetryInterval.getSeconds() && secondRetryInterval.getNano() > firstRetryInterval.getNano())); + + retry.incrementRetryCount(clientId); + Duration thirdRetryInterval = retry.getNextRetryInterval(clientId, new ServerBusyException(), Duration.ofSeconds(60)); + TestBase.TEST_LOGGER.log(Level.FINE, "thirdRetryInterval: " + thirdRetryInterval.toString()); + + Assert.assertTrue(thirdRetryInterval != null); + Assert.assertTrue(thirdRetryInterval.getSeconds() > secondRetryInterval.getSeconds() || + (thirdRetryInterval.getSeconds() == secondRetryInterval.getSeconds() && thirdRetryInterval.getNano() > secondRetryInterval.getNano())); + + retry.incrementRetryCount(clientId); + Duration fourthRetryInterval = retry.getNextRetryInterval(clientId, new ServerBusyException(), Duration.ofSeconds(60)); + TestBase.TEST_LOGGER.log(Level.FINE, "fourthRetryInterval: " + fourthRetryInterval.toString()); + + Assert.assertTrue(fourthRetryInterval != null); + Assert.assertTrue(fourthRetryInterval.getSeconds() > thirdRetryInterval.getSeconds() || + (fourthRetryInterval.getSeconds() == thirdRetryInterval.getSeconds() && fourthRetryInterval.getNano() > thirdRetryInterval.getNano())); + + retry.incrementRetryCount(clientId); + Duration fifthRetryInterval = retry.getNextRetryInterval(clientId, new ServerBusyException(), Duration.ofSeconds(60)); + TestBase.TEST_LOGGER.log(Level.FINE, "fifthRetryInterval: " + fifthRetryInterval.toString()); + + Assert.assertTrue(fifthRetryInterval != null); + Assert.assertTrue(fifthRetryInterval.getSeconds() > fourthRetryInterval.getSeconds() || + (fifthRetryInterval.getSeconds() == fourthRetryInterval.getSeconds() && fifthRetryInterval.getNano() > fourthRetryInterval.getNano())); + + retry.incrementRetryCount(clientId); + Duration sixthRetryInterval = retry.getNextRetryInterval(clientId, new ServerBusyException(), Duration.ofSeconds(60)); + TestBase.TEST_LOGGER.log(Level.FINE, "sixthRetryInterval: " + sixthRetryInterval.toString()); + + Assert.assertTrue(sixthRetryInterval != null); + Assert.assertTrue(sixthRetryInterval.getSeconds() > fifthRetryInterval.getSeconds() || + (sixthRetryInterval.getSeconds() == fifthRetryInterval.getSeconds() && sixthRetryInterval.getNano() > fifthRetryInterval.getNano())); + + retry.incrementRetryCount(clientId); + Duration seventhRetryInterval = retry.getNextRetryInterval(clientId, new ServerBusyException(), Duration.ofSeconds(60)); + TestBase.TEST_LOGGER.log(Level.FINE, "seventhRetryInterval: " + seventhRetryInterval.toString()); + + Assert.assertTrue(seventhRetryInterval != null); + Assert.assertTrue(seventhRetryInterval.getSeconds() > sixthRetryInterval.getSeconds() || + (seventhRetryInterval.getSeconds() == sixthRetryInterval.getSeconds() && seventhRetryInterval.getNano() > sixthRetryInterval.getNano())); + + retry.incrementRetryCount(clientId); + Duration nextRetryInterval = retry.getNextRetryInterval(clientId, new AuthorizationFailedException("authorizationerror"), Duration.ofSeconds(60)); + Assert.assertTrue(nextRetryInterval == null); + + retry.resetRetryCount(clientId); + retry.incrementRetryCount(clientId); + Duration firstRetryIntervalAfterReset = retry.getNextRetryInterval(clientId, new ServerBusyException(), Duration.ofSeconds(60)); + Assert.assertTrue(firstRetryInterval.equals(firstRetryIntervalAfterReset)); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/SecurityExceptionsTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/SecurityExceptionsTest.java new file mode 100644 index 000000000000..1ce01910cd5d --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/SecurityExceptionsTest.java @@ -0,0 +1,161 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.exceptioncontracts; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.impl.SharedAccessSignatureTokenProvider; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.After; +import org.junit.Test; + +import java.time.Duration; +import java.util.UUID; + +public class SecurityExceptionsTest extends ApiTestBase { + final static String PARTITION_ID = "0"; + EventHubClient ehClient; + + @Test(expected = AuthorizationFailedException.class) + public void testEventHubClientUnAuthorizedAccessKeyName() throws Throwable { + final ConnectionStringBuilder correctConnectionString = TestContext.getConnectionString(); + final ConnectionStringBuilder connectionString = new ConnectionStringBuilder() + .setEndpoint(correctConnectionString.getEndpoint()) + .setEventHubName(correctConnectionString.getEventHubName()) + .setSasKeyName("---------------wrongkey------------") + .setSasKey(correctConnectionString.getSasKey()); + + ehClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + ehClient.sendSync(EventData.create("Test Message".getBytes())); + } + + @Test(expected = AuthorizationFailedException.class) + public void testEventHubClientUnAuthorizedAccessKey() throws Throwable { + final ConnectionStringBuilder correctConnectionString = TestContext.getConnectionString(); + final ConnectionStringBuilder connectionString = new ConnectionStringBuilder() + .setEndpoint(correctConnectionString.getEndpoint()) + .setEventHubName(correctConnectionString.getEventHubName()) + .setSasKeyName(correctConnectionString.getSasKeyName()) + .setSasKey("--------------wrongvalue-----------"); + + ehClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + ehClient.sendSync(EventData.create("Test Message".getBytes())); + } + + @Test(expected = EventHubException.class) + public void testEventHubClientInvalidAccessToken() throws Throwable { + final ConnectionStringBuilder correctConnectionString = TestContext.getConnectionString(); + final ConnectionStringBuilder connectionString = new ConnectionStringBuilder() + .setEndpoint(correctConnectionString.getEndpoint()) + .setEventHubName(correctConnectionString.getEventHubName()) + .setSharedAccessSignature("--------------invalidtoken-------------"); + + ehClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + ehClient.sendSync(EventData.create(("Test Message".getBytes()))); + } + + @Test(expected = IllegalArgumentException.class) + public void testEventHubClientNullAccessToken() throws Throwable { + final ConnectionStringBuilder correctConnectionString = TestContext.getConnectionString(); + final ConnectionStringBuilder connectionString = new ConnectionStringBuilder() + .setEndpoint(correctConnectionString.getEndpoint()) + .setEventHubName(correctConnectionString.getEventHubName()) + .setSharedAccessSignature(null); + + ehClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + ehClient.sendSync(EventData.create(("Test Message".getBytes()))); + } + + @Test(expected = AuthorizationFailedException.class) + public void testEventHubClientUnAuthorizedAccessToken() throws Throwable { + final ConnectionStringBuilder correctConnectionString = TestContext.getConnectionString(); + final String wrongToken = SharedAccessSignatureTokenProvider.generateSharedAccessSignature( + "wrongkey", + correctConnectionString.getSasKey(), + String.format("amqps://%s/%s", correctConnectionString.getEndpoint().getHost(), correctConnectionString.getEventHubName()), + Duration.ofSeconds(10)); + final ConnectionStringBuilder connectionString = new ConnectionStringBuilder() + .setEndpoint(correctConnectionString.getEndpoint()) + .setEventHubName(correctConnectionString.getEventHubName()) + .setSharedAccessSignature(wrongToken); + + ehClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + ehClient.sendSync(EventData.create("Test Message".getBytes())); + } + + @Test(expected = AuthorizationFailedException.class) + public void testEventHubClientWrongResourceInAccessToken() throws Throwable { + final ConnectionStringBuilder correctConnectionString = TestContext.getConnectionString(); + final String wrongToken = SharedAccessSignatureTokenProvider.generateSharedAccessSignature( + correctConnectionString.getSasKeyName(), + correctConnectionString.getSasKey(), + "----------wrongresource-----------", + Duration.ofSeconds(10)); + final ConnectionStringBuilder connectionString = new ConnectionStringBuilder() + .setEndpoint(correctConnectionString.getEndpoint()) + .setEventHubName(correctConnectionString.getEventHubName()) + .setSharedAccessSignature(wrongToken); + + ehClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + ehClient.sendSync(EventData.create("Test Message".getBytes())); + } + + @Test(expected = AuthorizationFailedException.class) + public void testUnAuthorizedAccessSenderCreation() throws Throwable { + final ConnectionStringBuilder correctConnectionString = TestContext.getConnectionString(); + final ConnectionStringBuilder connectionString = new ConnectionStringBuilder() + .setEndpoint(correctConnectionString.getEndpoint()) + .setEventHubName(correctConnectionString.getEventHubName()) + .setSasKeyName("------------wrongkeyname----------") + .setSasKey(correctConnectionString.getSasKey()); + + ehClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + ehClient.createPartitionSenderSync(PARTITION_ID); + } + + @Test(expected = AuthorizationFailedException.class) + public void testUnAuthorizedAccessReceiverCreation() throws Throwable { + final ConnectionStringBuilder correctConnectionString = TestContext.getConnectionString(); + final ConnectionStringBuilder connectionString = new ConnectionStringBuilder() + .setEndpoint(correctConnectionString.getEndpoint()) + .setEventHubName(correctConnectionString.getEventHubName()) + .setSasKeyName("---------------wrongkey------------") + .setSasKey(correctConnectionString.getSasKey()); + + ehClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + ehClient.createReceiverSync(TestContext.getConsumerGroupName(), PARTITION_ID, EventPosition.fromStartOfStream()); + } + + @Test(expected = IllegalEntityException.class) + public void testSendToNonExistantEventHub() throws Throwable { + final ConnectionStringBuilder correctConnectionString = TestContext.getConnectionString(); + final ConnectionStringBuilder connectionString = new ConnectionStringBuilder() + .setEndpoint(correctConnectionString.getEndpoint()) + .setEventHubName("non-existant-entity" + UUID.randomUUID().toString()) + .setSasKeyName(correctConnectionString.getSasKeyName()) + .setSasKey(correctConnectionString.getSasKey()); + + ehClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + ehClient.sendSync(EventData.create("test string".getBytes())); + } + + @Test(expected = IllegalEntityException.class) + public void testReceiveFromNonExistantEventHub() throws Throwable { + final ConnectionStringBuilder correctConnectionString = TestContext.getConnectionString(); + final ConnectionStringBuilder connectionString = new ConnectionStringBuilder() + .setEndpoint(correctConnectionString.getEndpoint()) + .setEventHubName("non-existant-entity" + UUID.randomUUID().toString()) + .setSasKeyName(correctConnectionString.getSasKeyName()) + .setSasKey(correctConnectionString.getSasKey()); + + ehClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + ehClient.createReceiverSync(TestContext.getConsumerGroupName(), PARTITION_ID, EventPosition.fromStartOfStream()); + } + + @After + public void cleanup() throws EventHubException { + ehClient.closeSync(); + } +} \ No newline at end of file diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/SendLargeMessageTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/SendLargeMessageTest.java new file mode 100644 index 000000000000..b5cccf85c9b4 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/SendLargeMessageTest.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.exceptioncontracts; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.IOException; +import java.time.Instant; +import java.util.concurrent.ExecutionException; + +public class SendLargeMessageTest extends ApiTestBase { + static String partitionId = "0"; + + static EventHubClient ehClient; + static PartitionSender sender; + + static EventHubClient receiverHub; + static PartitionReceiver receiver; + + @BeforeClass + public static void initialize() throws Exception { + initializeEventHubClients(TestContext.getConnectionString()); + } + + public static void initializeEventHubClients(ConnectionStringBuilder connStr) throws Exception { + + ehClient = EventHubClient.createSync(connStr.toString(), TestContext.EXECUTOR_SERVICE); + sender = ehClient.createPartitionSender(partitionId).get(); + + receiverHub = EventHubClient.createSync(connStr.toString(), TestContext.EXECUTOR_SERVICE); + receiver = receiverHub.createReceiver(TestContext.getConsumerGroupName(), partitionId, EventPosition.fromEnqueuedTime(Instant.now())).get(); + } + + @AfterClass() + public static void cleanup() throws EventHubException { + if (receiverHub != null) { + receiverHub.close(); + } + + if (ehClient != null) { + ehClient.close(); + } + } + + @Test() + public void sendMsgLargerThan64k() throws EventHubException, InterruptedException, ExecutionException, IOException { + this.sendLargeMessageTest(100 * 1024); + } + + @Test(expected = PayloadSizeExceededException.class) + public void sendMsgLargerThan1024K() throws EventHubException, InterruptedException, ExecutionException, IOException { + int msgSize = 1024 * 1024 * 2; + byte[] body = new byte[msgSize]; + for (int i = 0; i < msgSize; i++) { + body[i] = 1; + } + + EventData largeMsg = EventData.create(body); + sender.sendSync(largeMsg); + } + + @Test() + public void sendMsgLargerThan128k() throws EventHubException, InterruptedException, ExecutionException, IOException { + this.sendLargeMessageTest(129 * 1024); + } + + public void sendLargeMessageTest(int msgSize) throws InterruptedException, ExecutionException, EventHubException { + byte[] body = new byte[msgSize]; + for (int i = 0; i < msgSize; i++) { + body[i] = 1; + } + + EventData largeMsg = EventData.create(body); + sender.sendSync(largeMsg); + + Iterable messages = receiver.receiveSync(100); + Assert.assertTrue(messages != null && messages.iterator().hasNext()); + + EventData recdMessage = messages.iterator().next(); + + Assert.assertTrue( + String.format("sent msg size: %s, recvd msg size: %s", msgSize, recdMessage.getBytes().length), + recdMessage.getBytes().length == msgSize); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/WebSocketsSendLargeMessageTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/WebSocketsSendLargeMessageTest.java new file mode 100644 index 000000000000..71b6b12f5969 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/exceptioncontracts/WebSocketsSendLargeMessageTest.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.exceptioncontracts; + +import com.microsoft.azure.eventhubs.ConnectionStringBuilder; +import com.microsoft.azure.eventhubs.EventHubException; +import com.microsoft.azure.eventhubs.PayloadSizeExceededException; +import com.microsoft.azure.eventhubs.TransportType; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.IOException; +import java.util.concurrent.ExecutionException; + +public class WebSocketsSendLargeMessageTest extends ApiTestBase { + private static SendLargeMessageTest sendLargeMessageTest; + + @BeforeClass + public static void initialize() throws Exception { + final ConnectionStringBuilder connectionStringBuilder = TestContext.getConnectionString(); + connectionStringBuilder.setTransportType(TransportType.AMQP_WEB_SOCKETS); + sendLargeMessageTest = new SendLargeMessageTest(); + SendLargeMessageTest.initializeEventHubClients(connectionStringBuilder); + } + + @AfterClass() + public static void cleanup() throws EventHubException { + SendLargeMessageTest.cleanup(); + } + + @Test() + public void sendMsgLargerThan64k() throws EventHubException, InterruptedException, ExecutionException, IOException { + sendLargeMessageTest.sendMsgLargerThan64k(); + } + + @Test(expected = PayloadSizeExceededException.class) + public void sendMsgLargerThan1024K() throws EventHubException, InterruptedException, ExecutionException, IOException { + sendLargeMessageTest.sendMsgLargerThan1024K(); + } + + @Test() + public void sendMsgLargerThan128k() throws EventHubException, InterruptedException, ExecutionException, IOException { + sendLargeMessageTest.sendMsgLargerThan128k(); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/impl/EventDataOrderTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/impl/EventDataOrderTest.java new file mode 100644 index 000000000000..f36066a1fee1 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/impl/EventDataOrderTest.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.impl; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; + +import com.microsoft.azure.eventhubs.EventData; +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.messaging.MessageAnnotations; +import org.apache.qpid.proton.message.Message; +import org.junit.Assert; +import org.junit.Test; + +public class EventDataOrderTest { + + private EventData constructMessage(long seqNumber) { + HashMap properties = new HashMap<>(); + properties.put(AmqpConstants.SEQUENCE_NUMBER, seqNumber); + + Message message = Message.Factory.create(); + + message.setMessageAnnotations(new MessageAnnotations(properties)); + + return new EventDataImpl(message); + } + + @Test + public void eventDataEmptyByteArray() { + ArrayList messages = new ArrayList<>(); + + EventData first = constructMessage(19); + EventData second = constructMessage(22); + EventData third = constructMessage(25); + EventData last = constructMessage(88); + + messages.add(second); + messages.add(first); + messages.add(last); + messages.add(third); + + Collections.sort(messages); + + Assert.assertEquals(messages.get(0), first); + Assert.assertEquals(messages.get(1), second); + Assert.assertEquals(messages.get(2), third); + Assert.assertEquals(messages.get(3), last); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/ApiTestBase.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/ApiTestBase.java new file mode 100644 index 000000000000..cca2e7c63738 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/ApiTestBase.java @@ -0,0 +1,17 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.lib; + +import org.junit.Assume; +import org.junit.BeforeClass; + +public class ApiTestBase extends TestBase { + + @BeforeClass + public static void skipIfNotConfigured() { + + Assume.assumeTrue(TestContext.isTestConfigurationSet()); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/FaultInjectingReactorFactory.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/FaultInjectingReactorFactory.java new file mode 100644 index 000000000000..d86d14b7a2e8 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/FaultInjectingReactorFactory.java @@ -0,0 +1,68 @@ +package com.microsoft.azure.eventhubs.lib; + +import com.microsoft.azure.eventhubs.impl.CustomIOHandler; +import com.microsoft.azure.eventhubs.impl.MessagingFactory; +import com.microsoft.azure.eventhubs.impl.ReactorHandler; +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.Event; +import org.apache.qpid.proton.engine.Transport; +import org.apache.qpid.proton.reactor.Reactor; + +import java.io.IOException; + +public class FaultInjectingReactorFactory extends MessagingFactory.ReactorFactory { + + private volatile FaultType faultType; + + public void setFaultType(final FaultType faultType) { + this.faultType = faultType; + } + + @Override + public Reactor create(final ReactorHandler reactorHandler, final int maxFrameSize) throws IOException { + final Reactor reactor = Proton.reactor(reactorHandler); + + switch (this.faultType) { + case NetworkOutage: + reactor.setGlobalHandler(new NetworkOutageSimulator()); + break; + default: + throw new UnsupportedOperationException(); + } + + return reactor; + } + + public enum FaultType { + NetworkOutage + } + + public final static class NetworkOutageSimulator extends CustomIOHandler { + + @Override + public void onUnhandled(final Event event) { + switch (event.getType()) { + case CONNECTION_BOUND: + this.handleBound(event); + break; + default: + super.onUnhandled(event); + } + } + + private void handleBound(final Event event) { + final Transport transport = event.getConnection().getTransport(); + final ErrorCondition condition = new ErrorCondition(); + condition.setCondition(Symbol.getSymbol("proton:io")); + condition.setDescription("induced fault"); + transport.setCondition(condition); + transport.close_tail(); + transport.close_head(); + transport.pop(Math.max(0, transport.pending())); + + this.selectableTransport(event.getReactor(), null, transport); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/Mock/MockServer.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/Mock/MockServer.java new file mode 100644 index 000000000000..68b12fe76f1c --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/Mock/MockServer.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.lib.Mock; + +import com.microsoft.azure.eventhubs.lib.TestBase; +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.engine.BaseHandler; +import org.apache.qpid.proton.reactor.Acceptor; +import org.apache.qpid.proton.reactor.Reactor; + +import java.io.Closeable; +import java.io.IOException; +import java.util.logging.Level; + +/** + * Mock Server (Singleton) designed to test AMQP related features in the javaClient + */ +public class MockServer implements Closeable { + public final static String HostName = "127.0.0.1"; + public final static int Port = 5671; + + private Reactor reactor; + private Acceptor acceptor; + + private MockServer(BaseHandler handler) throws IOException, InterruptedException { + this.reactor = Proton.reactor(); + + new Thread(new Runnable() { + @Override + public void run() { + if (TestBase.TEST_LOGGER.isLoggable(Level.FINE)) { + TestBase.TEST_LOGGER.log(Level.FINE, "starting reactor instance."); + } + + reactor.run(); + } + }).start(); + + this.acceptor = this.reactor.acceptor(MockServer.HostName, MockServer.Port, + handler == null ? new ServerTraceHandler() : handler); + } + + public static MockServer Create(BaseHandler handler) throws IOException, InterruptedException { + MockServer server = new MockServer(handler); + return server; + } + + @Override + public void close() throws IOException { + if (this.acceptor != null) { + this.acceptor.close(); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/Mock/Sender1MsgOnLinkFlowHandler.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/Mock/Sender1MsgOnLinkFlowHandler.java new file mode 100644 index 000000000000..b72aeee61933 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/Mock/Sender1MsgOnLinkFlowHandler.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.lib.Mock; + +import com.microsoft.azure.eventhubs.impl.AmqpConstants; +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.messaging.ApplicationProperties; +import org.apache.qpid.proton.amqp.messaging.MessageAnnotations; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.engine.Sender; +import org.apache.qpid.proton.message.Message; +import org.apache.qpid.proton.reactor.Handshaker; + +import java.util.*; + +/** + * Sends 1 Msg on the first onLinkFlow event + */ +public class Sender1MsgOnLinkFlowHandler extends ServerTraceHandler { + private final Object firstFlow; + private boolean isFirstFlow; + + public Sender1MsgOnLinkFlowHandler() { + add(new Handshaker()); + + this.firstFlow = new Object(); + this.isFirstFlow = true; + } + + @Override + public void onLinkFlow(Event event) { + if (this.isFirstFlow) { + synchronized (this.firstFlow) { + if (this.isFirstFlow) { + Sender sender = (Sender) event.getLink(); + if (sender != null) { + byte[] bytes = new byte[5 * 1024]; + Message msg = Proton.message(); + Map properties = new HashMap(); + properties.put("testkey", "testvalue"); + msg.setApplicationProperties(new ApplicationProperties(properties)); + Map annotations = new HashMap(); + annotations.put(AmqpConstants.OFFSET, "11111111"); + MessageAnnotations msgAnnotation = new MessageAnnotations(annotations); + msg.setMessageAnnotations(msgAnnotation); + int length = msg.encode(bytes, 0, 4 * 1024); + + byte[] tag = String.valueOf(1).getBytes(); + sender.delivery(tag); + sender.send(bytes, 0, length); + + sender.advance(); + this.isFirstFlow = false; + } + } + } + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/Mock/ServerTraceHandler.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/Mock/ServerTraceHandler.java new file mode 100644 index 000000000000..db707685e078 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/Mock/ServerTraceHandler.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.lib.Mock; + +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.engine.*; +import org.apache.qpid.proton.engine.SslDomain.Mode; +import org.apache.qpid.proton.reactor.Handshaker; + +import java.util.*; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Traces all server events if enabled. used for debugging + */ +public class ServerTraceHandler extends BaseHandler { + + private static final Logger TRACE_LOGGER = Logger.getLogger("servicebus.test.trace"); + + public ServerTraceHandler(BaseHandler... handlers) { + add(new Handshaker()); + for (BaseHandler handler : handlers) { + add(handler); + } + } + + + @Override + public void onUnhandled(Event event) { + if (TRACE_LOGGER.isLoggable(Level.FINE)) { + TRACE_LOGGER.log(Level.FINE, + "Connection.onUnhandled: name[" + event.getConnection().getHostname() + "] : event[" + event + "]"); + } + super.onUnhandled(event); + } + + @Override + public void onConnectionBound(Event event) { + Transport transport = event.getTransport(); + SslDomain domain = Proton.sslDomain(); + domain.init(Mode.SERVER); + + domain.setPeerAuthentication(SslDomain.VerifyMode.ANONYMOUS_PEER); + transport.ssl(domain); + + Sasl sasl = transport.sasl(); + sasl.allowSkip(true); + sasl.setMechanisms("PLAIN"); + // sasl.done(SaslOutcome.PN_SASL_OK);*/ + } + + @Override + public void onConnectionRemoteOpen(Event event) { + super.onConnectionRemoteOpen(event); + event.getConnection().open(); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/SasTokenTestBase.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/SasTokenTestBase.java new file mode 100644 index 000000000000..fa522e663f9d --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/SasTokenTestBase.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.lib; + +import com.microsoft.azure.eventhubs.ConnectionStringBuilder; +import com.microsoft.azure.eventhubs.EventHubException; +import com.microsoft.azure.eventhubs.impl.SharedAccessSignatureTokenProvider; +import org.junit.AfterClass; +import org.junit.BeforeClass; + +import java.time.Duration; + +public class SasTokenTestBase extends ApiTestBase { + + private static ConnectionStringBuilder originalConnectionString; + + @BeforeClass + public static void replaceConnectionString() throws Exception { + + originalConnectionString = TestContext.getConnectionString(); + final String connectionStringWithSasToken = new ConnectionStringBuilder() + .setEndpoint(originalConnectionString.getEndpoint()) + .setEventHubName(originalConnectionString.getEventHubName()) + .setSharedAccessSignature( + SharedAccessSignatureTokenProvider.generateSharedAccessSignature(originalConnectionString.getSasKeyName(), + originalConnectionString.getSasKey(), + String.format("amqp://%s/%s", originalConnectionString.getEndpoint().getHost(), originalConnectionString.getEventHubName()), + Duration.ofDays(1)) + ) + .toString(); + + TestContext.setConnectionString(connectionStringWithSasToken); + } + + @AfterClass + public static void undoReplace() throws EventHubException { + + if (originalConnectionString != null) + TestContext.setConnectionString(originalConnectionString.toString()); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/TestBase.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/TestBase.java new file mode 100644 index 000000000000..afc861e80ef3 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/TestBase.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.lib; + +import com.microsoft.azure.eventhubs.EventData; +import com.microsoft.azure.eventhubs.EventHubClient; +import com.microsoft.azure.eventhubs.EventHubException; +import com.microsoft.azure.eventhubs.PartitionSender; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.logging.Logger; + +/** + * all tests derive from this base - provides common functionality + * - provides a way to checkout EventHub for each test to exclusively run with + * - ******* Before running all Tests - fill data here ********* + */ +public abstract class TestBase { + public static final Logger TEST_LOGGER = Logger.getLogger("servicebus.test.trace"); + + public static CompletableFuture pushEventsToPartition(final EventHubClient ehClient, final String partitionId, final int noOfEvents) + throws EventHubException { + return ehClient.createPartitionSender(partitionId) + .thenComposeAsync(new Function>() { + @Override + public CompletableFuture apply(PartitionSender pSender) { + @SuppressWarnings("unchecked") + final CompletableFuture[] sends = new CompletableFuture[noOfEvents]; + for (int count = 0; count < noOfEvents; count++) { + final EventData sendEvent = EventData.create("test string".getBytes()); + sends[count] = pSender.send(sendEvent); + } + + return CompletableFuture.allOf(sends); + } + }); + } +} \ No newline at end of file diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/TestContext.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/TestContext.java new file mode 100644 index 000000000000..fa5215a34212 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/lib/TestContext.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.lib; + +import com.microsoft.azure.eventhubs.ConnectionStringBuilder; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; + +public final class TestContext { + + public final static ScheduledExecutorService EXECUTOR_SERVICE = Executors.newScheduledThreadPool(1); + + final static String EVENT_HUB_CONNECTION_STRING_ENV_NAME = "EVENT_HUB_CONNECTION_STRING"; + + private static String CONNECTION_STRING = System.getenv(EVENT_HUB_CONNECTION_STRING_ENV_NAME); + + private TestContext() { + // eq. of c# static class + } + + public static ConnectionStringBuilder getConnectionString() { + return new ConnectionStringBuilder(CONNECTION_STRING); + } + + public static void setConnectionString(final String connectionString) { + CONNECTION_STRING = connectionString; + } + + public static String getConsumerGroupName() { + return "$default"; + } + + public static boolean isTestConfigurationSet() { + return System.getenv(EVENT_HUB_CONNECTION_STRING_ENV_NAME) != null; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/proxy/ProxyReceiveTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/proxy/ProxyReceiveTest.java new file mode 100644 index 000000000000..52551cc89688 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/proxy/ProxyReceiveTest.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.proxy; + +import com.microsoft.azure.eventhubs.ConnectionStringBuilder; +import com.microsoft.azure.eventhubs.EventHubException; +import com.microsoft.azure.eventhubs.TransportType; +import com.microsoft.azure.eventhubs.lib.SasTokenTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import com.microsoft.azure.eventhubs.sendrecv.ReceiveTest; +import org.jutils.jproxy.ProxyServer; +import org.junit.*; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.SocketAddress; +import java.net.URI; +import java.util.LinkedList; +import java.util.List; + +public class ProxyReceiveTest extends SasTokenTestBase { + + private static final int proxyPort = 8899; + private static ProxyServer proxyServer; + private static ReceiveTest receiveTest; + private static ProxySelector defaultProxySelector; + + @BeforeClass + public static void initialize() throws Exception { + proxyServer = ProxyServer.create("localhost", proxyPort); + proxyServer.start(t -> {}); + + defaultProxySelector = ProxySelector.getDefault(); + ProxySelector.setDefault(new ProxySelector() { + @Override + public List select(URI uri) { + LinkedList proxies = new LinkedList<>(); + proxies.add(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost", proxyPort))); + return proxies; + } + + @Override + public void connectFailed(URI uri, SocketAddress sa, IOException ioe) { + // no-op + } + }); + + Assert.assertTrue(TestContext.getConnectionString().getSharedAccessSignature() != null + && TestContext.getConnectionString().getSasKey() == null + && TestContext.getConnectionString().getSasKeyName() == null); + + receiveTest = new ReceiveTest(); + ConnectionStringBuilder connectionString = TestContext.getConnectionString(); + connectionString.setTransportType(TransportType.AMQP_WEB_SOCKETS); + ReceiveTest.initializeEventHub(connectionString); + } + + @AfterClass() + public static void cleanup() throws Exception { + ReceiveTest.cleanup(); + + if (proxyServer != null) { + proxyServer.stop(); + } + + ProxySelector.setDefault(defaultProxySelector); + } + + @Test() + public void testReceiverStartOfStreamFilters() throws EventHubException { + receiveTest.testReceiverStartOfStreamFilters(); + } + + @After + public void testCleanup() throws EventHubException { + receiveTest.testCleanup(); + } +} \ No newline at end of file diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/proxy/ProxySelectorTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/proxy/ProxySelectorTest.java new file mode 100644 index 000000000000..fb2733e5c409 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/proxy/ProxySelectorTest.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.proxy; + +import com.microsoft.azure.eventhubs.ConnectionStringBuilder; +import com.microsoft.azure.eventhubs.EventHubClient; +import com.microsoft.azure.eventhubs.EventHubException; +import com.microsoft.azure.eventhubs.TransportType; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.SocketAddress; +import java.net.URI; +import java.time.Duration; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +public class ProxySelectorTest extends ApiTestBase { + + @Test + public void proxySelectorConnectFailedInvokeTest() throws Exception { + // doesn't start proxy server and verifies that the connectFailed callback is invoked. + int proxyPort = 8899; + final CompletableFuture connectFailedTask = new CompletableFuture<>(); + final ProxySelector defaultProxySelector = ProxySelector.getDefault(); + ProxySelector.setDefault(new ProxySelector() { + @Override + public List select(URI uri) { + LinkedList proxies = new LinkedList<>(); + proxies.add(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost", proxyPort))); + return proxies; + } + + @Override + public void connectFailed(URI uri, SocketAddress sa, IOException ioe) { + connectFailedTask.complete(null); + } + }); + + try { + ConnectionStringBuilder builder = new ConnectionStringBuilder(TestContext.getConnectionString().toString()); + builder.setTransportType(TransportType.AMQP_WEB_SOCKETS); + builder.setOperationTimeout(Duration.ofSeconds(10)); + + try { + EventHubClient.createSync(builder.toString(), TestContext.EXECUTOR_SERVICE); + Assert.assertTrue(false); // shouldn't reach here + } catch (EventHubException ex) { + Assert.assertEquals("connection aborted", ex.getMessage()); + } + + connectFailedTask.get(2, TimeUnit.SECONDS); + } finally { + ProxySelector.setDefault(defaultProxySelector); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/proxy/ProxySendLargeMessageTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/proxy/ProxySendLargeMessageTest.java new file mode 100644 index 000000000000..ec8f94b67980 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/proxy/ProxySendLargeMessageTest.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.proxy; + +import com.microsoft.azure.eventhubs.ConnectionStringBuilder; +import com.microsoft.azure.eventhubs.EventHubException; +import com.microsoft.azure.eventhubs.PayloadSizeExceededException; +import com.microsoft.azure.eventhubs.TransportType; +import com.microsoft.azure.eventhubs.exceptioncontracts.SendLargeMessageTest; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.jutils.jproxy.ProxyServer; + +import java.io.IOException; +import java.net.*; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.ExecutionException; + +public class ProxySendLargeMessageTest extends ApiTestBase { + private static int proxyPort = 8899; + private static ProxyServer proxyServer; + private static SendLargeMessageTest sendLargeMessageTest; + private static ProxySelector defaultProxySelector; + + @BeforeClass + public static void initialize() throws Exception { + proxyServer = ProxyServer.create("localhost", proxyPort); + proxyServer.start(t -> { + }); + + defaultProxySelector = ProxySelector.getDefault(); + ProxySelector.setDefault(new ProxySelector() { + @Override + public List select(URI uri) { + LinkedList proxies = new LinkedList<>(); + proxies.add(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost", proxyPort))); + return proxies; + } + + @Override + public void connectFailed(URI uri, SocketAddress sa, IOException ioe) { + // no-op + } + }); + + final ConnectionStringBuilder connectionStringBuilder = TestContext.getConnectionString(); + connectionStringBuilder.setTransportType(TransportType.AMQP_WEB_SOCKETS); + sendLargeMessageTest = new SendLargeMessageTest(); + SendLargeMessageTest.initializeEventHubClients(connectionStringBuilder); + } + + @AfterClass() + public static void cleanup() throws Exception { + SendLargeMessageTest.cleanup(); + + if (proxyServer != null) { + proxyServer.stop(); + } + + ProxySelector.setDefault(defaultProxySelector); + } + + @Test() + public void sendMsgLargerThan64k() throws EventHubException, InterruptedException, ExecutionException, IOException { + sendLargeMessageTest.sendMsgLargerThan64k(); + } + + @Test(expected = PayloadSizeExceededException.class) + public void sendMsgLargerThan256K() throws EventHubException, InterruptedException, ExecutionException, IOException { + sendLargeMessageTest.sendMsgLargerThan1024K(); + } + + @Test() + public void sendMsgLargerThan128k() throws EventHubException, InterruptedException, ExecutionException, IOException { + sendLargeMessageTest.sendMsgLargerThan128k(); + } +} \ No newline at end of file diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/proxy/ProxySendTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/proxy/ProxySendTest.java new file mode 100644 index 000000000000..8c47489dfcf7 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/proxy/ProxySendTest.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.proxy; + +import com.microsoft.azure.eventhubs.ConnectionStringBuilder; +import com.microsoft.azure.eventhubs.EventHubClient; +import com.microsoft.azure.eventhubs.EventHubException; +import com.microsoft.azure.eventhubs.TransportType; +import com.microsoft.azure.eventhubs.lib.SasTokenTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import com.microsoft.azure.eventhubs.sendrecv.SendTest; +import org.jutils.jproxy.ProxyServer; +import org.junit.*; + +import java.io.IOException; +import java.net.*; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +public class ProxySendTest extends SasTokenTestBase { + + private static int proxyPort = 8899; + private static ProxyServer proxyServer; + private static SendTest sendTest; + private static ProxySelector defaultProxySelector; + + @BeforeClass + public static void initialize() throws Exception { + proxyServer = ProxyServer.create("localhost", proxyPort); + proxyServer.start(t -> {}); + + defaultProxySelector = ProxySelector.getDefault(); + ProxySelector.setDefault(new ProxySelector() { + @Override + public List select(URI uri) { + LinkedList proxies = new LinkedList<>(); + proxies.add(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost", proxyPort))); + return proxies; + } + + @Override + public void connectFailed(URI uri, SocketAddress sa, IOException ioe) { + // no-op + } + }); + + Assert.assertTrue(TestContext.getConnectionString().getSharedAccessSignature() != null + && TestContext.getConnectionString().getSasKey() == null + && TestContext.getConnectionString().getSasKeyName() == null); + + sendTest = new SendTest(); + + ConnectionStringBuilder connectionString = TestContext.getConnectionString(); + connectionString.setTransportType(TransportType.AMQP_WEB_SOCKETS); + SendTest.initializeEventHub(connectionString); + } + + @AfterClass + public static void cleanupClient() throws Exception { + + SendTest.cleanupClient(); + + if (proxyServer != null) { + proxyServer.stop(); + } + + ProxySelector.setDefault(defaultProxySelector); + } + + @Test + public void sendBatchRetainsOrderWithinBatch() throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + + sendTest.sendBatchRetainsOrderWithinBatch(); + } + + @Test + public void sendResultsInSysPropertiesWithPartitionKey() throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + + sendTest.sendResultsInSysPropertiesWithPartitionKey(); + } + + @After + public void cleanup() throws Exception { + + sendTest.cleanup(); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/EventDataBatchAPITest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/EventDataBatchAPITest.java new file mode 100644 index 000000000000..cd4b94ccd262 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/EventDataBatchAPITest.java @@ -0,0 +1,268 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.sendrecv; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import junit.framework.AssertionFailedError; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.time.Duration; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +public class EventDataBatchAPITest extends ApiTestBase { + + private static final String cgName = TestContext.getConsumerGroupName(); + private static final String partitionId = "0"; + private static EventHubClient ehClient; + private static PartitionSender sender = null; + + @BeforeClass + public static void initializeEventHub() throws Exception { + final ConnectionStringBuilder connectionString = TestContext.getConnectionString(); + ehClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + sender = ehClient.createPartitionSenderSync(partitionId); + } + + @AfterClass + public static void cleanupClient() throws EventHubException { + if (sender != null) + sender.closeSync(); + + if (ehClient != null) + ehClient.closeSync(); + } + + @Test + public void sendSmallEventsFullBatchTest() + throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + final EventDataBatch batchEvents = sender.createBatch(); + + while (batchEvents.tryAdd(EventData.create("a".getBytes()))) ; + + sender = ehClient.createPartitionSenderSync(partitionId); + sender.sendSync(batchEvents); + } + + @Test + public void sendSmallEventsFullBatchPartitionKeyTest() + throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + final BatchOptions options = new BatchOptions() + .with(o -> o.partitionKey = UUID.randomUUID().toString()); + final EventDataBatch batchEvents = ehClient.createBatch(options); + + while (batchEvents.tryAdd(EventData.create("a".getBytes()))) ; + + ehClient.sendSync(batchEvents); + } + + @Test + public void sendBatchPartitionKeyValidateTest() + throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + final String partitionKey = UUID.randomUUID().toString(); + + final BatchOptions options = new BatchOptions().with(o -> o.partitionKey = partitionKey); + final EventDataBatch batchEvents = ehClient.createBatch(options); + + int count = 0; + while (batchEvents.tryAdd(EventData.create("a".getBytes())) && count++ < 10) ; + + final int sentCount = count; + final CompletableFuture testResult = new CompletableFuture<>(); + final PartitionReceiveHandler validator = new PartitionReceiveHandler() { + final AtomicInteger netCount = new AtomicInteger(0); + + @Override + public int getMaxEventCount() { + return 100; + } + + @Override + public void onReceive(Iterable events) { + if (events != null) { + final Iterator eterator = events.iterator(); + while (eterator.hasNext()) { + final EventData currentData = eterator.next(); + final String currentPartitionKey = currentData.getSystemProperties().getPartitionKey(); + if (!currentPartitionKey.equalsIgnoreCase(partitionKey)) + testResult.completeExceptionally(new AssertionFailedError()); + + final int countSoFar = netCount.incrementAndGet(); + if (countSoFar >= sentCount) + testResult.complete(null); + } + } + } + + @Override + public void onError(Throwable error) { + testResult.completeExceptionally(error); + } + }; + + final LinkedList receivers = new LinkedList<>(); + try { + final String[] partitionIds = ehClient.getRuntimeInformation().get().getPartitionIds(); + for (int index = 0; index < partitionIds.length; index++) { + final PartitionReceiver receiver = ehClient.createReceiverSync(TestContext.getConsumerGroupName(), partitionIds[index], EventPosition.fromEndOfStream()); + receiver.setReceiveTimeout(Duration.ofSeconds(5)); + receiver.setReceiveHandler(validator); + receivers.add(receiver); + } + + ehClient.sendSync(batchEvents); + testResult.get(); + } finally { + if (receivers.size() > 0) + receivers.forEach(new Consumer() { + @Override + public void accept(PartitionReceiver partitionReceiver) { + try { + partitionReceiver.closeSync(); + } catch (EventHubException ignore) { + } + } + }); + } + } + + @Test + public void sendEventsFullBatchWithAppPropsTest() + throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + final CompletableFuture validator = new CompletableFuture<>(); + final PartitionReceiver receiver = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromEndOfStream()); + receiver.setReceiveTimeout(Duration.ofSeconds(5)); + + try { + final EventDataBatch batchEvents = sender.createBatch(); + + int count = 0; + while (true) { + final EventData eventData = EventData.create(new String(new char[50000]).replace("\0", "a").getBytes()); + for (int i = 0; i < new Random().nextInt(20); i++) + eventData.getProperties().put("somekey" + i, "somevalue"); + + if (batchEvents.tryAdd(eventData)) + count++; + else + break; + } + + Assert.assertEquals(count, batchEvents.getSize()); + receiver.setReceiveHandler(new CountValidator(validator, count)); + + sender.sendSync(batchEvents); + + validator.get(100, TimeUnit.SECONDS); + + receiver.setReceiveHandler(null); + } finally { + receiver.closeSync(); + } + } + + @Test + public void sendEventsFullBatchWithPartitionKeyTest() + throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + + final String partitionKey = UUID.randomUUID().toString(); + final BatchOptions options = new BatchOptions().with(o -> o.partitionKey = partitionKey); + final EventDataBatch batchEvents = ehClient.createBatch(options); + + int count = 0; + while (true) { + final EventData eventData = EventData.create(new String("a").getBytes()); + for (int i = 0; i < new Random().nextInt(20); i++) + eventData.getProperties().put("somekey" + i, "somevalue"); + + if (batchEvents.tryAdd(eventData)) + count++; + else + break; + } + + Assert.assertEquals(count, batchEvents.getSize()); + ehClient.sendSync(batchEvents); + } + + @Test(expected = IllegalArgumentException.class) + public void sendBatchWithPartitionKeyOnPartitionSenderTest() + throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + + + final BatchOptions options = new BatchOptions().with(o -> o.partitionKey = UUID.randomUUID().toString()); + final EventDataBatch batchEvents = sender.createBatch(options); + + int count = 0; + while (true) { + final EventData eventData = EventData.create(new String("a").getBytes()); + for (int i = 0; i < new Random().nextInt(20); i++) + eventData.getProperties().put("somekey" + i, "somevalue"); + + if (batchEvents.tryAdd(eventData)) + count++; + else + break; + } + + Assert.assertEquals(count, batchEvents.getSize()); + + // the CreateBatch was created without taking PartitionKey size into account + // so this call should fail with payload size exceeded + sender.sendSync(batchEvents); + } + + public static class CountValidator implements PartitionReceiveHandler { + final CompletableFuture validateSignal; + final int netEventCount; + + int currentCount = 0; + + public CountValidator(final CompletableFuture validateSignal, final int netEventCount) { + + this.validateSignal = validateSignal; + this.netEventCount = netEventCount; + } + + @Override + public int getMaxEventCount() { + return PartitionReceiver.DEFAULT_PREFETCH_COUNT; + } + + @Override + public void onReceive(Iterable events) { + if (events != null) + for (EventData event : events) { + currentCount++; + } + + if (currentCount >= netEventCount) + this.validateSignal.complete(null); + + try { + Thread.sleep(100); // wait for events to accumulate in the receive pump + } catch (InterruptedException ignore) { + } + } + + @Override + public void onError(Throwable error) { + this.validateSignal.completeExceptionally(error); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/ReceiveParallelManualTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/ReceiveParallelManualTest.java new file mode 100644 index 000000000000..86935e74148d --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/ReceiveParallelManualTest.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.sendrecv; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.impl.IteratorUtil; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.AfterClass; +import org.junit.BeforeClass; + +import java.util.concurrent.ExecutionException; +import java.util.logging.FileHandler; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.logging.SimpleFormatter; + +public class ReceiveParallelManualTest extends ApiTestBase { + static final String cgName = TestContext.getConsumerGroupName(); + static final String partitionId = "0"; + + static EventHubClient[] ehClient; + + + @BeforeClass + public static void initializeEventHub() throws Exception { + FileHandler fhc = new FileHandler("c:\\proton-sb-sendbatch-1100.log", false); + Logger lc1 = Logger.getLogger("servicebus.trace"); + fhc.setFormatter(new SimpleFormatter()); + lc1.addHandler(fhc); + lc1.setLevel(Level.FINE); + + final ConnectionStringBuilder connectionString = TestContext.getConnectionString(); + ehClient = new EventHubClient[4]; + ehClient[0] = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + ehClient[1] = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + ehClient[2] = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + ehClient[3] = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + } + + @AfterClass() + public static void cleanup() throws EventHubException { + for (int i = 0; i < 4; i++) + if (ehClient[i] != null) { + ehClient[i].closeSync(); + } + } + + // Run this test manually and introduce network failures to test + // send/receive code is resilient to n/w failures + // and continues to run once the n/w is back online + // @Test() + public void testReceiverStartOfStreamFilters() throws Exception { + new Thread(new PRunnable("0")).start(); + new Thread(new PRunnable("1")).start(); + new Thread(new PRunnable("2")).start(); + new Thread(new PRunnable("3")).start(); + System.out.println("scheduled receivers"); + System.in.read(); + } + + class PRunnable implements Runnable { + final String sPartitionId; + + PRunnable(final String sPartitionId) { + this.sPartitionId = sPartitionId; + } + + @Override + public void run() { + + int partitionIdInt = Integer.parseInt(sPartitionId); + try { + TestBase.pushEventsToPartition(ehClient[partitionIdInt], sPartitionId, 100).get(); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } catch (EventHubException e) { + e.printStackTrace(); + } + + PartitionReceiver offsetReceiver1 = null; + try { + offsetReceiver1 = + ehClient[partitionIdInt].createReceiverSync(cgName, sPartitionId, EventPosition.fromStartOfStream()); + } catch (EventHubException e) { + e.printStackTrace(); + } + + Iterable receivedEvents; + long totalEvents = 0L; + while (true) { + try { + if ((receivedEvents = offsetReceiver1.receiveSync(10)) != null && !IteratorUtil.sizeEquals(receivedEvents, 0)) { + + long batchSize = (1 + IteratorUtil.getLast(receivedEvents.iterator()).getSystemProperties().getSequenceNumber()) - + (IteratorUtil.getFirst(receivedEvents).getSystemProperties().getSequenceNumber()); + totalEvents += batchSize; + System.out.println(String.format("[partitionId: %s] received %s events; total sofar: %s, begin: %s, end: %s", + sPartitionId, + batchSize, + totalEvents, + IteratorUtil.getLast(receivedEvents.iterator()).getSystemProperties().getSequenceNumber(), + IteratorUtil.getFirst(receivedEvents).getSystemProperties().getSequenceNumber())); + } else { + System.out.println(String.format("received null on partition %s", sPartitionId)); + } + } catch (Exception exp) { + System.out.println(exp.getMessage() + exp.toString()); + } + + try { + Thread.sleep(150); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/ReceivePumpEventHubTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/ReceivePumpEventHubTest.java new file mode 100644 index 000000000000..9383cfbd44ee --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/ReceivePumpEventHubTest.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.sendrecv; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.*; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public class ReceivePumpEventHubTest extends ApiTestBase { + static final String cgName = TestContext.getConsumerGroupName(); + static final String partitionId = "0"; + + static EventHubClient ehClient; + + PartitionReceiver receiver; + + @BeforeClass + public static void initializeEventHub() throws EventHubException, IOException { + final ConnectionStringBuilder connectionString = TestContext.getConnectionString(); + ehClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + } + + @AfterClass + public static void cleanup() throws EventHubException { + if (ehClient != null) + ehClient.closeSync(); + } + + @Before + public void initializeTest() throws EventHubException { + receiver = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromEnqueuedTime(Instant.now())); + } + + @Test(expected = TimeoutException.class) + public void testInvokeOnTimeoutKnobDefault() throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + CompletableFuture invokeSignal = new CompletableFuture(); + receiver.setReceiveTimeout(Duration.ofSeconds(1)); + receiver.setReceiveHandler(new InvokeOnReceiveEventValidator(invokeSignal)); + invokeSignal.get(3, TimeUnit.SECONDS); + } + + @Test(expected = TimeoutException.class) + public void testInvokeOnTimeoutKnobFalse() throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + CompletableFuture invokeSignal = new CompletableFuture(); + receiver.setReceiveTimeout(Duration.ofSeconds(1)); + receiver.setReceiveHandler(new InvokeOnReceiveEventValidator(invokeSignal), false); + invokeSignal.get(3, TimeUnit.SECONDS); + } + + @Test() + public void testInvokeOnTimeoutKnobTrue() throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + CompletableFuture invokeSignal = new CompletableFuture(); + receiver.setReceiveTimeout(Duration.ofSeconds(1)); + receiver.setReceiveHandler(new InvokeOnReceiveEventValidator(invokeSignal), true); + invokeSignal.get(3, TimeUnit.SECONDS); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvokeWithInvalidArgs() throws Throwable { + final CompletableFuture invokeSignal = new CompletableFuture(); + receiver.setReceiveTimeout(Duration.ofSeconds(1)); + receiver.setReceiveHandler(new InvokeOnReceiveEventValidator(invokeSignal, PartitionReceiver.DEFAULT_PREFETCH_COUNT + 1), true); + try { + invokeSignal.get(3, TimeUnit.SECONDS); + } catch (ExecutionException executionException) { + throw executionException.getCause(); + } + } + + @Test(expected = IllegalArgumentException.class) + public void testSetReceiveHandlerMultipleTimes() throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + CompletableFuture invokeSignal = new CompletableFuture(); + receiver.setReceiveTimeout(Duration.ofSeconds(1)); + receiver.setReceiveHandler(new InvokeOnReceiveEventValidator(invokeSignal), true); + + receiver.setReceiveHandler(new InvokeOnReceiveEventValidator(invokeSignal), true); + } + + @Test() + public void testGraceFullCloseReceivePump() throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + CompletableFuture invokeSignal = new CompletableFuture(); + receiver.setReceiveTimeout(Duration.ofSeconds(1)); + receiver.setReceiveHandler(new InvokeOnReceiveEventValidator(invokeSignal), true); + + receiver.setReceiveHandler(null).get(); + + invokeSignal = new CompletableFuture(); + receiver.setReceiveHandler(new InvokeOnReceiveEventValidator(invokeSignal), true); + invokeSignal.get(3, TimeUnit.SECONDS); + } + + @After + public void cleanupTest() throws EventHubException { + if (receiver != null) + receiver.closeSync(); + } + + public static final class InvokeOnReceiveEventValidator implements PartitionReceiveHandler { + final CompletableFuture signalInvoked; + final int maxEventCount; + + public InvokeOnReceiveEventValidator(final CompletableFuture signalInvoked) { + this(signalInvoked, 50); + } + + public InvokeOnReceiveEventValidator(final CompletableFuture signalInvoked, final int maxEventCount) { + this.signalInvoked = signalInvoked; + this.maxEventCount = maxEventCount; + } + + @Override + public int getMaxEventCount() { + return this.maxEventCount; + } + + @Override + public void onReceive(Iterable events) { + this.signalInvoked.complete(null); + } + + @Override + public void onError(Throwable error) { + this.signalInvoked.completeExceptionally(error); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/ReceivePumpTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/ReceivePumpTest.java new file mode 100644 index 000000000000..878fdbdd39c8 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/ReceivePumpTest.java @@ -0,0 +1,226 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.sendrecv; + +import com.microsoft.azure.eventhubs.EventData; +import com.microsoft.azure.eventhubs.EventHubException; +import com.microsoft.azure.eventhubs.PartitionReceiveHandler; +import com.microsoft.azure.eventhubs.TimeoutException; +import com.microsoft.azure.eventhubs.impl.IteratorUtil; +import com.microsoft.azure.eventhubs.impl.ReceivePump; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.util.LinkedList; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +public class ReceivePumpTest { + private final String exceptionMessage = "receive Exception"; + private volatile boolean assertion = false; + + @Before + public void initializeValidation() { + assertion = false; + } + + @Test() + public void testPumpOnReceiveEventFlow() throws Exception { + final CompletableFuture pumpRun = new CompletableFuture<>(); + final ReceivePump receivePump = new ReceivePump( + "eventhub1", "consumerGroup1", + new ReceivePump.IPartitionReceiver() { + @Override + public CompletableFuture> receive(int maxBatchSize) { + final LinkedList events = new LinkedList(); + events.add(EventData.create("some".getBytes())); + return CompletableFuture.completedFuture(events); + } + + @Override + public String getPartitionId() { + return "0"; + } + }, + new PartitionReceiveHandler() { + @Override + public int getMaxEventCount() { + return 10; + } + + @Override + public void onReceive(Iterable events) { + assertion = IteratorUtil.sizeEquals(events, 1); + + // stop-pump + throw new PumpClosedException(); + } + + @Override + public void onError(Throwable error) { + Assert.assertTrue(error instanceof PumpClosedException); + pumpRun.complete(null); + } + }, + true, + TestContext.EXECUTOR_SERVICE); + + try { + receivePump.receiveAndProcess(); + pumpRun.get(); + } finally { + receivePump.stop().get(); + } + + Assert.assertTrue(assertion); + } + + @Test() + public void testPumpReceiveTransientErrorsPropagated() throws Exception { + final CompletableFuture pumpRun = new CompletableFuture<>(); + final ReceivePump receivePump = new ReceivePump( + "eventhub1", "consumerGroup1", + new ReceivePump.IPartitionReceiver() { + @Override + public CompletableFuture> receive(int maxBatchSize) { + final CompletableFuture> result = new CompletableFuture<>(); + result.completeExceptionally(new RuntimeException(exceptionMessage)); + return result; + } + + @Override + public String getPartitionId() { + return "0"; + } + }, + new PartitionReceiveHandler() { + @Override + public int getMaxEventCount() { + return 10; + } + + @Override + public void onReceive(Iterable events) { + } + + @Override + public void onError(Throwable error) { + assertion = error.getMessage().equals(exceptionMessage); + pumpRun.complete(null); + } + }, + false, + TestContext.EXECUTOR_SERVICE); + + try { + receivePump.receiveAndProcess(); + pumpRun.get(); + } finally { + receivePump.stop().get(); + } + + Assert.assertTrue(assertion); + } + + @Test() + public void testPumpReceiveExceptionsPropagated() throws Exception { + final CompletableFuture pumpRun = new CompletableFuture<>(); + final ReceivePump receivePump = new ReceivePump( + "eventhub1", "consumerGroup1", + new ReceivePump.IPartitionReceiver() { + @Override + public CompletableFuture> receive(int maxBatchSize) { + final CompletableFuture> result = new CompletableFuture<>(); + result.completeExceptionally(new RuntimeException(exceptionMessage)); + return result; + } + + @Override + public String getPartitionId() { + return "0"; + } + }, + new PartitionReceiveHandler() { + @Override + public int getMaxEventCount() { + return 10; + } + + @Override + public void onReceive(Iterable events) { + } + + @Override + public void onError(Throwable error) { + assertion = error.getMessage().equals(exceptionMessage); + pumpRun.complete(null); + } + }, + true, + TestContext.EXECUTOR_SERVICE); + + try { + receivePump.receiveAndProcess(); + pumpRun.get(); + } finally { + receivePump.stop().get(); + } + + Assert.assertTrue(assertion); + } + + @Test() + public void testPumpOnReceiveExceptionsPropagated() throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + final String runtimeExceptionMsg = "random exception"; + final CompletableFuture pumpRun = new CompletableFuture<>(); + final ReceivePump receivePump = new ReceivePump( + "eventhub1", "consumerGroup1", + new ReceivePump.IPartitionReceiver() { + @Override + public CompletableFuture> receive(int maxBatchSize) { + return CompletableFuture.completedFuture(null); + } + + @Override + public String getPartitionId() { + return "0"; + } + }, + new PartitionReceiveHandler() { + @Override + public int getMaxEventCount() { + return 10; + } + + @Override + public void onReceive(Iterable events) { + throw new RuntimeException(runtimeExceptionMsg); + } + + @Override + public void onError(Throwable error) { + assertion = error.getMessage().equals(runtimeExceptionMsg); + pumpRun.complete(null); + } + }, + true, + TestContext.EXECUTOR_SERVICE); + + try { + receivePump.receiveAndProcess(); + pumpRun.get(); + } finally { + receivePump.stop().get(); + } + + Assert.assertTrue(assertion); + } + + public class PumpClosedException extends RuntimeException { + private static final long serialVersionUID = -5050327636359966016L; + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/ReceiveTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/ReceiveTest.java new file mode 100644 index 000000000000..fe072dcf610e --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/ReceiveTest.java @@ -0,0 +1,199 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.sendrecv; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.impl.AmqpConstants; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.*; + +import java.time.Duration; +import java.time.Instant; +import java.util.Iterator; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; + +public class ReceiveTest extends ApiTestBase { + static final String cgName = TestContext.getConsumerGroupName(); + static final String partitionId = "0"; + + static EventHubClient ehClient; + + PartitionReceiver offsetReceiver = null; + PartitionReceiver datetimeReceiver = null; + + @BeforeClass + public static void initialize() throws Exception { + final ConnectionStringBuilder connectionString = TestContext.getConnectionString(); + initializeEventHub(connectionString); + } + + public static void initializeEventHub(ConnectionStringBuilder connectionString) throws Exception { + ehClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + TestBase.pushEventsToPartition(ehClient, partitionId, 25).get(); + } + + @AfterClass() + public static void cleanup() throws EventHubException { + if (ehClient != null) { + ehClient.closeSync(); + } + } + + @Test() + public void testReceiverStartOfStreamFilters() throws EventHubException { + offsetReceiver = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromStartOfStream()); + Iterable startingEventsUsingOffsetReceiver = offsetReceiver.receiveSync(100); + + Assert.assertTrue(startingEventsUsingOffsetReceiver != null && startingEventsUsingOffsetReceiver.iterator().hasNext()); + + datetimeReceiver = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromEnqueuedTime(Instant.EPOCH)); + Iterable startingEventsUsingDateTimeReceiver = datetimeReceiver.receiveSync(100); + + Assert.assertTrue(startingEventsUsingOffsetReceiver != null && startingEventsUsingDateTimeReceiver.iterator().hasNext()); + + Iterator dateTimeIterator = startingEventsUsingDateTimeReceiver.iterator(); + for (EventData eventDataUsingOffset : startingEventsUsingOffsetReceiver) { + EventData eventDataUsingDateTime = dateTimeIterator.next(); + Assert.assertTrue( + String.format("START_OF_STREAM offset: %s, EPOCH offset: %s", eventDataUsingOffset.getSystemProperties().getOffset(), eventDataUsingDateTime.getSystemProperties().getOffset()), + eventDataUsingOffset.getSystemProperties().getOffset().equalsIgnoreCase(eventDataUsingDateTime.getSystemProperties().getOffset())); + + if (!dateTimeIterator.hasNext()) + break; + } + } + + @Test() + public void testReceiverLatestFilter() throws EventHubException, ExecutionException, InterruptedException { + offsetReceiver = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromEndOfStream()); + Iterable events = offsetReceiver.receiveSync(100); + Assert.assertTrue(events == null); + + TestBase.pushEventsToPartition(ehClient, partitionId, 10).get(); + events = offsetReceiver.receiveSync(100); + Assert.assertTrue(events != null && events.iterator().hasNext()); + } + + @Test() + public void testReceiverOffsetInclusiveFilter() throws EventHubException { + datetimeReceiver = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromEnqueuedTime(Instant.EPOCH)); + final Iterable events = datetimeReceiver.receiveSync(100); + + Assert.assertTrue(events != null && events.iterator().hasNext()); + final EventData event = events.iterator().next(); + + offsetReceiver = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromOffset(event.getSystemProperties().getOffset(), true)); + final EventData eventReturnedByOffsetReceiver = offsetReceiver.receiveSync(10).iterator().next(); + + Assert.assertTrue(eventReturnedByOffsetReceiver.getSystemProperties().getOffset().equals(event.getSystemProperties().getOffset())); + Assert.assertTrue(eventReturnedByOffsetReceiver.getSystemProperties().getSequenceNumber() == event.getSystemProperties().getSequenceNumber()); + } + + @Test() + public void testReceiverOffsetNonInclusiveFilter() throws EventHubException { + datetimeReceiver = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromEnqueuedTime(Instant.EPOCH)); + Iterable events = datetimeReceiver.receiveSync(100); + + Assert.assertTrue(events != null && events.iterator().hasNext()); + + EventData event = events.iterator().next(); + offsetReceiver = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromOffset(event.getSystemProperties().getOffset(), false)); + EventData eventReturnedByOffsetReceiver = offsetReceiver.receiveSync(10).iterator().next(); + + Assert.assertTrue(eventReturnedByOffsetReceiver.getSystemProperties().getSequenceNumber() == event.getSystemProperties().getSequenceNumber() + 1); + } + + @Test() + public void testReceiverSequenceNumberInclusiveFilter() throws EventHubException { + datetimeReceiver = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromEnqueuedTime(Instant.EPOCH)); + Iterable events = datetimeReceiver.receiveSync(100); + + Assert.assertTrue(events != null && events.iterator().hasNext()); + EventData event = events.iterator().next(); + + offsetReceiver = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromSequenceNumber(event.getSystemProperties().getSequenceNumber(), true)); + EventData eventReturnedByOffsetReceiver = offsetReceiver.receiveSync(10).iterator().next(); + + Assert.assertTrue(eventReturnedByOffsetReceiver.getSystemProperties().getOffset().equals(event.getSystemProperties().getOffset())); + Assert.assertTrue(eventReturnedByOffsetReceiver.getSystemProperties().getSequenceNumber() == event.getSystemProperties().getSequenceNumber()); + } + + @Test() + public void testReceiverSequenceNumberNonInclusiveFilter() throws EventHubException { + datetimeReceiver = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromEnqueuedTime(Instant.EPOCH)); + Iterable events = datetimeReceiver.receiveSync(100); + + Assert.assertTrue(events != null && events.iterator().hasNext()); + + EventData event = events.iterator().next(); + offsetReceiver = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromSequenceNumber(event.getSystemProperties().getSequenceNumber(), false)); + EventData eventReturnedByOffsetReceiver = offsetReceiver.receiveSync(10).iterator().next(); + + Assert.assertTrue(eventReturnedByOffsetReceiver.getSystemProperties().getSequenceNumber() == event.getSystemProperties().getSequenceNumber() + 1); + } + + @Test() + public void testReceivedBodyAndProperties() throws EventHubException { + datetimeReceiver = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromEndOfStream()); + datetimeReceiver.setReceiveTimeout(Duration.ofSeconds(5)); + + Iterable drainedEvents = datetimeReceiver.receiveSync(100); + while (drainedEvents != null && drainedEvents.iterator().hasNext()) { + drainedEvents = datetimeReceiver.receiveSync(100); + } + + final String payload = "TestMessage1"; + final String property1 = "property1"; + final String propertyValue1 = "something1"; + final String property2 = AmqpConstants.AMQP_PROPERTY_MESSAGE_ID; + final String propertyValue2 = "something2"; + + final Consumer validateReceivedEvent = new Consumer() { + @Override + public void accept(EventData event) { + Assert.assertEquals(new String(event.getBytes()), payload); + Assert.assertTrue(event.getProperties().containsKey(property1) && event.getProperties().get(property1).equals(propertyValue1)); + Assert.assertTrue(event.getProperties().containsKey(property2) && event.getProperties().get(property2).equals(propertyValue2)); + Assert.assertTrue(event.getSystemProperties().getOffset() != null); + Assert.assertTrue(event.getSystemProperties().getSequenceNumber() > 0L); + Assert.assertTrue(event.getSystemProperties().getEnqueuedTime() != null); + Assert.assertTrue(event.getSystemProperties().getPartitionKey() == null); + Assert.assertTrue(event.getSystemProperties().getPublisher() == null); + } + }; + + final EventData sentEvent = EventData.create(payload.getBytes()); + sentEvent.getProperties().put(property1, propertyValue1); + sentEvent.getProperties().put(property2, propertyValue2); + final PartitionSender sender = ehClient.createPartitionSenderSync(partitionId); + try { + sender.sendSync(sentEvent); + final EventData receivedEvent = datetimeReceiver.receiveSync(10).iterator().next(); + validateReceivedEvent.accept(receivedEvent); + + sender.sendSync(receivedEvent); + final EventData reSendReceivedEvent = datetimeReceiver.receiveSync(10).iterator().next(); + validateReceivedEvent.accept(reSendReceivedEvent); + } finally { + sender.closeSync(); + } + } + + @After + public void testCleanup() throws EventHubException { + if (offsetReceiver != null) { + offsetReceiver.closeSync(); + offsetReceiver = null; + } + + if (datetimeReceiver != null) { + datetimeReceiver.closeSync(); + datetimeReceiver = null; + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/ReceiverIdentifierTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/ReceiverIdentifierTest.java new file mode 100644 index 000000000000..d16233f416e8 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/ReceiverIdentifierTest.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.sendrecv; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.time.Instant; +import java.util.LinkedList; +import java.util.List; +import java.util.UUID; + +public class ReceiverIdentifierTest extends ApiTestBase { + + static final String cgName = TestContext.getConsumerGroupName(); + static final String partitionId = "0"; + static final Instant beforeTestStart = Instant.now(); + static final int sentEvents = 25; + static final List receivers = new LinkedList<>(); + + static EventHubClient ehClient; + + @BeforeClass + public static void initializeEventHub() throws Exception { + + final ConnectionStringBuilder connectionString = TestContext.getConnectionString(); + ehClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + + TestBase.pushEventsToPartition(ehClient, partitionId, sentEvents).get(); + } + + @AfterClass() + public static void cleanup() throws EventHubException { + + for (PartitionReceiver receiver : receivers) + receiver.closeSync(); + + if (ehClient != null) + ehClient.closeSync(); + } + + @Test() + public void testReceiverIdentierShowsUpInQuotaErrors() throws EventHubException { + + final String receiverIdentifierPrefix = UUID.randomUUID().toString(); + for (int receiverCount = 0; receiverCount < 5; receiverCount++) { + final ReceiverOptions options = new ReceiverOptions(); + options.setIdentifier(receiverIdentifierPrefix + receiverCount); + ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromStartOfStream(), options); + } + + try { + ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromStartOfStream()); + Assert.assertTrue(false); + } catch (QuotaExceededException quotaError) { + final String errorMsg = quotaError.getMessage(); + for (int receiverCount = 0; receiverCount < 5; receiverCount++) { + Assert.assertTrue(errorMsg.contains(receiverIdentifierPrefix + receiverCount)); + } + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/ReceiverRuntimeMetricsTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/ReceiverRuntimeMetricsTest.java new file mode 100644 index 000000000000..e86f4ed54a0f --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/ReceiverRuntimeMetricsTest.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.sendrecv; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.time.Instant; +import java.util.HashSet; +import java.util.LinkedList; + +public class ReceiverRuntimeMetricsTest extends ApiTestBase { + + static final String cgName = TestContext.getConsumerGroupName(); + static final String partitionId = "0"; + static final Instant beforeTestStart = Instant.now(); + static final int sentEvents = 25; + + static EventHubClient ehClient; + + static PartitionReceiver receiverWithOptions = null; + static PartitionReceiver receiverWithoutOptions = null; + static PartitionReceiver receiverWithOptionsDisabled = null; + + @BeforeClass + public static void initializeEventHub() throws Exception { + + final ConnectionStringBuilder connectionString = TestContext.getConnectionString(); + ehClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + + ReceiverOptions options = new ReceiverOptions(); + options.setReceiverRuntimeMetricEnabled(true); + + ReceiverOptions optionsWithMetricsDisabled = new ReceiverOptions(); + optionsWithMetricsDisabled.setReceiverRuntimeMetricEnabled(false); + + receiverWithOptions = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromEnqueuedTime(Instant.now()), options); + receiverWithoutOptions = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromEnqueuedTime(Instant.EPOCH)); + receiverWithOptionsDisabled = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromEnqueuedTime(Instant.EPOCH), optionsWithMetricsDisabled); + + TestBase.pushEventsToPartition(ehClient, partitionId, sentEvents).get(); + } + + @AfterClass() + public static void cleanup() throws EventHubException { + + if (receiverWithOptions != null) + receiverWithOptions.closeSync(); + + if (receiverWithoutOptions != null) + receiverWithoutOptions.closeSync(); + + if (receiverWithOptionsDisabled != null) + receiverWithOptionsDisabled.closeSync(); + + if (ehClient != null) + ehClient.closeSync(); + } + + @Test() + public void testRuntimeMetricsReturnedWhenEnabled() throws EventHubException { + + LinkedList receivedEventsWithOptions = new LinkedList<>(); + while (receivedEventsWithOptions.size() < sentEvents) + for (EventData eData : receiverWithOptions.receiveSync(1)) { + receivedEventsWithOptions.add(eData); + Assert.assertEquals((Long) eData.getSystemProperties().getSequenceNumber(), + receiverWithOptions.getEventPosition().getSequenceNumber()); + } + + HashSet offsets = new HashSet<>(); + for (EventData eData : receivedEventsWithOptions) + offsets.add(eData.getSystemProperties().getOffset()); + + Assert.assertTrue(receiverWithOptions.getRuntimeInformation() != null); + Assert.assertTrue(offsets.contains(receiverWithOptions.getRuntimeInformation().getLastEnqueuedOffset())); + Assert.assertTrue(receiverWithOptions.getRuntimeInformation().getLastEnqueuedSequenceNumber() >= receivedEventsWithOptions.iterator().next().getSystemProperties().getSequenceNumber()); + } + + @Test() + public void testRuntimeMetricsWhenDisabled() throws EventHubException { + + receiverWithOptionsDisabled.receiveSync(10); + Assert.assertTrue(receiverWithOptionsDisabled.getRuntimeInformation() == null); + } + + @Test() + public void testRuntimeMetricsDefaultDisabled() throws EventHubException { + + receiverWithoutOptions.receiveSync(10); + Assert.assertTrue(receiverWithoutOptions.getRuntimeInformation() == null); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/RequestResponseTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/RequestResponseTest.java new file mode 100644 index 000000000000..c95643f9c2c2 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/RequestResponseTest.java @@ -0,0 +1,385 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.sendrecv; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.impl.*; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import junit.framework.AssertionFailedError; +import org.apache.qpid.proton.Proton; +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.transport.ErrorCondition; +import org.apache.qpid.proton.message.Message; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.IOException; +import java.lang.reflect.*; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +public class RequestResponseTest extends ApiTestBase { + + static MessagingFactory factory; + static ConnectionStringBuilder connectionString; + + @BeforeClass + public static void initializeEventHub() throws Exception { + + connectionString = TestContext.getConnectionString(); + factory = MessagingFactory.createFromConnectionString(connectionString.toString(), TestContext.EXECUTOR_SERVICE).get(); + } + + @AfterClass() + public static void cleanup() throws EventHubException { + + if (factory != null) + factory.closeSync(); + } + + @Test() + public void testRequestResponse() throws Exception { + + final ReactorDispatcher dispatcher = factory.getReactorDispatcher(); + final RequestResponseChannel requestResponseChannel = new RequestResponseChannel( + "reqresp", + ClientConstants.MANAGEMENT_ADDRESS, + factory.getSession("path", null, null)); + final FaultTolerantObject fchannel = new FaultTolerantObject<>( + new Operation() { + @Override + public void run(OperationResult operationCallback) { + + requestResponseChannel.open( + new OperationResult() { + @Override + public void onComplete(Void result) { + factory.registerForConnectionError(requestResponseChannel.getSendLink()); + factory.registerForConnectionError(requestResponseChannel.getReceiveLink()); + + operationCallback.onComplete(requestResponseChannel); + } + + @Override + public void onError(Exception error) { + operationCallback.onError(error); + } + }, + new OperationResult() { + @Override + public void onComplete(Void result) { + factory.deregisterForConnectionError(requestResponseChannel.getSendLink()); + factory.deregisterForConnectionError(requestResponseChannel.getReceiveLink()); + } + + @Override + public void onError(Exception error) { + factory.deregisterForConnectionError(requestResponseChannel.getSendLink()); + factory.deregisterForConnectionError(requestResponseChannel.getReceiveLink()); + } + }); + } + }, + new Operation() { + @Override + public void run(OperationResult operationCallback) { + requestResponseChannel.close(new OperationResult() { + @Override + public void onComplete(Void result) { + operationCallback.onComplete(result); + } + + @Override + public void onError(Exception error) { + operationCallback.onError(error); + } + }); + } + }); + + int parallelization = 10; + final CompletableFuture[] tasks = new CompletableFuture[parallelization]; + + int i = 0; + while (true) { + final CompletableFuture task = new CompletableFuture<>(); + + final Message request = Proton.message(); + final Map properties = new HashMap<>(); + properties.put(ClientConstants.MANAGEMENT_ENTITY_TYPE_KEY, ClientConstants.MANAGEMENT_EVENTHUB_ENTITY_TYPE); + properties.put(ClientConstants.MANAGEMENT_ENTITY_NAME_KEY, connectionString.getEventHubName()); + properties.put(ClientConstants.MANAGEMENT_OPERATION_KEY, ClientConstants.READ_OPERATION_VALUE); + final ApplicationProperties applicationProperties = new ApplicationProperties(properties); + request.setApplicationProperties(applicationProperties); + + fchannel.runOnOpenedObject(dispatcher, + new OperationResult() { + @Override + public void onComplete(RequestResponseChannel result) { + result.request(request, + new OperationResult() { + @Override + public void onComplete(Message response) { + Map resultMap = null; + + final int statusCode = (int) response.getApplicationProperties().getValue().get(ClientConstants.MANAGEMENT_STATUS_CODE_KEY); + final String statusDescription = (String) response.getApplicationProperties().getValue().get(ClientConstants.MANAGEMENT_STATUS_DESCRIPTION_KEY); + + if (statusCode == AmqpResponseCode.ACCEPTED.getValue() || statusCode == AmqpResponseCode.OK.getValue()) { + + if (response.getBody() == null) + resultMap = null; + else + resultMap = (Map) ((AmqpValue) response.getBody()).getValue(); + } else { + + final Symbol condition = (Symbol) response.getApplicationProperties().getValue().get(ClientConstants.MANAGEMENT_RESPONSE_ERROR_CONDITION); + final ErrorCondition error = new ErrorCondition(condition, statusDescription); + this.onError(new AmqpException(error)); + } + + if (connectionString.getEventHubName().equalsIgnoreCase((String) resultMap.get(ClientConstants.MANAGEMENT_ENTITY_NAME_KEY))) + task.complete(null); + else + task.completeExceptionally(new AssertionFailedError("response doesn't have correct eventhub name")); + } + + @Override + public void onError(Exception error) { + task.completeExceptionally(error); + } + }); + } + + @Override + public void onError(Exception error) { + task.completeExceptionally(error); + } + }); + + tasks[i % parallelization] = task; + i++; + if (i % parallelization == 0) { + CompletableFuture.allOf(tasks).get(); + if (i >= (parallelization * 5)) + break; + } + } + + final CompletableFuture closeFuture = new CompletableFuture<>(); + fchannel.close(dispatcher, new OperationResult() { + @Override + public void onComplete(Void result) { + closeFuture.complete(null); + } + + @Override + public void onError(Exception error) { + closeFuture.completeExceptionally(error); + } + }); + + closeFuture.get(); + } + + @Test + public void testGetRuntimes() throws Exception { + testGetRuntimeInfos(TestContext.getConnectionString()); + } + + public void testGetRuntimeInfos(ConnectionStringBuilder connectionString) throws Exception { + EventHubClient ehc = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + EventHubRuntimeInformation ehInfo = ehc.getRuntimeInformation().get(); + + Assert.assertNotNull(ehInfo); + Assert.assertTrue(connectionString.getEventHubName().equalsIgnoreCase(ehInfo.getPath())); + Assert.assertNotNull(ehInfo.getCreatedAt()); // creation time could be almost anything, can't really check value + Assert.assertTrue(ehInfo.getPartitionCount() >= 1); // max legal partition count is variable but 2 is hard minimum + Assert.assertEquals(ehInfo.getPartitionIds().length, ehInfo.getPartitionCount()); + /* + System.out.println("Event hub name: " + ehInfo.getPath()); + System.out.println("Created at: " + ehInfo.getCreatedAt().toString()); + System.out.println("Partition count: " + ehInfo.getPartitionCount()); + */ + for (int i = 0; i < ehInfo.getPartitionCount(); i++) { + String id = ehInfo.getPartitionIds()[i]; + Assert.assertNotNull(id); + Assert.assertFalse(id.isEmpty()); + //System.out.println("Partition id[" + i + "]: " + ehInfo.getPartitionIds()[i]); + } + + for (String id : ehInfo.getPartitionIds()) { + PartitionRuntimeInformation partInfo = ehc.getPartitionRuntimeInformation(id).get(); + + Assert.assertNotNull(partInfo); + Assert.assertTrue(connectionString.getEventHubName().equalsIgnoreCase(partInfo.getEventHubPath())); + Assert.assertTrue(id.equalsIgnoreCase(partInfo.getPartitionId())); + Assert.assertTrue(partInfo.getBeginSequenceNumber() >= -1); + Assert.assertTrue(partInfo.getLastEnqueuedSequenceNumber() >= -1); + Assert.assertTrue(partInfo.getLastEnqueuedSequenceNumber() >= partInfo.getBeginSequenceNumber()); + Assert.assertNotNull(partInfo.getLastEnqueuedOffset()); + Assert.assertFalse(partInfo.getLastEnqueuedOffset().isEmpty()); + Assert.assertNotNull(partInfo.getLastEnqueuedTimeUtc()); // last enqueued time could be almost anything, can't really check value + /* + System.out.println("Event hub name: " + partInfo.getEventHubPath()); + System.out.println("Partition id: " + partInfo.getPartitionId()); + System.out.println("Begin seq: " + partInfo.getBeginSequenceNumber()); + System.out.println("Last seq: " + partInfo.getLastEnqueuedSequenceNumber()); + System.out.println("Last offset: " + partInfo.getLastEnqueuedOffset()); + System.out.println("Last time: " + partInfo.getLastEnqueuedTimeUtc().toString()); + */ + } + + ehc.closeSync(); + } + + @Test + public void testGetRuntimesWebSockets() throws Exception { + ConnectionStringBuilder connectionStringBuilder = TestContext.getConnectionString(); + connectionStringBuilder.setTransportType(TransportType.AMQP_WEB_SOCKETS); + testGetRuntimeInfos(connectionStringBuilder); + } + + @Test + public void testGetRuntimeInfoCallTimesout() throws Exception { + final EventHubClientImpl eventHubClient = (EventHubClientImpl) EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + + // set operation timeout to 5ms - so that the actual operation doesn't event start + final Field factoryField = EventHubClientImpl.class.getDeclaredField("underlyingFactory"); + factoryField.setAccessible(true); + final MessagingFactory factory = (MessagingFactory)factoryField.get(eventHubClient); + + final Field timeoutField = MessagingFactory.class.getDeclaredField("operationTimeout"); + timeoutField.setAccessible(true); + final Duration originalTimeout = factory.getOperationTimeout(); + timeoutField.set(factory, Duration.ofMillis(ClientConstants.MGMT_CHANNEL_MIN_RETRY_IN_MILLIS)); + + try { + eventHubClient.getPartitionRuntimeInformation("0").get(); + Assert.assertTrue(false); // exception should be thrown + } catch (ExecutionException exception) { + Assert.assertTrue(exception.getCause() instanceof TimeoutException); + } finally { + timeoutField.set(factory, originalTimeout); + eventHubClient.closeSync(); + } + } + + @Test + public void testGetRuntimesBadHub() throws EventHubException, IOException { + ConnectionStringBuilder bogusConnectionString = new ConnectionStringBuilder() + .setEndpoint(connectionString.getEndpoint()) + .setEventHubName("NOHUBZZZZZ") + .setSasKeyName(connectionString.getSasKeyName()) + .setSasKey(connectionString.getSasKey()); + EventHubClient ehc = EventHubClient.createSync(bogusConnectionString.toString(), TestContext.EXECUTOR_SERVICE); + + try { + ehc.getRuntimeInformation().get(); + Assert.fail("Expected exception, got success"); + } catch (ExecutionException e) { + if (e.getCause() == null) { + Assert.fail("Got ExecutionException but no inner exception"); + } else if (e.getCause() instanceof IllegalEntityException) { + Assert.assertTrue(e.getCause().getMessage().contains("could not be found")); + } else { + Assert.fail("Got unexpected inner exception " + e.getCause().toString()); + } + } catch (Exception e) { + Assert.fail("Unexpected exception " + e.toString()); + } + + try { + ehc.getPartitionRuntimeInformation("0").get(); + Assert.fail("Expected exception, got success"); + } catch (ExecutionException e) { + if (e.getCause() == null) { + Assert.fail("Got ExecutionException but no inner exception"); + } else if (e.getCause() instanceof IllegalEntityException) { + Assert.assertTrue(e.getCause().getMessage().contains("could not be found")); + } else { + Assert.fail("Got unexpected inner exception " + e.getCause().toString()); + } + } catch (Exception e) { + Assert.fail("Unexpected exception " + e.toString()); + } + + ehc.closeSync(); + } + + @Test + public void testGetRuntimesBadKeyname() throws EventHubException, IOException { + ConnectionStringBuilder bogusConnectionString = new ConnectionStringBuilder() + .setEndpoint(connectionString.getEndpoint()) + .setEventHubName(connectionString.getEventHubName()) + .setSasKeyName("xxxnokeyxxx") + .setSasKey(connectionString.getSasKey()); + EventHubClient ehc = EventHubClient.createSync(bogusConnectionString.toString(), TestContext.EXECUTOR_SERVICE); + + try { + ehc.getRuntimeInformation().get(); + Assert.fail("Expected exception, got success"); + } catch (ExecutionException e) { + if (e.getCause() == null) { + Assert.fail("Got ExecutionException but no inner exception"); + } else if (e.getCause() instanceof AuthorizationFailedException) { + // Success + } else { + Assert.fail("Got unexpected inner exception " + e.getCause().toString()); + } + } catch (Exception e) { + Assert.fail("Unexpected exception " + e.toString()); + } + + try { + ehc.getPartitionRuntimeInformation("0").get(); + Assert.fail("Expected exception, got success"); + } catch (ExecutionException e) { + if (e.getCause() == null) { + Assert.fail("Got ExecutionException but no inner exception"); + } else if (e.getCause() instanceof AuthorizationFailedException) { + // Success + } else { + Assert.fail("Got unexpected inner exception " + e.getCause().toString()); + } + } catch (Exception e) { + Assert.fail("Unexpected exception " + e.toString()); + } + + ehc.closeSync(); + } + + @Test + public void testGetRuntimesClosedClient() throws EventHubException, IOException, InterruptedException, ExecutionException { + EventHubClient ehc = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + ehc.closeSync(); + + try { + ehc.getRuntimeInformation().get(); + Assert.fail("getRuntimeInformation did not throw as expected"); + } catch (IllegalStateException e) { + // Success + } catch (Exception e) { + Assert.fail("Unexpected exception from getRuntimeInformation " + e.toString()); + } + + try { + ehc.getPartitionRuntimeInformation("0").get(); + Assert.fail("getPartitionRuntimeInformation did not throw as expected"); + } catch (IllegalStateException e) { + // Success + } catch (Exception e) { + Assert.fail("Unexpected exception from getPartitionRuntimeInformation " + e.toString()); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/SasTokenReceiveTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/SasTokenReceiveTest.java new file mode 100644 index 000000000000..5d975598129f --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/SasTokenReceiveTest.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.sendrecv; + +import com.microsoft.azure.eventhubs.EventHubException; +import com.microsoft.azure.eventhubs.lib.SasTokenTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.*; + +public class SasTokenReceiveTest extends SasTokenTestBase { + + private static ReceiveTest receiveTest; + + @BeforeClass + public static void initialize() throws Exception { + + Assert.assertTrue(TestContext.getConnectionString().getSharedAccessSignature() != null + && TestContext.getConnectionString().getSasKey() == null + && TestContext.getConnectionString().getSasKeyName() == null); + + receiveTest = new ReceiveTest(); + ReceiveTest.initializeEventHub(TestContext.getConnectionString()); + } + + @AfterClass() + public static void cleanup() throws EventHubException { + ReceiveTest.cleanup(); + } + + @Test() + public void testReceiverStartOfStreamFilters() throws EventHubException { + receiveTest.testReceiverStartOfStreamFilters(); + } + + @After + public void testCleanup() throws EventHubException { + receiveTest.testCleanup(); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/SasTokenSendTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/SasTokenSendTest.java new file mode 100644 index 000000000000..bd96fc0a7eef --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/SasTokenSendTest.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.sendrecv; + +import com.microsoft.azure.eventhubs.EventHubException; +import com.microsoft.azure.eventhubs.lib.SasTokenTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.*; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +public class SasTokenSendTest extends SasTokenTestBase { + + private static SendTest sendTest; + + @BeforeClass + public static void initialize() throws Exception { + + Assert.assertTrue(TestContext.getConnectionString().getSharedAccessSignature() != null + && TestContext.getConnectionString().getSasKey() == null + && TestContext.getConnectionString().getSasKeyName() == null); + + sendTest = new SendTest(); + SendTest.initializeEventHub(TestContext.getConnectionString()); + } + + @AfterClass + public static void cleanupClient() throws EventHubException { + + SendTest.cleanupClient(); + } + + @Test + public void sendBatchRetainsOrderWithinBatch() throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + + sendTest.sendBatchRetainsOrderWithinBatch(); + } + + @Test + public void sendResultsInSysPropertiesWithPartitionKey() throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + + sendTest.sendResultsInSysPropertiesWithPartitionKey(); + } + + @After + public void cleanup() throws EventHubException { + + sendTest.cleanup(); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/SendTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/SendTest.java new file mode 100644 index 000000000000..7f4eaed43b9e --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/SendTest.java @@ -0,0 +1,221 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.sendrecv; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import junit.framework.AssertionFailedError; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.TimeUnit; + +public class SendTest extends ApiTestBase { + static final String cgName = TestContext.getConsumerGroupName(); + static final String partitionId = "0"; + static final String ORDER_PROPERTY = "order"; + static EventHubClient ehClient; + + PartitionSender sender = null; + List receivers = new LinkedList<>(); + + @BeforeClass + public static void initialize() throws Exception { + final ConnectionStringBuilder connectionString = TestContext.getConnectionString(); + initializeEventHub(connectionString); + } + public static void initializeEventHub(final ConnectionStringBuilder connectionString) throws Exception { + ehClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + } + + @AfterClass + public static void cleanupClient() throws EventHubException { + if (ehClient != null) + ehClient.closeSync(); + } + + @Test + public void sendBatchRetainsOrderWithinBatch() throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + LinkedList batchEvents = new LinkedList<>(); + final int batchSize = 50; + for (int count = 0; count < batchSize; count++) { + EventData event = EventData.create("a".getBytes()); + event.getProperties().put(ORDER_PROPERTY, count); + batchEvents.add(event); + } + + final CompletableFuture validator = new CompletableFuture<>(); + final PartitionReceiver receiver = ehClient.createReceiverSync(cgName, partitionId, EventPosition.fromEnqueuedTime(Instant.now())); + this.receivers.add(receiver); + receiver.setReceiveTimeout(Duration.ofSeconds(1)); + receiver.setReceiveHandler(new OrderValidator(validator, batchSize)); + + // run out of messages in that specific partition - to account for clock-skew with Instant.now() on test machine vs eventhubs service + Iterable clockSkewEvents; + do { + clockSkewEvents = receiver.receiveSync(100); + } while (clockSkewEvents != null && clockSkewEvents.iterator().hasNext()); + + sender = ehClient.createPartitionSenderSync(partitionId); + sender.sendSync(batchEvents); + + validator.get(25, TimeUnit.SECONDS); + } + + @Test + public void sendResultsInSysPropertiesWithPartitionKey() throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + final int partitionCount = ehClient.getRuntimeInformation().get().getPartitionCount(); + final String partitionKey = UUID.randomUUID().toString(); + CompletableFuture validateSignal = new CompletableFuture<>(); + PartitionKeyValidator validator = new PartitionKeyValidator(validateSignal, partitionKey, 1); + for (int receiversCount = 0; receiversCount < partitionCount; receiversCount++) { + final PartitionReceiver receiver = ehClient.createReceiverSync(cgName, Integer.toString(receiversCount), EventPosition.fromEnqueuedTime(Instant.now())); + receivers.add(receiver); + + // run out of messages in that specific partition - to account for clock-skew with Instant.now() on test machine vs eventhubs service + receiver.setReceiveTimeout(Duration.ofSeconds(5)); + Iterable clockSkewEvents; + do { + clockSkewEvents = receiver.receiveSync(100); + } while (clockSkewEvents != null && clockSkewEvents.iterator().hasNext()); + + receiver.setReceiveHandler(validator); + } + + ehClient.sendSync(EventData.create("TestMessage".getBytes()), partitionKey); + validateSignal.get(partitionCount * 5, TimeUnit.SECONDS); + } + + @Test + public void sendBatchResultsInSysPropertiesWithPartitionKey() throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + final int batchSize = 20; + final int partitionCount = ehClient.getRuntimeInformation().get().getPartitionCount(); + final String partitionKey = UUID.randomUUID().toString(); + CompletableFuture validateSignal = new CompletableFuture<>(); + PartitionKeyValidator validator = new PartitionKeyValidator(validateSignal, partitionKey, batchSize); + for (int receiversCount = 0; receiversCount < partitionCount; receiversCount++) { + final PartitionReceiver receiver = ehClient.createReceiverSync(cgName, Integer.toString(receiversCount), EventPosition.fromEnqueuedTime(Instant.now())); + receivers.add(receiver); + + // run out of messages in that specific partition - to account for clock-skew with Instant.now() on test machine vs eventhubs service + receiver.setReceiveTimeout(Duration.ofSeconds(5)); + Iterable clockSkewEvents; + do { + clockSkewEvents = receiver.receiveSync(100); + } while (clockSkewEvents != null && clockSkewEvents.iterator().hasNext()); + + receiver.setReceiveHandler(validator); + } + + List events = new LinkedList<>(); + for (int index = 0; index < batchSize; index++) + events.add(EventData.create("TestMessage".getBytes())); + + ehClient.sendSync(events, partitionKey); + validateSignal.get(partitionCount * 5, TimeUnit.SECONDS); + } + + @After + public void cleanup() throws EventHubException { + if (sender != null) { + sender.closeSync(); + sender = null; + } + + if (receivers != null && !receivers.isEmpty()) { + for (PartitionReceiver receiver : receivers) + receiver.closeSync(); + + receivers.clear(); + } + } + + public static class PartitionKeyValidator implements PartitionReceiveHandler { + final CompletableFuture validateSignal; + final String partitionKey; + final int eventCount; + int currentEventCount = 0; + + protected PartitionKeyValidator(final CompletableFuture validateSignal, final String partitionKey, final int eventCount) { + this.validateSignal = validateSignal; + this.partitionKey = partitionKey; + this.eventCount = eventCount; + } + + @Override + public int getMaxEventCount() { + return 50; + } + + @Override + public void onReceive(Iterable events) { + if (events != null && events.iterator().hasNext()) { + for (EventData event : events) { + if (!partitionKey.equals(event.getSystemProperties().getPartitionKey())) + this.validateSignal.completeExceptionally( + new AssertionFailedError(String.format("received partitionKey: %s, expected partitionKey: %s", event.getSystemProperties().getPartitionKey(), partitionKey))); + + this.currentEventCount++; + } + + if (this.currentEventCount == this.eventCount) + this.validateSignal.complete(null); + } + } + + @Override + public void onError(Throwable error) { + this.validateSignal.completeExceptionally(error); + } + } + + public static class OrderValidator implements PartitionReceiveHandler { + final CompletableFuture validateSignal; + final int netEventCount; + + int currentCount = 0; + + public OrderValidator(final CompletableFuture validateSignal, final int netEventCount) { + this.validateSignal = validateSignal; + this.netEventCount = netEventCount; + } + + @Override + public int getMaxEventCount() { + return 100; + } + + @Override + public void onReceive(Iterable events) { + if (events != null) + for (EventData event : events) { + final int currentEventOrder = (int) event.getProperties().get(ORDER_PROPERTY); + if (currentEventOrder != currentCount) + this.validateSignal.completeExceptionally(new AssertionError(String.format("expected %s, got %s", currentCount, currentEventOrder))); + + currentCount++; + } + + if (currentCount >= netEventCount) + this.validateSignal.complete(null); + } + + @Override + public void onError(Throwable error) { + this.validateSignal.completeExceptionally(error); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/SetPrefetchCountTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/SetPrefetchCountTest.java new file mode 100644 index 000000000000..1a39f3ab353a --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/SetPrefetchCountTest.java @@ -0,0 +1,87 @@ +package com.microsoft.azure.eventhubs.sendrecv; + +import com.microsoft.azure.eventhubs.*; +import com.microsoft.azure.eventhubs.lib.ApiTestBase; +import com.microsoft.azure.eventhubs.lib.TestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.*; + +import java.time.Duration; +import java.util.LinkedList; + +public class SetPrefetchCountTest extends ApiTestBase { + static final String CONSUMER_GROUP_NAME = TestContext.getConsumerGroupName(); + static final String PARTITION_ID = "0"; + + // since we cannot test receiving very large prefetch like 100000 - in a unit test + // defaultPrefetchCount * 3 was chosen + static final int EVENT_COUNT = PartitionReceiver.DEFAULT_PREFETCH_COUNT * 3; + + static final int MAX_RETRY_TO_DECLARE_RECEIVE_STUCK = 3; + + static EventHubClient ehClient; + + PartitionReceiver testReceiver = null; + + @BeforeClass + public static void initializeEventHub() throws Exception { + final ConnectionStringBuilder connectionString = TestContext.getConnectionString(); + ehClient = EventHubClient.createSync(connectionString.toString(), TestContext.EXECUTOR_SERVICE); + TestBase.pushEventsToPartition(ehClient, PARTITION_ID, EVENT_COUNT).get(); + } + + @AfterClass() + public static void cleanup() throws EventHubException { + if (ehClient != null) { + ehClient.closeSync(); + } + } + + @Test() + public void testSetPrefetchCountToLargeValue() throws EventHubException { + ReceiverOptions options = new ReceiverOptions(); + options.setPrefetchCount(2000); + testReceiver = ehClient.createReceiverSync(CONSUMER_GROUP_NAME, PARTITION_ID, EventPosition.fromStartOfStream(), options); + testReceiver.setReceiveTimeout(Duration.ofSeconds(2)); + int eventsReceived = 0; + int retryCount = 0; + while (eventsReceived < EVENT_COUNT && retryCount < MAX_RETRY_TO_DECLARE_RECEIVE_STUCK) { + final Iterable events = testReceiver.receiveSync(EVENT_COUNT); + if (events == null || !events.iterator().hasNext()) { + retryCount++; + } else { + eventsReceived += ((LinkedList) events).size(); + } + } + + Assert.assertTrue(eventsReceived >= EVENT_COUNT); + } + + @Test() + public void testSetPrefetchCountToSmallValue() throws EventHubException { + ReceiverOptions options = new ReceiverOptions(); + options.setPrefetchCount(11); + testReceiver = ehClient.createReceiverSync(CONSUMER_GROUP_NAME, PARTITION_ID, EventPosition.fromStartOfStream(), options); + testReceiver.setReceiveTimeout(Duration.ofSeconds(2)); + int eventsReceived = 0; + int retryCount = 0; + while (eventsReceived < EVENT_COUNT && retryCount < MAX_RETRY_TO_DECLARE_RECEIVE_STUCK) { + final Iterable events = testReceiver.receiveSync(10); + if (events == null || !events.iterator().hasNext()) { + retryCount++; + } else { + eventsReceived += ((LinkedList) events).size(); + } + } + + Assert.assertTrue(eventsReceived >= EVENT_COUNT); + } + + @After + public void testCleanup() throws EventHubException { + + if (testReceiver != null) { + testReceiver.closeSync(); + } + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/WebSocketsReceiveTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/WebSocketsReceiveTest.java new file mode 100644 index 000000000000..29783d65e632 --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/WebSocketsReceiveTest.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.sendrecv; + +import com.microsoft.azure.eventhubs.ConnectionStringBuilder; +import com.microsoft.azure.eventhubs.EventHubException; +import com.microsoft.azure.eventhubs.TransportType; +import com.microsoft.azure.eventhubs.lib.SasTokenTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.*; + +public class WebSocketsReceiveTest extends SasTokenTestBase { + + private static ReceiveTest receiveTest; + + @BeforeClass + public static void initialize() throws Exception { + + Assert.assertTrue(TestContext.getConnectionString().getSharedAccessSignature() != null + && TestContext.getConnectionString().getSasKey() == null + && TestContext.getConnectionString().getSasKeyName() == null); + + receiveTest = new ReceiveTest(); + ConnectionStringBuilder connectionString = TestContext.getConnectionString(); + connectionString.setTransportType(TransportType.AMQP_WEB_SOCKETS); + ReceiveTest.initializeEventHub(connectionString); + } + + @AfterClass() + public static void cleanup() throws EventHubException { + ReceiveTest.cleanup(); + } + + @Test() + public void testReceiverStartOfStreamFilters() throws EventHubException { + receiveTest.testReceiverStartOfStreamFilters(); + } + + @After + public void testCleanup() throws EventHubException { + receiveTest.testCleanup(); + } +} diff --git a/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/WebSocketsSendTest.java b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/WebSocketsSendTest.java new file mode 100644 index 000000000000..005e6b115afc --- /dev/null +++ b/eventhubs/data-plane/azure-eventhubs/src/test/java/com/microsoft/azure/eventhubs/sendrecv/WebSocketsSendTest.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) Microsoft. All rights reserved. + * Licensed under the MIT license. See LICENSE file in the project root for full license information. + */ +package com.microsoft.azure.eventhubs.sendrecv; + +import com.microsoft.azure.eventhubs.ConnectionStringBuilder; +import com.microsoft.azure.eventhubs.EventHubException; +import com.microsoft.azure.eventhubs.TransportType; +import com.microsoft.azure.eventhubs.lib.SasTokenTestBase; +import com.microsoft.azure.eventhubs.lib.TestContext; +import org.junit.*; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +public class WebSocketsSendTest extends SasTokenTestBase { + + private static SendTest sendTest; + + @BeforeClass + public static void initialize() throws Exception { + + Assert.assertTrue(TestContext.getConnectionString().getSharedAccessSignature() != null + && TestContext.getConnectionString().getSasKey() == null + && TestContext.getConnectionString().getSasKeyName() == null); + + sendTest = new SendTest(); + + ConnectionStringBuilder connectionString = TestContext.getConnectionString(); + connectionString.setTransportType(TransportType.AMQP_WEB_SOCKETS); + SendTest.initializeEventHub(connectionString); + } + + @AfterClass + public static void cleanupClient() throws EventHubException { + + SendTest.cleanupClient(); + } + + @Test + public void sendBatchRetainsOrderWithinBatch() throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + + sendTest.sendBatchRetainsOrderWithinBatch(); + } + + @Test + public void sendResultsInSysPropertiesWithPartitionKey() throws EventHubException, InterruptedException, ExecutionException, TimeoutException { + + sendTest.sendResultsInSysPropertiesWithPartitionKey(); + } + + @After + public void cleanup() throws EventHubException { + + sendTest.cleanup(); + } +} diff --git a/eventhubs/data-plane/event-hubs.png b/eventhubs/data-plane/event-hubs.png new file mode 100644 index 000000000000..62f34790bbe3 Binary files /dev/null and b/eventhubs/data-plane/event-hubs.png differ diff --git a/eventhubs/data-plane/pom.xml b/eventhubs/data-plane/pom.xml new file mode 100644 index 000000000000..c99ba2231242 --- /dev/null +++ b/eventhubs/data-plane/pom.xml @@ -0,0 +1,79 @@ + + + Java libraries for talking to Windows Azure Event Hubs + + 4.0.0 + + com.microsoft.azure + azure-eventhubs-clients + 2.0.0 + pom + + https://github.com/Azure/azure-event-hubs + + + 0.31.0 + 1.1.0 + 4.12 + 1.8.0-alpha2 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.5 + + 1.8 + 1.8 + true + true + true + + + + + + + HTTP proxy server in java + http://raw.github.com/SreeramGarlapati/jproxy/master/releases + + + + + org.apache.qpid + proton-j + ${proton-j-version} + + + com.microsoft.azure + qpid-proton-j-extensions + ${qpid-proton-j-extensions-version} + + + org.slf4j + slf4j-api + ${slf4j-version} + + + junit + junit + ${junit-version} + test + + + org.jutils.jproxy + jproxy + 0.0.1 + test + + + + + azure-eventhubs + azure-eventhubs-eph + azure-eventhubs-extensions + + + diff --git a/eventhubs/data-plane/readme.md b/eventhubs/data-plane/readme.md new file mode 100644 index 000000000000..ea52c6191cd0 --- /dev/null +++ b/eventhubs/data-plane/readme.md @@ -0,0 +1,104 @@ +

+ Microsoft Azure Event Hubs +

+ +

Microsoft Azure Event Hubs Client for Java +

+ + star our repo + + follow on Twitter +

+ +|Branch|Status| +|------|-------------| +|master|[![Build status](https://ci.appveyor.com/api/projects/status/dq8qyu2k3wu2uexd/branch/master?svg=true)](https://ci.appveyor.com/project/sabeegrewal/azure-event-hubs-java/branch/master)| +|dev|[![Build status](https://ci.appveyor.com/api/projects/status/dq8qyu2k3wu2uexd/branch/dev?svg=true)](https://ci.appveyor.com/project/sabeegrewal/azure-event-hubs-java/branch/dev)| + +Azure Event Hubs is a hyper-scale data ingestion service, fully-managed by Microsoft, that enables you to collect, store and process trillions of events from websites, apps, IoT devices, and any stream of data. + +Refer to the [online documentation](https://azure.microsoft.com/services/event-hubs/) to learn more about Event Hubs in general and [General Overview document](Overview.md) for an overview of Event Hubs Client for Java. + +## Using the library + +### Samples + +Code samples are [here](https://github.com/Azure/azure-event-hubs/tree/master/samples/Java). + +### Referencing the library + +Two java packages are released to Maven Central Repository from this GitHub repository. + +#### Microsoft Azure EventHubs Java Client + +This library exposes the send and receive APIs. This library will in turn pull further required dependencies, specifically +the required versions of Apache Qpid Proton-J, and the cryptography library BCPKIX by the Legion of Bouncy Castle. + +|Package|Package Version| +|--------|------------------| +|azure-eventhubs|[![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.microsoft.azure/azure-eventhubs/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.microsoft.azure/azure-eventhubs) + +```XML + + com.microsoft.azure + azure-eventhubs + 2.0.0 + +``` + +#### Microsoft Azure EventHubs Java Event Processor Host library + +This library exposes an out-of-the-box distributed partition processor for Event Hubs. +It pulls the required versions of Event Hubs, Azure Storage and GSon libraries. + +|Package|Package Version| +|--------|------------------| +|azure-eventhubs-eph|[![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.microsoft.azure/azure-eventhubs-eph/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.microsoft.azure/azure-eventhubs-eph) + +```XML + + com.microsoft.azure + azure-eventhubs-eph + 2.2.0 + +``` + +## How to provide feedback + +First, if you experience any issues with the runtime behavior of the Azure Event Hubs service, please consider filing a support request +right away. Your options for [getting support are enumerated here](https://azure.microsoft.com/support/options/). In the Azure portal, +you can file a support request from the "Help and support" menu in the upper right hand corner of the page. + +If you find issues in this library or have suggestions for improvement of code or documentation, you can [file an issue in the project's +GitHub repository](https://github.com/Azure/azure-event-hubs/issues) or send across a pull request - see our [Contribution Guidelines](./.github/CONTRIBUTING.md). + +Issues related to runtime behavior of the service, such as sporadic exceptions or apparent service-side performance or reliability issues can not be handled here. + +Generally, if you want to discuss Azure Event Hubs or this client library with the community and the maintainers, you can turn to +[stackoverflow.com under the #azure-eventhub tag](http://stackoverflow.com/questions/tagged/azure-eventhub) or the +[MSDN Service Bus Forum](https://social.msdn.microsoft.com/Forums/en-US/home?forum=servbus). + +## Build & contribute to the library + +You will generally not have to build this client library yourself - this library is available on maven central. +If you have any specific requirement for which you want to contribute or need to generate a SNAPSHOT version, this section is for you. +**Your contributions are welcome and encouraged!** + +We adopted maven build model and strive to keep the project model intuitive enough to developers. +If you need any help with any specific IDE or cannot get the build going in any environment - please open an issue. +Here are few general topics, which we thought developers would need help with: + +### Running Integration tests + +Set the following two Environment variables to be able to run unit tests targeting Microsoft Azure EventHubs service: + + * EVENT_HUB_CONNECTION_STRING - the event hub connection string to which the tests should target. the format of the connection string is: `Endpoint=----NAMESPACE_ENDPOINT------;EntityPath=----EVENTHUB_NAME----;SharedAccessKeyName=----KEY_NAME----;SharedAccessKey=----KEY_VALUE----`. [Here's how to create an Event Hub on Azure Portal and get the connection string](https://docs.microsoft.com/en-us/azure/event-hubs/event-hubs-create). + + * EPHTESTSTORAGE - the Microsoft Azure Storage account connection string to use while running EPH tests. The format of the connection string is: `DefaultEndpointsProtocol=https;AccountName=---STORAGE_ACCOUNT_NAME---;AccountKey=---ACCOUNT_KEY---;EndpointSuffix=---ENPOINT_SUFFIX---`. For more details on this visit - [how to create an Azure Storage account connection string](https://docs.microsoft.com/en-us/azure/storage/common/storage-configure-connection-string#create-a-connection-string-for-an-azure-storage-account). + +### Explore the client library with IDEs + +* If you see any Build Errors - make sure the Execution Environment is set to JDK version 1.8 or higher + diff --git a/eventhubs/data-plane/templates/azuredeploy.json b/eventhubs/data-plane/templates/azuredeploy.json new file mode 100644 index 000000000000..addbca811f98 --- /dev/null +++ b/eventhubs/data-plane/templates/azuredeploy.json @@ -0,0 +1,108 @@ +{ + "$schema": "http://schema.management.azure.com/schemas/2014-04-01-preview/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "namespaceName": { + "type": "string", + "metadata": { + "description": "Name of the EventHub namespace" + } + }, + "eventHubName": { + "type": "string", + "metadata": { + "description": "Name of the Event Hub" + } + }, + "consumerGroupName": { + "type": "string", + "metadata": { + "description": "Name of the Consumer Group" + } + }, + "storageAccountName": { + "type": "string", + "metadata": { + "description": "Name of the storage account that is used by the Event Processor Host" + } + }, + "storageAccountType": { + "type": "string", + "defaultValue": "Standard_LRS", + "allowedValues": [ + "Standard_LRS", + "Standard_GRS", + "Standard_ZRS", + "Premium_LRS" + ], + "metadata": { + "description": "Storage Account type" + } + } + }, + "variables": { + "location": "[resourceGroup().location]", + "apiVersion": "2015-08-01", + "defaultSASKeyName": "RootManageSharedAccessKey", + "authRuleResourceId": "[resourceId('Microsoft.EventHub/namespaces/authorizationRules', parameters('namespaceName'), variables('defaultSASKeyName'))]", + "storageAccountResourceId": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]" + }, + "resources": [ + { + "apiVersion": "2015-08-01", + "name": "[parameters('namespaceName')]", + "type": "Microsoft.EventHub/Namespaces", + "location": "[variables('location')]", + "sku": { + "name": "Standard", + "tier": "Standard" + }, + "resources": [ + { + "apiVersion": "2015-08-01", + "name": "[parameters('eventHubName')]", + "type": "EventHubs", + "dependsOn": [ + "[concat('Microsoft.EventHub/namespaces/', parameters('namespaceName'))]" + ], + "properties": { + "path": "[parameters('eventHubName')]" + }, + "resources": [ + { + "apiVersion": "2015-08-01", + "name": "[parameters('consumerGroupName')]", + "type": "ConsumerGroups", + "dependsOn": [ + "[parameters('eventHubName')]" + ], + "properties": {} + } + ] + } + ] + }, + { + "type": "Microsoft.Storage/storageAccounts", + "name": "[parameters('storageAccountName')]", + "apiVersion": "2016-01-01", + "location": "[variables('location')]", + "sku": { + "name": "[parameters('storageAccountType')]" + }, + "kind": "Storage", + "properties": { + } + } + ], + "outputs": { + "NamespaceConnectionString": { + "type": "string", + "value": "[listkeys(variables('authRuleResourceId'), variables('apiVersion')).primaryConnectionString]" + }, + "StorageAccountConnectionString": { + "type": "string", + "value": "[Concat('DefaultEndpointsProtocol=https;AccountName=',parameters('storageAccountName'),';AccountKey=',listKeys(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), providers('Microsoft.Storage', 'storageAccounts').apiVersions[0]).keys[0].value)]" + } + } +} \ No newline at end of file