From b588cb0aa0ab77968a63ff6b49b208166af7c6c3 Mon Sep 17 00:00:00 2001 From: Jesse Squire Date: Thu, 27 Aug 2020 15:13:39 -0400 Subject: [PATCH] [Event Hubs Client] Idempotent Producer Client The focus of these changes is implementing the idempotent publising feature infrastructure into the `EventHubProducerClient` and associated types, refactoring the current structure to support idempotent and non-idempotent publishing as unqiue code paths. Not included in these changes is the addition of the `ReadPartitionPublishingProperties` member and live tests; those will be covered in dedicated work streams. --- .../src/Resources.Designer.cs | 24 +- .../src/Resources.resx | 10 +- ...zure.Messaging.EventHubs.netstandard2.0.cs | 8 +- .../src/Amqp/AmqpClient.cs | 9 +- .../src/Amqp/AmqpProducer.cs | 126 +- .../src/Core/TransportClient.cs | 5 + .../src/Core/TransportProducer.cs | 15 + .../src/Core/TransportProducerFeatures.cs | 22 + .../src/Core/TransportProducerPool.cs | 60 +- .../src/Diagnostics/EventHubsEventSource.cs | 152 +++ .../src/EventData.cs | 59 +- .../src/EventHubConnection.cs | 7 +- .../src/Producer/EventDataBatch.cs | 11 +- .../src/Producer/EventHubProducerClient.cs | 618 +++++++-- .../Producer/EventHubProducerClientOptions.cs | 39 + .../Producer/PartitionPublishingOptions.cs | 2 + .../Producer/PartitionPublishingProperties.cs | 8 +- .../src/Producer/PartitionPublishingState.cs | 67 + .../tests/Amqp/AmqpClientTests.cs | 2 +- .../tests/Amqp/AmqpProducerTests.cs | 533 +++++++- .../Connection/EventHubConnectionTests.cs | 15 +- .../tests/Core/EventDataTests.cs | 46 +- .../tests/Diagnostics/DiagnosticsTests.cs | 11 +- .../tests/Producer/EventDataBatchTests.cs | 38 - .../EventHubProducerClientOptionsTests.cs | 71 + .../Producer/EventHubProducerClientTests.cs | 1203 ++++++++++++++++- .../Producer/TransportProducerPoolTests.cs | 20 +- 27 files changed, 2827 insertions(+), 354 deletions(-) mode change 100755 => 100644 sdk/eventhub/Azure.Messaging.EventHubs.Shared/src/Resources.Designer.cs mode change 100755 => 100644 sdk/eventhub/Azure.Messaging.EventHubs.Shared/src/Resources.resx mode change 100755 => 100644 sdk/eventhub/Azure.Messaging.EventHubs/src/Amqp/AmqpClient.cs mode change 100755 => 100644 sdk/eventhub/Azure.Messaging.EventHubs/src/Core/TransportClient.cs mode change 100755 => 100644 sdk/eventhub/Azure.Messaging.EventHubs/src/Core/TransportProducer.cs create mode 100644 sdk/eventhub/Azure.Messaging.EventHubs/src/Core/TransportProducerFeatures.cs mode change 100755 => 100644 sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/EventHubProducerClient.cs create mode 100644 sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/PartitionPublishingState.cs mode change 100755 => 100644 sdk/eventhub/Azure.Messaging.EventHubs/tests/Amqp/AmqpClientTests.cs mode change 100755 => 100644 sdk/eventhub/Azure.Messaging.EventHubs/tests/Amqp/AmqpProducerTests.cs mode change 100755 => 100644 sdk/eventhub/Azure.Messaging.EventHubs/tests/Diagnostics/DiagnosticsTests.cs diff --git a/sdk/eventhub/Azure.Messaging.EventHubs.Shared/src/Resources.Designer.cs b/sdk/eventhub/Azure.Messaging.EventHubs.Shared/src/Resources.Designer.cs old mode 100755 new mode 100644 index 71116234a555f..3777fb7e97105 --- a/sdk/eventhub/Azure.Messaging.EventHubs.Shared/src/Resources.Designer.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs.Shared/src/Resources.Designer.cs @@ -78,7 +78,7 @@ internal static string CannotParseIntegerType } /// - /// Looks up a localized string similar to A producer created for a specific partition cannot send events using a partition key. This producer is associated with partition '{0}'.. + /// Looks up a localized string similar to An event cannot be published using both a partition key and a partition identifier. This operation specified partition key `{0}` and partition id `{1}`.. /// internal static string CannotSendWithPartitionIdAndPartitionKey { @@ -703,5 +703,27 @@ internal static string OnlyOneSharedAccessAuthorizationMayBeSpecified return ResourceManager.GetString("OnlyOneSharedAccessAuthorizationMayBeSpecified", resourceCulture); } } + + /// + /// Looks up a localized string similar to The producer was configured to use features that require publishing to a specific partition. Publishing with automatic routing or using a partition key is not supported by this producer.. + /// + internal static string CannotPublishToGateway + { + get + { + return ResourceManager.GetString("CannotPublishToGateway", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to These events have already been successfully published. When idempotent publishing is enabled, events that were acknowledged by the Event Hubs service may not be published again.. + /// + internal static string IdempotentAlreadyPublished + { + get + { + return ResourceManager.GetString("IdempotentAlreadyPublished", resourceCulture); + } + } } } diff --git a/sdk/eventhub/Azure.Messaging.EventHubs.Shared/src/Resources.resx b/sdk/eventhub/Azure.Messaging.EventHubs.Shared/src/Resources.resx old mode 100755 new mode 100644 index 1ad34df23a305..bd1f476eb16c4 --- a/sdk/eventhub/Azure.Messaging.EventHubs.Shared/src/Resources.resx +++ b/sdk/eventhub/Azure.Messaging.EventHubs.Shared/src/Resources.resx @@ -172,7 +172,7 @@ The requested transport type, '{0}' is not supported. - A producer created for a specific partition cannot send events using a partition key. This producer is associated with partition '{0}'. + An event cannot be published using both a partition key and a partition identifier. This operation specified partition key `{0}` and partition id `{1}`. The credential is not a known and supported credential type. Please use a JWT credential or shared key credential. @@ -289,6 +289,12 @@ One or more exceptions occured during event processing. Please see the inner exceptions for more detail. - The authorization for a connection string may specifiy a shared key or precomputed shared access signature, but not both. Please verify that your connection string does not have the `SharedAccessSignature` token if you are passing the `SharedKeyName` and `SharedKey`. + The authorization for a connection string may specify a shared key or pre-computed shared access signature, but not both. Please verify that your connection string does not have the `SharedAccessSignature` token if you are passing the `SharedKeyName` and `SharedKey`. + + + The producer was configured to use features that require publishing to a specific partition. Publishing with automatic routing or using a partition key is not supported by this producer. + + + These events have already been successfully published. When idempotent publishing is enabled, events that were acknowledged by the Event Hubs service may not be published again. diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/api/Azure.Messaging.EventHubs.netstandard2.0.cs b/sdk/eventhub/Azure.Messaging.EventHubs/api/Azure.Messaging.EventHubs.netstandard2.0.cs index 659c229d4256e..143dadce3e888 100644 --- a/sdk/eventhub/Azure.Messaging.EventHubs/api/Azure.Messaging.EventHubs.netstandard2.0.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/api/Azure.Messaging.EventHubs.netstandard2.0.cs @@ -465,7 +465,7 @@ public EventHubProducerClient(string connectionString, string eventHubName, Azur public virtual System.Threading.Tasks.Task GetPartitionIdsAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public virtual System.Threading.Tasks.Task GetPartitionPropertiesAsync(string partitionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public virtual System.Threading.Tasks.Task SendAsync(Azure.Messaging.EventHubs.Producer.EventDataBatch eventBatch, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public virtual System.Threading.Tasks.Task SendAsync(System.Collections.Generic.IEnumerable eventBatch, Azure.Messaging.EventHubs.Producer.SendEventOptions options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public virtual System.Threading.Tasks.Task SendAsync(System.Collections.Generic.IEnumerable eventSet, Azure.Messaging.EventHubs.Producer.SendEventOptions options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public virtual System.Threading.Tasks.Task SendAsync(System.Collections.Generic.IEnumerable eventBatch, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)] public override string ToString() { throw null; } @@ -495,9 +495,9 @@ public partial class PartitionPublishingProperties { protected internal PartitionPublishingProperties(bool isIdempotentPublishingEnabled, long? producerGroupId, short? ownerLevel, int? lastPublishedSequenceNumber) { } public bool IsIdempotentPublishingEnabled { get { throw null; } } - public int? LastPublishedSequenceNumber { get { throw null; } set { } } - public short? OwnerLevel { get { throw null; } set { } } - public long? ProducerGroupId { get { throw null; } set { } } + public int? LastPublishedSequenceNumber { get { throw null; } } + public short? OwnerLevel { get { throw null; } } + public long? ProducerGroupId { get { throw null; } } } public partial class SendEventOptions { diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/src/Amqp/AmqpClient.cs b/sdk/eventhub/Azure.Messaging.EventHubs/src/Amqp/AmqpClient.cs old mode 100755 new mode 100644 index 63917f742e44d..f5aedd29e91fa --- a/sdk/eventhub/Azure.Messaging.EventHubs/src/Amqp/AmqpClient.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/src/Amqp/AmqpClient.cs @@ -13,6 +13,7 @@ using Azure.Messaging.EventHubs.Consumer; using Azure.Messaging.EventHubs.Core; using Azure.Messaging.EventHubs.Diagnostics; +using Azure.Messaging.EventHubs.Producer; using Microsoft.Azure.Amqp; namespace Azure.Messaging.EventHubs.Amqp @@ -374,11 +375,15 @@ public override async Task GetPartitionPropertiesAsync(stri /// /// /// The identifier of the partition to which the transport producer should be bound; if null, the producer is unbound. + /// The flags specifying the set of special transport features that should be opted-into. + /// The set of options, if any, that should be considered when initializing the producer. /// The policy which governs retry behavior and try timeouts. /// /// A configured in the requested manner. /// public override TransportProducer CreateProducer(string partitionId, + TransportProducerFeatures requestedFeatures, + PartitionPublishingOptions partitionOptions, EventHubsRetryPolicy retryPolicy) { Argument.AssertNotClosed(_closed, nameof(AmqpClient)); @@ -389,7 +394,9 @@ public override TransportProducer CreateProducer(string partitionId, partitionId, ConnectionScope, MessageConverter, - retryPolicy + retryPolicy, + requestedFeatures, + partitionOptions ); } diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/src/Amqp/AmqpProducer.cs b/sdk/eventhub/Azure.Messaging.EventHubs/src/Amqp/AmqpProducer.cs index 440a310a6234a..649fd1ef25b7e 100644 --- a/sdk/eventhub/Azure.Messaging.EventHubs/src/Amqp/AmqpProducer.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/src/Amqp/AmqpProducer.cs @@ -58,6 +58,12 @@ internal class AmqpProducer : TransportProducer /// private string PartitionId { get; } + /// + /// The flags specifying the set of special transport features that this producer has opted-into. + /// + /// + private TransportProducerFeatures ActiveFeatures { get; } + /// /// The policy to use for determining retry behavior for when an operation fails. /// @@ -92,6 +98,17 @@ internal class AmqpProducer : TransportProducer /// private long? MaximumMessageSize { get; set; } + /// + /// The set of partition publishing properties active for this producer at the time it was initialized. + /// + /// + /// + /// It is important to note that these properties are a snapshot of the service state at the time when the + /// producer was initialized; they do not necessarily represent the current state of the service. + /// + /// + private PartitionPublishingProperties InitializedPartitionProperties { get; set; } + /// /// Initializes a new instance of the class. /// @@ -101,6 +118,8 @@ internal class AmqpProducer : TransportProducer /// The AMQP connection context for operations. /// The converter to use for translating between AMQP messages and client types. /// The retry policy to consider when an operation fails. + /// The flags specifying the set of special transport features that should be opted-into. + /// The set of options, if any, that should be considered when initializing the producer. /// /// /// As an internal type, this class performs only basic sanity checks against its arguments. It @@ -115,8 +134,12 @@ public AmqpProducer(string eventHubName, string partitionId, AmqpConnectionScope connectionScope, AmqpMessageConverter messageConverter, - EventHubsRetryPolicy retryPolicy) + EventHubsRetryPolicy retryPolicy, + TransportProducerFeatures requestedFeatures = TransportProducerFeatures.None, + PartitionPublishingOptions partitionOptions = null) { + partitionOptions ??= new PartitionPublishingOptions(); + Argument.AssertNotNullOrEmpty(eventHubName, nameof(eventHubName)); Argument.AssertNotNull(connectionScope, nameof(connectionScope)); Argument.AssertNotNull(messageConverter, nameof(messageConverter)); @@ -127,9 +150,10 @@ public AmqpProducer(string eventHubName, RetryPolicy = retryPolicy; ConnectionScope = connectionScope; MessageConverter = messageConverter; + ActiveFeatures = requestedFeatures; SendLink = new FaultTolerantAmqpObject( - timeout => CreateLinkAndEnsureProducerStateAsync(partitionId, timeout, CancellationToken.None), + timeout => CreateLinkAndEnsureProducerStateAsync(partitionId, partitionOptions, timeout, CancellationToken.None), link => { link.Session?.SafeClose(); @@ -211,7 +235,6 @@ public override async ValueTask CreateBatchAsync(CreateBatc if (!MaximumMessageSize.HasValue) { var failedAttemptCount = 0; - var retryDelay = default(TimeSpan?); var tryTimeout = RetryPolicy.CalculateTryTimeout(0); while ((!cancellationToken.IsCancellationRequested) && (!_closed)) @@ -223,13 +246,13 @@ public override async ValueTask CreateBatchAsync(CreateBatc } catch (Exception ex) { - Exception activeEx = ex.TranslateServiceException(EventHubName); + ++failedAttemptCount; // Determine if there should be a retry for the next attempt; if so enforce the delay but do not quit the loop. // Otherwise, bubble the exception. - ++failedAttemptCount; - retryDelay = RetryPolicy.CalculateRetryDelay(activeEx, failedAttemptCount); + var activeEx = ex.TranslateServiceException(EventHubName); + var retryDelay = RetryPolicy.CalculateRetryDelay(activeEx, failedAttemptCount); if ((retryDelay.HasValue) && (!ConnectionScope.IsDisposed) && (!cancellationToken.IsCancellationRequested)) { @@ -247,13 +270,7 @@ public override async ValueTask CreateBatchAsync(CreateBatc } } - // If MaximumMessageSize has not been populated nor exception thrown - // by this point, then cancellation has been requested. - - if (!MaximumMessageSize.HasValue) - { - throw new TaskCanceledException(); - } + cancellationToken.ThrowIfCancellationRequested(); } // Ensure that there was a maximum size populated; if none was provided, @@ -265,6 +282,74 @@ public override async ValueTask CreateBatchAsync(CreateBatc return new AmqpEventBatch(MessageConverter, options); } + /// + /// Reads the set of partition publishing properties active for this producer at the time it was initialized. + /// + /// + /// The cancellation token to consider when creating the link. + /// + /// The set of observed when the producer was initialized. + /// + /// + /// It is important to note that these properties are a snapshot of the service state at the time when the + /// producer was initialized; they do not necessarily represent the current state of the service. + /// + /// + public override async ValueTask ReadInitializationPublishingPropertiesAsync(CancellationToken cancellationToken) + { + Argument.AssertNotClosed(_closed, nameof(AmqpProducer)); + + // If the properties were already initialized, use them. + + if (InitializedPartitionProperties != null) + { + return InitializedPartitionProperties; + } + + // Initialize the properties by forcing the link to be opened. + + cancellationToken.ThrowIfCancellationRequested(); + + var failedAttemptCount = 0; + var tryTimeout = RetryPolicy.CalculateTryTimeout(0); + + while ((!cancellationToken.IsCancellationRequested) && (!_closed)) + { + try + { + await SendLink.GetOrCreateAsync(UseMinimum(ConnectionScope.SessionTimeout, tryTimeout)).ConfigureAwait(false); + break; + } + catch (Exception ex) + { + ++failedAttemptCount; + + // Determine if there should be a retry for the next attempt; if so enforce the delay but do not quit the loop. + // Otherwise, bubble the exception. + + var activeEx = ex.TranslateServiceException(EventHubName); + var retryDelay = RetryPolicy.CalculateRetryDelay(activeEx, failedAttemptCount); + + if ((retryDelay.HasValue) && (!ConnectionScope.IsDisposed) && (!cancellationToken.IsCancellationRequested)) + { + await Task.Delay(retryDelay.Value, cancellationToken).ConfigureAwait(false); + tryTimeout = RetryPolicy.CalculateTryTimeout(failedAttemptCount); + } + else if (ex is AmqpException) + { + ExceptionDispatchInfo.Capture(activeEx).Throw(); + } + else + { + throw; + } + } + } + + cancellationToken.ThrowIfCancellationRequested(); + return InitializedPartitionProperties; + } + /// /// Closes the connection to the transport producer instance. /// @@ -430,6 +515,7 @@ protected virtual async Task SendAsync(Func messageFactory, /// /// /// The identifier of the Event Hub partition to which it is bound; if unbound, null. + /// The set of options, if any, that should be considered when initializing the producer. /// The timeout to apply when creating the link. /// The cancellation token to consider when creating the link. /// @@ -443,6 +529,7 @@ protected virtual async Task SendAsync(Func messageFactory, /// /// protected virtual async Task CreateLinkAndEnsureProducerStateAsync(string partitionId, + PartitionPublishingOptions partitionOptions, TimeSpan timeout, CancellationToken cancellationToken) { @@ -465,6 +552,19 @@ protected virtual async Task CreateLinkAndEnsureProducerStateAs await Task.Delay(15, cancellationToken).ConfigureAwait(false); MaximumMessageSize = (long)link.Settings.MaxMessageSize; } + + if (InitializedPartitionProperties == null) + { + if ((ActiveFeatures & TransportProducerFeatures.IdempotentPublishing) != 0) + { + throw new NotImplementedException(nameof(CreateLinkAndEnsureProducerStateAsync)); + } + else + { + InitializedPartitionProperties = new PartitionPublishingProperties(false, null, null, null); + } + } + } catch (Exception ex) { diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/src/Core/TransportClient.cs b/sdk/eventhub/Azure.Messaging.EventHubs/src/Core/TransportClient.cs old mode 100755 new mode 100644 index 0611cb6736a9c..b96d5c8b6ea35 --- a/sdk/eventhub/Azure.Messaging.EventHubs/src/Core/TransportClient.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/src/Core/TransportClient.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Azure.Messaging.EventHubs.Consumer; +using Azure.Messaging.EventHubs.Producer; namespace Azure.Messaging.EventHubs.Core { @@ -67,11 +68,15 @@ public abstract Task GetPartitionPropertiesAsync(string par /// /// /// The identifier of the partition to which the transport producer should be bound; if null, the producer is unbound. + /// The flags specifying the set of special transport features that should be opted-into. + /// The set of options, if any, that should be considered when initializing the producer. /// The policy which governs retry behavior and try timeouts. /// /// A configured in the requested manner. /// public abstract TransportProducer CreateProducer(string partitionId, + TransportProducerFeatures requestedFeatures, + PartitionPublishingOptions partitionOptions, EventHubsRetryPolicy retryPolicy); /// diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/src/Core/TransportProducer.cs b/sdk/eventhub/Azure.Messaging.EventHubs/src/Core/TransportProducer.cs old mode 100755 new mode 100644 index 6a715d880468f..8129c90ae80da --- a/sdk/eventhub/Azure.Messaging.EventHubs/src/Core/TransportProducer.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/src/Core/TransportProducer.cs @@ -71,6 +71,21 @@ public abstract Task SendAsync(EventDataBatch eventBatch, public abstract ValueTask CreateBatchAsync(CreateBatchOptions options, CancellationToken cancellationToken); + /// + /// Reads the set of partition publishing properties active for this producer at the time it was initialized. + /// + /// + /// The cancellation token to consider when creating the link. + /// + /// The set of observed when the producer was initialized. + /// + /// + /// It is important to note that these properties are a snapshot of the service state at the time when the + /// producer was initialized; they do not necessarily represent the current state of the service. + /// + /// + public abstract ValueTask ReadInitializationPublishingPropertiesAsync(CancellationToken cancellationToken); + /// /// Closes the connection to the transport producer instance. /// diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/src/Core/TransportProducerFeatures.cs b/sdk/eventhub/Azure.Messaging.EventHubs/src/Core/TransportProducerFeatures.cs new file mode 100644 index 0000000000000..5ad44e0a133b7 --- /dev/null +++ b/sdk/eventhub/Azure.Messaging.EventHubs/src/Core/TransportProducerFeatures.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; + +namespace Azure.Messaging.EventHubs.Core +{ + /// + /// The set of special transport features specific to a which + /// require opting-into. + /// + /// + [Flags] + internal enum TransportProducerFeatures : byte + { + /// No transport features were requested. + None = 0, + + /// The idempotent publishing feature is requested. + IdempotentPublishing = 1 + } +} diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/src/Core/TransportProducerPool.cs b/sdk/eventhub/Azure.Messaging.EventHubs/src/Core/TransportProducerPool.cs index 16430a3c0c9e0..83497535dd7b4 100644 --- a/sdk/eventhub/Azure.Messaging.EventHubs/src/Core/TransportProducerPool.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/src/Core/TransportProducerPool.cs @@ -20,13 +20,6 @@ internal class TransportProducerPool /// The period after which is run. private static readonly TimeSpan DefaultPerformExpirationPeriod = TimeSpan.FromMinutes(10); - /// - /// The set of active Event Hub transport-specific producers specific to a given partition; - /// intended to perform delegated operations. - /// - /// - private ConcurrentDictionary Pool { get; } - /// /// An abstracted Event Hub transport-specific producer that is associated with the /// Event Hub gateway rather than a specific partition; intended to perform delegated operations. @@ -35,17 +28,11 @@ internal class TransportProducerPool public TransportProducer EventHubProducer { get; } /// - /// The active connection to the Azure Event Hubs service, enabling client communications for metadata - /// about the associated Event Hub and access to a transport-aware producer. - /// - /// - private EventHubConnection Connection { get; } - - /// - /// The policy to use for determining retry behavior for when an operation fails. + /// The set of active Event Hub transport-specific producers specific to a given partition; + /// intended to perform delegated operations. /// /// - private EventHubsRetryPolicy RetryPolicy { get; } + private ConcurrentDictionary Pool { get; } /// /// A reference to a periodically checking every @@ -55,39 +42,39 @@ internal class TransportProducerPool private Timer ExpirationTimer { get; } /// - /// Initializes a new instance of the class. + /// A factory method for spawning a for a given partition. /// /// - internal TransportProducerPool() - { - } + private Func TransportProducerFactory { get; } /// /// Initializes a new instance of the class. /// /// - /// The connection to use for communication with the Event Hubs service. - /// The policy to use for determining retry behavior for when an operation fails. + /// A factory method for spawning a for a given partition. /// The pool of that is going to be used to store the partition specific . /// The period after which is run. Overrides . /// An abstracted Event Hub transport-specific producer that is associated with the Event Hub gateway rather than a specific partition. /// - public TransportProducerPool(EventHubConnection connection, - EventHubsRetryPolicy retryPolicy, + public TransportProducerPool(Func transportProducerFactory, ConcurrentDictionary pool = default, TimeSpan? performExpirationPeriod = default, TransportProducer eventHubProducer = default) { - Connection = connection; - RetryPolicy = retryPolicy; - Pool = pool ?? new ConcurrentDictionary(); performExpirationPeriod ??= DefaultPerformExpirationPeriod; - EventHubProducer = eventHubProducer ?? connection.CreateTransportProducer(null, retryPolicy); - ExpirationTimer = new Timer(CreateExpirationTimerCallback(), - null, - performExpirationPeriod.Value, - performExpirationPeriod.Value); + Pool = pool ?? new ConcurrentDictionary(); + EventHubProducer = eventHubProducer ?? transportProducerFactory(null); + TransportProducerFactory = transportProducerFactory; + ExpirationTimer = new Timer(CreateExpirationTimerCallback(), null, performExpirationPeriod.Value, performExpirationPeriod.Value); + } + + /// + /// Initializes a new instance of the class. + /// + /// + internal TransportProducerPool() + { } /// @@ -114,8 +101,7 @@ public virtual PooledProducer GetPooledProducer(string partitionId, } var identifier = Guid.NewGuid().ToString(); - - var item = Pool.GetOrAdd(partitionId, id => new PoolItem(partitionId, Connection.CreateTransportProducer(id, RetryPolicy), removeAfterDuration)); + var item = Pool.GetOrAdd(partitionId, id => new PoolItem(partitionId, TransportProducerFactory(id), removeAfterDuration)); // A race condition at this point may end with CloseAsync called on // the returned PoolItem if it had expired. The probability is very low and @@ -124,7 +110,7 @@ public virtual PooledProducer GetPooledProducer(string partitionId, if (item.PartitionProducer.IsClosed || !item.ActiveInstances.TryAdd(identifier, 0)) { identifier = Guid.NewGuid().ToString(); - item = Pool.GetOrAdd(partitionId, id => new PoolItem(partitionId, Connection.CreateTransportProducer(id, RetryPolicy), removeAfterDuration)); + item = Pool.GetOrAdd(partitionId, id => new PoolItem(partitionId, TransportProducerFactory(id), removeAfterDuration)); item.ActiveInstances.TryAdd(identifier, 0); } @@ -141,7 +127,7 @@ public virtual PooledProducer GetPooledProducer(string partitionId, // If TryRemove returned false the PoolItem would not be closed deterministically // and the ExpirationTimer callback would eventually remove it from the - // Pool leaving to the Garbage Collector the responsability of closing + // Pool leaving to the Garbage Collector the responsibility of closing // the TransportProducer and the AMQP link. item.ActiveInstances.TryRemove(identifier, out _); @@ -200,7 +186,7 @@ private TimerCallback CreateExpirationTimerCallback() { return _ => { - // Capture the timestamp to use a consistent value. + // Capture the time stamp to use a consistent value. var now = DateTimeOffset.UtcNow; foreach (var key in Pool.Keys.ToList()) diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/src/Diagnostics/EventHubsEventSource.cs b/sdk/eventhub/Azure.Messaging.EventHubs/src/Diagnostics/EventHubsEventSource.cs index 3f7601f5906b7..ce9fb45124f33 100644 --- a/sdk/eventhub/Azure.Messaging.EventHubs/src/Diagnostics/EventHubsEventSource.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/src/Diagnostics/EventHubsEventSource.cs @@ -922,6 +922,158 @@ public virtual void EventProcessorClaimOwnershipError(string identifier, } } + /// + /// Indicates that the idempotent publishing of events has started. + /// + /// + /// The name of the Event Hub being published to. + /// The identifier of a partition used for idempotent publishing. + /// + [Event(46, Level = EventLevel.Informational, Message = "Impotently publishing events for Event Hub: {0} (Partition Id: '{1}').")] + public virtual void IdempotentPublishStart(string eventHubName, + string partitionId) + { + if (IsEnabled()) + { + WriteEvent(46, eventHubName ?? string.Empty, partitionId ?? string.Empty); + } + } + + /// + /// Indicates that the idempotent publishing of events has acquired the synchronization primitive. + /// + /// + /// The name of the Event Hub being published to. + /// The identifier of a partition used for idempotent publishing. + /// + [Event(47, Level = EventLevel.Verbose, Message = "Impotently publishing for Event Hub: {0} (Partition Id: '{1}') has acquired the partition synchronization primitive.")] + public virtual void IdempotentSynchronizationAcquire(string eventHubName, + string partitionId) + { + if (IsEnabled()) + { + WriteEvent(47, eventHubName ?? string.Empty, partitionId ?? string.Empty); + } + } + + /// + /// Indicates that the idempotent publishing of events has released the synchronization primitive. + /// + /// + /// The name of the Event Hub being published to. + /// The identifier of a partition used for idempotent publishing. + /// + [Event(48, Level = EventLevel.Verbose, Message = "Impotently publishing for Event Hub: {0} (Partition Id: '{1}') has released the partition synchronization primitive.")] + public virtual void IdempotentSynchronizationRelease(string eventHubName, + string partitionId) + { + if (IsEnabled()) + { + WriteEvent(48, eventHubName ?? string.Empty, partitionId ?? string.Empty); + } + } + + /// + /// Indicates that the idempotent publishing of events has released the synchronization primitive. + /// + /// + /// The name of the Event Hub being published to. + /// The identifier of a partition used for idempotent publishing. + /// The starting sequence number used for publishing. + /// The ending sequence number of partition state used for publishing. + /// + [Event(49, Level = EventLevel.Verbose, Message = "Impotently publishing for Event Hub: {0} (Partition Id: '{1}') is publishing events with the sequence number range from '{2}` to '{3}'.")] + public virtual void IdempotentSequencePublish(string eventHubName, + string partitionId, + long startSequenceNumber, + long endSequenceNumber) + { + if (IsEnabled()) + { + WriteEvent(49, eventHubName ?? string.Empty, partitionId ?? string.Empty, startSequenceNumber, endSequenceNumber); + } + } + + /// + /// Indicates that the idempotent publishing of events has released the synchronization primitive. + /// + /// + /// The name of the Event Hub being published to. + /// The identifier of a partition used for idempotent publishing. + /// The sequence number of partition state before the update. + /// The sequence number of partition state after the update. + /// + [Event(50, Level = EventLevel.Verbose, Message = "Impotently publishing for Event Hub: {0} (Partition Id: '{1}') has updated the tracked sequence number from '{2}` to '{3}'.")] + public virtual void IdempotentSequenceUpdate(string eventHubName, + string partitionId, + long oldSequenceNumber, + long newSequenceNumber) + { + if (IsEnabled()) + { + WriteEvent(50, eventHubName ?? string.Empty, partitionId ?? string.Empty, oldSequenceNumber, newSequenceNumber); + } + } + + /// + /// Indicates that the idempotent publishing of events has completed. + /// + /// + /// The name of the Event Hub being published to. + /// The identifier of a partition used for idempotent publishing. + /// + [Event(51, Level = EventLevel.Informational, Message = "Completed idempotent publishing events for Event Hub: {0} (Partition Id: '{1}').")] + public virtual void IdempotentPublishComplete(string eventHubName, + string partitionId) + { + if (IsEnabled()) + { + WriteEvent(51, eventHubName ?? string.Empty, partitionId ?? string.Empty); + } + } + + /// + /// Indicates that an exception was encountered while idempotent publishing events. + /// + /// + /// The name of the Event Hub being published to. + /// The identifier of a partition used for idempotent publishing. + /// The message for the exception that occurred. + /// + [Event(52, Level = EventLevel.Error, Message = "An exception occurred while idempotent publishing events for Event Hub: {0} (Partition Id: '{1}'). Error Message: '{2}'")] + public virtual void IdempotentPublishError(string eventHubName, + string partitionId, + string errorMessage) + { + if (IsEnabled()) + { + WriteEvent(52, eventHubName ?? string.Empty, partitionId ?? string.Empty, errorMessage ?? string.Empty); + } + } + + /// + /// Indicates that the idempotent publishing state for a partition has been initialized. + /// + /// + /// The name of the Event Hub being published to. + /// The identifier of a partition used for idempotent publishing. + /// The identifier of the producer group associated with the partition. + /// The owner level associated with the partition. + /// The sequence number last published to the partition for the producer group. + /// + [Event(53, Level = EventLevel.Informational, Message = "Initializing idempotent publishing state for Event Hub: {0} (Partition Id: '{1}'). Producer Group Id: '{2}', Owner Level: '{3}', Last Published Sequence: '{4}'.")] + public virtual void IdempotentPublishInitializeState(string eventHubName, + string partitionId, + long producerGroupId, + short ownerLevel, + long lastPublishedSequence) + { + if (IsEnabled()) + { + WriteEvent(53, eventHubName ?? string.Empty, partitionId ?? string.Empty, producerGroupId, ownerLevel, lastPublishedSequence); + } + } + /// /// Indicates that an exception was encountered in an unexpected code path, not directly associated with /// an Event Hubs operation. diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/src/EventData.cs b/sdk/eventhub/Azure.Messaging.EventHubs/src/EventData.cs index f3a75b9872150..e1289e06e181b 100644 --- a/sdk/eventhub/Azure.Messaging.EventHubs/src/EventData.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/src/EventData.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.ComponentModel; using System.IO; -using Azure.Core; using Azure.Messaging.EventHubs.Consumer; namespace Azure.Messaging.EventHubs @@ -17,9 +16,6 @@ namespace Azure.Messaging.EventHubs /// public class EventData { - /// The sequence number associated with publishing of the event. - private int? _publishedSequenceNumber = null; - /// /// The data associated with the event. /// @@ -104,20 +100,7 @@ public Stream BodyAsStream /// of the producer are enabled. For example, it is used by idempotent publishing. /// /// - public int? PublishedSequenceNumber - { - get => _publishedSequenceNumber; - - internal set - { - if (value.HasValue) - { - Argument.AssertAtLeast(value.Value, 0, nameof(PublishedSequenceNumber)); - } - - _publishedSequenceNumber = value; - } - } + public int? PublishedSequenceNumber { get; private set; } /// /// The sequence number assigned to the event when it was enqueued in the associated Event Hub partition. @@ -162,6 +145,24 @@ internal set /// public string PartitionKey { get; } + /// + /// The publishing sequence number assigned to the event as part of a publishing operation. + /// + /// + /// + /// The sequence number that was assigned during publishing, if the event was successfully + /// published by a sequence-aware producer. If the producer was not configured to apply + /// sequence numbering or if the event has not yet been successfully published, this member + /// will be null. + /// + /// + /// + /// The published sequence number is only populated and relevant when certain features + /// of the producer are enabled. For example, it is used by idempotent publishing. + /// + /// + internal int? PendingPublishSequenceNumber { get; set; } + /// /// The sequence number of the event that was last enqueued into the Event Hub partition from which this /// event was received. @@ -235,6 +236,8 @@ public EventData(ReadOnlyMemory eventBody) : this(eventBody, lastPartition /// The offset that was last enqueued into the Event Hub partition. /// The date and time, in UTC, of the event that was last enqueued into the Event Hub partition. /// The date and time, in UTC, that the last event information for the Event Hub partition was retrieved from the service. + /// The publishing sequence number assigned to the event at the time it was successfully published. + /// The publishing sequence number assigned to the event as part of a publishing operation. /// internal EventData(ReadOnlyMemory eventBody, IDictionary properties = null, @@ -246,7 +249,9 @@ internal EventData(ReadOnlyMemory eventBody, long? lastPartitionSequenceNumber = null, long? lastPartitionOffset = null, DateTimeOffset? lastPartitionEnqueuedTime = null, - DateTimeOffset? lastPartitionPropertiesRetrievalTime = null) + DateTimeOffset? lastPartitionPropertiesRetrievalTime = null, + int? publishedSequenceNumber = null, + int? pendingPublishSequenceNumber = null) { Body = eventBody; Properties = properties ?? new Dictionary(); @@ -255,10 +260,12 @@ internal EventData(ReadOnlyMemory eventBody, Offset = offset; EnqueuedTime = enqueuedTime; PartitionKey = partitionKey; + PendingPublishSequenceNumber = pendingPublishSequenceNumber; LastPartitionSequenceNumber = lastPartitionSequenceNumber; LastPartitionOffset = lastPartitionOffset; LastPartitionEnqueuedTime = lastPartitionEnqueuedTime; LastPartitionPropertiesRetrievalTime = lastPartitionPropertiesRetrievalTime; + PublishedSequenceNumber = publishedSequenceNumber; } /// @@ -312,6 +319,16 @@ protected EventData(ReadOnlyMemory eventBody, [EditorBrowsable(EditorBrowsableState.Never)] public override string ToString() => base.ToString(); + /// + /// Transitions the pending publishing sequence number to the published sequence number. + /// + /// + internal void CommitPublishingSequenceNumber() + { + PublishedSequenceNumber = PendingPublishSequenceNumber; + PendingPublishSequenceNumber = default; + } + /// /// Creates a new copy of the current , cloning its attributes into a new instance. /// @@ -331,7 +348,9 @@ internal EventData Clone() => LastPartitionSequenceNumber, LastPartitionOffset, LastPartitionEnqueuedTime, - LastPartitionPropertiesRetrievalTime + LastPartitionPropertiesRetrievalTime, + PublishedSequenceNumber, + PendingPublishSequenceNumber ); } } diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/src/EventHubConnection.cs b/sdk/eventhub/Azure.Messaging.EventHubs/src/EventHubConnection.cs index 164ee677c09e3..65a9165097b1c 100644 --- a/sdk/eventhub/Azure.Messaging.EventHubs/src/EventHubConnection.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/src/EventHubConnection.cs @@ -12,6 +12,7 @@ using Azure.Messaging.EventHubs.Consumer; using Azure.Messaging.EventHubs.Core; using Azure.Messaging.EventHubs.Diagnostics; +using Azure.Messaging.EventHubs.Producer; namespace Azure.Messaging.EventHubs { @@ -354,15 +355,19 @@ internal virtual async Task GetPartitionPropertiesAsync(str /// /// /// The identifier of the partition to which the transport producer should be bound; if null, the producer is unbound. + /// The flags specifying the set of special transport features that should be opted-into. + /// The set of options, if any, that should be considered when initializing the producer. /// The policy which governs retry behavior and try timeouts. /// /// A configured in the requested manner. /// internal virtual TransportProducer CreateTransportProducer(string partitionId, + TransportProducerFeatures requestedFeatures, + PartitionPublishingOptions partitionOptions, EventHubsRetryPolicy retryPolicy) { Argument.AssertNotNull(retryPolicy, nameof(retryPolicy)); - return InnerClient.CreateProducer(partitionId, retryPolicy); + return InnerClient.CreateProducer(partitionId, requestedFeatures, partitionOptions, retryPolicy); } /// diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/EventDataBatch.cs b/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/EventDataBatch.cs index 4ab80c4bd4357..f1b8166706943 100644 --- a/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/EventDataBatch.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/EventDataBatch.cs @@ -61,16 +61,7 @@ public sealed class EventDataBatch : IDisposable public int? StartingPublishedSequenceNumber { get => InnerBatch.StartingPublishedSequenceNumber; - - internal set - { - if (value.HasValue) - { - Argument.AssertAtLeast(value.Value, 0, nameof(StartingPublishedSequenceNumber)); - } - - InnerBatch.StartingPublishedSequenceNumber = value; - } + internal set => InnerBatch.StartingPublishedSequenceNumber = value; } /// diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/EventHubProducerClient.cs b/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/EventHubProducerClient.cs old mode 100755 new mode 100644 index f2fae7df0ae0b..ce99b97f046ad --- a/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/EventHubProducerClient.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/EventHubProducerClient.cs @@ -2,11 +2,13 @@ // Licensed under the MIT License. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; +using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; @@ -91,6 +93,12 @@ public bool IsClosed /// private EventHubsRetryPolicy RetryPolicy { get; } + /// + /// The set of options to use with the instance. + /// + /// + private EventHubProducerClientOptions Options { get; } + /// /// The active connection to the Azure Event Hubs service, enabling client communications for metadata /// about the associated Event Hub and access to a transport-aware producer. @@ -104,6 +112,17 @@ public bool IsClosed /// private TransportProducerPool PartitionProducerPool { get; } + /// + /// The publishing-related state associated with partitions. + /// + /// + /// + /// Created if the producer has been configured with one or more features which requires + /// publishing to partitions in a stateful manner; otherwise, null. + /// + /// + private ConcurrentDictionary PartitionState { get; } + /// /// Initializes a new instance of the class. /// @@ -194,7 +213,19 @@ public EventHubProducerClient(string connectionString, OwnsConnection = true; Connection = new EventHubConnection(connectionString, eventHubName, clientOptions.ConnectionOptions); RetryPolicy = clientOptions.RetryOptions.ToRetryPolicy(); - PartitionProducerPool = new TransportProducerPool(Connection, RetryPolicy); + Options = clientOptions; + + PartitionProducerPool = new TransportProducerPool(partitionId => + Connection.CreateTransportProducer( + partitionId, + clientOptions.CreateFeatureFlags(), + Options.GetPublishingOptionsOrDefaultForPartition(partitionId), + RetryPolicy)); + + if (RequiresStatefulPartitions(clientOptions)) + { + PartitionState = new ConcurrentDictionary(); + } } /// @@ -219,8 +250,20 @@ public EventHubProducerClient(string fullyQualifiedNamespace, OwnsConnection = true; Connection = new EventHubConnection(fullyQualifiedNamespace, eventHubName, credential, clientOptions.ConnectionOptions); + Options = clientOptions; RetryPolicy = clientOptions.RetryOptions.ToRetryPolicy(); - PartitionProducerPool = new TransportProducerPool(Connection, RetryPolicy); + + PartitionProducerPool = new TransportProducerPool(partitionId => + Connection.CreateTransportProducer( + partitionId, + clientOptions.CreateFeatureFlags(), + Options.GetPublishingOptionsOrDefaultForPartition(partitionId), + RetryPolicy)); + + if (RequiresStatefulPartitions(clientOptions)) + { + PartitionState = new ConcurrentDictionary(); + } } /// @@ -239,7 +282,19 @@ public EventHubProducerClient(EventHubConnection connection, OwnsConnection = false; Connection = connection; RetryPolicy = clientOptions.RetryOptions.ToRetryPolicy(); - PartitionProducerPool = new TransportProducerPool(Connection, RetryPolicy); + Options = clientOptions; + + PartitionProducerPool = new TransportProducerPool(partitionId => + Connection.CreateTransportProducer( + partitionId, + clientOptions.CreateFeatureFlags(), + Options.GetPublishingOptionsOrDefaultForPartition(partitionId), + RetryPolicy)); + + if (RequiresStatefulPartitions(clientOptions)) + { + PartitionState = new ConcurrentDictionary(); + } } /// @@ -265,7 +320,13 @@ internal EventHubProducerClient(EventHubConnection connection, OwnsConnection = false; Connection = connection; RetryPolicy = new EventHubsRetryOptions().ToRetryPolicy(); - PartitionProducerPool = partitionProducerPool ?? new TransportProducerPool(Connection, RetryPolicy, eventHubProducer: transportProducer); + Options = new EventHubProducerClientOptions(); + PartitionProducerPool = partitionProducerPool ?? new TransportProducerPool(partitionId => transportProducer); + + if (RequiresStatefulPartitions(Options)) + { + PartitionState = new ConcurrentDictionary(); + } } /// @@ -403,7 +464,7 @@ public virtual async Task SendAsync(IEnumerable eventBatch, /// validated until this method is invoked. The call will fail if the size of the specified set of events exceeds the maximum allowable size of a single batch. /// /// - /// The set of event data to send. + /// The set of event data to send. /// The set of options to consider when sending this batch. /// An optional instance to signal the request to cancel the operation. /// @@ -418,62 +479,31 @@ public virtual async Task SendAsync(IEnumerable eventBatch, /// /// /// - public virtual async Task SendAsync(IEnumerable eventBatch, + public virtual async Task SendAsync(IEnumerable eventSet, SendEventOptions options, CancellationToken cancellationToken = default) { options ??= DefaultSendOptions; - Argument.AssertNotNull(eventBatch, nameof(eventBatch)); + Argument.AssertNotNull(eventSet, nameof(eventSet)); AssertSinglePartitionReference(options.PartitionId, options.PartitionKey); - int attempts = 0; - - eventBatch = (eventBatch as IList) ?? eventBatch.ToList(); - InstrumentMessages(eventBatch); - - var diagnosticIdentifiers = new List(); - - foreach (var eventData in eventBatch) + var events = eventSet switch { - if (EventDataInstrumentation.TryExtractDiagnosticId(eventData, out var identifier)) - { - diagnosticIdentifiers.Add(identifier); - } - } - - using DiagnosticScope scope = CreateDiagnosticScope(diagnosticIdentifiers); + IReadOnlyList eventList => eventList, + _ => eventSet.ToList() + }; - var pooledProducer = PartitionProducerPool.GetPooledProducer(options.PartitionId, PartitionProducerLifespan); - - while (!cancellationToken.IsCancellationRequested) + if (events.Count == 0) { - try - { - await using var _ = pooledProducer.ConfigureAwait(false); - await pooledProducer.TransportProducer.SendAsync(eventBatch, options, cancellationToken).ConfigureAwait(false); - - return; - } - catch (EventHubsException eventHubException) - when (eventHubException.Reason == EventHubsException.FailureReason.ClientClosed && ShouldRecreateProducer(pooledProducer.TransportProducer, options.PartitionId)) - { - if (++attempts >= MaximumCreateProducerAttempts) - { - scope.Failed(eventHubException); - throw; - } - - pooledProducer = PartitionProducerPool.GetPooledProducer(options.PartitionId, PartitionProducerLifespan); - } - catch (Exception ex) - { - scope.Failed(ex); - throw; - } + return; } - throw new TaskCanceledException(); + var sendTask = (Options.EnableIdempotentPartitions) + ? SendIdempotentAsync(events, options, cancellationToken) + : SendInternalAsync(events, options, cancellationToken); + + await sendTask.ConfigureAwait(false); } /// @@ -493,45 +523,16 @@ public virtual async Task SendAsync(EventDataBatch eventBatch, Argument.AssertNotNull(eventBatch, nameof(eventBatch)); AssertSinglePartitionReference(eventBatch.SendOptions.PartitionId, eventBatch.SendOptions.PartitionKey); - using DiagnosticScope scope = CreateDiagnosticScope(eventBatch.GetEventDiagnosticIdentifiers()); - - var attempts = 0; - var pooledProducer = PartitionProducerPool.GetPooledProducer(eventBatch.SendOptions.PartitionId, PartitionProducerLifespan); - - while (!cancellationToken.IsCancellationRequested) + if (eventBatch.Count == 0) { - try - { - await using var _ = pooledProducer.ConfigureAwait(false); - - eventBatch.Lock(); - await pooledProducer.TransportProducer.SendAsync(eventBatch, cancellationToken).ConfigureAwait(false); - - return; - } - catch (EventHubsException eventHubException) - when (eventHubException.Reason == EventHubsException.FailureReason.ClientClosed && ShouldRecreateProducer(pooledProducer.TransportProducer, eventBatch.SendOptions.PartitionId)) - { - if (++attempts >= MaximumCreateProducerAttempts) - { - scope.Failed(eventHubException); - throw; - } - - pooledProducer = PartitionProducerPool.GetPooledProducer(eventBatch.SendOptions.PartitionId, PartitionProducerLifespan); - } - catch (Exception ex) - { - scope.Failed(ex); - throw; - } - finally - { - eventBatch.Unlock(); - } + return; } - throw new TaskCanceledException(); + var sendTask = (!Options.EnableIdempotentPartitions) + ? SendInternalAsync(eventBatch, cancellationToken) + : SendIdempotentAsync(eventBatch, cancellationToken); + + await sendTask.ConfigureAwait(false); } /// @@ -684,6 +685,354 @@ public virtual async Task CloseAsync(CancellationToken cancellationToken = defau [EditorBrowsable(EditorBrowsableState.Never)] public override string ToString() => base.ToString(); + /// + /// Sends a set of events to the associated Event Hub using a batched approach. Because the batch is implicitly created, the size of the event set is not + /// validated until this method is invoked. The call will fail if the size of the specified set of events exceeds the maximum allowable size of a single batch. + /// + /// + /// The set of event data to send. + /// The set of options to consider when sending this batch. + /// An optional instance to signal the request to cancel the operation. + /// + private async Task SendInternalAsync(IReadOnlyList events, + SendEventOptions options, + CancellationToken cancellationToken = default) + { + var attempts = 0; + var diagnosticIdentifiers = new List(); + + InstrumentMessages(events); + + foreach (var eventData in events) + { + if (EventDataInstrumentation.TryExtractDiagnosticId(eventData, out var identifier)) + { + diagnosticIdentifiers.Add(identifier); + } + } + + using DiagnosticScope scope = CreateDiagnosticScope(diagnosticIdentifiers); + + var pooledProducer = PartitionProducerPool.GetPooledProducer(options.PartitionId, PartitionProducerLifespan); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + await using var _ = pooledProducer.ConfigureAwait(false); + await pooledProducer.TransportProducer.SendAsync(events, options, cancellationToken).ConfigureAwait(false); + + return; + } + catch (EventHubsException eventHubException) + when (eventHubException.Reason == EventHubsException.FailureReason.ClientClosed && ShouldRecreateProducer(pooledProducer.TransportProducer, options.PartitionId)) + { + if (++attempts >= MaximumCreateProducerAttempts) + { + scope.Failed(eventHubException); + throw; + } + + pooledProducer = PartitionProducerPool.GetPooledProducer(options.PartitionId, PartitionProducerLifespan); + } + catch (Exception ex) + { + scope.Failed(ex); + throw; + } + } + + throw new TaskCanceledException(); + } + + /// + /// Sends a set of events to the associated Event Hub using a batched approach. + /// + /// + /// The set of event data to send. A batch may be created using . + /// An optional instance to signal the request to cancel the operation. + /// + private async Task SendInternalAsync(EventDataBatch eventBatch, + CancellationToken cancellationToken = default) + { + using DiagnosticScope scope = CreateDiagnosticScope(eventBatch.GetEventDiagnosticIdentifiers()); + + var attempts = 0; + var pooledProducer = PartitionProducerPool.GetPooledProducer(eventBatch.SendOptions.PartitionId, PartitionProducerLifespan); + + try + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await using var _ = pooledProducer.ConfigureAwait(false); + + eventBatch.Lock(); + await pooledProducer.TransportProducer.SendAsync(eventBatch, cancellationToken).ConfigureAwait(false); + + return; + } + catch (EventHubsException eventHubException) + when (eventHubException.Reason == EventHubsException.FailureReason.ClientClosed && ShouldRecreateProducer(pooledProducer.TransportProducer, eventBatch.SendOptions.PartitionId)) + { + if (++attempts >= MaximumCreateProducerAttempts) + { + scope.Failed(eventHubException); + throw; + } + + pooledProducer = PartitionProducerPool.GetPooledProducer(eventBatch.SendOptions.PartitionId, PartitionProducerLifespan); + } + catch (Exception ex) + { + scope.Failed(ex); + throw; + } + } + } + finally + { + eventBatch.Unlock(); + } + + throw new TaskCanceledException(); + } + + /// + /// Sends a set of events to the associated Event Hub using a batched approach. Because the batch is implicitly created, the size of the event set is not + /// validated until this method is invoked. The call will fail if the size of the specified set of events exceeds the maximum allowable size of a single batch. + /// + /// + /// The set of event data to send. + /// The set of options to consider when sending this batch. + /// An optional instance to signal the request to cancel the operation. + /// + private async Task SendIdempotentAsync(IReadOnlyList eventSet, + SendEventOptions options, + CancellationToken cancellationToken = default) + { + AssertPartitionIsReferenced(options.PartitionId); + AssertIdempotentEventsNotPublished(eventSet); + + try + { + cancellationToken.ThrowIfCancellationRequested(); + EventHubsEventSource.Log.IdempotentPublishStart(EventHubName, options.PartitionId); + + var partitionState = PartitionState.GetOrAdd(options.PartitionId, new PartitionPublishingState(options.PartitionId)); + + try + { + cancellationToken.ThrowIfCancellationRequested(); + + await partitionState.PublishingGuard.WaitAsync(cancellationToken).ConfigureAwait(false); + EventHubsEventSource.Log.IdempotentSynchronizationAcquire(EventHubName, options.PartitionId); + + // Ensure that the partition state has been initialized. + + if (!partitionState.IsInitialized) + { + cancellationToken.ThrowIfCancellationRequested(); + await InitializePartitionStateAsync(partitionState, cancellationToken).ConfigureAwait(false); + } + + // Sequence the events for publishing. + + var lastSequence = partitionState.LastPublishedSequenceNumber.Value; + var firstSequence = lastSequence; + + foreach (var eventData in eventSet) + { + lastSequence = NextSequence(lastSequence); + eventData.PendingPublishSequenceNumber = lastSequence; + } + + // Publish the events. + + cancellationToken.ThrowIfCancellationRequested(); + + EventHubsEventSource.Log.IdempotentSequencePublish(EventHubName, options.PartitionId, firstSequence, lastSequence); + await SendInternalAsync(eventSet, options, cancellationToken).ConfigureAwait(false); + + // Update state and commit the sequencing. + + EventHubsEventSource.Log.IdempotentSequenceUpdate(EventHubName, options.PartitionId, partitionState.LastPublishedSequenceNumber.Value, lastSequence); + partitionState.LastPublishedSequenceNumber = lastSequence; + + foreach (var eventData in eventSet) + { + eventData.CommitPublishingSequenceNumber(); + } + } + catch + { + // Clear the pending sequence numbers in the face of an exception. + + foreach (var eventData in eventSet) + { + eventData.PendingPublishSequenceNumber = null; + } + + throw; + } + finally + { + partitionState.PublishingGuard.Release(); + EventHubsEventSource.Log.IdempotentSynchronizationRelease(EventHubName, options.PartitionId); + } + } + catch (Exception ex) + { + EventHubsEventSource.Log.IdempotentPublishError(EventHubName, options.PartitionId, ex.Message); + throw; + } + finally + { + EventHubsEventSource.Log.IdempotentPublishComplete(EventHubName, options.PartitionId); + } + } + + /// + /// Sends a set of events to the associated Event Hub using a batched approach. + /// + /// + /// The set of event data to send. A batch may be created using . + /// An optional instance to signal the request to cancel the operation. + /// + private async Task SendIdempotentAsync(EventDataBatch eventBatch, + CancellationToken cancellationToken = default) + { + var options = eventBatch.SendOptions; + + AssertPartitionIsReferenced(options.PartitionId); + AssertIdempotentBatchNotPublished(eventBatch); + + try + { + cancellationToken.ThrowIfCancellationRequested(); + EventHubsEventSource.Log.IdempotentPublishStart(EventHubName, options.PartitionId); + + var partitionState = PartitionState.GetOrAdd(options.PartitionId, new PartitionPublishingState(options.PartitionId)); + + var eventSet = eventBatch.AsEnumerable() switch + { + IReadOnlyList eventList => eventList, + IEnumerable eventEnumerable => eventEnumerable.ToList() + }; + + try + { + cancellationToken.ThrowIfCancellationRequested(); + + await partitionState.PublishingGuard.WaitAsync(cancellationToken).ConfigureAwait(false); + EventHubsEventSource.Log.IdempotentSynchronizationAcquire(EventHubName, options.PartitionId); + + // Ensure that the partition state has been initialized. + + if (!partitionState.IsInitialized) + { + cancellationToken.ThrowIfCancellationRequested(); + await InitializePartitionStateAsync(partitionState, cancellationToken).ConfigureAwait(false); + } + + // Sequence the events for publishing. + + var lastSequence = partitionState.LastPublishedSequenceNumber.Value; + var firstSequence = NextSequence(lastSequence); + + foreach (var eventData in eventSet) + { + lastSequence = NextSequence(lastSequence); + eventData.PendingPublishSequenceNumber = lastSequence; + } + + // Publish the events. + + cancellationToken.ThrowIfCancellationRequested(); + + EventHubsEventSource.Log.IdempotentSequencePublish(EventHubName, options.PartitionId, firstSequence, lastSequence); + await SendInternalAsync(eventBatch, cancellationToken).ConfigureAwait(false); + + // Update state and commit the sequencing. This needs only to happen at the batch level, as the contained + // events are not accessible by callers. + + EventHubsEventSource.Log.IdempotentSequenceUpdate(EventHubName, options.PartitionId, partitionState.LastPublishedSequenceNumber.Value, lastSequence); + partitionState.LastPublishedSequenceNumber = lastSequence; + eventBatch.StartingPublishedSequenceNumber = firstSequence; + } + catch + { + // Clear the pending sequence numbers in the face of an exception. + + foreach (var eventData in eventSet) + { + eventData.PendingPublishSequenceNumber = null; + } + + throw; + } + finally + { + partitionState.PublishingGuard.Release(); + EventHubsEventSource.Log.IdempotentSynchronizationRelease(EventHubName, options.PartitionId); + } + } + catch (Exception ex) + { + EventHubsEventSource.Log.IdempotentPublishError(EventHubName, options.PartitionId, ex.Message); + throw; + } + finally + { + + EventHubsEventSource.Log.IdempotentPublishComplete(EventHubName, options.PartitionId); + } + } + + /// + /// Initializes state instance for a given partition. + /// + /// + /// The state of the partition to be initialized. This parameter will be mutated by this call. + /// An optional instance to signal the request to cancel the operation. + /// + /// + /// The parameter will be mutated by this call. To avoid duplicate initialization or state corruption, this + /// method should only be called while the primitive of the state instance is held. + /// + /// + private async Task InitializePartitionStateAsync(PartitionPublishingState partitionState, + CancellationToken cancellationToken = default) + { + if (partitionState.IsInitialized) + { + return; + } + + var producer = PartitionProducerPool.GetPooledProducer(partitionState.PartitionId, PartitionProducerLifespan); + var properties = await producer.TransportProducer.ReadInitializationPublishingPropertiesAsync(cancellationToken).ConfigureAwait(false); + + partitionState.ProducerGroupId = properties.ProducerGroupId; + partitionState.OwnerLevel = properties.OwnerLevel; + partitionState.LastPublishedSequenceNumber = properties.LastPublishedSequenceNumber; + + // If the state was not initialized and no exception has occurred, then the service is behaving + // unexpectedly and the client should be considered invalid. + + if (!partitionState.IsInitialized) + { + throw new EventHubsException(false, EventHubName, EventHubsException.FailureReason.InvalidClientState); + } + + EventHubsEventSource.Log.IdempotentPublishInitializeState( + EventHubName, + partitionState.PartitionId, + partitionState.ProducerGroupId.Value, + partitionState.OwnerLevel.Value, + partitionState.LastPublishedSequenceNumber.Value); + } + /// /// Creates and configures a diagnostics scope to be used for instrumenting /// events. @@ -728,6 +1077,21 @@ private void InstrumentMessages(IEnumerable events) } } + /// + /// Checks if the returned by the is still open. + /// + /// + /// The that has being checked. + /// The unique identifier of a partition associated with the Event Hub. + /// + /// true if the specified is closed; otherwise, false. + /// + private bool ShouldRecreateProducer(TransportProducer producer, + string partitionId) => !string.IsNullOrEmpty(partitionId) + && producer.IsClosed + && !IsClosed + && !Connection.IsClosed; + /// /// Ensures that no more than a single partition reference is active. /// @@ -740,23 +1104,87 @@ private static void AssertSinglePartitionReference(string partitionId, { if ((!string.IsNullOrEmpty(partitionId)) && (!string.IsNullOrEmpty(partitionKey))) { - throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, Resources.CannotSendWithPartitionIdAndPartitionKey, partitionId)); + throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, Resources.CannotSendWithPartitionIdAndPartitionKey, partitionKey, partitionId)); } } /// - /// Checks if the returned by the is still open. + /// Ensures that a partition reference is active and the request is not for publishing + /// to the Event Hubs gateway. /// /// - /// The that has being checked. - /// The unique identifier of a partition associated with the Event Hub. + /// The identifier of the partition to which the producer is bound. /// - /// true if the specified is closed; otherwise, false. + private static void AssertPartitionIsReferenced(string partitionId) + { + if (string.IsNullOrEmpty(partitionId)) + { + throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, Resources.CannotPublishToGateway, partitionId)); + } + } + + /// + /// Ensures that a batch of events has not been previously acknowledged by the Event Hubs + /// service as having been successfully published. + /// /// - private bool ShouldRecreateProducer(TransportProducer producer, - string partitionId) => !string.IsNullOrEmpty(partitionId) - && producer.IsClosed - && !IsClosed - && !Connection.IsClosed; + /// The to consider. + /// + private static void AssertIdempotentBatchNotPublished(EventDataBatch batch) + { + if ((batch.StartingPublishedSequenceNumber.HasValue) + || (batch.AsEnumerable().Any(eventData => eventData.PublishedSequenceNumber.HasValue))) + { + throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, Resources.IdempotentAlreadyPublished)); + } + } + + /// + /// Ensures that a batch of events has not been previously acknowledged by the Event Hubs + /// service as having been successfully published. + /// + /// + /// The set of to consider. + /// + private static void AssertIdempotentEventsNotPublished(IEnumerable eventSet) + { + foreach (var eventData in eventSet) + { + if (eventData.PublishedSequenceNumber.HasValue) + { + throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, Resources.IdempotentAlreadyPublished)); + } + } + } + + /// + /// Calculates the next sequence number based on the current sequence number. + /// + /// + /// The current sequence number to consider. + /// + /// The next sequence number, in proper order. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int NextSequence(int currentSequence) + { + if (unchecked(++currentSequence) < 0) + { + currentSequence = 0; + } + + return currentSequence; + } + + /// + /// Indicates whether publishing requires stateful partitions. + /// + /// + /// The set of options to consider for making the determination. + /// + /// true if publishing is stateful; otherwise, false. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool RequiresStatefulPartitions(EventHubProducerClientOptions options) => options.EnableIdempotentPartitions; } } diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/EventHubProducerClientOptions.cs b/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/EventHubProducerClientOptions.cs index 99bbf514beada..ccb8b5aa3a7bd 100644 --- a/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/EventHubProducerClientOptions.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/EventHubProducerClientOptions.cs @@ -127,5 +127,44 @@ internal EventHubProducerClientOptions Clone() return copiedOptions; } + + /// + /// Creates the set of flags that represents the features requested by these options. + /// + /// + /// The set of features that were requested for the . + /// + internal TransportProducerFeatures CreateFeatureFlags() + { + var features = TransportProducerFeatures.None; + + if (EnableIdempotentPartitions) + { + features |= TransportProducerFeatures.IdempotentPublishing; + } + + return features; + } + + /// + /// Attempts to retrieve the publishing options for a given partition, returning a + /// default in the case that no partition was specified or there were no available options + /// for that partition. + /// + /// + /// The identifier of the partition for which options are requested. + /// + /// null in the event that there was no partition specified or no options for the partition; otherwise, the publishing options. + /// + internal PartitionPublishingOptions GetPublishingOptionsOrDefaultForPartition(string partitionId) + { + if (string.IsNullOrEmpty(partitionId)) + { + return default; + } + + PartitionOptions.TryGetValue(partitionId, out var options); + return options; + } } } diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/PartitionPublishingOptions.cs b/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/PartitionPublishingOptions.cs index 91fa9d71376d0..5b02ef5891356 100644 --- a/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/PartitionPublishingOptions.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/PartitionPublishingOptions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using Azure.Messaging.EventHubs.Core; + namespace Azure.Messaging.EventHubs.Producer { /// diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/PartitionPublishingProperties.cs b/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/PartitionPublishingProperties.cs index 22b2644049bcd..31383b3ef499d 100644 --- a/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/PartitionPublishingProperties.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/PartitionPublishingProperties.cs @@ -21,20 +21,20 @@ public class PartitionPublishingProperties /// The identifier of the producer group for which this producer is publishing to the associated partition. /// /// - public long? ProducerGroupId { get; set; } + public long? ProducerGroupId { get; } /// - /// The owner level of this producer for publishing to the associated partition. + /// The owner level of the producer publishing to the associated partition. /// /// - public short? OwnerLevel { get; set; } + public short? OwnerLevel { get; } /// /// The sequence number assigned to the event that was most recently published to the associated partition /// successfully. /// /// - public int? LastPublishedSequenceNumber { get; set; } + public int? LastPublishedSequenceNumber { get; } /// /// Initializes a new instance of the class. diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/PartitionPublishingState.cs b/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/PartitionPublishingState.cs new file mode 100644 index 0000000000000..da2b110cbaee0 --- /dev/null +++ b/sdk/eventhub/Azure.Messaging.EventHubs/src/Producer/PartitionPublishingState.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading; + +namespace Azure.Messaging.EventHubs.Producer +{ + /// + /// The set of state associated with stateful publishing to a partition, such as when + /// idempotency is enabled. + /// + /// + internal class PartitionPublishingState + { + /// + /// The identifier of the partition whose state is represented. + /// + /// + /// + /// This class is not intended to be used with its + /// for synchronizing access; it does not provide any inherent thread safety guarantees. + /// + /// + public string PartitionId { get; } + + /// + /// Indicates whether or not the state has been initialized. + /// + /// + /// true if this instance is initialized; otherwise, false. + /// + public bool IsInitialized => (ProducerGroupId.HasValue && OwnerLevel.HasValue && LastPublishedSequenceNumber.HasValue); + + /// + /// The primitive for synchronizing access for publishing to the partition. + /// + /// + public SemaphoreSlim PublishingGuard { get; } = new SemaphoreSlim(1, 1); + + /// + /// The identifier of the producer group for which publishing is being performed. + /// + /// + public long? ProducerGroupId { get; set; } + + /// + /// The owner level for which publishing is being performed. + /// + /// + public short? OwnerLevel { get; set; } + + /// + /// The sequence number assigned to the event that was most recently published to the associated partition + /// successfully. + /// + /// + public int? LastPublishedSequenceNumber { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The identifier of the partition whose state is represented. + /// + public PartitionPublishingState(string partitionId) => PartitionId = partitionId; + } +} diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/tests/Amqp/AmqpClientTests.cs b/sdk/eventhub/Azure.Messaging.EventHubs/tests/Amqp/AmqpClientTests.cs old mode 100755 new mode 100644 index ae46c0719c27d..7dfb51389f778 --- a/sdk/eventhub/Azure.Messaging.EventHubs/tests/Amqp/AmqpClientTests.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/tests/Amqp/AmqpClientTests.cs @@ -717,7 +717,7 @@ public async Task CreateProducerValidatesClosed() var client = new AmqpClient("my.eventhub.com", "somePath", credential.Object, new EventHubConnectionOptions()); await client.CloseAsync(cancellationSource.Token); - Assert.That(() => client.CreateProducer(null, Mock.Of()), Throws.InstanceOf().And.Property(nameof(EventHubsException.Reason)).EqualTo(EventHubsException.FailureReason.ClientClosed)); + Assert.That(() => client.CreateProducer(null, TransportProducerFeatures.None, null, Mock.Of()), Throws.InstanceOf().And.Property(nameof(EventHubsException.Reason)).EqualTo(EventHubsException.FailureReason.ClientClosed)); } /// diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/tests/Amqp/AmqpProducerTests.cs b/sdk/eventhub/Azure.Messaging.EventHubs/tests/Amqp/AmqpProducerTests.cs old mode 100755 new mode 100644 index 0be549cf7098a..1566216b40bd3 --- a/sdk/eventhub/Azure.Messaging.EventHubs/tests/Amqp/AmqpProducerTests.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/tests/Amqp/AmqpProducerTests.cs @@ -55,7 +55,7 @@ public void ConstructorRequiresTheEventHubName(string eventHub) [Test] public void ConstructorRequiresTheConnectionScope() { - Assert.That(() => new AmqpProducer("theMostAwesomeHubEvar", "0", null, Mock.Of(), Mock.Of()), Throws.ArgumentNullException); + Assert.That(() => new AmqpProducer("theMostAwesomeHubEvar", "0", null, Mock.Of(), Mock.Of(), TransportProducerFeatures.IdempotentPublishing), Throws.ArgumentNullException); } /// @@ -65,7 +65,7 @@ public void ConstructorRequiresTheConnectionScope() [Test] public void ConstructorRequiresTheRetryPolicy() { - Assert.That(() => new AmqpProducer("theMostAwesomeHubEvar", null, Mock.Of(), Mock.Of(), null), Throws.ArgumentNullException); + Assert.That(() => new AmqpProducer("theMostAwesomeHubEvar", null, Mock.Of(), Mock.Of(), null, TransportProducerFeatures.IdempotentPublishing, new PartitionPublishingOptions()), Throws.ArgumentNullException); } /// @@ -99,6 +99,41 @@ public void CloseRespectsTheCancellationToken() Assert.That(producer.IsClosed, Is.False, "Cancellation should have interrupted closing and left the producer in an open state."); } + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public async Task CreateLinkAndEnsureProducerStateAsyncRespectsPartitionOptions() + { + var expectedOptions = new PartitionPublishingOptions { ProducerGroupId = 1, OwnerLevel = 4, StartingSequenceNumber = 88 }; + var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions { TryTimeout = TimeSpan.FromSeconds(17) }); + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.IdempotentPublishing, expectedOptions) + { + CallBase = true + }; + + producer + .Protected() + .Setup>("CreateLinkAndEnsureProducerStateAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns(Task.FromResult(new SendingAmqpLink(new AmqpLinkSettings()))); + + await producer.Object.ReadInitializationPublishingPropertiesAsync(default); + + producer + .Protected() + .Verify>("CreateLinkAndEnsureProducerStateAsync", Times.Once(), + ItExpr.IsAny(), + ItExpr.Is(options => object.ReferenceEquals(options, expectedOptions)), + ItExpr.IsAny(), + ItExpr.IsAny()); + } + /// /// Verifies functionality of the /// method. @@ -123,7 +158,7 @@ public async Task CreateBatchAsyncEnsuresNotClosed(bool createLinkBeforehand) { var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions()); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -134,6 +169,7 @@ public async Task CreateBatchAsyncEnsuresNotClosed(bool createLinkBeforehand) .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Callback(() => SetMaximumMessageSize(producer.Object, 100)) @@ -157,7 +193,7 @@ public async Task CreateBatchAsyncEnsuresNotClosed(bool createLinkBeforehand) public async Task CreateBatchAsyncEnsuresMaximumMessageSizeIsPopulated() { var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions { TryTimeout = TimeSpan.FromSeconds(17) }); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -166,6 +202,7 @@ public async Task CreateBatchAsyncEnsuresMaximumMessageSizeIsPopulated() .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Callback(() => SetMaximumMessageSize(producer.Object, 512)) @@ -188,7 +225,7 @@ public async Task CreateBatchAsyncDefaultsTheMaximumSizeWhenNotProvided() var options = new CreateBatchOptions { MaximumSizeInBytes = null }; var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions { TryTimeout = TimeSpan.FromSeconds(17) }); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -197,6 +234,7 @@ public async Task CreateBatchAsyncDefaultsTheMaximumSizeWhenNotProvided() .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Callback(() => SetMaximumMessageSize(producer.Object, expectedMaximumSize)) @@ -219,7 +257,7 @@ public async Task CreateBatchAsyncRespectsTheMaximumSizeWhenProvided() var options = new CreateBatchOptions { MaximumSizeInBytes = expectedMaximumSize }; var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions { TryTimeout = TimeSpan.FromSeconds(17) }); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -228,6 +266,7 @@ public async Task CreateBatchAsyncRespectsTheMaximumSizeWhenProvided() .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Callback(() => SetMaximumMessageSize(producer.Object, expectedMaximumSize + 27)) @@ -250,7 +289,7 @@ public void CreateBatchAsyncVerifiesTheMaximumSize() var options = new CreateBatchOptions { MaximumSizeInBytes = 1024 }; var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions { TryTimeout = TimeSpan.FromSeconds(17) }); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -259,6 +298,7 @@ public void CreateBatchAsyncVerifiesTheMaximumSize() .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Callback(() => SetMaximumMessageSize(producer.Object, linkMaximumSize)) @@ -279,7 +319,7 @@ public async Task CreateBatchAsyncBuildsAnAmqpEventBatchWithTheOptions() var options = new CreateBatchOptions { MaximumSizeInBytes = 512 }; var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions { TryTimeout = TimeSpan.FromSeconds(17) }); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -288,6 +328,7 @@ public async Task CreateBatchAsyncBuildsAnAmqpEventBatchWithTheOptions() .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Callback(() => SetMaximumMessageSize(producer.Object, options.MaximumSizeInBytes.Value + 982)) @@ -313,7 +354,7 @@ public void CreateBatchAsyncRespectsTheCancellationTokenIfSetWhenCalled(bool cre { var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions()); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -322,6 +363,7 @@ public void CreateBatchAsyncRespectsTheCancellationTokenIfSetWhenCalled(bool cre .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Callback(() => SetMaximumMessageSize(producer.Object, 100)) @@ -353,7 +395,7 @@ public void CreateBatchAsyncAppliesTheRetryPolicy(EventHubsRetryOptions retryOpt var retriableException = new EventHubsException(true, "Test"); var retryPolicy = new BasicRetryPolicy(retryOptions); - var producer = new Mock("aHub", partitionId, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", partitionId, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -362,6 +404,7 @@ public void CreateBatchAsyncAppliesTheRetryPolicy(EventHubsRetryOptions retryOpt .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Throws(retriableException); @@ -373,6 +416,7 @@ public void CreateBatchAsyncAppliesTheRetryPolicy(EventHubsRetryOptions retryOpt .Protected() .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Exactly(1 + retryOptions.MaximumRetries), ItExpr.Is(value => value == partitionId), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } @@ -390,7 +434,7 @@ public void CreateBatchAsyncConsidersOperationCanceledExceptionAsRetriable(Event var retriableException = new OperationCanceledException(); var retryPolicy = new BasicRetryPolicy(retryOptions); - var producer = new Mock("aHub", partitionId, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", partitionId, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -399,6 +443,7 @@ public void CreateBatchAsyncConsidersOperationCanceledExceptionAsRetriable(Event .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Throws(retriableException); @@ -410,6 +455,7 @@ public void CreateBatchAsyncConsidersOperationCanceledExceptionAsRetriable(Event .Protected() .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Exactly(1 + retryOptions.MaximumRetries), ItExpr.Is(value => value == partitionId), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } @@ -427,7 +473,7 @@ public void CreateBatchAsyncAppliesTheRetryPolicyForAmqpErrors(EventHubsRetryOpt var retriableException = AmqpError.CreateExceptionForError(new Error { Condition = AmqpError.ServerBusyError }, "dummy"); var retryPolicy = new BasicRetryPolicy(retryOptions); - var producer = new Mock("aHub", partitionId, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", partitionId, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -436,6 +482,7 @@ public void CreateBatchAsyncAppliesTheRetryPolicyForAmqpErrors(EventHubsRetryOpt .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Throws(retriableException); @@ -447,6 +494,7 @@ public void CreateBatchAsyncAppliesTheRetryPolicyForAmqpErrors(EventHubsRetryOpt .Protected() .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Exactly(1 + retryOptions.MaximumRetries), ItExpr.Is(value => value == partitionId), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } @@ -462,7 +510,7 @@ public void CreateBatchAsyncDetectsAnEmbeddedErrorForOperationCanceled() var embeddedException = new OperationCanceledException("", new ArgumentNullException()); var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions()); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -471,6 +519,7 @@ public void CreateBatchAsyncDetectsAnEmbeddedErrorForOperationCanceled() .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Throws(embeddedException); @@ -482,6 +531,7 @@ public void CreateBatchAsyncDetectsAnEmbeddedErrorForOperationCanceled() .Protected() .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Once(), ItExpr.Is(value => value == null), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } @@ -497,7 +547,7 @@ public void CreateBatchAsyncDetectsAnEmbeddedAmqpErrorForOperationCanceled() var embeddedException = new OperationCanceledException("", new AmqpException(new Error { Condition = AmqpError.ArgumentError })); var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions()); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -506,6 +556,7 @@ public void CreateBatchAsyncDetectsAnEmbeddedAmqpErrorForOperationCanceled() .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Throws(embeddedException); @@ -517,6 +568,385 @@ public void CreateBatchAsyncDetectsAnEmbeddedAmqpErrorForOperationCanceled() .Protected() .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Once(), ItExpr.Is(value => value == null), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + [TestCase(true)] + [TestCase(false)] + public async Task ReadInitializationPublishingPropertiesAsyncEnsuresNotClosed(bool createLinkBeforehand) + { + var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions()); + + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) + { + CallBase = true + }; + + if (createLinkBeforehand) + { + producer + .Protected() + .Setup>("CreateLinkAndEnsureProducerStateAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback(() => + { + SetMaximumMessageSize(producer.Object, 100); + SetInitializedPartitionProperties(producer.Object, new PartitionPublishingProperties(false, null, null, null)); + }) + .Returns(Task.FromResult(new SendingAmqpLink(new AmqpLinkSettings()))) + .Verifiable(); + + Assert.That(async () => await producer.Object.CreateBatchAsync(new CreateBatchOptions(), CancellationToken.None), Throws.Nothing); + } + + await producer.Object.CloseAsync(CancellationToken.None); + Assert.That(async () => await producer.Object.ReadInitializationPublishingPropertiesAsync(CancellationToken.None), Throws.InstanceOf().And.Property(nameof(EventHubsException.Reason)).EqualTo(EventHubsException.FailureReason.ClientClosed)); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public async Task ReadInitializationPublishingPropertiesAsyncEnsuresPropertiesArePopulated() + { + var expectedProperties = new PartitionPublishingProperties(false, null, null, null); + var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions { TryTimeout = TimeSpan.FromSeconds(17) }); + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) + { + CallBase = true + }; + + producer + .Protected() + .Setup>("CreateLinkAndEnsureProducerStateAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback(() => SetInitializedPartitionProperties(producer.Object, expectedProperties)) + .Returns(Task.FromResult(new SendingAmqpLink(new AmqpLinkSettings()))) + .Verifiable(); + + await producer.Object.ReadInitializationPublishingPropertiesAsync(default); + producer.VerifyAll(); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public async Task ReadInitializationPublishingPropertiesAsyncReturnsPropertiesOnInitialization() + { + var expectedProperties = new PartitionPublishingProperties(true, 3, 17, 32768); + var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions { TryTimeout = TimeSpan.FromSeconds(17) }); + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) + { + CallBase = true + }; + + producer + .Protected() + .Setup>("CreateLinkAndEnsureProducerStateAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback(() => SetInitializedPartitionProperties(producer.Object, expectedProperties)) + .Returns(Task.FromResult(new SendingAmqpLink(new AmqpLinkSettings()))) + .Verifiable(); + + var actualProperties = await producer.Object.ReadInitializationPublishingPropertiesAsync(default); + Assert.That(actualProperties, Is.SameAs(expectedProperties), "The correct instance of the properties should have been returned."); + + producer.VerifyAll(); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public async Task ReadInitializationPublishingPropertiesAsyncReturnsCachedProperties() + { + var expectedProperties = new PartitionPublishingProperties(true, 3, 17, 32768); + var callbackProperties = expectedProperties; + var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions { TryTimeout = TimeSpan.FromSeconds(17) }); + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) + { + CallBase = true + }; + + producer + .Protected() + .Setup>("CreateLinkAndEnsureProducerStateAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback(() => + { + SetInitializedPartitionProperties(producer.Object, callbackProperties); + callbackProperties = new PartitionPublishingProperties(false, null, null, null); + }) + .Returns(Task.FromResult(new SendingAmqpLink(new AmqpLinkSettings()))); + + // The first invocation should trigger initialization and return the new value. + + var actualProperties = await producer.Object.ReadInitializationPublishingPropertiesAsync(default); + Assert.That(actualProperties, Is.SameAs(expectedProperties), "The correct instance of the properties should have been returned for the first call."); + + // The subsequent invocations should not trigger link creation and should return the cached value. + + actualProperties = await producer.Object.ReadInitializationPublishingPropertiesAsync(default); + Assert.That(actualProperties, Is.SameAs(expectedProperties), "The correct instance of the properties should have been returned for the second call."); + + actualProperties = await producer.Object.ReadInitializationPublishingPropertiesAsync(default); + Assert.That(actualProperties, Is.SameAs(expectedProperties), "The correct instance of the properties should have been returned for the third call."); + + producer + .Protected() + .Verify>("CreateLinkAndEnsureProducerStateAsync", Times.Once(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public void ReadInitializationPublishingPropertiesAsyncRespectsTheCancellationTokenIfSetWhenCalled() + { + var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions()); + + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) + { + CallBase = true + }; + + producer + .Protected() + .Setup>("CreateLinkAndEnsureProducerStateAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback(() => SetInitializedPartitionProperties(producer.Object, new PartitionPublishingProperties(false, null, null, null))) + .Returns(Task.FromResult(new SendingAmqpLink(new AmqpLinkSettings()))) + .Verifiable(); + + using CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + Assert.That(async () => await producer.Object.ReadInitializationPublishingPropertiesAsync(cancellationTokenSource.Token), Throws.InstanceOf()); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + [TestCaseSource(nameof(RetryOptionTestCases))] + public void ReadInitializationPublishingPropertiesAsyncAppliesTheRetryPolicy(EventHubsRetryOptions retryOptions) + { + var partitionId = "testMe"; + var retriableException = new EventHubsException(true, "Test"); + var retryPolicy = new BasicRetryPolicy(retryOptions); + + var producer = new Mock("aHub", partitionId, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) + { + CallBase = true + }; + + producer + .Protected() + .Setup>("CreateLinkAndEnsureProducerStateAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Throws(retriableException); + + using CancellationTokenSource cancellationSource = new CancellationTokenSource(); + Assert.That(async () => await producer.Object.ReadInitializationPublishingPropertiesAsync(cancellationSource.Token), Throws.InstanceOf(retriableException.GetType())); + + producer + .Protected() + .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Exactly(1 + retryOptions.MaximumRetries), + ItExpr.Is(value => value == partitionId), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + [TestCaseSource(nameof(RetryOptionTestCases))] + public void ReadInitializationPublishingPropertiesAsyncConsidersOperationCanceledExceptionAsRetriable(EventHubsRetryOptions retryOptions) + { + var partitionId = "testMe"; + var retriableException = new OperationCanceledException(); + var retryPolicy = new BasicRetryPolicy(retryOptions); + + var producer = new Mock("aHub", partitionId, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) + { + CallBase = true + }; + + producer + .Protected() + .Setup>("CreateLinkAndEnsureProducerStateAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Throws(retriableException); + + using CancellationTokenSource cancellationSource = new CancellationTokenSource(); + Assert.That(async () => await producer.Object.ReadInitializationPublishingPropertiesAsync(cancellationSource.Token), Throws.InstanceOf(retriableException.GetType())); + + producer + .Protected() + .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Exactly(1 + retryOptions.MaximumRetries), + ItExpr.Is(value => value == partitionId), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + [TestCaseSource(nameof(RetryOptionTestCases))] + public void ReadInitializationPublishingPropertiesAsyncAppliesTheRetryPolicyForAmqpErrors(EventHubsRetryOptions retryOptions) + { + var partitionId = "testMe"; + var retriableException = AmqpError.CreateExceptionForError(new Error { Condition = AmqpError.ServerBusyError }, "dummy"); + var retryPolicy = new BasicRetryPolicy(retryOptions); + + var producer = new Mock("aHub", partitionId, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) + { + CallBase = true + }; + + producer + .Protected() + .Setup>("CreateLinkAndEnsureProducerStateAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Throws(retriableException); + + using CancellationTokenSource cancellationSource = new CancellationTokenSource(); + Assert.That(async () => await producer.Object.ReadInitializationPublishingPropertiesAsync(cancellationSource.Token), Throws.InstanceOf(retriableException.GetType())); + + producer + .Protected() + .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Exactly(1 + retryOptions.MaximumRetries), + ItExpr.Is(value => value == partitionId), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public void ReadInitializationPublishingPropertiesAsyncDetectsAnEmbeddedErrorForOperationCanceled() + { + var embeddedException = new OperationCanceledException("", new ArgumentNullException()); + var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions()); + + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) + { + CallBase = true + }; + + producer + .Protected() + .Setup>("CreateLinkAndEnsureProducerStateAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Throws(embeddedException); + + using CancellationTokenSource cancellationSource = new CancellationTokenSource(); + Assert.That(async () => await producer.Object.ReadInitializationPublishingPropertiesAsync(cancellationSource.Token), Throws.InstanceOf()); + + producer + .Protected() + .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Once(), + ItExpr.Is(value => value == null), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public void ReadInitializationPublishingPropertiesAsyncDetectsAnEmbeddedAmqpErrorForOperationCanceled() + { + var embeddedException = new OperationCanceledException("", new AmqpException(new Error { Condition = AmqpError.ArgumentError })); + var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions()); + + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) + { + CallBase = true + }; + + producer + .Protected() + .Setup>("CreateLinkAndEnsureProducerStateAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Throws(embeddedException); + + using CancellationTokenSource cancellationSource = new CancellationTokenSource(); + Assert.That(async () => await producer.Object.ReadInitializationPublishingPropertiesAsync(cancellationSource.Token), Throws.InstanceOf()); + + producer + .Protected() + .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Once(), + ItExpr.Is(value => value == null), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } @@ -529,7 +959,7 @@ public void CreateBatchAsyncDetectsAnEmbeddedAmqpErrorForOperationCanceled() [Test] public void SendEnumerableValidatesTheEvents() { - var producer = new AmqpProducer("aHub", null, Mock.Of(), new AmqpMessageConverter(), Mock.Of()); + var producer = new AmqpProducer("aHub", null, Mock.Of(), new AmqpMessageConverter(), Mock.Of(), TransportProducerFeatures.None); Assert.That(async () => await producer.SendAsync(null, new SendEventOptions(), CancellationToken.None), Throws.ArgumentNullException); } @@ -541,7 +971,7 @@ public void SendEnumerableValidatesTheEvents() [Test] public async Task SendEnumerableEnsuresNotClosed() { - var producer = new AmqpProducer("aHub", null, Mock.Of(), new AmqpMessageConverter(), Mock.Of()); + var producer = new AmqpProducer("aHub", null, Mock.Of(), new AmqpMessageConverter(), Mock.Of(), TransportProducerFeatures.IdempotentPublishing); await producer.CloseAsync(CancellationToken.None); Assert.That(async () => await producer.SendAsync(Enumerable.Empty(), new SendEventOptions(), CancellationToken.None), Throws.InstanceOf().And.Property(nameof(EventHubsException.Reason)).EqualTo(EventHubsException.FailureReason.ClientClosed)); @@ -559,7 +989,7 @@ public async Task SendEnumerableUsesThePartitionKey() var options = new SendEventOptions { PartitionKey = expectedPartitionKey }; var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions { TryTimeout = TimeSpan.FromSeconds(17) }); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -591,7 +1021,7 @@ public async Task SendEnumerableCreatesTheAmqpMessageFromTheEnumerable(string pa var messageFactory = default(Func); var events = new[] { new EventData(new byte[] { 0x15 }) }; - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), Mock.Of()) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), Mock.Of(), TransportProducerFeatures.None, null) { CallBase = true }; @@ -642,7 +1072,7 @@ public void SendEnumerableAppliesTheRetryPolicy(EventHubsRetryOptions retryOptio var retriableException = new EventHubsException(true, "Test"); var retryPolicy = new BasicRetryPolicy(retryOptions); - var producer = new Mock("aHub", partitionId, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", partitionId, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -651,6 +1081,7 @@ public void SendEnumerableAppliesTheRetryPolicy(EventHubsRetryOptions retryOptio .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Throws(retriableException); @@ -662,6 +1093,7 @@ public void SendEnumerableAppliesTheRetryPolicy(EventHubsRetryOptions retryOptio .Protected() .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Exactly(1 + retryOptions.MaximumRetries), ItExpr.Is(value => value == partitionId), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } @@ -679,7 +1111,7 @@ public void SendEnumerableConsidersOperationCanceledExceptionAsRetriable(EventHu var retriableException = new OperationCanceledException(); var retryPolicy = new BasicRetryPolicy(retryOptions); - var producer = new Mock("aHub", partitionId, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", partitionId, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -688,6 +1120,7 @@ public void SendEnumerableConsidersOperationCanceledExceptionAsRetriable(EventHu .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Throws(retriableException); @@ -699,6 +1132,7 @@ public void SendEnumerableConsidersOperationCanceledExceptionAsRetriable(EventHu .Protected() .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Exactly(1 + retryOptions.MaximumRetries), ItExpr.Is(value => value == partitionId), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } @@ -716,7 +1150,7 @@ public void SendEnumerableAppliesTheRetryPolicyForAmqpErrors(EventHubsRetryOptio var retriableException = AmqpError.CreateExceptionForError(new Error { Condition = AmqpError.ServerBusyError }, "dummy"); var retryPolicy = new BasicRetryPolicy(retryOptions); - var producer = new Mock("aHub", partitionId, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", partitionId, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -725,6 +1159,7 @@ public void SendEnumerableAppliesTheRetryPolicyForAmqpErrors(EventHubsRetryOptio .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Throws(retriableException); @@ -736,6 +1171,7 @@ public void SendEnumerableAppliesTheRetryPolicyForAmqpErrors(EventHubsRetryOptio .Protected() .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Exactly(1 + retryOptions.MaximumRetries), ItExpr.Is(value => value == partitionId), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } @@ -751,7 +1187,7 @@ public void SendEnumerableDetectsAnEmbeddedErrorForOperationCanceled() var embeddedException = new OperationCanceledException("", new ArgumentNullException()); var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions()); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -760,6 +1196,7 @@ public void SendEnumerableDetectsAnEmbeddedErrorForOperationCanceled() .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Throws(embeddedException); @@ -771,6 +1208,7 @@ public void SendEnumerableDetectsAnEmbeddedErrorForOperationCanceled() .Protected() .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Once(), ItExpr.Is(value => value == null), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } @@ -786,7 +1224,7 @@ public void SendEnumerableDetectsAnEmbeddedAmqpErrorForOperationCanceled() var embeddedException = new OperationCanceledException("", new AmqpException(new Error { Condition = AmqpError.ArgumentError })); var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions()); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -795,6 +1233,7 @@ public void SendEnumerableDetectsAnEmbeddedAmqpErrorForOperationCanceled() .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Throws(embeddedException); @@ -806,6 +1245,7 @@ public void SendEnumerableDetectsAnEmbeddedAmqpErrorForOperationCanceled() .Protected() .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Once(), ItExpr.Is(value => value == null), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } @@ -834,7 +1274,7 @@ public async Task SendBatchEnsuresNotClosed() var options = new CreateBatchOptions { MaximumSizeInBytes = null }; var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions { TryTimeout = TimeSpan.FromSeconds(17) }); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -843,6 +1283,7 @@ public async Task SendBatchEnsuresNotClosed() .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Callback(() => SetMaximumMessageSize(producer.Object, expectedMaximumSize)) @@ -867,7 +1308,7 @@ public async Task SendBatchUsesThePartitionKey() var options = new CreateBatchOptions { PartitionKey = expectedPartitionKey }; var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions { TryTimeout = TimeSpan.FromSeconds(17) }); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -876,6 +1317,7 @@ public async Task SendBatchUsesThePartitionKey() .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Callback(() => SetMaximumMessageSize(producer.Object, expectedMaximumSize)) @@ -912,7 +1354,7 @@ public async Task SendBatchCreatesTheAmqpMessageFromTheBatch(string partitonKey) var options = new CreateBatchOptions { PartitionKey = partitonKey }; var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions { TryTimeout = TimeSpan.FromSeconds(17) }); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -921,6 +1363,7 @@ public async Task SendBatchCreatesTheAmqpMessageFromTheBatch(string partitonKey) .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Callback(() => SetMaximumMessageSize(producer.Object, expectedMaximumSize)) @@ -961,7 +1404,7 @@ public async Task SendBatchDoesNotDisposeTheEventDataBatch() var options = new CreateBatchOptions { MaximumSizeInBytes = null }; var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions { TryTimeout = TimeSpan.FromSeconds(17) }); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -970,6 +1413,7 @@ public async Task SendBatchDoesNotDisposeTheEventDataBatch() .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Callback(() => SetMaximumMessageSize(producer.Object, expectedMaximumSize)) @@ -1006,7 +1450,7 @@ public async Task SendBatchRespectsTheCancellationTokenIfSetWhenCalled() var options = new CreateBatchOptions(); var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions { TryTimeout = TimeSpan.FromSeconds(17) }); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -1015,6 +1459,7 @@ public async Task SendBatchRespectsTheCancellationTokenIfSetWhenCalled() .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Callback(() => SetMaximumMessageSize(producer.Object, expectedMaximumSize)) @@ -1042,7 +1487,7 @@ public void SendBatchAppliesTheRetryPolicy(EventHubsRetryOptions retryOptions) var retryPolicy = new BasicRetryPolicy(retryOptions); var batch = new EventDataBatch(Mock.Of(), "ns", "eh", options); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -1051,6 +1496,7 @@ public void SendBatchAppliesTheRetryPolicy(EventHubsRetryOptions retryOptions) .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Throws(retriableException); @@ -1062,6 +1508,7 @@ public void SendBatchAppliesTheRetryPolicy(EventHubsRetryOptions retryOptions) .Protected() .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Exactly(1 + retryOptions.MaximumRetries), ItExpr.Is(value => value == null), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } @@ -1081,7 +1528,7 @@ public void SendBatchConsidersOperationCanceledExceptionAsRetriable(EventHubsRet var retryPolicy = new BasicRetryPolicy(retryOptions); var batch = new EventDataBatch(Mock.Of(), "ns", "eh", options); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -1090,6 +1537,7 @@ public void SendBatchConsidersOperationCanceledExceptionAsRetriable(EventHubsRet .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Throws(retriableException); @@ -1101,6 +1549,7 @@ public void SendBatchConsidersOperationCanceledExceptionAsRetriable(EventHubsRet .Protected() .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Exactly(1 + retryOptions.MaximumRetries), ItExpr.Is(value => value == null), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } @@ -1120,7 +1569,7 @@ public void SendBatchAppliesTheRetryPolicyForAmqpErrors(EventHubsRetryOptions re var retryPolicy = new BasicRetryPolicy(retryOptions); var batch = new EventDataBatch(Mock.Of(), "ns", "eh", options); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -1129,6 +1578,7 @@ public void SendBatchAppliesTheRetryPolicyForAmqpErrors(EventHubsRetryOptions re .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Throws(retriableException); @@ -1140,6 +1590,7 @@ public void SendBatchAppliesTheRetryPolicyForAmqpErrors(EventHubsRetryOptions re .Protected() .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Exactly(1 + retryOptions.MaximumRetries), ItExpr.Is(value => value == null), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } @@ -1158,7 +1609,7 @@ public void SendBatchDetectsAnEmbeddedErrorForOperationCanceled() var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions()); var batch = new EventDataBatch(Mock.Of(), "ns", "eh", options); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -1167,6 +1618,7 @@ public void SendBatchDetectsAnEmbeddedErrorForOperationCanceled() .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Throws(embeddedException); @@ -1178,6 +1630,7 @@ public void SendBatchDetectsAnEmbeddedErrorForOperationCanceled() .Protected() .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Once(), ItExpr.Is(value => value == null), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } @@ -1196,7 +1649,7 @@ public void SendBatchDetectsAnEmbeddedAmqpErrorForOperationCanceled() var retryPolicy = new BasicRetryPolicy(new EventHubsRetryOptions()); var batch = new EventDataBatch(Mock.Of(), "ns", "eh", options); - var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy) + var producer = new Mock("aHub", null, Mock.Of(), new AmqpMessageConverter(), retryPolicy, TransportProducerFeatures.None, null) { CallBase = true }; @@ -1205,6 +1658,7 @@ public void SendBatchDetectsAnEmbeddedAmqpErrorForOperationCanceled() .Protected() .Setup>("CreateLinkAndEnsureProducerStateAsync", ItExpr.IsAny(), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .Throws(embeddedException); @@ -1216,6 +1670,7 @@ public void SendBatchDetectsAnEmbeddedAmqpErrorForOperationCanceled() .Protected() .Verify("CreateLinkAndEnsureProducerStateAsync", Times.Once(), ItExpr.Is(value => value == null), + ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } @@ -1246,5 +1701,17 @@ private static void SetMaximumMessageSize(AmqpProducer target, long value) .GetProperty("MaximumMessageSize", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.SetProperty) .SetValue(target, value); } + + /// + /// Sets the initialized partition properties for the given producer, using its + /// private accessor. + /// + /// + private static void SetInitializedPartitionProperties(AmqpProducer target, PartitionPublishingProperties value) + { + typeof(AmqpProducer) + .GetProperty("InitializedPartitionProperties", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.SetProperty) + .SetValue(target, value); + } } } diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/tests/Connection/EventHubConnectionTests.cs b/sdk/eventhub/Azure.Messaging.EventHubs/tests/Connection/EventHubConnectionTests.cs index 7d4dcbbb1741b..5cab2995be99c 100644 --- a/sdk/eventhub/Azure.Messaging.EventHubs/tests/Connection/EventHubConnectionTests.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/tests/Connection/EventHubConnectionTests.cs @@ -572,13 +572,18 @@ public void CreateProducerInvokesTheTransportClient() { var transportClient = new ObservableTransportClientMock(); var client = new InjectableTransportClientMock(transportClient, "Endpoint=sb://not-real.servicebus.windows.net/;SharedAccessKeyName=DummyKey;SharedAccessKey=[not_real];EntityPath=fake"); - var options = new EventHubProducerClientOptions { RetryOptions = new EventHubsRetryOptions { MaximumRetries = 6, TryTimeout = TimeSpan.FromMinutes(4) } }; + var options = new EventHubProducerClientOptions { EnableIdempotentPartitions = true, RetryOptions = new EventHubsRetryOptions { MaximumRetries = 6, TryTimeout = TimeSpan.FromMinutes(4) } }; + var expectedFeatures = options.CreateFeatureFlags(); + var expectedPartitionOptions = new PartitionPublishingOptions { ProducerGroupId = 123 }; var expectedRetry = options.RetryOptions.ToRetryPolicy(); - client.CreateTransportProducer(null, expectedRetry); + client.CreateTransportProducer(null, expectedFeatures, expectedPartitionOptions, expectedRetry); Assert.That(transportClient.CreateProducerCalledWith, Is.Not.Null, "The producer options should have been set."); Assert.That(transportClient.CreateProducerCalledWith.PartitionId, Is.Null, "There should have been no partition specified."); + Assert.That(transportClient.CreateProducerCalledWith.Features, Is.EqualTo(expectedFeatures), "The features should match."); + Assert.That(transportClient.CreateProducerCalledWith.PartitionOptions, Is.Not.Null, "The partition options should have been specified."); + Assert.That(transportClient.CreateProducerCalledWith.PartitionOptions, Is.SameAs(expectedPartitionOptions), "The partition options should match."); Assert.That(transportClient.CreateProducerCalledWith.RetryPolicy, Is.Not.Null, "The retry policy should have been specified."); Assert.That(transportClient.CreateProducerCalledWith.RetryPolicy, Is.SameAs(expectedRetry), "The retry policies should match."); } @@ -802,7 +807,7 @@ private void SetTransportClient(TransportClient transportClient) => private class ObservableTransportClientMock : TransportClient { public (string ConsumerGroup, string Partition, EventPosition Position, EventHubsRetryPolicy RetryPolicy, bool TrackLastEnqueued, long? OwnerLevel, uint? Prefetch) CreateConsumerCalledWith; - public (string PartitionId, EventHubsRetryPolicy RetryPolicy) CreateProducerCalledWith; + public (string PartitionId, TransportProducerFeatures Features, PartitionPublishingOptions PartitionOptions, EventHubsRetryPolicy RetryPolicy) CreateProducerCalledWith; public string GetPartitionPropertiesCalledForId; public bool WasGetPropertiesCalled; public bool WasCloseCalled; @@ -823,9 +828,11 @@ public override Task GetPartitionPropertiesAsync(string par } public override TransportProducer CreateProducer(string partitionId, + TransportProducerFeatures requestedFeatures, + PartitionPublishingOptions partitionOptions, EventHubsRetryPolicy retryPolicy) { - CreateProducerCalledWith = (partitionId, retryPolicy); + CreateProducerCalledWith = (partitionId, requestedFeatures, partitionOptions, retryPolicy); return default; } diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/tests/Core/EventDataTests.cs b/sdk/eventhub/Azure.Messaging.EventHubs/tests/Core/EventDataTests.cs index 0174e1a2356c6..5bd82fe1e2023 100644 --- a/sdk/eventhub/Azure.Messaging.EventHubs/tests/Core/EventDataTests.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/tests/Core/EventDataTests.cs @@ -55,38 +55,26 @@ public void BodyAsStreamAllowsAnEmptyBody() } /// - /// Verifies functionality of the + /// Verifies functionality of the /// property. /// /// [Test] - [TestCase(-1)] - [TestCase(-10)] - [TestCase(-100)] - public void PublishedSequenceNumberValidatesOnSet(int value) + public void CommitPublishingSequenceNumberTransitionsState() { - var eventData = new EventData(Array.Empty()); - Assert.That(() => eventData.PublishedSequenceNumber = value, Throws.InstanceOf(), "Negative values should not be allowed."); - } + var expectedSequence = 8675309; - /// - /// Verifies functionality of the - /// property. - /// - /// - [Test] - [TestCase(null)] - [TestCase(0)] - [TestCase(1)] - [TestCase(10)] - [TestCase(100)] - [TestCase(32768)] - public void PublishedSequenceNumberAllowsValidValues(int? value) - { - var eventData = new EventData(Array.Empty()); - eventData.PublishedSequenceNumber = value; + var eventData = new EventData(Array.Empty()) + { + PendingPublishSequenceNumber = expectedSequence + }; + + Assert.That(eventData.PendingPublishSequenceNumber, Is.EqualTo(expectedSequence), "The pending sequence number should have been set."); + + eventData.CommitPublishingSequenceNumber(); - Assert.That(eventData.PublishedSequenceNumber, Is.EqualTo(value), "The value should have been accepted."); + Assert.That(eventData.PublishedSequenceNumber, Is.EqualTo(expectedSequence), "The published sequence number should have been set."); + Assert.That(eventData.PendingPublishSequenceNumber, Is.EqualTo(default(int?)), "The pending sequence number should have been cleared."); } /// @@ -108,7 +96,9 @@ public void CloneProducesACopy() 111222, 999888, DateTimeOffset.Parse("2012-03-04T09:00:00Z"), - DateTimeOffset.Parse("2003-09-27T15:00:00Z")); + DateTimeOffset.Parse("2003-09-27T15:00:00Z"), + 787878, + 987654); var clone = sourceEvent.Clone(); Assert.That(clone, Is.Not.Null, "The clone should not be null."); @@ -136,7 +126,9 @@ public void CloneIsolatesPropertyChanges() 111222, 999888, DateTimeOffset.Parse("2012-03-04T09:00:00Z"), - DateTimeOffset.Parse("2003-09-27T15:00:00Z")); + DateTimeOffset.Parse("2003-09-27T15:00:00Z"), + 787878, + 987654); var clone = sourceEvent.Clone(); Assert.That(clone, Is.Not.Null, "The clone should not be null."); diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/tests/Diagnostics/DiagnosticsTests.cs b/sdk/eventhub/Azure.Messaging.EventHubs/tests/Diagnostics/DiagnosticsTests.cs old mode 100755 new mode 100644 index 1726cde3cd1d7..533c5ea92fbf0 --- a/sdk/eventhub/Azure.Messaging.EventHubs/tests/Diagnostics/DiagnosticsTests.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/tests/Diagnostics/DiagnosticsTests.cs @@ -98,7 +98,6 @@ public async Task EventHubProducerCreatesDiagnosticScopeOnBatchSend() var fakeConnection = new MockConnection(endpoint, eventHubName); var batchTransportMock = new Mock(); - batchTransportMock .Setup(m => m.TryAdd(It.IsAny())) .Callback(addedEvent => batchEvent = addedEvent) @@ -108,6 +107,10 @@ public async Task EventHubProducerCreatesDiagnosticScopeOnBatchSend() return eventCount <= 1; }); + batchTransportMock + .Setup(m => m.Count) + .Returns(1); + var transportMock = new Mock(); transportMock @@ -122,7 +125,7 @@ public async Task EventHubProducerCreatesDiagnosticScopeOnBatchSend() var eventData = new EventData(ReadOnlyMemory.Empty); var batch = await producer.CreateBatchAsync(); - Assert.True(batch.TryAdd(eventData)); + Assert.That(batch.TryAdd(eventData), Is.True); await producer.SendAsync(batch); activity.Stop(); @@ -307,6 +310,10 @@ public async Task EventHubProducerLinksSendScopeToMessageScopesOnBatchSend() return hasSpace; }); + batchTransportMock + .Setup(m => m.Count) + .Returns(2); + transportMock .Setup(m => m.CreateBatchAsync(It.IsAny(), It.IsAny())) .Returns(new ValueTask(Task.FromResult(batchTransportMock.Object))); diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/tests/Producer/EventDataBatchTests.cs b/sdk/eventhub/Azure.Messaging.EventHubs/tests/Producer/EventDataBatchTests.cs index bd5038274097f..a52516cf577dd 100644 --- a/sdk/eventhub/Azure.Messaging.EventHubs/tests/Producer/EventDataBatchTests.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/tests/Producer/EventDataBatchTests.cs @@ -99,44 +99,6 @@ public void PropertyAccessIsDelegatedToTheTransportClient() Assert.That(batch.StartingPublishedSequenceNumber, Is.EqualTo(mockBatch.StartingPublishedSequenceNumber), "The starting published sequence number should have been delegated."); } - /// - /// Verifies property accessors for the - /// class. - /// - /// - [Test] - [TestCase(-1)] - [TestCase(-10)] - [TestCase(-100)] - public void StartingPublishedSequenceNumberValidatesOnSet(int value) - { - var mockBatch = new MockTransportBatch(); - var batch = new EventDataBatch(mockBatch, "ns", "eh", new SendEventOptions()); - - Assert.That(() => batch.StartingPublishedSequenceNumber = value, Throws.InstanceOf(), "Negative values should not be allowed."); - } - - // - /// Verifies property accessors for the - /// class. - /// - /// - [Test] - [TestCase(null)] - [TestCase(0)] - [TestCase(1)] - [TestCase(10)] - [TestCase(100)] - [TestCase(32768)] - public void StartingPublishedSequenceNumberValidatesAllowsValidValues(int? value) - { - var mockBatch = new MockTransportBatch(); - var batch = new EventDataBatch(mockBatch, "ns", "eh", new SendEventOptions()); - - batch.StartingPublishedSequenceNumber = value; - Assert.That(batch.StartingPublishedSequenceNumber, Is.EqualTo(value), "The value should have been accepted."); - } - /// /// Verifies property accessors for the /// method. diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/tests/Producer/EventHubProducerClientOptionsTests.cs b/sdk/eventhub/Azure.Messaging.EventHubs/tests/Producer/EventHubProducerClientOptionsTests.cs index 048b4bc601b6f..3310c39bad7b9 100644 --- a/sdk/eventhub/Azure.Messaging.EventHubs/tests/Producer/EventHubProducerClientOptionsTests.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/tests/Producer/EventHubProducerClientOptionsTests.cs @@ -82,5 +82,76 @@ public void RetryOptionsAreValidated() { Assert.That(() => new EventHubProducerClientOptions { RetryOptions = null }, Throws.ArgumentNullException); } + + /// + /// Verifies functionality of the + /// property. + /// + /// + [Test] + public void CreateFeatureFlagsDetectsWhenNoFeaturesWereRequested() + { + var options = new EventHubProducerClientOptions { EnableIdempotentPartitions = false }; + Assert.That(options.CreateFeatureFlags(), Is.EqualTo(TransportProducerFeatures.None)); + } + + /// + /// Verifies functionality of the + /// property. + /// + /// + [Test] + public void CreateFeatureFlagsDetectsIdempotentPublishing() + { + var options = new EventHubProducerClientOptions { EnableIdempotentPartitions = true }; + Assert.That(options.CreateFeatureFlags(), Is.EqualTo(TransportProducerFeatures.IdempotentPublishing)); + } + + /// + /// Verifies functionality of the + /// property. + /// + /// + [Test] + [TestCase(null)] + [TestCase("")] + public void GetPublishingOptionsOrDefaultForPartitionDefaultsWhenNoPartitionIsSpecified(string partitionId) + { + var options = new EventHubProducerClientOptions(); + options.PartitionOptions.Add("1", new PartitionPublishingOptions{ ProducerGroupId = 1 }); + + Assert.That(options.GetPublishingOptionsOrDefaultForPartition(partitionId), Is.EqualTo(default(PartitionPublishingOptions))); + } + + /// + /// Verifies functionality of the + /// property. + /// + /// + [Test] + public void GetPublishingOptionsOrDefaultForPartitionDefaultsWhenNoPartitionIsFound() + { + var options = new EventHubProducerClientOptions(); + options.PartitionOptions.Add("1", new PartitionPublishingOptions{ ProducerGroupId = 1 }); + + Assert.That(options.GetPublishingOptionsOrDefaultForPartition("0"), Is.EqualTo(default(PartitionPublishingOptions))); + } + + /// + /// Verifies functionality of the + /// property. + /// + /// + [Test] + public void GetPublishingOptionsOrDefaultForPartitionReturnsTheOptionsWhenThePartitionIsFound() + { + var partitionId = "12"; + var expectedPartitionOptions = new PartitionPublishingOptions{ ProducerGroupId = 1 }; + + var options = new EventHubProducerClientOptions(); + options.PartitionOptions.Add(partitionId, expectedPartitionOptions); + + Assert.That(options.GetPublishingOptionsOrDefaultForPartition(partitionId), Is.SameAs(expectedPartitionOptions)); + } } } diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/tests/Producer/EventHubProducerClientTests.cs b/sdk/eventhub/Azure.Messaging.EventHubs/tests/Producer/EventHubProducerClientTests.cs index 2c60e66549111..101335151e29a 100644 --- a/sdk/eventhub/Azure.Messaging.EventHubs/tests/Producer/EventHubProducerClientTests.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/tests/Producer/EventHubProducerClientTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics.Tracing; using System.Linq; using System.Reflection; using System.Threading; @@ -12,6 +13,7 @@ using Azure.Messaging.EventHubs.Authorization; using Azure.Messaging.EventHubs.Core; using Azure.Messaging.EventHubs.Producer; +using Microsoft.Azure.Amqp; using Moq; using NUnit.Framework; @@ -487,16 +489,663 @@ public void SendForASpecificPartitionDoesNotAllowAPartitionHashKeyWithABatch() [Test] public async Task SendWithoutOptionsInvokesTheTransportProducer() { - var events = new EventData[0]; + var events = new[] { new EventData(Array.Empty()) }; + var transportProducer = new ObservableTransportProducerMock(); + var producer = new EventHubProducerClient(new MockConnection(() => transportProducer)); + + await producer.SendAsync(events); + + (IEnumerable calledWithEvents, SendEventOptions calledWithOptions) = transportProducer.SendCalledWith; + + Assert.That(calledWithEvents, Is.EquivalentTo(events), "The events should contain same elements."); + Assert.That(calledWithOptions, Is.Not.Null, "A default set of options should be used."); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public async Task SendInvokesTheTransportProducer() + { + var events = new[] { new EventData(Array.Empty()) }; + var options = new SendEventOptions(); + var transportProducer = new ObservableTransportProducerMock(); + var producer = new EventHubProducerClient(new MockConnection(() => transportProducer)); + + await producer.SendAsync(events, options); + + (IEnumerable calledWithEvents, SendEventOptions calledWithOptions) = transportProducer.SendCalledWith; + + Assert.That(calledWithEvents, Is.EquivalentTo(events), "The events should contain same elements."); + Assert.That(calledWithOptions, Is.SameAs(options), "The options should be the same instance"); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public async Task SendInvokesTheTransportProducerWithABatch() + { + var batchOptions = new CreateBatchOptions { PartitionKey = "testKey" }; + var batch = new EventDataBatch(new MockTransportBatch(1), "ns", "eh", batchOptions); + var transportProducer = new ObservableTransportProducerMock(); + var producer = new EventHubProducerClient(new MockConnection(() => transportProducer)); + + await producer.SendAsync(batch); + Assert.That(transportProducer.SendBatchCalledWith, Is.SameAs(batch), "The batch should be the same instance."); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public void SendIdempotentRequiresThePartition() + { + + var events = EventGenerator.CreateEvents(5); + var transportProducer = new ObservableTransportProducerMock(); + var connection = new MockConnection(() => transportProducer); + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + var sendOptions = new SendEventOptions(); + Assert.That(async () => await producer.SendAsync(events, sendOptions), Throws.InstanceOf(), "Automatic routing cannot be used with idempotent publishing."); + + sendOptions.PartitionKey = "Still not allowed"; + Assert.That(async () => await producer.SendAsync(events, sendOptions), Throws.InstanceOf(), "A partition key cannot be used with idempotent publishing."); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public void SendIdempotentDoesNotAllowResending() + { + var transportProducer = new ObservableTransportProducerMock(); + var connection = new MockConnection(() => transportProducer); + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + var events = EventGenerator.CreateEvents(5).Select(item => + { + item.PendingPublishSequenceNumber = 5; + item.CommitPublishingSequenceNumber(); + + return item; + }); + + var sendOptions = new SendEventOptions { PartitionId = "0" }; + Assert.That(async () => await producer.SendAsync(events, sendOptions), Throws.InstanceOf(), "Resending of events cannot be done with idempotent publishing."); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public async Task SendIdempotentCreatesTheTransportWithTheCorrectOptions() + { + var expectedPartition = "5"; + var eventCount = 1; + var expectedProperties = new PartitionPublishingProperties(true, 123, 456, 798); + var expectedLastSequence = expectedProperties.LastPublishedSequenceNumber + eventCount; + var expectedOptions = new PartitionPublishingOptions(); + var requestedOptions = default(PartitionPublishingOptions); + var requestedFeatures = TransportProducerFeatures.None; + var sendOptions = new SendEventOptions { PartitionId = expectedPartition }; + var events = EventGenerator.CreateEvents(eventCount); + var mockTransport = new Mock(); + + expectedOptions.ProducerGroupId = expectedProperties.ProducerGroupId; + expectedOptions.OwnerLevel = expectedProperties.OwnerLevel; + expectedOptions.StartingSequenceNumber = expectedOptions.StartingSequenceNumber; + + var clientOptions = new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }; + + clientOptions.PartitionOptions.Add(expectedPartition, expectedOptions); + clientOptions.PartitionOptions.Add("0", new PartitionPublishingOptions()); + clientOptions.PartitionOptions.Add("1", new PartitionPublishingOptions()); + + var connection = new MockConnection((partition, feature, options, retry) => + { + requestedFeatures = feature; + requestedOptions = options; + + return mockTransport.Object; + }); + + var producer = new EventHubProducerClient(connection, clientOptions); + + mockTransport + .Setup(transportProducer => transportProducer.ReadInitializationPublishingPropertiesAsync(It.IsAny())) + .ReturnsAsync(expectedProperties) + .Verifiable(); + + await producer.SendAsync(events, sendOptions); + + Assert.That(requestedFeatures, Is.EqualTo(TransportProducerFeatures.IdempotentPublishing), "The idempotent feature should have been requested."); + Assert.That(requestedOptions.ProducerGroupId, Is.EqualTo(expectedOptions.ProducerGroupId), "The wrong producer group option was used to create the transport client."); + Assert.That(requestedOptions.OwnerLevel, Is.EqualTo(expectedOptions.OwnerLevel), "The wrong owner level option was used to create the transport client."); + Assert.That(requestedOptions.StartingSequenceNumber, Is.EqualTo(expectedOptions.StartingSequenceNumber), "The wrong sequence number option was used to create the transport client."); + + mockTransport.VerifyAll(); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public async Task SendIdempotentInitializesPartitionState() + { + var expectedPartition = "5"; + var eventCount = 1; + var expectedProperties = new PartitionPublishingProperties(true, 123, 456, 798); + var expectedLastSequence = expectedProperties.LastPublishedSequenceNumber + eventCount; + var events = EventGenerator.CreateEvents(eventCount); + var sendOptions = new SendEventOptions { PartitionId = expectedPartition }; + var mockTransport = new Mock(); + var connection = new MockConnection(() => mockTransport.Object); + + var clientOptions = new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }; + + clientOptions.PartitionOptions.Add("0", new PartitionPublishingOptions()); + clientOptions.PartitionOptions.Add("1", new PartitionPublishingOptions()); + + clientOptions.PartitionOptions.Add(expectedPartition, new PartitionPublishingOptions + { + ProducerGroupId = 999, + OwnerLevel = 999, + StartingSequenceNumber = 999 + }); + + var producer = new EventHubProducerClient(connection, clientOptions); + + mockTransport + .Setup(transportProducer => transportProducer.ReadInitializationPublishingPropertiesAsync(It.IsAny())) + .ReturnsAsync(expectedProperties) + .Verifiable(); + + var partitionStateCollection = GetPartitionState(producer); + Assert.That(partitionStateCollection, Is.Not.Null, "The collection for partition state should have been initialized with the client."); + + await producer.SendAsync(events, sendOptions); + + Assert.That(partitionStateCollection.TryGetValue(expectedPartition, out var partitionState), Is.True, "The state collection should have an entry for the partition."); + Assert.That(partitionState.ProducerGroupId, Is.EqualTo(expectedProperties.ProducerGroupId), "The producer group should match."); + Assert.That(partitionState.OwnerLevel, Is.EqualTo(expectedProperties.OwnerLevel), "The owner level should match."); + Assert.That(partitionState.LastPublishedSequenceNumber, Is.EqualTo(expectedLastSequence), "The sequence number should match."); + + mockTransport.VerifyAll(); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public void SendIdempotentFailsIfPartitionStateCannotBeInitialized() + { + var expectedPartition = "5"; + var expectedProperties = new PartitionPublishingProperties(true, null, null, null); + var events = EventGenerator.CreateEvents(1); + var sendOptions = new SendEventOptions { PartitionId = expectedPartition }; + var mockTransport = new Mock(); + var connection = new MockConnection(() => mockTransport.Object); + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + mockTransport + .Setup(transportProducer => transportProducer.ReadInitializationPublishingPropertiesAsync(It.IsAny())) + .ReturnsAsync(expectedProperties) + .Verifiable(); + + var partitionStateCollection = GetPartitionState(producer); + Assert.That(partitionStateCollection, Is.Not.Null, "The collection for partition state should have been initialized with the client."); + + Assert.That(async () => await producer.SendAsync(events, sendOptions), + Throws.InstanceOf().With.Property("Reason").EqualTo(EventHubsException.FailureReason.InvalidClientState), + "The client should not allow an uninitialized partition state."); + + mockTransport.VerifyAll(); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public void SendIdempotentHonorsCancellationIfSetWhenCalled() + { + var expectedPartition = "5"; + var events = EventGenerator.CreateEvents(1); + var sendOptions = new SendEventOptions { PartitionId = expectedPartition }; + var mockTransport = new Mock(); + var connection = new MockConnection(() => mockTransport.Object); + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.Cancel(); + + Assert.That(async () => await producer.SendAsync(events, sendOptions, cancellationSource.Token), Throws.InstanceOf()); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public void SendIdempotentHonorsCancellationIfSetWhenInitializingPartitionState() + { + var expectedPartition = "5"; + var expectedProperties = new PartitionPublishingProperties(true, 123, 456, 798); + var events = EventGenerator.CreateEvents(1); + var sendOptions = new SendEventOptions { PartitionId = expectedPartition }; + var mockTransport = new Mock(); + var connection = new MockConnection(() => mockTransport.Object); + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + using var cancellationSource = new CancellationTokenSource(); + + mockTransport + .Setup(transportProducer => transportProducer.ReadInitializationPublishingPropertiesAsync(It.IsAny())) + .Callback(() => cancellationSource.Cancel()) + .ReturnsAsync(expectedProperties) + .Verifiable(); + + Assert.That(async () => await producer.SendAsync(events, sendOptions, cancellationSource.Token), Throws.InstanceOf()); + mockTransport.VerifyAll(); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public async Task SendIdempotentAppliesSequenceNumbers() + { + var expectedPartition = "5"; + var eventCount = 5; + var startingSequence = 435; + var expectedProperties = new PartitionPublishingProperties(true, 123, 456, startingSequence); + var events = EventGenerator.CreateEvents(eventCount).ToArray(); + var sendOptions = new SendEventOptions { PartitionId = expectedPartition }; + var mockTransport = new Mock(); + var connection = new MockConnection(() => mockTransport.Object); + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + mockTransport + .Setup(transportProducer => transportProducer.ReadInitializationPublishingPropertiesAsync(It.IsAny())) + .ReturnsAsync(expectedProperties); + + mockTransport + .Setup(transportProducer => transportProducer.SendAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable("The events should have been sent using the transport producer."); + + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.CancelAfter(EventHubsTestEnvironment.Instance.TestExecutionTimeLimit); + + await producer.SendAsync(events, sendOptions); + + for (var index = 0; index < events.Length; ++index) + { + Assert.That(events[index].PublishedSequenceNumber, Is.EqualTo(startingSequence + 1 + index), $"The event in position `{ index }` was not in the proper sequence."); + Assert.That(events[index].PendingPublishSequenceNumber, Is.Null, $"The event in position `{ index }` should not have a pending sequence number remaining."); + } + + var partitionStateCollection = GetPartitionState(producer); + Assert.That(partitionStateCollection, Is.Not.Null, "The collection for partition state should have been initialized with the client."); + Assert.That(partitionStateCollection.TryGetValue(expectedPartition, out var partitionState), Is.True, "The state collection should have an entry for the partition."); + Assert.That(partitionState.LastPublishedSequenceNumber, Is.EqualTo(startingSequence + events.Length), "The sequence number for partition state should have been updated."); + + mockTransport.VerifyAll(); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public async Task SendIdempotentRollsOverSequenceNumbersToZero() + { + var expectedPartition = "5"; + var eventCount = 5; + var startingSequence = int.MaxValue; + var expectedProperties = new PartitionPublishingProperties(true, 123, 456, startingSequence); + var events = EventGenerator.CreateEvents(eventCount).ToArray(); + var sendOptions = new SendEventOptions { PartitionId = expectedPartition }; + var mockTransport = new Mock(); + var connection = new MockConnection(() => mockTransport.Object); + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + mockTransport + .Setup(transportProducer => transportProducer.ReadInitializationPublishingPropertiesAsync(It.IsAny())) + .ReturnsAsync(expectedProperties); + + mockTransport + .Setup(transportProducer => transportProducer.SendAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable("The events should have been sent using the transport producer."); + + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.CancelAfter(EventHubsTestEnvironment.Instance.TestExecutionTimeLimit); + + await producer.SendAsync(events, sendOptions); + + for (var index = 0; index < events.Length; ++index) + { + Assert.That(events[index].PublishedSequenceNumber, Is.EqualTo(index), $"The event in position `{ index }` was not in the proper sequence."); + Assert.That(events[index].PendingPublishSequenceNumber, Is.Null, $"The event in position `{ index }` should not have a pending sequence number remaining."); + } + + var partitionStateCollection = GetPartitionState(producer); + Assert.That(partitionStateCollection, Is.Not.Null, "The collection for partition state should have been initialized with the client."); + Assert.That(partitionStateCollection.TryGetValue(expectedPartition, out var partitionState), Is.True, "The state collection should have an entry for the partition."); + Assert.That(partitionState.LastPublishedSequenceNumber, Is.EqualTo(events.Length - 1), "The sequence number for partition state should have been updated."); + + mockTransport.VerifyAll(); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public void SendIdempotentRollsBackSequenceNumbersOnFailure() + { + var expectedPartition = "5"; + var eventCount = 5; + var startingSequence = 435; + var expectedProperties = new PartitionPublishingProperties(true, 123, 456, startingSequence); + var events = EventGenerator.CreateEvents(eventCount).ToArray(); + var sendOptions = new SendEventOptions { PartitionId = expectedPartition }; + var mockTransport = new Mock(); + var connection = new MockConnection(() => mockTransport.Object); + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + mockTransport + .Setup(transportProducer => transportProducer.ReadInitializationPublishingPropertiesAsync(It.IsAny())) + .ReturnsAsync(expectedProperties); + + mockTransport + .Setup(transportProducer => transportProducer.SendAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns(Task.FromException(new OverflowException())); + + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.CancelAfter(EventHubsTestEnvironment.Instance.TestExecutionTimeLimit); + + Assert.That(async () => await producer.SendAsync(events, sendOptions), Throws.Exception, "The send operation should have failed."); + + for (var index = 0; index < events.Length; ++index) + { + Assert.That(events[index].PublishedSequenceNumber, Is.Null, $"The event in position `{ index }`should not have a sequence number."); + Assert.That(events[index].PendingPublishSequenceNumber, Is.Null, $"The event in position `{ index }` should not have a pending sequence number remaining."); + } + + var partitionStateCollection = GetPartitionState(producer); + Assert.That(partitionStateCollection, Is.Not.Null, "The collection for partition state should have been initialized with the client."); + Assert.That(partitionStateCollection.TryGetValue(expectedPartition, out var partitionState), Is.True, "The state collection should have an entry for the partition."); + Assert.That(partitionState.LastPublishedSequenceNumber, Is.EqualTo(startingSequence), "The sequence number for partition state should have been rolled back."); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public async Task SendIdempotentOrdersConcurrentRequests() + { + var expectedPartition = "5"; + var eventCount = 5; + var startingSequence = 435; + var expectedProperties = new PartitionPublishingProperties(true, 123, 456, startingSequence); + var sendOptions = new SendEventOptions { PartitionId = expectedPartition }; + var mockTransport = new Mock(); + var connection = new MockConnection(() => mockTransport.Object); + + var events = new[] + { + EventGenerator.CreateEvents(eventCount).ToArray(), + EventGenerator.CreateEvents(eventCount).ToArray(), + EventGenerator.CreateEvents(eventCount).ToArray() + }; + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + mockTransport + .Setup(transportProducer => transportProducer.ReadInitializationPublishingPropertiesAsync(It.IsAny())) + .ReturnsAsync(expectedProperties); + + // Each send operation will wait less time before completing to give later operations an + // advantage to complete first if synchronization does not take place properly. + + var sendCountdown = events.Length; + + mockTransport + .Setup(transportProducer => transportProducer.SendAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns(Task.Delay(TimeSpan.FromMilliseconds(150 * (--sendCountdown)))); + + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.CancelAfter(EventHubsTestEnvironment.Instance.TestExecutionTimeLimit); + + await Task.WhenAll(events.Select(batch => producer.SendAsync(batch, sendOptions))); + + var eventPosition = 0; + + foreach (var batch in events) + { + for (var index = 0; index < batch.Length; ++index) + { + ++eventPosition; + + Assert.That(batch[index].PublishedSequenceNumber, Is.EqualTo(startingSequence + eventPosition), $"The event in position `{ eventPosition }` was not in the proper sequence."); + Assert.That(batch[index].PendingPublishSequenceNumber, Is.Null, $"The event in position `{ eventPosition }` should not have a pending sequence number remaining."); + } + } + + var partitionStateCollection = GetPartitionState(producer); + Assert.That(partitionStateCollection, Is.Not.Null, "The collection for partition state should have been initialized with the client."); + Assert.That(partitionStateCollection.TryGetValue(expectedPartition, out var partitionState), Is.True, "The state collection should have an entry for the partition."); + Assert.That(partitionState.LastPublishedSequenceNumber, Is.EqualTo(startingSequence + (events.Length * eventCount)), "The sequence number for partition state should have been updated."); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public async Task SendIdempotentAllowsConcurrentRequestsToDifferentPartitions() + { + var eventCount = 5; + var startingSequence = 435; + var expectedProperties = new PartitionPublishingProperties(true, 123, 456, startingSequence); + var mockTransport = new Mock(); + var connection = new MockConnection(() => mockTransport.Object); + + var events = new[] + { + EventGenerator.CreateEvents(eventCount).ToArray(), + EventGenerator.CreateEvents(eventCount).ToArray(), + EventGenerator.CreateEvents(eventCount).ToArray() + }; + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + mockTransport + .Setup(transportProducer => transportProducer.ReadInitializationPublishingPropertiesAsync(It.IsAny())) + .ReturnsAsync(expectedProperties); + + // Each send operation will wait less time before completing to give later operations an + // advantage to complete first if synchronization does not take place properly. + + var sendCountdown = events.Length; + + mockTransport + .Setup(transportProducer => transportProducer.SendAsync(It.IsAny(), It.IsAny())) + .Returns(Task.Delay(TimeSpan.FromMilliseconds(150 * (--sendCountdown)))); + + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.CancelAfter(EventHubsTestEnvironment.Instance.TestExecutionTimeLimit); + + var partition = 0L; + await Task.WhenAll(events.Select(batch => producer.SendAsync(batch, new SendEventOptions { PartitionId = Interlocked.Increment(ref partition).ToString() }))); + + for (var batchIndex = 0; batchIndex < events.Length; ++batchIndex) + { + var batch = events[batchIndex]; + + for (var index = 0; index < batch.Length; ++index) + { + Assert.That(batch[index].PublishedSequenceNumber, Is.EqualTo(startingSequence + 1 + index), $"The event in batch `{ batchIndex }` position `{ index }` was not in the proper sequence."); + } + } + + var partitionStateCollection = GetPartitionState(producer); + Assert.That(partitionStateCollection, Is.Not.Null, "The collection for partition state should have been initialized with the client."); + + foreach (var stateKey in partitionStateCollection.Keys) + { + Assert.That(partitionStateCollection.TryGetValue(stateKey, out var partitionState), Is.True, $"The state collection should have an entry for the partition `{ stateKey }`."); + Assert.That(partitionState.LastPublishedSequenceNumber, Is.EqualTo(startingSequence + eventCount), $"The sequence number for partition `{ stateKey }` state should have been updated."); + } + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public void SendIdempotentRequiresThePartitionWithABatch() + { + var transportProducer = new ObservableTransportProducerMock(); + var connection = new MockConnection(() => transportProducer); + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + var batch = new EventDataBatch(new MockTransportBatch(1), "ns", "eh", new CreateBatchOptions()); + Assert.That(async () => await producer.SendAsync(batch), Throws.InstanceOf(), "Automatic routing cannot be used with idempotent publishing."); + + var batchOptions = new CreateBatchOptions { PartitionKey = "testKey" }; + batch = new EventDataBatch(new MockTransportBatch(1), "ns", "eh", batchOptions); + Assert.That(async () => await producer.SendAsync(batch), Throws.InstanceOf(), "A partition key cannot be used with idempotent publishing.");; + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public void SendIdempotentDoesNotAllowResendingWithAPublishedBatch() + { + var transportProducer = new ObservableTransportProducerMock(); + var connection = new MockConnection(() => transportProducer); + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + var events = EventGenerator.CreateEvents(5); + var batch = new EventDataBatch(new MockTransportBatch(), "ns", "eh", new CreateBatchOptions { PartitionId = "0" }); + events.ToList().ForEach(item => batch.TryAdd(item)); + + batch.StartingPublishedSequenceNumber = 0; + Assert.That(async () => await producer.SendAsync(batch), Throws.InstanceOf(), "Resending of events cannot be done with idempotent publishing."); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public void SendIdempotentDoesNotAllowResendingWithABatchContainingPublishedEvents() + { var transportProducer = new ObservableTransportProducerMock(); - var producer = new EventHubProducerClient(new MockConnection(() => transportProducer)); + var connection = new MockConnection(() => transportProducer); - await producer.SendAsync(events); + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); - (IEnumerable calledWithEvents, SendEventOptions calledWithOptions) = transportProducer.SendCalledWith; + var events = EventGenerator.CreateEvents(5).Skip(4).Select(item => + { + item.PendingPublishSequenceNumber = 5; + item.CommitPublishingSequenceNumber(); - Assert.That(calledWithEvents, Is.EquivalentTo(events), "The events should contain same elements."); - Assert.That(calledWithOptions, Is.Not.Null, "A default set of options should be used."); + return item; + }); + + var batch = new EventDataBatch(new MockTransportBatch(), "ns", "eh", new CreateBatchOptions { PartitionId = "0" }); + events.ToList().ForEach(item => batch.TryAdd(item)); + + Assert.That(async () => await producer.SendAsync(batch), Throws.InstanceOf(), "Resending of events cannot be done with idempotent publishing."); } /// @@ -505,19 +1154,105 @@ public async Task SendWithoutOptionsInvokesTheTransportProducer() /// /// [Test] - public async Task SendInvokesTheTransportProducer() + public async Task SendIdempotentCreatesTheTransportWithTheCorrectOptionsWithABatch() + { + var expectedPartition = "5"; + var eventCount = 1; + var expectedProperties = new PartitionPublishingProperties(true, 123, 456, 798); + var expectedLastSequence = expectedProperties.LastPublishedSequenceNumber + eventCount; + var expectedOptions = new PartitionPublishingOptions(); + var requestedOptions = default(PartitionPublishingOptions); + var requestedFeatures = TransportProducerFeatures.None; + var batch = new EventDataBatch(new MockTransportBatch(eventCount), "ns", "eh", new CreateBatchOptions { PartitionId = expectedPartition }); + var mockTransport = new Mock(); + + expectedOptions.ProducerGroupId = expectedProperties.ProducerGroupId; + expectedOptions.OwnerLevel = expectedProperties.OwnerLevel; + expectedOptions.StartingSequenceNumber = expectedOptions.StartingSequenceNumber; + + var clientOptions = new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }; + + clientOptions.PartitionOptions.Add(expectedPartition, expectedOptions); + clientOptions.PartitionOptions.Add("0", new PartitionPublishingOptions()); + clientOptions.PartitionOptions.Add("1", new PartitionPublishingOptions()); + + var connection = new MockConnection((partition, feature, options, retry) => + { + requestedFeatures = feature; + requestedOptions = options; + + return mockTransport.Object; + }); + + var producer = new EventHubProducerClient(connection, clientOptions); + + mockTransport + .Setup(transportProducer => transportProducer.ReadInitializationPublishingPropertiesAsync(It.IsAny())) + .ReturnsAsync(expectedProperties) + .Verifiable(); + + await producer.SendAsync(batch); + + Assert.That(requestedFeatures, Is.EqualTo(TransportProducerFeatures.IdempotentPublishing), "The idempotent feature should have been requested."); + Assert.That(requestedOptions.ProducerGroupId, Is.EqualTo(expectedOptions.ProducerGroupId), "The wrong producer group option was used to create the transport client."); + Assert.That(requestedOptions.OwnerLevel, Is.EqualTo(expectedOptions.OwnerLevel), "The wrong owner level option was used to create the transport client."); + Assert.That(requestedOptions.StartingSequenceNumber, Is.EqualTo(expectedOptions.StartingSequenceNumber), "The wrong sequence number option was used to create the transport client."); + + mockTransport.VerifyAll(); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public async Task SendIdempotentInitializesPartitionStateWithABatch() { - var events = new EventData[0]; - var options = new SendEventOptions(); - var transportProducer = new ObservableTransportProducerMock(); - var producer = new EventHubProducerClient(new MockConnection(() => transportProducer)); + var expectedPartition = "5"; + var eventCount = 1; + var expectedProperties = new PartitionPublishingProperties(true, 123, 456, 798); + var expectedLastSequence = expectedProperties.LastPublishedSequenceNumber + eventCount; + var mockTransport = new Mock(); + var connection = new MockConnection(() => mockTransport.Object); + var batch = new EventDataBatch(new MockTransportBatch(eventCount), "ns", "eh", new CreateBatchOptions { PartitionId = expectedPartition }); - await producer.SendAsync(events, options); + var clientOptions = new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }; - (IEnumerable calledWithEvents, SendEventOptions calledWithOptions) = transportProducer.SendCalledWith; + clientOptions.PartitionOptions.Add("0", new PartitionPublishingOptions()); + clientOptions.PartitionOptions.Add("1", new PartitionPublishingOptions()); - Assert.That(calledWithEvents, Is.EquivalentTo(events), "The events should contain same elements."); - Assert.That(calledWithOptions, Is.SameAs(options), "The options should be the same instance"); + clientOptions.PartitionOptions.Add(expectedPartition, new PartitionPublishingOptions + { + ProducerGroupId = 999, + OwnerLevel = 999, + StartingSequenceNumber = 999 + }); + + var producer = new EventHubProducerClient(connection, clientOptions); + + mockTransport + .Setup(transportProducer => transportProducer.ReadInitializationPublishingPropertiesAsync(It.IsAny())) + .ReturnsAsync(expectedProperties) + .Verifiable(); + + var partitionStateCollection = GetPartitionState(producer); + Assert.That(partitionStateCollection, Is.Not.Null, "The collection for partition state should have been initialized with the client."); + + await producer.SendAsync(batch); + + Assert.That(partitionStateCollection.TryGetValue(expectedPartition, out var partitionState), Is.True, "The state collection should have an entry for the partition."); + Assert.That(partitionState.ProducerGroupId, Is.EqualTo(expectedProperties.ProducerGroupId), "The producer group should match."); + Assert.That(partitionState.OwnerLevel, Is.EqualTo(expectedProperties.OwnerLevel), "The owner level should match."); + Assert.That(partitionState.LastPublishedSequenceNumber, Is.EqualTo(expectedLastSequence), "The sequence number should match."); + + mockTransport.VerifyAll(); } /// @@ -526,15 +1261,336 @@ public async Task SendInvokesTheTransportProducer() /// /// [Test] - public async Task SendInvokesTheTransportProducerWithABatch() + public void SendIdempotentFailsIfPartitionStateCannotBeInitializedWithABatch() { - var batchOptions = new CreateBatchOptions { PartitionKey = "testKey" }; - var batch = new EventDataBatch(new MockTransportBatch(), "ns", "eh", batchOptions); - var transportProducer = new ObservableTransportProducerMock(); - var producer = new EventHubProducerClient(new MockConnection(() => transportProducer)); + var expectedPartition = "5"; + var expectedProperties = new PartitionPublishingProperties(true, null, null, null); + var mockTransport = new Mock(); + var connection = new MockConnection(() => mockTransport.Object); + var batch = new EventDataBatch(new MockTransportBatch(1), "ns", "eh", new CreateBatchOptions { PartitionId = expectedPartition }); + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + mockTransport + .Setup(transportProducer => transportProducer.ReadInitializationPublishingPropertiesAsync(It.IsAny())) + .ReturnsAsync(expectedProperties); + + var partitionStateCollection = GetPartitionState(producer); + Assert.That(partitionStateCollection, Is.Not.Null, "The collection for partition state should have been initialized with the client."); + + Assert.That(async () => await producer.SendAsync(batch), + Throws.InstanceOf().With.Property("Reason").EqualTo(EventHubsException.FailureReason.InvalidClientState), + "The client should not allow an uninitialized partition state."); + + mockTransport.VerifyAll(); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public void SendIdempotentHonorsCancellationIfSetWhenCalledWithABatch() + { + var expectedPartition = "5"; + var mockTransport = new Mock(); + var connection = new MockConnection(() => mockTransport.Object); + var batch = new EventDataBatch(new MockTransportBatch(1), "ns", "eh", new CreateBatchOptions { PartitionId = expectedPartition }); + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.Cancel(); + + Assert.That(async () => await producer.SendAsync(batch, cancellationSource.Token), Throws.InstanceOf()); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public void SendIdempotentHonorsCancellationIfSetWhenInitializingPartitionStateWithABatch() + { + var expectedPartition = "5"; + var expectedProperties = new PartitionPublishingProperties(true, 123, 456, 798); + var mockTransport = new Mock(); + var connection = new MockConnection(() => mockTransport.Object); + var batch = new EventDataBatch(new MockTransportBatch(1), "ns", "eh", new CreateBatchOptions { PartitionId = expectedPartition }); + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + using var cancellationSource = new CancellationTokenSource(); + + mockTransport + .Setup(transportProducer => transportProducer.ReadInitializationPublishingPropertiesAsync(It.IsAny())) + .Callback(() => cancellationSource.Cancel()) + .ReturnsAsync(expectedProperties) + .Verifiable(); + + var partitionStateCollection = GetPartitionState(producer); + Assert.That(partitionStateCollection, Is.Not.Null, "The collection for partition state should have been initialized with the client."); + + Assert.That(async () => await producer.SendAsync(batch, cancellationSource.Token), Throws.InstanceOf()); + mockTransport.VerifyAll(); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public async Task SendIdempotentAppliesSequenceNumbersWithABatch() + { + var expectedPartition = "5"; + var eventCount = 6; + var startingSequence = 435; + var expectedProperties = new PartitionPublishingProperties(true, 123, 456, startingSequence); + var batch = new EventDataBatch(new MockTransportBatch(eventCount), "ns", "eh", new CreateBatchOptions { PartitionId = expectedPartition }); + var mockTransport = new Mock(); + var connection = new MockConnection(() => mockTransport.Object); + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + mockTransport + .Setup(transportProducer => transportProducer.ReadInitializationPublishingPropertiesAsync(It.IsAny())) + .ReturnsAsync(expectedProperties); + + mockTransport + .Setup(transportProducer => transportProducer.SendAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable("The events should have been sent using the transport producer."); + + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.CancelAfter(EventHubsTestEnvironment.Instance.TestExecutionTimeLimit); await producer.SendAsync(batch); - Assert.That(transportProducer.SendBatchCalledWith, Is.SameAs(batch), "The batch should be the same instance."); + Assert.That(batch.StartingPublishedSequenceNumber, Is.EqualTo(startingSequence + 1), "The batch did not have the correct starting sequence number."); + + var partitionStateCollection = GetPartitionState(producer); + Assert.That(partitionStateCollection, Is.Not.Null, "The collection for partition state should have been initialized with the client."); + Assert.That(partitionStateCollection.TryGetValue(expectedPartition, out var partitionState), Is.True, "The state collection should have an entry for the partition."); + Assert.That(partitionState.LastPublishedSequenceNumber, Is.EqualTo(startingSequence + batch.Count), "The sequence number for partition state should have been updated."); + + mockTransport.VerifyAll(); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public void SendIdempotentRollsBackSequenceNumbersOnFailureWithABatch() + { + var expectedPartition = "5"; + var eventCount = 6; + var startingSequence = 435; + var expectedProperties = new PartitionPublishingProperties(true, 123, 456, startingSequence); + var batch = new EventDataBatch(new MockTransportBatch(eventCount), "ns", "eh", new CreateBatchOptions { PartitionId = expectedPartition }); + var mockTransport = new Mock(); + var connection = new MockConnection(() => mockTransport.Object); + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + mockTransport + .Setup(transportProducer => transportProducer.ReadInitializationPublishingPropertiesAsync(It.IsAny())) + .ReturnsAsync(expectedProperties); + + mockTransport + .Setup(transportProducer => transportProducer.SendAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromException(new OverflowException())); + + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.CancelAfter(EventHubsTestEnvironment.Instance.TestExecutionTimeLimit); + + Assert.That(async () => await producer.SendAsync(batch), Throws.Exception, "The send operation should have failed."); + Assert.That(batch.StartingPublishedSequenceNumber, Is.Null, "The batch should not have a starting sequence number."); + + var partitionStateCollection = GetPartitionState(producer); + Assert.That(partitionStateCollection, Is.Not.Null, "The collection for partition state should have been initialized with the client."); + Assert.That(partitionStateCollection.TryGetValue(expectedPartition, out var partitionState), Is.True, "The state collection should have an entry for the partition."); + Assert.That(partitionState.LastPublishedSequenceNumber, Is.EqualTo(startingSequence), "The sequence number for partition state should have been rolled back."); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public async Task SendIdempotentRollsOverSequenceNumbersToZeroWithABatch() + { + var expectedPartition = "5"; + var eventCount = 6; + var startingSequence = int.MaxValue; + var expectedProperties = new PartitionPublishingProperties(true, 123, 456, startingSequence); + var batch = new EventDataBatch(new MockTransportBatch(eventCount), "ns", "eh", new CreateBatchOptions { PartitionId = expectedPartition }); + var mockTransport = new Mock(); + var connection = new MockConnection(() => mockTransport.Object); + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + mockTransport + .Setup(transportProducer => transportProducer.ReadInitializationPublishingPropertiesAsync(It.IsAny())) + .ReturnsAsync(expectedProperties); + + mockTransport + .Setup(transportProducer => transportProducer.SendAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable("The events should have been sent using the transport producer."); + + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.CancelAfter(EventHubsTestEnvironment.Instance.TestExecutionTimeLimit); + + await producer.SendAsync(batch); + Assert.That(batch.StartingPublishedSequenceNumber, Is.EqualTo(0), "The batch did not roll over the starting sequence number to zero."); + + var partitionStateCollection = GetPartitionState(producer); + Assert.That(partitionStateCollection, Is.Not.Null, "The collection for partition state should have been initialized with the client."); + Assert.That(partitionStateCollection.TryGetValue(expectedPartition, out var partitionState), Is.True, "The state collection should have an entry for the partition."); + Assert.That(partitionState.LastPublishedSequenceNumber, Is.EqualTo(batch.Count - 1), "The sequence number for partition state should have been updated."); + + mockTransport.VerifyAll(); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public async Task SendIdempotentOrdersConcurrentRequestsWithABatch() + { + var expectedPartition = "5"; + var eventCount = 5; + var startingSequence = 435; + var expectedProperties = new PartitionPublishingProperties(true, 123, 456, startingSequence); + var mockTransport = new Mock(); + var connection = new MockConnection(() => mockTransport.Object); + + var batches = new[] + { + new EventDataBatch(new MockTransportBatch(eventCount), "ns", "eh", new CreateBatchOptions { PartitionId = expectedPartition }), + new EventDataBatch(new MockTransportBatch(eventCount), "ns", "eh", new CreateBatchOptions { PartitionId = expectedPartition }), + new EventDataBatch(new MockTransportBatch(eventCount), "ns", "eh", new CreateBatchOptions { PartitionId = expectedPartition }), + }; + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + mockTransport + .Setup(transportProducer => transportProducer.ReadInitializationPublishingPropertiesAsync(It.IsAny())) + .ReturnsAsync(expectedProperties); + + // Each send operation will wait less time before completing to give later operations an + // advantage to complete first if synchronization does not take place properly. + + var sendCountdown = batches.Length; + + mockTransport + .Setup(transportProducer => transportProducer.SendAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns(Task.Delay(TimeSpan.FromMilliseconds(150 * (--sendCountdown)))); + + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.CancelAfter(EventHubsTestEnvironment.Instance.TestExecutionTimeLimit); + + await Task.WhenAll(batches.Select(batch => producer.SendAsync(batch))); + + for (var index = 0; index < batches.Length; ++index) + { + var batch = batches[index]; + Assert.That(batch.StartingPublishedSequenceNumber, Is.EqualTo(startingSequence + 1 + (index * eventCount)), $"The batch in position `{ index }` did not have the correct starting sequence number."); + } + + var partitionStateCollection = GetPartitionState(producer); + Assert.That(partitionStateCollection, Is.Not.Null, "The collection for partition state should have been initialized with the client."); + Assert.That(partitionStateCollection.TryGetValue(expectedPartition, out var partitionState), Is.True, "The state collection should have an entry for the partition."); + Assert.That(partitionState.LastPublishedSequenceNumber, Is.EqualTo(startingSequence + (batches.Length * eventCount)), "The sequence number for partition state should have been updated."); + } + + /// + /// Verifies functionality of the + /// method. + /// + /// + [Test] + public async Task SendIdempotentAllowsConcurrentRequestsToDifferentPartitionsWithABatch() + { + var eventCount = 5; + var startingSequence = 435; + var partition = 0; + var expectedProperties = new PartitionPublishingProperties(true, 123, 456, startingSequence); + var mockTransport = new Mock(); + var connection = new MockConnection(() => mockTransport.Object); + + var batches = new[] + { + new EventDataBatch(new MockTransportBatch(eventCount), "ns", "eh", new CreateBatchOptions { PartitionId = (++partition).ToString() }), + new EventDataBatch(new MockTransportBatch(eventCount), "ns", "eh", new CreateBatchOptions { PartitionId = (++partition).ToString() }), + new EventDataBatch(new MockTransportBatch(eventCount), "ns", "eh", new CreateBatchOptions { PartitionId = (++partition).ToString() }), + }; + + var producer = new EventHubProducerClient(connection, new EventHubProducerClientOptions + { + EnableIdempotentPartitions = true + }); + + mockTransport + .Setup(transportProducer => transportProducer.ReadInitializationPublishingPropertiesAsync(It.IsAny())) + .ReturnsAsync(expectedProperties); + + // Each send operation will wait less time before completing to give later operations an + // advantage to complete first if synchronization does not take place properly. + + var sendCountdown = batches.Length; + + mockTransport + .Setup(transportProducer => transportProducer.SendAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns(Task.Delay(TimeSpan.FromMilliseconds(150 * (--sendCountdown)))); + + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.CancelAfter(EventHubsTestEnvironment.Instance.TestExecutionTimeLimit); + + await Task.WhenAll(batches.Select(batch => producer.SendAsync(batch))); + + for (var index = 0; index < batches.Length; ++index) + { + var batch = batches[index]; + Assert.That(batch.StartingPublishedSequenceNumber, Is.EqualTo(startingSequence + 1), $"The batch in position `{ index }` did not have the correct starting sequence number."); + } + + var partitionStateCollection = GetPartitionState(producer); + Assert.That(partitionStateCollection, Is.Not.Null, "The collection for partition state should have been initialized with the client."); + + foreach (var stateKey in partitionStateCollection.Keys) + { + Assert.That(partitionStateCollection.TryGetValue(stateKey, out var partitionState), Is.True, $"The state collection should have an entry for the partition `{ stateKey }`."); + Assert.That(partitionState.LastPublishedSequenceNumber, Is.EqualTo(startingSequence + eventCount), $"The sequence number for partition `{ stateKey }` state should have been updated."); + } } /// @@ -559,6 +1615,10 @@ public async Task SendManagesLockingTheBatch() .Setup(transport => transport.TryAdd(It.IsAny())) .Returns(true); + mockTransportBatch + .Setup(transport => transport.Count) + .Returns(1); + mockTransportProducer .Setup(transport => transport.SendAsync(It.IsAny(), It.IsAny())) .Returns(async () => await Task.WhenAny(completionSource.Task, Task.Delay(Timeout.Infinite, cancellationSource.Token))); @@ -657,8 +1717,8 @@ public async Task CreateBatchSetsTheSendOptionsForTheEventBatch() public async Task CloseAsyncClosesTheTransportProducers() { var transportProducer = new ObservableTransportProducerMock(); - var mockFirstBatch = new EventDataBatch(new MockTransportBatch(), "ns", "eh", new SendEventOptions { PartitionId = "1" }); - var mockSecondBatch = new EventDataBatch(new MockTransportBatch(), "ns", "eh", new SendEventOptions { PartitionId = "2" }); + var mockFirstBatch = new EventDataBatch(new MockTransportBatch(1), "ns", "eh", new SendEventOptions { PartitionId = "1" }); + var mockSecondBatch = new EventDataBatch(new MockTransportBatch(1), "ns", "eh", new SendEventOptions { PartitionId = "2" }); var producer = new EventHubProducerClient(new MockConnection(() => transportProducer)); await producer.SendAsync(mockFirstBatch).IgnoreExceptions(); @@ -779,10 +1839,10 @@ public async Task EventHubProducerClientShouldPickAnItemFromPoolWithABatch() /// /// [Test] - public async Task EventHubProducerClientShouldCloseAProducer() + public async Task EventHubProducerClientShouldCloseAProducerWithABatch() { var batchOptions = new CreateBatchOptions { PartitionId = "0" }; - var batch = new EventDataBatch(new MockTransportBatch(), "ns", "eh", batchOptions); + var batch = new EventDataBatch(new MockTransportBatch(1), "ns", "eh", batchOptions); var transportProducer = new ObservableTransportProducerMock(); var eventHubConnection = new MockConnection(() => transportProducer); var retryPolicy = new EventHubProducerClientOptions().RetryOptions.ToRetryPolicy(); @@ -806,16 +1866,16 @@ public async Task EventHubProducerClientShouldCloseAProducer() /// /// [Test] - public async Task EventHubProducerClientShouldCloseAProducerWithABatch() + public async Task EventHubProducerClientShouldCloseAProducer() { var options = new SendEventOptions { PartitionId = "0" }; + var events = new[] { new EventData(Array.Empty()) }; var transportProducer = new ObservableTransportProducerMock(); var eventHubConnection = new MockConnection(() => transportProducer); var retryPolicy = new EventHubProducerClientOptions().RetryOptions.ToRetryPolicy(); var mockTransportProducerPool = new MockTransportProducerPool(new ObservableTransportProducerMock(), eventHubConnection, retryPolicy); var mockPooledProducer = mockTransportProducerPool.GetPooledProducer(options.PartitionId) as MockPooledProducer; var producerClient = new EventHubProducerClient(eventHubConnection, transportProducer, mockTransportProducerPool); - var events = new EventData[0]; await producerClient.SendAsync(events, options); Assert.That(mockPooledProducer.WasClosed, Is.True, $"A { nameof(TransportProducerPool.PooledProducer) } should be closed when disposed (for a batch)."); @@ -837,7 +1897,7 @@ public void EventHubProducerClientShouldRetrySending() var mockTransportProducerPool = new MockTransportProducerPool(transportProducer.Object, eventHubConnection, retryPolicy); var mockPooledProducer = mockTransportProducerPool.GetPooledProducer(options.PartitionId) as MockPooledProducer; var producerClient = new EventHubProducerClient(eventHubConnection, transportProducer.Object, mockTransportProducerPool); - var events = new EventData[0]; + var events = new[] { new EventData(Array.Empty()) }; transportProducer .Setup(transportProducer => transportProducer.SendAsync(It.IsAny>(), @@ -868,7 +1928,7 @@ public void EventHubProducerClientShouldRetrySending() public void EventHubProducerClientShouldRetrySendingWithABatch() { var batchOptions = new CreateBatchOptions { PartitionId = "0" }; - var batch = new EventDataBatch(new MockTransportBatch(), "ns", "eh", batchOptions); + var batch = new EventDataBatch(new MockTransportBatch(1), "ns", "eh", batchOptions); var transportProducer = new Mock(); var eventHubConnection = new MockConnection(() => transportProducer.Object); var retryPolicy = new EventHubProducerClientOptions().RetryOptions.ToRetryPolicy(); @@ -965,7 +2025,7 @@ public void RetryLogicEndsWithABatch() public void RetryLogicDoesNotStartWhenPartitionIdIsNull() { var options = new SendEventOptions { PartitionId = "0" }; - var events = new EventData[0]; + var events = new[] { new EventData(Array.Empty()) }; var transportProducer = new Mock(); var eventHubConnection = new MockConnection(() => transportProducer.Object); var retryPolicy = new EventHubProducerClientOptions().RetryOptions.ToRetryPolicy(); @@ -996,7 +2056,7 @@ public void RetryLogicDoesNotStartWhenPartitionIdIsNull() public void RetryLogicDoesNotStartWhenPartitionIdIsNullWithABatch() { var batchOptions = new CreateBatchOptions { PartitionId = "0" }; - var batch = new EventDataBatch(new MockTransportBatch(), "ns", "eh", batchOptions); + var batch = new EventDataBatch(new MockTransportBatch(1), "ns", "eh", batchOptions); var transportProducer = new Mock(); var eventHubConnection = new MockConnection(() => transportProducer.Object); var retryPolicy = new EventHubProducerClientOptions().RetryOptions.ToRetryPolicy(); @@ -1025,7 +2085,7 @@ public void RetryLogicDoesNotStartWhenPartitionIdIsNullWithABatch() public async Task RetryLogicDoesNotWorkForClosedConnections() { var options = new SendEventOptions { PartitionId = "0" }; - var events = new EventData[0]; + var events = new[] { new EventData(Array.Empty()) }; var transportProducer = new Mock(); var eventHubConnection = new MockConnection(() => transportProducer.Object); var retryPolicy = new EventHubProducerClientOptions().RetryOptions.ToRetryPolicy(); @@ -1062,7 +2122,7 @@ public async Task RetryLogicDoesNotWorkForClosedConnections() public async Task RetryLogicDoesNotWorkForClosedConnectionsWithABatch() { var batchOptions = new CreateBatchOptions { PartitionId = "0" }; - var batch = new EventDataBatch(new MockTransportBatch(), "ns", "eh", batchOptions); + var batch = new EventDataBatch(new MockTransportBatch(1), "ns", "eh", batchOptions); var transportProducer = new Mock(); var eventHubConnection = new MockConnection(() => transportProducer.Object); var retryPolicy = new EventHubProducerClientOptions().RetryOptions.ToRetryPolicy(); @@ -1080,7 +2140,6 @@ public async Task RetryLogicDoesNotWorkForClosedConnectionsWithABatch() .Returns(true); await eventHubConnection.CloseAsync(CancellationToken.None); - Assert.That(async () => await producerClient.SendAsync(batch), Throws.InstanceOf().And.Property(nameof(EventHubsException.Reason)).EqualTo(EventHubsException.FailureReason.ClientClosed)); transportProducer.Verify(t => t.SendAsync(It.IsAny(), @@ -1097,7 +2156,7 @@ public async Task RetryLogicDoesNotWorkForClosedConnectionsWithABatch() public void RetryLogicDoesNotWorkForClosedEventHubProducerClients() { var options = new SendEventOptions { PartitionId = "0" }; - var events = new EventData[0]; + var events = new[] { new EventData(Array.Empty()) }; var transportProducer = new Mock(); var eventHubConnection = new MockConnection(() => transportProducer.Object); var retryPolicy = new EventHubProducerClientOptions().RetryOptions.ToRetryPolicy(); @@ -1134,7 +2193,7 @@ public void RetryLogicDoesNotWorkForClosedEventHubProducerClients() public void RetryLogicDoesNotWorkForClosedEventHubProducerClientsWithABatch() { var batchOptions = new CreateBatchOptions { PartitionId = "0" }; - var batch = new EventDataBatch(new MockTransportBatch(), "ns", "eh", batchOptions); + var batch = new EventDataBatch(new MockTransportBatch(1), "ns", "eh", batchOptions); var transportProducer = new Mock(); var eventHubConnection = new MockConnection(() => transportProducer.Object); var retryPolicy = new EventHubProducerClientOptions().RetryOptions.ToRetryPolicy(); @@ -1152,7 +2211,6 @@ public void RetryLogicDoesNotWorkForClosedEventHubProducerClientsWithABatch() .Returns(true); SetIsClosed(producerClient, true); - Assert.That(async () => await producerClient.SendAsync(batch), Throws.InstanceOf().And.Property(nameof(EventHubsException.Reason)).EqualTo(EventHubsException.FailureReason.ClientClosed)); transportProducer.Verify(t => t.SendAsync(It.IsAny(), @@ -1169,7 +2227,7 @@ public void RetryLogicDoesNotWorkForClosedEventHubProducerClientsWithABatch() public void RetryLogicShouldNotStartWhenCancellationTriggered() { var options = new SendEventOptions { PartitionId = "0" }; - var events = new EventData[0]; + var events = new[] { new EventData(Array.Empty()) }; var transportProducer = new Mock(); var eventHubConnection = new MockConnection(() => transportProducer.Object); var retryPolicy = new EventHubProducerClientOptions().RetryOptions.ToRetryPolicy(); @@ -1196,7 +2254,7 @@ public void RetryLogicShouldNotStartWhenCancellationTriggered() public void RetryLogicShouldNotStartWhenCancellationTriggeredWithABatch() { var batchOptions = new CreateBatchOptions { PartitionId = "0" }; - var batch = new EventDataBatch(new MockTransportBatch(), "ns", "eh", batchOptions); + var batch = new EventDataBatch(new MockTransportBatch(1), "ns", "eh", batchOptions); var transportProducer = new Mock(); var eventHubConnection = new MockConnection(() => transportProducer.Object); var retryPolicy = new EventHubProducerClientOptions().RetryOptions.ToRetryPolicy(); @@ -1206,7 +2264,6 @@ public void RetryLogicShouldNotStartWhenCancellationTriggeredWithABatch() var cancellationTokenSource = new CancellationTokenSource(); cancellationTokenSource.Cancel(); - Assert.That(async () => await producerClient.SendAsync(batch, cancellationTokenSource.Token), Throws.InstanceOf()); transportProducer.Verify(t => t.SendAsync(It.IsAny(), @@ -1225,7 +2282,7 @@ public void RetryLogicShouldNotStartWhenCancellationTriggeredWithABatch() public void RetryLogicDetectsAnEmbeddedAmqpErrorForOperationCanceled() { var options = new SendEventOptions { PartitionId = "0" }; - var events = new EventData[0]; + var events = new[] { new EventData(Array.Empty()) }; var transportProducer = new Mock(); var eventHubConnection = new MockConnection(() => transportProducer.Object); var retryPolicy = new EventHubProducerClientOptions().RetryOptions.ToRetryPolicy(); @@ -1251,7 +2308,7 @@ public void RetryLogicDetectsAnEmbeddedAmqpErrorForOperationCanceled() public void RetryLogicDetectsAnEmbeddedAmqpErrorForOperationCanceledWithABatch() { var batchOptions = new CreateBatchOptions { PartitionId = "0" }; - var batch = new EventDataBatch(new MockTransportBatch(), "ns", "eh", batchOptions); + var batch = new EventDataBatch(new MockTransportBatch(1), "ns", "eh", batchOptions); var transportProducer = new Mock(); var eventHubConnection = new MockConnection(() => transportProducer.Object); var retryPolicy = new EventHubProducerClientOptions().RetryOptions.ToRetryPolicy(); @@ -1288,6 +2345,16 @@ private static EventHubsRetryPolicy GetRetryPolicy(EventHubProducerClient produc .GetProperty("RetryPolicy", BindingFlags.Instance | BindingFlags.NonPublic) .GetValue(producer); + /// + /// Retrieves the PartitionState for the producer using its private accessor. + /// + /// + private static ConcurrentDictionary GetPartitionState(EventHubProducerClient producer) => + (ConcurrentDictionary) + typeof(EventHubProducerClient) + .GetProperty("PartitionState", BindingFlags.Instance | BindingFlags.NonPublic) + .GetValue(producer); + /// /// Sets property using its protected accessor. /// @@ -1336,6 +2403,8 @@ public override ValueTask CreateBatchAsync(CreateBatchOptio return new ValueTask(Task.FromResult((TransportEventBatch)new MockTransportBatch())); } + public override ValueTask ReadInitializationPublishingPropertiesAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); + public override Task CloseAsync(CancellationToken cancellationToken) { WasCloseCalled = true; @@ -1353,23 +2422,36 @@ private class MockConnection : EventHubConnection public EventHubsRetryPolicy GetPropertiesInvokedWith = null; public EventHubsRetryPolicy GetPartitionIdsInvokedWith = null; public EventHubsRetryPolicy GetPartitionPropertiesInvokedWith = null; - public Func TransportProducerFactory = () => Mock.Of(); public Mock InnerClientMock = null; - public bool WasClosed = false; + public Func TransportProducerFactory = + (partition, features, options, retry) => Mock.Of(); + + public MockConnection(string namespaceName = "fakeNamespace", string eventHubName = "fakeEventHub") : base(namespaceName, eventHubName, new Mock(Mock.Of(), "{namespace}.servicebus.windows.net").Object) { } - public MockConnection(Func transportProducerFactory, + public MockConnection(Func transportProducerFactory, string namespaceName, string eventHubName) : this(namespaceName, eventHubName) { TransportProducerFactory = transportProducerFactory; } + public MockConnection(Func transportProducerFactory, + string namespaceName, + string eventHubName) : this((partition, features, options, retry) => transportProducerFactory(), namespaceName, eventHubName) + { + } + + public MockConnection(Func transportProducerFactory) + : this(transportProducerFactory, "fakeNamespace", "fakeEventHub") + { + } + public MockConnection(Func transportProducerFactory) : this(transportProducerFactory, "fakeNamespace", "fakeEventHub") { } @@ -1397,7 +2479,9 @@ internal override Task GetPartitionPropertiesAsync(string p } internal override TransportProducer CreateTransportProducer(string partitionId, - EventHubsRetryPolicy retryPolicy) => TransportProducerFactory(); + TransportProducerFeatures requestedFeatures, + PartitionPublishingOptions partitionOptions, + EventHubsRetryPolicy retryPolicy) => TransportProducerFactory(partitionId, requestedFeatures, partitionOptions, retryPolicy); internal override TransportClient CreateTransportClient(string fullyQualifiedNamespace, string eventHubName, EventHubTokenCredential credential, @@ -1429,14 +2513,29 @@ public override Task CloseAsync(CancellationToken cancellationToken = default) /// private class MockTransportBatch : TransportEventBatch { + private List Events { get; } = new List(); + public override long MaximumSizeInBytes { get; } public override long SizeInBytes { get; } public override int? StartingPublishedSequenceNumber { get; set; } - public override int Count { get; } - public override bool TryAdd(EventData eventData) => throw new NotImplementedException(); - public override IEnumerable AsEnumerable() => throw new NotImplementedException(); - public override void Clear() => throw new NotImplementedException(); + public override int Count => Events.Count; + public override IEnumerable AsEnumerable() => (IEnumerable)Events; + public override void Clear() => Events.Clear(); public override void Dispose() => throw new NotImplementedException(); + + public MockTransportBatch() + { + } + public MockTransportBatch(int generatedEventCount) + { + Events.AddRange(EventGenerator.CreateEvents(generatedEventCount)); + } + + public override bool TryAdd(EventData eventData) + { + Events.Add(eventData); + return true; + } } /// @@ -1454,7 +2553,6 @@ public MockPooledProducer(TransportProducer transportProducer): base(transportPr public override ValueTask DisposeAsync() { WasClosed = true; - return new ValueTask(Task.CompletedTask); } } @@ -1473,7 +2571,7 @@ public MockTransportProducerPool(TransportProducer transportProducer, EventHubConnection connection, EventHubsRetryPolicy retryPolicy, ConcurrentDictionary pool = default, - TimeSpan? expirationInterval = default): base(connection, retryPolicy, pool, expirationInterval) + TimeSpan? expirationInterval = default) : base(partition => connection.CreateTransportProducer(partition, TransportProducerFeatures.None, null, retryPolicy), pool, expirationInterval) { MockPooledProducer = new MockPooledProducer(transportProducer); } @@ -1482,7 +2580,6 @@ public override PooledProducer GetPooledProducer(string partitionId, TimeSpan? removeAfterDuration = default) { GetPooledProducerWasCalled = true; - return MockPooledProducer; } } diff --git a/sdk/eventhub/Azure.Messaging.EventHubs/tests/Producer/TransportProducerPoolTests.cs b/sdk/eventhub/Azure.Messaging.EventHubs/tests/Producer/TransportProducerPoolTests.cs index df0e8481e28b1..78c05281b783f 100644 --- a/sdk/eventhub/Azure.Messaging.EventHubs/tests/Producer/TransportProducerPoolTests.cs +++ b/sdk/eventhub/Azure.Messaging.EventHubs/tests/Producer/TransportProducerPoolTests.cs @@ -41,7 +41,7 @@ public void TransportProducerPoolRemovesExpiredItems() ["1"] = new TransportProducerPool.PoolItem("0", transportProducer), ["2"] = new TransportProducerPool.PoolItem("0", transportProducer), }; - TransportProducerPool transportProducerPool = new TransportProducerPool(connection, retryPolicy, startingPool); + TransportProducerPool transportProducerPool = new TransportProducerPool(partition => connection.CreateTransportProducer(partition, TransportProducerFeatures.None, null, retryPolicy), startingPool); GetExpirationCallBack(transportProducerPool).Invoke(null); @@ -68,7 +68,7 @@ public void TransportProducerPoolRefreshesAccessedItems() // An expired item in the pool ["0"] = new TransportProducerPool.PoolItem("0", transportProducer, removeAfter: oneMinuteAgo) }; - TransportProducerPool transportProducerPool = new TransportProducerPool(connection, retryPolicy, startingPool); + TransportProducerPool transportProducerPool = new TransportProducerPool(partition => connection.CreateTransportProducer(partition, TransportProducerFeatures.None, null, retryPolicy), startingPool); // This call should refresh the timespan associated to the item _ = transportProducerPool.GetPooledProducer("0"); @@ -94,7 +94,7 @@ public async Task PoolItemsAreRefreshedOnDisposal() }; var connection = new MockConnection(() => transportProducer); var retryPolicy = new EventHubProducerClientOptions().RetryOptions.ToRetryPolicy(); - TransportProducerPool transportProducerPool = new TransportProducerPool(connection, retryPolicy); + TransportProducerPool transportProducerPool = new TransportProducerPool(partition => connection.CreateTransportProducer(partition, TransportProducerFeatures.None, null, retryPolicy)); var expectedTime = DateTimeOffset.UtcNow.AddMinutes(10); await using var pooledProducer = transportProducerPool.GetPooledProducer("0"); @@ -121,7 +121,7 @@ public async Task TransportProducerPoolTracksAProducerUsage() // An expired item in the pool ["0"] = new TransportProducerPool.PoolItem("0", transportProducer, removeAfter: oneMinuteAgo) }; - TransportProducerPool transportProducerPool = new TransportProducerPool(connection, retryPolicy, startingPool); + TransportProducerPool transportProducerPool = new TransportProducerPool(partition => connection.CreateTransportProducer(partition, TransportProducerFeatures.None, null, retryPolicy), startingPool); var pooledProducer = transportProducerPool.GetPooledProducer("0"); startingPool.TryGetValue("0", out var poolItem); @@ -148,7 +148,7 @@ public async Task TransportProducerPoolAllowsConfiguringRemoveAfter() { ["0"] = new TransportProducerPool.PoolItem("0", transportProducer) }; - TransportProducerPool transportProducerPool = new TransportProducerPool(connection, retryPolicy, startingPool); + TransportProducerPool transportProducerPool = new TransportProducerPool(partition => connection.CreateTransportProducer(partition, TransportProducerFeatures.None, null, retryPolicy), startingPool); var pooledProducer = transportProducerPool.GetPooledProducer("0", TimeSpan.FromMinutes(-1)); @@ -179,7 +179,7 @@ public void TransportProducerPoolAllowsTakingTheRightTransportProducer(string pa { ["0"] = new TransportProducerPool.PoolItem("0", partitionProducer) }; - TransportProducerPool transportProducerPool = new TransportProducerPool(connection, retryPolicy, eventHubProducer: transportProducer); + TransportProducerPool transportProducerPool = new TransportProducerPool(partition => connection.CreateTransportProducer(partition, TransportProducerFeatures.None, null, retryPolicy), eventHubProducer: transportProducer); var returnedProducer = transportProducerPool.GetPooledProducer(partitionId).TransportProducer as ObservableTransportProducerMock; @@ -202,7 +202,7 @@ public void CloseAsyncSurfacesExceptionsForTransportProducer() { ["0"] = new TransportProducerPool.PoolItem("0", partitionProducer.Object) }; - TransportProducerPool transportProducerPool = new TransportProducerPool(connection, retryPolicy, eventHubProducer: transportProducer.Object); + TransportProducerPool transportProducerPool = new TransportProducerPool(partition => connection.CreateTransportProducer(partition, TransportProducerFeatures.None, null, retryPolicy), eventHubProducer: transportProducer.Object); transportProducer .Setup(producer => producer.CloseAsync(It.IsAny())) @@ -229,7 +229,7 @@ public void CloseAsyncSurfacesExceptionsForPartitionTransportProducer() { ["0"] = new TransportProducerPool.PoolItem("0", partitionProducer.Object) }; - TransportProducerPool transportProducerPool = new TransportProducerPool(connection, retryPolicy, eventHubProducer: transportProducer.Object); + TransportProducerPool transportProducerPool = new TransportProducerPool(partition => connection.CreateTransportProducer(partition, TransportProducerFeatures.None, null, retryPolicy), eventHubProducer: transportProducer.Object); partitionProducer .Setup(producer => producer.CloseAsync(It.IsAny())) @@ -302,6 +302,8 @@ internal override Task GetPartitionPropertiesAsync(string p } internal override TransportProducer CreateTransportProducer(string partitionId, + TransportProducerFeatures requestedFeatures, + PartitionPublishingOptions partitionOptions, EventHubsRetryPolicy retryPolicy) => TransportProducerFactory(); internal override TransportClient CreateTransportClient(string fullyQualifiedNamespace, @@ -359,6 +361,8 @@ public override ValueTask CreateBatchAsync(CreateBatchOptio return new ValueTask(Task.FromResult((TransportEventBatch)new MockTransportBatch())); } + public override ValueTask ReadInitializationPublishingPropertiesAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); + public override Task CloseAsync(CancellationToken cancellationToken) { WasCloseCalled = true;