diff --git a/eng/.docsettings.yml b/eng/.docsettings.yml index 4165f16ae065..50cd77e7f7c4 100644 --- a/eng/.docsettings.yml +++ b/eng/.docsettings.yml @@ -168,6 +168,7 @@ known_content_issues: - ['sdk/core/Microsoft.Azure.Core.NewtonsoftJson/README.md', '#15423'] - ['sdk/core/Microsoft.Azure.Core.Spatial/README.md', '#15423'] - ['sdk/core/Microsoft.Azure.Core.Spatial.NewtonsoftJson/README.md', '#15423'] + - ['sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/README.md', '#15423'] # .net climbs upwards. placing these to prevent assigning readmes to the wrong project package_indexing_exclusion_list: diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/CHANGELOG.md b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/CHANGELOG.md new file mode 100644 index 000000000000..0620c48c0f79 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/CHANGELOG.md @@ -0,0 +1,5 @@ +# Release History + +## 5.0.0-beta.1 (Unreleased) + +- The initial release of Microsoft.Azure.WebJobs.Extensions.ServiceBus 5.0.0 diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/Directory.Build.props b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/Directory.Build.props new file mode 100644 index 000000000000..805ca8beaf23 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/Directory.Build.props @@ -0,0 +1,7 @@ + + + true + + + + diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/Microsoft.Azure.WebJobs.Extensions.ServiceBus.sln b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/Microsoft.Azure.WebJobs.Extensions.ServiceBus.sln new file mode 100644 index 000000000000..62ef554f02dd --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/Microsoft.Azure.WebJobs.Extensions.ServiceBus.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30804.86 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Azure.WebJobs.Extensions.ServiceBus", "src\Microsoft.Azure.WebJobs.Extensions.ServiceBus.csproj", "{2AC0E7B8-38C9-4B94-87B2-2FDDFA278464}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Azure.WebJobs.Extensions.ServiceBus.Tests", "tests\Microsoft.Azure.WebJobs.Extensions.ServiceBus.Tests.csproj", "{564B3907-57C9-456A-9C7B-C6D876B24479}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2AC0E7B8-38C9-4B94-87B2-2FDDFA278464}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2AC0E7B8-38C9-4B94-87B2-2FDDFA278464}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2AC0E7B8-38C9-4B94-87B2-2FDDFA278464}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2AC0E7B8-38C9-4B94-87B2-2FDDFA278464}.Release|Any CPU.Build.0 = Release|Any CPU + {564B3907-57C9-456A-9C7B-C6D876B24479}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {564B3907-57C9-456A-9C7B-C6D876B24479}.Debug|Any CPU.Build.0 = Debug|Any CPU + {564B3907-57C9-456A-9C7B-C6D876B24479}.Release|Any CPU.ActiveCfg = Release|Any CPU + {564B3907-57C9-456A-9C7B-C6D876B24479}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {371BFA14-0980-4A43-A18A-CA1C1A9CB784} + EndGlobalSection +EndGlobal diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/README.md b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/README.md new file mode 100644 index 000000000000..bfeb94ebe89b --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/README.md @@ -0,0 +1,56 @@ +# Azure WebJobs Service Bus client library for .NET + +This extension provides functionality for accessing Azure Service Bus from an Azure Function. + +## Getting started + +### Install the package + +### Prerequisites + + +### Authenticate the Client + +#### Managed identity authentication + + +## Key concepts + + +## Examples + + +## Troubleshooting + + +## Next steps + + +## Contributing + +See our [CONTRIBUTING.md][contrib] for details on building, +testing, and contributing to this library. + +This project welcomes contributions and suggestions. Most contributions require +you to agree to a Contributor License Agreement (CLA) declaring that you have +the right to, and actually do, grant us the rights to use your contribution. For +details, visit [cla.microsoft.com][cla]. + +This project has adopted the [Microsoft Open Source Code of Conduct][coc]. +For more information see the [Code of Conduct FAQ][coc_faq] +or contact [opencode@microsoft.com][coc_contact] with any +additional questions or comments. + +![Impressions](https://azure-sdk-impressions.azurewebsites.net/api/impressions/azure-sdk-for-net%2Fsdk%2Fservicebus%2FMicrosoft.Azure.WebJobs.Extensions.ServiceBus%2FREADME.png) + + +[source]: https://github.com/Azure/azure-sdk-for-net/tree/master/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src +[package]: https://www.nuget.org/packages/Microsoft.Azure.WebJobs.Extensions.ServiceBus/ +[docs]: https://docs.microsoft.com/dotnet/api/Microsoft.Azure.WebJobs.Extensions.ServiceBus +[nuget]: https://www.nuget.org/ + +[contrib]: https://github.com/Azure/azure-sdk-for-net/tree/master/CONTRIBUTING.md +[cla]: https://cla.microsoft.com +[coc]: https://opensource.microsoft.com/codeofconduct/ +[coc_faq]: https://opensource.microsoft.com/codeofconduct/faq/ +[coc_contact]: mailto:opencode@microsoft.com diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/AsyncCollectorArgumentBindingProvider.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/AsyncCollectorArgumentBindingProvider.cs new file mode 100644 index 000000000000..36dea113e955 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/AsyncCollectorArgumentBindingProvider.cs @@ -0,0 +1,83 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Azure.WebJobs.Host.Converters; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class AsyncCollectorArgumentBindingProvider : IQueueArgumentBindingProvider + { + public IArgumentBinding TryCreate(ParameterInfo parameter) + { + Type parameterType = parameter.ParameterType; + + if (!parameterType.IsGenericType) + { + return null; + } + + Type genericTypeDefinition = parameterType.GetGenericTypeDefinition(); + + if (genericTypeDefinition != typeof(IAsyncCollector<>)) + { + return null; + } + + Type itemType = parameterType.GetGenericArguments()[0]; + return CreateBinding(itemType); + } + + private static IArgumentBinding CreateBinding(Type itemType) + { + MethodInfo method = typeof(AsyncCollectorArgumentBindingProvider).GetMethod("CreateBindingGeneric", + BindingFlags.NonPublic | BindingFlags.Static); + Debug.Assert(method != null); + MethodInfo genericMethod = method.MakeGenericMethod(itemType); + Debug.Assert(genericMethod != null); + Func> lambda = + (Func>)Delegate.CreateDelegate( + typeof(Func>), genericMethod); + return lambda.Invoke(); + } + + private static IArgumentBinding CreateBindingGeneric() + { + return new AsyncCollectorArgumentBinding(MessageConverterFactory.Create()); + } + + private class AsyncCollectorArgumentBinding : IArgumentBinding + { + private readonly IConverter _converter; + + public AsyncCollectorArgumentBinding(IConverter converter) + { + _converter = converter; + } + + public Type ValueType + { + get { return typeof(IAsyncCollector); } + } + + public Task BindAsync(ServiceBusEntity value, ValueBindingContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + IAsyncCollector collector = new MessageSenderAsyncCollector(value, _converter, + context.FunctionInstanceId); + IValueProvider provider = new CollectorValueProvider(value, collector, typeof(IAsyncCollector)); + + return Task.FromResult(provider); + } + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/BindableServiceBusPath.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/BindableServiceBusPath.cs new file mode 100644 index 000000000000..311502f69a0b --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/BindableServiceBusPath.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Azure.WebJobs.Host.Bindings.Path; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + /// + /// Utility class with factory method to create an instance of a strategy class implementing interface. + /// + internal static class BindableServiceBusPath + { + /// + /// A factory method detecting parameters in supplied queue or topic name pattern and creating + /// an instance of relevant strategy class implementing . + /// + /// Service Bus queue or topic name pattern containing optional binding parameters. + /// An object implementing + public static IBindableServiceBusPath Create(string queueOrTopicNamePattern) + { + if (queueOrTopicNamePattern == null) + { + throw new ArgumentNullException(nameof(queueOrTopicNamePattern)); + } + + BindingTemplate template = BindingTemplate.FromString(queueOrTopicNamePattern); + + if (template.ParameterNames.Any()) + { + return new ParameterizedServiceBusPath(template); + } + + return new BoundServiceBusPath(queueOrTopicNamePattern); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/BoundServiceBusPath.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/BoundServiceBusPath.cs new file mode 100644 index 000000000000..56dfc28ea283 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/BoundServiceBusPath.cs @@ -0,0 +1,42 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + /// + /// Bindable queue or topic path strategy implementation for "degenerate" bindable patterns, + /// i.e. containing no parameters. + /// + internal class BoundServiceBusPath : IBindableServiceBusPath + { + private readonly string _queueOrTopicNamePattern; + + public BoundServiceBusPath(string queueOrTopicNamePattern) + { + _queueOrTopicNamePattern = queueOrTopicNamePattern; + } + + public string QueueOrTopicNamePattern + { + get { return _queueOrTopicNamePattern; } + } + + public bool IsBound + { + get { return true; } + } + + public IEnumerable ParameterNames + { + get { return Enumerable.Empty(); } + } + + public string Bind(IReadOnlyDictionary bindingData) + { + return QueueOrTopicNamePattern; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ByteArrayArgumentBindingProvider.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ByteArrayArgumentBindingProvider.cs new file mode 100644 index 000000000000..dc5b25c96521 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ByteArrayArgumentBindingProvider.cs @@ -0,0 +1,64 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Bindings; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class ByteArrayArgumentBindingProvider : IQueueArgumentBindingProvider + { + public IArgumentBinding TryCreate(ParameterInfo parameter) + { + if (!parameter.IsOut || parameter.ParameterType != typeof(byte[]).MakeByRefType()) + { + return null; + } + + return new ByteArrayArgumentBinding(); + } + + private class ByteArrayArgumentBinding : IArgumentBinding + { + public Type ValueType + { + get { return typeof(byte[]); } + } + + /// + /// The out byte array parameter is processed as follows: + /// + /// + /// + /// If the value is , no message will be sent. + /// + /// + /// + /// + /// If the value is an empty byte array, a message with empty content will be sent. + /// + /// + /// + /// + /// If the value is a non-empty byte array, a message with that content will be sent. + /// + /// + /// + /// + public Task BindAsync(ServiceBusEntity value, ValueBindingContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + IValueProvider provider = new NonNullConverterValueBinder(value, + new ByteArrayToBrokeredMessageConverter(), context.FunctionInstanceId); + + return Task.FromResult(provider); + } + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ByteArrayToBrokeredMessageConverter.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ByteArrayToBrokeredMessageConverter.cs new file mode 100644 index 000000000000..205edcff8ab4 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ByteArrayToBrokeredMessageConverter.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Azure.ServiceBus; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class ByteArrayToBrokeredMessageConverter : IConverter + { + [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope")] + public Message Convert(byte[] input) + { + if (input == null) + { + throw new InvalidOperationException("A brokered message cannot contain a null byte array instance."); + } + + return new Message(input) + { + ContentType = ContentTypes.ApplicationOctetStream + }; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/CollectorArgumentBindingProvider.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/CollectorArgumentBindingProvider.cs new file mode 100644 index 000000000000..05f3da39235f --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/CollectorArgumentBindingProvider.cs @@ -0,0 +1,83 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Azure.WebJobs.Host.Converters; +using Microsoft.Azure.ServiceBus; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class CollectorArgumentBindingProvider : IQueueArgumentBindingProvider + { + public IArgumentBinding TryCreate(ParameterInfo parameter) + { + Type parameterType = parameter.ParameterType; + + if (!parameterType.IsGenericType) + { + return null; + } + + Type genericTypeDefinition = parameterType.GetGenericTypeDefinition(); + + if (genericTypeDefinition != typeof(ICollector<>)) + { + return null; + } + + Type itemType = parameterType.GetGenericArguments()[0]; + return CreateBinding(itemType); + } + + private static IArgumentBinding CreateBinding(Type itemType) + { + MethodInfo method = typeof(CollectorArgumentBindingProvider).GetMethod("CreateBindingGeneric", + BindingFlags.NonPublic | BindingFlags.Static); + Debug.Assert(method != null); + MethodInfo genericMethod = method.MakeGenericMethod(itemType); + Debug.Assert(genericMethod != null); + Func> lambda = + (Func>)Delegate.CreateDelegate( + typeof(Func>), genericMethod); + return lambda.Invoke(); + } + + private static IArgumentBinding CreateBindingGeneric() + { + return new CollectorArgumentBinding(MessageConverterFactory.Create()); + } + + private class CollectorArgumentBinding : IArgumentBinding + { + private readonly IConverter _converter; + + public CollectorArgumentBinding(IConverter converter) + { + _converter = converter; + } + + public Type ValueType + { + get { return typeof(ICollector); } + } + + public Task BindAsync(ServiceBusEntity value, ValueBindingContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + ICollector collector = new MessageSenderCollector(value, _converter, + context.FunctionInstanceId); + IValueProvider provider = new CollectorValueProvider(value, collector, typeof(ICollector)); + + return Task.FromResult(provider); + } + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/CollectorValueProvider.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/CollectorValueProvider.cs new file mode 100644 index 000000000000..9d6beee23ff9 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/CollectorValueProvider.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Bindings; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class CollectorValueProvider : IValueProvider + { + private readonly ServiceBusEntity _entity; + private readonly object _value; + private readonly Type _valueType; + + public CollectorValueProvider(ServiceBusEntity entity, object value, Type valueType) + { + if (value != null && !valueType.IsAssignableFrom(value.GetType())) + { + throw new InvalidOperationException("value is not of the correct type."); + } + + _entity = entity; + _value = value; + _valueType = valueType; + } + + public Type Type + { + get { return _valueType; } + } + + public Task GetValueAsync() + { + return Task.FromResult(_value); + } + + public string ToInvokeString() + { + return _entity.MessageSender.Path; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/CompositeArgumentBindingProvider.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/CompositeArgumentBindingProvider.cs new file mode 100644 index 000000000000..2cb2015450a6 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/CompositeArgumentBindingProvider.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Reflection; +using Microsoft.Azure.WebJobs.Host.Bindings; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class CompositeArgumentBindingProvider : IQueueArgumentBindingProvider + { + private readonly IEnumerable _providers; + + public CompositeArgumentBindingProvider(params IQueueArgumentBindingProvider[] providers) + { + _providers = providers; + } + + public IArgumentBinding TryCreate(ParameterInfo parameter) + { + foreach (IQueueArgumentBindingProvider provider in _providers) + { + IArgumentBinding binding = provider.TryCreate(parameter); + + if (binding != null) + { + return binding; + } + } + + return null; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ConverterValueBinder.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ConverterValueBinder.cs new file mode 100644 index 000000000000..b608eef67690 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ConverterValueBinder.cs @@ -0,0 +1,54 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.WebJobs.Host.Bindings; +using System.Diagnostics; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class ConverterValueBinder : IOrderedValueBinder + { + private readonly ServiceBusEntity _entity; + private readonly IConverter _converter; + private readonly Guid _functionInstanceId; + + public ConverterValueBinder(ServiceBusEntity entity, IConverter converter, + Guid functionInstanceId) + { + _entity = entity; + _converter = converter; + _functionInstanceId = functionInstanceId; + } + + public BindStepOrder StepOrder + { + get { return BindStepOrder.Enqueue; } + } + + public Type Type + { + get { return typeof(TInput); } + } + + public Task GetValueAsync() + { + return Task.FromResult(default(TInput)); + } + + public string ToInvokeString() + { + return _entity.MessageSender.Path; + } + + public Task SetValueAsync(object value, CancellationToken cancellationToken) + { + Message message = _converter.Convert((TInput)value); + Debug.Assert(message != null); + return _entity.SendAndCreateEntityIfNotExistsAsync(message, _functionInstanceId, cancellationToken); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/IBindableServiceBusPath.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/IBindableServiceBusPath.cs new file mode 100644 index 000000000000..46037a4d75d3 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/IBindableServiceBusPath.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal interface IBindableServiceBusPath + { + string QueueOrTopicNamePattern { get; } + + /// + /// Gets a value indicating whether this path is bound. + /// + bool IsBound { get; } + + /// + /// Gets the collection of parameter names for the path. + /// + IEnumerable ParameterNames { get; } + + /// + /// Bind to the path. + /// + /// The binding data. + /// The path binding. + string Bind(IReadOnlyDictionary bindingData); + + /// + /// Gets a string representation of the path. + /// + /// + string ToString(); + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/IQueueArgumentBindingProvider.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/IQueueArgumentBindingProvider.cs new file mode 100644 index 000000000000..52c4e2a2e1e0 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/IQueueArgumentBindingProvider.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Reflection; +using Microsoft.Azure.WebJobs.Host.Bindings; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal interface IQueueArgumentBindingProvider + { + IArgumentBinding TryCreate(ParameterInfo parameter); + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageArgumentBinding.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageArgumentBinding.cs new file mode 100644 index 000000000000..3a324a336014 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageArgumentBinding.cs @@ -0,0 +1,100 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.WebJobs.Host.Bindings; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class MessageArgumentBinding : IArgumentBinding + { + public Type ValueType + { + get { return typeof(Message); } + } + + public Task BindAsync(ServiceBusEntity value, ValueBindingContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + IValueProvider provider = new MessageValueBinder(value, context.FunctionInstanceId); + + return Task.FromResult(provider); + } + + private class MessageValueBinder : IOrderedValueBinder + { + private readonly ServiceBusEntity _entity; + private readonly Guid _functionInstanceId; + + public MessageValueBinder(ServiceBusEntity entity, Guid functionInstanceId) + { + _entity = entity; + _functionInstanceId = functionInstanceId; + } + + public BindStepOrder StepOrder + { + get { return BindStepOrder.Enqueue; } + } + + public Type Type + { + get { return typeof(Message); } + } + + public Task GetValueAsync() + { + return Task.FromResult(null); + } + + public string ToInvokeString() + { + return _entity.MessageSender.Path; + } + + /// + /// Sends a Message to the bound queue. + /// + /// BrokeredMessage instance as retrieved from user's WebJobs method argument. + /// a cancellation token + /// + /// The out message parameter is processed as follows: + /// + /// + /// + /// If the value is , no message will be sent. + /// + /// + /// + /// + /// If the value has empty content, a message with empty content will be sent. + /// + /// + /// + /// + /// If the value has non-empty content, a message with that content will be sent. + /// + /// + /// + /// + public async Task SetValueAsync(object value, CancellationToken cancellationToken) + { + if (value == null) + { + return; + } + + var message = (Message)value; + + await _entity.SendAndCreateEntityIfNotExistsAsync(message, _functionInstanceId, cancellationToken).ConfigureAwait(false); + } + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageArgumentBindingProvider.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageArgumentBindingProvider.cs new file mode 100644 index 000000000000..aaded5bfc760 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageArgumentBindingProvider.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Reflection; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.WebJobs.Host.Bindings; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class MessageArgumentBindingProvider : IQueueArgumentBindingProvider + { + public IArgumentBinding TryCreate(ParameterInfo parameter) + { + if (!parameter.IsOut || parameter.ParameterType != typeof(Message).MakeByRefType()) + { + return null; + } + + return new MessageArgumentBinding(); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageConverterFactory.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageConverterFactory.cs new file mode 100644 index 000000000000..5bf44aef262c --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageConverterFactory.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections; +using Microsoft.Azure.WebJobs.Host.Converters; +using Microsoft.Azure.ServiceBus; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal static class MessageConverterFactory + { + internal static IConverter Create() + { + if (typeof(TInput) == typeof(Message)) + { + return (IConverter)new IdentityConverter(); + } + else if (typeof(TInput) == typeof(string)) + { + return (IConverter)new StringToBrokeredMessageConverter(); + } + else if (typeof(TInput) == typeof(byte[])) + { + return (IConverter)new ByteArrayToBrokeredMessageConverter(); + } + else + { + if (typeof(TInput).IsPrimitive) + { + throw new NotSupportedException("Primitive types are not supported."); + } + + if (typeof(IEnumerable).IsAssignableFrom(typeof(TInput))) + { + throw new InvalidOperationException("Nested collections are not supported."); + } + + return new UserTypeToBrokeredMessageConverter(); + } + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageSenderArgumentBindingProvider.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageSenderArgumentBindingProvider.cs new file mode 100644 index 000000000000..9af76cd3baa1 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageSenderArgumentBindingProvider.cs @@ -0,0 +1,80 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus.Core; +using Microsoft.Azure.WebJobs.Host.Bindings; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class MessageSenderArgumentBindingProvider : IQueueArgumentBindingProvider + { + public IArgumentBinding TryCreate(ParameterInfo parameter) + { + if (parameter.ParameterType != typeof(MessageSender)) + { + return null; + } + + return new MessageSenderArgumentBinding(); + } + + internal class MessageSenderArgumentBinding : IArgumentBinding + { + public Type ValueType + { + get { return typeof(MessageSender); } + } + + public Task BindAsync(ServiceBusEntity value, ValueBindingContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + IValueProvider provider = new MessageSenderValueBinder(value.MessageSender); + + return Task.FromResult(provider); + } + + private class MessageSenderValueBinder : IValueBinder + { + private readonly MessageSender _messageSender; + + public MessageSenderValueBinder(MessageSender messageSender) + { + _messageSender = messageSender; + } + + public static BindStepOrder StepOrder + { + get { return BindStepOrder.Enqueue; } + } + + public Type Type + { + get { return typeof(MessageSender); } + } + + public Task GetValueAsync() + { + return Task.FromResult(_messageSender); + } + + public string ToInvokeString() + { + return _messageSender.Path; + } + + public Task SetValueAsync(object value, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageSenderAsyncCollector.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageSenderAsyncCollector.cs new file mode 100644 index 000000000000..71e767a36da1 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageSenderAsyncCollector.cs @@ -0,0 +1,53 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class MessageSenderAsyncCollector : IAsyncCollector + { + private readonly ServiceBusEntity _entity; + private readonly IConverter _converter; + private readonly Guid _functionInstanceId; + + public MessageSenderAsyncCollector(ServiceBusEntity entity, IConverter converter, + Guid functionInstanceId) + { + if (entity == null) + { + throw new ArgumentNullException(nameof(entity)); + } + + if (converter == null) + { + throw new ArgumentNullException(nameof(converter)); + } + + _entity = entity; + _converter = converter; + _functionInstanceId = functionInstanceId; + } + + public Task AddAsync(T item, CancellationToken cancellationToken) + { + Message message = _converter.Convert(item); + + if (message == null) + { + throw new InvalidOperationException("Cannot enqueue a null brokered message instance."); + } + + return _entity.SendAndCreateEntityIfNotExistsAsync(message, _functionInstanceId, cancellationToken); + } + + public Task FlushAsync(CancellationToken cancellationToken = default(CancellationToken)) + { + // Batching not supported. + return Task.FromResult(0); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageSenderCollector.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageSenderCollector.cs new file mode 100644 index 000000000000..0f9ee61de535 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageSenderCollector.cs @@ -0,0 +1,50 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Threading; +using Microsoft.Azure.WebJobs.Host.Converters; +using Microsoft.Azure.ServiceBus; +using Azure.Core.Pipeline; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class MessageSenderCollector : ICollector + { + private readonly ServiceBusEntity _entity; + private readonly IConverter _converter; + private readonly Guid _functionInstanceId; + + public MessageSenderCollector(ServiceBusEntity entity, IConverter converter, + Guid functionInstanceId) + { + if (entity == null) + { + throw new ArgumentNullException(nameof(entity)); + } + + if (converter == null) + { + throw new ArgumentNullException(nameof(converter)); + } + + _entity = entity; + _converter = converter; + _functionInstanceId = functionInstanceId; + } + + public void Add(T item) + { + Message message = _converter.Convert(item); + + if (message == null) + { + throw new InvalidOperationException("Cannot enqueue a null brokered message instance."); + } +#pragma warning disable AZC0106 + _entity.SendAndCreateEntityIfNotExistsAsync(message, _functionInstanceId, + CancellationToken.None).EnsureCompleted(); +#pragma warning restore AZC0106 + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageSenderExtensions.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageSenderExtensions.cs new file mode 100644 index 000000000000..9e83568a111c --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/MessageSenderExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.ServiceBus.Listeners; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.ServiceBus.Core; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal static class MessageSenderExtensions + { + public static async Task SendAndCreateEntityIfNotExists(this MessageSender sender, Message message, + Guid functionInstanceId, CancellationToken cancellationToken) + { + if (sender == null) + { + throw new ArgumentNullException(nameof(sender)); + } + + ServiceBusCausalityHelper.EncodePayload(functionInstanceId, message); + + cancellationToken.ThrowIfCancellationRequested(); + + await sender.SendAsync(message).ConfigureAwait(false); + return; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/NonNullConverterValueBinder.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/NonNullConverterValueBinder.cs new file mode 100644 index 000000000000..a211c2956883 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/NonNullConverterValueBinder.cs @@ -0,0 +1,62 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.WebJobs.Host.Bindings; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + // Same as ConverterValueBinder, but doesn't enqueue null values. + internal class NonNullConverterValueBinder : IOrderedValueBinder + { + private readonly ServiceBusEntity _entity; + private readonly IConverter _converter; + private readonly Guid _functionInstanceId; + + public NonNullConverterValueBinder(ServiceBusEntity entity, IConverter converter, + Guid functionInstanceId) + { + _entity = entity; + _converter = converter; + _functionInstanceId = functionInstanceId; + } + + public BindStepOrder StepOrder + { + get { return BindStepOrder.Enqueue; } + } + + public Type Type + { + get { return typeof(TInput); } + } + + public Task GetValueAsync() + { + return Task.FromResult(null); + } + + public string ToInvokeString() + { + return _entity.MessageSender.Path; + } + + public Task SetValueAsync(object value, CancellationToken cancellationToken) + { + if (value == null) + { + return Task.FromResult(0); + } + + Debug.Assert(value is TInput); + Message message = _converter.Convert((TInput)value); + Debug.Assert(message != null); + + return _entity.SendAndCreateEntityIfNotExistsAsync(message, _functionInstanceId, cancellationToken); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/OutputConverter.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/OutputConverter.cs new file mode 100644 index 000000000000..2749c2a2b0ea --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/OutputConverter.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Converters; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class OutputConverter : IAsyncObjectToTypeConverter + where TInput : class + { + private readonly IAsyncConverter _innerConverter; + + public OutputConverter(IAsyncConverter innerConverter) + { + _innerConverter = innerConverter; + } + + public async Task> TryConvertAsync(object input, + CancellationToken cancellationToken) + { + TInput typedInput = input as TInput; + + if (typedInput == null) + { + return new ConversionResult + { + Succeeded = false, + Result = null + }; + } + + ServiceBusEntity entity = await _innerConverter.ConvertAsync(typedInput, cancellationToken).ConfigureAwait(false); + + return new ConversionResult + { + Succeeded = true, + Result = entity + }; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ParameterizedServiceBusPath.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ParameterizedServiceBusPath.cs new file mode 100644 index 000000000000..5dea1c9323ed --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ParameterizedServiceBusPath.cs @@ -0,0 +1,55 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Azure.WebJobs.Host.Bindings.Path; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + /// + /// Implementation of strategy for paths + /// containing one or more parameters. + /// + internal class ParameterizedServiceBusPath : IBindableServiceBusPath + { + private readonly BindingTemplate _template; + + public ParameterizedServiceBusPath(BindingTemplate template) + { + Debug.Assert(template != null, "template must not be null"); + Debug.Assert(template.ParameterNames.Any(), "template must contain one or more parameters"); + + _template = template; + } + + public string QueueOrTopicNamePattern + { + get { return _template.Pattern; } + } + + public bool IsBound + { + get { return false; } + } + + public IEnumerable ParameterNames + { + get { return _template.ParameterNames; } + } + + public string Bind(IReadOnlyDictionary bindingData) + { + if (bindingData == null) + { + throw new ArgumentNullException(nameof(bindingData)); + } + + string queueOrTopicName = _template.Bind(bindingData); + return queueOrTopicName; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ServiceBusAttributeBindingProvider.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ServiceBusAttributeBindingProvider.cs new file mode 100644 index 000000000000..8e41243512e0 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ServiceBusAttributeBindingProvider.cs @@ -0,0 +1,119 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class ServiceBusAttributeBindingProvider : IBindingProvider + { + private static readonly IQueueArgumentBindingProvider InnerProvider = + new CompositeArgumentBindingProvider( + new MessageSenderArgumentBindingProvider(), + new MessageArgumentBindingProvider(), + new StringArgumentBindingProvider(), + new ByteArrayArgumentBindingProvider(), + new UserTypeArgumentBindingProvider(), + new CollectorArgumentBindingProvider(), + new AsyncCollectorArgumentBindingProvider()); + + private readonly INameResolver _nameResolver; + private readonly ServiceBusOptions _options; + private readonly IConfiguration _configuration; + private readonly MessagingProvider _messagingProvider; + + public ServiceBusAttributeBindingProvider(INameResolver nameResolver, ServiceBusOptions options, IConfiguration configuration, MessagingProvider messagingProvider) + { + if (nameResolver == null) + { + throw new ArgumentNullException(nameof(nameResolver)); + } + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + if (messagingProvider == null) + { + throw new ArgumentNullException(nameof(messagingProvider)); + } + + _nameResolver = nameResolver; + _options = options; + _configuration = configuration; + _messagingProvider = messagingProvider; + } + + public Task TryCreateAsync(BindingProviderContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + ParameterInfo parameter = context.Parameter; + var attribute = TypeUtility.GetResolvedAttribute(parameter); + + if (attribute == null) + { + return Task.FromResult(null); + } + + string queueOrTopicName = Resolve(attribute.QueueOrTopicName); + IBindableServiceBusPath path = BindableServiceBusPath.Create(queueOrTopicName); + ValidateContractCompatibility(path, context.BindingDataContract); + + IArgumentBinding argumentBinding = InnerProvider.TryCreate(parameter); + if (argumentBinding == null) + { + throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, "Can't bind ServiceBus to type '{0}'.", parameter.ParameterType)); + } + + attribute.Connection = Resolve(attribute.Connection); + ServiceBusAccount account = new ServiceBusAccount(_options, _configuration, attribute); + + IBinding binding = new ServiceBusBinding(parameter.Name, argumentBinding, account, path, attribute, _messagingProvider); + return Task.FromResult(binding); + } + + private static void ValidateContractCompatibility(IBindableServiceBusPath path, IReadOnlyDictionary bindingDataContract) + { + if (path == null) + { + throw new ArgumentNullException(nameof(path)); + } + + IEnumerable parameterNames = path.ParameterNames; + if (parameterNames != null) + { + foreach (string parameterName in parameterNames) + { + if (bindingDataContract != null && !bindingDataContract.ContainsKey(parameterName)) + { + throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, "No binding parameter exists for '{0}'.", parameterName)); + } + } + } + } + + private string Resolve(string queueName) + { + if (_nameResolver == null) + { + return queueName; + } + + return _nameResolver.ResolveWholeString(queueName); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ServiceBusBinding.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ServiceBusBinding.cs new file mode 100644 index 000000000000..42cba0adfef2 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ServiceBusBinding.cs @@ -0,0 +1,101 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus.Core; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Azure.WebJobs.Host.Converters; +using Microsoft.Azure.WebJobs.Host.Protocols; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class ServiceBusBinding : IBinding + { + private readonly string _parameterName; + private readonly IArgumentBinding _argumentBinding; + private readonly ServiceBusAccount _account; + private readonly IBindableServiceBusPath _path; + private readonly IAsyncObjectToTypeConverter _converter; + private readonly EntityType _entityType; + private readonly MessagingProvider _messagingProvider; + + public ServiceBusBinding(string parameterName, IArgumentBinding argumentBinding, ServiceBusAccount account, IBindableServiceBusPath path, ServiceBusAttribute attr, MessagingProvider messagingProvider) + { + _parameterName = parameterName; + _argumentBinding = argumentBinding; + _account = account; + _path = path; + _entityType = attr.EntityType; + _messagingProvider = messagingProvider; + _converter = new OutputConverter(new StringToServiceBusEntityConverter(account, _path, _entityType, _messagingProvider)); + } + + public bool FromAttribute + { + get { return true; } + } + + public async Task BindAsync(BindingContext context) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + string boundQueueName = _path.Bind(context.BindingData); + var messageSender = _messagingProvider.CreateMessageSender(boundQueueName, _account.ConnectionString); + + var entity = new ServiceBusEntity + { + MessageSender = messageSender, + EntityType = _entityType + }; + + return await BindAsync(entity, context.ValueContext).ConfigureAwait(false); + } + + public async Task BindAsync(object value, ValueBindingContext context) + { + ConversionResult conversionResult = await _converter.TryConvertAsync(value, context.CancellationToken).ConfigureAwait(false); + + if (!conversionResult.Succeeded) + { + throw new InvalidOperationException("Unable to convert value to ServiceBusEntity."); + } + + return await BindAsync(conversionResult.Result, context).ConfigureAwait(false); + } + + public ParameterDescriptor ToParameterDescriptor() + { + return new ServiceBusParameterDescriptor + { + Name = _parameterName, + QueueOrTopicName = _path.QueueOrTopicNamePattern, + DisplayHints = CreateParameterDisplayHints(_path.QueueOrTopicNamePattern, false) + }; + } + + private Task BindAsync(ServiceBusEntity value, ValueBindingContext context) + { + return _argumentBinding.BindAsync(value, context); + } + + internal static ParameterDisplayHints CreateParameterDisplayHints(string entityPath, bool isInput) + { + ParameterDisplayHints descriptor = new ParameterDisplayHints + { + Description = isInput ? + string.Format(CultureInfo.CurrentCulture, "dequeue from '{0}'", entityPath) : + string.Format(CultureInfo.CurrentCulture, "enqueue to '{0}'", entityPath), + + Prompt = isInput ? + "Enter the queue message body" : + "Enter the output entity name", + + DefaultValue = isInput ? null : entityPath + }; + + return descriptor; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ServiceBusEntity.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ServiceBusEntity.cs new file mode 100644 index 000000000000..78a6f4c35569 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ServiceBusEntity.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.ServiceBus.Core; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class ServiceBusEntity + { + public MessageSender MessageSender { get; set; } + + public EntityType EntityType { get; set; } = EntityType.Queue; + + public Task SendAndCreateEntityIfNotExistsAsync(Message message, Guid functionInstanceId, CancellationToken cancellationToken) + { + return MessageSender.SendAndCreateEntityIfNotExists(message, functionInstanceId, cancellationToken); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ServiceBusParameterDescriptor.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ServiceBusParameterDescriptor.cs new file mode 100644 index 000000000000..5778e1ab6153 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/ServiceBusParameterDescriptor.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.Azure.WebJobs.Host.Protocols; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class ServiceBusParameterDescriptor : ParameterDescriptor + { + /// Gets or sets the name of the queue or topic. + public string QueueOrTopicName { get; set; } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/StringArgumentBindingProvider.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/StringArgumentBindingProvider.cs new file mode 100644 index 000000000000..ec12dd83401f --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/StringArgumentBindingProvider.cs @@ -0,0 +1,64 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Bindings; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class StringArgumentBindingProvider : IQueueArgumentBindingProvider + { + public IArgumentBinding TryCreate(ParameterInfo parameter) + { + if (!parameter.IsOut || parameter.ParameterType != typeof(string).MakeByRefType()) + { + return null; + } + + return new StringArgumentBinding(); + } + + private class StringArgumentBinding : IArgumentBinding + { + public Type ValueType + { + get { return typeof(string); } + } + + /// + /// The out string parameter is processed as follows: + /// + /// + /// + /// If the value is , no message will be sent. + /// + /// + /// + /// + /// If the value is an empty string, a message with empty content will be sent. + /// + /// + /// + /// + /// If the value is a non-empty string, a message with that content will be sent. + /// + /// + /// + /// + public Task BindAsync(ServiceBusEntity value, ValueBindingContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + IValueProvider provider = new NonNullConverterValueBinder(value, + new StringToBrokeredMessageConverter(), context.FunctionInstanceId); + + return Task.FromResult(provider); + } + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/StringToBrokeredMessageConverter.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/StringToBrokeredMessageConverter.cs new file mode 100644 index 000000000000..f9a5bf91cde4 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/StringToBrokeredMessageConverter.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using Microsoft.Azure.WebJobs.Host.Converters; +using Microsoft.Azure.ServiceBus; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class StringToBrokeredMessageConverter : IConverter + { + [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope")] + public Message Convert(string input) + { + if (input == null) + { + throw new InvalidOperationException("A brokered message cannot contain a null string instance."); + } + + byte[] bytes = StrictEncodings.Utf8.GetBytes(input); + + return new Message(bytes) + { + ContentType = ContentTypes.TextPlain + }; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/StringToServiceBusEntityConverter.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/StringToServiceBusEntityConverter.cs new file mode 100644 index 000000000000..f70b37253e9c --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/StringToServiceBusEntityConverter.cs @@ -0,0 +1,52 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus.Core; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class StringToServiceBusEntityConverter : IAsyncConverter + { + private readonly ServiceBusAccount _account; + private readonly IBindableServiceBusPath _defaultPath; + private readonly EntityType _entityType; + private readonly MessagingProvider _messagingProvider; + + public StringToServiceBusEntityConverter(ServiceBusAccount account, IBindableServiceBusPath defaultPath, EntityType entityType, MessagingProvider messagingProvider) + { + _account = account; + _defaultPath = defaultPath; + _entityType = entityType; + _messagingProvider = messagingProvider; + } + + public Task ConvertAsync(string input, CancellationToken cancellationToken) + { + string queueOrTopicName; + + // For convenience, treat an an empty string as a request for the default value. + if (String.IsNullOrEmpty(input) && _defaultPath.IsBound) + { + queueOrTopicName = _defaultPath.Bind(null); + } + else + { + queueOrTopicName = input; + } + + cancellationToken.ThrowIfCancellationRequested(); + var messageSender = _messagingProvider.CreateMessageSender(queueOrTopicName, _account.ConnectionString); + + var entity = new ServiceBusEntity + { + MessageSender = messageSender, + EntityType = _entityType + }; + + return Task.FromResult(entity); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/UserTypeArgumentBindingProvider.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/UserTypeArgumentBindingProvider.cs new file mode 100644 index 000000000000..abbda2c27147 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/UserTypeArgumentBindingProvider.cs @@ -0,0 +1,65 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Azure.WebJobs.Host.Converters; +using Microsoft.Azure.ServiceBus; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class UserTypeArgumentBindingProvider : IQueueArgumentBindingProvider + { + public IArgumentBinding TryCreate(ParameterInfo parameter) + { + if (!parameter.IsOut) + { + return null; + } + + Type itemType = parameter.ParameterType.GetElementType(); + + if (typeof(IEnumerable).IsAssignableFrom(itemType)) + { + throw new InvalidOperationException("Enumerable types are not supported. Use ICollector or IAsyncCollector instead."); + } + else if (typeof(object) == itemType) + { + throw new InvalidOperationException("Object element types are not supported."); + } + + return CreateBinding(itemType); + } + + private static IArgumentBinding CreateBinding(Type itemType) + { + Type genericType = typeof(UserTypeArgumentBinding<>).MakeGenericType(itemType); + return (IArgumentBinding)Activator.CreateInstance(genericType); + } + + private class UserTypeArgumentBinding : IArgumentBinding + { + public Type ValueType + { + get { return typeof(TInput); } + } + + public Task BindAsync(ServiceBusEntity value, ValueBindingContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + IConverter converter = new UserTypeToBrokeredMessageConverter(); + IValueProvider provider = new ConverterValueBinder(value, converter, + context.FunctionInstanceId); + + return Task.FromResult(provider); + } + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/UserTypeToBrokeredMessageConverter.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/UserTypeToBrokeredMessageConverter.cs new file mode 100644 index 000000000000..12dd537b6c28 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Bindings/UserTypeToBrokeredMessageConverter.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Diagnostics.CodeAnalysis; +using System.IO; +using Microsoft.Azure.WebJobs.Host.Converters; +using Microsoft.Azure.ServiceBus; +using Newtonsoft.Json; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Bindings +{ + internal class UserTypeToBrokeredMessageConverter : IConverter + { + public Message Convert(TInput input) + { + string text = JsonConvert.SerializeObject(input, Constants.JsonSerializerSettings); + byte[] bytes = StrictEncodings.Utf8.GetBytes(text); + + return new Message(bytes) + { + ContentType = ContentTypes.ApplicationJson + }; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Config/BatchOptions.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Config/BatchOptions.cs new file mode 100644 index 000000000000..c23d312d31af --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Config/BatchOptions.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +using System; + +namespace Microsoft.Azure.WebJobs.ServiceBus +{ + /// + /// Configuration options for ServiceBus batch receive. + /// + public class BatchOptions + { + /// + /// The maximum number of messages that will be received. + /// + public int MaxMessageCount { get; set; } + + /// + /// The time span the client waits for receiving a message before it times out. + /// + public TimeSpan OperationTimeout { get; set; } + + /// + /// Gets or sets a value that indicates whether the messages should be completed after successful processing. + /// + public bool AutoComplete { get; set; } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Config/ServiceBusExtensionConfigProvider.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Config/ServiceBusExtensionConfigProvider.cs new file mode 100644 index 000000000000..4f0e5490632e --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Config/ServiceBusExtensionConfigProvider.cs @@ -0,0 +1,126 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.WebJobs.Description; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Azure.WebJobs.Host.Config; +using Microsoft.Azure.WebJobs.Logging; +using Microsoft.Azure.WebJobs.ServiceBus.Bindings; +using Microsoft.Azure.WebJobs.ServiceBus.Triggers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Config +{ + /// + /// Extension configuration provider used to register ServiceBus triggers and binders + /// + [Extension("ServiceBus")] + internal class ServiceBusExtensionConfigProvider : IExtensionConfigProvider + { + private readonly INameResolver _nameResolver; + private readonly IConfiguration _configuration; + private readonly ILoggerFactory _loggerFactory; + private readonly ServiceBusOptions _options; + private readonly MessagingProvider _messagingProvider; + private readonly IConverterManager _converterManager; + + /// + /// Creates a new instance. + /// + ///// The to use./> + public ServiceBusExtensionConfigProvider(IOptions options, + MessagingProvider messagingProvider, + INameResolver nameResolver, + IConfiguration configuration, + ILoggerFactory loggerFactory, + IConverterManager converterManager) + { + _options = options.Value; + _messagingProvider = messagingProvider; + _nameResolver = nameResolver; + _configuration = configuration; + _loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; + _converterManager = converterManager; + } + + /// + /// Gets the + /// + public ServiceBusOptions Options + { + get + { + return _options; + } + } + + /// + public void Initialize(ExtensionConfigContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Set the default exception handler for background exceptions + // coming from MessageReceivers. + Options.ExceptionHandler = (e) => + { + LogExceptionReceivedEvent(e, _loggerFactory); + }; + + context + .AddConverter(new MessageToStringConverter()) + .AddConverter(new MessageToByteArrayConverter()) + .AddOpenConverter(typeof(MessageToPocoConverter<>)); + + // register our trigger binding provider + ServiceBusTriggerAttributeBindingProvider triggerBindingProvider = new ServiceBusTriggerAttributeBindingProvider(_nameResolver, _options, _messagingProvider, _configuration, _loggerFactory, _converterManager); + context.AddBindingRule() + .BindToTrigger(triggerBindingProvider); + + // register our binding provider + ServiceBusAttributeBindingProvider bindingProvider = new ServiceBusAttributeBindingProvider(_nameResolver, _options, _configuration, _messagingProvider); + context.AddBindingRule().Bind(bindingProvider); + } + + internal static void LogExceptionReceivedEvent(ExceptionReceivedEventArgs e, ILoggerFactory loggerFactory) + { + try + { + var ctxt = e.ExceptionReceivedContext; + var logger = loggerFactory?.CreateLogger(LogCategories.Executor); + string message = $"Message processing error (Action={ctxt.Action}, ClientId={ctxt.ClientId}, EntityPath={ctxt.EntityPath}, Endpoint={ctxt.Endpoint})"; + + var logLevel = GetLogLevel(e.Exception); + logger?.Log(logLevel, 0, message, e.Exception, (s, ex) => message); + } + catch (Exception) + { + // best effort logging + } + } + + private static LogLevel GetLogLevel(Exception ex) + { + var sbex = ex as ServiceBusException; + if (!(ex is OperationCanceledException) && (sbex == null || !sbex.IsTransient)) + { + // any non-transient exceptions or unknown exception types + // we want to log as errors + return LogLevel.Error; + } + else + { + // transient messaging errors we log as info so we have a record + // of them, but we don't treat them as actual errors + return LogLevel.Information; + } + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Config/ServiceBusHostBuilderExtensions.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Config/ServiceBusHostBuilderExtensions.cs new file mode 100644 index 000000000000..495122290659 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Config/ServiceBusHostBuilderExtensions.cs @@ -0,0 +1,57 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.ServiceBus; +using Microsoft.Azure.WebJobs.ServiceBus.Config; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.Hosting +{ + public static class ServiceBusHostBuilderExtensions + { + public static IWebJobsBuilder AddServiceBus(this IWebJobsBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.AddServiceBus(p => { }); + + return builder; + } + + public static IWebJobsBuilder AddServiceBus(this IWebJobsBuilder builder, Action configure) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + builder.AddExtension() + .ConfigureOptions((config, path, options) => + { + options.ConnectionString = config.GetConnectionString(Constants.DefaultConnectionStringName) ?? + config[Constants.DefaultConnectionSettingStringName]; + + IConfigurationSection section = config.GetSection(path); + section.Bind(options); + + configure(options); + }); + + builder.Services.TryAddSingleton(); + + return builder; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Config/ServiceBusOptions.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Config/ServiceBusOptions.cs new file mode 100644 index 000000000000..a53169b8754e --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Config/ServiceBusOptions.cs @@ -0,0 +1,128 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.WebJobs.Hosting; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.WebJobs.ServiceBus +{ + /// + /// Configuration options for the ServiceBus extension. + /// + public class ServiceBusOptions : IOptionsFormatter + { + /// + /// Constructs a new instance. + /// + public ServiceBusOptions() + { + // Our default options will delegate to our own exception + // logger. Customers can override this completely by setting their + // own MessageHandlerOptions instance. + MessageHandlerOptions = new MessageHandlerOptions(ExceptionReceivedHandler) + { + MaxConcurrentCalls = Utility.GetProcessorCount() * 16 + }; + + SessionHandlerOptions = new SessionHandlerOptions(ExceptionReceivedHandler); + + // Default operation timeout is 1 minute in ServiceBus SDK + // https://github.com/Azure/azure-sdk-for-net/blob/master/sdk/servicebus/Microsoft.Azure.ServiceBus/src/Constants.cs#L30 + BatchOptions = new BatchOptions() + { + MaxMessageCount = 1000, + OperationTimeout = TimeSpan.FromMinutes(1), + AutoComplete = true + }; + } + + /// + /// Gets or sets the Azure ServiceBus connection string. + /// + public string ConnectionString { get; set; } + + /// + /// Gets or sets the default that will be used by + /// s. + /// + public MessageHandlerOptions MessageHandlerOptions { get; set; } + + /// + /// Gets or sets the default that will be used by + /// s. + /// + public SessionHandlerOptions SessionHandlerOptions { get; set; } + + /// + /// Gets or sets the default PrefetchCount that will be used by s. + /// + public int PrefetchCount { get; set; } + + /// + /// Gets or sets the default that will be used by + /// s. + /// + public BatchOptions BatchOptions { get; set; } + + internal Action ExceptionHandler { get; set; } + + public string Format() + { + JObject messageHandlerOptions = null; + if (MessageHandlerOptions != null) + { + messageHandlerOptions = new JObject + { + { nameof(MessageHandlerOptions.AutoComplete), MessageHandlerOptions.AutoComplete }, + { nameof(MessageHandlerOptions.MaxAutoRenewDuration), MessageHandlerOptions.MaxAutoRenewDuration }, + { nameof(MessageHandlerOptions.MaxConcurrentCalls), MessageHandlerOptions.MaxConcurrentCalls } + }; + } + + JObject sessionHandlerOptions = null; + if (SessionHandlerOptions != null) + { + sessionHandlerOptions = new JObject + { + { nameof(SessionHandlerOptions.AutoComplete), SessionHandlerOptions.AutoComplete }, + { nameof(SessionHandlerOptions.MaxAutoRenewDuration), SessionHandlerOptions.MaxAutoRenewDuration }, + { nameof(SessionHandlerOptions.MaxConcurrentSessions), SessionHandlerOptions.MaxConcurrentSessions }, + { nameof(SessionHandlerOptions.MessageWaitTimeout), SessionHandlerOptions.MessageWaitTimeout } + }; + } + + JObject batchOptions = null; + if (BatchOptions != null) + { + batchOptions = new JObject + { + { nameof(BatchOptions.MaxMessageCount), BatchOptions.MaxMessageCount }, + { nameof(BatchOptions.OperationTimeout), BatchOptions.OperationTimeout }, + { nameof(BatchOptions.AutoComplete), BatchOptions.AutoComplete }, + }; + } + + // Do not include ConnectionString in loggable options. + JObject options = new JObject + { + { nameof(PrefetchCount), PrefetchCount }, + { nameof(MessageHandlerOptions), messageHandlerOptions }, + { nameof(SessionHandlerOptions), sessionHandlerOptions }, + { nameof(BatchOptions), batchOptions} + }; + + return options.ToString(Formatting.Indented); + } + + private Task ExceptionReceivedHandler(ExceptionReceivedEventArgs args) + { + ExceptionHandler?.Invoke(args); + + return Task.CompletedTask; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Constants.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Constants.cs new file mode 100644 index 000000000000..d979e205053a --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Constants.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Newtonsoft.Json; + +namespace Microsoft.Azure.WebJobs.ServiceBus +{ +#pragma warning disable AZC0012 + public static class Constants + { +#pragma warning restore AZC0012 + private static JsonSerializerSettings _serializerSettings = new JsonSerializerSettings + { + // The default value, DateParseHandling.DateTime, drops time zone information from DateTimeOffets. + // This value appears to work well with both DateTimes (without time zone information) and DateTimeOffsets. + DateParseHandling = DateParseHandling.DateTimeOffset, + NullValueHandling = NullValueHandling.Ignore, + Formatting = Formatting.Indented + }; + +#pragma warning disable AZC0014 + public static JsonSerializerSettings JsonSerializerSettings + { + get + { + return _serializerSettings; + } + } +#pragma warning restore AZC0014 + + public const string DefaultConnectionStringName = "ServiceBus"; + public const string DefaultConnectionSettingStringName = "AzureWebJobsServiceBus"; + public const string DynamicSku = "Dynamic"; + public const string AzureWebsiteSku = "WEBSITE_SKU"; + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/ContentTypes.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/ContentTypes.cs new file mode 100644 index 000000000000..daa3480d6542 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/ContentTypes.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +namespace Microsoft.Azure.WebJobs.ServiceBus +{ + internal static class ContentTypes + { + public const string TextPlain = "text/plain"; + + public const string ApplicationJson = "application/json"; + + public const string ApplicationOctetStream = "application/octet-stream"; + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/EntityType.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/EntityType.cs new file mode 100644 index 000000000000..5ac296adc476 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/EntityType.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +namespace Microsoft.Azure.WebJobs.ServiceBus +{ + /// + /// Service Bus entity type. + /// + public enum EntityType + { + /// + /// Service Bus Queue + /// + Queue, + + /// + /// Service Bus Topic + /// + Topic + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/GlobalSuppressions.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/GlobalSuppressions.cs new file mode 100644 index 000000000000..aa9a3f656c38 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/GlobalSuppressions.cs @@ -0,0 +1,8 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA2204:Literals should be spelled correctly", MessageId = "AzureWebJobs", Scope = "member", Target = "Microsoft.Azure.WebJobs.ServiceBus.MessagingProvider.#GetConnectionString(System.String)")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Prefetch", Scope = "member", Target = "Microsoft.Azure.WebJobs.ServiceBus.ServiceBusConfiguration.#PrefetchCount")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable", Scope = "type", Target = "Microsoft.Azure.WebJobs.ServiceBus.EventHubListener+Listenter")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable", Scope = "type", Target = "Microsoft.Azure.WebJobs.ServiceBus.EventHubListener+Listener")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1014:MarkAssembliesWithClsCompliant")] \ No newline at end of file diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Listeners/ServiceBusCausalityHelper.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Listeners/ServiceBusCausalityHelper.cs new file mode 100644 index 000000000000..bf192a4b5cbd --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Listeners/ServiceBusCausalityHelper.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using Microsoft.Azure.ServiceBus; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Listeners +{ + internal static class ServiceBusCausalityHelper + { + private const string ParentGuidFieldName = "$AzureWebJobsParentId"; + + public static void EncodePayload(Guid functionOwner, Message msg) + { + msg.UserProperties[ParentGuidFieldName] = functionOwner.ToString(); + } + + public static Guid? GetOwner(Message msg) + { + object parent; + if (msg.UserProperties.TryGetValue(ParentGuidFieldName, out parent)) + { + var parentString = parent as string; + if (parentString != null) + { + Guid parentGuid; + if (Guid.TryParse(parentString, out parentGuid)) + { + return parentGuid; + } + } + } + return null; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Listeners/ServiceBusEntityPathHelper.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Listeners/ServiceBusEntityPathHelper.cs new file mode 100644 index 000000000000..74b3d1b9daa7 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Listeners/ServiceBusEntityPathHelper.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Listeners +{ + internal static class ServiceBusEntityPathHelper + { + public static EntityType ParseEntityType(string entityPath) + { + return entityPath.IndexOf("/Subscriptions/", StringComparison.OrdinalIgnoreCase) >= 0 ? EntityType.Topic : EntityType.Queue; + } + + public static void ParseTopicAndSubscription(string entityPath, out string topic, out string subscription) + { + string[] arr = Regex.Split(entityPath, "/Subscriptions/", RegexOptions.IgnoreCase); + + if (arr.Length < 2) + { + throw new InvalidOperationException($"{entityPath} is either formatted incorrectly, or is not a valid Service Bus subscription path"); + } + + topic = arr[0]; + subscription = arr[1]; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Listeners/ServiceBusListener.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Listeners/ServiceBusListener.cs new file mode 100644 index 000000000000..43fb3071e92f --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Listeners/ServiceBusListener.cs @@ -0,0 +1,381 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.ServiceBus.Core; +using Microsoft.Azure.WebJobs.Host.Executors; +using Microsoft.Azure.WebJobs.Host.Listeners; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Listeners +{ + internal sealed class ServiceBusListener : IListener, IScaleMonitorProvider + { + private readonly MessagingProvider _messagingProvider; + private readonly ITriggeredFunctionExecutor _triggerExecutor; + private readonly string _functionId; + private readonly EntityType _entityType; + private readonly string _entityPath; + private readonly bool _isSessionsEnabled; + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly MessageProcessor _messageProcessor; + private readonly ServiceBusAccount _serviceBusAccount; + private readonly ServiceBusOptions _serviceBusOptions; + private readonly ILoggerFactory _loggerFactory; + private readonly bool _singleDispatch; + private readonly ILogger _logger; + + private Lazy _receiver; + private Lazy _sessionClient; + private ClientEntity _clientEntity; + private bool _disposed; + private bool _started; + // Serialize execution of StopAsync to avoid calling Unregister* concurrently + private readonly SemaphoreSlim _stopAsyncSemaphore = new SemaphoreSlim(1, 1); + + private IMessageSession _messageSession; + private SessionMessageProcessor _sessionMessageProcessor; + + private Lazy _scaleMonitor; + + public ServiceBusListener(string functionId, EntityType entityType, string entityPath, bool isSessionsEnabled, ITriggeredFunctionExecutor triggerExecutor, + ServiceBusOptions config, ServiceBusAccount serviceBusAccount, MessagingProvider messagingProvider, ILoggerFactory loggerFactory, bool singleDispatch) + { + _functionId = functionId; + _entityType = entityType; + _entityPath = entityPath; + _isSessionsEnabled = isSessionsEnabled; + _triggerExecutor = triggerExecutor; + _cancellationTokenSource = new CancellationTokenSource(); + _messagingProvider = messagingProvider; + _serviceBusAccount = serviceBusAccount; + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger(); + _receiver = CreateMessageReceiver(); + _sessionClient = CreateSessionClient(); + _scaleMonitor = new Lazy(() => new ServiceBusScaleMonitor(_functionId, _entityType, _entityPath, _serviceBusAccount.ConnectionString, _receiver, _loggerFactory)); + _singleDispatch = singleDispatch; + + if (_isSessionsEnabled) + { + _sessionMessageProcessor = _messagingProvider.CreateSessionMessageProcessor(_entityPath, _serviceBusAccount.ConnectionString); + } + else + { + _messageProcessor = _messagingProvider.CreateMessageProcessor(entityPath, _serviceBusAccount.ConnectionString); + } + _serviceBusOptions = config; + } + + internal MessageReceiver Receiver => _receiver.Value; + + internal IMessageSession MessageSession => _messageSession; + + public Task StartAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + + if (_started) + { + throw new InvalidOperationException("The listener has already been started."); + } + + if (_singleDispatch) + { + if (_isSessionsEnabled) + { + _clientEntity = _messagingProvider.CreateClientEntity(_entityPath, _serviceBusAccount.ConnectionString); + if (_clientEntity is QueueClient queueClient) + { + queueClient.RegisterSessionHandler(ProcessSessionMessageAsync, _serviceBusOptions.SessionHandlerOptions); + } + else + { + SubscriptionClient subscriptionClient = _clientEntity as SubscriptionClient; + subscriptionClient.RegisterSessionHandler(ProcessSessionMessageAsync, _serviceBusOptions.SessionHandlerOptions); + } + } + else + { + Receiver.RegisterMessageHandler(ProcessMessageAsync, _serviceBusOptions.MessageHandlerOptions); + } + } + else + { + StartMessageBatchReceiver(_cancellationTokenSource.Token); + } + _started = true; + + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + await _stopAsyncSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + { + try + { + if (!_started) + { + throw new InvalidOperationException("The listener has not yet been started or has already been stopped."); + } + + // Unregister* methods stop new messages from being processed while allowing in-flight messages to complete. + // As the amount of time functions are allowed to complete processing varies by SKU, we specify max timespan + // as the amount of time Service Bus SDK should wait for in-flight messages to complete procesing after + // unregistering the message handler so that functions have as long as the host continues to run time to complete. + if (_singleDispatch) + { + if (_isSessionsEnabled) + { + if (_clientEntity != null) + { + if (_clientEntity is QueueClient queueClient) + { + await queueClient.UnregisterSessionHandlerAsync(TimeSpan.MaxValue).ConfigureAwait(false); + } + else + { + SubscriptionClient subscriptionClient = _clientEntity as SubscriptionClient; + await subscriptionClient.UnregisterSessionHandlerAsync(TimeSpan.MaxValue).ConfigureAwait(false); + } + } + } + else + { + if (_receiver != null && _receiver.IsValueCreated) + { + await Receiver.UnregisterMessageHandlerAsync(TimeSpan.MaxValue).ConfigureAwait(false); + } + } + } + // Batch processing will be stopped via the _started flag on its next iteration + + _started = false; + } + finally + { + _stopAsyncSemaphore.Release(); + } + } + } + + public void Cancel() + { + ThrowIfDisposed(); + _cancellationTokenSource.Cancel(); + } + + [SuppressMessage("Microsoft.Usage", "CA2213:DisposableFieldsShouldBeDisposed", MessageId = "_cancellationTokenSource")] + public void Dispose() + { + if (!_disposed) + { + // Running callers might still be using the cancellation token. + // Mark it canceled but don't dispose of the source while the callers are running. + // Otherwise, callers would receive ObjectDisposedException when calling token.Register. + // For now, rely on finalization to clean up _cancellationTokenSource's wait handle (if allocated). + _cancellationTokenSource.Cancel(); + + if (_receiver != null && _receiver.IsValueCreated) + { + Receiver.CloseAsync().Wait(); + _receiver = null; + } + + if (_sessionClient != null && _sessionClient.IsValueCreated) + { + _sessionClient.Value.CloseAsync().Wait(); + _sessionClient = null; + } + + if (_clientEntity != null) + { + _clientEntity.CloseAsync().Wait(); + _clientEntity = null; + } + + _stopAsyncSemaphore.Dispose(); + _cancellationTokenSource.Dispose(); + + _disposed = true; + } + } + + private Lazy CreateMessageReceiver() + { + return new Lazy(() => _messagingProvider.CreateMessageReceiver(_entityPath, _serviceBusAccount.ConnectionString)); + } + + private Lazy CreateSessionClient() + { + return new Lazy(() => _messagingProvider.CreateSessionClient(_entityPath, _serviceBusAccount.ConnectionString)); + } + + internal async Task ProcessMessageAsync(Message message, CancellationToken cancellationToken) + { + using (CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationTokenSource.Token)) + { + if (!await _messageProcessor.BeginProcessingMessageAsync(message, linkedCts.Token).ConfigureAwait(false)) + { + return; + } + + ServiceBusTriggerInput input = ServiceBusTriggerInput.CreateSingle(message); + input.MessageReceiver = Receiver; + + TriggeredFunctionData data = input.GetTriggerFunctionData(); + FunctionResult result = await _triggerExecutor.TryExecuteAsync(data, linkedCts.Token).ConfigureAwait(false); + await _messageProcessor.CompleteProcessingMessageAsync(message, result, linkedCts.Token).ConfigureAwait(false); + } + } + + internal async Task ProcessSessionMessageAsync(IMessageSession session, Message message, CancellationToken cancellationToken) + { + using (CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationTokenSource.Token)) + { + _messageSession = session; + if (!await _sessionMessageProcessor.BeginProcessingMessageAsync(session, message, linkedCts.Token).ConfigureAwait(false)) + { + return; + } + + ServiceBusTriggerInput input = ServiceBusTriggerInput.CreateSingle(message); + input.MessageReceiver = session; + + TriggeredFunctionData data = input.GetTriggerFunctionData(); + FunctionResult result = await _triggerExecutor.TryExecuteAsync(data, linkedCts.Token).ConfigureAwait(false); + await _sessionMessageProcessor.CompleteProcessingMessageAsync(session, message, result, linkedCts.Token).ConfigureAwait(false); + } + } + + internal void StartMessageBatchReceiver(CancellationToken cancellationToken) + { + SessionClient sessionClient = null; + IMessageReceiver receiver = null; + if (_isSessionsEnabled) + { + sessionClient = _sessionClient.Value; + } + else + { + receiver = Receiver; + } + + Task.Run(async () => + { + while (true) + { + try + { + if (!_started || cancellationToken.IsCancellationRequested) + { + _logger.LogInformation("Message processing has been stopped or cancelled"); + return; + } + + if (_isSessionsEnabled && ( receiver == null || receiver.IsClosedOrClosing)) + { + try + { + receiver = await sessionClient.AcceptMessageSessionAsync().ConfigureAwait(false); + receiver.PrefetchCount = _serviceBusOptions.PrefetchCount; + } + catch (ServiceBusTimeoutException) + { + // it's expected if the entity is empty, try next time + continue; + } + } + + IList messages = await receiver.ReceiveAsync(_serviceBusOptions.BatchOptions.MaxMessageCount, _serviceBusOptions.BatchOptions.OperationTimeout).ConfigureAwait(false); + + if (messages != null) + { + Message[] messagesArray = messages.ToArray(); + ServiceBusTriggerInput input = ServiceBusTriggerInput.CreateBatch(messagesArray); + input.MessageReceiver = receiver; + + FunctionResult result = await _triggerExecutor.TryExecuteAsync(input.GetTriggerFunctionData(), cancellationToken).ConfigureAwait(false); + + if (cancellationToken.IsCancellationRequested) + { + return; + } + + // Complete batch of messages only if the execution was successful + if (_serviceBusOptions.BatchOptions.AutoComplete && _started) + { + if (result.Succeeded) + { + await receiver.CompleteAsync(messagesArray.Select(x => x.SystemProperties.LockToken)).ConfigureAwait(false); + } + else + { + List abandonTasks = new List(); + foreach (var lockTocken in messagesArray.Select(x => x.SystemProperties.LockToken)) + { + abandonTasks.Add(receiver.AbandonAsync(lockTocken)); + } + await Task.WhenAll(abandonTasks).ConfigureAwait(false); + } + } + } + else + { + // Close the session and release the session lock after draining all messages for the accepted session. + if (_isSessionsEnabled) + { + await receiver.CloseAsync().ConfigureAwait(false); + } + } + } + catch (ObjectDisposedException) + { + // Ignore as we are stopping the host + } + catch (Exception ex) + { + // Log another exception + _logger.LogError(ex, $"An unhandled exception occurred in the message batch receive loop"); + + if (_isSessionsEnabled && receiver != null) + { + // Attempt to close the session and release session lock to accept a new session on the next loop iteration + try + { + await receiver.CloseAsync().ConfigureAwait(false); + } + catch + { + // Best effort + receiver = null; + } + } + } + } + }, cancellationToken); + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(null); + } + } + + public IScaleMonitor GetMonitor() + { + return _scaleMonitor.Value; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Listeners/ServiceBusListenerFactory.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Listeners/ServiceBusListenerFactory.cs new file mode 100644 index 000000000000..6b8fca434d89 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Listeners/ServiceBusListenerFactory.cs @@ -0,0 +1,50 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.Azure.WebJobs.Host.Executors; +using Microsoft.Azure.WebJobs.Host.Listeners; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Azure.WebJobs.Host.Protocols; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Listeners +{ + internal class ServiceBusListenerFactory : IListenerFactory + { + private readonly ServiceBusAccount _account; + private readonly EntityType _entityType; + private readonly string _entityPath; + private readonly bool _isSessionsEnabled; + private readonly ITriggeredFunctionExecutor _executor; + private readonly FunctionDescriptor _descriptor; + private readonly ServiceBusOptions _options; + private readonly MessagingProvider _messagingProvider; + private readonly ILoggerFactory _loggerFactory; + private readonly bool _singleDispatch; + + public ServiceBusListenerFactory(ServiceBusAccount account, EntityType entityType, string entityPath, bool isSessionsEnabled, ITriggeredFunctionExecutor executor, + FunctionDescriptor descriptor, ServiceBusOptions options, MessagingProvider messagingProvider, ILoggerFactory loggerFactory, bool singleDispatch) + { + _account = account; + _entityType = entityType; + _entityPath = entityPath; + _isSessionsEnabled = isSessionsEnabled; + _executor = executor; + _descriptor = descriptor; + _options = options; + _messagingProvider = messagingProvider; + _loggerFactory = loggerFactory; + _singleDispatch = singleDispatch; + } + + public Task CreateAsync(CancellationToken cancellationToken) + { + var listener = new ServiceBusListener(_descriptor.Id, _entityType, _entityPath, _isSessionsEnabled, _executor, _options, _account, _messagingProvider, _loggerFactory, _singleDispatch); + return Task.FromResult(listener); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Listeners/ServiceBusScaleMonitor.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Listeners/ServiceBusScaleMonitor.cs new file mode 100644 index 000000000000..0f0fd35618b5 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Listeners/ServiceBusScaleMonitor.cs @@ -0,0 +1,317 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.ServiceBus.Core; +using Microsoft.Azure.ServiceBus.Management; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Extensions.Logging; +using System.Globalization; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Listeners +{ + internal class ServiceBusScaleMonitor : IScaleMonitor + { + private const string DeadLetterQueuePath = @"/$DeadLetterQueue"; + + private readonly string _functionId; + private readonly EntityType _entityType; + private readonly string _entityPath; + private readonly string _connectionString; + private readonly ScaleMonitorDescriptor _scaleMonitorDescriptor; + private readonly bool _isListeningOnDeadLetterQueue; + private readonly Lazy _receiver; + private readonly Lazy _managementClient; + private readonly ILogger _logger; + + private DateTime _nextWarningTime; + + public ServiceBusScaleMonitor(string functionId, EntityType entityType, string entityPath, string connectionString, Lazy receiver, ILoggerFactory loggerFactory) + { + _functionId = functionId; + _entityType = entityType; + _entityPath = entityPath; + _connectionString = connectionString; + _scaleMonitorDescriptor = new ScaleMonitorDescriptor($"{_functionId}-ServiceBusTrigger-{_entityPath}".ToLower(CultureInfo.InvariantCulture)); + _isListeningOnDeadLetterQueue = entityPath.EndsWith(DeadLetterQueuePath, StringComparison.OrdinalIgnoreCase); + _receiver = receiver; + _managementClient = new Lazy(() => new ManagementClient(_connectionString)); + _logger = loggerFactory.CreateLogger(); + _nextWarningTime = DateTime.UtcNow; + } + + public ScaleMonitorDescriptor Descriptor + { + get + { + return _scaleMonitorDescriptor; + } + } + + async Task IScaleMonitor.GetMetricsAsync() + { + return await GetMetricsAsync().ConfigureAwait(false); + } + + public async Task GetMetricsAsync() + { + Message message = null; + string entityName = _entityType == EntityType.Queue ? "queue" : "topic"; + + try + { + // Peek the first message in the queue without removing it from the queue + // PeekAsync remembers the sequence number of the last message, so the second call returns the second message instead of the first one + // Use PeekBySequenceNumberAsync with fromSequenceNumber = 0 to always get the first available message + message = await _receiver.Value.PeekBySequenceNumberAsync(0).ConfigureAwait(false); + + if (_entityType == EntityType.Queue) + { + return await GetQueueMetricsAsync(message).ConfigureAwait(false); + } + else + { + return await GetTopicMetricsAsync(message).ConfigureAwait(false); + } + } + catch (MessagingEntityNotFoundException) + { + _logger.LogWarning($"ServiceBus {entityName} '{_entityPath}' was not found."); + } + catch (UnauthorizedException) // When manage claim is not used on Service Bus connection string + { + if (TimeToLogWarning()) + { + _logger.LogWarning($"Connection string does not have Manage claim for {entityName} '{_entityPath}'. Failed to get {entityName} description to " + + $"derive {entityName} length metrics. Falling back to using first message enqueued time."); + } + } + catch (Exception e) + { + _logger.LogWarning($"Error querying for Service Bus {entityName} scale status: {e.Message}"); + } + + // Path for connection strings with no manage claim + return CreateTriggerMetrics(message, 0, 0, 0, _isListeningOnDeadLetterQueue); + } + + private async Task GetQueueMetricsAsync(Message message) + { + QueueRuntimeInfo queueRuntimeInfo; + QueueDescription queueDescription; + long activeMessageCount = 0, deadLetterCount = 0; + int partitionCount = 0; + + queueRuntimeInfo = await _managementClient.Value.GetQueueRuntimeInfoAsync(_entityPath).ConfigureAwait(false); + activeMessageCount = queueRuntimeInfo.MessageCountDetails.ActiveMessageCount; + deadLetterCount = queueRuntimeInfo.MessageCountDetails.DeadLetterMessageCount; + + // If partitioning is turned on, then Service Bus automatically partitions queues into 16 partitions + // See more information here: https://docs.microsoft.com/en-us/azure/service-bus-messaging/service-bus-partitioning#standard + queueDescription = await _managementClient.Value.GetQueueAsync(_entityPath).ConfigureAwait(false); + partitionCount = queueDescription.EnablePartitioning ? 16 : 0; + + return CreateTriggerMetrics(message, activeMessageCount, deadLetterCount, partitionCount, _isListeningOnDeadLetterQueue); + } + + private async Task GetTopicMetricsAsync(Message message) + { + TopicDescription topicDescription; + SubscriptionRuntimeInfo subscriptionRuntimeInfo; + string topicPath, subscriptionPath; + long activeMessageCount = 0, deadLetterCount = 0; + int partitionCount = 0; + + ServiceBusEntityPathHelper.ParseTopicAndSubscription(_entityPath, out topicPath, out subscriptionPath); + + subscriptionRuntimeInfo = await _managementClient.Value.GetSubscriptionRuntimeInfoAsync(topicPath, subscriptionPath).ConfigureAwait(false); + activeMessageCount = subscriptionRuntimeInfo.MessageCountDetails.ActiveMessageCount; + deadLetterCount = subscriptionRuntimeInfo.MessageCountDetails.DeadLetterMessageCount; + + // If partitioning is turned on, then Service Bus automatically partitions queues into 16 partitions + // See more information here: https://docs.microsoft.com/en-us/azure/service-bus-messaging/service-bus-partitioning#standard + topicDescription = await _managementClient.Value.GetTopicAsync(topicPath).ConfigureAwait(false); + partitionCount = topicDescription.EnablePartitioning ? 16 : 0; + + return CreateTriggerMetrics(message, activeMessageCount, deadLetterCount, partitionCount, _isListeningOnDeadLetterQueue); + } + + internal static ServiceBusTriggerMetrics CreateTriggerMetrics(Message message, long activeMessageCount, long deadLetterCount, int partitionCount, bool isListeningOnDeadLetterQueue) + { + long totalNewMessageCount = 0; + TimeSpan queueTime = TimeSpan.Zero; + + if (message != null) + { + queueTime = DateTime.UtcNow.Subtract(message.SystemProperties.EnqueuedTimeUtc); + totalNewMessageCount = 1; // There's at least one if message != null. Default for connection string with no manage claim + } + + if ((!isListeningOnDeadLetterQueue && activeMessageCount > 0) || (isListeningOnDeadLetterQueue && deadLetterCount > 0)) + { + totalNewMessageCount = isListeningOnDeadLetterQueue ? deadLetterCount : activeMessageCount; + } + + return new ServiceBusTriggerMetrics + { + MessageCount = totalNewMessageCount, + PartitionCount = partitionCount, + QueueTime = queueTime + }; + } + + ScaleStatus IScaleMonitor.GetScaleStatus(ScaleStatusContext context) + { + return GetScaleStatusCore(context.WorkerCount, context.Metrics?.Cast().ToArray()); + } + + public ScaleStatus GetScaleStatus(ScaleStatusContext context) + { + return GetScaleStatusCore(context.WorkerCount, context.Metrics?.ToArray()); + } + + private ScaleStatus GetScaleStatusCore(int workerCount, ServiceBusTriggerMetrics[] metrics) + { + ScaleStatus status = new ScaleStatus + { + Vote = ScaleVote.None + }; + + const int NumberOfSamplesToConsider = 5; + + // Unable to determine the correct vote with no metrics. + if (metrics == null || metrics.Length == 0) + { + return status; + } + + // We shouldn't assign more workers than there are partitions + // This check is first, because it is independent of load or number of samples. + int partitionCount = metrics.Last().PartitionCount; + if (partitionCount > 0 && partitionCount < workerCount) + { + status.Vote = ScaleVote.ScaleIn; + _logger.LogInformation($"WorkerCount ({workerCount}) > PartitionCount ({partitionCount})."); + _logger.LogInformation($"Number of instances ({workerCount}) is too high relative to number " + + $"of partitions for Service Bus entity ({_entityPath}, {partitionCount})."); + return status; + } + + // At least 5 samples are required to make a scale decision for the rest of the checks. + if (metrics.Length < NumberOfSamplesToConsider) + { + return status; + } + + // Maintain a minimum ratio of 1 worker per 1,000 messages. + long latestMessageCount = metrics.Last().MessageCount; + if (latestMessageCount > workerCount * 1000) + { + status.Vote = ScaleVote.ScaleOut; + _logger.LogInformation($"MessageCount ({latestMessageCount}) > WorkerCount ({workerCount}) * 1,000."); + _logger.LogInformation($"Message count for Service Bus Entity ({_entityPath}, {latestMessageCount}) " + + $"is too high relative to the number of instances ({workerCount})."); + return status; + } + + // Check to see if the queue/topic has been empty for a while. Only if all metrics samples are empty do we scale down. + bool isIdle = metrics.All(m => m.MessageCount == 0); + if (isIdle) + { + status.Vote = ScaleVote.ScaleIn; + _logger.LogInformation($"'{_entityPath}' is idle."); + return status; + } + + // Samples are in chronological order. Check for a continuous increase in message count. + // If detected, this results in an automatic scale out for the site container. + if (metrics[0].MessageCount > 0) + { + bool messageCountIncreasing = + IsTrueForLastN( + metrics, + NumberOfSamplesToConsider, + (prev, next) => prev.MessageCount < next.MessageCount) && metrics[0].MessageCount > 0; + if (messageCountIncreasing) + { + status.Vote = ScaleVote.ScaleOut; + _logger.LogInformation($"Message count is increasing for '{_entityPath}'."); + return status; + } + } + + if (metrics[0].QueueTime > TimeSpan.Zero && metrics[0].QueueTime < metrics[NumberOfSamplesToConsider - 1].QueueTime) + { + bool queueTimeIncreasing = + IsTrueForLastN( + metrics, + NumberOfSamplesToConsider, + (prev, next) => prev.QueueTime <= next.QueueTime); + if (queueTimeIncreasing) + { + status.Vote = ScaleVote.ScaleOut; + _logger.LogInformation($"Queue time is increasing for '{_entityPath}'."); + return status; + } + } + + bool messageCountDecreasing = + IsTrueForLastN( + metrics, + NumberOfSamplesToConsider, + (prev, next) => prev.MessageCount > next.MessageCount); + if (messageCountDecreasing) + { + status.Vote = ScaleVote.ScaleIn; + _logger.LogInformation($"Message count is decreasing for '{_entityPath}'."); + return status; + } + + bool queueTimeDecreasing = IsTrueForLastN( + metrics, + NumberOfSamplesToConsider, + (prev, next) => prev.QueueTime > next.QueueTime); + if (queueTimeDecreasing) + { + status.Vote = ScaleVote.ScaleIn; + _logger.LogInformation($"Queue time is decreasing for '{_entityPath}'."); + return status; + } + + _logger.LogInformation($"Service Bus entity '{_entityPath}' is steady."); + + return status; + } + + private bool TimeToLogWarning() + { + DateTime currentTime = DateTime.UtcNow; + bool timeToLog = currentTime >= _nextWarningTime; + if (timeToLog) + { + _nextWarningTime = currentTime.AddHours(1); + } + return timeToLog; + } + + private static bool IsTrueForLastN(IList samples, int count, Func predicate) + { + // Walks through the list from left to right starting at len(samples) - count. + for (int i = samples.Count - count; i < samples.Count - 1; i++) + { + if (!predicate(samples[i], samples[i + 1])) + { + return false; + } + } + + return true; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Listeners/ServiceBusTriggerMetrics.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Listeners/ServiceBusTriggerMetrics.cs new file mode 100644 index 000000000000..51325c7b255c --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Listeners/ServiceBusTriggerMetrics.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.Azure.WebJobs.Host.Scale; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Listeners +{ + internal class ServiceBusTriggerMetrics : ScaleMetrics + { + /// + /// The number of messages currently in the queue/topic. + /// + public long MessageCount { get; set; } + + /// + /// The number of partitions. + /// + public int PartitionCount { get; set; } + + /// + /// The length of time the next message has been + /// sitting there. + /// + public TimeSpan QueueTime { get; set; } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/MessageProcessor.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/MessageProcessor.cs new file mode 100644 index 000000000000..8adb5bea58e5 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/MessageProcessor.cs @@ -0,0 +1,87 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Executors; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.ServiceBus.Core; + +namespace Microsoft.Azure.WebJobs.ServiceBus +{ + /// + /// This class defines a strategy used for processing ServiceBus messages. + /// + /// + /// Custom implementations can be specified by implementing + /// a custom and setting it via ServiceBusOptions.MessagingProvider. + /// + public class MessageProcessor + { + /// + /// Constructs a new instance. + /// + /// The . + /// The to use. + public MessageProcessor(MessageReceiver messageReceiver, MessageHandlerOptions messageOptions) + { + MessageReceiver = messageReceiver ?? throw new ArgumentNullException(nameof(messageReceiver)); + MessageOptions = messageOptions ?? throw new ArgumentNullException(nameof(messageOptions)); + } + + /// + /// Gets the that will be used by the . + /// + public MessageHandlerOptions MessageOptions { get; } + + /// + /// Gets or sets the that will be used by the . + /// + protected MessageReceiver MessageReceiver { get; set; } + + /// + /// This method is called when there is a new message to process, before the job function is invoked. + /// This allows any preprocessing to take place on the message before processing begins. + /// + /// The message to process. + /// The to use. + /// A that returns true if the message processing should continue, false otherwise. + public virtual Task BeginProcessingMessageAsync(Message message, CancellationToken cancellationToken) + { + return Task.FromResult(true); + } + + /// + /// This method completes processing of the specified message, after the job function has been invoked. + /// + /// + /// The message is completed by the ServiceBus SDK based on how the option + /// is configured. E.g. if is false, it is up to the job function to complete + /// the message. + /// + /// The message to complete processing for. + /// The from the job invocation. + /// The to use + /// A that will complete the message processing. + public virtual Task CompleteProcessingMessageAsync(Message message, FunctionResult result, CancellationToken cancellationToken) + { + if (result == null) + { + throw new ArgumentNullException(nameof(result)); + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (!result.Succeeded) + { + // if the invocation failed, we must propagate the + // exception back to SB so it can handle message state + // correctly + throw result.Exception; + } + + return Task.CompletedTask; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/MessagingProvider.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/MessagingProvider.cs new file mode 100644 index 000000000000..bace14229d07 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/MessagingProvider.cs @@ -0,0 +1,212 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.ServiceBus.Core; +using Microsoft.Azure.WebJobs.ServiceBus.Listeners; +using Microsoft.Extensions.Options; + +namespace Microsoft.Azure.WebJobs.ServiceBus +{ + /// + /// This class provides factory methods for the creation of instances + /// used for ServiceBus message processing. + /// + public class MessagingProvider + { + private readonly ServiceBusOptions _options; + private readonly ConcurrentDictionary _messageReceiverCache = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _messageSenderCache = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _clientEntityCache = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _sessionClientCache = new ConcurrentDictionary(); + + /// + /// Constructs a new instance. + /// + /// The . + public MessagingProvider(IOptions serviceBusOptions) + { + _options = serviceBusOptions?.Value ?? throw new ArgumentNullException(nameof(serviceBusOptions)); + } + + /// + /// Creates a for the specified ServiceBus entity. + /// + /// The ServiceBus entity to create a for. + /// The ServiceBus connection string. + /// The . + public virtual MessageProcessor CreateMessageProcessor(string entityPath, string connectionString) + { + if (string.IsNullOrEmpty(entityPath)) + { + throw new ArgumentNullException(nameof(entityPath)); + } + if (string.IsNullOrEmpty(connectionString)) + { + throw new ArgumentNullException(nameof(connectionString)); + } + + return new MessageProcessor(GetOrAddMessageReceiver(entityPath, connectionString), _options.MessageHandlerOptions); + } + + /// + /// Creates a for the specified ServiceBus entity. + /// + /// + /// You can override this method to customize the . + /// + /// The ServiceBus entity to create a for. + /// The ServiceBus connection string. + /// + public virtual MessageReceiver CreateMessageReceiver(string entityPath, string connectionString) + { + if (string.IsNullOrEmpty(entityPath)) + { + throw new ArgumentNullException(nameof(entityPath)); + } + if (string.IsNullOrEmpty(connectionString)) + { + throw new ArgumentNullException(nameof(connectionString)); + } + + return GetOrAddMessageReceiver(entityPath, connectionString); + } + + /// + /// Creates a for the specified ServiceBus entity. + /// + /// + /// You can override this method to customize the . + /// + /// The ServiceBus entity to create a for. + /// The ServiceBus connection string. + /// + public virtual MessageSender CreateMessageSender(string entityPath, string connectionString) + { + if (string.IsNullOrEmpty(entityPath)) + { + throw new ArgumentNullException(nameof(entityPath)); + } + if (string.IsNullOrEmpty(connectionString)) + { + throw new ArgumentNullException(nameof(connectionString)); + } + + return GetOrAddMessageSender(entityPath, connectionString); + } + + /// + /// Creates a for the specified ServiceBus entity. + /// + /// + /// You can override this method to customize the . + /// + /// The ServiceBus entity to create a for. + /// The ServiceBus connection string. + /// + public virtual ClientEntity CreateClientEntity(string entityPath, string connectionString) + { + if (string.IsNullOrEmpty(entityPath)) + { + throw new ArgumentNullException(nameof(entityPath)); + } + if (string.IsNullOrEmpty(connectionString)) + { + throw new ArgumentNullException(nameof(connectionString)); + } + + return GetOrAddClientEntity(entityPath, connectionString); + } + + /// + /// Creates a for the specified ServiceBus entity. + /// + /// + /// You can override this method to customize the . + /// + /// The ServiceBus entity to create a for. + /// The ServiceBus connection string. + /// + public virtual SessionClient CreateSessionClient(string entityPath, string connectionString) + { + if (string.IsNullOrEmpty(entityPath)) + { + throw new ArgumentNullException(nameof(entityPath)); + } + if (string.IsNullOrEmpty(connectionString)) + { + throw new ArgumentNullException(nameof(connectionString)); + } + + return GetOrAddSessionClient(entityPath, connectionString); + } + + /// + /// Creates a for the specified ServiceBus entity. + /// + /// The ServiceBus entity to create a for. + /// The ServiceBus connection string. + /// + public virtual SessionMessageProcessor CreateSessionMessageProcessor(string entityPath, string connectionString) + { + if (string.IsNullOrEmpty(entityPath)) + { + throw new ArgumentNullException(nameof(entityPath)); + } + if (string.IsNullOrEmpty(connectionString)) + { + throw new ArgumentNullException(nameof(connectionString)); + } + + return new SessionMessageProcessor(GetOrAddClientEntity(entityPath, connectionString), _options.SessionHandlerOptions); + } + + private MessageReceiver GetOrAddMessageReceiver(string entityPath, string connectionString) + { + string cacheKey = $"{entityPath}-{connectionString}"; + return _messageReceiverCache.GetOrAdd(cacheKey, + new MessageReceiver(connectionString, entityPath) + { + PrefetchCount = _options.PrefetchCount + }); + } + + private MessageSender GetOrAddMessageSender(string entityPath, string connectionString) + { + string cacheKey = $"{entityPath}-{connectionString}"; + return _messageSenderCache.GetOrAdd(cacheKey, new MessageSender(connectionString, entityPath)); + } + + private ClientEntity GetOrAddClientEntity(string entityPath, string connectionString) + { + string cacheKey = $"{entityPath}-{connectionString}"; + if (ServiceBusEntityPathHelper.ParseEntityType(entityPath) == EntityType.Topic) + { + string topic, subscription; + + // entityPath for a subscription is "{TopicName}/Subscriptions/{SubscriptionName}" + ServiceBusEntityPathHelper.ParseTopicAndSubscription(entityPath, out topic, out subscription); + return _clientEntityCache.GetOrAdd(cacheKey, new SubscriptionClient(connectionString, topic, subscription) + { + PrefetchCount = _options.PrefetchCount + }); + } + else + { + // entityPath for a queue is "{QueueName}" + return _clientEntityCache.GetOrAdd(cacheKey, new QueueClient(connectionString, entityPath) + { + PrefetchCount = _options.PrefetchCount + }); + } + } + + private SessionClient GetOrAddSessionClient(string entityPath, string connectionString) + { + string cacheKey = $"{entityPath}-{connectionString}"; + return _sessionClientCache.GetOrAdd(cacheKey, new SessionClient(connectionString, entityPath)); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Microsoft.Azure.WebJobs.Extensions.ServiceBus.csproj b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Microsoft.Azure.WebJobs.Extensions.ServiceBus.csproj new file mode 100644 index 000000000000..aa2e7cfa4337 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Microsoft.Azure.WebJobs.Extensions.ServiceBus.csproj @@ -0,0 +1,22 @@ + + + + netstandard2.0 + Microsoft Azure WebJobs SDK ServiceBus Extension + 5.0.0-beta.1 + $(NoWarn);AZC0001;CS1591;SA1636;CA1507 + true + + + + + + + + + + + + + + diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Properties/AssemblyInfo.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Properties/AssemblyInfo.cs new file mode 100644 index 000000000000..97bd95be1f58 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Properties/AssemblyInfo.cs @@ -0,0 +1,8 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Reflection; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.Azure.WebJobs.Extensions.ServiceBus.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100d15ddcb29688295338af4b7686603fe614abd555e09efba8fb88ee09e1f7b1ccaeed2e8f823fa9eef3fdd60217fc012ea67d2479751a0b8c087a4185541b851bd8b16f8d91b840e51b1cb0ba6fe647997e57429265e85ef62d565db50a69ae1647d54d7bd855e4db3d8a91510e5bcbd0edfbbecaa20a7bd9ae74593daa7b11b4")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/ServiceBusAccount.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/ServiceBusAccount.cs new file mode 100644 index 000000000000..dca0b479b39a --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/ServiceBusAccount.cs @@ -0,0 +1,53 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Globalization; +using Microsoft.Azure.WebJobs.Logging; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Azure.WebJobs.ServiceBus +{ + internal class ServiceBusAccount + { + private readonly ServiceBusOptions _options; + private readonly IConnectionProvider _connectionProvider; + private readonly IConfiguration _configuration; + private string _connectionString; + + public ServiceBusAccount(ServiceBusOptions options, IConfiguration configuration, IConnectionProvider connectionProvider = null) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _configuration = configuration; + _connectionProvider = connectionProvider; + } + + internal ServiceBusAccount() + { + } + + public virtual string ConnectionString + { + get + { + if (string.IsNullOrEmpty(_connectionString)) + { + _connectionString = _options.ConnectionString; + if (_connectionProvider != null && !string.IsNullOrEmpty(_connectionProvider.Connection)) + { + _connectionString = _configuration.GetWebJobsConnectionString(_connectionProvider.Connection); + } + + if (string.IsNullOrEmpty(_connectionString)) + { + throw new InvalidOperationException( + string.Format(CultureInfo.InvariantCulture, "Microsoft Azure WebJobs SDK ServiceBus connection string '{0}' is missing or empty.", + Sanitizer.Sanitize(_connectionProvider.Connection) ?? Constants.DefaultConnectionSettingStringName)); + } + } + + return _connectionString; + } + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/ServiceBusAccountAttribute.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/ServiceBusAccountAttribute.cs new file mode 100644 index 000000000000..a1b3afb5d128 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/ServiceBusAccountAttribute.cs @@ -0,0 +1,55 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Azure.WebJobs +{ + /// + /// Attribute used to override the default ServiceBus account used by triggers and binders. + /// + /// + /// This attribute can be applied at the parameter/method/class level, and the precedence + /// is in that order. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Parameter)] + public sealed class ServiceBusAccountAttribute : Attribute, IConnectionProvider + { + /// + /// Constructs a new instance. + /// + /// A string value indicating the Service Bus connection string to use. This + /// string should be in one of the following formats. These checks will be applied in order and the + /// first match wins. + /// - The name of an "AzureWebJobs" prefixed app setting or connection string name. E.g., if your setting + /// name is "AzureWebJobsMyServiceBus", you can specify "MyServiceBus" here. + /// - Can be a string containing %% values (e.g. %StagingServiceBus%). The value provided will be passed + /// to any INameResolver registered on the JobHostConfiguration to resolve the actual setting name to use. + /// - Can be an app setting or connection string name of your choosing. + /// + public ServiceBusAccountAttribute(string account) + { + Account = account; + } + + /// + /// Gets or sets the name of the app setting that contains the Service Bus connection string. + /// + public string Account { get; private set; } + + /// + /// Gets or sets the app setting name that contains the Service Bus connection string. + /// + string IConnectionProvider.Connection + { + get + { + return Account; + } + set + { + Account = value; + } + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/ServiceBusAttribute.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/ServiceBusAttribute.cs new file mode 100644 index 000000000000..29660d254593 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/ServiceBusAttribute.cs @@ -0,0 +1,70 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.Azure.WebJobs.Description; +using Microsoft.Azure.WebJobs.ServiceBus; +using Microsoft.Azure.ServiceBus; + +namespace Microsoft.Azure.WebJobs +{ + /// + /// Attribute used to bind a parameter to Azure ServiceBus Queues and Topics. + /// + ///// + ///// The method parameter type can be one of the following: + ///// + ///// BrokeredMessage (out parameter) + ///// (out parameter) + ///// (out parameter) + ///// A user-defined type (out parameter, serialized as JSON) + ///// + ///// of these types (to enqueue multiple messages via + ///// + ///// + ///// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.ReturnValue)] + [DebuggerDisplay("{QueueOrTopicName,nq}")] + [ConnectionProvider(typeof(ServiceBusAccountAttribute))] + [Binding] + public sealed class ServiceBusAttribute : Attribute, IConnectionProvider + { + /// + /// Initializes a new instance of the class. + /// + /// The name of the queue or topic to bind to. + public ServiceBusAttribute(string queueOrTopicName) + { + QueueOrTopicName = queueOrTopicName; + EntityType = EntityType.Queue; + } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the queue or topic to bind to. + /// The type of the entity to bind to. + public ServiceBusAttribute(string queueOrTopicName, EntityType entityType) + { + QueueOrTopicName = queueOrTopicName; + EntityType = entityType; + } + + /// + /// Gets the name of the queue or topic to bind to. + /// + public string QueueOrTopicName { get; private set; } + + /// + /// Gets or sets the app setting name that contains the Service Bus connection string. + /// + public string Connection { get; set; } + + /// + /// Value indicating the type of the entity to bind to. + /// + public EntityType EntityType { get; set; } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/ServiceBusTriggerAttribute.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/ServiceBusTriggerAttribute.cs new file mode 100644 index 000000000000..0e0d0a00f347 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/ServiceBusTriggerAttribute.cs @@ -0,0 +1,106 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using Microsoft.Azure.WebJobs.Description; +using Microsoft.Azure.ServiceBus; + +namespace Microsoft.Azure.WebJobs +{ + /// + /// Attribute used to bind a parameter to a ServiceBus Queue message, causing the function to run when a + /// message is enqueued. + /// + ///// + ///// The method parameter type can be one of the following: + ///// + ///// BrokeredMessage + ///// + ///// + ///// A user-defined type (serialized as JSON) + ///// + ///// + [AttributeUsage(AttributeTargets.Parameter)] + [DebuggerDisplay("{DebuggerDisplay,nq}")] + [ConnectionProvider(typeof(ServiceBusAccountAttribute))] + [Binding] + public sealed class ServiceBusTriggerAttribute : Attribute, IConnectionProvider + { + private readonly string _queueName; + private readonly string _topicName; + private readonly string _subscriptionName; + + /// + /// Initializes a new instance of the class. + /// + /// The name of the queue to which to bind. + public ServiceBusTriggerAttribute(string queueName) + { + _queueName = queueName; + } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the topic to bind to. + /// The name of the subscription in to bind to. + public ServiceBusTriggerAttribute(string topicName, string subscriptionName) + { + _topicName = topicName; + _subscriptionName = subscriptionName; + } + + /// + /// Gets or sets the app setting name that contains the Service Bus connection string. + /// + public string Connection { get; set; } + + /// + /// Gets the name of the queue to which to bind. + /// + /// When binding to a subscription in a topic, returns . + public string QueueName + { + get { return _queueName; } + } + + /// + /// Gets the name of the topic to which to bind. + /// + /// When binding to a queue, returns . + public string TopicName + { + get { return _topicName; } + } + + /// + /// Gets the name of the subscription in to bind to. + /// + /// When binding to a queue, returns . + public string SubscriptionName + { + get { return _subscriptionName; } + } + + /// + /// Gets or sets a value indicating whether the sessions are enabled. + /// + public bool IsSessionsEnabled { get; set; } + + private string DebuggerDisplay + { + get + { + if (_queueName != null) + { + return _queueName; + } + else + { + return _topicName + "/" + _subscriptionName; + } + } + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/ServiceBusWebJobsStartup.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/ServiceBusWebJobsStartup.cs new file mode 100644 index 000000000000..2b3b10b4f88a --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/ServiceBusWebJobsStartup.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.Azure.WebJobs.ServiceBus; +using Microsoft.Azure.WebJobs.Hosting; +using Microsoft.Extensions.Hosting; + +[assembly: WebJobsStartup(typeof(ServiceBusWebJobsStartup))] + +namespace Microsoft.Azure.WebJobs.ServiceBus +{ + public class ServiceBusWebJobsStartup : IWebJobsStartup + { + public void Configure(IWebJobsBuilder builder) + { + builder.AddServiceBus(); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/SessionMessageProcessor.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/SessionMessageProcessor.cs new file mode 100644 index 000000000000..03f53e075582 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/SessionMessageProcessor.cs @@ -0,0 +1,78 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.WebJobs.Host.Executors; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.WebJobs.ServiceBus +{ + public class SessionMessageProcessor + { + public SessionMessageProcessor(ClientEntity clientEntity, SessionHandlerOptions sessionHandlerOptions) + { + ClientEntity = clientEntity ?? throw new ArgumentNullException(nameof(clientEntity)); + SessionHandlerOptions = sessionHandlerOptions ?? throw new ArgumentNullException(nameof(sessionHandlerOptions)); + } + + /// + /// Gets the that will be used by the . + /// + public SessionHandlerOptions SessionHandlerOptions { get; } + + /// + /// Gets or sets the that will be used by the . + /// + protected ClientEntity ClientEntity { get; set; } + + /// + /// This method is called when there is a new message to process, before the job function is invoked. + /// This allows any preprocessing to take place on the message before processing begins. + /// + /// The session associated with the message. + /// The message to process. + /// The to use. + /// A that returns true if the message processing should continue, false otherwise. + public virtual Task BeginProcessingMessageAsync(IMessageSession session, Message message, CancellationToken cancellationToken) + { + return Task.FromResult(true); + } + + /// + /// This method completes processing of the specified message, after the job function has been invoked. + /// + /// + /// The message is completed by the ServiceBus SDK based on how the option + /// is configured. E.g. if is false, it is up to the job function to complete + /// the message. + /// + /// The session associated with the message. + /// The message to complete processing for. + /// The from the job invocation. + /// The to use + /// A that will complete the message processing. + public virtual Task CompleteProcessingMessageAsync(IMessageSession session, Message message, FunctionResult result, CancellationToken cancellationToken) + { + if (result == null) + { + throw new ArgumentNullException(nameof(result)); + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (!result.Succeeded) + { + // if the invocation failed, we must propagate the + // exception back to SB so it can handle message state + // correctly + throw result.Exception; + } + + return Task.CompletedTask; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/StrictEncodings.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/StrictEncodings.cs new file mode 100644 index 000000000000..e1b4ea6570b9 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/StrictEncodings.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Text; + +namespace Microsoft.Azure.WebJobs.ServiceBus +{ + internal static class StrictEncodings + { + private static UTF8Encoding _utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, + throwOnInvalidBytes: true); + + public static UTF8Encoding Utf8 + { + get { return _utf8; } + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/MessageToByteArrayConverter.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/MessageToByteArrayConverter.cs new file mode 100644 index 000000000000..e8ca4777175e --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/MessageToByteArrayConverter.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Runtime.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Converters; +using Microsoft.Azure.ServiceBus; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Triggers +{ + internal class MessageToByteArrayConverter : IAsyncConverter + { + public Task ConvertAsync(Message input, CancellationToken cancellationToken) + { + if (input == null) + { + throw new ArgumentNullException(nameof(input)); + } + + cancellationToken.ThrowIfCancellationRequested(); + + return Task.FromResult(input.Body); + } + } +} \ No newline at end of file diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/MessageToPocoConverter.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/MessageToPocoConverter.cs new file mode 100644 index 000000000000..48005ed64b21 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/MessageToPocoConverter.cs @@ -0,0 +1,68 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using System.Text; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.ServiceBus.InteropExtensions; +using Newtonsoft.Json; +using System.Globalization; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Triggers +{ + // Convert from Message --> T + internal class MessageToPocoConverter : IConverter + { + public MessageToPocoConverter() + { + } + + public TElement Convert(Message message) + { + // 1. If ContentType is "application/json" deserialize as JSON + // 2. If ContentType is not "application/json" attempt to deserialize using Message.GetBody, which will handle cases like XML object serialization + // 3. If this deserialization fails, do a final attempt at JSON deserialization to catch cases where the content type might be incorrect + + if (message.ContentType == ContentTypes.ApplicationJson) + { + return DeserializeJsonObject(message); + } + else + { + try + { + return message.GetBody(); + } + catch (SerializationException) + { + return DeserializeJsonObject(message); + } + } + } + + private static TElement DeserializeJsonObject(Message message) + { + string contents = StrictEncodings.Utf8.GetString(message.Body); + + try + { + return JsonConvert.DeserializeObject(contents, Constants.JsonSerializerSettings); + } + catch (JsonException e) + { + // Easy to have the queue payload not deserialize properly. So give a useful error. + string msg = string.Format( + CultureInfo.InvariantCulture, +@"Binding parameters to complex objects (such as '{0}') uses Json.NET serialization or XML object serialization. + 1. If ContentType is 'application/json' deserialize as JSON + 2. If ContentType is not 'application/json' attempt to deserialize using Message.GetBody, which will handle cases like XML object serialization + 3. If this deserialization fails, do a final attempt at JSON deserialization to catch cases where the content type might be incorrect +The JSON parser failed: {1} +", typeof(TElement).Name, e.Message); + throw new InvalidOperationException(msg); + } + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/MessageToStringConverter.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/MessageToStringConverter.cs new file mode 100644 index 000000000000..d216925f8519 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/MessageToStringConverter.cs @@ -0,0 +1,69 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.ServiceBus.InteropExtensions; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Triggers +{ + internal class MessageToStringConverter : IAsyncConverter + { + public async Task ConvertAsync(Message input, CancellationToken cancellationToken) + { + if (input == null) + { + throw new ArgumentNullException(nameof(input)); + } + if (input.Body == null) + { + return null; + } + Stream stream = new MemoryStream(input.Body); + + TextReader reader = new StreamReader(stream, StrictEncodings.Utf8); + try + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + return await reader.ReadToEndAsync().ConfigureAwait(false); + } + catch (DecoderFallbackException) + { + // we'll try again below + } + + // We may get here if the message is a string yet was DataContract-serialized when created. We'll + // try to deserialize it here using GetBody(). This may fail as well, in which case we'll + // provide a decent error. + + try + { + return input.GetBody(); + } + catch + { + // always possible to get a valid string from the message + return Encoding.UTF8.GetString(input.Body, 0, input.Body.Length); + } + } + finally + { + if (stream != null) + { + stream.Dispose(); + } + if (reader != null) + { + reader.Dispose(); + } + } + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/OutputConverter.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/OutputConverter.cs new file mode 100644 index 000000000000..771806d38ce5 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/OutputConverter.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.Azure.WebJobs.Host.Converters; +using Microsoft.Azure.ServiceBus; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Triggers +{ + internal class OutputConverter : IObjectToTypeConverter + where TInput : class + { + private readonly IConverter _innerConverter; + + public OutputConverter(IConverter innerConverter) + { + _innerConverter = innerConverter; + } + + public bool TryConvert(object input, out Message output) + { + TInput typedInput = input as TInput; + + if (typedInput == null) + { + output = null; + return false; + } + + output = _innerConverter.Convert(typedInput); + return true; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/ServiceBusTriggerAttributeBindingProvider.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/ServiceBusTriggerAttributeBindingProvider.cs new file mode 100644 index 000000000000..274bae2e28a6 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/ServiceBusTriggerAttributeBindingProvider.cs @@ -0,0 +1,103 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Globalization; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.WebJobs.Host; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Azure.WebJobs.Host.Listeners; +using Microsoft.Azure.WebJobs.Host.Protocols; +using Microsoft.Azure.WebJobs.Host.Triggers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Azure.WebJobs.ServiceBus.Listeners; +using Microsoft.Azure.WebJobs.Host.Config; + +namespace Microsoft.Azure.WebJobs.ServiceBus.Triggers +{ + internal class ServiceBusTriggerAttributeBindingProvider : ITriggerBindingProvider + { + private readonly INameResolver _nameResolver; + private readonly ServiceBusOptions _options; + private readonly MessagingProvider _messagingProvider; + private readonly IConfiguration _configuration; + private readonly ILoggerFactory _loggerFactory; + private readonly IConverterManager _converterManager; + + public ServiceBusTriggerAttributeBindingProvider(INameResolver nameResolver, ServiceBusOptions options, MessagingProvider messagingProvider, IConfiguration configuration, + ILoggerFactory loggerFactory, IConverterManager converterManager) + { + _nameResolver = nameResolver ?? throw new ArgumentNullException(nameof(nameResolver)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _messagingProvider = messagingProvider ?? throw new ArgumentNullException(nameof(messagingProvider)); + _configuration = configuration; + _loggerFactory = loggerFactory; + _converterManager = converterManager; + } + + public Task TryCreateAsync(TriggerBindingProviderContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + ParameterInfo parameter = context.Parameter; + var attribute = TypeUtility.GetResolvedAttribute(parameter); + + if (attribute == null) + { + return Task.FromResult(null); + } + + string queueName = null; + string topicName = null; + string subscriptionName = null; + string entityPath = null; + EntityType entityType; + + if (attribute.QueueName != null) + { + queueName = Resolve(attribute.QueueName); + entityPath = queueName; + entityType = EntityType.Queue; + } + else + { + topicName = Resolve(attribute.TopicName); + subscriptionName = Resolve(attribute.SubscriptionName); + entityPath = EntityNameHelper.FormatSubscriptionPath(topicName, subscriptionName); + entityType = EntityType.Topic; + } + + attribute.Connection = Resolve(attribute.Connection); + ServiceBusAccount account = new ServiceBusAccount(_options, _configuration, attribute); + + Func> createListener = + (factoryContext, singleDispatch) => + { + IListener listener = new ServiceBusListener(factoryContext.Descriptor.Id, entityType, entityPath, attribute.IsSessionsEnabled, factoryContext.Executor, _options, account, _messagingProvider, _loggerFactory, singleDispatch); + return Task.FromResult(listener); + }; + +#pragma warning disable 618 + ITriggerBinding binding = BindingFactory.GetTriggerBinding(new ServiceBusTriggerBindingStrategy(), parameter, _converterManager, createListener); +#pragma warning restore 618 + + return Task.FromResult(binding); + } + + private string Resolve(string queueName) + { + if (_nameResolver == null) + { + return queueName; + } + + return _nameResolver.ResolveWholeString(queueName); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/ServiceBusTriggerBindingStrategy.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/ServiceBusTriggerBindingStrategy.cs new file mode 100644 index 000000000000..d5cb7abaa8ee --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/ServiceBusTriggerBindingStrategy.cs @@ -0,0 +1,185 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.ServiceBus.Core; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Azure.WebJobs.Host.Triggers; + +namespace Microsoft.Azure.WebJobs.ServiceBus +{ + // Binding strategy for a service bus triggers. +#pragma warning disable 618 + internal class ServiceBusTriggerBindingStrategy : ITriggerBindingStrategy +#pragma warning restore 618 + { + public ServiceBusTriggerInput ConvertFromString(string input) + { + byte[] bytes = Encoding.UTF8.GetBytes(input); + Message message = new Message(bytes); + + // Return a single message. Doesn't support multiple dispatch + return ServiceBusTriggerInput.CreateSingle(message); + } + + // Single instance: Core --> Message + public Message BindSingle(ServiceBusTriggerInput value, ValueBindingContext context) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + return value.Messages[0]; + } + + public Message[] BindMultiple(ServiceBusTriggerInput value, ValueBindingContext context) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + return value.Messages; + } + + public Dictionary GetBindingData(ServiceBusTriggerInput value) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + var bindingData = new Dictionary(StringComparer.OrdinalIgnoreCase); + SafeAddValue(() => bindingData.Add(nameof(value.MessageReceiver), value.MessageReceiver as MessageReceiver)); + SafeAddValue(() => bindingData.Add("MessageSession", value.MessageReceiver as IMessageSession)); + + if (value.IsSingleDispatch) + { + AddBindingData(bindingData, value.Messages[0]); + } + else + { + AddBindingData(bindingData, value.Messages); + } + + return bindingData; + } + + public Dictionary GetBindingContract(bool isSingleDispatch = true) + { + var contract = new Dictionary(StringComparer.OrdinalIgnoreCase); + AddBindingContractMember(contract, "DeliveryCount", typeof(int), isSingleDispatch); + AddBindingContractMember(contract, "DeadLetterSource", typeof(string), isSingleDispatch); + AddBindingContractMember(contract, "LockToken", typeof(string), isSingleDispatch); + AddBindingContractMember(contract, "ExpiresAtUtc", typeof(DateTime), isSingleDispatch); + AddBindingContractMember(contract, "EnqueuedTimeUtc", typeof(DateTime), isSingleDispatch); + AddBindingContractMember(contract, "MessageId", typeof(string), isSingleDispatch); + AddBindingContractMember(contract, "ContentType", typeof(string), isSingleDispatch); + AddBindingContractMember(contract, "ReplyTo", typeof(string), isSingleDispatch); + AddBindingContractMember(contract, "SequenceNumber", typeof(long), isSingleDispatch); + AddBindingContractMember(contract, "To", typeof(string), isSingleDispatch); + AddBindingContractMember(contract, "Label", typeof(string), isSingleDispatch); + AddBindingContractMember(contract, "CorrelationId", typeof(string), isSingleDispatch); + AddBindingContractMember(contract, "UserProperties", typeof(IDictionary), isSingleDispatch); + contract.Add("MessageReceiver", typeof(MessageReceiver)); + contract.Add("MessageSession", typeof(IMessageSession)); + + return contract; + } + + internal static void AddBindingData(Dictionary bindingData, Message[] messages) + { + int length = messages.Length; + var deliveryCounts = new int[length]; + var deadLetterSources = new string[length]; + var lockTokens = new string[length]; + var expiresAtUtcs = new DateTime[length]; + var enqueuedTimeUtcs = new DateTime[length]; + var messageIds = new string[length]; + var contentTypes = new string[length]; + var replyTos = new string[length]; + var sequenceNumbers = new long[length]; + var tos = new string[length]; + var labels = new string[length]; + var correlationIds = new string[length]; + var userProperties = new IDictionary[length]; + + SafeAddValue(() => bindingData.Add("DeliveryCountArray", deliveryCounts)); + SafeAddValue(() => bindingData.Add("DeadLetterSourceArray", deadLetterSources)); + SafeAddValue(() => bindingData.Add("LockTokenArray", lockTokens)); + SafeAddValue(() => bindingData.Add("ExpiresAtUtcArray", expiresAtUtcs)); + SafeAddValue(() => bindingData.Add("EnqueuedTimeUtcArray", enqueuedTimeUtcs)); + SafeAddValue(() => bindingData.Add("MessageIdArray", messageIds)); + SafeAddValue(() => bindingData.Add("ContentTypeArray", contentTypes)); + SafeAddValue(() => bindingData.Add("ReplyToArray", replyTos)); + SafeAddValue(() => bindingData.Add("SequenceNumberArray", sequenceNumbers)); + SafeAddValue(() => bindingData.Add("ToArray", tos)); + SafeAddValue(() => bindingData.Add("LabelArray", labels)); + SafeAddValue(() => bindingData.Add("CorrelationIdArray", correlationIds)); + SafeAddValue(() => bindingData.Add("UserPropertiesArray", userProperties)); + + for (int i = 0; i < messages.Length; i++) + { + deliveryCounts[i] = messages[i].SystemProperties.DeliveryCount; + deadLetterSources[i] = messages[i].SystemProperties.DeadLetterSource; + lockTokens[i] = messages[i].SystemProperties.IsLockTokenSet ? messages[i].SystemProperties.LockToken : string.Empty; + //this is temporary until the Service Bus SDK addresses the missing timezone issue in case DateTime.MaxValue, github.com/Azure/azure-sdk-for-net/issues/15343 + expiresAtUtcs[i] = messages[i].ExpiresAtUtc.ToUniversalTime(); + enqueuedTimeUtcs[i] = messages[i].SystemProperties.EnqueuedTimeUtc; + messageIds[i] = messages[i].MessageId; + contentTypes[i] = messages[i].ContentType; + replyTos[i] = messages[i].ReplyTo; + sequenceNumbers[i] = messages[i].SystemProperties.SequenceNumber; + tos[i] = messages[i].To; + labels[i] = messages[i].Label; + correlationIds[i] = messages[i].CorrelationId; + userProperties[i] = messages[i].UserProperties; + } + } + + private static void AddBindingData(Dictionary bindingData, Message value) + { + SafeAddValue(() => bindingData.Add(nameof(value.SystemProperties.DeliveryCount), value.SystemProperties.DeliveryCount)); + SafeAddValue(() => bindingData.Add(nameof(value.SystemProperties.DeadLetterSource), value.SystemProperties.DeadLetterSource)); + SafeAddValue(() => bindingData.Add(nameof(value.SystemProperties.LockToken), value.SystemProperties.IsLockTokenSet ? value.SystemProperties.LockToken : string.Empty)); + //this is temporary until the Service Bus SDK addresses the missing timezone issue in case DateTime.MaxValue, github.com/Azure/azure-sdk-for-net/issues/15343 + SafeAddValue(() => bindingData.Add(nameof(value.ExpiresAtUtc), value.ExpiresAtUtc.ToUniversalTime())); + SafeAddValue(() => bindingData.Add(nameof(value.SystemProperties.EnqueuedTimeUtc), value.SystemProperties.EnqueuedTimeUtc)); + SafeAddValue(() => bindingData.Add(nameof(value.MessageId), value.MessageId)); + SafeAddValue(() => bindingData.Add(nameof(value.ContentType), value.ContentType)); + SafeAddValue(() => bindingData.Add(nameof(value.ReplyTo), value.ReplyTo)); + SafeAddValue(() => bindingData.Add(nameof(value.SystemProperties.SequenceNumber), value.SystemProperties.SequenceNumber)); + SafeAddValue(() => bindingData.Add(nameof(value.To), value.To)); + SafeAddValue(() => bindingData.Add(nameof(value.Label), value.Label)); + SafeAddValue(() => bindingData.Add(nameof(value.CorrelationId), value.CorrelationId)); + SafeAddValue(() => bindingData.Add(nameof(value.UserProperties), value.UserProperties)); + } + + private static void SafeAddValue(Action addValue) + { + try + { + addValue(); + } + catch + { + // some message propery getters can throw, based on the + // state of the message + } + } + + private static void AddBindingContractMember(Dictionary contract, string name, Type type, bool isSingleDispatch) + { + if (!isSingleDispatch) + { + name += "Array"; + } + + contract.Add(name, isSingleDispatch ? type : type.MakeArrayType()); + } + } +} \ No newline at end of file diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/ServiceBusTriggerInput.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/ServiceBusTriggerInput.cs new file mode 100644 index 000000000000..10b705aaef6d --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Triggers/ServiceBusTriggerInput.cs @@ -0,0 +1,118 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.ServiceBus.Core; +using Microsoft.Azure.WebJobs.Host.Executors; +using Microsoft.Azure.WebJobs.ServiceBus.Listeners; +using System.Globalization; + +namespace Microsoft.Azure.WebJobs.ServiceBus +{ + // The core object we get when an ServiceBus is triggered. + // This gets converted to the user type (Message, string, poco, etc) + internal sealed class ServiceBusTriggerInput + { + private bool _isSingleDispatch; + + private ServiceBusTriggerInput() { } + + public IMessageReceiver MessageReceiver; + + public Message[] Messages { get; set; } + + public static ServiceBusTriggerInput CreateSingle(Message message) + { + return new ServiceBusTriggerInput + { + Messages = new Message[] + { + message + }, + _isSingleDispatch = true + }; + } + + public static ServiceBusTriggerInput CreateBatch(Message[] messages) + { + return new ServiceBusTriggerInput + { + Messages = messages, + _isSingleDispatch = false + }; + } + + public bool IsSingleDispatch + { + get + { + return _isSingleDispatch; + } + } + + public TriggeredFunctionData GetTriggerFunctionData() + { + if (Messages.Length > 0) + { + Message message = Messages[0]; + if (IsSingleDispatch) + { + Guid? parentId = ServiceBusCausalityHelper.GetOwner(message); + return new TriggeredFunctionData() + { + ParentId = parentId, + TriggerValue = this, + TriggerDetails = new Dictionary() + { + { "MessageId", message.MessageId }, + { "SequenceNumber", message.SystemProperties.SequenceNumber.ToString(CultureInfo.InvariantCulture) }, + { "DeliveryCount", message.SystemProperties.DeliveryCount.ToString(CultureInfo.InvariantCulture) }, + { "EnqueuedTimeUtc", message.SystemProperties.EnqueuedTimeUtc.ToUniversalTime().ToString("o") }, + { "LockedUntilUtc", message.SystemProperties.LockedUntilUtc.ToUniversalTime().ToString("o") }, + { "SessionId", message.SessionId } + } + }; + } + else + { + Guid? parentId = ServiceBusCausalityHelper.GetOwner(message); + + int length = Messages.Length; + string[] messageIds = new string[length]; + int[] deliveryCounts = new int[length]; + long[] sequenceNumbers = new long[length]; + string[] enqueuedTimes = new string[length]; + string[] lockedUntils = new string[length]; + string sessionId = Messages[0].SessionId; + + for (int i = 0; i < Messages.Length; i++) + { + messageIds[i] = Messages[i].MessageId; + sequenceNumbers[i] = Messages[i].SystemProperties.SequenceNumber; + deliveryCounts[i] = Messages[i].SystemProperties.DeliveryCount; + enqueuedTimes[i] = Messages[i].SystemProperties.EnqueuedTimeUtc.ToUniversalTime().ToString("o"); + lockedUntils[i] = Messages[i].SystemProperties.LockedUntilUtc.ToUniversalTime().ToString("o"); + } + + return new TriggeredFunctionData() + { + ParentId = parentId, + TriggerValue = this, + TriggerDetails = new Dictionary() + { + { "MessageIdArray", string.Join(",", messageIds)}, + { "SequenceNumberArray", string.Join(",", sequenceNumbers)}, + { "DeliveryCountArray", string.Join(",", deliveryCounts) }, + { "EnqueuedTimeUtcArray", string.Join(",", enqueuedTimes) }, + { "LockedUntilArray", string.Join(",", lockedUntils) }, + { "SessionId", sessionId } + } + }; + } + } + return null; + } + } +} \ No newline at end of file diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Utility.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Utility.cs new file mode 100644 index 000000000000..e2d33319a506 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/Utility.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Azure.WebJobs.ServiceBus +{ + internal class Utility + { + /// + /// Returns processor count for a worker, for consumption plan always returns 1 + /// + /// + public static int GetProcessorCount() + { + string skuValue = Environment.GetEnvironmentVariable(Constants.AzureWebsiteSku); + return string.Equals(skuValue, Constants.DynamicSku, StringComparison.OrdinalIgnoreCase) ? 1 : Environment.ProcessorCount; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/sign.snk b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/sign.snk new file mode 100644 index 000000000000..695f1b38774e Binary files /dev/null and b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/src/sign.snk differ diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/stylecop.json b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/stylecop.json new file mode 100644 index 000000000000..2b4b4bba86bc --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/stylecop.json @@ -0,0 +1,24 @@ +{ + // ACTION REQUIRED: This file was automatically added to your project, but it + // will not take effect until additional steps are taken to enable it. See the + // following page for additional information: + // + // https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/EnableConfiguration.md + + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "documentationRules": { + "companyName": ".NET Foundation", + "copyrightText": "Copyright (c) .NET Foundation. All rights reserved.\r\nLicensed under the MIT License. See License.txt in the project root for license information.", + "xmlHeader": false, + "documentInterfaces": false, + "documentInternalElements": false, + "documentExposedElements": false, + "documentPrivateElements": false, + "documentPrivateFields": false + }, + "orderingRules": { + "usingDirectivesPlacement": "outsideNamespace" + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Bindings/BindableServiceBusPathTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Bindings/BindableServiceBusPathTests.cs new file mode 100644 index 000000000000..042b47b6e3a4 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Bindings/BindableServiceBusPathTests.cs @@ -0,0 +1,53 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.Azure.WebJobs.Host.TestCommon; +using Microsoft.Azure.WebJobs.ServiceBus.Bindings; +using Xunit; + +namespace Microsoft.Azure.WebJobs.ServiceBus.UnitTests.Bindings +{ + public class BindableServiceBusPathTests + { + [Fact] + public void Create_IfNonParameterizedPattern_ReturnsBoundPath() + { + const string queueOrTopicNamePattern = "queue-name-with-no-parameters"; + + IBindableServiceBusPath path = BindableServiceBusPath.Create(queueOrTopicNamePattern); + + Assert.NotNull(path); + Assert.Equal(queueOrTopicNamePattern, path.QueueOrTopicNamePattern); + Assert.True(path.IsBound); + } + + [Fact] + public void Create_IfParameterizedPattern_ReturnsNotBoundPath() + { + const string queueOrTopicNamePattern = "queue-{name}-with-{parameter}"; + + IBindableServiceBusPath path = BindableServiceBusPath.Create(queueOrTopicNamePattern); + + Assert.NotNull(path); + Assert.Equal(queueOrTopicNamePattern, path.QueueOrTopicNamePattern); + Assert.False(path.IsBound); + } + + [Fact] + public void Create_IfNullPattern_Throws() + { + ExceptionAssert.ThrowsArgumentNull(() => BindableServiceBusPath.Create(null), "queueOrTopicNamePattern"); + } + + [Fact] + public void Create_IfMalformedPattern_PropagatesThrownException() + { + const string queueNamePattern = "malformed-queue-{name%"; + + ExceptionAssert.ThrowsFormat( + () => BindableServiceBusPath.Create(queueNamePattern), + "Invalid template 'malformed-queue-{name%'. Missing closing bracket at position 17."); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Bindings/BoundServiceBusTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Bindings/BoundServiceBusTests.cs new file mode 100644 index 000000000000..1db42ffba03a --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Bindings/BoundServiceBusTests.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.ServiceBus.Bindings; +using Xunit; + +namespace Microsoft.Azure.WebJobs.ServiceBus.UnitTests.Bindings +{ + public class BoundServiceBusTests + { + [Fact] + public void Bind_IfNotNullBindingData_ReturnsResolvedQueueName() + { + const string queueOrTopicNamePattern = "queue-name-with-no-parameters"; + var bindingData = new Dictionary { { "name", "value" } }; + IBindableServiceBusPath path = new BoundServiceBusPath(queueOrTopicNamePattern); + + string result = path.Bind(bindingData); + + Assert.Equal(queueOrTopicNamePattern, result); + } + + [Fact] + public void Bind_IfNullBindingData_ReturnsResolvedQueueName() + { + const string queueOrTopicNamePattern = "queue-name-with-no-parameters"; + IBindableServiceBusPath path = new BoundServiceBusPath(queueOrTopicNamePattern); + + string result = path.Bind(null); + + Assert.Equal(queueOrTopicNamePattern, result); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Bindings/ParameterizedServiceBusPathTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Bindings/ParameterizedServiceBusPathTests.cs new file mode 100644 index 000000000000..a4a3a3b499ce --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Bindings/ParameterizedServiceBusPathTests.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Azure.WebJobs.Host.Bindings.Path; +using Microsoft.Azure.WebJobs.Host.TestCommon; +using Microsoft.Azure.WebJobs.ServiceBus.Bindings; +using Xunit; + +namespace Microsoft.Azure.WebJobs.ServiceBus.UnitTests.Bindings +{ + public class ParameterizedServiceBusPathTests + { + [Fact] + public void Bind_IfNotNullBindingData_ReturnsResolvedQueueName() + { + const string queueOrTopicNamePattern = "queue-{name}-with-{parameter}"; + var bindingData = new Dictionary { { "name", "name" }, { "parameter", "parameter" } }; + IBindableServiceBusPath path = CreateProductUnderTest(queueOrTopicNamePattern); + + string result = path.Bind(bindingData); + + Assert.Equal("queue-name-with-parameter", result); + } + + [Fact] + public void Bind_IfNullBindingData_Throws() + { + const string queueOrTopicNamePattern = "queue-{name}-with-{parameter}"; + IBindableServiceBusPath path = CreateProductUnderTest(queueOrTopicNamePattern); + + ExceptionAssert.ThrowsArgumentNull(() => path.Bind(null), "bindingData"); + } + + private static IBindableServiceBusPath CreateProductUnderTest(string queueOrTopicNamePattern) + { + BindingTemplate template = BindingTemplate.FromString(queueOrTopicNamePattern); + IBindableServiceBusPath path = new ParameterizedServiceBusPath(template); + return path; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Bindings/ServiceBusAttributeBindingProviderTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Bindings/ServiceBusAttributeBindingProviderTests.cs new file mode 100644 index 000000000000..e57eb45824f8 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Bindings/ServiceBusAttributeBindingProviderTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Azure.WebJobs.ServiceBus.Bindings; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Microsoft.Azure.WebJobs.ServiceBus.UnitTests.Bindings +{ + public class ServiceBusAttributeBindingProviderTests + { + private readonly ServiceBusAttributeBindingProvider _provider; + private readonly IConfiguration _configuration; + + public ServiceBusAttributeBindingProviderTests() + { + _configuration = new ConfigurationBuilder() + .AddEnvironmentVariables() + .Build(); + Mock mockResolver = new Mock(MockBehavior.Strict); + ServiceBusOptions config = new ServiceBusOptions(); + _provider = new ServiceBusAttributeBindingProvider(mockResolver.Object, config, _configuration, new MessagingProvider(new OptionsWrapper(config))); + } + + [Fact] + public async Task TryCreateAsync_AccountOverride_OverrideIsApplied() + { + ParameterInfo parameter = GetType().GetMethod("TestJob_AccountOverride", BindingFlags.NonPublic | BindingFlags.Static).GetParameters()[0]; + BindingProviderContext context = new BindingProviderContext(parameter, new Dictionary(), CancellationToken.None); + + IBinding binding = await _provider.TryCreateAsync(context); + + Assert.NotNull(binding); + } + + [Fact] + public async Task TryCreateAsync_DefaultAccount() + { + ParameterInfo parameter = GetType().GetMethod("TestJob", BindingFlags.NonPublic | BindingFlags.Static).GetParameters()[0]; + BindingProviderContext context = new BindingProviderContext(parameter, new Dictionary(), CancellationToken.None); + + IBinding binding = await _provider.TryCreateAsync(context); + + Assert.NotNull(binding); + } + + internal static void TestJob_AccountOverride( + [ServiceBusAttribute("test"), + ServiceBusAccount(Constants.DefaultConnectionStringName)] out Message message) + { + message = new Message(); + } + + internal static void TestJob( + [ServiceBusAttribute("test", Connection = Constants.DefaultConnectionStringName)] out Message message) + { + message = new Message(); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Bindings/ServiceBusTriggerAttributeBindingProviderTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Bindings/ServiceBusTriggerAttributeBindingProviderTests.cs new file mode 100644 index 000000000000..b56560ee13d6 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Bindings/ServiceBusTriggerAttributeBindingProviderTests.cs @@ -0,0 +1,74 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.WebJobs.Host.Triggers; +using Microsoft.Azure.WebJobs.ServiceBus.Triggers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Microsoft.Azure.WebJobs.ServiceBus.UnitTests.Bindings +{ + public class ServiceBusTriggerAttributeBindingProviderTests + { + private readonly Mock _mockMessagingProvider; + private readonly ServiceBusTriggerAttributeBindingProvider _provider; + private readonly IConfiguration _configuration; + + public ServiceBusTriggerAttributeBindingProviderTests() + { + _configuration = new ConfigurationBuilder() + .AddEnvironmentVariables() + .Build(); + Mock mockResolver = new Mock(MockBehavior.Strict); + + ServiceBusOptions config = new ServiceBusOptions(); + _mockMessagingProvider = new Mock(MockBehavior.Strict, new OptionsWrapper(config)); + + Mock convertManager = new Mock(MockBehavior.Default); + + _provider = new ServiceBusTriggerAttributeBindingProvider(mockResolver.Object, config, _mockMessagingProvider.Object, _configuration, NullLoggerFactory.Instance, convertManager.Object); + } + + [Fact] + public async Task TryCreateAsync_AccountOverride_OverrideIsApplied() + { + ParameterInfo parameter = GetType().GetMethod("TestJob_AccountOverride", BindingFlags.NonPublic | BindingFlags.Static).GetParameters()[0]; + TriggerBindingProviderContext context = new TriggerBindingProviderContext(parameter, CancellationToken.None); + + ITriggerBinding binding = await _provider.TryCreateAsync(context); + + Assert.NotNull(binding); + } + + [Fact] + public async Task TryCreateAsync_DefaultAccount() + { + ParameterInfo parameter = GetType().GetMethod("TestJob", BindingFlags.NonPublic | BindingFlags.Static).GetParameters()[0]; + TriggerBindingProviderContext context = new TriggerBindingProviderContext(parameter, CancellationToken.None); + + ITriggerBinding binding = await _provider.TryCreateAsync(context); + + Assert.NotNull(binding); + } + + internal static void TestJob_AccountOverride( + [ServiceBusTriggerAttribute("test"), + ServiceBusAccount(Constants.DefaultConnectionStringName)] Message message) + { + message = new Message(); + } + + internal static void TestJob( + [ServiceBusTriggerAttribute("test", Connection = Constants.DefaultConnectionStringName)] Message message) + { + message = new Message(); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Config/ServiceBusHostBuilderExtensionsTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Config/ServiceBusHostBuilderExtensionsTests.cs new file mode 100644 index 000000000000..385918fc3f2d --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Config/ServiceBusHostBuilderExtensionsTests.cs @@ -0,0 +1,131 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.WebJobs.Host; +using Microsoft.Azure.WebJobs.Host.Config; +using Microsoft.Azure.WebJobs.Host.TestCommon; +using Microsoft.Azure.WebJobs.ServiceBus.Config; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.EnvironmentVariables; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.Azure.WebJobs.ServiceBus.UnitTests.Config +{ + public class ServiceBusHostBuilderExtensionsTests + { + [Fact] + public void ConfigureOptions_AppliesValuesCorrectly() + { + string extensionPath = "AzureWebJobs:Extensions:ServiceBus"; + var values = new Dictionary + { + { $"{extensionPath}:PrefetchCount", "123" }, + { $"ConnectionStrings:ServiceBus", "TestConnectionString" }, + { $"{extensionPath}:MessageHandlerOptions:MaxConcurrentCalls", "123" }, + { $"{extensionPath}:MessageHandlerOptions:AutoComplete", "false" }, + { $"{extensionPath}:MessageHandlerOptions:MaxAutoRenewDuration", "00:00:15" } + }; + + ServiceBusOptions options = TestHelpers.GetConfiguredOptions(b => + { + b.AddServiceBus(); + }, values); + + Assert.Equal(123, options.PrefetchCount); + Assert.Equal("TestConnectionString", options.ConnectionString); + Assert.Equal(123, options.MessageHandlerOptions.MaxConcurrentCalls); + Assert.False(options.MessageHandlerOptions.AutoComplete); + Assert.Equal(TimeSpan.FromSeconds(15), options.MessageHandlerOptions.MaxAutoRenewDuration); + } + + [Fact] + public void AddServiceBus_ThrowsArgumentNull_WhenServiceBusOptionsIsNull() + { + IHost host = new HostBuilder() + .ConfigureDefaultTestHost(b => + { + b.AddServiceBus(); + }) + .ConfigureServices(s => s.AddSingleton>(p => null)) + .Build(); + + var exception = Assert.Throws(() => host.Services.GetServices()); + + Assert.Equal("serviceBusOptions", exception.ParamName); + } + + [Fact] + public void AddServiceBus_NoServiceBusOptions_PerformsExpectedRegistration() + { + IHost host = new HostBuilder() + .ConfigureDefaultTestHost(b => + { + b.AddServiceBus(); + }) + .Build(); + + var extensions = host.Services.GetService(); + IExtensionConfigProvider[] configProviders = extensions.GetExtensions().ToArray(); + + // verify that the service bus config provider was registered + var serviceBusExtensionConfig = configProviders.OfType().Single(); + } + + [Fact] + public void AddServiceBus_ServiceBusOptionsProvided_PerformsExpectedRegistration() + { + string fakeConnStr = "test service bus connection"; + + IHost host = new HostBuilder() + .ConfigureDefaultTestHost(b => + { + b.AddServiceBus(); + }) + .ConfigureServices(s => + { + s.Configure(o => + { + o.ConnectionString = fakeConnStr; + }); + }) + .Build(); + + // verify that the service bus config provider was registered + var extensions = host.Services.GetService(); + IExtensionConfigProvider[] configProviders = extensions.GetExtensions().ToArray(); + + // verify that the service bus config provider was registered + var serviceBusExtensionConfig = configProviders.OfType().Single(); + + Assert.Equal(fakeConnStr, serviceBusExtensionConfig.Options.ConnectionString); + } + [Theory] + [InlineData("DefaultConnectionString", "DefaultConectionSettingString", "DefaultConnectionString")] + [InlineData("DefaultConnectionString", null, "DefaultConnectionString")] + [InlineData(null, "DefaultConectionSettingString", "DefaultConectionSettingString")] + [InlineData(null, null, null)] + public void ReadDeafultConnectionString(string defaultConnectionString, string sefaultConectionSettingString, string expectedValue) + { + ServiceBusOptions options = TestHelpers.GetConfiguredOptions(b => + { + var test = b.Services.Single(x => x.ServiceType == typeof(IConfiguration)); + + var envPrpvider = (test.ImplementationInstance as ConfigurationRoot).Providers + .Single(x => x.GetType() == typeof(EnvironmentVariablesConfigurationProvider)); + envPrpvider.Set("ConnectionStrings:" + Constants.DefaultConnectionStringName, defaultConnectionString); + envPrpvider.Set(Constants.DefaultConnectionSettingStringName, sefaultConectionSettingString); + + b.AddServiceBus(); + }, new Dictionary()); + + Assert.Equal(options.ConnectionString, expectedValue); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Config/ServiceBusOptionsTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Config/ServiceBusOptionsTests.cs new file mode 100644 index 000000000000..b33fe365fd8b --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Config/ServiceBusOptionsTests.cs @@ -0,0 +1,88 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.Azure.ServiceBus; +//using Microsoft.Azure.WebJobs.Host.TestCommon; +using Microsoft.Azure.WebJobs.Logging; +using Microsoft.Azure.WebJobs.ServiceBus.Config; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Microsoft.Azure.WebJobs.ServiceBus.UnitTests.Config +{ + public class ServiceBusOptionsTests + { + //private readonly ILoggerFactory _loggerFactory; + //private readonly TestLoggerProvider _loggerProvider; + + //public ServiceBusOptionsTests() + //{ + // _loggerFactory = new LoggerFactory(); + // _loggerProvider = new TestLoggerProvider(); + // _loggerFactory.AddProvider(_loggerProvider); + //} + + //[Fact] + //public void Constructor_SetsExpectedDefaults() + //{ + // ServiceBusOptions config = new ServiceBusOptions(); + // Assert.Equal(16 * Utility.GetProcessorCount(), config.MessageHandlerOptions.MaxConcurrentCalls); + // Assert.Equal(0, config.PrefetchCount); + //} + + //[Fact] + //public void PrefetchCount_GetSet() + //{ + // ServiceBusOptions config = new ServiceBusOptions(); + // Assert.Equal(0, config.PrefetchCount); + // config.PrefetchCount = 100; + // Assert.Equal(100, config.PrefetchCount); + //} + + //[Fact] + //public void LogExceptionReceivedEvent_NonTransientEvent_LoggedAsError() + //{ + // var ex = new ServiceBusException(false); + // Assert.False(ex.IsTransient); + // ExceptionReceivedEventArgs e = new ExceptionReceivedEventArgs(ex, "TestAction", "TestEndpoint", "TestEntity", "TestClient"); + // ServiceBusExtensionConfigProvider.LogExceptionReceivedEvent(e, _loggerFactory); + + // var expectedMessage = $"Message processing error (Action=TestAction, ClientId=TestClient, EntityPath=TestEntity, Endpoint=TestEndpoint)"; + // var logMessage = _loggerProvider.GetAllLogMessages().Single(); + // Assert.Equal(LogLevel.Error, logMessage.Level); + // Assert.Same(ex, logMessage.Exception); + // Assert.Equal(expectedMessage, logMessage.FormattedMessage); + //} + + //[Fact] + //public void LogExceptionReceivedEvent_TransientEvent_LoggedAsInformation() + //{ + // var ex = new ServiceBusException(true); + // Assert.True(ex.IsTransient); + // ExceptionReceivedEventArgs e = new ExceptionReceivedEventArgs(ex, "TestAction", "TestEndpoint", "TestEntity", "TestClient"); + // ServiceBusExtensionConfigProvider.LogExceptionReceivedEvent(e, _loggerFactory); + + // var expectedMessage = $"Message processing error (Action=TestAction, ClientId=TestClient, EntityPath=TestEntity, Endpoint=TestEndpoint)"; + // var logMessage = _loggerProvider.GetAllLogMessages().Single(); + // Assert.Equal(LogLevel.Information, logMessage.Level); + // Assert.Same(ex, logMessage.Exception); + // Assert.Equal(expectedMessage, logMessage.FormattedMessage); + //} + + //[Fact] + //public void LogExceptionReceivedEvent_NonMessagingException_LoggedAsError() + //{ + // var ex = new MissingMethodException("What method??"); + // ExceptionReceivedEventArgs e = new ExceptionReceivedEventArgs(ex, "TestAction", "TestEndpoint", "TestEntity", "TestClient"); + // ServiceBusExtensionConfigProvider.LogExceptionReceivedEvent(e, _loggerFactory); + + // var expectedMessage = $"Message processing error (Action=TestAction, ClientId=TestClient, EntityPath=TestEntity, Endpoint=TestEndpoint)"; + // var logMessage = _loggerProvider.GetAllLogMessages().Single(); + // Assert.Equal(LogLevel.Error, logMessage.Level); + // Assert.Same(ex, logMessage.Exception); + // Assert.Equal(expectedMessage, logMessage.FormattedMessage); + //} + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Listeners/ServiceBusListenerTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Listeners/ServiceBusListenerTests.cs new file mode 100644 index 000000000000..5f0f4e72950c --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Listeners/ServiceBusListenerTests.cs @@ -0,0 +1,135 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.ServiceBus.Core; +using Microsoft.Azure.WebJobs.Host.Executors; +using Microsoft.Azure.WebJobs.Host.Scale; +using Microsoft.Azure.WebJobs.Host.TestCommon; +using Microsoft.Azure.WebJobs.ServiceBus.Listeners; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; +using static Microsoft.Azure.ServiceBus.Message; + +namespace Microsoft.Azure.WebJobs.ServiceBus.UnitTests.Listeners +{ + public class ServiceBusListenerTests + { + private readonly ServiceBusListener _listener; + private readonly Mock _mockExecutor; + private readonly Mock _mockMessagingProvider; + private readonly Mock _mockMessageProcessor; + private readonly TestLoggerProvider _loggerProvider; + private readonly LoggerFactory _loggerFactory; + private readonly string _functionId = "test-functionid"; + private readonly string _entityPath = "test-entity-path"; + private readonly string _testConnection = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123="; + + public ServiceBusListenerTests() + { + _mockExecutor = new Mock(MockBehavior.Strict); + + MessageHandlerOptions messageOptions = new MessageHandlerOptions(ExceptionReceivedHandler); + MessageReceiver messageReceiver = new MessageReceiver(_testConnection, _entityPath); + _mockMessageProcessor = new Mock(MockBehavior.Strict, messageReceiver, messageOptions); + + ServiceBusOptions config = new ServiceBusOptions + { + MessageHandlerOptions = messageOptions + }; + _mockMessagingProvider = new Mock(MockBehavior.Strict, new OptionsWrapper(config)); + + _mockMessagingProvider + .Setup(p => p.CreateMessageProcessor(_entityPath, _testConnection)) + .Returns(_mockMessageProcessor.Object); + + _mockMessagingProvider + .Setup(p => p.CreateMessageReceiver(_entityPath, _testConnection)) + .Returns(messageReceiver); + + Mock mockServiceBusAccount = new Mock(MockBehavior.Strict); + mockServiceBusAccount.Setup(a => a.ConnectionString).Returns(_testConnection); + + _loggerFactory = new LoggerFactory(); + _loggerProvider = new TestLoggerProvider(); + _loggerFactory.AddProvider(_loggerProvider); + + _listener = new ServiceBusListener(_functionId, EntityType.Queue, _entityPath, false, _mockExecutor.Object, config, mockServiceBusAccount.Object, + _mockMessagingProvider.Object, _loggerFactory, false); + } + + [Fact] + public async Task ProcessMessageAsync_Success() + { + var message = new CustomMessage(); + var systemProperties = new Message.SystemPropertiesCollection(); + typeof(Message.SystemPropertiesCollection).GetProperty("SequenceNumber").SetValue(systemProperties, 1); + typeof(Message.SystemPropertiesCollection).GetProperty("DeliveryCount").SetValue(systemProperties, 55); + typeof(Message.SystemPropertiesCollection).GetProperty("EnqueuedTimeUtc").SetValue(systemProperties, DateTime.Now); + typeof(Message.SystemPropertiesCollection).GetProperty("LockedUntilUtc").SetValue(systemProperties, DateTime.Now); + typeof(Message).GetProperty("SystemProperties").SetValue(message, systemProperties); + + message.MessageId = Guid.NewGuid().ToString(); + _mockMessageProcessor.Setup(p => p.BeginProcessingMessageAsync(message, It.IsAny())).ReturnsAsync(true); + + FunctionResult result = new FunctionResult(true); + _mockExecutor.Setup(p => p.TryExecuteAsync(It.Is(q => ((ServiceBusTriggerInput)q.TriggerValue).Messages[0] == message), It.IsAny())).ReturnsAsync(result); + + _mockMessageProcessor.Setup(p => p.CompleteProcessingMessageAsync(message, result, It.IsAny())).Returns(Task.FromResult(0)); + + await _listener.ProcessMessageAsync(message, CancellationToken.None); + + _mockMessageProcessor.VerifyAll(); + _mockExecutor.VerifyAll(); + _mockMessageProcessor.VerifyAll(); + } + + [Fact] + public async Task ProcessMessageAsync_BeginProcessingReturnsFalse_MessageNotProcessed() + { + var message = new CustomMessage(); + message.MessageId = Guid.NewGuid().ToString(); + _mockMessageProcessor.Setup(p => p.BeginProcessingMessageAsync(message, It.IsAny())).ReturnsAsync(false); + + await _listener.ProcessMessageAsync(message, CancellationToken.None); + + _mockMessageProcessor.VerifyAll(); + } + + [Fact] + public void GetMonitor_ReturnsExpectedValue() + { + IScaleMonitor scaleMonitor = _listener.GetMonitor(); + + Assert.Equal(typeof(ServiceBusScaleMonitor), scaleMonitor.GetType()); + Assert.Equal(scaleMonitor.Descriptor.Id, $"{_functionId}-ServiceBusTrigger-{_entityPath}".ToLower()); + + var scaleMonitor2 = _listener.GetMonitor(); + + Assert.Same(scaleMonitor, scaleMonitor2); + } + + private Task ExceptionReceivedHandler(ExceptionReceivedEventArgs eventArgs) + { + return Task.CompletedTask; + } + } + + // Mock calls ToString() for Mesage. This ckass fixes bug in azure-service-bus-dotnet. + // https://github.com/Azure/azure-service-bus-dotnet/blob/dev/src/Microsoft.Azure.ServiceBus/Message.cs#L291 +#pragma warning disable SA1402 // File may only contain a single type + internal class CustomMessage : Message +#pragma warning restore SA1402 // File may only contain a single type + { + public override string ToString() + { + return MessageId; + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Listeners/ServiceBusScaleMonitorTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Listeners/ServiceBusScaleMonitorTests.cs new file mode 100644 index 000000000000..e60ac4a508b5 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Listeners/ServiceBusScaleMonitorTests.cs @@ -0,0 +1,457 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.ServiceBus.Core; +using Microsoft.Azure.ServiceBus.Management; +using Microsoft.Azure.WebJobs.Host.Executors; +using Microsoft.Azure.WebJobs.Host.Scale; +//using Microsoft.Azure.WebJobs.Host.TestCommon; +using Microsoft.Azure.WebJobs.ServiceBus.Listeners; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; +using static Microsoft.Azure.ServiceBus.Message; + +namespace Microsoft.Azure.WebJobs.ServiceBus.UnitTests.Listeners +{ + public class ServiceBusScaleMonitorTests + { + //private readonly MessagingFactory _messagingFactory; + // private readonly ServiceBusListener _listener; + // private readonly ServiceBusScaleMonitor _scaleMonitor; + // private readonly ServiceBusOptions _serviceBusOptions; + // private readonly Mock _mockServiceBusAccount; + // private readonly Mock _mockExecutor; + // private readonly Mock _mockMessagingProvider; + // private readonly Mock _mockMessageProcessor; + // private readonly TestLoggerProvider _loggerProvider; + // private readonly LoggerFactory _loggerFactory; + // private readonly string _functionId = "test-functionid"; + // private readonly string _entityPath = "test-entity-path"; + // private readonly string _testConnection = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123="; + + // public ServiceBusScaleMonitorTests() + // { + // _mockExecutor = new Mock(MockBehavior.Strict); + + // MessageHandlerOptions messageOptions = new MessageHandlerOptions(ExceptionReceivedHandler); + // MessageReceiver messageReceiver = new MessageReceiver(_testConnection, _entityPath); + // _mockMessageProcessor = new Mock(MockBehavior.Strict, messageReceiver, messageOptions); + + // _serviceBusOptions = new ServiceBusOptions + // { + // MessageHandlerOptions = messageOptions + // }; + // _mockMessagingProvider = new Mock(MockBehavior.Strict, new OptionsWrapper(_serviceBusOptions)); + + // _mockMessagingProvider + // .Setup(p => p.CreateMessageProcessor(_entityPath, _testConnection)) + // .Returns(_mockMessageProcessor.Object); + + // _mockServiceBusAccount = new Mock(MockBehavior.Strict); + // _mockServiceBusAccount.Setup(a => a.ConnectionString).Returns(_testConnection); + + // _loggerFactory = new LoggerFactory(); + // _loggerProvider = new TestLoggerProvider(); + // _loggerFactory.AddProvider(_loggerProvider); + + // _listener = new ServiceBusListener(_functionId, EntityType.Queue, _entityPath, false, _mockExecutor.Object, _serviceBusOptions, _mockServiceBusAccount.Object, + // _mockMessagingProvider.Object, _loggerFactory, false); + // _scaleMonitor = (ServiceBusScaleMonitor)_listener.GetMonitor(); + // } + + // Task ExceptionReceivedHandler(ExceptionReceivedEventArgs eventArgs) + // { + // return Task.CompletedTask; + // } + + // [Fact] + // public void ScaleMonitorDescriptor_ReturnsExpectedValue() + // { + // Assert.Equal($"{_functionId}-ServiceBusTrigger-{_entityPath}".ToLower(), _scaleMonitor.Descriptor.Id); + // } + + // [Fact] + // public void GetMetrics_ReturnsExpectedResult() + // { + // // Unable to test QueueTime because of restrictions on creating Message objects + + // // Test base case + // var metrics = ServiceBusScaleMonitor.CreateTriggerMetrics(null, 0, 0, 0, false); + + // Assert.Equal(0, metrics.PartitionCount); + // Assert.Equal(0, metrics.MessageCount); + // Assert.Equal(TimeSpan.FromSeconds(0), metrics.QueueTime); + // Assert.NotEqual(default(DateTime), metrics.Timestamp); + + // // Test messages on main queue + // metrics = ServiceBusScaleMonitor.CreateTriggerMetrics(null, 10, 0, 0, false); + + // Assert.Equal(0, metrics.PartitionCount); + // Assert.Equal(10, metrics.MessageCount); + // Assert.Equal(TimeSpan.FromSeconds(0), metrics.QueueTime); + // Assert.NotEqual(default(DateTime), metrics.Timestamp); + + // // Test listening on dead letter queue + // metrics = ServiceBusScaleMonitor.CreateTriggerMetrics(null, 10, 100, 0, true); + + // Assert.Equal(0, metrics.PartitionCount); + // Assert.Equal(100, metrics.MessageCount); + // Assert.Equal(TimeSpan.FromSeconds(0), metrics.QueueTime); + // Assert.NotEqual(default(DateTime), metrics.Timestamp); + + // // Test partitions + // metrics = ServiceBusScaleMonitor.CreateTriggerMetrics(null, 0, 0, 16, false); + + // Assert.Equal(16, metrics.PartitionCount); + // Assert.Equal(0, metrics.MessageCount); + // Assert.Equal(TimeSpan.FromSeconds(0), metrics.QueueTime); + // Assert.NotEqual(default(DateTime), metrics.Timestamp); + // } + + // [Fact] + // public async Task GetMetrics_HandlesExceptions() + // { + // // MessagingEntityNotFoundException + // _mockMessagingProvider + // .Setup(p => p.CreateMessageReceiver(_entityPath, _testConnection)) + // .Throws(new MessagingEntityNotFoundException("")); + // ServiceBusListener listener = new ServiceBusListener(_functionId, EntityType.Queue, _entityPath, false, _mockExecutor.Object, _serviceBusOptions, + // _mockServiceBusAccount.Object, _mockMessagingProvider.Object, _loggerFactory, false); + + // var metrics = await ((ServiceBusScaleMonitor)listener.GetMonitor()).GetMetricsAsync(); + + // Assert.Equal(0, metrics.PartitionCount); + // Assert.Equal(0, metrics.MessageCount); + // Assert.Equal(TimeSpan.FromSeconds(0), metrics.QueueTime); + // Assert.NotEqual(default(DateTime), metrics.Timestamp); + + // var warning = _loggerProvider.GetAllLogMessages().Single(p => p.Level == LogLevel.Warning); + // Assert.Equal($"ServiceBus queue '{_entityPath}' was not found.", warning.FormattedMessage); + // _loggerProvider.ClearAllLogMessages(); + + // // UnauthorizedAccessException + // _mockMessagingProvider + // .Setup(p => p.CreateMessageReceiver(_entityPath, _testConnection)) + // .Throws(new UnauthorizedException("")); + // listener = new ServiceBusListener(_functionId, EntityType.Queue, _entityPath, false, _mockExecutor.Object, _serviceBusOptions, + // _mockServiceBusAccount.Object, _mockMessagingProvider.Object, _loggerFactory, false); + + // metrics = await ((ServiceBusScaleMonitor)listener.GetMonitor()).GetMetricsAsync(); + + // Assert.Equal(0, metrics.PartitionCount); + // Assert.Equal(0, metrics.MessageCount); + // Assert.Equal(TimeSpan.FromSeconds(0), metrics.QueueTime); + // Assert.NotEqual(default(DateTime), metrics.Timestamp); + + // warning = _loggerProvider.GetAllLogMessages().Single(p => p.Level == LogLevel.Warning); + // Assert.Equal($"Connection string does not have Manage claim for queue '{_entityPath}'. Failed to get queue description to derive queue length metrics. " + + // $"Falling back to using first message enqueued time.", + // warning.FormattedMessage); + // _loggerProvider.ClearAllLogMessages(); + + // // Generic Exception + // _mockMessagingProvider + // .Setup(p => p.CreateMessageReceiver(_entityPath, _testConnection)) + // .Throws(new Exception("Uh oh")); + // listener = new ServiceBusListener(_functionId, EntityType.Queue, _entityPath, false, _mockExecutor.Object, _serviceBusOptions, + // _mockServiceBusAccount.Object, _mockMessagingProvider.Object, _loggerFactory, false); + + // metrics = await ((ServiceBusScaleMonitor)listener.GetMonitor()).GetMetricsAsync(); + + // Assert.Equal(0, metrics.PartitionCount); + // Assert.Equal(0, metrics.MessageCount); + // Assert.Equal(TimeSpan.FromSeconds(0), metrics.QueueTime); + // Assert.NotEqual(default(DateTime), metrics.Timestamp); + + // warning = _loggerProvider.GetAllLogMessages().Single(p => p.Level == LogLevel.Warning); + // Assert.Equal($"Error querying for Service Bus queue scale status: Uh oh", warning.FormattedMessage); + // } + + // [Fact] + // public void GetScaleStatus_NoMetrics_ReturnsVote_None() + // { + // var context = new ScaleStatusContext + // { + // WorkerCount = 1 + // }; + + // var status = _scaleMonitor.GetScaleStatus(context); + // Assert.Equal(ScaleVote.None, status.Vote); + + // // verify the non-generic implementation works properly + // status = ((IScaleMonitor)_scaleMonitor).GetScaleStatus(context); + // Assert.Equal(ScaleVote.None, status.Vote); + // } + + // [Fact] + // public void GetScaleStatus_InstancesPerPartitionThresholdExceeded_ReturnsVote_ScaleIn() + // { + // var context = new ScaleStatusContext + // { + // WorkerCount = 17 + // }; + // var timestamp = DateTime.UtcNow; + // var serviceBusTriggerMetrics = new List + // { + // new ServiceBusTriggerMetrics { MessageCount = 2500, PartitionCount = 16, QueueTime = TimeSpan.FromSeconds(15), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 2505, PartitionCount = 16, QueueTime = TimeSpan.FromSeconds(15), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 2612, PartitionCount = 16, QueueTime = TimeSpan.FromSeconds(15), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 2700, PartitionCount = 16, QueueTime = TimeSpan.FromSeconds(15), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 2810, PartitionCount = 16, QueueTime = TimeSpan.FromSeconds(15), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 2900, PartitionCount = 16, QueueTime = TimeSpan.FromSeconds(15), Timestamp = timestamp.AddSeconds(15) }, + // }; + // context.Metrics = serviceBusTriggerMetrics; + + // var status = _scaleMonitor.GetScaleStatus(context); + // Assert.Equal(ScaleVote.ScaleIn, status.Vote); + + // var logs = _loggerProvider.GetAllLogMessages().ToArray(); + // var log = logs[0]; + // Assert.Equal(LogLevel.Information, log.Level); + // Assert.Equal("WorkerCount (17) > PartitionCount (16).", log.FormattedMessage); + // log = logs[1]; + // Assert.Equal(LogLevel.Information, log.Level); + // Assert.Equal($"Number of instances (17) is too high relative to number of partitions for Service Bus entity ({_entityPath}, 16).", log.FormattedMessage); + + // // verify again with a non generic context instance + // var context2 = new ScaleStatusContext + // { + // WorkerCount = 1, + // Metrics = serviceBusTriggerMetrics + // }; + // status = ((IScaleMonitor)_scaleMonitor).GetScaleStatus(context2); + // Assert.Equal(ScaleVote.ScaleOut, status.Vote); + // } + + // [Fact] + // public void GetScaleStatus_MessagesPerWorkerThresholdExceeded_ReturnsVote_ScaleOut() + // { + // var context = new ScaleStatusContext + // { + // WorkerCount = 1 + // }; + // var timestamp = DateTime.UtcNow; + // var serviceBusTriggerMetrics = new List + // { + // new ServiceBusTriggerMetrics { MessageCount = 2500, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(0), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 2505, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(0), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 2612, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(0), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 2700, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(0), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 2810, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(0), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 2900, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(0), Timestamp = timestamp.AddSeconds(15) }, + // }; + // context.Metrics = serviceBusTriggerMetrics; + + // var status = _scaleMonitor.GetScaleStatus(context); + // Assert.Equal(ScaleVote.ScaleOut, status.Vote); + + // var logs = _loggerProvider.GetAllLogMessages().ToArray(); + // var log = logs[0]; + // Assert.Equal(LogLevel.Information, log.Level); + // Assert.Equal("MessageCount (2900) > WorkerCount (1) * 1,000.", log.FormattedMessage); + // log = logs[1]; + // Assert.Equal(LogLevel.Information, log.Level); + // Assert.Equal($"Message count for Service Bus Entity ({_entityPath}, 2900) " + + // $"is too high relative to the number of instances (1).", log.FormattedMessage); + + // // verify again with a non generic context instance + // var context2 = new ScaleStatusContext + // { + // WorkerCount = 1, + // Metrics = serviceBusTriggerMetrics + // }; + // status = ((IScaleMonitor)_scaleMonitor).GetScaleStatus(context2); + // Assert.Equal(ScaleVote.ScaleOut, status.Vote); + // } + + // [Fact] + // public void GetScaleStatus_QueueLengthIncreasing_ReturnsVote_ScaleOut() + // { + // var context = new ScaleStatusContext + // { + // WorkerCount = 1 + // }; + // var timestamp = DateTime.UtcNow; + // context.Metrics = new List + // { + // new ServiceBusTriggerMetrics { MessageCount = 10, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(0), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 20, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(0), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 40, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(0), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 80, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(0), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 100, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(0), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 150, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(0), Timestamp = timestamp.AddSeconds(15) }, + // }; + + // var status = _scaleMonitor.GetScaleStatus(context); + // Assert.Equal(ScaleVote.ScaleOut, status.Vote); + + // var logs = _loggerProvider.GetAllLogMessages().ToArray(); + // var log = logs[0]; + // Assert.Equal(LogLevel.Information, log.Level); + // Assert.Equal($"Message count is increasing for '{_entityPath}'.", log.FormattedMessage); + // } + + // [Fact] + // public void GetScaleStatus_QueueTimeIncreasing_ReturnsVote_ScaleOut() + // { + // var context = new ScaleStatusContext + // { + // WorkerCount = 1 + // }; + // var timestamp = DateTime.UtcNow; + // context.Metrics = new List + // { + // new ServiceBusTriggerMetrics { MessageCount = 100, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(1), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 100, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(2), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 100, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(3), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 100, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(4), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 100, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(5), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 100, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(6), Timestamp = timestamp.AddSeconds(15) }, + // }; + + // var status = _scaleMonitor.GetScaleStatus(context); + // Assert.Equal(ScaleVote.ScaleOut, status.Vote); + + // var logs = _loggerProvider.GetAllLogMessages().ToArray(); + // var log = logs[0]; + // Assert.Equal(LogLevel.Information, log.Level); + // Assert.Equal($"Queue time is increasing for '{_entityPath}'.", log.FormattedMessage); + // } + + // [Fact] + // public void GetScaleStatus_QueueLengthDecreasing_ReturnsVote_ScaleIn() + // { + // var context = new ScaleStatusContext + // { + // WorkerCount = 1 + // }; + // var timestamp = DateTime.UtcNow; + // context.Metrics = new List + // { + // new ServiceBusTriggerMetrics { MessageCount = 150, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(0), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 100, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(0), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 80, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(0), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 40, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(0), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 20, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(0), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 10, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(0), Timestamp = timestamp.AddSeconds(15) }, + // }; + + // var status = _scaleMonitor.GetScaleStatus(context); + // Assert.Equal(ScaleVote.ScaleIn, status.Vote); + + // var logs = _loggerProvider.GetAllLogMessages().ToArray(); + // var log = logs[0]; + // Assert.Equal(LogLevel.Information, log.Level); + // Assert.Equal($"Message count is decreasing for '{_entityPath}'.", log.FormattedMessage); + // } + + // [Fact] + // public void GetScaleStatus_QueueTimeDecreasing_ReturnsVote_ScaleIn() + // { + // var context = new ScaleStatusContext + // { + // WorkerCount = 1 + // }; + // var timestamp = DateTime.UtcNow; + // context.Metrics = new List + // { + // new ServiceBusTriggerMetrics { MessageCount = 100, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(6), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 100, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(5), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 100, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(4), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 100, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(3), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 100, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(2), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 100, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(1), Timestamp = timestamp.AddSeconds(15) }, + // }; + + // var status = _scaleMonitor.GetScaleStatus(context); + // Assert.Equal(ScaleVote.ScaleIn, status.Vote); + + // var logs = _loggerProvider.GetAllLogMessages().ToArray(); + // var log = logs[0]; + // Assert.Equal(LogLevel.Information, log.Level); + // Assert.Equal($"Queue time is decreasing for '{_entityPath}'.", log.FormattedMessage); + // } + + // [Fact] + // public void GetScaleStatus_QueueSteady_ReturnsVote_None() + // { + // var context = new ScaleStatusContext + // { + // WorkerCount = 2 + // }; + // var timestamp = DateTime.UtcNow; + // context.Metrics = new List + // { + // new ServiceBusTriggerMetrics { MessageCount = 1500, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(1), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 1600, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(1), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 1400, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(1), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 1300, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(1), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 1700, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(1), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 1600, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(1), Timestamp = timestamp.AddSeconds(15) }, + // }; + + // var status = _scaleMonitor.GetScaleStatus(context); + // Assert.Equal(ScaleVote.None, status.Vote); + + // var logs = _loggerProvider.GetAllLogMessages().ToArray(); + // var log = logs[0]; + // Assert.Equal(LogLevel.Information, log.Level); + // Assert.Equal($"Service Bus entity '{_entityPath}' is steady.", log.FormattedMessage); + // } + + // [Fact] + // public void GetScaleStatus_QueueIdle_ReturnsVote_ScaleIn() + // { + // var context = new ScaleStatusContext + // { + // WorkerCount = 3 + // }; + // var timestamp = DateTime.UtcNow; + // context.Metrics = new List + // { + // new ServiceBusTriggerMetrics { MessageCount = 0, PartitionCount = 0, QueueTime = TimeSpan.Zero, Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 0, PartitionCount = 0, QueueTime = TimeSpan.Zero, Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 0, PartitionCount = 0, QueueTime = TimeSpan.Zero, Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 0, PartitionCount = 0, QueueTime = TimeSpan.Zero, Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 0, PartitionCount = 0, QueueTime = TimeSpan.Zero, Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 0, PartitionCount = 0, QueueTime = TimeSpan.Zero, Timestamp = timestamp.AddSeconds(15) }, + // }; + + // var status = _scaleMonitor.GetScaleStatus(context); + // Assert.Equal(ScaleVote.ScaleIn, status.Vote); + + // var logs = _loggerProvider.GetAllLogMessages().ToArray(); + // var log = logs[0]; + // Assert.Equal(LogLevel.Information, log.Level); + // Assert.Equal($"'{_entityPath}' is idle.", log.FormattedMessage); + // } + + // [Fact] + // public void GetScaleStatus_UnderSampleCountThreshold_ReturnsVote_None() + // { + // var context = new ScaleStatusContext + // { + // WorkerCount = 1 + // }; + // var timestamp = DateTime.UtcNow; + // context.Metrics = new List + // { + // new ServiceBusTriggerMetrics { MessageCount = 5, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(1), Timestamp = timestamp.AddSeconds(15) }, + // new ServiceBusTriggerMetrics { MessageCount = 10, PartitionCount = 0, QueueTime = TimeSpan.FromSeconds(1), Timestamp = timestamp.AddSeconds(15) } + // }; + + // var status = _scaleMonitor.GetScaleStatus(context); + // Assert.Equal(ScaleVote.None, status.Vote); + // } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/MessageProcessorTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/MessageProcessorTests.cs new file mode 100644 index 000000000000..6a7f158fb703 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/MessageProcessorTests.cs @@ -0,0 +1,61 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.ServiceBus.Core; +using Microsoft.Azure.WebJobs.Host.Executors; +using Xunit; + +namespace Microsoft.Azure.WebJobs.ServiceBus.UnitTests +{ + public class MessageProcessorTests + { + private readonly MessageProcessor _processor; + private readonly MessageHandlerOptions _options; + + public MessageProcessorTests() + { + _options = new MessageHandlerOptions(ExceptionReceivedHandler); + MessageReceiver receiver = new MessageReceiver("Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123=", "test-entity"); + _processor = new MessageProcessor(receiver, _options); + } + + [Fact] + public async Task CompleteProcessingMessageAsync_Failure_PropagatesException() + { + _options.AutoComplete = false; + + Message message = new Message(); + var functionException = new InvalidOperationException("Kaboom!"); + FunctionResult result = new FunctionResult(functionException); + var ex = await Assert.ThrowsAsync(async () => + { + await _processor.CompleteProcessingMessageAsync(message, result, CancellationToken.None); + }); + + Assert.Same(functionException, ex); + } + + [Fact] + public async Task CompleteProcessingMessageAsync_DefaultOnMessageOptions() + { + Message message = new Message(); + FunctionResult result = new FunctionResult(true); + await _processor.CompleteProcessingMessageAsync(message, result, CancellationToken.None); + } + + [Fact] + public void MessageOptions_ReturnsOptions() + { + Assert.Same(_options, _processor.MessageOptions); + } + + private Task ExceptionReceivedHandler(ExceptionReceivedEventArgs eventArgs) + { + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/MessageToByteArrayConverterTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/MessageToByteArrayConverterTests.cs new file mode 100644 index 000000000000..04c93c896353 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/MessageToByteArrayConverterTests.cs @@ -0,0 +1,37 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.ServiceBus.Triggers; +using Microsoft.Azure.ServiceBus; +using Xunit; + +namespace Microsoft.Azure.WebJobs.ServiceBus.UnitTests +{ + public class MessageToByteArrayConverterTests + { + private const string TestString = "This is a test!"; + + [Theory] + [InlineData(ContentTypes.TextPlain)] + [InlineData(ContentTypes.ApplicationJson)] + [InlineData(ContentTypes.ApplicationOctetStream)] + [InlineData("some-other-contenttype")] + [InlineData(null)] + public async Task ConvertAsync_ReturnsExpectedResults(string contentType) + { + Message message = new Message(Encoding.UTF8.GetBytes(TestString)); + message.ContentType = contentType; + MessageToByteArrayConverter converter = new MessageToByteArrayConverter(); + + byte[] result = await converter.ConvertAsync(message, CancellationToken.None); + string decoded = Encoding.UTF8.GetString(result); + Assert.Equal(TestString, decoded); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/MessageToStringConverterTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/MessageToStringConverterTests.cs new file mode 100644 index 000000000000..d48145ef5910 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/MessageToStringConverterTests.cs @@ -0,0 +1,89 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.ServiceBus.Triggers; +using Microsoft.Azure.ServiceBus; +using Xunit; +using System.Collections.Generic; +using Microsoft.Azure.ServiceBus.InteropExtensions; + +namespace Microsoft.Azure.WebJobs.ServiceBus.UnitTests +{ + public class MessageToStringConverterTests + { + private const string TestString = "This is a test!"; + private const string TestJson = "{ value: 'This is a test!' }"; + + [Theory] + [InlineData(ContentTypes.TextPlain, TestString)] + [InlineData(ContentTypes.ApplicationJson, TestJson)] + [InlineData(ContentTypes.ApplicationOctetStream, TestString)] + [InlineData(null, TestJson)] + [InlineData("application/xml", TestJson)] + [InlineData(ContentTypes.TextPlain, null)] + public async Task ConvertAsync_ReturnsExpectedResult_WithBinarySerializer(string contentType, string value) + { + byte[] bytes; + using (MemoryStream ms = new MemoryStream()) + { + DataContractBinarySerializer.Instance.WriteObject(ms, value); + bytes = ms.ToArray(); + } + + Message message = new Message(bytes); + message.ContentType = contentType; + + MessageToStringConverter converter = new MessageToStringConverter(); + string result = await converter.ConvertAsync(message, CancellationToken.None); + + Assert.Equal(value, result); + } + + [Theory] + [InlineData(ContentTypes.TextPlain, TestString)] + [InlineData(ContentTypes.ApplicationJson, TestJson)] + [InlineData(ContentTypes.ApplicationOctetStream, TestString)] + [InlineData(null, TestJson)] + [InlineData("application/xml", TestJson)] + [InlineData(ContentTypes.TextPlain, null)] + [InlineData(ContentTypes.TextPlain, "")] + public async Task ConvertAsync_ReturnsExpectedResult_WithSerializedString(string contentType, string value) + { + Message message = new Message(value == null ? null : Encoding.UTF8.GetBytes(value)); + message.ContentType = contentType; + + MessageToStringConverter converter = new MessageToStringConverter(); + string result = await converter.ConvertAsync(message, CancellationToken.None); + Assert.Equal(value, result); + } + + [Fact] + public async Task ConvertAsync_ReturnsExpectedResult_WithSerializedObject() + { + byte[] bytes; + using (MemoryStream ms = new MemoryStream()) + { + DataContractBinarySerializer.Instance.WriteObject(ms, new TestObject() { Text = "Test" }); + bytes = ms.ToArray(); + } + + Message message = new Message(bytes); + + MessageToStringConverter converter = new MessageToStringConverter(); + string result = await converter.ConvertAsync(message, CancellationToken.None); + Assert.Equal(Encoding.UTF8.GetString(message.Body), result); + } + + [Serializable] + public class TestObject + { + public string Text { get; set; } + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/MessagingProviderTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/MessagingProviderTests.cs new file mode 100644 index 000000000000..26a38177cb8a --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/MessagingProviderTests.cs @@ -0,0 +1,68 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.Azure.ServiceBus; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.Azure.WebJobs.ServiceBus.UnitTests +{ + public class MessagingProviderTests + { + [Fact] + public void CreateMessageReceiver_ReturnsExpectedReceiver() + { + string defaultConnection = "Endpoint=sb://default.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123="; + var config = new ServiceBusOptions + { + ConnectionString = defaultConnection + }; + var provider = new MessagingProvider(new OptionsWrapper(config)); + var receiver = provider.CreateMessageReceiver("entityPath", defaultConnection); + Assert.Equal("entityPath", receiver.Path); + + var receiver2 = provider.CreateMessageReceiver("entityPath", defaultConnection); + Assert.Same(receiver, receiver2); + + config.PrefetchCount = 100; + receiver = provider.CreateMessageReceiver("entityPath1", defaultConnection); + Assert.Equal(100, receiver.PrefetchCount); + } + + [Fact] + public void CreateClientEntity_ReturnsExpectedReceiver() + { + string defaultConnection = "Endpoint=sb://default.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123="; + var config = new ServiceBusOptions + { + ConnectionString = defaultConnection + }; + var provider = new MessagingProvider(new OptionsWrapper(config)); + var clientEntity = provider.CreateClientEntity("entityPath", defaultConnection); + Assert.Equal("entityPath", clientEntity.Path); + + var receiver2 = provider.CreateClientEntity("entityPath", defaultConnection); + Assert.Same(clientEntity, receiver2); + + config.PrefetchCount = 100; + clientEntity = provider.CreateClientEntity("entityPath1", defaultConnection); + Assert.Equal(100, ((QueueClient)clientEntity).PrefetchCount); + } + + [Fact] + public void CreateMessageSender_ReturnsExpectedSender() + { + string defaultConnection = "Endpoint=sb://default.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123="; + var config = new ServiceBusOptions + { + ConnectionString = defaultConnection + }; + var provider = new MessagingProvider(new OptionsWrapper(config)); + var sender = provider.CreateMessageSender("entityPath", defaultConnection); + Assert.Equal("entityPath", sender.Path); + + var sender2 = provider.CreateMessageSender("entityPath", defaultConnection); + Assert.Same(sender, sender2); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Microsoft.Azure.WebJobs.Extensions.ServiceBus.Tests.csproj b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Microsoft.Azure.WebJobs.Extensions.ServiceBus.Tests.csproj new file mode 100644 index 000000000000..0766776aff23 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Microsoft.Azure.WebJobs.Extensions.ServiceBus.Tests.csproj @@ -0,0 +1,27 @@ + + + $(RequiredTargetFrameworks) + SA1636 + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Properties/AssemblyInfo.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Properties/AssemblyInfo.cs new file mode 100644 index 000000000000..0a62756790e7 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Reflection; +using Xunit; + +[assembly: CollectionBehavior(CollectionBehavior.CollectionPerAssembly)] \ No newline at end of file diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/PublicSurfaceTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/PublicSurfaceTests.cs new file mode 100644 index 000000000000..476782d3ae32 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/PublicSurfaceTests.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.Azure.WebJobs.Host.TestCommon; +using Xunit; + +namespace Microsoft.Azure.WebJobs.Host.UnitTests +{ + public class PublicSurfaceTests + { + [Fact] + public void WebJobs_Extensions_ServiceBus_VerifyPublicSurfaceArea() + { + var assembly = typeof(ServiceBusAttribute).Assembly; + + var expected = new[] + { + "Constants", + "EntityType", + "MessageProcessor", + "MessagingProvider", + "ServiceBusAccountAttribute", + "ServiceBusAttribute", + "ServiceBusTriggerAttribute", + "ServiceBusHostBuilderExtensions", + "ServiceBusOptions", + "ServiceBusWebJobsStartup", + "SessionMessageProcessor", + "BatchOptions" + }; + + TestHelpers.AssertPublicTypes(expected, assembly); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/ServiceBusAccountTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/ServiceBusAccountTests.cs new file mode 100644 index 000000000000..ab76c6906908 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/ServiceBusAccountTests.cs @@ -0,0 +1,59 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace Microsoft.Azure.WebJobs.ServiceBus.UnitTests +{ + public class ServiceBusAccountTests + { + private readonly IConfiguration _configuration; + + public ServiceBusAccountTests() + { + _configuration = new ConfigurationBuilder() + .AddEnvironmentVariables() + .Build(); + } + + [Fact] + public void GetConnectionString_ReturnsExpectedConnectionString() + { + string defaultConnection = "Endpoint=sb://default.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123="; + var options = new ServiceBusOptions() + { + ConnectionString = defaultConnection + }; + var attribute = new ServiceBusTriggerAttribute("entity-name"); + var account = new ServiceBusAccount(options, _configuration, attribute); + + Assert.True(defaultConnection == account.ConnectionString); + } + + [Fact] + public void GetConnectionString_ThrowsIfConnectionStringNullOrEmpty() + { + var config = new ServiceBusOptions(); + var attribute = new ServiceBusTriggerAttribute("testqueue"); + attribute.Connection = "MissingConnection"; + + var ex = Assert.Throws(() => + { + var account = new ServiceBusAccount(config, _configuration, attribute); + var cs = account.ConnectionString; + }); + Assert.Equal("Microsoft Azure WebJobs SDK ServiceBus connection string 'MissingConnection' is missing or empty.", ex.Message); + + attribute.Connection = null; + config.ConnectionString = null; + ex = Assert.Throws(() => + { + var account = new ServiceBusAccount(config, _configuration, attribute); + var cs = account.ConnectionString; + }); + Assert.Equal("Microsoft Azure WebJobs SDK ServiceBus connection string 'AzureWebJobsServiceBus' is missing or empty.", ex.Message); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/ServiceBusAttributeTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/ServiceBusAttributeTests.cs new file mode 100644 index 000000000000..7f1f6021689f --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/ServiceBusAttributeTests.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.Azure.WebJobs.ServiceBus.UnitTests +{ + public class ServiceBusAttributeTests + { + [Fact] + public void Constructor_Success() + { + ServiceBusAttribute attribute = new ServiceBusAttribute("testqueue"); + Assert.Equal("testqueue", attribute.QueueOrTopicName); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/ServiceBusEndToEndTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/ServiceBusEndToEndTests.cs new file mode 100644 index 000000000000..cc7812e07668 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/ServiceBusEndToEndTests.cs @@ -0,0 +1,1038 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Serialization; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.ServiceBus.Core; +using Microsoft.Azure.WebJobs.Host.TestCommon; +using Microsoft.Azure.WebJobs.ServiceBus; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.WebJobs.Host.EndToEndTests +{ + public class ServiceBusEndToEndTests : IDisposable + { + private const string SecondaryConnectionStringKey = "ServiceBusSecondary"; + + private const string Prefix = "core-test-"; + private const string FirstQueueName = Prefix + "queue1"; + private const string SecondQueueName = Prefix + "queue2"; + private const string BinderQueueName = Prefix + "queue3"; + + private const string TopicName = Prefix + "topic1"; + private const string TopicSubscriptionName1 = "sub1"; + private const string TopicSubscriptionName2 = "sub2"; + + private const string TriggerDetailsMessageStart = "Trigger Details:"; + private const string DrainingQueueMessageBody = "queue-message-draining-no-sessions-1"; + private const string DrainingTopicMessageBody = "topic-message-draining-no-sessions-1"; + + private const int SBTimeoutMills = 120 * 1000; + private const int DrainWaitTimeoutMills = 120 * 1000; + private const int DrainSleepMills = 5 * 1000; + private const int MaxAutoRenewDurationMin = 5; + internal static TimeSpan HostShutdownTimeout = TimeSpan.FromSeconds(120); + + private static EventWaitHandle _topicSubscriptionCalled1; + private static EventWaitHandle _topicSubscriptionCalled2; + private static EventWaitHandle _eventWait; + private static EventWaitHandle _drainValidationPreDelay; + private static EventWaitHandle _drainValidationPostDelay; + + // These two variables will be checked at the end of the test + private static string _resultMessage1; + private static string _resultMessage2; + + private readonly RandomNameResolver _nameResolver; + private readonly string _primaryConnectionString; + private readonly string _secondaryConnectionString; + + private readonly ITestOutputHelper outputLogger; + + public ServiceBusEndToEndTests(ITestOutputHelper output) + { + outputLogger = output; + + var config = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddTestSettings() + .Build(); + + // Add all test configuration to the environment as WebJobs requires a few of them to be in the environment + foreach (var kv in config.AsEnumerable()) + { + Environment.SetEnvironmentVariable(kv.Key, kv.Value); + } + + _eventWait = new ManualResetEvent(initialState: false); + + _primaryConnectionString = config.GetConnectionStringOrSetting(Constants.DefaultConnectionStringName); + _secondaryConnectionString = config.GetConnectionStringOrSetting(SecondaryConnectionStringKey); + + _nameResolver = new RandomNameResolver(); + + Cleanup().GetAwaiter().GetResult(); + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task ServiceBusEndToEnd() + { + await ServiceBusEndToEndInternal(); + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task ServiceBusBinderTest() + { + var hostType = typeof(ServiceBusTestJobs); + using (IHost host = CreateHost()) + { + var method = typeof(ServiceBusTestJobs).GetMethod("ServiceBusBinderTest"); + + int numMessages = 10; + var args = new { message = "Test Message", numMessages = numMessages }; + var jobHost = host.GetJobHost(); + await jobHost.CallAsync(method, args); + await jobHost.CallAsync(method, args); + await jobHost.CallAsync(method, args); + + var count = await CleanUpEntity(BinderQueueName); + + Assert.Equal(numMessages * 3, count); + + await host.StopAsync(); + } + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task CustomMessageProcessorTest() + { + using (IHost host = new HostBuilder() + .ConfigureDefaultTestHost(b => + { + b.AddServiceBus(); + }) + .ConfigureServices(services => + { + services.AddSingleton(); + }) + .ConfigureServices(s => + { + s.Configure(opts => opts.ShutdownTimeout = HostShutdownTimeout); + }) + .Build()) + { + var loggerProvider = host.GetTestLoggerProvider(); + + await ServiceBusEndToEndInternal(host: host); + + // in addition to verifying that our custom processor was called, we're also + // verifying here that extensions can log + IEnumerable messages = loggerProvider.GetAllLogMessages().Where(m => m.Category == CustomMessagingProvider.CustomMessagingCategory); + Assert.Equal(4, messages.Count(p => p.FormattedMessage.Contains("Custom processor Begin called!"))); + Assert.Equal(4, messages.Count(p => p.FormattedMessage.Contains("Custom processor End called!"))); + } + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task MultipleAccountTest() + { + using (IHost host = new HostBuilder() + .ConfigureDefaultTestHost(b => + { + b.AddServiceBus(); + }, nameResolver: _nameResolver) + .ConfigureServices(services => + { + services.AddSingleton(); + }) + .ConfigureServices(s => + { + s.Configure(opts => opts.ShutdownTimeout = HostShutdownTimeout); + }) + .Build()) + { + await WriteQueueMessage(_secondaryConnectionString, FirstQueueName, "Test"); + + _topicSubscriptionCalled1 = new ManualResetEvent(initialState: false); + _topicSubscriptionCalled2 = new ManualResetEvent(initialState: false); + + await host.StartAsync(); + + _topicSubscriptionCalled1.WaitOne(SBTimeoutMills); + _topicSubscriptionCalled2.WaitOne(SBTimeoutMills); + + // ensure all logs have had a chance to flush + await Task.Delay(3000); + + // Wait for the host to terminate + await host.StopAsync(); + } + + Assert.Equal("Test-topic-1", _resultMessage1); + Assert.Equal("Test-topic-2", _resultMessage2); + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task TestBatch_String() + { + await TestMultiple(); + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task TestBatch_Messages() + { + await TestMultiple(); + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task TestBatch_JsonPoco() + { + await TestMultiple(); + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task TestBatch_DataContractPoco() + { + await TestMultiple(true); + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task BindToPoco() + { + using (IHost host = BuildTestHost()) + { + await WriteQueueMessage(_primaryConnectionString, FirstQueueName, "{ Name: 'foo', Value: 'bar' }"); + + await host.StartAsync(); + + bool result = _eventWait.WaitOne(SBTimeoutMills); + Assert.True(result); + + var logs = host.GetTestLoggerProvider().GetAllLogMessages().Select(p => p.FormattedMessage); + Assert.Contains("PocoValues(foo,bar)", logs); + + await host.StopAsync(); + } + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task BindToString() + { + using (IHost host = BuildTestHost()) + { + var method = typeof(ServiceBusArgumentBindingJob).GetMethod(nameof(ServiceBusArgumentBindingJob.BindToString), BindingFlags.Static | BindingFlags.Public); + var jobHost = host.GetJobHost(); + await jobHost.CallAsync(method, new { input = "foobar" }); + + bool result = _eventWait.WaitOne(SBTimeoutMills); + Assert.True(result); + + var logs = host.GetTestLoggerProvider().GetAllLogMessages().Select(p => p.FormattedMessage); + Assert.Contains("Input(foobar)", logs); + + await host.StopAsync(); + } + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task MessageDrainingQueue() + { + await TestSingleDrainMode(true); + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task MessageDrainingTopic() + { + await TestSingleDrainMode(false); + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task MessageDrainingQueueBatch() + { + await TestMultipleDrainMode(true); + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task MessageDrainingTopicBatch() + { + await TestMultipleDrainMode(false); + } + + /* + * Helper functions + */ + + private async Task TestSingleDrainMode(bool sendToQueue) + { + using (IHost host = BuildTestHostMessageDraining()) + { + await host.StartAsync(); + + _drainValidationPreDelay = new ManualResetEvent(initialState: false); + _drainValidationPostDelay = new ManualResetEvent(initialState: false); + + if (sendToQueue) + { + await WriteQueueMessage(_primaryConnectionString, FirstQueueName, DrainingQueueMessageBody); + } + else + { + await WriteTopicMessage(_primaryConnectionString, TopicName, DrainingTopicMessageBody); + } + + // Wait to ensure function invocatoin has started before draining messages + Assert.True(_drainValidationPreDelay.WaitOne(SBTimeoutMills)); + + // Start draining in-flight messages + var drainModeManager = host.Services.GetService(); + await drainModeManager.EnableDrainModeAsync(CancellationToken.None); + + // Validate that function execution was allowed to complete + Assert.True(_drainValidationPostDelay.WaitOne(DrainWaitTimeoutMills + SBTimeoutMills)); + + await host.StopAsync(); + } + } + + private async Task TestMultiple(bool isXml = false) + { + using (IHost host = BuildTestHost()) + { + if (isXml) + { + await WriteQueueMessage(_primaryConnectionString, FirstQueueName, new TestPoco() { Name = "Test1", Value = "Value" }); + await WriteQueueMessage(_primaryConnectionString, FirstQueueName, new TestPoco() { Name = "Test2", Value = "Value" }); + } + else + { + await WriteQueueMessage(_primaryConnectionString, FirstQueueName, "{'Name': 'Test1', 'Value': 'Value'}"); + await WriteQueueMessage(_primaryConnectionString, FirstQueueName, "{'Name': 'Test2', 'Value': 'Value'}"); + } + + _topicSubscriptionCalled1 = new ManualResetEvent(initialState: false); + + await host.StartAsync(); + + bool result = _topicSubscriptionCalled1.WaitOne(SBTimeoutMills); + Assert.True(result); + + // ensure message are completed + await Task.Delay(2000); + + // Wait for the host to terminate + await host.StopAsync(); + } + } + + private async Task TestMultipleDrainMode(bool sendToQueue) + { + using (IHost host = BuildTestHostMessageDraining()) + { + await host.StartAsync(); + + _drainValidationPreDelay = new ManualResetEvent(initialState: false); + _drainValidationPostDelay = new ManualResetEvent(initialState: false); + + if (sendToQueue) + { + await ServiceBusEndToEndTests.WriteQueueMessage(_primaryConnectionString, FirstQueueName, DrainingQueueMessageBody); + } + else + { + await ServiceBusEndToEndTests.WriteTopicMessage(_primaryConnectionString, TopicName, DrainingTopicMessageBody); + } + + // Wait to ensure function invocatoin has started before draining messages + Assert.True(_drainValidationPreDelay.WaitOne(SBTimeoutMills)); + + // Start draining in-flight messages + var drainModeManager = host.Services.GetService(); + await drainModeManager.EnableDrainModeAsync(CancellationToken.None); + + // Validate that function execution was allowed to complete + Assert.True(_drainValidationPostDelay.WaitOne(DrainWaitTimeoutMills + SBTimeoutMills)); + + // Wait for the host to terminate + await host.StopAsync(); + } + } + + private async Task CleanUpEntity(string queueName, string connectionString = null) + { + var messageReceiver = new MessageReceiver(!string.IsNullOrEmpty(connectionString) ? connectionString : _primaryConnectionString, queueName, ReceiveMode.ReceiveAndDelete); + Message message; + int count = 0; + + do + { + message = await messageReceiver.ReceiveAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + if (message != null) + { + count++; + } + else + { + break; + } + } while (true); + + await messageReceiver.CloseAsync(); + + return count; + } + + private async Task Cleanup() + { + var tasks = new List + { + CleanUpEntity(FirstQueueName), + CleanUpEntity(SecondQueueName), + CleanUpEntity(BinderQueueName), + CleanUpEntity(FirstQueueName, _secondaryConnectionString), + CleanUpEntity(EntityNameHelper.FormatSubscriptionPath(TopicName, TopicSubscriptionName1)), + CleanUpEntity(EntityNameHelper.FormatSubscriptionPath(TopicName, TopicSubscriptionName2)) + }; + + await Task.WhenAll(tasks); + } + + private IHost CreateHost() + { + return new HostBuilder() + .ConfigureDefaultTestHost(b => + { + b.AddServiceBus(); + }) + .ConfigureServices(services => + { + services.AddSingleton(_nameResolver); + }) + .ConfigureServices(s => + { + s.Configure(opts => opts.ShutdownTimeout = HostShutdownTimeout); + }) + .Build(); + } + + private async Task ServiceBusEndToEndInternal(IHost host = null) + { + bool hostSupplied = (host != null); + if (!hostSupplied) + { + host = CreateHost(); + } + + var jobContainerType = typeof(T); + + await WriteQueueMessage(_primaryConnectionString, FirstQueueName, "E2E"); + + _topicSubscriptionCalled1 = new ManualResetEvent(initialState: false); + _topicSubscriptionCalled2 = new ManualResetEvent(initialState: false); + + await host.StartAsync(); + + _topicSubscriptionCalled1.WaitOne(SBTimeoutMills); + _topicSubscriptionCalled2.WaitOne(SBTimeoutMills); + + // ensure all logs have had a chance to flush + await Task.Delay(4000); + + // Wait for the host to terminate + await host.StopAsync(); + + Assert.Equal("E2E-SBQueue2SBQueue-SBQueue2SBTopic-topic-1", _resultMessage1); + Assert.Equal("E2E-SBQueue2SBQueue-SBQueue2SBTopic-topic-2", _resultMessage2); + + IEnumerable logMessages = host.GetTestLoggerProvider() + .GetAllLogMessages(); + + // filter out anything from the custom processor for easier validation. + IEnumerable consoleOutput = logMessages + .Where(m => m.Category != CustomMessagingProvider.CustomMessagingCategory); + + Assert.DoesNotContain(consoleOutput, p => p.Level == LogLevel.Error); + + string[] consoleOutputLines = consoleOutput + .Where(p => p.FormattedMessage != null) + .SelectMany(p => p.FormattedMessage.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)) + .OrderBy(p => p) + .ToArray(); + + string[] expectedOutputLines = new string[] + { + "Found the following functions:", + $"{jobContainerType.FullName}.SBQueue2SBQueue", + $"{jobContainerType.FullName}.MultipleAccounts", + $"{jobContainerType.FullName}.SBQueue2SBTopic", + $"{jobContainerType.FullName}.SBTopicListener1", + $"{jobContainerType.FullName}.SBTopicListener2", + $"{jobContainerType.FullName}.ServiceBusBinderTest", + "Job host started", + $"Executing '{jobContainerType.Name}.SBQueue2SBQueue'", + $"Executed '{jobContainerType.Name}.SBQueue2SBQueue' (Succeeded, Id=", + $"Trigger Details:", + $"Executing '{jobContainerType.Name}.SBQueue2SBTopic'", + $"Executed '{jobContainerType.Name}.SBQueue2SBTopic' (Succeeded, Id=", + $"Trigger Details:", + $"Executing '{jobContainerType.Name}.SBTopicListener1'", + $"Executed '{jobContainerType.Name}.SBTopicListener1' (Succeeded, Id=", + $"Trigger Details:", + $"Executing '{jobContainerType.Name}.SBTopicListener2'", + $"Executed '{jobContainerType.Name}.SBTopicListener2' (Succeeded, Id=", + $"Trigger Details:", + "Job host stopped", + "Starting JobHost", + "Stopping JobHost", + "Stoppingthelistener'Microsoft.Azure.WebJobs.ServiceBus.Listeners.ServiceBusListener'forfunction'MultipleAccounts'", + "Stoppedthelistener'Microsoft.Azure.WebJobs.ServiceBus.Listeners.ServiceBusListener'forfunction'MultipleAccounts'", + "Stoppingthelistener'Microsoft.Azure.WebJobs.ServiceBus.Listeners.ServiceBusListener'forfunction'SBQueue2SBQueue'", + "Stoppedthelistener'Microsoft.Azure.WebJobs.ServiceBus.Listeners.ServiceBusListener'forfunction'SBQueue2SBQueue'", + "Stoppingthelistener'Microsoft.Azure.WebJobs.ServiceBus.Listeners.ServiceBusListener'forfunction'SBQueue2SBTopic'", + "Stoppedthelistener'Microsoft.Azure.WebJobs.ServiceBus.Listeners.ServiceBusListener'forfunction'SBQueue2SBTopic'", + "Stoppingthelistener'Microsoft.Azure.WebJobs.ServiceBus.Listeners.ServiceBusListener'forfunction'SBTopicListener1'", + "Stoppedthelistener'Microsoft.Azure.WebJobs.ServiceBus.Listeners.ServiceBusListener'forfunction'SBTopicListener1'", + "Stoppingthelistener'Microsoft.Azure.WebJobs.ServiceBus.Listeners.ServiceBusListener'forfunction'SBTopicListener2'", + "Stoppedthelistener'Microsoft.Azure.WebJobs.ServiceBus.Listeners.ServiceBusListener'forfunction'SBTopicListener2'", + "FunctionResultAggregatorOptions", + "{", + " \"BatchSize\": 1000", + " \"FlushTimeout\": \"00:00:30\",", + " \"IsEnabled\": true", + "}", + "LoggerFilterOptions", + "{", + " \"MinLevel\": \"Information\"", + " \"Rules\": []", + "}", + "ServiceBusOptions", + "{", + " \"PrefetchCount\": 0,", + " \"MessageHandlerOptions\": {", + " \"AutoComplete\": true,", + " \"MaxAutoRenewDuration\": \"00:05:00\",", + $" \"MaxConcurrentCalls\": {16 * Utility.GetProcessorCount()}", + " }", + " \"SessionHandlerOptions\": {", + " \"MaxAutoRenewDuration\": \"00:05:00\",", + " \"MessageWaitTimeout\": \"00:01:00\",", + " \"MaxConcurrentSessions\": 2000", + " \"AutoComplete\": true", + " }", + "}", + " \"BatchOptions\": {", + " \"MaxMessageCount\": 1000,", + " \"OperationTimeout\": \"00:01:00\",", + " \"AutoComplete\": true", + " }", + "SingletonOptions", + "{", + " \"ListenerLockPeriod\": \"00:01:00\"", + " \"ListenerLockRecoveryPollingInterval\": \"00:01:00\"", + " \"LockAcquisitionPollingInterval\": \"00:00:05\"", + " \"LockAcquisitionTimeout\": \"", + " \"LockPeriod\": \"00:00:15\"", + "}", + }.OrderBy(p => p).ToArray(); + + expectedOutputLines = expectedOutputLines.Select(x => x.Replace(" ", string.Empty)).ToArray(); + consoleOutputLines = consoleOutputLines.Select(x => x.Replace(" ", string.Empty)).ToArray(); + + Action[] inspectors = expectedOutputLines.Select>(p => (string m) => + { + Assert.True(p.StartsWith(m) || m.StartsWith(p)); + }).ToArray(); + Assert.Collection(consoleOutputLines, inspectors); + + // Verify that trigger details are properly formatted + string[] triggerDetailsConsoleOutput = consoleOutputLines + .Where(m => m.StartsWith(TriggerDetailsMessageStart)).ToArray(); + + string expectedPattern = "Trigger Details: MessageId: (.*), DeliveryCount: [0-9]+, EnqueuedTime: (.*), LockedUntil: (.*)"; + + foreach (string msg in triggerDetailsConsoleOutput) + { + Assert.True(Regex.IsMatch(msg, expectedPattern), $"Expected trace event {expectedPattern} not found."); + } + + if (!hostSupplied) + { + host.Dispose(); + } + } + + internal static async Task WriteQueueMessage(string connectionString, string queueName, string message, string sessionId = null) + { + QueueClient queueClient = new QueueClient(connectionString, queueName); + Message messageObj = new Message(Encoding.UTF8.GetBytes(message)); + if (!string.IsNullOrEmpty(sessionId)) + { + messageObj.SessionId = sessionId; + } + await queueClient.SendAsync(messageObj); + await queueClient.CloseAsync(); + } + + internal static async Task WriteQueueMessage(string connectionString, string queueName, TestPoco obj, string sessionId = null) + { + var serializer = new DataContractSerializer(typeof(TestPoco)); + byte[] payload = null; + using (var memoryStream = new MemoryStream(10)) + { + var xmlDictionaryWriter = XmlDictionaryWriter.CreateBinaryWriter(memoryStream, null, null, false); + serializer.WriteObject(xmlDictionaryWriter, obj); + xmlDictionaryWriter.Flush(); + memoryStream.Flush(); + memoryStream.Position = 0; + payload = memoryStream.ToArray(); + } + + QueueClient queueClient = new QueueClient(connectionString, queueName); + Message messageObj = new Message(payload); + if (!string.IsNullOrEmpty(sessionId)) + { + messageObj.SessionId = sessionId; + } + await queueClient.SendAsync(messageObj); + await queueClient.CloseAsync(); + } + + internal static async Task WriteTopicMessage(string connectionString, string topicName, string message, string sessionId = null) + { + TopicClient client = new TopicClient(connectionString, topicName); + Message messageObj = new Message(Encoding.UTF8.GetBytes(message)); + if (!string.IsNullOrEmpty(sessionId)) + { + messageObj.SessionId = sessionId; + } + await client.SendAsync(messageObj); + await client.CloseAsync(); + } + + public abstract class ServiceBusTestJobsBase + { + protected static Message SBQueue2SBQueue_GetOutputMessage(string input) + { + input = input + "-SBQueue2SBQueue"; + return new Message + { + ContentType = "text/plain", + Body = Encoding.UTF8.GetBytes(input) + }; + } + + protected static Message SBQueue2SBTopic_GetOutputMessage(string input) + { + input = input + "-SBQueue2SBTopic"; + + return new Message(Encoding.UTF8.GetBytes(input)) + { + ContentType = "text/plain" + }; + } + + protected static void SBTopicListener1Impl(string input) + { + _resultMessage1 = input + "-topic-1"; + _topicSubscriptionCalled1.Set(); + } + + protected static void SBTopicListener2Impl(Message message) + { + using (Stream stream = new MemoryStream(message.Body)) + using (TextReader reader = new StreamReader(stream)) + { + _resultMessage2 = reader.ReadToEnd() + "-topic-2"; + } + + _topicSubscriptionCalled2.Set(); + } + } + + public class ServiceBusTestJobs : ServiceBusTestJobsBase + { + // Passes service bus message from a queue to another queue + public static async Task SBQueue2SBQueue( + [ServiceBusTrigger(FirstQueueName)] string start, int deliveryCount, + MessageReceiver messageReceiver, + string lockToken, + [ServiceBus(SecondQueueName)] MessageSender messageSender) + { + Assert.Equal(FirstQueueName, messageReceiver.Path); + Assert.Equal(1, deliveryCount); + + // verify the message receiver and token are valid + await messageReceiver.RenewLockAsync(lockToken); + + var message = SBQueue2SBQueue_GetOutputMessage(start); + await messageSender.SendAsync(message); + } + + // Passes a service bus message from a queue to topic using a brokered message + public static void SBQueue2SBTopic( + [ServiceBusTrigger(SecondQueueName)] string message, + [ServiceBus(TopicName)] out Message output) + { + output = SBQueue2SBTopic_GetOutputMessage(message); + } + + // First listener for the topic + public static void SBTopicListener1( + [ServiceBusTrigger(TopicName, TopicSubscriptionName1)] string message, + MessageReceiver messageReceiver, + string lockToken) + { + SBTopicListener1Impl(message); + } + + // Second listener for the topic + // Just sprinkling Singleton here because previously we had a bug where this didn't work + // for ServiceBus. + [Singleton] + public static void SBTopicListener2( + [ServiceBusTrigger(TopicName, TopicSubscriptionName2)] Message message) + { + SBTopicListener2Impl(message); + } + + // Demonstrate triggering on a queue in one account, and writing to a topic + // in the primary subscription + public static void MultipleAccounts( + [ServiceBusTrigger(FirstQueueName, Connection = SecondaryConnectionStringKey)] string input, + [ServiceBus(TopicName)] out string output) + { + output = input; + } + + [NoAutomaticTrigger] + public static async Task ServiceBusBinderTest( + string message, + int numMessages, + Binder binder) + { + var attribute = new ServiceBusAttribute(BinderQueueName) + { + EntityType = EntityType.Queue + }; + + var collector = await binder.BindAsync>(attribute); + + for (int i = 0; i < numMessages; i++) + { + await collector.AddAsync(message + i); + } + + await collector.FlushAsync(); + } + } + + public class ServiceBusMultipleTestJobsBase + { + protected static bool firstReceived = false; + protected static bool secondReceived = false; + + public static void ProcessMessages(string[] messages) + { + if (messages.Contains("{'Name': 'Test1', 'Value': 'Value'}")) + { + firstReceived = true; + } + if (messages.Contains("{'Name': 'Test2', 'Value': 'Value'}")) + { + secondReceived = true; + } + + if (firstReceived && secondReceived) + { + _topicSubscriptionCalled1.Set(); + } + } + } + + public class ServiceBusMultipleMessagesTestJob_BindToStringArray + { + public static async Task SBQueue2SBQueue( + [ServiceBusTrigger(FirstQueueName)] string[] messages, + MessageReceiver messageReceiver, CancellationToken cancellationToken) + { + try + { + Assert.Equal(FirstQueueName, messageReceiver.Path); + ServiceBusMultipleTestJobsBase.ProcessMessages(messages); + await Task.Delay(0, cancellationToken); + } + catch (OperationCanceledException) + { + } + } + } + + public class ServiceBusMultipleMessagesTestJob_BindToMessageArray + { + public static void SBQueue2SBQueue( + [ServiceBusTrigger(FirstQueueName)] Message[] array, + MessageReceiver messageReceiver) + { + Assert.Equal(FirstQueueName, messageReceiver.Path); + string[] messages = array.Select(x => + { + using (Stream stream = new MemoryStream(x.Body)) + using (TextReader reader = new StreamReader(stream)) + { + return reader.ReadToEnd(); + } + }).ToArray(); + ServiceBusMultipleTestJobsBase.ProcessMessages(messages); + } + } + + public class ServiceBusMultipleMessagesTestJob_BindToPocoArray + { + public static void SBQueue2SBQueue( + [ServiceBusTrigger(FirstQueueName)] TestPoco[] array, + MessageReceiver messageReceiver) + { + Assert.Equal(FirstQueueName, messageReceiver.Path); + string[] messages = array.Select(x => "{'Name': '" + x.Name + "', 'Value': 'Value'}").ToArray(); + ServiceBusMultipleTestJobsBase.ProcessMessages(messages); + } + } + + public class ServiceBusArgumentBindingJob + { + public static void BindToPoco( + [ServiceBusTrigger(FirstQueueName)] TestPoco input, + string name, string value, string messageId, + ILogger logger) + { + Assert.Equal(input.Name, name); + Assert.Equal(input.Value, value); + logger.LogInformation($"PocoValues({name},{value})"); + _eventWait.Set(); + } + + [NoAutomaticTrigger] + public static void BindToString( + [ServiceBusTrigger(FirstQueueName)] string input, + string messageId, + ILogger logger) + { + logger.LogInformation($"Input({input})"); + _eventWait.Set(); + } + } + + public class DrainModeTestJobQueue + { + public static async Task QueueNoSessions( + [ServiceBusTrigger(FirstQueueName)] Message msg, + MessageReceiver messageReceiver, + CancellationToken cancellationToken, + ILogger logger) + { + logger.LogInformation($"DrainModeValidationFunctions.QueueNoSessions: message data {msg.Body}"); + _drainValidationPreDelay.Set(); + await DrainModeHelper.WaitForCancellation(cancellationToken); + Assert.True(cancellationToken.IsCancellationRequested); + await messageReceiver.CompleteAsync(msg.SystemProperties.LockToken); + _drainValidationPostDelay.Set(); + } + } + + public class DrainModeTestJobTopic + { + public static async Task TopicNoSessions( + [ServiceBusTrigger(TopicName, TopicSubscriptionName1)] Message msg, + MessageReceiver messageReceiver, + CancellationToken cancellationToken, + ILogger logger) + { + logger.LogInformation($"DrainModeValidationFunctions.NoSessions: message data {msg.Body}"); + _drainValidationPreDelay.Set(); + await DrainModeHelper.WaitForCancellation(cancellationToken); + Assert.True(cancellationToken.IsCancellationRequested); + await messageReceiver.CompleteAsync(msg.SystemProperties.LockToken); + _drainValidationPostDelay.Set(); + } + } + + public class DrainModeTestJobQueueBatch + { + public static async Task QueueNoSessionsBatch( + [ServiceBusTrigger(FirstQueueName)] Message[] array, + MessageReceiver messageReceiver, + CancellationToken cancellationToken, + ILogger logger) + { + Assert.True(array.Length > 0); + logger.LogInformation($"DrainModeTestJobBatch.QueueNoSessionsBatch: received {array.Length} messages"); + _drainValidationPreDelay.Set(); + await DrainModeHelper.WaitForCancellation(cancellationToken); + Assert.True(cancellationToken.IsCancellationRequested); + foreach (Message msg in array) + { + await messageReceiver.CompleteAsync(msg.SystemProperties.LockToken); + } + _drainValidationPostDelay.Set(); + } + } + + public class DrainModeTestJobTopicBatch + { + public static async Task TopicNoSessionsBatch( + [ServiceBusTrigger(TopicName, TopicSubscriptionName1)] Message[] array, + MessageReceiver messageReceiver, + CancellationToken cancellationToken, + ILogger logger) + { + Assert.True(array.Length > 0); + logger.LogInformation($"DrainModeTestJobBatch.TopicNoSessionsBatch: received {array.Length} messages"); + _drainValidationPreDelay.Set(); + await DrainModeHelper.WaitForCancellation(cancellationToken); + Assert.True(cancellationToken.IsCancellationRequested); + foreach (Message msg in array) + { + await messageReceiver.CompleteAsync(msg.SystemProperties.LockToken); + } + _drainValidationPostDelay.Set(); + } + } + + public class DrainModeHelper + { + public static async Task WaitForCancellation(CancellationToken cancellationToken) + { + // Wait until the drain operation begins, signalled by the cancellation token + int elapsedTimeMills = 0; + while (elapsedTimeMills < DrainWaitTimeoutMills && !cancellationToken.IsCancellationRequested) + { + await Task.Delay(elapsedTimeMills += 500); + } + // Allow some time for the Service Bus SDK to start draining before returning + await Task.Delay(DrainSleepMills); + } + } + + private class CustomMessagingProvider : MessagingProvider + { + public const string CustomMessagingCategory = "CustomMessagingProvider"; + private readonly ILogger _logger; + private readonly ServiceBusOptions _options; + + public CustomMessagingProvider(IOptions serviceBusOptions, ILoggerFactory loggerFactory) + : base(serviceBusOptions) + { + _options = serviceBusOptions.Value; + _logger = loggerFactory?.CreateLogger(CustomMessagingCategory); + } + + public override MessageProcessor CreateMessageProcessor(string entityPath, string connectionName = null) + { + var options = new MessageHandlerOptions(ExceptionReceivedHandler) + { + MaxConcurrentCalls = 3, + MaxAutoRenewDuration = TimeSpan.FromMinutes(MaxAutoRenewDurationMin) + }; + + var messageReceiver = new MessageReceiver(_options.ConnectionString, entityPath); + + return new CustomMessageProcessor(messageReceiver, options, _logger); + } + + private class CustomMessageProcessor : MessageProcessor + { + private readonly ILogger _logger; + + public CustomMessageProcessor(MessageReceiver messageReceiver, MessageHandlerOptions messageOptions, ILogger logger) + : base(messageReceiver, messageOptions) + { + _logger = logger; + } + + public override async Task BeginProcessingMessageAsync(Message message, CancellationToken cancellationToken) + { + _logger?.LogInformation("Custom processor Begin called!"); + return await base.BeginProcessingMessageAsync(message, cancellationToken); + } + + public override async Task CompleteProcessingMessageAsync(Message message, Executors.FunctionResult result, CancellationToken cancellationToken) + { + _logger?.LogInformation("Custom processor End called!"); + await base.CompleteProcessingMessageAsync(message, result, cancellationToken); + } + } + + private Task ExceptionReceivedHandler(ExceptionReceivedEventArgs eventArgs) + { + return Task.CompletedTask; + } + } + + private IHost BuildTestHost() + { + IHost host = new HostBuilder() + .ConfigureDefaultTestHost(b => + { + b.AddServiceBus(); + }, nameResolver: _nameResolver) + .ConfigureServices(s => + { + s.Configure(opts => opts.ShutdownTimeout = HostShutdownTimeout); + }) + .Build(); + + return host; + } + + private IHost BuildTestHostMessageDraining() + { + IHost host = new HostBuilder() + .ConfigureDefaultTestHost(b => + { + b.AddServiceBus(sbOptions => + { + // We want to ensure messages can be completed in the function code before signaling success to the test + sbOptions.MessageHandlerOptions.AutoComplete = false; + sbOptions.BatchOptions.AutoComplete = false; + sbOptions.MessageHandlerOptions.MaxAutoRenewDuration = TimeSpan.FromMinutes(MaxAutoRenewDurationMin); + sbOptions.MessageHandlerOptions.MaxConcurrentCalls = 1; + }); + }, nameResolver: _nameResolver) + .ConfigureServices(s => + { + s.Configure(opts => opts.ShutdownTimeout = HostShutdownTimeout); + }) + .Build(); + + return host; + } + + public void Dispose() + { + Cleanup().GetAwaiter().GetResult(); + } + } + +#pragma warning disable SA1402 // File may only contain a single type + public class TestPoco +#pragma warning restore SA1402 // File may only contain a single type + { + public string Name { get; set; } + public string Value { get; set; } + } +} \ No newline at end of file diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/ServiceBusSessionsEndToEndTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/ServiceBusSessionsEndToEndTests.cs new file mode 100644 index 000000000000..0a2131d270d4 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/ServiceBusSessionsEndToEndTests.cs @@ -0,0 +1,923 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.ServiceBus.Core; +using Microsoft.Azure.WebJobs.Host.TestCommon; +using Microsoft.Azure.WebJobs.ServiceBus; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.WebJobs.Host.EndToEndTests +{ + public class ServiceBusSessionsEndToEndTests : IDisposable + { + private const string Prefix = "core-test-"; + private const string _queueName = Prefix + "queue1-sessions"; + private const string _topicName = Prefix + "topic1-sessions"; + private const string _subscriptionName = "sub1-sessions"; + private const string _drainModeSessionId = "drain-session"; + private const string DrainingQueueMessageBody = "queue-message-draining-with-sessions-1"; + private const string DrainingTopicMessageBody = "topic-message-draining-with-sessions-1"; + private static EventWaitHandle _waitHandle1; + private static EventWaitHandle _waitHandle2; + private static EventWaitHandle _drainValidationPreDelay; + private static EventWaitHandle _drainValidationPostDelay; + private readonly RandomNameResolver _nameResolver; + private const int SBTimeoutMills = 120 * 1000; + private const int DrainWaitTimeoutMills = 120 * 1000; + private const int DrainSleepMills = 5 * 1000; + public const int MaxAutoRenewDurationMin = 5; + internal static TimeSpan HostShutdownTimeout = TimeSpan.FromSeconds(120); + private readonly string _connectionString; + + private readonly ITestOutputHelper outputLogger; + + public ServiceBusSessionsEndToEndTests(ITestOutputHelper output) + { + outputLogger = output; + + var config = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddTestSettings() + .Build(); + + // Add all test configuration to the environment as WebJobs requires a few of them to be in the environment + foreach (var kv in config.AsEnumerable()) + { + Environment.SetEnvironmentVariable(kv.Key, kv.Value); + } + _connectionString = config.GetConnectionStringOrSetting(ServiceBus.Constants.DefaultConnectionStringName); + + _nameResolver = new RandomNameResolver(); + + Cleanup().GetAwaiter().GetResult(); + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task ServiceBusSessionQueue_OrderGuaranteed() + { + using (var host = ServiceBusSessionsTestHelper.CreateHost(_nameResolver)) + { + await host.StartAsync(); + + _waitHandle1 = new ManualResetEvent(initialState: false); + + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message1", "test-session1"); + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message2", "test-session1"); + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message3", "test-session1"); + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message4", "test-session1"); + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message5", "test-session1"); + + Assert.True(_waitHandle1.WaitOne(SBTimeoutMills)); + + IEnumerable logMessages = host.GetTestLoggerProvider().GetAllLogMessages(); + + // filter out anything from the custom processor for easier validation. + List consoleOutput = logMessages.Where(m => m.Category == "Function.SBQueue1Trigger.User").ToList(); + + Assert.True(consoleOutput.Count() == 5, ServiceBusSessionsTestHelper.GetLogsAsString(consoleOutput)); + + int i = 1; + foreach (LogMessage logMessage in consoleOutput) + { + Assert.StartsWith("message" + i++, logMessage.FormattedMessage); + } + + await host.StopAsync(); + } + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task ServiceBusSessionTopicSubscription_OrderGuaranteed() + { + using (var host = ServiceBusSessionsTestHelper.CreateHost(_nameResolver)) + { + await host.StartAsync(); + + _waitHandle1 = new ManualResetEvent(initialState: false); + + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message1", "test-session1"); + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message2", "test-session1"); + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message3", "test-session1"); + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message4", "test-session1"); + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message5", "test-session1"); + + Assert.True(_waitHandle1.WaitOne(SBTimeoutMills)); + + IEnumerable logMessages = host.GetTestLoggerProvider().GetAllLogMessages(); + + // filter out anything from the custom processor for easier validation. + List consoleOutput = logMessages.Where(m => m.Category == "Function.SBSub1Trigger.User").ToList(); + + Assert.True(consoleOutput.Count() == 5, ServiceBusSessionsTestHelper.GetLogsAsString(consoleOutput)); + + int i = 1; + foreach (LogMessage logMessage in consoleOutput) + { + Assert.StartsWith("message" + i++, logMessage.FormattedMessage); + } + + await host.StopAsync(); + } + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task ServiceBusSessionQueue_DifferentHosts_DifferentSessions() + { + using (var host1 = ServiceBusSessionsTestHelper.CreateHost(_nameResolver, true)) + using (var host2 = ServiceBusSessionsTestHelper.CreateHost(_nameResolver, true)) + { + await host1.StartAsync(); + await host2.StartAsync(); + + _waitHandle1 = new ManualResetEvent(initialState: false); + _waitHandle2 = new ManualResetEvent(initialState: false); + + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message1", "test-session1"); + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message1", "test-session2"); + + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message2", "test-session1"); + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message2", "test-session2"); + + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message3", "test-session1"); + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message3", "test-session2"); + + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message4", "test-session1"); + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message4", "test-session2"); + + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message5", "test-session1"); + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message5", "test-session2"); + + Assert.True(_waitHandle1.WaitOne(SBTimeoutMills)); + Assert.True(_waitHandle2.WaitOne(SBTimeoutMills)); + + IEnumerable logMessages1 = host1.GetTestLoggerProvider().GetAllLogMessages(); + List consoleOutput1 = logMessages1.Where(m => m.Category == "Function.SBQueue1Trigger.User").ToList(); + Assert.NotEmpty(logMessages1.Where(m => m.Category == "CustomMessagingProvider" && m.FormattedMessage.StartsWith("Custom processor Begin called!"))); + Assert.NotEmpty(logMessages1.Where(m => m.Category == "CustomMessagingProvider" && m.FormattedMessage.StartsWith("Custom processor End called!"))); + IEnumerable logMessages2 = host2.GetTestLoggerProvider().GetAllLogMessages(); + List consoleOutput2 = logMessages2.Where(m => m.Category == "Function.SBQueue2Trigger.User").ToList(); + Assert.NotEmpty(logMessages2.Where(m => m.Category == "CustomMessagingProvider" && m.FormattedMessage.StartsWith("Custom processor Begin called!"))); + Assert.NotEmpty(logMessages2.Where(m => m.Category == "CustomMessagingProvider" && m.FormattedMessage.StartsWith("Custom processor End called!"))); + char sessionId1 = consoleOutput1[0].FormattedMessage[consoleOutput1[0].FormattedMessage.Length - 1]; + foreach (LogMessage m in consoleOutput1) + { + Assert.Equal(sessionId1, m.FormattedMessage[m.FormattedMessage.Length - 1]); + } + + char sessionId2 = consoleOutput2[0].FormattedMessage[consoleOutput1[0].FormattedMessage.Length - 1]; + foreach (LogMessage m in consoleOutput2) + { + Assert.Equal(sessionId2, m.FormattedMessage[m.FormattedMessage.Length - 1]); + } + + List tasks = new List + { + host1.StopAsync(), + host2.StopAsync() + }; + Task.WaitAll(tasks.ToArray()); + } + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task ServiceBusSessionSub_DifferentHosts_DifferentSessions() + { + using (var host1 = ServiceBusSessionsTestHelper.CreateHost(_nameResolver, true)) + using (var host2 = ServiceBusSessionsTestHelper.CreateHost(_nameResolver, true)) + { + await host1.StartAsync(); + await host2.StartAsync(); + + _waitHandle1 = new ManualResetEvent(initialState: false); + _waitHandle2 = new ManualResetEvent(initialState: false); + + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message1", "test-session1"); + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message1", "test-session2"); + + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message2", "test-session1"); + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message2", "test-session2"); + + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message3", "test-session1"); + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message3", "test-session2"); + + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message4", "test-session1"); + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message4", "test-session2"); + + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message5", "test-session1"); + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message5", "test-session2"); + + Assert.True(_waitHandle1.WaitOne(SBTimeoutMills)); + Assert.True(_waitHandle2.WaitOne(SBTimeoutMills)); + + IEnumerable logMessages1 = host1.GetTestLoggerProvider().GetAllLogMessages(); + List consoleOutput1 = logMessages1.Where(m => m.Category == "Function.SBSub1Trigger.User").ToList(); + Assert.NotEmpty(logMessages1.Where(m => m.Category == "CustomMessagingProvider" && m.FormattedMessage.StartsWith("Custom processor Begin called!"))); + Assert.NotEmpty(logMessages1.Where(m => m.Category == "CustomMessagingProvider" && m.FormattedMessage.StartsWith("Custom processor End called!"))); + IEnumerable logMessages2 = host2.GetTestLoggerProvider().GetAllLogMessages(); + List consoleOutput2 = logMessages2.Where(m => m.Category == "Function.SBSub2Trigger.User").ToList(); + Assert.NotEmpty(logMessages2.Where(m => m.Category == "CustomMessagingProvider" && m.FormattedMessage.StartsWith("Custom processor Begin called!"))); + Assert.NotEmpty(logMessages2.Where(m => m.Category == "CustomMessagingProvider" && m.FormattedMessage.StartsWith("Custom processor End called!"))); + + char sessionId1 = consoleOutput1[0].FormattedMessage[consoleOutput1[0].FormattedMessage.Length - 1]; + foreach (LogMessage m in consoleOutput1) + { + Assert.Equal(sessionId1, m.FormattedMessage[m.FormattedMessage.Length - 1]); + } + + char sessionId2 = consoleOutput2[0].FormattedMessage[consoleOutput1[0].FormattedMessage.Length - 1]; + foreach (LogMessage m in consoleOutput2) + { + Assert.Equal(sessionId2, m.FormattedMessage[m.FormattedMessage.Length - 1]); + } + + List tasks = new List + { + host1.StopAsync(), + host2.StopAsync() + }; + Task.WaitAll(tasks.ToArray()); + } + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task ServiceBusSessionQueue_SessionLocks() + { + using (var host = ServiceBusSessionsTestHelper.CreateHost(_nameResolver, true)) + { + await host.StartAsync(); + + _waitHandle1 = new ManualResetEvent(initialState: false); + _waitHandle2 = new ManualResetEvent(initialState: false); + + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message1", "test-session1"); + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message1", "test-session2"); + + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message2", "test-session1"); + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message2", "test-session2"); + + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message3", "test-session1"); + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message3", "test-session2"); + + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message4", "test-session1"); + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message4", "test-session2"); + + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message5", "test-session1"); + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "message5", "test-session2"); + + Assert.True(_waitHandle1.WaitOne(SBTimeoutMills)); + Assert.True(_waitHandle2.WaitOne(SBTimeoutMills)); + + IEnumerable logMessages1 = host.GetTestLoggerProvider().GetAllLogMessages(); + + // filter out anything from the custom processor for easier validation. + List consoleOutput1 = logMessages1.Where(m => m.Category == "Function.SBQueue1Trigger.User").ToList(); + Assert.True(consoleOutput1.Count() == 10, ServiceBusSessionsTestHelper.GetLogsAsString(consoleOutput1)); + double seconsds = (consoleOutput1[5].Timestamp - consoleOutput1[4].Timestamp).TotalSeconds; + Assert.True(seconsds > 90 && seconsds < 110, seconsds.ToString()); + for (int i = 0; i < consoleOutput1.Count(); i++) + { + if (i < 5) + { + Assert.Equal(consoleOutput1[i].FormattedMessage[consoleOutput1[0].FormattedMessage.Length - 1], + consoleOutput1[0].FormattedMessage[consoleOutput1[0].FormattedMessage.Length - 1]); + } + else + { + Assert.Equal(consoleOutput1[i].FormattedMessage[consoleOutput1[0].FormattedMessage.Length - 1], + consoleOutput1[5].FormattedMessage[consoleOutput1[0].FormattedMessage.Length - 1]); + } + } + + await host.StopAsync(); + } + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task ServiceBusSessionSub_SessionLocks() + { + using (var host = ServiceBusSessionsTestHelper.CreateHost(_nameResolver, true)) + { + await host.StartAsync(); + + _waitHandle1 = new ManualResetEvent(initialState: false); + _waitHandle2 = new ManualResetEvent(initialState: false); + + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message1", "test-session1"); + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message1", "test-session2"); + + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message2", "test-session1"); + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message2", "test-session2"); + + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message3", "test-session1"); + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message3", "test-session2"); + + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message4", "test-session1"); + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message4", "test-session2"); + + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message5", "test-session1"); + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, "message5", "test-session2"); + + Assert.True(_waitHandle1.WaitOne(SBTimeoutMills)); + Assert.True(_waitHandle2.WaitOne(SBTimeoutMills)); + + IEnumerable logMessages1 = host.GetTestLoggerProvider().GetAllLogMessages(); + + // filter out anything from the custom processor for easier validation. + List consoleOutput1 = logMessages1.Where(m => m.Category == "Function.SBSub1Trigger.User").ToList(); + Assert.True(consoleOutput1.Count() == 10, ServiceBusSessionsTestHelper.GetLogsAsString(consoleOutput1)); + double seconsds = (consoleOutput1[5].Timestamp - consoleOutput1[4].Timestamp).TotalSeconds; + Assert.True(seconsds > 90 && seconsds < 110, seconsds.ToString()); + for (int i = 0; i < consoleOutput1.Count(); i++) + { + if (i < 5) + { + Assert.Equal(consoleOutput1[i].FormattedMessage[consoleOutput1[0].FormattedMessage.Length - 1], + consoleOutput1[0].FormattedMessage[consoleOutput1[0].FormattedMessage.Length - 1]); + } + else + { + Assert.Equal(consoleOutput1[i].FormattedMessage[consoleOutput1[0].FormattedMessage.Length - 1], + consoleOutput1[5].FormattedMessage[consoleOutput1[0].FormattedMessage.Length - 1]); + } + } + + await host.StopAsync(); + } + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task TestBatch_String() + { + await TestMultiple(); + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task TestBatch_Messages() + { + await TestMultiple(); + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task TestBatch_JsonPoco() + { + await TestMultiple(); + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task TestBatch_DataContractPoco() + { + await TestMultiple(true); + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task MessageDraining_QueueWithSessions() + { + await TestSingleDrainMode(true); + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task MessageDraining_TopicWithSessions() + { + await TestSingleDrainMode(false); + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task MessageDraining_QueueWithSessions_Batch() + { + await TestMultipleDrainMode(true); + } + + [Fact(Skip = "Will enable after migrating to NUnit and integrating with TestEnvironment")] + public async Task MessageDraining_TopicWithSessions_Batch() + { + await TestMultipleDrainMode(false); + } + + /* + * Helper functions + */ + + private async Task TestSingleDrainMode(bool sendToQueue) + { + _drainValidationPreDelay = new ManualResetEvent(initialState: false); + _drainValidationPostDelay = new ManualResetEvent(initialState: false); + + if (sendToQueue) + { + await ServiceBusEndToEndTests.WriteQueueMessage( + _connectionString, _queueName, DrainingQueueMessageBody, _drainModeSessionId); + } + else + { + await ServiceBusEndToEndTests.WriteTopicMessage( + _connectionString, _topicName, DrainingTopicMessageBody, _drainModeSessionId); + } + + using (IHost host = ServiceBusSessionsTestHelper.CreateHost(_nameResolver, false, false)) + { + await host.StartAsync(); + + // Wait to ensure function invocatoin has started before draining messages + Assert.True(_drainValidationPreDelay.WaitOne(SBTimeoutMills)); + + // Start draining in-flight messages + var drainModeManager = host.Services.GetService(); + await drainModeManager.EnableDrainModeAsync(CancellationToken.None); + + // Validate that function execution was allowed to complete + Assert.True(_drainValidationPostDelay.WaitOne(DrainWaitTimeoutMills + SBTimeoutMills)); + + await host.StopAsync(); + } + } + + private async Task TestMultiple(bool isXml = false) + { + _waitHandle1 = new ManualResetEvent(initialState: false); + + if (isXml) + { + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, new TestPoco() { Name = "Test1" }, "sessionId"); + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, new TestPoco() { Name = "Test2" }, "sessionId"); + } + else + { + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "{'Name': 'Test1', 'Value': 'Value'}", "sessionId"); + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, "{'Name': 'Test2', 'Value': 'Value'}", "sessionId"); + } + + using (IHost host = ServiceBusSessionsTestHelper.CreateHost(_nameResolver)) + { + await host.StartAsync(); + + bool result = _waitHandle1.WaitOne(SBTimeoutMills); + Assert.True(result); + + // ensure message are completed + await Task.Delay(2000); + + // Wait for the host to terminate + await host.StopAsync(); + } + } + + private async Task TestMultipleDrainMode(bool sendToQueue) + { + _drainValidationPreDelay = new ManualResetEvent(initialState: false); + _drainValidationPostDelay = new ManualResetEvent(initialState: false); + + if (sendToQueue) + { + await ServiceBusEndToEndTests.WriteQueueMessage(_connectionString, _queueName, DrainingQueueMessageBody, _drainModeSessionId); + } + else + { + await ServiceBusEndToEndTests.WriteTopicMessage(_connectionString, _topicName, DrainingTopicMessageBody, _drainModeSessionId); + } + + using (IHost host = ServiceBusSessionsTestHelper.CreateHost(_nameResolver, false, false)) + { + await host.StartAsync(); + + // Wait to ensure function invocatoin has started before draining messages + Assert.True(_drainValidationPreDelay.WaitOne(SBTimeoutMills)); + + // Start draining in-flight messages + var drainModeManager = host.Services.GetService(); + await drainModeManager.EnableDrainModeAsync(CancellationToken.None); + + // Validate that function execution was allowed to complete + Assert.True(_drainValidationPostDelay.WaitOne(DrainWaitTimeoutMills + SBTimeoutMills)); + + // Wait for the host to terminate + await host.StopAsync(); + } + } + + private async Task Cleanup() + { + var tasks = new List() + { + ServiceBusSessionsTestHelper.CleanUpQueue(_connectionString, _queueName), + ServiceBusSessionsTestHelper.CleanUpSubscription(_connectionString, _topicName, _subscriptionName) + }; + + await Task.WhenAll(tasks); + } + + public class ServiceBusSessionsTestJobs1 + { + public static void SBQueue1Trigger( + [ServiceBusTrigger(_queueName, IsSessionsEnabled = true)] Message message, int deliveryCount, + IMessageSession messageSession, + ILogger log, + string lockToken) + { + Assert.Equal(_queueName, messageSession.Path); + Assert.Equal(1, deliveryCount); + + ServiceBusSessionsTestHelper.ProcessMessage(message, log, _waitHandle1, _waitHandle2); + } + + public static void SBSub1Trigger( + [ServiceBusTrigger(_topicName, _subscriptionName, IsSessionsEnabled = true)] Message message, int deliveryCount, + IMessageSession messageSession, + ILogger log, + string lockToken) + { + Assert.Equal(EntityNameHelper.FormatSubscriptionPath(_topicName, _subscriptionName), messageSession.Path); + Assert.Equal(1, deliveryCount); + + ServiceBusSessionsTestHelper.ProcessMessage(message, log, _waitHandle1, _waitHandle2); + } + } + + public class ServiceBusSessionsTestJobs2 + { + public static void SBQueue2Trigger( + [ServiceBusTrigger(_queueName, IsSessionsEnabled = true)] Message message, + ILogger log) + { + ServiceBusSessionsTestHelper.ProcessMessage(message, log, _waitHandle1, _waitHandle2); + } + + public static void SBSub2Trigger( + [ServiceBusTrigger(_topicName, _subscriptionName, IsSessionsEnabled = true)] Message message, + ILogger log) + { + ServiceBusSessionsTestHelper.ProcessMessage(message, log, _waitHandle1, _waitHandle2); + } + } + + public class DrainModeTestJobQueue + { + public static async Task QueueWithSessions( + [ServiceBusTrigger(_queueName, IsSessionsEnabled = true)] Message msg, + IMessageSession messageSession, + CancellationToken cancellationToken, + ILogger logger) + { + logger.LogInformation($"DrainModeValidationFunctions.QueueWithSessions: message data {msg.Body} with session id {msg.SessionId}"); + Assert.Equal(_drainModeSessionId, msg.SessionId); + _drainValidationPreDelay.Set(); + await DrainModeHelper.WaitForCancellation(cancellationToken); + Assert.True(cancellationToken.IsCancellationRequested); + await messageSession.CompleteAsync(msg.SystemProperties.LockToken); + _drainValidationPostDelay.Set(); + } + } + + public class DrainModeTestJobTopic + { + public static async Task TopicWithSessions( + [ServiceBusTrigger(_topicName, _subscriptionName, IsSessionsEnabled = true)] Message msg, + IMessageSession messageSession, + CancellationToken cancellationToken, + ILogger logger) + { + logger.LogInformation($"DrainModeValidationFunctions.TopicWithSessions: message data {msg.Body} with session id {msg.SessionId}"); + Assert.Equal(_drainModeSessionId, msg.SessionId); + _drainValidationPreDelay.Set(); + await DrainModeHelper.WaitForCancellation(cancellationToken); + Assert.True(cancellationToken.IsCancellationRequested); + await messageSession.CompleteAsync(msg.SystemProperties.LockToken); + _drainValidationPostDelay.Set(); + } + } + + public class DrainModeTestJobQueueBatch + { + public static async Task QueueWithSessionsBatch( + [ServiceBusTrigger(_queueName, IsSessionsEnabled = true)] Message[] array, + IMessageSession messageSession, + CancellationToken cancellationToken, + ILogger logger) + { + Assert.True(array.Length > 0); + logger.LogInformation($"DrainModeTestJobBatch.QueueWithSessionsBatch: received {array.Length} messages with session id {array[0].SessionId}"); + Assert.Equal(_drainModeSessionId, array[0].SessionId); + _drainValidationPreDelay.Set(); + await DrainModeHelper.WaitForCancellation(cancellationToken); + Assert.True(cancellationToken.IsCancellationRequested); + foreach (Message msg in array) + { + await messageSession.CompleteAsync(msg.SystemProperties.LockToken); + } + _drainValidationPostDelay.Set(); + } + } + + public class DrainModeTestJobTopicBatch + { + public static async Task TopicWithSessionsBatch( + [ServiceBusTrigger(_topicName, _subscriptionName, IsSessionsEnabled = true)] Message[] array, + MessageReceiver messageReceiver, + CancellationToken cancellationToken, + ILogger logger) + { + Assert.True(array.Length > 0); + logger.LogInformation($"DrainModeTestJobBatch.TopicWithSessionsBatch: received {array.Length} messages with session id {array[0].SessionId}"); + Assert.Equal(_drainModeSessionId, array[0].SessionId); + _drainValidationPreDelay.Set(); + await DrainModeHelper.WaitForCancellation(cancellationToken); + Assert.True(cancellationToken.IsCancellationRequested); + foreach (Message msg in array) + { + await messageReceiver.CompleteAsync(msg.SystemProperties.LockToken); + } + _drainValidationPostDelay.Set(); + } + } + + public class DrainModeHelper + { + public static async Task WaitForCancellation(CancellationToken cancellationToken) + { + // Wait until the drain operation begins, signalled by the cancellation token + int elapsedTimeMills = 0; + while (elapsedTimeMills < DrainWaitTimeoutMills && !cancellationToken.IsCancellationRequested) + { + await Task.Delay(elapsedTimeMills += 500); + } + // Allow some time for the Service Bus SDK to start draining before returning + await Task.Delay(DrainSleepMills); + } + } + + public class ServiceBusMultipleTestJobsBase + { + protected static bool firstReceived = false; + protected static bool secondReceived = false; + + public static void ProcessMessages(string[] messages, EventWaitHandle waitHandle = null) + { + if (messages.Contains("{'Name': 'Test1', 'Value': 'Value'}")) + { + firstReceived = true; + } + if (messages.Contains("{'Name': 'Test2', 'Value': 'Value'}")) + { + secondReceived = true; + } + + if (firstReceived && secondReceived) + { + bool b = (waitHandle != null) ? waitHandle.Set() : _waitHandle1.Set(); + } + } + } + + public class ServiceBusMultipleMessagesTestJob_BindToStringArray + { + public static async Task SBQueue2SBQueue( + [ServiceBusTrigger(_queueName, IsSessionsEnabled = true)] string[] messages, + IMessageSession messageSession, CancellationToken cancellationToken) + { + try + { + Assert.Equal(_queueName, messageSession.Path); + ServiceBusMultipleTestJobsBase.ProcessMessages(messages); + await Task.Delay(0, cancellationToken); + } + catch (OperationCanceledException) + { + } + } + } + + public class ServiceBusMultipleMessagesTestJob_BindToMessageArray + { + public static void SBQueue2SBQueue( + [ServiceBusTrigger(_queueName, IsSessionsEnabled = true)] Message[] array, + IMessageSession messageSession) + { + Assert.Equal(_queueName, messageSession.Path); + string[] messages = array.Select(x => + { + using (Stream stream = new MemoryStream(x.Body)) + using (TextReader reader = new StreamReader(stream)) + { + return reader.ReadToEnd(); + } + }).ToArray(); + ServiceBusMultipleTestJobsBase.ProcessMessages(messages); + } + } + + public class ServiceBusMultipleMessagesTestJob_BindToPocoArray + { + public static void SBQueue2SBQueue( + [ServiceBusTrigger(_queueName, IsSessionsEnabled = true)] TestPoco[] array, + IMessageSession messageSession) + { + Assert.Equal(_queueName, messageSession.Path); + string[] messages = array.Select(x => "{'Name': '" + x.Name + "', 'Value': 'Value'}").ToArray(); + ServiceBusMultipleTestJobsBase.ProcessMessages(messages); + } + } + + public class CustomMessagingProvider : MessagingProvider + { + public const string CustomMessagingCategory = "CustomMessagingProvider"; + private readonly ILogger _logger; + private readonly ServiceBusOptions _options; + + public CustomMessagingProvider(IOptions serviceBusOptions, ILoggerFactory loggerFactory) + : base(serviceBusOptions) + { + _options = serviceBusOptions.Value; + _options.SessionHandlerOptions.MessageWaitTimeout = TimeSpan.FromSeconds(90); + _options.SessionHandlerOptions.MaxConcurrentSessions = 1; + _logger = loggerFactory?.CreateLogger(CustomMessagingCategory); + } + + public override SessionMessageProcessor CreateSessionMessageProcessor(string entityPath, string connectionString) + { + if (entityPath == _queueName) + { + return new CustomSessionMessageProcessor(new QueueClient(connectionString, entityPath), _options.SessionHandlerOptions, _logger); + } + else + { + string[] arr = entityPath.Split('/'); + return new CustomSessionMessageProcessor(new SubscriptionClient(connectionString, arr[0], arr[2]), _options.SessionHandlerOptions, _logger); + } + } + + private class CustomSessionMessageProcessor : SessionMessageProcessor + { + private readonly ILogger _logger; + + public CustomSessionMessageProcessor(ClientEntity clientEntity, SessionHandlerOptions messageOptions, ILogger logger) + : base(clientEntity, messageOptions) + { + _logger = logger; + } + + public override async Task BeginProcessingMessageAsync(IMessageSession session, Message message, CancellationToken cancellationToken) + { + _logger?.LogInformation("Custom processor Begin called!" + ServiceBusSessionsTestHelper.GetStringMessage(message)); + return await base.BeginProcessingMessageAsync(session, message, cancellationToken); + } + + public override async Task CompleteProcessingMessageAsync(IMessageSession session, Message message, Executors.FunctionResult result, CancellationToken cancellationToken) + { + _logger?.LogInformation("Custom processor End called!" + ServiceBusSessionsTestHelper.GetStringMessage(message)); + await base.CompleteProcessingMessageAsync(session, message, result, cancellationToken); + } + } + + private Task ExceptionReceivedHandler(ExceptionReceivedEventArgs eventArgs) + { + return Task.CompletedTask; + } + } + + public void Dispose() + { + if (_waitHandle1 != null) + { + _waitHandle1.Dispose(); + } + if (_waitHandle2 != null) + { + _waitHandle2.Dispose(); + } + Cleanup().GetAwaiter().GetResult(); + } + } + +#pragma warning disable SA1402 // File may only contain a single type + internal class ServiceBusSessionsTestHelper +#pragma warning restore SA1402 // File may only contain a single type + { + private static SessionHandlerOptions sessionHandlerOptions = new SessionHandlerOptions(ExceptionReceivedHandler); + public static async Task CleanUpQueue(string connectionString, string queueName) + { + await CleanUpEntity(connectionString, queueName); + } + + public static async Task CleanUpSubscription(string connectionString, string topicName, string subscriptionName) + { + await CleanUpEntity(connectionString, EntityNameHelper.FormatSubscriptionPath(topicName, subscriptionName)); + } + + private static async Task CleanUpEntity(string connectionString, string entityPAth) + { + var client = new SessionClient(connectionString, entityPAth, ReceiveMode.PeekLock); + client.OperationTimeout = TimeSpan.FromSeconds(5); + + IMessageSession session = null; + try + { + session = await client.AcceptMessageSessionAsync(); + var messages = await session.ReceiveAsync(1000, TimeSpan.FromSeconds(1)); + await session.CompleteAsync(messages.Select(m => m.SystemProperties.LockToken)); + } + catch (ServiceBusException) + { + } + finally + { + if (session != null) + { + await session.CloseAsync(); + } + } + } + + private static async Task ProcessMessagesInSessionAsync(IMessageSession messageSession, Message message, CancellationToken token) + { + await messageSession.CompleteAsync(message.SystemProperties.LockToken); + } + + public static string GetStringMessage(Message message) + { + using (Stream stream = new MemoryStream(message.Body)) + using (TextReader reader = new StreamReader(stream)) + { + return reader.ReadToEnd(); + } + } + + public static IHost CreateHost(INameResolver nameResolver, bool addCustomProvider = false, bool autoComplete = true) + { + return new HostBuilder() + .ConfigureDefaultTestHost(b => + { + b.AddServiceBus(sbOptions => + { + // Will be disabled for drain mode validation as messages are completed by functoin code to validate draining allows completion + sbOptions.SessionHandlerOptions.AutoComplete = autoComplete; + sbOptions.BatchOptions.AutoComplete = autoComplete; + sbOptions.SessionHandlerOptions.MaxAutoRenewDuration = TimeSpan.FromMinutes(ServiceBusSessionsEndToEndTests.MaxAutoRenewDurationMin); + sbOptions.SessionHandlerOptions.MaxConcurrentSessions = 1; + }); + }) + .ConfigureServices(services => + { + services.AddSingleton(nameResolver); + if (addCustomProvider) + { + services.AddSingleton(); + } + }) + .ConfigureServices(s => + { + s.Configure(opts => opts.ShutdownTimeout = ServiceBusSessionsEndToEndTests.HostShutdownTimeout); + }) + .Build(); + } + + public static void ProcessMessage(Message message, ILogger log, EventWaitHandle waitHandle1, EventWaitHandle waitHandle2) + { + string messageString = ServiceBusSessionsTestHelper.GetStringMessage(message); + log.LogInformation($"{messageString}-{message.SessionId}"); + + if (messageString == "message5" && message.SessionId == "test-session1") + { + waitHandle1.Set(); + } + + if (messageString == "message5" && message.SessionId == "test-session2") + { + waitHandle2.Set(); + } + } + + public static string GetLogsAsString(List messages) + { + if (messages.Count() != 5 && messages.Count() != 10) + { + } + + string reuslt = string.Empty; + foreach (LogMessage message in messages) + { + reuslt += message.FormattedMessage + System.Environment.NewLine; + } + return reuslt; + } + + private static Task ExceptionReceivedHandler(ExceptionReceivedEventArgs args) + { + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/ServiceBusTriggerAttributeTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/ServiceBusTriggerAttributeTests.cs new file mode 100644 index 000000000000..082180910017 --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/ServiceBusTriggerAttributeTests.cs @@ -0,0 +1,38 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.Azure.WebJobs.ServiceBus.UnitTests +{ + public class ServiceBusTriggerAttributeTests + { + [Fact] + public void Constructor_Queue_SetsExpectedValues() + { + ServiceBusTriggerAttribute attribute = new ServiceBusTriggerAttribute("testqueue"); + Assert.Equal("testqueue", attribute.QueueName); + Assert.Null(attribute.SubscriptionName); + Assert.Null(attribute.TopicName); + + attribute = new ServiceBusTriggerAttribute("testqueue"); + Assert.Equal("testqueue", attribute.QueueName); + Assert.Null(attribute.SubscriptionName); + Assert.Null(attribute.TopicName); + } + + [Fact] + public void Constructor_Topic_SetsExpectedValues() + { + ServiceBusTriggerAttribute attribute = new ServiceBusTriggerAttribute("testtopic", "testsubscription"); + Assert.Null(attribute.QueueName); + Assert.Equal("testtopic", attribute.TopicName); + Assert.Equal("testsubscription", attribute.SubscriptionName); + + attribute = new ServiceBusTriggerAttribute("testtopic", "testsubscription"); + Assert.Null(attribute.QueueName); + Assert.Equal("testtopic", attribute.TopicName); + Assert.Equal("testsubscription", attribute.SubscriptionName); + } + } +} diff --git a/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/ServiceBusTriggerStrategyTests.cs b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/ServiceBusTriggerStrategyTests.cs new file mode 100644 index 000000000000..26617d04b25a --- /dev/null +++ b/sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus/tests/ServiceBusTriggerStrategyTests.cs @@ -0,0 +1,186 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Azure.ServiceBus; +using Microsoft.Azure.ServiceBus.Core; +using Microsoft.Azure.WebJobs.Host.TestCommon; +using Xunit; +using static Microsoft.Azure.ServiceBus.Message; + +namespace Microsoft.Azure.WebJobs.ServiceBus.UnitTests +{ + public class ServiceBusTriggerStrategyTests + { + [Fact] + public void GetStaticBindingContract_ReturnsExpectedValue() + { + var strategy = new ServiceBusTriggerBindingStrategy(); + var bindingDataContract = strategy.GetBindingContract(); + + CheckBindingContract(bindingDataContract); + } + + [Fact] + public void GetBindingContract_SingleDispatch_ReturnsExpectedValue() + { + var strategy = new ServiceBusTriggerBindingStrategy(); + var bindingDataContract = strategy.GetBindingContract(true); + + CheckBindingContract(bindingDataContract); + } + + [Fact] + public void GetBindingContract_MultipleDispatch_ReturnsExpectedValue() + { + var strategy = new ServiceBusTriggerBindingStrategy(); + var bindingDataContract = strategy.GetBindingContract(false); + + Assert.Equal(15, bindingDataContract.Count); + Assert.Equal(typeof(int[]), bindingDataContract["DeliveryCountArray"]); + Assert.Equal(typeof(string[]), bindingDataContract["DeadLetterSourceArray"]); + Assert.Equal(typeof(string[]), bindingDataContract["LockTokenArray"]); + Assert.Equal(typeof(DateTime[]), bindingDataContract["ExpiresAtUtcArray"]); + Assert.Equal(typeof(DateTime[]), bindingDataContract["EnqueuedTimeUtcArray"]); + Assert.Equal(typeof(string[]), bindingDataContract["MessageIdArray"]); + Assert.Equal(typeof(string[]), bindingDataContract["ContentTypeArray"]); + Assert.Equal(typeof(string[]), bindingDataContract["ReplyToArray"]); + Assert.Equal(typeof(long[]), bindingDataContract["SequenceNumberArray"]); + Assert.Equal(typeof(string[]), bindingDataContract["ToArray"]); + Assert.Equal(typeof(string[]), bindingDataContract["LabelArray"]); + Assert.Equal(typeof(string[]), bindingDataContract["CorrelationIdArray"]); + Assert.Equal(typeof(IDictionary[]), bindingDataContract["UserPropertiesArray"]); + Assert.Equal(typeof(MessageReceiver), bindingDataContract["MessageReceiver"]); + Assert.Equal(typeof(IMessageSession), bindingDataContract["MessageSession"]); + } + + [Fact] + public void GetBindingData_SingleDispatch_ReturnsExpectedValue() + { + var message = new Message(new byte[] { }); + SystemPropertiesCollection sysProp = GetSystemProperties(); + TestHelpers.SetField(message, "SystemProperties", sysProp); + IDictionary userProps = new Dictionary(); + userProps.Add(new KeyValuePair("prop1", "value1")); + userProps.Add(new KeyValuePair("prop2", "value2")); + TestHelpers.SetField(message, "UserProperties", userProps); + + var input = ServiceBusTriggerInput.CreateSingle(message); + var strategy = new ServiceBusTriggerBindingStrategy(); + var bindingData = strategy.GetBindingData(input); + + Assert.Equal(15, bindingData.Count); // SystemPropertiesCollection is sealed + + Assert.Same(input.MessageReceiver as MessageReceiver, bindingData["MessageReceiver"]); + Assert.Same(input.MessageReceiver as IMessageSession, bindingData["MessageSession"]); + Assert.Equal(message.SystemProperties.LockToken, bindingData["LockToken"]); + Assert.Equal(message.SystemProperties.SequenceNumber, bindingData["SequenceNumber"]); + Assert.Equal(message.SystemProperties.DeliveryCount, bindingData["DeliveryCount"]); + Assert.Same(message.SystemProperties.DeadLetterSource, bindingData["DeadLetterSource"]); + Assert.Equal(message.ExpiresAtUtc, bindingData["ExpiresAtUtc"]); + Assert.Same(message.MessageId, bindingData["MessageId"]); + Assert.Same(message.ContentType, bindingData["ContentType"]); + Assert.Same(message.ReplyTo, bindingData["ReplyTo"]); + Assert.Same(message.To, bindingData["To"]); + Assert.Same(message.Label, bindingData["Label"]); + Assert.Same(message.CorrelationId, bindingData["CorrelationId"]); + + IDictionary bindingDataUserProps = bindingData["UserProperties"] as Dictionary; + Assert.NotNull(bindingDataUserProps); + Assert.Equal("value1", bindingDataUserProps["prop1"]); + Assert.Equal("value2", bindingDataUserProps["prop2"]); + } + + [Fact] + public void GetBindingData_MultipleDispatch_ReturnsExpectedValue() + { + var messages = new Message[3] + { + new Message(Encoding.UTF8.GetBytes("Event 1")), + new Message(Encoding.UTF8.GetBytes("Event 2")), + new Message(Encoding.UTF8.GetBytes("Event 3")), + }; + + foreach (var message in messages) + { + SystemPropertiesCollection sysProps = GetSystemProperties(); + TestHelpers.SetField(message, "SystemProperties", sysProps); + } + + var input = ServiceBusTriggerInput.CreateBatch(messages); + var strategy = new ServiceBusTriggerBindingStrategy(); + var bindingData = strategy.GetBindingData(input); + + Assert.Equal(15, bindingData.Count); + Assert.Same(input.MessageReceiver as MessageReceiver, bindingData["MessageReceiver"]); + Assert.Same(input.MessageReceiver as IMessageSession, bindingData["MessageSession"]); + + // verify an array was created for each binding data type + Assert.Equal(messages.Length, ((int[])bindingData["DeliveryCountArray"]).Length); + Assert.Equal(messages.Length, ((string[])bindingData["DeadLetterSourceArray"]).Length); + Assert.Equal(messages.Length, ((string[])bindingData["LockTokenArray"]).Length); + Assert.Equal(messages.Length, ((DateTime[])bindingData["ExpiresAtUtcArray"]).Length); + Assert.Equal(messages.Length, ((DateTime[])bindingData["EnqueuedTimeUtcArray"]).Length); + Assert.Equal(messages.Length, ((string[])bindingData["MessageIdArray"]).Length); + Assert.Equal(messages.Length, ((string[])bindingData["ContentTypeArray"]).Length); + Assert.Equal(messages.Length, ((string[])bindingData["ReplyToArray"]).Length); + Assert.Equal(messages.Length, ((long[])bindingData["SequenceNumberArray"]).Length); + Assert.Equal(messages.Length, ((string[])bindingData["ToArray"]).Length); + Assert.Equal(messages.Length, ((string[])bindingData["LabelArray"]).Length); + Assert.Equal(messages.Length, ((string[])bindingData["CorrelationIdArray"]).Length); + Assert.Equal(messages.Length, ((IDictionary[])bindingData["UserPropertiesArray"]).Length); + } + + [Fact] + public void BindSingle_Returns_Exptected_Message() + { + string data = "123"; + + var strategy = new ServiceBusTriggerBindingStrategy(); + ServiceBusTriggerInput triggerInput = strategy.ConvertFromString(data); + + var contract = strategy.GetBindingData(triggerInput); + + Message single = strategy.BindSingle(triggerInput, null); + string body = Encoding.UTF8.GetString(single.Body); + + Assert.Equal(data, body); + Assert.Null(contract["MessageReceiver"]); + Assert.Null(contract["MessageSession"]); + } + + private static void CheckBindingContract(Dictionary bindingDataContract) + { + Assert.Equal(15, bindingDataContract.Count); + Assert.Equal(typeof(int), bindingDataContract["DeliveryCount"]); + Assert.Equal(typeof(string), bindingDataContract["DeadLetterSource"]); + Assert.Equal(typeof(string), bindingDataContract["LockToken"]); + Assert.Equal(typeof(DateTime), bindingDataContract["ExpiresAtUtc"]); + Assert.Equal(typeof(DateTime), bindingDataContract["EnqueuedTimeUtc"]); + Assert.Equal(typeof(string), bindingDataContract["MessageId"]); + Assert.Equal(typeof(string), bindingDataContract["ContentType"]); + Assert.Equal(typeof(string), bindingDataContract["ReplyTo"]); + Assert.Equal(typeof(long), bindingDataContract["SequenceNumber"]); + Assert.Equal(typeof(string), bindingDataContract["To"]); + Assert.Equal(typeof(string), bindingDataContract["Label"]); + Assert.Equal(typeof(string), bindingDataContract["CorrelationId"]); + Assert.Equal(typeof(IDictionary), bindingDataContract["UserProperties"]); + Assert.Equal(typeof(MessageReceiver), bindingDataContract["MessageReceiver"]); + Assert.Equal(typeof(IMessageSession), bindingDataContract["MessageSession"]); + } + + private static SystemPropertiesCollection GetSystemProperties() + { + SystemPropertiesCollection sysProps = new SystemPropertiesCollection(); + TestHelpers.SetField(sysProps, "deliveryCount", 1); + TestHelpers.SetField(sysProps, "lockedUntilUtc", DateTime.MinValue); + TestHelpers.SetField(sysProps, "sequenceNumber", 1); + TestHelpers.SetField(sysProps, "enqueuedTimeUtc", DateTime.MinValue); + TestHelpers.SetField(sysProps, "lockTokenGuid", Guid.NewGuid()); + TestHelpers.SetField(sysProps, "deadLetterSource", "test"); + return sysProps; + } + } +} diff --git a/sdk/servicebus/ci.yml b/sdk/servicebus/ci.yml index 32cc5a5303cc..5b15cfc781bc 100644 --- a/sdk/servicebus/ci.yml +++ b/sdk/servicebus/ci.yml @@ -10,6 +10,7 @@ trigger: include: - sdk/servicebus/ci.yml - sdk/servicebus/Azure.Messaging.ServiceBus + - sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus pr: branches: @@ -22,6 +23,7 @@ pr: include: - sdk/servicebus/ci.yml - sdk/servicebus/Azure.Messaging.ServiceBus + - sdk/servicebus/Microsoft.Azure.WebJobs.Extensions.ServiceBus extends: template: ../../eng/pipelines/templates/stages/archetype-sdk-client.yml @@ -32,3 +34,5 @@ extends: Artifacts: - name: Azure.Messaging.ServiceBus safeName: AzureMessagingServiceBus + - name: Microsoft.Azure.WebJobs.Extensions.ServiceBus + safeName: MicrosoftAzureWebJobsExtensionsServiceBus